diff --git a/__tests__/components/WaveGroupRemoveButton.test.tsx b/__tests__/components/WaveGroupRemoveButton.test.tsx index 8e566b54c4..d31e55b77a 100644 --- a/__tests__/components/WaveGroupRemoveButton.test.tsx +++ b/__tests__/components/WaveGroupRemoveButton.test.tsx @@ -1,13 +1,13 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import WaveGroupRemoveButton from "@/components/waves/specs/groups/group/edit/WaveGroupRemoveButton"; -import { WaveGroupType } from "@/components/waves/specs/groups/group/WaveGroup"; +import { WaveGroupType } from "@/components/waves/specs/groups/group/WaveGroup.types"; import React from "react"; jest.mock("@/components/waves/specs/groups/group/edit/WaveGroupRemove", () => function MockRemove(props: any) { return ( -
props.onEdit({})}> +
props.onWaveUpdate({})}> remove
); @@ -17,13 +17,13 @@ jest.mock("@/components/waves/specs/groups/group/edit/WaveGroupRemove", () => describe("WaveGroupRemoveButton", () => { it("opens modal and triggers edit", async () => { const user = userEvent.setup(); - const onEdit = jest.fn().mockResolvedValue(undefined); + const onWaveUpdate = jest.fn().mockResolvedValue(undefined); render( - + ); await user.click(screen.getByTitle("Remove")); expect(screen.getByTestId("modal")).toBeInTheDocument(); await user.click(screen.getByTestId("modal")); - expect(onEdit).toHaveBeenCalled(); + expect(onWaveUpdate).toHaveBeenCalled(); }); }); diff --git a/__tests__/components/common/TabToggleWithOverflow.test.tsx b/__tests__/components/common/TabToggleWithOverflow.test.tsx index f7e85552f4..a7eef2ee70 100644 --- a/__tests__/components/common/TabToggleWithOverflow.test.tsx +++ b/__tests__/components/common/TabToggleWithOverflow.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { TabToggleWithOverflow } from '@/components/common/TabToggleWithOverflow'; @@ -28,13 +28,15 @@ describe('TabToggleWithOverflow', () => { expect(screen.getByText('B')).toBeInTheDocument(); // open overflow dropdown - await user.click(screen.getByRole('button', { name: 'More' })); - const optionC = screen.getByRole('tab', { name: 'C' }); + await user.click(screen.getByRole('button', { name: 'More tabs' })); + const optionC = screen.getByRole('menuitem', { name: 'C' }); expect(optionC).toBeInTheDocument(); await user.click(optionC); expect(onSelect).toHaveBeenCalledWith('c'); - expect(screen.queryByRole('tab', { name: 'C' })).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument() + ); }); it('shows active label when active tab is in overflow', () => { @@ -83,26 +85,28 @@ describe('TabToggleWithOverflow', () => { /> ); - const moreButton = screen.getByRole('button', { name: 'More' }); + const moreButton = screen.getByRole('button', { name: 'More tabs' }); expect(moreButton).toHaveAttribute('aria-expanded', 'false'); - moreButton.focus(); + await user.tab(); // focus first tab + await user.tab(); // focus second tab + await user.tab(); // focus overflow trigger await user.keyboard('{Enter}'); expect(moreButton).toHaveAttribute('aria-expanded', 'true'); - const optionC = screen.getByRole('tab', { name: 'C' }); + const optionC = screen.getByRole('menuitem', { name: 'C' }); expect(optionC).toBeInTheDocument(); - optionC.focus(); await user.keyboard('{Escape}'); expect(moreButton).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByRole('tab', { name: 'C' })).not.toBeInTheDocument(); + await waitFor(() => + expect(screen.queryByRole('menuitem', { name: 'C' })).not.toBeInTheDocument() + ); - moreButton.focus(); - await user.keyboard('{Space}'); + await user.keyboard(' '); expect(moreButton).toHaveAttribute('aria-expanded', 'true'); }); - it('marks overflow tabs with aria-selected when opened', async () => { + it('indicates overflow active state via data attribute when opened', async () => { const user = userEvent.setup(); render( { /> ); - const moreButton = screen.getByRole('button', { name: 'D' }); + const moreButton = screen.getByRole('button', { name: 'More tabs' }); await user.click(moreButton); - expect(screen.getByRole('tab', { name: 'D' })).toHaveAttribute( - 'aria-selected', + expect(screen.getByRole('menuitem', { name: 'D' })).toHaveAttribute( + 'data-active', 'true' ); - expect(screen.getByRole('tab', { name: 'C' })).toHaveAttribute( - 'aria-selected', + expect(screen.getByRole('menuitem', { name: 'C' })).toHaveAttribute( + 'data-active', 'false' ); }); diff --git a/__tests__/components/groups/GroupCreateWallets.test.tsx b/__tests__/components/groups/GroupCreateWallets.test.tsx index f1bf1993b5..c7bb1b8dc1 100644 --- a/__tests__/components/groups/GroupCreateWallets.test.tsx +++ b/__tests__/components/groups/GroupCreateWallets.test.tsx @@ -34,6 +34,6 @@ describe('GroupCreateWallets', () => { const user = userEvent.setup(); const { setWallets } = renderComp({ walletsLimit:5 }); await user.click(screen.getByLabelText('Remove wallets')); - expect(setWallets).toHaveBeenLastCalledWith(null); + expect(setWallets).toHaveBeenCalledWith(null); }); }); diff --git a/__tests__/components/groups/page/create/actions/GroupCreateActions.test.tsx b/__tests__/components/groups/page/create/actions/GroupCreateActions.test.tsx index 910264b061..db298d6468 100644 --- a/__tests__/components/groups/page/create/actions/GroupCreateActions.test.tsx +++ b/__tests__/components/groups/page/create/actions/GroupCreateActions.test.tsx @@ -8,30 +8,20 @@ import { ReactQueryWrapperContext } from '@/components/react-query-wrapper/React jest.mock('@/components/groups/page/create/actions/GroupCreateTest', () => () =>
); jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => () =>
); -const commonApiPost = jest.fn(); -jest.mock('@/services/api/common-api', () => ({ - commonApiPost: (...args: any[]) => commonApiPost(...args), +const mockSubmit = jest.fn(); +const mockValidate = jest.fn(); +jest.mock('@/hooks/groups/useGroupMutations', () => ({ + useGroupMutations: () => ({ + validate: mockValidate, + submit: mockSubmit, + runTest: jest.fn(), + isSubmitting: false, + isTesting: false, + updateVisibility: jest.fn(), + isUpdatingVisibility: false, + }), })); -// simple useMutation mock that executes mutationFn and callbacks -const useMutationMock = jest.fn((options: any) => { - const mutateAsync = jest.fn(async (param?: any) => { - try { - const result = await options.mutationFn(param); - if (options.onSuccess) options.onSuccess(result, param, undefined); - if (options.onSettled) options.onSettled(result, undefined, param, undefined); - return result; - } catch (err) { - if (options.onError) options.onError(err, param, undefined); - if (options.onSettled) options.onSettled(undefined, err, param, undefined); - throw err; - } - }); - return { mutateAsync }; -}); - -jest.mock('@tanstack/react-query', () => ({ useMutation: (options: any) => useMutationMock(options) })); - const defaultGroup = { name: '', group: { @@ -68,7 +58,14 @@ function renderActions(props?: Partial { + jest.clearAllMocks(); + mockSubmit.mockReset(); + mockValidate.mockReset(); +}); + it('disables create button when no filters selected', () => { + mockValidate.mockReturnValue({ valid: false, issues: [] }); renderActions(); expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); }); @@ -80,20 +77,19 @@ it('creates group and marks visible on save', async () => { group: { ...defaultGroup.group, identity_addresses: ['0x1'] }, }; const originalGroup = { id: 'old', created_by: { handle: 'Alice' } } as any; - commonApiPost.mockResolvedValueOnce({ id: '123' }).mockResolvedValueOnce({}); + mockValidate.mockReturnValue({ valid: true, issues: [] }); + mockSubmit.mockResolvedValueOnce({ ok: true, group: { id: '123' }, published: true }); const { auth, queryCtx, onCompleted } = renderActions({ groupConfig, originalGroup }); await userEvent.click(screen.getByRole('button', { name: 'Create' })); - await waitFor(() => expect(commonApiPost).toHaveBeenCalledTimes(2)); - expect(auth.requestAuth).toHaveBeenCalled(); - expect(commonApiPost).toHaveBeenNthCalledWith(1, { endpoint: 'groups', body: groupConfig }); - expect(commonApiPost).toHaveBeenNthCalledWith(2, { - endpoint: 'groups/123/visible', - body: { visible: true, old_version_id: 'old' }, + await waitFor(() => expect(mockSubmit).toHaveBeenCalledTimes(1)); + expect(mockSubmit).toHaveBeenCalledWith({ + payload: groupConfig, + previousGroup: originalGroup, + currentHandle: 'alice', }); expect(auth.setToast).toHaveBeenCalledWith({ message: 'Group created.', type: 'success' }); - expect(queryCtx.onGroupCreate).toHaveBeenCalled(); expect(onCompleted).toHaveBeenCalled(); }); diff --git a/__tests__/components/groups/page/create/actions/GroupCreateTest.test.tsx b/__tests__/components/groups/page/create/actions/GroupCreateTest.test.tsx index fa45302358..ba02da4f1d 100644 --- a/__tests__/components/groups/page/create/actions/GroupCreateTest.test.tsx +++ b/__tests__/components/groups/page/create/actions/GroupCreateTest.test.tsx @@ -1,76 +1,107 @@ -// @ts-nocheck -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import GroupCreateTest from '@/components/groups/page/create/actions/GroupCreateTest'; import { AuthContext } from '@/components/auth/Auth'; -const commonApiPost = jest.fn(); -const commonApiFetch = jest.fn(); +const hookState = { + runTest: jest.fn(), + isTesting: false, + submit: jest.fn(), + validate: jest.fn(), + updateVisibility: jest.fn(), + isUpdatingVisibility: false, +}; -jest.mock('@/services/api/common-api', () => ({ - commonApiPost: (...args: any[]) => commonApiPost(...args), - commonApiFetch: (...args: any[]) => commonApiFetch(...args), +jest.mock('@/hooks/groups/useGroupMutations', () => ({ + useGroupMutations: () => hookState, })); -const mutateAsyncMock = jest.fn(); - -const useMutationMock = jest.fn((options: any) => { - const mutateAsync = async (param?: any) => { - return options.mutationFn(param); - }; - mutateAsyncMock.mockImplementation(mutateAsync); - return { mutateAsync: mutateAsyncMock }; -}); - -const useQueryMock = jest.fn((...args: any[]) => ({ isFetching: false, data: undefined })); - +const useQueryMock = jest.fn().mockReturnValue({ isFetching: false, data: undefined }); jest.mock('@tanstack/react-query', () => ({ - useMutation: (opts: any) => useMutationMock(opts), - // @ts-expect-error - test mock + // @ts-expect-error - partial mock for tests useQuery: (...args: any[]) => useQueryMock(...args), keepPreviousData: {}, })); +const commonApiFetch = jest.fn(); +jest.mock('@/services/api/common-api', () => ({ + commonApiFetch: (...args: any[]) => commonApiFetch(...args), +})); + +const defaultGroupConfig = { + name: 'My Group', + group: { + identity_addresses: [], + excluded_identity_addresses: [], + owns_nfts: [], + tdh: { min: null, max: null }, + rep: { min: null, max: null, user_identity: null, category: null, direction: null }, + cic: { min: null, max: null, user_identity: null, direction: null }, + level: { min: null, max: null }, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + hookState.runTest.mockReset(); + hookState.isTesting = false; +}); + function renderComponent(props?: Partial>) { const auth = { requestAuth: jest.fn().mockResolvedValue({ success: true }), setToast: jest.fn(), connectedProfile: { handle: 'alice' }, } as any; + render( - + ); + return { auth }; } -beforeEach(() => { - jest.clearAllMocks(); -}); - -test('disables button when disabled prop true', () => { +it('disables button when disabled prop true', () => { render( - + ); + expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled(); }); -test('calls mutation and displays loader on click', async () => { - const { auth } = renderComponent(); - const button = screen.getByRole('button', { name: 'Test' }); +it('calls runTest with payload and fallback name', async () => { + hookState.runTest.mockResolvedValueOnce({ ok: true, group: { id: '123' } }); + const customConfig = { + ...defaultGroupConfig, + name: '', + }; - await fireEvent.click(button); + renderComponent({ groupConfig: customConfig as any }); - await waitFor(() => expect(auth.requestAuth).toHaveBeenCalled()); - expect(mutateAsyncMock).toHaveBeenCalledWith({ - name: 'name', - group: {}, + await fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await waitFor(() => expect(hookState.runTest).toHaveBeenCalledTimes(1)); + expect(hookState.runTest).toHaveBeenCalledWith({ + payload: customConfig, + nameFallback: 'alice Test Run', + }); +}); + +it('shows toast when runTest fails with api error', async () => { + hookState.runTest.mockResolvedValueOnce({ + ok: false, + reason: 'api', + error: 'failed', }); + + const { auth } = renderComponent(); + + await fireEvent.click(screen.getByRole('button', { name: 'Test' })); + + await waitFor(() => expect(hookState.runTest).toHaveBeenCalled()); + expect(auth.setToast).toHaveBeenCalledWith({ message: 'failed', type: 'error' }); }); diff --git a/__tests__/components/groups/page/list/card/actions/delete/GroupCardDeleteModal.test.tsx b/__tests__/components/groups/page/list/card/actions/delete/GroupCardDeleteModal.test.tsx index 1db74f6264..4cbd7ed6bc 100644 --- a/__tests__/components/groups/page/list/card/actions/delete/GroupCardDeleteModal.test.tsx +++ b/__tests__/components/groups/page/list/card/actions/delete/GroupCardDeleteModal.test.tsx @@ -19,27 +19,34 @@ import userEvent from '@testing-library/user-event'; import GroupCardDeleteModal from '@/components/groups/page/list/card/actions/delete/GroupCardDeleteModal'; import { AuthContext } from '@/components/auth/Auth'; import { ReactQueryWrapperContext } from '@/components/react-query-wrapper/ReactQueryWrapper'; -import { useMutation } from '@tanstack/react-query'; +const hookState = { + updateVisibility: jest.fn(), + isUpdatingVisibility: false, + submit: jest.fn(), + runTest: jest.fn(), + validate: jest.fn(), + isSubmitting: false, + isTesting: false, +}; -jest.mock('@tanstack/react-query'); -jest.mock('react-redux', () => ({ useDispatch: jest.fn(() => jest.fn()), useSelector: jest.fn(() => null) })); - -const mutateAsync = jest.fn(); -(useMutation as jest.Mock).mockImplementation((opts) => ({ - mutateAsync: async (p:any) => { - await opts.mutationFn(p); - opts.onSuccess?.(); - opts.onSettled?.(); - return mutateAsync(); - }, +jest.mock('@/hooks/groups/useGroupMutations', () => ({ + useGroupMutations: () => hookState, })); +jest.mock('react-redux', () => ({ useDispatch: jest.fn(() => jest.fn()), useSelector: jest.fn(() => null) })); describe('GroupCardDeleteModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + hookState.updateVisibility.mockReset(); + hookState.isUpdatingVisibility = false; + }); + const auth = { requestAuth: jest.fn().mockResolvedValue({ success: true }), setToast: jest.fn() } as any; const rq = { onGroupRemoved: jest.fn() } as any; it('deletes group when confirmed', async () => { const user = userEvent.setup(); + hookState.updateVisibility.mockResolvedValueOnce({ ok: true, visible: false, groupId: 'g' }); render( @@ -48,8 +55,11 @@ describe('GroupCardDeleteModal', () => { ); await user.click(screen.getByRole('button', { name: 'Delete' })); - expect(auth.requestAuth).toHaveBeenCalled(); - expect(mutateAsync).toHaveBeenCalled(); + expect(hookState.updateVisibility).toHaveBeenCalledWith({ + groupId: 'g', + visible: false, + }); + expect(auth.setToast).toHaveBeenCalledWith({ message: 'Group deleted.', type: 'warning' }); expect(rq.onGroupRemoved).toHaveBeenCalledWith({ groupId: 'g' }); }); diff --git a/__tests__/components/utils/input/identity/IdentitySearch.test.tsx b/__tests__/components/utils/input/identity/IdentitySearch.test.tsx index 234b42dc95..461b682c1b 100644 --- a/__tests__/components/utils/input/identity/IdentitySearch.test.tsx +++ b/__tests__/components/utils/input/identity/IdentitySearch.test.tsx @@ -13,13 +13,16 @@ jest.mock('@/helpers/AllowlistToolHelpers', () => ({ getRandomObjectId: () => 'i describe('IdentitySearch', () => { const setIdentity = jest.fn(); beforeEach(() => { + receivedProps = undefined; (useQuery as jest.Mock).mockReturnValue({ data: [{ handle: 'user' }] }); }); - it('opens dropdown on focus and selects value', () => { + it('opens dropdown after typing and selects value', () => { render(); const input = screen.getByRole('textbox'); fireEvent.focus(input); + expect(receivedProps.open).toBe(false); + fireEvent.change(input, { target: { value: 'a' } }); 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 1456ef8bbb..9fb9a8aede 100644 --- a/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx +++ b/__tests__/components/utils/input/profile-search/CommonProfileSearchItem.test.tsx @@ -6,9 +6,15 @@ 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(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 d36acdc04e..a049e346b3 100644 --- a/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx +++ b/__tests__/components/utils/input/profile-search/CommonProfileSearchItems.test.tsx @@ -2,7 +2,9 @@ import { render, screen } from '@testing-library/react'; import CommonProfileSearchItems from '@/components/utils/input/profile-search/CommonProfileSearchItems'; jest.mock('@/components/utils/input/profile-search/CommonProfileSearchItem', () => (props: any) => ( -
  • {props.profile.wallet}
  • +
  • + {props.profile.wallet} +
  • )); describe('CommonProfileSearchItems', () => { @@ -29,4 +31,20 @@ describe('CommonProfileSearchItems', () => { ); expect(screen.getByText('No results')).toBeInTheDocument(); }); + + it('notifies highlighted option id when highlighted option changes', () => { + const onHighlightedOptionIdChange = jest.fn(); + render( + + ); + expect(onHighlightedOptionIdChange).toHaveBeenCalledWith('profile-search-item-b-1'); + }); }); diff --git a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx index 7fcd24f479..2c4d57c9b3 100644 --- a/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx +++ b/__tests__/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper.test.tsx @@ -40,7 +40,7 @@ test('closes on escape press', () => { test('positions dropdown when buttonPosition provided', async () => { jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(40); const { container } = render( - {}} buttonRef={{ current: null }} buttonPosition={{ bottom:0, right:100 }}> + {}} buttonRef={{ current: null }} buttonPosition={{ right:100 }}>
  • item
  • ); diff --git a/__tests__/components/waves/groups/WaveGroups.test.tsx b/__tests__/components/waves/groups/WaveGroups.test.tsx index fc5644dd53..b5b967bdf0 100644 --- a/__tests__/components/waves/groups/WaveGroups.test.tsx +++ b/__tests__/components/waves/groups/WaveGroups.test.tsx @@ -6,14 +6,14 @@ import { ApiWaveType } from '@/generated/models/ApiWaveType'; // Capture props passed to the mocked WaveGroup component const captured: any[] = []; jest.mock('@/components/waves/specs/groups/group/WaveGroup', () => { - const real = jest.requireActual('../../../../components/waves/specs/groups/group/WaveGroup'); + const { WaveGroupType } = jest.requireActual('../../../../components/waves/specs/groups/group/WaveGroup.types'); return { __esModule: true, default: (props: any) => { captured.push(props); return
    ; }, - WaveGroupType: real.WaveGroupType, + WaveGroupType, }; }); diff --git a/__tests__/components/waves/specs/groups/group/WaveGroup.test.tsx b/__tests__/components/waves/specs/groups/group/WaveGroup.test.tsx index 3c181d1b40..2b65913f3d 100644 --- a/__tests__/components/waves/specs/groups/group/WaveGroup.test.tsx +++ b/__tests__/components/waves/specs/groups/group/WaveGroup.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; -import WaveGroup, { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup'; +import WaveGroup from '@/components/waves/specs/groups/group/WaveGroup'; +import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; import { AuthContext } from '@/components/auth/Auth'; jest.mock('@/components/waves/specs/groups/group/WaveGroupTitle', () => () =>
    ); diff --git a/__tests__/components/waves/specs/groups/group/WaveGroupTitle.test.tsx b/__tests__/components/waves/specs/groups/group/WaveGroupTitle.test.tsx index 3b894fa430..0aa3231b38 100644 --- a/__tests__/components/waves/specs/groups/group/WaveGroupTitle.test.tsx +++ b/__tests__/components/waves/specs/groups/group/WaveGroupTitle.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import WaveGroupTitle from '@/components/waves/specs/groups/group/WaveGroupTitle'; -import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup'; +import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; describe('WaveGroupTitle', () => { it('renders label for each type', () => { diff --git a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEdit.test.tsx b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEdit.test.tsx index 2a366e2cfa..09c91bbb8a 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEdit.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEdit.test.tsx @@ -1,6 +1,6 @@ import { render } from '@testing-library/react'; import WaveGroupEdit from '@/components/waves/specs/groups/group/edit/WaveGroupEdit'; -import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup'; +import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; import { convertWaveToUpdateWave } from '@/helpers/waves/waves.helpers'; let triggerSelect: (g: any) => void; @@ -22,19 +22,19 @@ jest.mock('@/helpers/waves/waves.helpers'); type Body = ReturnType; it('builds body according to type', () => { - const onEdit = jest.fn(); + const onWaveUpdate = jest.fn(); render( - + ); triggerSelect({ id: 'g1' }); - const bodyView = onEdit.mock.calls[0][0] as Body; + const bodyView = onWaveUpdate.mock.calls[0][0] as Body; expect(bodyView.visibility.scope.group_id).toBe('g1'); - onEdit.mockClear(); + onWaveUpdate.mockClear(); render( - + ); triggerSelect({ id: 'g2' }); - const bodyAdmin = onEdit.mock.calls[0][0] as any; + const bodyAdmin = onWaveUpdate.mock.calls[0][0] as any; expect(bodyAdmin.wave.admin_group.group_id).toBe('g2'); }); diff --git a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButton.test.tsx b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButton.test.tsx index 8a70d72c18..cf2bcf8806 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButton.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButton.test.tsx @@ -8,15 +8,15 @@ let editProps: any; jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupEdit', () => (props: any) => { editProps = props; - return
    props.onEdit('body')} />; + return
    props.onWaveUpdate('body')} />; }); const wave = { id: 'w1' } as ApiWave; const setup = () => { - const onEdit = jest.fn(() => Promise.resolve()); - render(); - return { onEdit }; + const onWaveUpdate = jest.fn(() => Promise.resolve()); + render(); + return { onWaveUpdate }; }; describe('WaveGroupEditButton', () => { @@ -29,11 +29,10 @@ describe('WaveGroupEditButton', () => { it('calls onEdit and closes', async () => { const user = userEvent.setup(); - const { onEdit } = setup(); + const { onWaveUpdate } = setup(); await user.click(screen.getByTitle('Edit')); await user.click(screen.getByTestId('edit')); - expect(onEdit).toHaveBeenCalledWith('body'); + expect(onWaveUpdate).toHaveBeenCalledWith('body'); expect(editProps.isEditOpen).toBe(false); }); }); - 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 18e384a2b6..6fa72f3e44 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import WaveGroupEditButtons from '@/components/waves/specs/groups/group/edit/WaveGroupEditButtons'; -import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup'; +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'; @@ -10,12 +10,37 @@ jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn() })); jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupEditButton', () => ({ __esModule: true, - default: ({ onEdit }: any) => , + default: ({ onWaveUpdate, renderTrigger }: any) => { + const handleOpen = () => onWaveUpdate({}); + if (renderTrigger === null) { + return null; + } + return renderTrigger ? <>{renderTrigger({ open: handleOpen })} : ; + }, })); jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupRemoveButton', () => ({ __esModule: true, - default: ({ onEdit }: any) => , + default: ({ onWaveUpdate, renderTrigger }: any) => { + const handleOpen = () => onWaveUpdate({}); + if (renderTrigger === null) { + return null; + } + return renderTrigger ? <>{renderTrigger({ open: handleOpen })} : ; + }, +})); + +jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal', () => ({ + __esModule: true, + WaveGroupManageIdentitiesMode: { + INCLUDE: 'INCLUDE', + EXCLUDE: 'EXCLUDE', + }, + default: ({ mode, onClose }: any) => ( +
    + +
    + ), })); jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({ @@ -29,6 +54,7 @@ const mutateAsync = jest.fn(); const auth = { setToast: jest.fn(), requestAuth: jest.fn().mockResolvedValue({ success: true }), + connectedProfile: { id: "profile-1", handle: "alice" }, } as any; const wrapper = ({ children }: any) => ( @@ -39,16 +65,45 @@ const wrapper = ({ children }: any) => ( ); -const wave: any = { id: 'w1' }; +const baseGroup = { + id: "group-1", + name: "Group 1", + author: { id: "profile-1", handle: "alice" }, + created_at: Date.now(), + is_hidden: false, + is_direct_message: false, +}; + +const wave: any = { + id: "w1", + visibility: { scope: { group: baseGroup } }, + participation: { + scope: { group: baseGroup }, + authenticated_user_eligible: true, + }, + voting: { + scope: { group: baseGroup }, + authenticated_user_eligible: true, + }, + chat: { + scope: { group: baseGroup }, + authenticated_user_eligible: true, + }, + wave: { + admin_group: { group: baseGroup }, + authenticated_user_eligible_for_admin: true, + }, +}; describe('WaveGroupEditButtons', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('calls mutate on edit', async () => { + it('opens menu and calls mutate on edit', async () => { render(, { wrapper }); - fireEvent.click(screen.getByText('edit')); + fireEvent.click(screen.getByRole('button', { name: /Group options/i })); + fireEvent.click(screen.getByText('Change group')); await waitFor(() => expect(auth.requestAuth).toHaveBeenCalled()); expect(mutateAsync).toHaveBeenCalled(); }); @@ -56,12 +111,53 @@ describe('WaveGroupEditButtons', () => { it('shows error toast when auth fails', async () => { auth.requestAuth.mockResolvedValueOnce({ success: false }); render(, { wrapper }); - fireEvent.click(screen.getByText('edit')); + fireEvent.click(screen.getByRole('button', { name: /Group options/i })); + fireEvent.click(screen.getByText('Change group')); await waitFor(() => expect(auth.setToast).toHaveBeenCalled()); }); - it('hides remove button for admin type', () => { - const { queryByText } = render(, { wrapper }); - expect(queryByText('remove')).toBeNull(); + it('hides remove option for admin type', async () => { + render(, { wrapper }); + fireEvent.click(screen.getByRole('button', { name: /Group options/i })); + expect(screen.getByText('Change group')).toBeInTheDocument(); + expect(screen.queryByText('Remove group')).toBeNull(); + }); + + it('shows add label when no group is linked', async () => { + const waveWithoutGroup = { + ...wave, + visibility: { + ...wave.visibility, + scope: { group: null }, + }, + }; + + render( + , + { wrapper }, + ); + + fireEvent.click(screen.getByRole('button', { name: /Group options/i })); + + expect(screen.getByText('Add group')).toBeInTheDocument(); + expect(screen.queryByText('Change group')).toBeNull(); }); }); +jest.mock('@headlessui/react', () => { + const close = jest.fn(); + return { + Menu: ({ children, ...props }: any) => ( +
    + {typeof children === 'function' + ? children({ open: false, close }) + : children} +
    + ), + MenuButton: React.forwardRef(({ children, ...props }, ref) => ( + + )), + MenuItems: ({ children, anchor: _anchor, ...props }: any) =>
    {children}
    , + MenuItem: ({ children }: any) => children({ close, active: false }), + Transition: ({ children }: any) => <>{typeof children === 'function' ? children() : children}, + }; +}); diff --git a/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemove.test.tsx b/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemove.test.tsx index ceb020c8cf..55aab9480e 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemove.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemove.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import WaveGroupRemove from '@/components/waves/specs/groups/group/edit/WaveGroupRemove'; -import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup'; +import { WaveGroupType } from '@/components/waves/specs/groups/group/WaveGroup.types'; import { convertWaveToUpdateWave } from '@/helpers/waves/waves.helpers'; jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupRemoveModal', () => (props: any) => ( @@ -31,19 +31,19 @@ describe('WaveGroupRemove', () => { it('builds body according to type', async () => { const user = userEvent.setup(); - const onEdit = jest.fn(() => Promise.resolve()); + const onWaveUpdate = jest.fn(() => Promise.resolve()); render( - + ); await user.click(screen.getByTestId('remove')); - expect(onEdit).toHaveBeenCalled(); - expect(onEdit.mock.calls[0][0].visibility.scope.group_id).toBeNull(); + expect(onWaveUpdate).toHaveBeenCalled(); + expect(onWaveUpdate.mock.calls[0][0].visibility.scope.group_id).toBeNull(); - onEdit.mockClear(); + onWaveUpdate.mockClear(); render( - + ); await user.click(screen.getAllByTestId('remove')[1]); - expect(onEdit.mock.calls[0][0].wave.admin_group.group_id).toBeNull(); + expect(onWaveUpdate.mock.calls[0][0].wave.admin_group.group_id).toBeNull(); }); }); diff --git a/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.test.tsx b/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.test.tsx index 8c2d2a2e9c..342704c87b 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.test.tsx @@ -11,6 +11,10 @@ it('calls handlers on actions', () => { render(); fireEvent.click(screen.getByText('Remove')); expect(remove).toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('Cancel')); - expect(close).toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(close).toHaveBeenCalledTimes(2); + expect(remove).toHaveBeenCalledTimes(1); }); 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 new file mode 100644 index 0000000000..ff6a9e8b81 --- /dev/null +++ b/__tests__/components/waves/specs/groups/group/edit/buttons/useWaveGroupEditButtonsController.test.tsx @@ -0,0 +1,298 @@ +import { act, renderHook } from '@testing-library/react'; +import { ApiGroupFilterDirection } from '@/generated/models/ApiGroupFilterDirection'; +import { + useWaveGroupEditButtonsController, + 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 { + createGroup as createGroupMutation, + publishGroup as publishGroupMutation, +} from '@/services/groups/groupMutations'; + +jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn() })); + +const mockCommonApiFetch = jest.fn(); +const mockCommonApiPost = jest.fn(); + +jest.mock('@/services/api/common-api', () => ({ + commonApiFetch: (...args: any[]) => mockCommonApiFetch(...args), + commonApiPost: (...args: any[]) => mockCommonApiPost(...args), +})); + +jest.mock('@/services/groups/groupMutations', () => { + const actual = jest.requireActual('@/services/groups/groupMutations'); + return { + ...actual, + createGroup: jest.fn(), + publishGroup: jest.fn(), + }; +}); + +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 mockCreateGroup = createGroupMutation as jest.Mock; +const mockPublishGroup = publishGroupMutation as jest.Mock; + +const connectedProfile = { id: 'u1', handle: 'alice' } as any; + +const baseGroupFull = { + id: 'group-1', + name: 'Existing Group', + group: { + tdh: { min: null, max: null }, + rep: { + min: null, + max: null, + direction: ApiGroupFilterDirection.Received, + user_identity: null, + category: null, + }, + cic: { + min: null, + max: null, + direction: ApiGroupFilterDirection.Received, + user_identity: null, + }, + level: { min: null, max: null }, + owns_nfts: [], + identity_group_id: 'include-group', + identity_group_identities_count: 0, + excluded_identity_group_id: 'exclude-group', + excluded_identity_group_identities_count: 0, + }, + created_at: Date.now(), + created_by: { id: 'u1', handle: 'alice' }, + visible: true, + is_private: false, +}; + +const buildWave = (withGroup: boolean) => ({ + id: 'wave-1', + name: 'Wave Alpha', + visibility: { + scope: { + group: withGroup + ? { id: baseGroupFull.id, author: { id: 'u1', handle: 'alice' } } + : null, + }, + }, + participation: { + scope: { group: null }, + authenticated_user_eligible: true, + }, + voting: { + scope: { group: null }, + authenticated_user_eligible: true, + }, + chat: { + scope: { group: null }, + authenticated_user_eligible: true, + }, + wave: { + admin_group: { group: null }, + authenticated_user_eligible_for_admin: true, + }, +}) as any; + +const requestAuth = jest.fn().mockResolvedValue({ success: true }); +const setToast = jest.fn(); +const onWaveCreated = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + mockCommonApiPost.mockResolvedValue({}); + mockCreateGroup.mockResolvedValue({ + ...baseGroupFull, + id: 'new-group-id', + }); + mockPublishGroup.mockResolvedValue(undefined); + mutateAsyncSpy.mockClear(); +}); + +describe('useWaveGroupEditButtonsController - identity management', () => { + it('includes identity by recreating the existing group', async () => { + mockCommonApiFetch.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint === `groups/${baseGroupFull.id}`) { + return Promise.resolve(baseGroupFull); + } + if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.identity_group_id}`) { + return Promise.resolve([]); + } + if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.excluded_identity_group_id}`) { + return Promise.resolve(['0xabcd']); + } + throw new Error(`Unexpected endpoint ${endpoint}`); + }); + + const { result } = renderHook(() => + useWaveGroupEditButtonsController({ + haveGroup: true, + wave: buildWave(true), + type: WaveGroupType.VIEW, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, + }), + ); + + await act(async () => { + await result.current.onIdentityConfirm({ + identity: '0xABCD', + mode: WaveGroupIdentitiesModal.INCLUDE, + }); + }); + + expect(requestAuth).toHaveBeenCalled(); + expect(mockCreateGroup).toHaveBeenCalledTimes(1); + 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(mockCommonApiPost).not.toHaveBeenCalled(); + expect(mutateAsyncSpy).not.toHaveBeenCalled(); + expect(onWaveCreated).toHaveBeenCalledTimes(1); + expect(setToast).toHaveBeenCalledWith({ + message: 'Identity successfully included in the group.', + type: 'success', + }); + }); + + it('blocks including identities when no group exists', async () => { + mockCommonApiFetch.mockReset(); + + const { result } = renderHook(() => + useWaveGroupEditButtonsController({ + haveGroup: false, + wave: buildWave(false), + type: WaveGroupType.VIEW, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, + }), + ); + + expect(result.current.canIncludeIdentity).toBe(false); + + await act(async () => { + await result.current.onIdentityConfirm({ + identity: '0xFACE', + mode: WaveGroupIdentitiesModal.INCLUDE, + }); + }); + + 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(setToast).toHaveBeenCalledWith({ + message: 'You need to define group filters before including specific identities.', + type: 'error', + }); + }); + + it('moves identity from include to exclude list', async () => { + mockCommonApiFetch.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint === `groups/${baseGroupFull.id}`) { + return Promise.resolve(baseGroupFull); + } + if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.identity_group_id}`) { + return Promise.resolve(['0xAAA', '0xbbb']); + } + if (endpoint === `groups/${baseGroupFull.id}/identity_groups/${baseGroupFull.group.excluded_identity_group_id}`) { + return Promise.resolve(['0xccc']); + } + throw new Error(`Unexpected endpoint ${endpoint}`); + }); + + const { result } = renderHook(() => + useWaveGroupEditButtonsController({ + haveGroup: true, + wave: buildWave(true), + type: WaveGroupType.VIEW, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, + }), + ); + + await act(async () => { + await result.current.onIdentityConfirm({ + identity: '0xAAA', + mode: WaveGroupIdentitiesModal.EXCLUDE, + }); + }); + + const payloadArg = mockCreateGroup.mock.calls[0][0].payload; + expect(payloadArg.group.identity_addresses).toEqual(['0xbbb']); + expect(payloadArg.group.excluded_identity_addresses).toContain('0xaaa'); + expect(mockCommonApiPost).not.toHaveBeenCalled(); + expect(mutateAsyncSpy).not.toHaveBeenCalled(); + expect(onWaveCreated).toHaveBeenCalledTimes(1); + expect(setToast).toHaveBeenCalledWith({ + message: 'Identity successfully excluded from the group.', + type: 'success', + }); + }); + + it('falls back to empty lists when identity group fetch fails', async () => { + mockCommonApiFetch.mockImplementation(({ endpoint }: { endpoint: string }) => { + if (endpoint === `groups/${baseGroupFull.id}`) { + return Promise.resolve(baseGroupFull); + } + return Promise.reject(new Error('Group does not have identity group')); + }); + + const { result } = renderHook(() => + useWaveGroupEditButtonsController({ + haveGroup: true, + wave: buildWave(true), + type: WaveGroupType.VIEW, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, + }), + ); + + await act(async () => { + await result.current.onIdentityConfirm({ + identity: '0xF00', + mode: WaveGroupIdentitiesModal.INCLUDE, + }); + }); + + const payloadArg = mockCreateGroup.mock.calls[0][0].payload; + expect(payloadArg.group.identity_addresses).toEqual(['0xf00']); + expect(payloadArg.group.excluded_identity_addresses).toBeNull(); + expect(mockCommonApiPost).not.toHaveBeenCalled(); + expect(mutateAsyncSpy).not.toHaveBeenCalled(); + expect(onWaveCreated).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/services/groups/groupMutations.test.ts b/__tests__/services/groups/groupMutations.test.ts new file mode 100644 index 0000000000..031c71b8bf --- /dev/null +++ b/__tests__/services/groups/groupMutations.test.ts @@ -0,0 +1,91 @@ +import { + GROUP_EXCLUDE_LIMIT, + GROUP_INCLUDE_LIMIT, + ValidationIssue, + validateGroupPayload, +} from '@/services/groups/groupMutations'; +import { ApiCreateGroup } from '@/generated/models/ApiCreateGroup'; + +const createPayload = (overrides: Partial = {}): ApiCreateGroup => { + const { group: groupOverrides, ...rest } = overrides; + return { + name: 'Test', + group: { + tdh: { min: null, max: null }, + rep: { min: null, max: null, direction: null, user_identity: null, category: null }, + cic: { min: null, max: null, direction: null, user_identity: null }, + level: { min: null, max: null }, + owns_nfts: [], + identity_addresses: [], + excluded_identity_addresses: [], + ...(groupOverrides ?? {}), + }, + is_private: false, + ...rest, + }; +}; + +const expectIssue = (issues: ValidationIssue[], issue: ValidationIssue) => { + expect(issues).toContain(issue); +}; + +describe('validateGroupPayload', () => { + it('marks payload valid when include wallets present', () => { + const result = validateGroupPayload( + createPayload({ + group: { + identity_addresses: ['0x1'], + }, + }) + ); + + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('flags missing filters', () => { + const result = validateGroupPayload(createPayload()); + expect(result.valid).toBe(false); + expectIssue(result.issues, 'NO_FILTERS'); + }); + + it('marks payload valid when only exclude wallets present', () => { + const result = validateGroupPayload( + createPayload({ + group: { + excluded_identity_addresses: ['0xdead'], + }, + }) + ); + + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('flags include list above limit', () => { + const include = Array.from({ length: GROUP_INCLUDE_LIMIT + 1 }, (_, idx) => `0x${idx}`); + const result = validateGroupPayload( + createPayload({ + group: { + identity_addresses: include, + }, + }) + ); + expect(result.valid).toBe(false); + expectIssue(result.issues, 'INCLUDE_LIMIT'); + }); + + it('flags exclude list above limit', () => { + const exclude = Array.from({ length: GROUP_EXCLUDE_LIMIT + 1 }, (_, idx) => `0x${idx}`); + const result = validateGroupPayload( + createPayload({ + group: { + identity_addresses: ['0x1'], + excluded_identity_addresses: exclude, + }, + }) + ); + expect(result.valid).toBe(false); + expectIssue(result.issues, 'EXCLUDE_LIMIT'); + }); +}); diff --git a/codex/STATE.md b/codex/STATE.md index 195a0be019..9f9a44af61 100644 --- a/codex/STATE.md +++ b/codex/STATE.md @@ -14,6 +14,10 @@ This table is the single source of truth for active and historical tickets. Keep | TKT-0008 | Reconcile Codex board merge conflicts | In-Progress | P1 | openai-assistant | [#1539](https://github.com/6529-Collections/6529seize-frontend/pull/1539) | 2025-10-14 | | TKT-0009 | Refactor Brain notifications shell for modular clarity | In-Progress | P1 | simo6529 | [#1545](https://github.com/6529-Collections/6529seize-frontend/pull/1545) | 2025-10-15 | | TKT-0010 | Refactor WaveDropsAll component for modular clarity | In-Progress | P1 | openai-assistant | [#1560](https://github.com/6529-Collections/6529seize-frontend/pull/1560) | 2025-10-22 | +| TKT-0011 | Restore identity search keyboard navigation | Done | P1 | simo6529 | Pending (branch block-add-identity-to-wave) | 2025-10-26 | +| TKT-0012 | Refactor wave group edit buttons for modular clarity | In-Progress | P1 | openai-assistant | [#1544](https://github.com/6529-Collections/6529seize-frontend/pull/1544) | 2025-10-26 | +| TKT-0013 | Respect unstyled flag in compact menu button | In-Progress | P1 | openai-assistant | — | 2025-10-23 | +| TKT-0014 | Replace wave publish wait with backend confirmation | Backlog | P1 | openai-assistant | — | 2025-10-24 | ## Usage Guidelines diff --git a/codex/tickets/TKT-0010.md b/codex/tickets/TKT-0010.md index 8e901ddb66..bcfa55ada3 100644 --- a/codex/tickets/TKT-0010.md +++ b/codex/tickets/TKT-0010.md @@ -28,6 +28,7 @@ title: Refactor WaveDropsAll component for modular clarity ## Links +- Primary PR: [#1544](https://github.com/6529-Collections/6529seize-frontend/pull/1544) - Source file: `components/waves/drops/WaveDropsAll.tsx` - [PR #1560](https://github.com/6529-Collections/6529seize-frontend/pull/1560) diff --git a/codex/tickets/TKT-0011.md b/codex/tickets/TKT-0011.md new file mode 100644 index 0000000000..2fe0f0a1c9 --- /dev/null +++ b/codex/tickets/TKT-0011.md @@ -0,0 +1,47 @@ +--- +created: 2025-10-16 +id: TKT-0011 +owner: simo6529 +priority: P1 +status: Done +title: Restore identity search keyboard navigation +--- + +## Context + +The identity picker modal opens a typeahead list, but arrow key navigation has regressed—users cannot move through results or select them with the keyboard, making the flow inaccessible without a mouse. + +## Plan + +- [x] Confirm the identity search modal and list render paths still exist. +- [x] Reuse or extend the existing profile search list to track a highlighted item driven by keyboard input. +- [x] Manually verify keyboard navigation can traverse the list and select an identity. + +## Acceptance + +- [x] Pressing Arrow Up/Down while the identity list is open moves a visible highlight between results. +- [x] Pressing Enter while a result is highlighted selects it and closes the list. +- [x] Esc still closes the list without selection changes. + +## Links + +- Primary PR: _(pending; branch `block-add-identity-to-wave`)_ +- Follow-ups: _(reference additional tickets or TODO items)_ + +## Log + +- 2025-10-16T00:00:00Z – Opened ticket based on bug report from identity inclusion modal; keyboard navigation missing. +- 2025-10-16T00:45:00Z – Wired arrow key handling into the identity search input and highlighted list items; awaiting manual QA. +- 2025-10-16T02:30:00Z – Ensured the compact menu auto-focuses the first enabled option so arrow navigation works again; pending manual verification. +- 2025-10-16T03:45:00Z – Switched the modal to a form submit so a second Enter confirms selection; regression suite passes aside from pre-existing type errors. +- 2025-10-21T08:50:23Z – Updated the identity dropdown to stay hidden until the user types a character and refreshed unit tests to cover the behavior. +- 2025-10-23T14:15:00Z – Added stable option ids, moved highlight tracking to the listbox, and wired aria-activedescendant so keyboard navigation announces the active result. +- 2025-10-24T10:24:27Z – Recomputed dropdown positioning after layout and applied explicit menu roles to keep keyboard navigation aligned with ARIA expectations. +- 2025-10-24T14:15:02Z – Wrapped the listbox markup in a neutral container so Sonar accepts the interactive role while preserving the combobox semantics. +- 2025-10-25T15:47:37Z – Replaced the random ObjectId-based input/listbox ids with React `useId` so the combobox uses semantic, predictable identifiers. +- 2025-10-26T09:30:25Z – Removed the legacy `list` attribute from the combobox input to avoid mixing native datalist behavior with our ARIA implementation. +- 2025-10-26T11:05:00Z – Ensured each visual list option exposes a roving `tabIndex` so Sonar’s accessibility check passes without breaking the combobox navigation model. +- 2025-10-26T11:40:10Z – Double-checked `CommonProfileSearchItems` after the latest refactor and confirmed no element assigns interactive roles incorrectly, so the reported Sonar warning is now stale. +- 2025-10-26T11:55:18Z – Replaced the nested button with a clickable list item so Sonar stops flagging duplicated roles while preserving keyboard activation. +- 2025-10-26T12:05:09Z – Added an explicit `role="listbox"` to the profile search results container so the custom `
  • ` elements meet the WAI-ARIA combobox contract and silence the reopened Sonar warning. +- 2025-10-26T12:20:00Z – Manually re-ran the keyboard navigation checks, confirmed all acceptance criteria, and documented the pending PR linkage for `block-add-identity-to-wave`. diff --git a/codex/tickets/TKT-0012.md b/codex/tickets/TKT-0012.md new file mode 100644 index 0000000000..fa793aff34 --- /dev/null +++ b/codex/tickets/TKT-0012.md @@ -0,0 +1,63 @@ +--- +created: 2025-10-16 +id: TKT-0012 +owner: openai-assistant +priority: P1 +status: In-Progress +title: Refactor wave group edit buttons for modular clarity +--- + +## Context + +> The `WaveGroupEditButtons` component has accreted mixed responsibilities spanning UI composition, event wiring, and conditional logic. This refactor extracts reusable building blocks while preserving runtime behaviour, visuals, and typing guarantees. + +## Plan + +- [x] Audit existing component structure, state and handler responsibilities. +- [x] Propose modular breakdown across subcomponents, hooks, and helpers aligned with project conventions. +- [x] Implement refactor with behaviour parity and updated imports/exports. +- [ ] Validate via linting, type-checks, and targeted tests as required. + +## Acceptance + +- [ ] `WaveGroupEditButtons` rebuilt as a composition shell delegating to extracted modules. +- [ ] Behaviour, styling, and TypeScript typings remain unchanged. +- [ ] Tests, lint, and type-check commands succeed without new warnings. + +## Links + +- Primary PR: +- Follow-ups: + - TKT-0014 – Replace wave publish wait with backend confirmation (target removal 2025-11-08) + +## Log + +- 2025-10-16T00:00:00Z – Ticket drafted and initial component audit started. +- 2025-10-16T01:00:00Z – Extracted hooks/subcomponents/utils, updated exports; type-check blocked by existing repo errors. +- 2025-10-16T01:30:00Z – Consolidated controller naming and modal handling via enum-driven API. +- 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-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. +- 2025-10-24T14:16:46Z – Re-exported group payload validator to align with updated lint rule. +- 2025-10-25T15:05:16Z – Swapped the edit menu trigger to FontAwesome to satisfy the TSX icon guideline. +- 2025-10-25T15:10:00Z – Extracted the wave group type enum into a standalone module and updated imports to stop circular evaluation crashes in remove modal. +- 2025-10-25T15:52:57Z – Documented the combobox listbox option semantics, suppressing Sonar rule S6819 where native `
  • - +
  • ); } diff --git a/components/utils/input/profile-search/CommonProfileSearchItems.tsx b/components/utils/input/profile-search/CommonProfileSearchItems.tsx index 7de15f68f8..d757e85e5a 100644 --- a/components/utils/input/profile-search/CommonProfileSearchItems.tsx +++ b/components/utils/input/profile-search/CommonProfileSearchItems.tsx @@ -1,6 +1,8 @@ +import { useEffect, useId } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { CommunityMemberMinimal } from "@/entities/IProfile"; import CommonProfileSearchItem from "./CommonProfileSearchItem"; +import { getSelectableIdentity } from "./getSelectableIdentity"; export default function CommonProfileSearchItems({ open, @@ -8,17 +10,81 @@ export default function CommonProfileSearchItems({ selected, searchCriteria, onProfileSelect, + highlightedIndex = null, + onHighlightedOptionIdChange, + listboxId, }: { readonly open: boolean; readonly profiles: CommunityMemberMinimal[]; readonly selected: string | null; readonly searchCriteria: string | null; readonly onProfileSelect: (newV: CommunityMemberMinimal | null) => void; + readonly highlightedIndex?: number | null; + readonly onHighlightedOptionIdChange?: (optionId: string | undefined) => void; + readonly listboxId?: string; }) { + const generatedListboxId = useId(); + const resolvedListboxId = listboxId ?? generatedListboxId; + const normalizedSelected = selected?.toLowerCase() ?? null; + + const buildOptionId = ( + profile: CommunityMemberMinimal, + index: number + ): string => { + const rawId = + profile.wallet ?? + profile.primary_wallet ?? + profile.handle ?? + profile.display ?? + ""; + + const sanitized = + String(rawId) + .trim() + .replaceAll(/[^a-zA-Z0-9_-]+/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/(^-|-$)/g, "") || "item"; + + return `profile-search-item-${sanitized}-${index}`; + }; + + const optionMetadata = profiles.map((profile, index) => { + const optionId = buildOptionId(profile, index); + const identity = getSelectableIdentity(profile); + const label = + profile.display ?? + profile.handle ?? + profile.primary_wallet ?? + profile.wallet ?? + `Profile ${index + 1}`; + + return { profile, optionId, identity, label, index }; + }); + + const highlightedOptionId = + highlightedIndex !== null && + highlightedIndex >= 0 && + highlightedIndex < optionMetadata.length + ? optionMetadata[highlightedIndex].optionId + : undefined; + + useEffect(() => { + if (!onHighlightedOptionIdChange) { + return; + } + if (!open) { + onHighlightedOptionIdChange(undefined); + return; + } + onHighlightedOptionIdChange(highlightedOptionId); + }, [highlightedOptionId, onHighlightedOptionIdChange, open]); + const noResultsText = !searchCriteria || searchCriteria.length < 3 ? "Type at least 3 characters" : "No results"; + const selectSize = Math.max(Math.min(optionMetadata.length || 1, 10), 1); + return ( {open && ( @@ -31,16 +97,53 @@ export default function CommonProfileSearchItems({ >
    -
      - {profiles.length ? ( - profiles.map((profile) => ( - - )) + +
    + ); -} +}); + +export default WaveGroupEditButton; diff --git a/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx b/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx index b50a6e621a..8b00449437 100644 --- a/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx +++ b/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx @@ -1,74 +1,107 @@ "use client"; -import { useContext, useState } from "react"; -import { ApiWave } from "@/generated/models/ApiWave"; -import { WaveGroupType } from "../WaveGroup"; -import { useMutation } from "@tanstack/react-query"; -import { commonApiPost } from "@/services/api/common-api"; -import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { AuthContext } from "@/components/auth/Auth"; -import WaveGroupEditButton from "./WaveGroupEditButton"; -import WaveGroupRemoveButton from "./WaveGroupRemoveButton"; -import { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import { useCallback, useContext } from "react"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { WaveGroupType } from "../WaveGroup.types"; +import { + useWaveGroupEditButtonsController, + WaveGroupIdentitiesModal, +} from "./buttons/hooks/useWaveGroupEditButtonsController"; +import WaveGroupEditMenu from "./buttons/subcomponents/WaveGroupEditMenu"; +import WaveGroupManageIdentitiesModals from "./buttons/subcomponents/WaveGroupManageIdentitiesModals"; +import { + WaveGroupManageIdentitiesMode, + type WaveGroupManageIdentitiesConfirmEvent, +} from "./WaveGroupManageIdentitiesModal"; + +export interface WaveGroupEditButtonsProps { + readonly haveGroup: boolean; + readonly wave: ApiWave; + readonly type: WaveGroupType; +} export default function WaveGroupEditButtons({ haveGroup, wave, type, -}: { - readonly haveGroup: boolean; - readonly wave: ApiWave; - readonly type: WaveGroupType; -}) { - const { setToast, requestAuth } = useContext(AuthContext); +}: WaveGroupEditButtonsProps) { + const { setToast, requestAuth, connectedProfile } = + useContext(AuthContext); const { onWaveCreated } = useContext(ReactQueryWrapperContext); - const [mutating, setMutating] = useState(false); - const editWaveMutation = useMutation({ - mutationFn: async (body: ApiUpdateWaveRequest) => - await commonApiPost({ - endpoint: `waves/${wave.id}`, - body, - }), - onSuccess: () => { - onWaveCreated(); - }, - onError: (error) => { - setToast({ - message: error as unknown as string, - type: "error", - }); - }, - onSettled: () => { - setMutating(false); - }, + const { + mutating, + updateWave, + canIncludeIdentity, + canExcludeIdentity, + canRemoveGroup, + activeIdentitiesModal, + openIdentitiesModal, + closeIdentitiesModal, + onIdentityConfirm, + } = useWaveGroupEditButtonsController({ + haveGroup, + wave, + type, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, }); - const onEdit = async (body: ApiUpdateWaveRequest) => { - setMutating(true); - const { success } = await requestAuth(); - if (!success) { - setToast({ - type: "error", - message: "Failed to authenticate", - }); - setMutating(false); - return; - } - await editWaveMutation.mutateAsync(body); - }; + const handleIdentityConfirm = useCallback( + ({ identity, mode }: WaveGroupManageIdentitiesConfirmEvent) => { + const normalizedMode = + mode === WaveGroupManageIdentitiesMode.INCLUDE + ? WaveGroupIdentitiesModal.INCLUDE + : WaveGroupIdentitiesModal.EXCLUDE; + onIdentityConfirm({ identity, mode: normalizedMode }); + }, + [onIdentityConfirm], + ); + + const handleIncludeIdentity = useCallback( + () => openIdentitiesModal(WaveGroupIdentitiesModal.INCLUDE), + [openIdentitiesModal], + ); + + const handleExcludeIdentity = useCallback( + () => openIdentitiesModal(WaveGroupIdentitiesModal.EXCLUDE), + [openIdentitiesModal], + ); if (mutating) { - return ; + return ( + + + Updating wave group identities + + ); } return ( -
    - - {haveGroup && type !== WaveGroupType.ADMIN && ( - - )} -
    + <> + + + ); } diff --git a/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal.tsx b/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal.tsx new file mode 100644 index 0000000000..ec406b5097 --- /dev/null +++ b/components/waves/specs/groups/group/edit/WaveGroupManageIdentitiesModal.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FocusTrap } from "focus-trap-react"; +import { FormEvent, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useClickAway, useKeyPressEvent } from "react-use"; +import IdentitySearch, { + IdentitySearchSize, +} from "@/components/utils/input/identity/IdentitySearch"; + +export enum WaveGroupManageIdentitiesMode { + INCLUDE = "INCLUDE", + EXCLUDE = "EXCLUDE", +} + +export interface WaveGroupManageIdentitiesConfirmEvent { + readonly identity: string; + readonly mode: WaveGroupManageIdentitiesMode; +} + +export default function WaveGroupManageIdentitiesModal({ + mode, + onClose, + onConfirm, +}: { + readonly mode: WaveGroupManageIdentitiesMode; + readonly onClose: () => void; + readonly onConfirm: (event: WaveGroupManageIdentitiesConfirmEvent) => void; +}) { + const modalRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + useClickAway(modalRef, onClose); + useKeyPressEvent("Escape", (event: KeyboardEvent) => { + if (event.defaultPrevented) { + return; + } + + const activeElement = document.activeElement as HTMLElement | null; + if ( + activeElement && + modalRef.current?.contains(activeElement) && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.tagName === "SELECT" || + activeElement.isContentEditable || + activeElement.getAttribute("role") === "combobox") + ) { + return; + } + + onClose(); + }); + + const title = + mode === WaveGroupManageIdentitiesMode.INCLUDE + ? "Include identity" + : "Exclude identity"; + + const description = + mode === WaveGroupManageIdentitiesMode.INCLUDE + ? "Add an identity to this group's allow list." + : "Move an identity to this group's exclude list."; + + const [identity, setIdentity] = useState(null); + const actionLabel = + mode === WaveGroupManageIdentitiesMode.INCLUDE ? "Include" : "Exclude"; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (identity) { + onConfirm({ + identity, + mode, + }); + onClose(); + } + }; + + return createPortal( + modalRef.current ?? document.body, + }}> +
    +
    +
    +
    + +
    +
    +

    + {title} +

    +

    + {description} +

    +
    + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    +
    +
    , + document.body + ); +} diff --git a/components/waves/specs/groups/group/edit/WaveGroupRemove.tsx b/components/waves/specs/groups/group/edit/WaveGroupRemove.tsx index b2816b80fc..15b282ddaf 100644 --- a/components/waves/specs/groups/group/edit/WaveGroupRemove.tsx +++ b/components/waves/specs/groups/group/edit/WaveGroupRemove.tsx @@ -1,10 +1,9 @@ import { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; import { ApiWave } from "@/generated/models/ApiWave"; -import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; -import { convertWaveToUpdateWave } from "@/helpers/waves/waves.helpers"; import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; import CommonAnimationWrapper from "@/components/utils/animation/CommonAnimationWrapper"; -import { WaveGroupType } from "../WaveGroup"; +import { WaveGroupType } from "../WaveGroup.types"; +import { buildWaveUpdateBody } from "./buttons/utils/waveGroupEdit"; import WaveGroupRemoveModal from "./WaveGroupRemoveModal"; export default function WaveGroupRemove({ @@ -12,81 +11,23 @@ export default function WaveGroupRemove({ type, isEditOpen, setIsEditOpen, - onEdit, + onWaveUpdate, }: { readonly wave: ApiWave; readonly type: WaveGroupType; readonly isEditOpen: boolean; readonly setIsEditOpen: (isOpen: boolean) => void; - readonly onEdit: (body: ApiUpdateWaveRequest) => Promise; + readonly onWaveUpdate: ( + body: ApiUpdateWaveRequest, + opts?: { readonly skipAuth?: boolean }, + ) => Promise; }) { - const getBody = (): ApiUpdateWaveRequest => { - const originalBody = convertWaveToUpdateWave(wave); - switch (type) { - case WaveGroupType.VIEW: - return { - ...originalBody, - visibility: { - ...originalBody.visibility, - scope: { - ...originalBody.visibility.scope, - group_id: null, - }, - }, - }; - case WaveGroupType.DROP: - return { - ...originalBody, - participation: { - ...originalBody.participation, - scope: { - ...originalBody.participation.scope, - group_id: null, - }, - }, - }; - case WaveGroupType.VOTE: - return { - ...originalBody, - voting: { - ...originalBody.voting, - scope: { - ...originalBody.voting.scope, - group_id: null, - }, - }, - }; - case WaveGroupType.CHAT: - return { - ...originalBody, - chat: { - ...originalBody.chat, - scope: { - ...originalBody.chat.scope, - group_id: null, - }, - }, - }; - case WaveGroupType.ADMIN: - return { - ...originalBody, - wave: { - ...originalBody.wave, - admin_group: { - ...originalBody.wave.admin_group, - group_id: null, - }, - }, - }; - default: - assertUnreachable(type); - return originalBody; - } - }; + const getBody = (): ApiUpdateWaveRequest => + buildWaveUpdateBody(wave, type, null); const onRemove = async (): Promise => { const body = getBody(); - await onEdit(body); + await onWaveUpdate(body); }; return ( diff --git a/components/waves/specs/groups/group/edit/WaveGroupRemoveButton.tsx b/components/waves/specs/groups/group/edit/WaveGroupRemoveButton.tsx index 202093025b..1ff794f031 100644 --- a/components/waves/specs/groups/group/edit/WaveGroupRemoveButton.tsx +++ b/components/waves/specs/groups/group/edit/WaveGroupRemoveButton.tsx @@ -1,52 +1,96 @@ "use client"; -import { useState } from "react"; -import { ApiWave } from "@/generated/models/ApiWave"; -import { WaveGroupType } from "../WaveGroup"; +import { + forwardRef, + ReactNode, + useCallback, + useImperativeHandle, + useState, +} from "react"; +import { faCircleXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { WaveGroupType } from "../WaveGroup.types"; import WaveGroupRemove from "./WaveGroupRemove"; -import { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import type { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; -export default function WaveGroupRemoveButton({ - wave, - type, - onEdit, -}: { +export type WaveGroupRemoveButtonHandle = { + open: () => void; +}; + +interface WaveGroupRemoveButtonProps { readonly wave: ApiWave; readonly type: WaveGroupType; - readonly onEdit: (body: ApiUpdateWaveRequest) => Promise; -}) { + readonly onWaveUpdate: ( + body: ApiUpdateWaveRequest, + opts?: { readonly skipAuth?: boolean }, + ) => Promise; + readonly renderTrigger?: ((options: { readonly open: () => void }) => ReactNode) | null; +} + +const WaveGroupRemoveButton = forwardRef< + WaveGroupRemoveButtonHandle, + WaveGroupRemoveButtonProps +>(function WaveGroupRemoveButton( + { wave, type, onWaveUpdate, renderTrigger }, + ref, +) { const [isEditOpen, setIsEditOpen] = useState(false); - return ( -
    + const handleOpen = useCallback(() => { + setIsEditOpen(true); + }, []); + + useImperativeHandle( + ref, + () => ({ + open: handleOpen, + }), + [handleOpen], + ); + + const handleWaveUpdate = useCallback( + async (body: ApiUpdateWaveRequest) => { + await onWaveUpdate(body); + setIsEditOpen(false); + }, + [onWaveUpdate], + ); + + let triggerContent: ReactNode | null; + + if (renderTrigger === null) { + triggerContent = null; + } else if (renderTrigger) { + triggerContent = renderTrigger({ open: handleOpen }); + } else { + triggerContent = ( + ); + } + + return ( + <> + {triggerContent} { - setIsEditOpen(false); - await onEdit(body); - }} + onWaveUpdate={handleWaveUpdate} /> -
    + ); -} +}); + +export default WaveGroupRemoveButton; diff --git a/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.tsx b/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.tsx index 0a7dcddfa7..902fa113f0 100644 --- a/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.tsx +++ b/components/waves/specs/groups/group/edit/WaveGroupRemoveModal.tsx @@ -1,5 +1,7 @@ "use client"; +import { faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useRef } from "react"; import { createPortal } from "react-dom"; import { useClickAway, useKeyPressEvent } from "react-use"; @@ -26,19 +28,10 @@ export default function WaveGroupRemoveModal({
    - - - + />
    @@ -52,23 +45,11 @@ export default function WaveGroupRemoveModal({
    diff --git a/components/waves/specs/groups/group/edit/buttons/hooks/useWaveGroupEditButtonsController.ts b/components/waves/specs/groups/group/edit/buttons/hooks/useWaveGroupEditButtonsController.ts new file mode 100644 index 0000000000..411a65df46 --- /dev/null +++ b/components/waves/specs/groups/group/edit/buttons/hooks/useWaveGroupEditButtonsController.ts @@ -0,0 +1,776 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MutableRefObject, ReactNode } from "react"; +import type { TypeOptions } from "react-toastify"; +import { + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; +import { ApiGroupFilterDirection } from "@/generated/models/ApiGroupFilterDirection"; +import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { + createGroup, + publishGroup, + validateGroupPayload, + type ValidationIssue, + toErrorMessage, +} from "@/services/groups/groupMutations"; +import { WaveGroupType } from "../../../WaveGroup.types"; +import { + buildWaveUpdateBody, + getGroupIdFromUpdateBody, + getScopedGroup, + isGroupAuthor, +} from "../utils/waveGroupEdit"; + +const WAVE_GROUP_LABELS = { + VIEW: "View", + DROP: "Drop", + VOTE: "Vote", + CHAT: "Chat", + ADMIN: "Admin", +} satisfies Record; + +const normalizeIdentity = (identity: string): string => + identity.trim().toLowerCase(); + +const dedupeAddresses = (addresses: readonly string[]): string[] => + Array.from( + new Set( + addresses + .filter((addr): addr is string => typeof addr === "string") + .map((addr) => addr.trim().toLowerCase()) + .filter((addr) => addr.length > 0), + ), + ); + +const fetchIdentityGroupWallets = async ( + groupId: string, + identityGroupId: string | null, + signal?: AbortSignal, +): Promise => { + if (!identityGroupId) { + return []; + } + try { + const wallets = await commonApiFetch({ + endpoint: `groups/${groupId}/identity_groups/${identityGroupId}`, + signal, + }); + return dedupeAddresses(wallets); + } catch (error) { + console.warn( + `[WaveGroupEditButtons] Failed to load identity group ${identityGroupId} for group ${groupId}:`, + error, + ); + return []; + } +}; + +const waitWithAbort = async ( + ms: number, + signal: AbortSignal, +): Promise => { + if (signal.aborted) { + throw new DOMException("Request aborted", "AbortError"); + } + await new Promise((resolve, reject) => { + let timeoutId: ReturnType | null = null; + const onAbort = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + signal.removeEventListener("abort", onAbort); + reject(new DOMException("Request aborted", "AbortError")); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + timeoutId = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + }); +}; + +const cloneGroupPayload = (group: ApiGroupFull): ApiCreateGroup => ({ + name: group.name, + group: { + tdh: { ...group.group.tdh }, + rep: { ...group.group.rep }, + cic: { ...group.group.cic }, + level: { ...group.group.level }, + owns_nfts: group.group.owns_nfts.map((nft) => ({ ...nft })), + identity_addresses: null, + excluded_identity_addresses: null, + }, + is_private: group.is_private ?? false, +}); + +const createEmptyGroupPayload = (name: string): ApiCreateGroup => ({ + name, + group: { + tdh: { min: null, max: null }, + rep: { + min: null, + max: null, + direction: ApiGroupFilterDirection.Received, + user_identity: null, + category: null, + }, + cic: { + min: null, + max: null, + direction: ApiGroupFilterDirection.Received, + user_identity: null, + }, + level: { min: null, max: null }, + owns_nfts: [], + identity_addresses: null, + excluded_identity_addresses: null, + }, + is_private: false, +}); + +const buildDefaultGroupName = ( + wave: ApiWave, + type: WaveGroupType, + mode: WaveGroupIdentitiesModal, +): string => { + const typeLabel = WAVE_GROUP_LABELS[type] ?? "Group"; + const actionLabel = + mode === WaveGroupIdentitiesModal.INCLUDE ? "Include" : "Exclude"; + const waveName = wave.name?.trim(); + if (waveName) { + return `${waveName} ${typeLabel} ${actionLabel}`; + } + return `Wave ${wave.id} ${typeLabel} ${actionLabel}`; +}; + +const applyIdentityChangeToPayload = ( + payload: ApiCreateGroup, + normalizedIdentity: string, + mode: WaveGroupIdentitiesModal, +): ApiCreateGroup => { + + const includeSet = new Set( + dedupeAddresses(payload.group.identity_addresses ?? []), + ); + const excludeSet = new Set( + dedupeAddresses(payload.group.excluded_identity_addresses ?? []), + ); + + if (mode === WaveGroupIdentitiesModal.INCLUDE) { + excludeSet.delete(normalizedIdentity); + includeSet.add(normalizedIdentity); + } else { + includeSet.delete(normalizedIdentity); + excludeSet.add(normalizedIdentity); + } + + const nextIncludes = Array.from(includeSet); + const nextExcludes = Array.from(excludeSet); + + return { + ...payload, + group: { + ...payload.group, + identity_addresses: nextIncludes.length ? nextIncludes : null, + excluded_identity_addresses: nextExcludes.length ? nextExcludes : null, + }, + }; +}; + +const mapValidationToMessage = ( + issues: ValidationIssue[], + mode: WaveGroupIdentitiesModal, +): string => { + if (issues.includes("INCLUDE_LIMIT")) { + return "This group already contains the maximum number of included identities."; + } + if (issues.includes("EXCLUDE_LIMIT")) { + return "This group already contains the maximum number of excluded identities."; + } + if (issues.includes("NO_FILTERS")) { + return mode === WaveGroupIdentitiesModal.EXCLUDE + ? "You need to define at least one filter before excluding identities." + : "Unable to update the group with the selected identity."; + } + return "Unable to update the group with the selected identity."; +}; + +type ScopedGroup = ReturnType; +type IdentityModalPermissions = Readonly< + Record +>; + +const isIdentityActionAllowed = ( + mode: WaveGroupIdentitiesModal, + permissions: IdentityModalPermissions, + setToast: SetToast, +): boolean => { + if (permissions[mode]) { + return true; + } + setToast({ + type: "error", + message: + "You do not have permission to modify identities for this group.", + }); + return false; +}; + +const ensureAuthenticated = async ( + requestAuth: RequestAuth, + setToast: SetToast, +): Promise => { + const { success } = await requestAuth(); + if (success) { + return true; + } + setToast({ + type: "error", + message: "Failed to authenticate", + }); + return false; +}; + +interface BuildIdentityPayloadParams { + readonly scopedGroup: ScopedGroup; + readonly wave: ApiWave; + readonly type: WaveGroupType; + readonly mode: WaveGroupIdentitiesModal; + readonly fetchScopedGroupFull: () => Promise; + readonly loadIdentityGroupWallets: ( + groupId: string, + identityGroupId: string | null, + ) => Promise; +} + +const buildIdentityPayload = async ({ + scopedGroup, + wave, + type, + mode, + fetchScopedGroupFull, + loadIdentityGroupWallets, +}: BuildIdentityPayloadParams): Promise<{ + payload: ApiCreateGroup; + previousGroupId: string | null; +}> => { + if (!scopedGroup?.id) { + const groupName = buildDefaultGroupName(wave, type, mode); + return { + payload: createEmptyGroupPayload(groupName), + previousGroupId: null, + }; + } + + const groupFull = await fetchScopedGroupFull(); + if (!groupFull) { + throw new Error("Unable to load scoped group details."); + } + + const [includeWallets, excludeWallets] = await Promise.all([ + loadIdentityGroupWallets( + groupFull.id, + groupFull.group.identity_group_id, + ), + loadIdentityGroupWallets( + groupFull.id, + groupFull.group.excluded_identity_group_id, + ), + ]); + + const basePayload = cloneGroupPayload(groupFull); + return { + payload: { + ...basePayload, + group: { + ...basePayload.group, + identity_addresses: includeWallets.length ? includeWallets : null, + excluded_identity_addresses: excludeWallets.length + ? excludeWallets + : null, + }, + }, + previousGroupId: groupFull.id, + }; +}; + +const validatePayloadOrNotify = ( + payload: ApiCreateGroup, + mode: WaveGroupIdentitiesModal, + setToast: SetToast, +): boolean => { + const validation = validateGroupPayload(payload); + if (validation.valid) { + return true; + } + setToast({ + message: mapValidationToMessage(validation.issues, mode), + type: "error", + }); + return false; +}; + +interface PollGroupVisibilityParams { + readonly createdGroupId: string; + readonly waveId: string; + readonly abortControllersRef: MutableRefObject>; + readonly queryClient: QueryClient; +} + +const pollGroupVisibility = async ({ + createdGroupId, + waveId, + abortControllersRef, + queryClient, +}: PollGroupVisibilityParams): Promise => { + const pollController = new AbortController(); + abortControllersRef.current.add(pollController); + try { + const maxDelayMs = 2000; + const maxElapsedMs = 10000; + let delayMs = 200; + const pollStart = Date.now(); + + while (Date.now() - pollStart < maxElapsedMs) { + try { + const refreshed = await commonApiFetch({ + endpoint: `groups/${createdGroupId}`, + signal: pollController.signal, + }); + if (refreshed.visible) { + queryClient.setQueryData( + [QueryKey.GROUP, createdGroupId], + refreshed, + ); + return; + } + } catch (pollError) { + if ( + pollError instanceof DOMException && + pollError.name === "AbortError" + ) { + throw pollError; + } + } + + await waitWithAbort(delayMs, pollController.signal); + delayMs = Math.min(delayMs * 2, maxDelayMs); + } + + console.warn("[WaveGroupEditButtons] Group publish polling timed out", { + groupId: createdGroupId, + waveId, + }); + } finally { + pollController.abort(); + abortControllersRef.current.delete(pollController); + } +}; + +interface CreateAndPublishGroupParams { + readonly payload: ApiCreateGroup; + readonly previousGroupId: string | null; + readonly abortControllersRef: MutableRefObject>; + readonly queryClient: QueryClient; + readonly waveId: string; + readonly waveGroupType: WaveGroupType; +} + +const createAndPublishGroupWithVisibility = async ({ + payload, + previousGroupId, + abortControllersRef, + queryClient, + waveId, + waveGroupType, +}: CreateAndPublishGroupParams): Promise => { + const trimmedName = payload.name.trim(); + const createdGroup = await createGroup({ + payload: { + ...payload, + name: trimmedName, + }, + nameOverride: trimmedName, + }); + + const publishController = new AbortController(); + abortControllersRef.current.add(publishController); + try { + await publishGroup({ + id: createdGroup.id, + oldVersionId: previousGroupId, + signal: publishController.signal, + }); + } finally { + publishController.abort(); + abortControllersRef.current.delete(publishController); + } + + await pollGroupVisibility({ + createdGroupId: createdGroup.id, + waveId, + abortControllersRef, + queryClient, + }); + + console.info("[WaveGroupEditButtons] Published updated group", { + waveId, + waveGroupType, + previousGroupId, + newGroupId: createdGroup.id, + }); + + return createdGroup.id; +}; + +type RequestAuth = () => Promise<{ success: boolean }>; + +type SetToast = (options: { + readonly message: string | ReactNode; + readonly type: TypeOptions; +}) => void; + +interface UseWaveGroupEditButtonsControllerProps { + readonly haveGroup: boolean; + readonly wave: ApiWave; + readonly type: WaveGroupType; + readonly connectedProfile: ApiIdentity | null; + readonly requestAuth: RequestAuth; + readonly setToast: SetToast; + readonly onWaveCreated: () => void; +} + +export enum WaveGroupIdentitiesModal { + INCLUDE = "include", + EXCLUDE = "exclude", +} + +export interface WaveGroupEditButtonsController { + readonly mutating: boolean; + readonly canIncludeIdentity: boolean; + readonly canExcludeIdentity: boolean; + readonly canRemoveGroup: boolean; + readonly activeIdentitiesModal: WaveGroupIdentitiesModal | null; + readonly openIdentitiesModal: (modal: WaveGroupIdentitiesModal) => void; + readonly closeIdentitiesModal: () => void; + readonly updateWave: ( + body: ApiUpdateWaveRequest, + opts?: { readonly skipAuth?: boolean }, + ) => Promise; + readonly onIdentityConfirm: (event: Readonly<{ + identity: string; + mode: WaveGroupIdentitiesModal; + }>) => Promise; +} + +export const useWaveGroupEditButtonsController = ({ + haveGroup, + wave, + type, + connectedProfile, + requestAuth, + setToast, + onWaveCreated, +}: UseWaveGroupEditButtonsControllerProps): WaveGroupEditButtonsController => { + const [mutating, setMutating] = useState(false); + const [activeIdentitiesModal, setActiveIdentitiesModal] = + useState(null); + const scopedGroup = useMemo( + () => getScopedGroup(wave, type), + [wave, type], + ); + const queryClient = useQueryClient(); + const abortControllersRef = useRef(new Set()); + + useEffect(() => { + return () => { + abortControllersRef.current.forEach((controller) => controller.abort()); + abortControllersRef.current.clear(); + }; + }, []); + + useQuery({ + queryKey: [QueryKey.GROUP, scopedGroup?.id ?? ""], + enabled: !!scopedGroup?.id, + queryFn: async ({ signal }) => { + if (!scopedGroup?.id) { + throw new Error("Missing scoped group id"); + } + return await commonApiFetch({ + endpoint: `groups/${scopedGroup.id}`, + signal, + }); + }, + }); + + const fetchScopedGroupFull = useCallback(async (): Promise => { + if (!scopedGroup?.id) { + return null; + } + return await queryClient.ensureQueryData({ + queryKey: [QueryKey.GROUP, scopedGroup.id], + queryFn: async ({ signal }) => + await commonApiFetch({ + endpoint: `groups/${scopedGroup.id}`, + signal, + }), + }); + }, [queryClient, scopedGroup]); + + const loadIdentityGroupWallets = useCallback( + async ( + groupId: string, + identityGroupId: string | null, + ): Promise => { + if (!identityGroupId) { + return []; + } + return await queryClient.fetchQuery({ + queryKey: [ + QueryKey.GROUP_WALLET_GROUP_WALLETS, + { + group_id: groupId, + wallet_group_id: identityGroupId, + }, + ], + queryFn: async ({ signal }) => + await fetchIdentityGroupWallets(groupId, identityGroupId, signal), + }); + }, + [queryClient], + ); + + const isWaveAdmin = + wave.wave.authenticated_user_eligible_for_admin ?? false; + + const isAuthor = useMemo( + () => isGroupAuthor(scopedGroup, connectedProfile), + [scopedGroup, connectedProfile], + ); + + const canIncludeIdentity = isWaveAdmin || isAuthor; + const canExcludeIdentity = isWaveAdmin || isAuthor; + const canRemoveGroup = + haveGroup && type !== WaveGroupType.ADMIN; + + const identityModalPermissions = useMemo( + () => ({ + [WaveGroupIdentitiesModal.INCLUDE]: canIncludeIdentity, + [WaveGroupIdentitiesModal.EXCLUDE]: canExcludeIdentity, + }), + [canIncludeIdentity, canExcludeIdentity], + ); + + const editWaveMutation = useMutation({ + mutationFn: async (body: ApiUpdateWaveRequest) => + await commonApiPost({ + endpoint: `waves/${wave.id}`, + body, + }), + onSuccess: () => { + onWaveCreated(); + }, + onError: (error, body) => { + const groupId = body ? getGroupIdFromUpdateBody(body, type) : null; + console.error( + "[WaveGroupEditButtons] Wave update failed", + { + waveId: wave.id, + waveGroupType: type, + groupId, + error, + }, + ); + setToast({ + message: toErrorMessage(error), + type: "error", + }); + }, + onSettled: () => { + setMutating(false); + }, + }); + + const updateWave = useCallback( + async ( + body: ApiUpdateWaveRequest, + opts?: { readonly skipAuth?: boolean }, + ) => { + setMutating(true); + if (!opts?.skipAuth) { + const authenticated = await ensureAuthenticated( + requestAuth, + setToast, + ); + if (!authenticated) { + setMutating(false); + return; + } + } + await editWaveMutation.mutateAsync(body); + }, + [editWaveMutation, requestAuth, setToast], + ); + + useEffect(() => { + if ( + activeIdentitiesModal && + !identityModalPermissions[activeIdentitiesModal] + ) { + setActiveIdentitiesModal(null); + } + }, [activeIdentitiesModal, identityModalPermissions]); + + const openIdentitiesModal = useCallback( + (modal: WaveGroupIdentitiesModal) => { + setActiveIdentitiesModal(modal); + }, + [], + ); + + const closeIdentitiesModal = useCallback(() => { + setActiveIdentitiesModal(null); + }, []); + + const onIdentityConfirm = useCallback( + async ({ + identity, + mode, + }: Readonly<{ + identity: string; + mode: WaveGroupIdentitiesModal; + }>) => { + const normalizedIdentity = normalizeIdentity(identity); + if ( + !normalizedIdentity || + !isIdentityActionAllowed( + mode, + identityModalPermissions, + setToast, + ) + ) { + return; + } + + setMutating(true); + const needsWaveUpdate = !scopedGroup?.id; + let waveMutationTriggered = false; + try { + const authenticated = await ensureAuthenticated( + requestAuth, + setToast, + ); + if (!authenticated) { + return; + } + + const { payload, previousGroupId } = await buildIdentityPayload({ + scopedGroup, + wave, + type, + mode, + fetchScopedGroupFull, + loadIdentityGroupWallets, + }); + + const updatedPayload = applyIdentityChangeToPayload( + payload, + normalizedIdentity, + mode, + ); + + if (!validatePayloadOrNotify(updatedPayload, mode, setToast)) { + return; + } + + const newGroupId = await createAndPublishGroupWithVisibility({ + payload: updatedPayload, + previousGroupId, + abortControllersRef, + queryClient, + waveId: wave.id, + waveGroupType: type, + }); + + if (needsWaveUpdate) { + const updateBody = buildWaveUpdateBody( + wave, + type, + newGroupId, + ); + waveMutationTriggered = true; + await updateWave(updateBody, { skipAuth: true }); + } else { + onWaveCreated(); + } + + const successMessage = + mode === WaveGroupIdentitiesModal.INCLUDE + ? "Identity successfully included in the group." + : "Identity successfully excluded from the group."; + + setActiveIdentitiesModal(null); + setToast({ + message: successMessage, + type: "success", + }); + } catch (error) { + if (!waveMutationTriggered) { + if ( + error instanceof DOMException && + error.name === "AbortError" + ) { + console.info("[WaveGroupEditButtons] Identity update aborted"); + } else { + setToast({ + message: toErrorMessage(error), + type: "error", + }); + } + } + } finally { + if (!waveMutationTriggered) { + setMutating(false); + } + } + }, + [ + identityModalPermissions, + requestAuth, + scopedGroup, + setToast, + type, + wave, + onWaveCreated, + updateWave, + fetchScopedGroupFull, + loadIdentityGroupWallets, + abortControllersRef, + queryClient, + ], + ); + + return { + mutating, + canIncludeIdentity, + canExcludeIdentity, + canRemoveGroup, + activeIdentitiesModal, + openIdentitiesModal, + closeIdentitiesModal, + updateWave, + onIdentityConfirm, + }; +}; diff --git a/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupEditMenu.tsx b/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupEditMenu.tsx new file mode 100644 index 0000000000..4d00e001a9 --- /dev/null +++ b/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupEditMenu.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useMemo, useRef } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGear } from "@fortawesome/free-solid-svg-icons"; +import { CompactMenu, type CompactMenuItem } from "@/components/common/CompactMenu"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import { WaveGroupType } from "../../../WaveGroup.types"; +import WaveGroupEditButton, { + type WaveGroupEditButtonHandle, +} from "../../WaveGroupEditButton"; +import WaveGroupRemoveButton, { + type WaveGroupRemoveButtonHandle, +} from "../../WaveGroupRemoveButton"; + +const GROUP_OPTIONS_LABEL = "Group options"; + +interface WaveGroupEditMenuProps { + readonly wave: ApiWave; + readonly type: WaveGroupType; + readonly onWaveUpdate: ( + body: ApiUpdateWaveRequest, + opts?: { readonly skipAuth?: boolean }, + ) => Promise; + readonly hasGroup: boolean; + readonly canIncludeIdentity: boolean; + readonly canExcludeIdentity: boolean; + readonly canRemoveGroup: boolean; + readonly onIncludeIdentity: () => void; + readonly onExcludeIdentity: () => void; + readonly onChangeGroup?: () => void; + readonly onRemoveGroup?: () => void; +} + +interface WaveGroupEditMenuTriggerProps { + readonly label: string; +} + +function WaveGroupEditMenuTrigger({ label }: WaveGroupEditMenuTriggerProps) { + return ( + <> + {label} + + + ); +} + +export default function WaveGroupEditMenu({ + wave, + type, + onWaveUpdate, + hasGroup, + canIncludeIdentity, + canExcludeIdentity, + canRemoveGroup, + onIncludeIdentity, + onExcludeIdentity, + onChangeGroup, + onRemoveGroup, +}: WaveGroupEditMenuProps) { + const editButtonRef = useRef(null); + const removeButtonRef = useRef(null); + + const menuItems = useMemo(() => { + const items: CompactMenuItem[] = []; + const changeGroupLabel = hasGroup ? "Change group" : "Add group"; + + if (canIncludeIdentity) { + items.push({ + id: "include", + label: "Include identity", + onSelect: onIncludeIdentity, + }); + } + + if (canExcludeIdentity) { + items.push({ + id: "exclude", + label: "Exclude identity", + onSelect: onExcludeIdentity, + }); + } + + items.push({ + id: "change", + label: changeGroupLabel, + onSelect: () => { + if (onChangeGroup) { + onChangeGroup(); + return; + } + editButtonRef.current?.open(); + }, + }); + + if (canRemoveGroup) { + items.push({ + id: "remove", + label: "Remove group", + onSelect: () => { + if (onRemoveGroup) { + onRemoveGroup(); + return; + } + removeButtonRef.current?.open(); + }, + className: + "tw-text-red desktop-hover:hover:tw-text-red", + }); + } + + return items; + }, [ + canIncludeIdentity, + canExcludeIdentity, + onIncludeIdentity, + onExcludeIdentity, + onChangeGroup, + onRemoveGroup, + canRemoveGroup, + hasGroup, + ]); + + return ( +
    + } + aria-label={GROUP_OPTIONS_LABEL} + items={menuItems} + /> + + {canRemoveGroup ? ( + + ) : null} +
    + ); +} diff --git a/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupManageIdentitiesModals.tsx b/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupManageIdentitiesModals.tsx new file mode 100644 index 0000000000..beea589ede --- /dev/null +++ b/components/waves/specs/groups/group/edit/buttons/subcomponents/WaveGroupManageIdentitiesModals.tsx @@ -0,0 +1,36 @@ +"use client"; + +import WaveGroupManageIdentitiesModal, { + WaveGroupManageIdentitiesConfirmEvent, + WaveGroupManageIdentitiesMode, +} from "../../WaveGroupManageIdentitiesModal"; +import { WaveGroupIdentitiesModal } from "../hooks/useWaveGroupEditButtonsController"; + +interface WaveGroupManageIdentitiesModalsProps { + readonly activeModal: WaveGroupIdentitiesModal | null; + readonly onClose: () => void; + readonly onConfirm: (event: WaveGroupManageIdentitiesConfirmEvent) => void; +} + +export default function WaveGroupManageIdentitiesModals({ + activeModal, + onClose, + onConfirm, +}: WaveGroupManageIdentitiesModalsProps) { + if (activeModal === null) { + return null; + } + + const modalMode = + activeModal === WaveGroupIdentitiesModal.INCLUDE + ? WaveGroupManageIdentitiesMode.INCLUDE + : WaveGroupManageIdentitiesMode.EXCLUDE; + + return ( + + ); +} diff --git a/components/waves/specs/groups/group/edit/buttons/utils/waveGroupEdit.ts b/components/waves/specs/groups/group/edit/buttons/utils/waveGroupEdit.ts new file mode 100644 index 0000000000..4df444818c --- /dev/null +++ b/components/waves/specs/groups/group/edit/buttons/utils/waveGroupEdit.ts @@ -0,0 +1,81 @@ +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ApiGroup } from "@/generated/models/ApiGroup"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { WaveGroupType } from "../../../WaveGroup.types"; +import type { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import { convertWaveToUpdateWave } from "@/helpers/waves/waves.helpers"; +import { + getGroupIdByType, + updateGroupIdByType, +} from "../../utils/waveGroupUpdate"; + +export const clearGroupIdFromUpdateBody = ( + body: ApiUpdateWaveRequest, + type: WaveGroupType, +): ApiUpdateWaveRequest => updateGroupIdByType(body, type, null); + +export const buildWaveUpdateBody = ( + wave: ApiWave, + type: WaveGroupType, + groupId: string | null, +): ApiUpdateWaveRequest => { + const originalBody = convertWaveToUpdateWave(wave); + return updateGroupIdByType(originalBody, type, groupId); +}; + +export const getGroupIdFromUpdateBody = ( + body: ApiUpdateWaveRequest, + type: WaveGroupType, +): string | null => getGroupIdByType(body, type); + +export const getScopedGroup = ( + wave: ApiWave, + type: WaveGroupType, +): ApiGroup | null => { + switch (type) { + case WaveGroupType.VIEW: + return wave.visibility?.scope?.group ?? null; + case WaveGroupType.DROP: + return wave.participation?.scope?.group ?? null; + case WaveGroupType.VOTE: + return wave.voting?.scope?.group ?? null; + case WaveGroupType.CHAT: + return wave.chat?.scope?.group ?? null; + case WaveGroupType.ADMIN: + return wave.wave?.admin_group?.group ?? null; + default: + return null; + } +}; + +export const isGroupAuthor = ( + scopedGroup: ApiGroup | null, + connectedProfile: ApiIdentity | null, +): boolean => { + if (!scopedGroup || !connectedProfile) { + return false; + } + + const groupAuthorId = + scopedGroup.author?.id !== undefined && scopedGroup.author?.id !== null + ? String(scopedGroup.author.id) + : null; + + const userId = + connectedProfile.id !== undefined && connectedProfile.id !== null + ? String(connectedProfile.id) + : null; + + if (groupAuthorId && userId && groupAuthorId === userId) { + return true; + } + + const groupAuthorHandle = scopedGroup.author?.handle?.toLowerCase(); + const userHandle = connectedProfile.handle?.toLowerCase(); + + if (!groupAuthorHandle || !userHandle) { + return false; + } + + return groupAuthorHandle === userHandle; +}; diff --git a/components/waves/specs/groups/group/edit/utils/waveGroupUpdate.ts b/components/waves/specs/groups/group/edit/utils/waveGroupUpdate.ts new file mode 100644 index 0000000000..95c1c3e73d --- /dev/null +++ b/components/waves/specs/groups/group/edit/utils/waveGroupUpdate.ts @@ -0,0 +1,78 @@ +import type { ApiUpdateWaveRequest } from "@/generated/models/ApiUpdateWaveRequest"; +import { WaveGroupType } from "../../WaveGroup.types"; + +const waveGroupUpdatePaths = { + [WaveGroupType.VIEW]: ["visibility", "scope", "group_id"], + [WaveGroupType.DROP]: ["participation", "scope", "group_id"], + [WaveGroupType.VOTE]: ["voting", "scope", "group_id"], + [WaveGroupType.CHAT]: ["chat", "scope", "group_id"], + [WaveGroupType.ADMIN]: ["wave", "admin_group", "group_id"], +} as const satisfies Record; + +const toRecord = (value: unknown): Record => { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record; + } + return {}; +}; + +const cloneWithValueAtPath = ( + body: ApiUpdateWaveRequest, + path: readonly string[], + value: string | null, +): ApiUpdateWaveRequest => { + const clone: ApiUpdateWaveRequest = { ...body }; + let currentSource: Record | undefined = + body as unknown as Record; + let currentTarget: Record = + clone as unknown as Record; + + for (let index = 0; index < path.length; index += 1) { + const segment = path[index]; + if (index === path.length - 1) { + currentTarget[segment] = value; + break; + } + + const nextSource = toRecord(currentSource?.[segment]); + const nextTarget = { ...nextSource }; + + currentTarget[segment] = nextTarget; + currentSource = nextSource; + currentTarget = nextTarget; + } + + return clone; +}; + +export const updateGroupIdByType = ( + body: ApiUpdateWaveRequest, + type: WaveGroupType, + groupId: string | null, +): ApiUpdateWaveRequest => { + const path = waveGroupUpdatePaths[type]; + return cloneWithValueAtPath(body, path, groupId); +}; + +export const getGroupIdByType = ( + body: ApiUpdateWaveRequest, + type: WaveGroupType, +): string | null => { + const path = waveGroupUpdatePaths[type]; + let current: unknown = body; + + for (const segment of path) { + if ( + !current || + typeof current !== "object" || + Array.isArray(current) + ) { + return null; + } + current = (current as Record)[segment]; + } + + return typeof current === "string" || current === null ? current : null; +}; + +export { waveGroupUpdatePaths }; diff --git a/helpers/WalletHelpers.ts b/helpers/WalletHelpers.ts new file mode 100644 index 0000000000..82d395df88 --- /dev/null +++ b/helpers/WalletHelpers.ts @@ -0,0 +1,41 @@ +export const normaliseWalletList = (list: readonly string[]): string[] => + list.map((wallet) => wallet.trim().toLowerCase()).sort((a, b) => a.localeCompare(b)); + +export const walletListsMatch = ( + lhs: readonly string[] | null, + rhs: readonly string[] | null +): boolean => { + if (lhs === rhs) return true; + if (!lhs || !rhs) return lhs === rhs; + if (lhs.length !== rhs.length) return false; + const a = lhs.map((wallet) => wallet.trim().toLowerCase()); + const b = rhs.map((wallet) => wallet.trim().toLowerCase()); + const seen = new Map(); + for (const wallet of a) { + seen.set(wallet, (seen.get(wallet) ?? 0) + 1); + } + for (const wallet of b) { + const nextCount = (seen.get(wallet) ?? 0) - 1; + if (nextCount < 0) { + return false; + } + if (nextCount === 0) { + seen.delete(wallet); + } else { + seen.set(wallet, nextCount); + } + } + return seen.size === 0; +}; + +export const dedupeWallets = (wallets: readonly string[]): string[] => { + const seen = new Set(); + return wallets.filter((wallet) => { + const normalised = wallet.trim().toLowerCase(); + if (seen.has(normalised)) { + return false; + } + seen.add(normalised); + return true; + }); +}; diff --git a/hooks/groups/useGroupMutations.ts b/hooks/groups/useGroupMutations.ts new file mode 100644 index 0000000000..9306de3178 --- /dev/null +++ b/hooks/groups/useGroupMutations.ts @@ -0,0 +1,354 @@ +import { useCallback } from "react"; +import { useMutation } from "@tanstack/react-query"; +import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import { + createGroup, + hideGroup, + publishGroup, + validateGroupPayload as validateGroupPayloadLocal, + toErrorMessage, +} from "@/services/groups/groupMutations"; +import type { + ValidationResult as GroupValidationResult, +} from "@/services/groups/groupMutations"; + +export interface SubmitArgs { + readonly payload: ApiCreateGroup; + readonly previousGroup?: ApiGroupFull | null; + readonly currentHandle?: string | null; + readonly publish?: boolean; +} + +export type SubmitResult = + | { + readonly ok: true; + readonly group: ApiGroupFull; + readonly published: boolean; + } + | { + readonly ok: false; + readonly reason: "validation" | "auth" | "api" | "busy"; + readonly error: string; + readonly validation?: GroupValidationResult; + }; + +export interface TestArgs { + readonly payload: ApiCreateGroup; + readonly nameFallback: string; +} + +export type TestResult = + | { + readonly ok: true; + readonly group: ApiGroupFull; + } + | { + readonly ok: false; + readonly reason: "validation" | "auth" | "api" | "busy"; + readonly error: string; + readonly validation?: GroupValidationResult; + }; + +interface UseGroupMutationsArgs { + readonly requestAuth: () => Promise<{ success: boolean }>; + readonly onGroupCreate?: () => void; +} + +export interface UpdateVisibilityArgs { + readonly groupId: string; + readonly visible: boolean; + readonly oldVersionId?: string | null; + readonly skipAuth?: boolean; +} + +export type UpdateVisibilityResult = + | { + readonly ok: true; + readonly groupId: string; + readonly visible: boolean; + readonly group?: ApiGroupFull; + } + | { + readonly ok: false; + readonly reason: "auth" | "api" | "busy"; + readonly error: string; + }; + +const resolveOldVersionId = ({ + previousGroup, + currentHandle, +}: { + readonly previousGroup?: ApiGroupFull | null; + readonly currentHandle?: string | null; +}): string | null => { + if (!previousGroup?.id) { + return null; + } + + const previousHandle = previousGroup.created_by?.handle; + if (!previousHandle || !currentHandle) { + return null; + } + + return previousHandle.toLowerCase() === currentHandle.toLowerCase() + ? previousGroup.id + : null; +}; + +export { GROUP_INCLUDE_LIMIT, GROUP_EXCLUDE_LIMIT } from "@/services/groups/groupMutations"; +export { validateGroupPayload } from "@/services/groups/groupMutations"; +export type { ValidationIssue, ValidationResult } from "@/services/groups/groupMutations"; + +export const useGroupMutations = ({ + requestAuth, + onGroupCreate, +}: UseGroupMutationsArgs) => { + const createGroupMutation = useMutation({ + mutationFn: async ({ + payload, + nameOverride, + }: { + readonly payload: ApiCreateGroup; + readonly nameOverride?: string; + }) => await createGroup({ payload, nameOverride }), + }); + + const publishGroupMutation = useMutation({ + mutationFn: async ({ + id, + oldVersionId, + }: { + readonly id: string; + readonly oldVersionId: string | null; + }) => await publishGroup({ id, oldVersionId }), + }); + + const hideGroupMutation = useMutation({ + mutationFn: async (groupId: string) => await hideGroup({ id: groupId }), + }); + + const testGroupMutation = useMutation({ + mutationFn: async ({ + payload, + nameOverride, + }: { + readonly payload: ApiCreateGroup; + readonly nameOverride?: string; + }) => await createGroup({ payload, nameOverride }), + }); + + const updateVisibility = useCallback( + async ({ + groupId, + visible, + oldVersionId = null, + skipAuth = false, + }: UpdateVisibilityArgs): Promise => { + const isPublishing = visible; + const isPending = isPublishing + ? publishGroupMutation.isPending + : hideGroupMutation.isPending; + + if (isPending) { + return { + ok: false, + reason: "busy", + error: "Another group action is already running.", + }; + } + + if (!skipAuth) { + const authResult = await requestAuth(); + if (!authResult.success) { + return { + ok: false, + reason: "auth", + error: "Authentication was cancelled.", + }; + } + } + + let updatedGroup: ApiGroupFull | undefined; + try { + if (visible) { + updatedGroup = await publishGroupMutation.mutateAsync({ + id: groupId, + oldVersionId, + }); + onGroupCreate?.(); + } else { + await hideGroupMutation.mutateAsync(groupId); + } + } catch (error) { + return { + ok: false, + reason: "api", + error: toErrorMessage(error), + }; + } + + return { + ok: true, + groupId, + visible, + ...(updatedGroup ? { group: updatedGroup } : {}), + }; + }, + [hideGroupMutation, onGroupCreate, publishGroupMutation, requestAuth] + ); + + const submit = useCallback( + async ({ + payload, + previousGroup = null, + currentHandle = null, + publish = true, + }: SubmitArgs): Promise => { + if (createGroupMutation.isPending || publishGroupMutation.isPending) { + return { + ok: false, + reason: "busy", + error: "Another group action is already running.", + }; + } + + const trimmedName = payload.name.trim(); + if (!trimmedName.length) { + return { + ok: false, + reason: "validation", + error: "Please name your group.", + }; + } + + const validation = validateGroupPayloadLocal(payload); + if (!validation.valid) { + return { + ok: false, + reason: "validation", + error: "Group configuration is invalid.", + validation, + }; + } + + const authResult = await requestAuth(); + if (!authResult.success) { + return { + ok: false, + reason: "auth", + error: "Authentication was cancelled.", + }; + } + + let created: ApiGroupFull; + try { + created = await createGroupMutation.mutateAsync({ + payload, + nameOverride: trimmedName, + }); + } catch (error) { + return { + ok: false, + reason: "api", + error: toErrorMessage(error), + }; + } + + if (!publish) { + return { + ok: true, + group: created, + published: false, + }; + } + + const oldVersionId = resolveOldVersionId({ previousGroup, currentHandle }); + + const visibilityResult = await updateVisibility({ + groupId: created.id, + visible: true, + oldVersionId, + skipAuth: true, + }); + + if (!visibilityResult.ok) { + return { + ok: false, + reason: visibilityResult.reason, + error: visibilityResult.error, + }; + } + + return { + ok: true, + group: visibilityResult.group ?? created, + published: true, + }; + }, + [createGroupMutation, publishGroupMutation, requestAuth, updateVisibility] + ); + + const runTest = useCallback( + async ({ payload, nameFallback }: TestArgs): Promise => { + if (testGroupMutation.isPending) { + return { + ok: false, + reason: "busy", + error: "Another group test is already running.", + }; + } + + const trimmedName = payload.name.trim(); + const effectiveName = trimmedName.length ? trimmedName : nameFallback; + + const validation = validateGroupPayloadLocal(payload); + if (!validation.valid) { + return { + ok: false, + reason: "validation", + error: "Group configuration is invalid.", + validation, + }; + } + + const authResult = await requestAuth(); + if (!authResult.success) { + return { + ok: false, + reason: "auth", + error: "Authentication was cancelled.", + }; + } + + try { + const created = await testGroupMutation.mutateAsync({ + payload, + nameOverride: effectiveName, + }); + return { + ok: true, + group: created, + }; + } catch (error) { + return { + ok: false, + reason: "api", + error: toErrorMessage(error), + }; + } + }, + [requestAuth, testGroupMutation] + ); + + return { + validate: validateGroupPayloadLocal, + submit, + runTest, + updateVisibility, + isSubmitting: createGroupMutation.isPending || publishGroupMutation.isPending, + isTesting: testGroupMutation.isPending, + isUpdatingVisibility: + publishGroupMutation.isPending || hideGroupMutation.isPending, + }; +}; diff --git a/hooks/waves/useWaveScopePermissions.ts b/hooks/waves/useWaveScopePermissions.ts new file mode 100644 index 0000000000..aa1a18ce2a --- /dev/null +++ b/hooks/waves/useWaveScopePermissions.ts @@ -0,0 +1,99 @@ +import { useMemo } from "react"; +import { useWaveData } from "@/hooks/useWaveData"; +import { useWaveEligibility } from "@/contexts/wave/WaveEligibilityContext"; +import { useAuth } from "@/components/auth/Auth"; +import { isGroupAuthor as computeIsGroupAuthor } from "@/components/waves/specs/groups/group/edit/buttons/utils/waveGroupEdit"; +import { ApiWaveScope } from "@/generated/models/ApiWaveScope"; +import { ApiGroup } from "@/generated/models/ApiGroup"; +import { ApiWave } from "@/generated/models/ApiWave"; +import { ApiIdentity } from "@/generated/models/ApiIdentity"; + +interface WaveScopeSummary { + readonly hasGroup: boolean; + readonly group: ApiGroup | null; + readonly isGroupAuthor: boolean; + /** Convenience flag: true when the user created the group or there is no group yet */ + readonly isGroupAuthorOrNoGroup: boolean; +} + +interface UseWaveScopePermissionsResult { + readonly isLoading: boolean; + readonly isFetching: boolean; + readonly isWaveAdmin: boolean; + readonly hasEligibility: boolean; + readonly eligibilityUpdatedAt: number | null; + readonly wave: ApiWave | undefined; + readonly scopes: { + readonly view: WaveScopeSummary; + readonly drop: WaveScopeSummary; + readonly vote: WaveScopeSummary; + readonly chat: WaveScopeSummary; + } | null; +} + +const buildScopeSummary = ( + scope: ApiWaveScope, + connectedProfile: ApiIdentity | null +): WaveScopeSummary => { + const group = scope.group ?? null; + const hasGroup = group !== null; + const isGroupAuthor = computeIsGroupAuthor(group, connectedProfile); + + return { + hasGroup, + group, + isGroupAuthor, + isGroupAuthorOrNoGroup: isGroupAuthor || !hasGroup, + }; +}; + +export function useWaveScopePermissions( + waveId: string | null | undefined = null +): UseWaveScopePermissionsResult { + const { data: wave, isLoading, isFetching } = useWaveData({ + waveId: waveId ?? null, + }); + const { getEligibility } = useWaveEligibility(); + const { connectedProfile } = useAuth(); + + const eligibility = waveId ? getEligibility(waveId) : null; + + const scopes = useMemo(() => { + if (!wave) { + return null; + } + + return { + view: buildScopeSummary( + wave.visibility.scope, + connectedProfile + ), + drop: buildScopeSummary( + wave.participation.scope, + connectedProfile + ), + vote: buildScopeSummary( + wave.voting.scope, + connectedProfile + ), + chat: buildScopeSummary( + wave.chat.scope, + connectedProfile + ), + }; + }, [wave, connectedProfile, connectedProfile?.id, connectedProfile?.handle]); + + const isWaveAdmin = eligibility?.authenticated_user_admin ?? false; + + return { + isLoading, + isFetching, + isWaveAdmin, + hasEligibility: !!eligibility, + eligibilityUpdatedAt: eligibility?.lastUpdated ?? null, + wave, + scopes, + }; +} + +export default useWaveScopePermissions; diff --git a/i18n/messages.ts b/i18n/messages.ts new file mode 100644 index 0000000000..03c04f428d --- /dev/null +++ b/i18n/messages.ts @@ -0,0 +1,4 @@ +export const TAB_TOGGLE_WITH_OVERFLOW_MESSAGES = { + overflowFallbackLabel: "More", + overflowMenuAriaLabel: "More tabs", +} as const; diff --git a/services/groups/groupMutations.ts b/services/groups/groupMutations.ts new file mode 100644 index 0000000000..74672f8fdc --- /dev/null +++ b/services/groups/groupMutations.ts @@ -0,0 +1,185 @@ +import { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; +import { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import { commonApiPost } from "@/services/api/common-api"; + +export const GROUP_INCLUDE_LIMIT = 10000; +export const GROUP_EXCLUDE_LIMIT = 1000; + +export type ValidationIssue = + | "INCLUDE_LIMIT" + | "EXCLUDE_LIMIT" + | "NO_FILTERS" + | "INVALID_LEVEL_RANGE" + | "INVALID_TDH_RANGE" + | "INVALID_REP_RANGE" + | "INVALID_CIC_RANGE"; + +export interface ValidationResult { + readonly valid: boolean; + readonly issues: ValidationIssue[]; +} + +export const toErrorMessage = (error: unknown): string => { + if (typeof error === "string") { + return error; + } + if (error instanceof Error && error.message) { + return error.message; + } + return "Something went wrong"; +}; + +export const sanitiseGroupPayload = ( + payload: ApiCreateGroup, + name: string +): ApiCreateGroup => ({ + ...payload, + name, + group: { + ...payload.group, + owns_nfts: [...payload.group.owns_nfts], + identity_addresses: + payload.group.identity_addresses?.length + ? [...payload.group.identity_addresses] + : null, + excluded_identity_addresses: + payload.group.excluded_identity_addresses?.length + ? [...payload.group.excluded_identity_addresses] + : null, + }, +}); + +export const validateGroupPayload = ( + payload: ApiCreateGroup +): ValidationResult => { + const issues: ValidationIssue[] = []; + const includeCount = payload.group.identity_addresses?.length ?? 0; + const excludeCount = payload.group.excluded_identity_addresses?.length ?? 0; + + if (includeCount > GROUP_INCLUDE_LIMIT) { + issues.push("INCLUDE_LIMIT"); + } + + if (excludeCount > GROUP_EXCLUDE_LIMIT) { + issues.push("EXCLUDE_LIMIT"); + } + + const hasIncludeWallets = includeCount > 0; + const hasExcludeWallets = excludeCount > 0; + const hasLevel = + payload.group.level.min !== null || payload.group.level.max !== null; + const hasTdh = + payload.group.tdh.min !== null || payload.group.tdh.max !== null; + const hasRep = + payload.group.rep.min !== null || + payload.group.rep.max !== null || + payload.group.rep.user_identity !== null || + payload.group.rep.category !== null; + const hasCic = + payload.group.cic.min !== null || + payload.group.cic.max !== null || + payload.group.cic.user_identity !== null; + const hasNfts = payload.group.owns_nfts.length > 0; + + if ( + !( + hasIncludeWallets || + hasExcludeWallets || + hasLevel || + hasTdh || + hasRep || + hasCic || + hasNfts + ) + ) { + issues.push("NO_FILTERS"); + } + + if ( + payload.group.level.min !== null && + payload.group.level.max !== null && + payload.group.level.min > payload.group.level.max + ) { + issues.push("INVALID_LEVEL_RANGE"); + } + + if ( + payload.group.tdh.min !== null && + payload.group.tdh.max !== null && + payload.group.tdh.min > payload.group.tdh.max + ) { + issues.push("INVALID_TDH_RANGE"); + } + + if ( + payload.group.rep.min !== null && + payload.group.rep.max !== null && + payload.group.rep.min > payload.group.rep.max + ) { + issues.push("INVALID_REP_RANGE"); + } + + if ( + payload.group.cic.min !== null && + payload.group.cic.max !== null && + payload.group.cic.min > payload.group.cic.max + ) { + issues.push("INVALID_CIC_RANGE"); + } + + return { + valid: issues.length === 0, + issues, + }; +}; + +export const createGroup = async ({ + payload, + nameOverride, +}: { + readonly payload: ApiCreateGroup; + readonly nameOverride?: string; +}): Promise => { + const effectiveName = (nameOverride ?? payload.name).trim(); + if (!effectiveName) { + throw new Error("Group name cannot be empty"); + } + const body = sanitiseGroupPayload(payload, effectiveName); + + return await commonApiPost({ + endpoint: `groups`, + body, + }); +}; + +export const publishGroup = async ({ + id, + oldVersionId, + signal, +}: { + readonly id: string; + readonly oldVersionId: string | null; + readonly signal?: AbortSignal; +}): Promise => + await commonApiPost< + { visible: true; old_version_id: string | null }, + ApiGroupFull + >({ + endpoint: `groups/${id}/visible`, + body: { visible: true, old_version_id: oldVersionId }, + signal, + }); + +export const hideGroup = async ({ + id, + signal, +}: { + readonly id: string; + readonly signal?: AbortSignal; +}): Promise => { + return await commonApiPost<{ visible: false }, ApiGroupFull>({ + endpoint: `groups/${id}/visible`, + body: { visible: false }, + signal, + }); +};