diff --git a/__tests__/components/waves/create-wave/CreateWave.test.tsx b/__tests__/components/waves/create-wave/CreateWave.test.tsx index 8cdd1ec40f..5d253e380d 100644 --- a/__tests__/components/waves/create-wave/CreateWave.test.tsx +++ b/__tests__/components/waves/create-wave/CreateWave.test.tsx @@ -218,13 +218,6 @@ describe("CreateWave", () => { selectedOutcomeType: null, errors: [], groupsCache: {}, - groupBuilders: { - CAN_VIEW: {}, - CAN_DROP: {}, - CAN_VOTE: {}, - CAN_CHAT: {}, - ADMIN: {}, - }, setOverview: jest.fn(), setDates: jest.fn(), setDrops: jest.fn(), @@ -233,12 +226,6 @@ describe("CreateWave", () => { onStep: jest.fn(), onOutcomeTypeChange: jest.fn(), onGroupSelect: jest.fn(), - setGroupBuilderPanel: jest.fn(), - setGroupBuilderRule: jest.fn(), - setGroupBuilderDraft: jest.fn(), - addGroupBuilderIdentity: jest.fn(), - removeGroupBuilderIdentity: jest.fn(), - resetGroupBuilder: jest.fn(), onVotingTypeChange: jest.fn(), onCategoryChange: jest.fn(), onProfileIdChange: jest.fn(), diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx index 835f20229b..53b8509bc7 100644 --- a/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx +++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx @@ -84,18 +84,6 @@ describe("CreateWaveGroup", () => { onInlineGroupCreate: mockOnInlineGroupCreate, groupsCache: {}, groups: defaultGroups, - groupBuilder: { - draft: { group: {} }, - identities: [], - panel: "actions", - activeRule: null, - } as any, - setGroupBuilderPanel: jest.fn(), - setGroupBuilderRule: jest.fn(), - setGroupBuilderDraft: jest.fn(), - addGroupBuilderIdentity: jest.fn(), - removeGroupBuilderIdentity: jest.fn(), - resetGroupBuilder: jest.fn(), setDropsAdminCanDelete: mockSetDropsAdminCanDelete, }; @@ -127,6 +115,15 @@ describe("CreateWaveGroup", () => { expect(inlinePanelProps.selectedGroup).toEqual(exampleGroup); }); + it("passes the suggested group name and simplified callbacks to the inline panel", () => { + renderComponent(); + + expect(inlinePanelProps.suggestedName).toBe("Test Wave Who can drop"); + expect(inlinePanelProps.onChange).toBe(mockOnGroupSelect); + expect(inlinePanelProps.onCreateGroup).toBe(mockOnInlineGroupCreate); + expect(inlinePanelProps.groupBuilder).toBeUndefined(); + }); + it("shows the chat toggle for non-chat waves when editing chat scope", async () => { const user = userEvent.setup(); renderComponent({ diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx index b340c8b2be..6915f11016 100644 --- a/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx +++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx @@ -1,16 +1,8 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { CommunityMemberMinimal } from "@/entities/IProfile"; import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; import CreateWaveGroupInlinePanel from "@/components/waves/create-wave/groups/CreateWaveGroupInlinePanel"; -import { - createEmptyInlineGroupPayload, - createInitialInlineGroupBuilderState, - type CreateWaveInlineGroupBuilderState, - type CreateWaveInlineGroupPanel, - type CreateWaveInlineGroupRuleType, -} from "@/components/waves/create-wave/groups/createWaveInlineGroupBuilder"; jest.mock( "@/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities", @@ -69,6 +61,9 @@ jest.mock( > select group + ); } @@ -81,8 +76,18 @@ jest.mock("@/components/groups/page/create/config/GroupCreateLevel", () => { }); jest.mock("@/components/groups/page/create/config/GroupCreateTDH", () => { - return function MockGroupCreateTDH() { - return
TDH
; + return function MockGroupCreateTDH(props: any) { + return ( +
+ TDH + +
+ ); }; }); @@ -93,8 +98,18 @@ jest.mock("@/components/groups/page/create/config/GroupCreateCIC", () => { }); jest.mock("@/components/groups/page/create/config/GroupCreateRep", () => { - return function MockGroupCreateRep() { - return
Rep
; + return function MockGroupCreateRep(props: any) { + return ( +
+ Rep + +
+ ); }; }); @@ -113,118 +128,56 @@ jest.mock("@/components/groups/page/create/config/nfts/GroupCreateNfts", () => { }; }); -const exampleIdentity: CommunityMemberMinimal = { - profile_id: "profile-1", - handle: "alpha", - normalised_handle: "alpha", - primary_wallet: "0xABC", - display: "Alpha", - tdh: 0, - level: 0, - cic_rating: 0, - wallet: "0xABC", - pfp: null, -}; - const createdGroup: ApiGroupFull = { id: "group-created", name: "Created Group", created_by: { handle: "builder" }, } as ApiGroupFull; -function TestHarness({ - initialBuilder = createInitialInlineGroupBuilderState(), - onGroupSelect = jest.fn(), - onInlineGroupCreate = jest.fn().mockResolvedValue(createdGroup), +function renderInlinePanel({ + suggestedName = "My Wave Who can view", + onChange = jest.fn(), + onCreateGroup = jest.fn().mockResolvedValue(createdGroup), selectedGroup = null, disabled = false, + allowGroupClear = true, }: { - readonly initialBuilder?: CreateWaveInlineGroupBuilderState; - readonly onGroupSelect?: jest.Mock; - readonly onInlineGroupCreate?: jest.Mock; + readonly suggestedName?: string; + readonly onChange?: jest.Mock; + readonly onCreateGroup?: jest.Mock; readonly selectedGroup?: ApiGroupFull | null; readonly disabled?: boolean; -}) { - const [builder, setBuilder] = - React.useState(initialBuilder); - const [currentGroup, setCurrentGroup] = React.useState( - selectedGroup - ); - - const updatePanel = (panel: CreateWaveInlineGroupPanel) => { - setBuilder((prev) => ({ - ...prev, - panel, - })); - }; + readonly allowGroupClear?: boolean; +} = {}) { + const initialSelectedGroup = selectedGroup; - const updateRule = (rule: CreateWaveInlineGroupRuleType | null) => { - setBuilder((prev) => ({ - ...prev, - activeRule: rule, - panel: rule ? "rule-editor" : prev.panel, - })); - }; + function ControlledPanel() { + const [currentGroup, setCurrentGroup] = React.useState( + () => initialSelectedGroup + ); - return ( - { - setCurrentGroup(group); - onGroupSelect(group); - }} - onInlineGroupCreate={onInlineGroupCreate} - setGroupBuilderPanel={updatePanel} - setGroupBuilderRule={updateRule} - setGroupBuilderDraft={(draft) => - setBuilder((prev) => ({ - ...prev, - draft, - })) - } - addGroupBuilderIdentity={(identity) => - setBuilder((prev) => ({ - ...prev, - identities: [identity], - draft: { - ...prev.draft, - group: { - ...prev.draft.group, - identity_addresses: [ - (identity.primary_wallet ?? identity.wallet).toLowerCase(), - ], - }, - }, - })) - } - removeGroupBuilderIdentity={() => - setBuilder((prev) => ({ - ...prev, - identities: [], - draft: { - ...prev.draft, - group: { - ...prev.draft.group, - identity_addresses: null, - }, - }, - })) - } - resetGroupBuilder={() => - setBuilder(createInitialInlineGroupBuilderState()) - } - /> - ); + return ( + { + setCurrentGroup(group); + onChange(group); + }} + onCreateGroup={onCreateGroup} + /> + ); + } + + return render(); } describe("CreateWaveGroupInlinePanel", () => { it("renders the current state and primary actions", () => { - render(); + renderInlinePanel(); expect(screen.getByText("Current state")).toBeInTheDocument(); expect(screen.getByText("Anyone")).toBeInTheDocument(); @@ -241,7 +194,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("opens the identity panel", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add identity" })); @@ -256,7 +209,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("returns to options when the active identity pill is clicked", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add identity" })); await user.click(screen.getByRole("button", { name: "Add identity" })); @@ -269,7 +222,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("opens a quick rule editor", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add rule" })); await user.click(screen.getByRole("button", { name: "TDH" })); @@ -290,7 +243,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("returns to rule options when the active rule pill is clicked", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add rule" })); await user.click(screen.getByRole("button", { name: "TDH" })); @@ -304,7 +257,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("shows all rule options without an extra more-rules step", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add rule" })); @@ -324,7 +277,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("returns to options when the active existing group pill is clicked", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click( screen.getByRole("button", { name: "Use existing group" }) @@ -346,15 +299,15 @@ describe("CreateWaveGroupInlinePanel", () => { it("returns to the actions view after selecting an existing group", async () => { const user = userEvent.setup(); - const onGroupSelect = jest.fn(); - render(); + const onChange = jest.fn(); + renderInlinePanel({ onChange }); await user.click( screen.getByRole("button", { name: "Use existing group" }) ); await user.click(screen.getByRole("button", { name: "select group" })); - expect(onGroupSelect).toHaveBeenCalledWith( + expect(onChange).toHaveBeenCalledWith( expect.objectContaining({ name: "Selected Group" }) ); expect( @@ -362,57 +315,69 @@ describe("CreateWaveGroupInlinePanel", () => { ).toBeInTheDocument(); }); - it("creates and attaches a valid inline group draft", async () => { + it("returns null when clearing an existing group", async () => { const user = userEvent.setup(); - const onGroupSelect = jest.fn(); - const onInlineGroupCreate = jest.fn().mockResolvedValue(createdGroup); - const draft = createEmptyInlineGroupPayload(); - draft.group.rep = { - ...draft.group.rep, - min: 5, - }; + const onChange = jest.fn(); + renderInlinePanel({ + onChange, + selectedGroup: { id: "group-1", name: "Existing Group" } as ApiGroupFull, + }); - render( - + await user.click( + screen.getByRole("button", { name: "Use existing group" }) ); + await user.click(screen.getByRole("button", { name: "clear group" })); + expect(onChange).toHaveBeenCalledWith(null); + expect(screen.getByText("Anyone")).toBeInTheDocument(); + }); + + it("keeps the selected group when clearing is disabled", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderInlinePanel({ + onChange, + allowGroupClear: false, + selectedGroup: { id: "group-1", name: "Existing Group" } as ApiGroupFull, + }); + + await user.click( + screen.getByRole("button", { name: "Use existing group" }) + ); + await user.click(screen.getByRole("button", { name: "clear group" })); + + expect(onChange).not.toHaveBeenCalledWith(null); + expect(screen.getByText("Existing Group")).toBeInTheDocument(); + }); + + it("creates and attaches a valid inline group draft", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const onCreateGroup = jest.fn().mockResolvedValue(createdGroup); + renderInlinePanel({ onChange, onCreateGroup }); + + await user.click(screen.getByRole("button", { name: "Add rule" })); + await user.click(screen.getByRole("button", { name: "Rep" })); + await user.click(screen.getByRole("button", { name: "set rep min" })); await user.click(screen.getByRole("button", { name: "Create + use" })); await waitFor(() => { - expect(onInlineGroupCreate).toHaveBeenCalledWith( + expect(onCreateGroup).toHaveBeenCalledWith( expect.objectContaining({ name: "My Wave Who can view", }) ); }); - expect(onGroupSelect).toHaveBeenCalledWith(createdGroup); + expect(onChange).toHaveBeenCalledWith(createdGroup); }); it("keeps reset available when the draft is invalid", async () => { const user = userEvent.setup(); - const draft = createEmptyInlineGroupPayload(); - draft.group.tdh = { - ...draft.group.tdh, - min: 10, - max: 5, - }; - - render( - - ); + renderInlinePanel(); + await user.click(screen.getByRole("button", { name: "Add rule" })); + await user.click(screen.getByRole("button", { name: "TDH" })); + await user.click(screen.getByRole("button", { name: "set invalid tdh" })); expect(screen.getByRole("button", { name: "Create + use" })).toBeDisabled(); const startOverButton = screen.getByRole("button", { name: "Start over" }); expect(startOverButton).toBeEnabled(); @@ -431,21 +396,12 @@ describe("CreateWaveGroupInlinePanel", () => { it("opens configured rules from the draft chips", async () => { const user = userEvent.setup(); - const draft = createEmptyInlineGroupPayload(); - draft.group.rep = { - ...draft.group.rep, - min: 5, - }; - - render( - - ); + renderInlinePanel(); + await user.click(screen.getByRole("button", { name: "Add rule" })); + await user.click(screen.getByRole("button", { name: "Rep" })); + await user.click(screen.getByRole("button", { name: "set rep min" })); + await user.click(screen.getByRole("button", { name: "Add rule" })); await user.click(screen.getByRole("button", { name: "Rep" })); expect(screen.getByTestId("rule-rep")).toBeInTheDocument(); @@ -453,7 +409,7 @@ describe("CreateWaveGroupInlinePanel", () => { it("updates the draft summary after adding an identity", async () => { const user = userEvent.setup(); - render(); + renderInlinePanel(); await user.click(screen.getByRole("button", { name: "Add identity" })); await user.click(screen.getByRole("button", { name: "add identity" })); diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroupSearchField.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroupSearchField.test.tsx new file mode 100644 index 0000000000..e57ea5bf81 --- /dev/null +++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroupSearchField.test.tsx @@ -0,0 +1,360 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState, type DependencyList, type ReactNode } from "react"; +import CreateWaveGroupSearchField from "@/components/waves/create-wave/groups/CreateWaveGroupSearchField"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import { commonApiFetch } from "@/services/api/common-api"; + +jest.mock("@/services/api/common-api", () => ({ + commonApiFetch: jest.fn(), +})); + +jest.mock("framer-motion", () => { + const React = require("react"); + + return { + AnimatePresence: ({ children }: { children: ReactNode }) => + React.createElement(React.Fragment, null, children), + motion: { + div: (props: any) => { + const nextProps = { ...props }; + const { children } = nextProps; + delete nextProps.animate; + delete nextProps.children; + delete nextProps.exit; + delete nextProps.initial; + delete nextProps.transition; + + return React.createElement("div", nextProps, children); + }, + }, + }; +}); + +jest.mock("react-use", () => { + const React = require("react"); + const actual = jest.requireActual("react-use"); + + return { + ...actual, + useDebounce: ( + callback: () => void, + _delay: number, + deps: DependencyList + ) => { + React.useEffect(callback, deps); + }, + }; +}); + +const groups: ApiGroupFull[] = [ + { + id: "group-1", + name: "Alpha Group", + created_by: { handle: "alice" }, + } as ApiGroupFull, + { + id: "group-2", + name: "Beta Group", + created_by: { handle: "builder" }, + } as ApiGroupFull, +]; + +const mockCommonApiFetch = commonApiFetch as jest.Mock; + +function renderSearchField({ + defaultLabel = "Anyone", + disabled = false, + selectedGroup = null, + allowClear = true, + onSelect = jest.fn(), +}: { + readonly defaultLabel?: string; + readonly disabled?: boolean; + readonly selectedGroup?: ApiGroupFull | null; + readonly allowClear?: boolean; + readonly onSelect?: jest.Mock; +} = {}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + render( + + + + ); + + return { onSelect }; +} + +function renderStatefulSearchField({ + defaultLabel = "Anyone", + disabled = false, + selectedGroup = null, + allowClear = true, + onSelect = jest.fn(), +}: { + readonly defaultLabel?: string; + readonly disabled?: boolean; + readonly selectedGroup?: ApiGroupFull | null; + readonly allowClear?: boolean; + readonly onSelect?: jest.Mock; +} = {}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + function StatefulSearchField() { + const [currentSelectedGroup, setCurrentSelectedGroup] = + useState(selectedGroup); + + return ( + { + setCurrentSelectedGroup(group); + onSelect(group); + }} + /> + ); + } + + render( + + + + ); + + return { onSelect }; +} + +describe("CreateWaveGroupSearchField", () => { + beforeEach(() => { + mockCommonApiFetch.mockResolvedValue(groups); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("opens on focus, renders results, and selects with keyboard", async () => { + const user = userEvent.setup(); + const { onSelect } = renderSearchField(); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + await user.click(input); + + expect(input).toHaveAttribute("aria-expanded", "true"); + expect( + await screen.findByRole("option", { name: /Alpha Group/i }) + ).toBeInTheDocument(); + + await user.keyboard("{ArrowDown}{Enter}"); + + await waitFor(() => expect(onSelect).toHaveBeenCalledWith(groups[0])); + expect(input).toHaveValue("Alpha Group"); + expect(input).toHaveAttribute("aria-expanded", "false"); + }); + + it("uses the debounced search value for group queries", async () => { + const user = userEvent.setup(); + mockCommonApiFetch.mockImplementation(async ({ params }: any) => + params.group_name === "missing" ? [] : groups + ); + renderSearchField(); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + await user.click(input); + await screen.findByRole("option", { name: /Alpha Group/i }); + + await user.clear(input); + await user.type(input, "missing"); + + await waitFor(() => + expect(mockCommonApiFetch).toHaveBeenCalledWith({ + endpoint: "groups", + params: { group_name: "missing" }, + }) + ); + expect(await screen.findByText("No groups found")).toBeInTheDocument(); + }); + + it("highlights literal search matches case-insensitively", async () => { + const user = userEvent.setup(); + mockCommonApiFetch.mockResolvedValue([ + { + id: "group-literal", + name: "A.a A.A Group", + created_by: { handle: "literal-owner" }, + } as ApiGroupFull, + ]); + renderSearchField(); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + await user.click(input); + await user.type(input, "a.a"); + + const option = await screen.findByRole("option", { + name: /A\.a A\.A Group/i, + }); + + await waitFor(() => { + const highlightedParts = Array.from( + option.querySelectorAll(".tw-text-primary-300") + ).map((element) => element.textContent); + + expect(highlightedParts).toEqual(["A.a", "A.A"]); + }); + }); + + it("renders the selected group as the initial input value", () => { + renderSearchField({ selectedGroup: groups[0] }); + + expect( + screen.getByRole("combobox", { name: "Search groups..." }) + ).toHaveValue("Alpha Group"); + expect(screen.getByText("Selected: Alpha Group")).toBeInTheDocument(); + }); + + it("syncs input and search query when selected group changes externally", async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + const renderField = (selectedGroup: ApiGroupFull | null) => ( + + + + ); + + const { rerender } = render(renderField(groups[0])); + const input = screen.getByRole("combobox", { name: "Search groups..." }); + + expect(input).toHaveValue("Alpha Group"); + + rerender(renderField(groups[1])); + + expect(input).toHaveValue("Beta Group"); + expect(screen.getByText("Selected: Beta Group")).toBeInTheDocument(); + + await user.click(input); + + await waitFor(() => + expect(mockCommonApiFetch).toHaveBeenCalledWith({ + endpoint: "groups", + params: { group_name: "Beta Group" }, + }) + ); + + rerender(renderField(null)); + + await waitFor(() => expect(input).toHaveValue("")); + expect(screen.getByText("Default: Anyone")).toBeInTheDocument(); + await waitFor(() => + expect(mockCommonApiFetch).toHaveBeenCalledWith({ + endpoint: "groups", + params: {}, + }) + ); + }); + + it("clears selected group while preserving typed search text", async () => { + const { onSelect } = renderStatefulSearchField({ + selectedGroup: groups[0], + }); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + + fireEvent.change(input, { target: { value: "Custom query" } }); + + expect(onSelect).toHaveBeenCalledWith(null); + await waitFor(() => expect(input).toHaveValue("Custom query")); + }); + + it("closes the results on Escape and outside click", async () => { + const user = userEvent.setup(); + renderSearchField(); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + await user.click(input); + expect(await screen.findByRole("listbox")).toBeInTheDocument(); + + const stopPropagationSpy = jest.spyOn(Event.prototype, "stopPropagation"); + try { + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + expect(stopPropagationSpy).toHaveBeenCalled(); + } finally { + stopPropagationSpy.mockRestore(); + } + + await user.click(input); + expect(await screen.findByRole("listbox")).toBeInTheDocument(); + + await user.click(document.body); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + }); + + it("only clears the selected group when clearing is allowed", async () => { + const user = userEvent.setup(); + const { onSelect } = renderSearchField({ selectedGroup: groups[0] }); + + await user.click( + screen.getByRole("button", { name: "Clear selected group" }) + ); + + expect(onSelect).toHaveBeenCalledWith(null); + }); + + it("hides clear action when clearing is disabled", () => { + renderSearchField({ selectedGroup: groups[0], allowClear: false }); + + expect( + screen.queryByRole("button", { name: "Clear selected group" }) + ).not.toBeInTheDocument(); + }); + + it("does not open or query while disabled", async () => { + const user = userEvent.setup(); + renderSearchField({ disabled: true }); + + const input = screen.getByRole("combobox", { name: "Search groups..." }); + await user.click(input); + + expect(input).toBeDisabled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(mockCommonApiFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx index e4ce2e8108..499e8aa010 100644 --- a/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx +++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx @@ -25,22 +25,7 @@ describe("CreateWaveGroups", () => { chatEnabled={false} adminCanDeleteDrops={false} groupsCache={{}} - groupBuilders={ - { - CAN_VIEW: {}, - CAN_DROP: {}, - CAN_VOTE: {}, - CAN_CHAT: {}, - ADMIN: {}, - } as any - } setChatEnabled={jest.fn()} - setGroupBuilderPanel={jest.fn()} - setGroupBuilderRule={jest.fn()} - setGroupBuilderDraft={jest.fn()} - addGroupBuilderIdentity={jest.fn()} - removeGroupBuilderIdentity={jest.fn()} - resetGroupBuilder={jest.fn()} setDropsAdminCanDelete={jest.fn()} /> ); 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 392ba988ab..ee35ba99a7 100644 --- a/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx +++ b/__tests__/components/waves/specs/groups/group/edit/WaveGroupEditButtons.test.tsx @@ -1,13 +1,18 @@ -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.types'; -import { AuthContext } from '@/components/auth/Auth'; -import { ReactQueryWrapperContext } from '@/components/react-query-wrapper/ReactQueryWrapper'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -jest.mock('@tanstack/react-query', () => { - const actual = jest.requireActual('@tanstack/react-query'); +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.types"; +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { ApiGroupFilterDirection } from "@/generated/models/ApiGroupFilterDirection"; +import { ApiGroupTdhInclusionStrategy } from "@/generated/models/ApiGroupTdhInclusionStrategy"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +const mockSubmitInlineGroup = jest.fn(); +let mockInlinePanelProps: any; + +jest.mock("@tanstack/react-query", () => { + const actual = jest.requireActual("@tanstack/react-query"); return { ...actual, useMutation: jest.fn(), @@ -15,50 +20,173 @@ jest.mock('@tanstack/react-query', () => { useQueryClient: jest.fn(), }; }); -jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupEditButton', () => { - const React = require('react'); - return { - __esModule: true, - default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref) => { - const handleOpen = () => onWaveUpdate({}); - React.useImperativeHandle(ref, () => ({ open: handleOpen }), [handleOpen]); + +jest.mock("focus-trap-react", () => ({ + FocusTrap: ({ children }: any) => <>{children}, +})); + +jest.mock("@/hooks/groups/useGroupMutations", () => ({ + useGroupMutations: () => ({ + submit: mockSubmitInlineGroup, + }), +})); + +jest.mock("@/helpers/waves/waves.helpers", () => ({ + convertWaveToUpdateWave: jest.fn(() => ({ + name: "Wave 1", + picture: null, + visibility: { scope: { group_id: "group-1" } }, + participation: { scope: { group_id: "group-1" } }, + voting: { scope: { group_id: "group-1" } }, + chat: { scope: { group_id: "group-1" } }, + wave: { admin_group: { group_id: "group-1" } }, + })), +})); + +jest.mock( + "@/components/waves/create-wave/groups/CreateWaveGroupInlinePanel", + () => + function MockCreateWaveGroupInlinePanel(props: any) { + mockInlinePanelProps = props; + const selectedGroup = { + id: "group-2", + name: "Selected Group", + }; + const payload = { + name: "Draft Group", + group: {}, + is_private: false, + }; + + return ( +
+ {props.selectedGroup?.name ?? props.defaultLabel} + + + +
+ ); + } +); + +jest.mock( + "@/components/waves/specs/groups/group/edit/WaveGroupEditButton", + () => { + const React = require("react"); + + function MockWaveGroupEditTrigger({ open, renderTrigger }: any) { if (renderTrigger === null) { return null; } - return renderTrigger ? <>{renderTrigger({ open: handleOpen })} : ; - }), - }; -}); -jest.mock('@/components/waves/specs/groups/group/edit/WaveGroupRemoveButton', () => { - const React = require('react'); - return { - __esModule: true, - default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref) => { - const handleOpen = () => onWaveUpdate({}); - React.useImperativeHandle(ref, () => ({ open: handleOpen }), [handleOpen]); + if (renderTrigger) { + const Trigger = renderTrigger; + return ; + } + + return ; + } + + return { + __esModule: true, + default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref) => { + const handleOpen = () => onWaveUpdate({}); + React.useImperativeHandle(ref, () => ({ open: handleOpen }), [ + handleOpen, + ]); + return ( + + ); + }), + }; + } +); + +jest.mock( + "@/components/waves/specs/groups/group/edit/WaveGroupRemoveButton", + () => { + const React = require("react"); + + function MockWaveGroupRemoveTrigger({ open, renderTrigger }: any) { 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) => ( -
- -
- ), -})); + if (renderTrigger) { + const Trigger = renderTrigger; + return ; + } -jest.mock('@/components/distribution-plan-tool/common/CircleLoader', () => ({ + return ; + } + + return { + __esModule: true, + default: React.forwardRef(({ onWaveUpdate, renderTrigger }: any, ref) => { + const handleOpen = () => onWaveUpdate({}); + React.useImperativeHandle(ref, () => ({ open: handleOpen }), [ + handleOpen, + ]); + return ( + + ); + }), + }; + } +); + +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", () => ({ __esModule: true, default: () =>
, })); @@ -117,48 +245,214 @@ const wave: any = { wave: { admin_group: { group: baseGroup }, authenticated_user_eligible_for_admin: true, + type: "RANK", }, }; -describe('WaveGroupEditButtons', () => { +describe("WaveGroupEditButtons", () => { beforeEach(() => { jest.clearAllMocks(); + mockInlinePanelProps = null; + mockSubmitInlineGroup.mockResolvedValue({ + ok: true, + group: { + id: "group-created", + name: "Created Group", + }, + published: true, + }); + auth.requestAuth.mockResolvedValue({ success: true }); mutateAsync.mockReset(); + mutateAsync.mockResolvedValue({}); queryClientMock = createQueryClientMock(); (useMutation as jest.Mock).mockReturnValue({ mutateAsync }); - (useQuery as jest.Mock).mockImplementation(({ enabled, queryFn }) => { - if (enabled && typeof queryFn === 'function') { - queryFn({ signal: undefined }); - } - return { data: undefined }; - }); + (useQuery as jest.Mock).mockReturnValue({ data: undefined }); (useQueryClient as jest.Mock).mockReturnValue(queryClientMock); }); - it('opens menu and calls mutate on edit', async () => { - render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /Group options/i })); - fireEvent.click(screen.getByText('Change group')); + it("opens inline group dialog from change group", async () => { + render( + , + { wrapper } + ); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); + fireEvent.click(screen.getByText("Change group")); + + expect(screen.getByTestId("inline-panel")).toHaveAttribute( + "data-allow-clear", + "false" + ); + expect(screen.getByText("Group 1")).toBeInTheDocument(); + expect(mockInlinePanelProps.selectedGroup).toEqual( + expect.objectContaining({ + id: "group-1", + name: "Group 1", + group: expect.objectContaining({ + tdh: { + min: null, + max: null, + inclusion_strategy: ApiGroupTdhInclusionStrategy.Both, + }, + 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: null, + identity_group_identities_count: 0, + excluded_identity_group_id: null, + excluded_identity_group_identities_count: 0, + is_beneficiary_of_grant_id: null, + is_beneficiary_of_grant: null, + }), + }) + ); + }); + + it("provides a safe created_by fallback for groups without authors", async () => { + const groupWithoutAuthor = { + id: "group-without-author", + name: "Group Without Author", + created_at: 100, + is_hidden: false, + is_direct_message: false, + }; + const waveWithAuthorlessGroup = { + ...wave, + visibility: { + scope: { group: groupWithoutAuthor }, + }, + }; + + render( + , + { wrapper } + ); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); + fireEvent.click(screen.getByText("Change group")); + + expect(mockInlinePanelProps.selectedGroup).toEqual( + expect.objectContaining({ + id: "group-without-author", + name: "Group Without Author", + created_by: { + id: "unknown", + handle: null, + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, + }, + }) + ); + }); + + it("updates the wave when selecting an existing group", async () => { + render( + , + { wrapper } + ); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); + fireEvent.click(screen.getByText("Change group")); + fireEvent.click(screen.getByText("select existing group")); + await waitFor(() => expect(auth.requestAuth).toHaveBeenCalled()); expect(mutateAsync).toHaveBeenCalled(); + expect(mutateAsync.mock.calls[0][0].visibility.scope.group_id).toBe( + "group-2" + ); }); - it('shows error toast when auth fails', async () => { + it("shows error toast when auth fails", async () => { auth.requestAuth.mockResolvedValueOnce({ success: false }); - render(, { wrapper }); - fireEvent.click(screen.getByRole('button', { name: /Group options/i })); - fireEvent.click(screen.getByText('Change group')); + render( + , + { wrapper } + ); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); + fireEvent.click(screen.getByText("Change group")); + fireEvent.click(screen.getByText("select existing group")); + await waitFor(() => expect(auth.setToast).toHaveBeenCalled()); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("creates an inline group and attaches it without a second auth prompt", async () => { + mockSubmitInlineGroup.mockImplementationOnce(async () => { + await auth.requestAuth(); + return { + ok: true, + group: { + id: "group-created", + name: "Created Group", + }, + published: true, + }; + }); + + render( + , + { wrapper } + ); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); + fireEvent.click(screen.getByText("Change group")); + fireEvent.click(screen.getByText("create inline group")); + + await waitFor(() => expect(mockSubmitInlineGroup).toHaveBeenCalled()); + const submitArgs = mockSubmitInlineGroup.mock.calls[0][0]; + expect(submitArgs).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ name: "Draft Group" }), + currentHandle: "alice", + }) + ); + expect(submitArgs).not.toHaveProperty("previousGroup"); + await waitFor(() => expect(mutateAsync).toHaveBeenCalled()); + expect(auth.requestAuth).toHaveBeenCalledTimes(1); + expect(mutateAsync.mock.calls[0][0].visibility.scope.group_id).toBe( + "group-created" + ); }); - 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("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 () => { + it("shows add label when no group is linked", async () => { const waveWithoutGroup = { ...wave, visibility: { @@ -168,31 +462,49 @@ describe('WaveGroupEditButtons', () => { }; render( - , - { wrapper }, + , + { wrapper } ); - fireEvent.click(screen.getByRole('button', { name: /Group options/i })); + fireEvent.click(screen.getByRole("button", { name: /Group options/i })); - expect(screen.getByText('Add group')).toBeInTheDocument(); - expect(screen.queryByText('Change group')).toBeNull(); + expect(screen.getByText("Add group")).toBeInTheDocument(); + expect(screen.queryByText("Change group")).toBeNull(); + + fireEvent.click(screen.getByText("Add group")); + expect(screen.getByTestId("inline-panel")).toHaveAttribute( + "data-allow-clear", + "false" + ); }); }); -jest.mock('@headlessui/react', () => { +jest.mock("@headlessui/react", () => { const close = jest.fn(); return { Menu: ({ children, ...props }: any) => (
- {typeof children === 'function' + {typeof children === "function" ? children({ open: false, close }) : children}
), - MenuButton: React.forwardRef(({ children, ...props }, ref) => ( - - )), - MenuItems: ({ children, anchor: _anchor, ...props }: any) =>
{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}, + Transition: ({ children }: any) => ( + <>{typeof children === "function" ? children() : children} + ), }; }); diff --git a/__tests__/hooks/useWaveConfig.test.ts b/__tests__/hooks/useWaveConfig.test.ts index c996e67acf..5e169f68d5 100644 --- a/__tests__/hooks/useWaveConfig.test.ts +++ b/__tests__/hooks/useWaveConfig.test.ts @@ -1,29 +1,7 @@ import { renderHook, act } from "@testing-library/react"; import { useWaveConfig } from "@/components/waves/create-wave/hooks/useWaveConfig"; -import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { CreateWaveGroupConfigType, CreateWaveStep } from "@/types/waves.types"; - -const exampleIdentity = { - profile_id: "profile-1", - handle: "alpha", - normalised_handle: "alpha", - primary_wallet: "0xPRIMARY", - display: "Alpha", - tdh: 0, - level: 0, - cic_rating: 0, - wallet: "0xABC", - pfp: null, -}; - -const secondSelectedWalletIdentity = { - ...exampleIdentity, - profile_id: "profile-2", - handle: "beta", - normalised_handle: "beta", - display: "Beta", - wallet: "0xDEF", -}; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; describe("useWaveConfig", () => { it("prevents step change when validation fails", () => { @@ -46,105 +24,21 @@ describe("useWaveConfig", () => { expect(result.current.config.drops.adminCanDeleteDrops).toBe(true); }); - it("stores inline group draft state per slot", () => { + it("stores selected group ids and caches selected group objects", () => { const { result } = renderHook(() => useWaveConfig()); + const group = { + id: "group-1", + name: "Alpha Group", + } as ApiGroupFull; act(() => { - result.current.addGroupBuilderIdentity( - CreateWaveGroupConfigType.CAN_VIEW, - exampleIdentity - ); - }); - - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW] - .identities - ).toHaveLength(1); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW].draft - .group.identity_addresses - ).toEqual(["0xabc"]); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VOTE] - .identities - ).toHaveLength(0); - }); - - it("keeps distinct selected wallets for multi-wallet identities", () => { - const { result } = renderHook(() => useWaveConfig()); - - act(() => { - result.current.addGroupBuilderIdentity( - CreateWaveGroupConfigType.CAN_VIEW, - exampleIdentity - ); - result.current.addGroupBuilderIdentity( - CreateWaveGroupConfigType.CAN_VIEW, - secondSelectedWalletIdentity - ); - }); - - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW] - .identities - ).toHaveLength(2); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW].draft - .group.identity_addresses - ).toEqual(["0xabc", "0xdef"]); - }); - - it("removes inline identities by selected wallet", () => { - const { result } = renderHook(() => useWaveConfig()); - - act(() => { - result.current.addGroupBuilderIdentity( - CreateWaveGroupConfigType.CAN_VIEW, - exampleIdentity - ); - }); - - act(() => { - result.current.removeGroupBuilderIdentity( - CreateWaveGroupConfigType.CAN_VIEW, - exampleIdentity.wallet - ); - }); - - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW] - .identities - ).toHaveLength(0); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.CAN_VIEW].draft - .group.identity_addresses - ).toBeNull(); - }); - - it("resets inline group builder state when overview type changes", () => { - const { result } = renderHook(() => useWaveConfig()); - - act(() => { - result.current.addGroupBuilderIdentity( - CreateWaveGroupConfigType.ADMIN, - exampleIdentity - ); - }); - - act(() => { - result.current.setOverview({ - type: ApiWaveType.Rank, - name: "Updated Wave", - image: null, + result.current.onGroupSelect({ + group, + groupType: CreateWaveGroupConfigType.CAN_VIEW, }); }); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.ADMIN].identities - ).toHaveLength(0); - expect( - result.current.groupBuilders[CreateWaveGroupConfigType.ADMIN].draft.group - .identity_addresses - ).toBeNull(); + expect(result.current.config.groups.canView).toBe("group-1"); + expect(result.current.groupsCache["group-1"]).toEqual(group); }); }); diff --git a/components/waves/create-wave/CreateWave.tsx b/components/waves/create-wave/CreateWave.tsx index 3b5ab1dcc8..a44b2da4be 100644 --- a/components/waves/create-wave/CreateWave.tsx +++ b/components/waves/create-wave/CreateWave.tsx @@ -60,7 +60,6 @@ export default function CreateWave({ selectedOutcomeType, errors, groupsCache, - groupBuilders, // Section updaters setOverview, setDates, @@ -73,12 +72,6 @@ export default function CreateWave({ onOutcomeTypeChange, // Group handling onGroupSelect, - setGroupBuilderPanel, - setGroupBuilderRule, - setGroupBuilderDraft, - addGroupBuilderIdentity, - removeGroupBuilderIdentity, - resetGroupBuilder, // Voting onVotingTypeChange, onCategoryChange, @@ -249,18 +242,11 @@ export default function CreateWave({ waveType={config.overview.type} groups={config.groups} groupsCache={groupsCache} - groupBuilders={groupBuilders} chatEnabled={config.chat.enabled} adminCanDeleteDrops={config.drops.adminCanDeleteDrops} setChatEnabled={onChatEnabledChange} onGroupSelect={onGroupSelect} onInlineGroupCreate={onInlineGroupCreate} - setGroupBuilderPanel={setGroupBuilderPanel} - setGroupBuilderRule={setGroupBuilderRule} - setGroupBuilderDraft={setGroupBuilderDraft} - addGroupBuilderIdentity={addGroupBuilderIdentity} - removeGroupBuilderIdentity={removeGroupBuilderIdentity} - resetGroupBuilder={resetGroupBuilder} setDropsAdminCanDelete={setDropsAdminCanDelete} /> ), diff --git a/components/waves/create-wave/groups/CreateWaveGroup.tsx b/components/waves/create-wave/groups/CreateWaveGroup.tsx index 6ff8f5b6b7..f596dafcd2 100644 --- a/components/waves/create-wave/groups/CreateWaveGroup.tsx +++ b/components/waves/create-wave/groups/CreateWaveGroup.tsx @@ -6,16 +6,11 @@ import { CREATE_WAVE_NONE_GROUP_LABELS, CREATE_WAVE_SELECT_GROUP_LABELS, } from "@/helpers/waves/waves.constants"; -import type { CommunityMemberMinimal } from "@/entities/IProfile"; import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import CreateWaveToggle from "../utils/CreateWaveToggle"; -import type { - CreateWaveInlineGroupBuilderState, - CreateWaveInlineGroupPanel, - CreateWaveInlineGroupRuleType, -} from "./createWaveInlineGroupBuilder"; +import { buildInlineGroupName } from "./createWaveInlineGroupBuilder"; import CreateWaveGroupInlinePanel from "./CreateWaveGroupInlinePanel"; export default function CreateWaveGroup({ @@ -29,13 +24,6 @@ export default function CreateWaveGroup({ onInlineGroupCreate, groupsCache, groups, - groupBuilder, - setGroupBuilderPanel, - setGroupBuilderRule, - setGroupBuilderDraft, - addGroupBuilderIdentity, - removeGroupBuilderIdentity, - resetGroupBuilder, setDropsAdminCanDelete, }: { readonly waveName: string; @@ -50,15 +38,6 @@ export default function CreateWaveGroup({ ) => Promise; readonly groupsCache: Record; readonly groups: WaveGroupsConfig; - readonly groupBuilder: CreateWaveInlineGroupBuilderState; - readonly setGroupBuilderPanel: (panel: CreateWaveInlineGroupPanel) => void; - readonly setGroupBuilderRule: ( - rule: CreateWaveInlineGroupRuleType | null - ) => void; - readonly setGroupBuilderDraft: (draft: ApiCreateGroup) => void; - readonly addGroupBuilderIdentity: (identity: CommunityMemberMinimal) => void; - readonly removeGroupBuilderIdentity: (wallet: string) => void; - readonly resetGroupBuilder: () => void; readonly setDropsAdminCanDelete: (adminCanDeleteDrops: boolean) => void; }) { const getSelectedGroupId = () => { @@ -91,13 +70,7 @@ export default function CreateWaveGroup({ !chatEnabled; const defaultLabel = CREATE_WAVE_NONE_GROUP_LABELS[groupType]; const groupLabel = CREATE_WAVE_SELECT_GROUP_LABELS[waveType][groupType]; - const resolvedGroupBuilder = inputDisabled - ? { - ...groupBuilder, - panel: "actions" as const, - activeRule: null, - } - : groupBuilder; + const suggestedName = buildInlineGroupName({ waveName, groupLabel }); return (
@@ -124,20 +97,12 @@ export default function CreateWaveGroup({
); diff --git a/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx b/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx index 47784502f1..8aba912f7e 100644 --- a/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx +++ b/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx @@ -6,9 +6,11 @@ import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; import { validateGroupPayload } from "@/services/groups/groupMutations"; import { - buildInlineGroupName, + createInitialInlineGroupBuilderState, + dedupeInlineIdentities, getInlineGroupConfiguredRules, getInlineGroupDraftSummary, + getInlineIdentityAddresses, } from "./createWaveInlineGroupBuilder"; import type { CreateWaveInlineGroupBuilderState, @@ -26,99 +28,168 @@ import { } from "./CreateWaveInlineGroupRules"; import CreateWaveInlineGroupSearch from "./CreateWaveInlineGroupSearch"; +const PANEL_ACTIONS: CreateWaveInlineGroupPanel = "actions"; +const PANEL_IDENTITY: CreateWaveInlineGroupPanel = "identity"; +const PANEL_RULE_LIST: CreateWaveInlineGroupPanel = "rule-list"; +const PANEL_RULE_EDITOR: CreateWaveInlineGroupPanel = "rule-editor"; +const PANEL_SEARCH: CreateWaveInlineGroupPanel = "search"; + export default function CreateWaveGroupInlinePanel({ - waveName, - groupLabel, + suggestedName, defaultLabel, - disabled, + disabled = false, selectedGroup, - groupBuilder, - onGroupSelect, - onInlineGroupCreate, - setGroupBuilderPanel, - setGroupBuilderRule, - setGroupBuilderDraft, - addGroupBuilderIdentity, - removeGroupBuilderIdentity, - resetGroupBuilder, + allowGroupClear = true, + onChange, + onCreateGroup, }: { - readonly waveName: string; - readonly groupLabel: string; + readonly suggestedName: string; readonly defaultLabel: string; - readonly disabled: boolean; + readonly disabled?: boolean; readonly selectedGroup: ApiGroupFull | null; - readonly groupBuilder: CreateWaveInlineGroupBuilderState; - readonly onGroupSelect: (group: ApiGroupFull | null) => void; - readonly onInlineGroupCreate: ( + readonly allowGroupClear?: boolean; + readonly onChange: (group: ApiGroupFull | null) => void | Promise; + readonly onCreateGroup: ( payload: ApiCreateGroup ) => Promise; - readonly setGroupBuilderPanel: (panel: CreateWaveInlineGroupPanel) => void; - readonly setGroupBuilderRule: ( - rule: CreateWaveInlineGroupRuleType | null - ) => void; - readonly setGroupBuilderDraft: (draft: ApiCreateGroup) => void; - readonly addGroupBuilderIdentity: (identity: CommunityMemberMinimal) => void; - readonly removeGroupBuilderIdentity: (wallet: string) => void; - readonly resetGroupBuilder: () => void; }) { const [isCreating, setIsCreating] = useState(false); + const [builder, setBuilder] = useState( + () => createInitialInlineGroupBuilderState() + ); + const displayedBuilder: CreateWaveInlineGroupBuilderState = disabled + ? { + ...builder, + panel: PANEL_ACTIONS, + activeRule: null, + } + : builder; const draftSummary = useMemo( () => getInlineGroupDraftSummary({ - draft: groupBuilder.draft, - identityCount: groupBuilder.identities.length, + draft: builder.draft, + identityCount: builder.identities.length, }), - [groupBuilder.draft, groupBuilder.identities.length] + [builder.draft, builder.identities.length] ); const configuredRules = useMemo( - () => getInlineGroupConfiguredRules(groupBuilder.draft), - [groupBuilder.draft] + () => getInlineGroupConfiguredRules(builder.draft), + [builder.draft] ); - const validation = validateGroupPayload(groupBuilder.draft); + const validation = validateGroupPayload(builder.draft); const canCreateDraft = validation.valid && !disabled && !isCreating; const canResetDraft = !!draftSummary && !disabled && !isCreating; const currentStateLabel = selectedGroup?.name ?? defaultLabel; - const identityCount = groupBuilder.identities.length; + const identityCount = displayedBuilder.identities.length; const identityLabel = identityCount === 1 ? "identity" : "identities"; - const isIdentityPanel = groupBuilder.panel === "identity"; + const isIdentityPanel = displayedBuilder.panel === PANEL_IDENTITY; const isRulePanel = - groupBuilder.panel === "rule-list" || groupBuilder.panel === "rule-editor"; - const isSearchPanel = groupBuilder.panel === "search"; - const showModeChips = !!draftSummary || groupBuilder.panel !== "actions"; + displayedBuilder.panel === PANEL_RULE_LIST || + displayedBuilder.panel === PANEL_RULE_EDITOR; + const isSearchPanel = displayedBuilder.panel === PANEL_SEARCH; + const showModeChips = + !!draftSummary || displayedBuilder.panel !== PANEL_ACTIONS; const identityChipLabel = identityCount > 0 ? `${identityCount} ${identityLabel}` : "Add identity"; + const resetBuilder = () => { + setBuilder(createInitialInlineGroupBuilderState()); + }; + + const setPanel = (panel: CreateWaveInlineGroupPanel) => { + setBuilder((current) => ({ + ...current, + panel, + })); + }; + + const setActiveRule = (rule: CreateWaveInlineGroupRuleType | null) => { + setBuilder((current) => ({ + ...current, + activeRule: rule, + panel: rule === null ? current.panel : PANEL_RULE_EDITOR, + })); + }; + + const setDraft = (draft: ApiCreateGroup) => { + setBuilder((current) => ({ + ...current, + draft, + })); + }; + + const addIdentity = (identity: CommunityMemberMinimal) => { + setBuilder((current) => { + const identities = dedupeInlineIdentities([ + ...current.identities, + identity, + ]); + + return { + ...current, + identities, + draft: { + ...current.draft, + group: { + ...current.draft.group, + identity_addresses: getInlineIdentityAddresses(identities), + }, + }, + }; + }); + }; + + const removeIdentity = (wallet: string) => { + const normalizedWallet = wallet.trim().toLowerCase(); + + setBuilder((current) => { + const identities = current.identities.filter((identity) => { + const key = identity.wallet.trim().toLowerCase(); + return key !== normalizedWallet; + }); + + return { + ...current, + identities, + draft: { + ...current.draft, + group: { + ...current.draft.group, + identity_addresses: getInlineIdentityAddresses(identities), + }, + }, + }; + }); + }; + const openPanel = (panel: CreateWaveInlineGroupPanel) => { - setGroupBuilderRule(null); - setGroupBuilderPanel(panel); + setActiveRule(null); + setPanel(panel); }; const togglePanel = ( panel: CreateWaveInlineGroupPanel, isActive: boolean ) => { - openPanel(isActive ? "actions" : panel); + openPanel(isActive ? PANEL_ACTIONS : panel); }; const openRule = (rule: CreateWaveInlineGroupRuleType) => { - setGroupBuilderRule(rule); + setActiveRule(rule); }; const toggleRule = (rule: CreateWaveInlineGroupRuleType) => { - if ( - groupBuilder.panel === "rule-editor" && - groupBuilder.activeRule === rule - ) { - setGroupBuilderRule(null); - setGroupBuilderPanel("rule-list"); + if (builder.panel === PANEL_RULE_EDITOR && builder.activeRule === rule) { + setActiveRule(null); + setPanel(PANEL_RULE_LIST); return; } openRule(rule); }; - const onCreateAndUse = async () => { + const createAndUse = async () => { if (!canCreateDraft) { return; } @@ -126,41 +197,42 @@ export default function CreateWaveGroupInlinePanel({ setIsCreating(true); try { const nextPayload: ApiCreateGroup = { - ...groupBuilder.draft, - name: buildInlineGroupName({ - waveName, - groupLabel, - }), + ...builder.draft, + name: suggestedName.trim() || "Wave Group", }; - const createdGroup = await onInlineGroupCreate(nextPayload); + const createdGroup = await onCreateGroup(nextPayload); if (!createdGroup) { return; } - onGroupSelect(createdGroup); - resetGroupBuilder(); - setGroupBuilderRule(null); - setGroupBuilderPanel("actions"); + await onChange(createdGroup); + resetBuilder(); } finally { setIsCreating(false); } }; + const onCreateAndUse = () => { + void createAndUse(); + }; + const onStartOver = () => { if (!canResetDraft) { return; } - resetGroupBuilder(); + resetBuilder(); }; - const onExistingGroupSelect = (group: ApiGroupFull | null) => { - onGroupSelect(group); + const onExistingGroupSelect = async (group: ApiGroupFull | null) => { + if (!group && !allowGroupClear) { + return; + } + + await onChange(group); if (group) { - resetGroupBuilder(); - setGroupBuilderRule(null); - setGroupBuilderPanel("actions"); + resetBuilder(); } }; @@ -176,63 +248,66 @@ export default function CreateWaveGroupInlinePanel({ isRulePanel={isRulePanel} isSearchPanel={isSearchPanel} configuredRules={configuredRules} - onIdentityToggle={() => togglePanel("identity", isIdentityPanel)} + onIdentityToggle={() => togglePanel(PANEL_IDENTITY, isIdentityPanel)} onRuleOpen={openRule} - onRulesToggle={() => togglePanel("rule-list", isRulePanel)} - onSearchToggle={() => togglePanel("search", isSearchPanel)} + onRulesToggle={() => togglePanel(PANEL_RULE_LIST, isRulePanel)} + onSearchToggle={() => togglePanel(PANEL_SEARCH, isSearchPanel)} /> - {groupBuilder.panel === "actions" && !draftSummary && ( + {displayedBuilder.panel === PANEL_ACTIONS && !draftSummary && ( openPanel("identity")} - onAddRule={() => openPanel("rule-list")} - onUseExistingGroup={() => openPanel("search")} + onAddIdentity={() => openPanel(PANEL_IDENTITY)} + onAddRule={() => openPanel(PANEL_RULE_LIST)} + onUseExistingGroup={() => openPanel(PANEL_SEARCH)} /> )} - {groupBuilder.panel === "identity" && ( + {displayedBuilder.panel === PANEL_IDENTITY && (
)} - {groupBuilder.panel === "rule-list" && ( + {displayedBuilder.panel === PANEL_RULE_LIST && ( )} - {groupBuilder.panel === "rule-editor" && - groupBuilder.activeRule !== null && ( + {displayedBuilder.panel === PANEL_RULE_EDITOR && + displayedBuilder.activeRule !== null && ( )} - {groupBuilder.panel === "search" && ( + {displayedBuilder.panel === PANEL_SEARCH && ( { + void onExistingGroupSelect(group); + }} /> )} - {groupBuilder.panel !== "search" && draftSummary && ( + {displayedBuilder.panel !== PANEL_SEARCH && draftSummary && ( {text}; - } - - const matcher = new RegExp(`(${escapeRegExp(trimmed)})`, "ig"); - const parts = text.split(matcher); - - return ( - <> - {parts.map((part, index) => - index % 2 === 1 ? ( - - {part} - - ) : ( - - {part} - - ) - )} - - ); -} +import CreateWaveGroupSearchInput from "./CreateWaveGroupSearchInput"; +import CreateWaveGroupSearchResults from "./CreateWaveGroupSearchResults"; +import { useCreateWaveGroupSearch } from "./useCreateWaveGroupSearch"; export default function CreateWaveGroupSearchField({ label, defaultLabel, disabled, selectedGroup, + allowClear = true, onSelect, }: { readonly label: string; readonly defaultLabel: string; readonly disabled: boolean; readonly selectedGroup: ApiGroupFull | null; + readonly allowClear?: boolean; readonly onSelect: (group: ApiGroupFull | null) => void; }) { const inputRef = useRef(null); - const baseId = useId(); - const inputId = `${baseId}-input`; - const listboxId = `${baseId}-listbox`; const wrapperRef = useRef(null); - - const [inputValue, setInputValue] = useState( - selectedGroup?.name ?? "" - ); - const [searchCriteria, setSearchCriteria] = useState( - selectedGroup?.name ?? "" - ); - const [debouncedValue, setDebouncedValue] = useState( - selectedGroup?.name ?? "" - ); - const [isOpen, setIsOpen] = useState(false); - const [activeIndex, setActiveIndex] = useState(-1); - - useDebounce( - () => { - setDebouncedValue(searchCriteria.trim()); - }, - DEBOUNCE_MS, - [searchCriteria] - ); - - const { data, isFetching } = useQuery({ - queryKey: [QueryKey.GROUPS, { group_name: debouncedValue || null }], - queryFn: async () => { - const params: Mutable< - NonNullableNotRequired - > = {}; - if (debouncedValue) { - params.group_name = debouncedValue; - } - - return await commonApiFetch< - ApiGroupFull[], - NonNullableNotRequired - >({ - endpoint: "groups", - params, - }); - }, - enabled: isOpen && !disabled, - placeholderData: keepPreviousData, - }); - - const suggestions = (data ?? []).slice(0, MAX_RESULTS); - - const clearSelection = useCallback(() => { - setInputValue(""); - setSearchCriteria(""); - onSelect(null); - setIsOpen(true); - setActiveIndex(-1); - inputRef.current?.focus(); - }, [onSelect]); - - // Keep local state in sync with external selection - useEffect(() => { - if (selectedGroup) { - setInputValue(selectedGroup.name); - setSearchCriteria(selectedGroup.name); - } else { - setInputValue(""); - setSearchCriteria(""); - } - }, [selectedGroup]); - - useClickAway(wrapperRef, () => { - if (!isOpen) return; - setIsOpen(false); - setActiveIndex(-1); + const search = useCreateWaveGroupSearch({ + defaultLabel, + disabled, + inputRef, + selectedGroup, + allowClear, + onSelect, + wrapperRef, }); - useKeyPressEvent("Escape", () => { - if (!isOpen) return; - setIsOpen(false); - setActiveIndex(-1); - }); - - const onInputFocus = () => { - if (disabled) return; - setIsOpen(true); - setActiveIndex(-1); - }; - - const handleInputChange = (value: string) => { - setInputValue(value); - setSearchCriteria(value); - setIsOpen(true); - setActiveIndex(-1); - if (selectedGroup) { - onSelect(null); - } - }; - - const onOptionSelect = (group: ApiGroupFull) => { - setInputValue(group.name); - setSearchCriteria(group.name); - onSelect(group); - setIsOpen(false); - setActiveIndex(-1); - inputRef.current?.focus(); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (!isOpen) { - if (event.key === "ArrowDown") { - setIsOpen(true); - setActiveIndex(0); - event.preventDefault(); - } - return; - } - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveIndex((prev) => { - const nextIndex = prev + 1; - if (nextIndex >= suggestions.length) { - return suggestions.length ? 0 : -1; - } - return nextIndex; - }); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveIndex((prev) => { - if (suggestions.length === 0) { - return -1; - } - if (prev <= 0) { - return suggestions.length - 1; - } - return prev - 1; - }); - } else if (event.key === "Enter") { - if (activeIndex >= 0 && activeIndex < suggestions.length) { - event.preventDefault(); - onOptionSelect(suggestions[activeIndex]!); - } - } else if (event.key === "Escape") { - event.preventDefault(); - setIsOpen(false); - setActiveIndex(-1); - } - }; - - const showNoResults = !isFetching && isOpen && suggestions.length === 0; - const helperText = selectedGroup - ? `Selected: ${selectedGroup.name}` - : `Default: ${defaultLabel}`; - - const hasValue = inputValue.trim().length > 0; - const showClearButton = (hasValue || !!selectedGroup) && !disabled; - const inputClasses = [ - "tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-appearance-none tw-font-medium tw-peer tw-pl-10 tw-pr-4 tw-bg-iron-900 tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out tw-text-base tw-pt-3 tw-pb-3", - disabled - ? "tw-ring-iron-700 tw-text-iron-500 tw-caret-iron-500 placeholder:tw-text-iron-500 tw-bg-iron-800" - : "tw-ring-iron-700 focus:tw-border-blue-500 tw-caret-primary-400 focus:tw-ring-primary-400 hover:tw-ring-iron-650 placeholder:tw-text-iron-500", - hasValue || selectedGroup - ? "focus:tw-text-white tw-text-primary-400" - : "tw-text-white", - ].join(" "); return (
-
-
- = 0 ? `${listboxId}-option-${activeIndex}` : undefined - } - value={inputValue} - onFocus={onInputFocus} - onChange={(event) => handleInputChange(event.target.value)} - onKeyDown={handleKeyDown} - placeholder=" " - disabled={disabled} - className={inputClasses} - autoComplete="off" - /> - - {showClearButton && ( - - )} - - -
- - - {isOpen && !disabled && ( - -
-
- { } -
    - {isFetching ? ( -
  • - -
  • - ) : suggestions.length > 0 ? ( - suggestions.map((group, index) => { - const isActive = index === activeIndex; - const isSelected = selectedGroup?.id === group.id; - const optionStateClasses = - isActive || isSelected - ? "tw-bg-iron-800 tw-text-white" - : "tw-text-white hover:tw-bg-iron-800"; - return ( -
  • setActiveIndex(index)} - onMouseDown={(event) => event.preventDefault()} - onClick={() => onOptionSelect(group)} - > -
    - - - - - - -
    -
  • - ); - }) - ) : ( -
  • - {showNoResults ? "No groups found" : helperText} -
  • - )} -
-
-
-
- )} -
+
+ +
-

- {helperText} +

+ {search.helperText}

); diff --git a/components/waves/create-wave/groups/CreateWaveGroupSearchInput.tsx b/components/waves/create-wave/groups/CreateWaveGroupSearchInput.tsx new file mode 100644 index 0000000000..a615dd48c8 --- /dev/null +++ b/components/waves/create-wave/groups/CreateWaveGroupSearchInput.tsx @@ -0,0 +1,116 @@ +import type { KeyboardEvent, RefObject } from "react"; + +export default function CreateWaveGroupSearchInput({ + inputRef, + inputId, + listboxId, + activeOptionId, + label, + value, + isOpen, + disabled, + hasValue, + hasSelectedGroup, + showClearButton, + onInputFocus, + onInputChange, + onInputKeyDown, + onClearSelection, +}: { + readonly inputRef: RefObject; + readonly inputId: string; + readonly listboxId: string; + readonly activeOptionId?: string | undefined; + readonly label: string; + readonly value: string; + readonly isOpen: boolean; + readonly disabled: boolean; + readonly hasValue: boolean; + readonly hasSelectedGroup: boolean; + readonly showClearButton: boolean; + readonly onInputFocus: () => void; + readonly onInputChange: (value: string) => void; + readonly onInputKeyDown: (event: KeyboardEvent) => void; + readonly onClearSelection: () => void; +}) { + const inputClasses = [ + "tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-appearance-none tw-font-medium tw-peer tw-pl-10 tw-pr-4 tw-bg-iron-900 tw-shadow-sm tw-ring-1 tw-ring-inset tw-transition tw-duration-300 tw-ease-out tw-text-base tw-pt-3 tw-pb-3", + disabled + ? "tw-ring-iron-700 tw-text-iron-500 tw-caret-iron-500 placeholder:tw-text-iron-500 tw-bg-iron-800" + : "tw-ring-iron-700 focus:tw-border-blue-500 tw-caret-primary-400 focus:tw-ring-primary-400 hover:tw-ring-iron-650 placeholder:tw-text-iron-500", + hasValue || hasSelectedGroup + ? "focus:tw-text-white tw-text-primary-400" + : "tw-text-white", + ].join(" "); + + return ( +
+ onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + placeholder=" " + disabled={disabled} + className={inputClasses} + autoComplete="off" + /> + + {showClearButton && ( + + )} + + +
+ ); +} diff --git a/components/waves/create-wave/groups/CreateWaveGroupSearchResults.tsx b/components/waves/create-wave/groups/CreateWaveGroupSearchResults.tsx new file mode 100644 index 0000000000..157cf09dfd --- /dev/null +++ b/components/waves/create-wave/groups/CreateWaveGroupSearchResults.tsx @@ -0,0 +1,275 @@ +import { AnimatePresence, LazyMotion, domAnimation, m } from "framer-motion"; +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; + +type HighlightedTextPart = { + readonly isMatch: boolean; + readonly startIndex: number; + readonly value: string; +}; + +function getHighlightedTextParts( + text: string, + query: string +): readonly HighlightedTextPart[] { + const trimmed = query.trim(); + if (!trimmed) { + return [{ isMatch: false, startIndex: 0, value: text }]; + } + + const parts: HighlightedTextPart[] = []; + const lowerQuery = trimmed.toLowerCase(); + let partStartIndex = 0; + let searchIndex = 0; + + while (searchIndex <= text.length - trimmed.length) { + const candidate = text.slice(searchIndex, searchIndex + trimmed.length); + if (candidate.toLowerCase() !== lowerQuery) { + searchIndex += 1; + continue; + } + + if (partStartIndex < searchIndex) { + parts.push({ + isMatch: false, + startIndex: partStartIndex, + value: text.slice(partStartIndex, searchIndex), + }); + } + parts.push({ isMatch: true, startIndex: searchIndex, value: candidate }); + + searchIndex += trimmed.length; + partStartIndex = searchIndex; + } + + if (partStartIndex < text.length) { + parts.push({ + isMatch: false, + startIndex: partStartIndex, + value: text.slice(partStartIndex), + }); + } + + return parts.length + ? parts + : [{ isMatch: false, startIndex: 0, value: text }]; +} + +function HighlightedText({ + text, + query, +}: { + readonly text: string; + readonly query: string; +}) { + const trimmed = query.trim(); + if (!trimmed) { + return <>{text}; + } + + const parts = getHighlightedTextParts(text, trimmed); + + return ( + <> + {parts.map((part) => ( + + {part.value} + + ))} + + ); +} + +function CreateWaveGroupSearchResultOption({ + group, + index, + listboxId, + searchCriteria, + isActive, + isSelected, + onHover, + onSelect, +}: { + readonly group: ApiGroupFull; + readonly index: number; + readonly listboxId: string; + readonly searchCriteria: string; + readonly isActive: boolean; + readonly isSelected: boolean; + readonly onHover: (index: number) => void; + readonly onSelect: (group: ApiGroupFull) => void; +}) { + const optionStateClasses = + isActive || isSelected + ? "tw-bg-iron-800 tw-text-white" + : "tw-text-white hover:tw-bg-iron-800"; + + return ( +
  • onHover(index)} + onMouseDown={(event) => event.preventDefault()} + onClick={() => onSelect(group)} + > +
    + + + + + + +
    +
  • + ); +} + +function CreateWaveGroupSearchResultsList({ + isFetching, + suggestions, + selectedGroupId, + activeIndex, + listboxId, + searchCriteria, + showNoResults, + helperText, + onActiveIndexChange, + onOptionSelect, +}: { + readonly isFetching: boolean; + readonly suggestions: readonly ApiGroupFull[]; + readonly selectedGroupId: string | null; + readonly activeIndex: number; + readonly listboxId: string; + readonly searchCriteria: string; + readonly showNoResults: boolean; + readonly helperText: string; + readonly onActiveIndexChange: (index: number) => void; + readonly onOptionSelect: (group: ApiGroupFull) => void; +}) { + if (isFetching) { + return ( +
  • +
    + Loading groups + +
    +
  • + ); + } + + if (suggestions.length > 0) { + return ( + <> + {suggestions.map((group, index) => ( + + ))} + + ); + } + + return ( +
  • + {showNoResults ? "No groups found" : helperText} +
  • + ); +} + +export default function CreateWaveGroupSearchResults({ + isOpen, + disabled, + listboxId, + isFetching, + suggestions, + selectedGroup, + activeIndex, + searchCriteria, + showNoResults, + helperText, + onActiveIndexChange, + onOptionSelect, +}: { + readonly isOpen: boolean; + readonly disabled: boolean; + readonly listboxId: string; + readonly isFetching: boolean; + readonly suggestions: readonly ApiGroupFull[]; + readonly selectedGroup: ApiGroupFull | null; + readonly activeIndex: number; + readonly searchCriteria: string; + readonly showNoResults: boolean; + readonly helperText: string; + readonly onActiveIndexChange: (index: number) => void; + readonly onOptionSelect: (group: ApiGroupFull) => void; +}) { + return ( + + + {isOpen && !disabled && ( + +
    +
    +
      + +
    +
    +
    +
    + )} +
    +
    + ); +} diff --git a/components/waves/create-wave/groups/CreateWaveGroups.tsx b/components/waves/create-wave/groups/CreateWaveGroups.tsx index c66b33ead5..ed7c6f38fa 100644 --- a/components/waves/create-wave/groups/CreateWaveGroups.tsx +++ b/components/waves/create-wave/groups/CreateWaveGroups.tsx @@ -1,5 +1,6 @@ import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; import type { ApiWaveType } from "@/generated/models/ApiWaveType"; +import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; import { CREATE_WAVE_GROUPS } from "@/helpers/waves/waves.constants"; import type { CreateWaveGroupConfigType, @@ -7,13 +8,6 @@ import type { } from "@/types/waves.types"; import CreateWaveWarning from "../utils/CreateWaveWarning"; import CreateWaveGroup from "./CreateWaveGroup"; -import type { CommunityMemberMinimal } from "@/entities/IProfile"; -import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; -import type { - CreateWaveInlineGroupBuilderState, - CreateWaveInlineGroupPanel, - CreateWaveInlineGroupRuleType, -} from "./createWaveInlineGroupBuilder"; export default function CreateWaveGroups({ waveName, @@ -24,14 +18,7 @@ export default function CreateWaveGroups({ chatEnabled, adminCanDeleteDrops, groupsCache, - groupBuilders, setChatEnabled, - setGroupBuilderPanel, - setGroupBuilderRule, - setGroupBuilderDraft, - addGroupBuilderIdentity, - removeGroupBuilderIdentity, - resetGroupBuilder, setDropsAdminCanDelete, }: { readonly waveName: string; @@ -47,32 +34,7 @@ export default function CreateWaveGroups({ readonly chatEnabled: boolean; readonly adminCanDeleteDrops: boolean; readonly groupsCache: Record; - readonly groupBuilders: Record< - CreateWaveGroupConfigType, - CreateWaveInlineGroupBuilderState - >; readonly setChatEnabled: (enabled: boolean) => void; - readonly setGroupBuilderPanel: ( - groupType: CreateWaveGroupConfigType, - panel: CreateWaveInlineGroupPanel - ) => void; - readonly setGroupBuilderRule: ( - groupType: CreateWaveGroupConfigType, - rule: CreateWaveInlineGroupRuleType | null - ) => void; - readonly setGroupBuilderDraft: ( - groupType: CreateWaveGroupConfigType, - draft: ApiCreateGroup - ) => void; - readonly addGroupBuilderIdentity: ( - groupType: CreateWaveGroupConfigType, - identity: CommunityMemberMinimal - ) => void; - readonly removeGroupBuilderIdentity: ( - groupType: CreateWaveGroupConfigType, - wallet: string - ) => void; - readonly resetGroupBuilder: (groupType: CreateWaveGroupConfigType) => void; readonly setDropsAdminCanDelete: (adminCanDeleteDrops: boolean) => void; }) { const isRestrictedGroup = !!groups.admin && !!groups.canView; @@ -88,25 +50,10 @@ export default function CreateWaveGroups({ chatEnabled={chatEnabled} groupsCache={groupsCache} groups={groups} - groupBuilder={groupBuilders[groupType]} adminCanDeleteDrops={adminCanDeleteDrops} setChatEnabled={setChatEnabled} onGroupSelect={(group) => onGroupSelect({ group, groupType })} onInlineGroupCreate={onInlineGroupCreate} - setGroupBuilderPanel={(panel) => - setGroupBuilderPanel(groupType, panel) - } - setGroupBuilderRule={(rule) => setGroupBuilderRule(groupType, rule)} - setGroupBuilderDraft={(draft) => - setGroupBuilderDraft(groupType, draft) - } - addGroupBuilderIdentity={(identity) => - addGroupBuilderIdentity(groupType, identity) - } - removeGroupBuilderIdentity={(wallet) => - removeGroupBuilderIdentity(groupType, wallet) - } - resetGroupBuilder={() => resetGroupBuilder(groupType)} setDropsAdminCanDelete={setDropsAdminCanDelete} /> ))} diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx index 10a7dc57de..0509a0fcd3 100644 --- a/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx +++ b/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx @@ -5,11 +5,13 @@ export default function CreateWaveInlineGroupSearch({ defaultLabel, disabled, selectedGroup, + allowGroupClear = true, onSelect, }: { readonly defaultLabel: string; readonly disabled: boolean; readonly selectedGroup: ApiGroupFull | null; + readonly allowGroupClear?: boolean; readonly onSelect: (group: ApiGroupFull | null) => void; }) { return ( @@ -19,6 +21,7 @@ export default function CreateWaveInlineGroupSearch({ defaultLabel={defaultLabel} disabled={disabled} selectedGroup={selectedGroup} + allowClear={allowGroupClear} onSelect={onSelect} />
    diff --git a/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts b/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts index ff57f83da9..e941a55a22 100644 --- a/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts +++ b/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts @@ -4,7 +4,6 @@ import type { CommunityMemberMinimal } from "@/entities/IProfile"; import { ApiGroupFilterDirection } from "@/generated/models/ApiGroupFilterDirection"; import { ApiGroupTdhInclusionStrategy } from "@/generated/models/ApiGroupTdhInclusionStrategy"; import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; -import { CreateWaveGroupConfigType } from "@/types/waves.types"; export type CreateWaveInlineGroupPanel = | "actions" @@ -97,17 +96,6 @@ export const createInitialInlineGroupBuilderState = activeRule: null, }); -export const createInitialInlineGroupBuilderMap = (): Record< - CreateWaveGroupConfigType, - CreateWaveInlineGroupBuilderState -> => ({ - [CreateWaveGroupConfigType.CAN_VIEW]: createInitialInlineGroupBuilderState(), - [CreateWaveGroupConfigType.CAN_DROP]: createInitialInlineGroupBuilderState(), - [CreateWaveGroupConfigType.CAN_VOTE]: createInitialInlineGroupBuilderState(), - [CreateWaveGroupConfigType.CAN_CHAT]: createInitialInlineGroupBuilderState(), - [CreateWaveGroupConfigType.ADMIN]: createInitialInlineGroupBuilderState(), -}); - const normalizeAddress = (address: string): string => address.trim().toLowerCase(); diff --git a/components/waves/create-wave/groups/useCreateWaveGroupSearch.ts b/components/waves/create-wave/groups/useCreateWaveGroupSearch.ts new file mode 100644 index 0000000000..1082a45fe8 --- /dev/null +++ b/components/waves/create-wave/groups/useCreateWaveGroupSearch.ts @@ -0,0 +1,397 @@ +import { + type Dispatch, + useCallback, + useId, + useState, + type KeyboardEvent, + type RefObject, + type SetStateAction, +} from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useClickAway, useDebounce, useKeyPressEvent } from "react-use"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { GroupsRequestParams } from "@/entities/IGroup"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import type { Mutable, NonNullableNotRequired } from "@/helpers/Types"; +import { commonApiFetch } from "@/services/api/common-api"; + +const MAX_RESULTS = 7; +const DEBOUNCE_MS = 200; + +type CreateWaveGroupSearchParams = { + readonly defaultLabel: string; + readonly disabled: boolean; + readonly inputRef: RefObject; + readonly selectedGroup: ApiGroupFull | null; + readonly allowClear: boolean; + readonly onSelect: (group: ApiGroupFull | null) => void; + readonly wrapperRef: RefObject; +}; + +type CreateWaveGroupSearchState = { + readonly selectedGroupKey: string | null; + readonly inputValue: string; + readonly searchCriteria: string; + readonly activeIndex: number; +}; + +function getSelectedGroupKey(group: ApiGroupFull | null): string | null { + if (!group) { + return null; + } + + return JSON.stringify([group.id, group.name]); +} + +function createSearchState({ + selectedGroupKey, + selectedGroupName, +}: { + readonly selectedGroupKey: string | null; + readonly selectedGroupName: string; +}): CreateWaveGroupSearchState { + return { + selectedGroupKey, + inputValue: selectedGroupName, + searchCriteria: selectedGroupName, + activeIndex: -1, + }; +} + +function getEffectiveSearchState({ + searchState, + selectedGroupKey, + selectedGroupName, +}: { + readonly searchState: CreateWaveGroupSearchState; + readonly selectedGroupKey: string | null; + readonly selectedGroupName: string; +}): CreateWaveGroupSearchState { + if (searchState.selectedGroupKey === selectedGroupKey) { + return searchState; + } + + return createSearchState({ selectedGroupKey, selectedGroupName }); +} + +function useCreateWaveGroupSuggestions({ + debouncedValue, + disabled, + isOpen, +}: { + readonly debouncedValue: string; + readonly disabled: boolean; + readonly isOpen: boolean; +}) { + const { data, isFetching } = useQuery({ + queryKey: [QueryKey.GROUPS, { group_name: debouncedValue || null }], + queryFn: async () => { + const params: Mutable> = {}; + if (debouncedValue) { + params.group_name = debouncedValue; + } + + return await commonApiFetch< + ApiGroupFull[], + NonNullableNotRequired + >({ + endpoint: "groups", + params, + }); + }, + enabled: isOpen && !disabled, + placeholderData: keepPreviousData, + }); + + return { + isFetching, + suggestions: (data ?? []).slice(0, MAX_RESULTS), + }; +} + +function useCreateWaveGroupKeyboardNavigation({ + activeIndex, + closeSearch, + isOpen, + onOptionSelect, + setActiveIndex, + setIsOpen, + suggestions, +}: { + readonly activeIndex: number; + readonly closeSearch: () => void; + readonly isOpen: boolean; + readonly onOptionSelect: (group: ApiGroupFull) => void; + readonly setActiveIndex: Dispatch>; + readonly setIsOpen: Dispatch>; + readonly suggestions: readonly ApiGroupFull[]; +}) { + return useCallback( + (event: KeyboardEvent) => { + if (!isOpen) { + if (event.key === "ArrowDown") { + setIsOpen(true); + setActiveIndex(0); + event.preventDefault(); + } + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((prev) => { + const nextIndex = prev + 1; + if (nextIndex >= suggestions.length) { + return suggestions.length ? 0 : -1; + } + return nextIndex; + }); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((prev) => { + if (suggestions.length === 0) { + return -1; + } + if (prev <= 0) { + return suggestions.length - 1; + } + return prev - 1; + }); + } else if (event.key === "Enter") { + if (activeIndex >= 0 && activeIndex < suggestions.length) { + event.preventDefault(); + onOptionSelect(suggestions[activeIndex]!); + } + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeSearch(); + } + }, + [ + activeIndex, + closeSearch, + isOpen, + onOptionSelect, + setActiveIndex, + setIsOpen, + suggestions, + ] + ); +} + +function useCreateWaveGroupSearchState({ + selectedGroupKey, + selectedGroupName, +}: { + readonly selectedGroupKey: string | null; + readonly selectedGroupName: string; +}) { + const [searchState, setSearchState] = useState( + () => createSearchState({ selectedGroupKey, selectedGroupName }) + ); + const [debouncedValue, setDebouncedValue] = + useState(selectedGroupName); + const effectiveSearchState = getEffectiveSearchState({ + searchState, + selectedGroupKey, + selectedGroupName, + }); + const { activeIndex, inputValue, searchCriteria } = effectiveSearchState; + + const updateSearchState = useCallback( + ( + getNextState: ( + current: CreateWaveGroupSearchState + ) => CreateWaveGroupSearchState + ) => { + setSearchState((current) => { + const effectiveCurrent = getEffectiveSearchState({ + searchState: current, + selectedGroupKey, + selectedGroupName, + }); + + return getNextState(effectiveCurrent); + }); + }, + [selectedGroupKey, selectedGroupName] + ); + + const setActiveIndex = useCallback>>( + (nextActiveIndex) => { + updateSearchState((current) => ({ + ...current, + activeIndex: + typeof nextActiveIndex === "function" + ? nextActiveIndex(current.activeIndex) + : nextActiveIndex, + })); + }, + [updateSearchState] + ); + + useDebounce( + () => { + setDebouncedValue(searchCriteria.trim()); + }, + DEBOUNCE_MS, + [searchCriteria] + ); + + return { + activeIndex, + debouncedValue, + inputValue, + searchCriteria, + setActiveIndex, + setSearchState, + }; +} + +export function useCreateWaveGroupSearch({ + defaultLabel, + disabled, + inputRef, + selectedGroup, + allowClear, + onSelect, + wrapperRef, +}: CreateWaveGroupSearchParams) { + const baseId = useId(); + const inputId = `${baseId}-input`; + const listboxId = `${baseId}-listbox`; + const selectedGroupKey = getSelectedGroupKey(selectedGroup); + const selectedGroupName = selectedGroup?.name ?? ""; + const [isOpen, setIsOpen] = useState(false); + const { + activeIndex, + debouncedValue, + inputValue, + searchCriteria, + setActiveIndex, + setSearchState, + } = useCreateWaveGroupSearchState({ + selectedGroupKey, + selectedGroupName, + }); + + const { isFetching, suggestions } = useCreateWaveGroupSuggestions({ + debouncedValue, + disabled, + isOpen, + }); + + const closeSearch = useCallback(() => { + setIsOpen(false); + setActiveIndex(-1); + }, [setActiveIndex]); + + const clearSelection = useCallback(() => { + if (!allowClear || disabled) { + return; + } + + setSearchState({ + selectedGroupKey: null, + inputValue: "", + searchCriteria: "", + activeIndex: -1, + }); + onSelect(null); + setIsOpen(true); + inputRef.current?.focus(); + }, [allowClear, disabled, inputRef, onSelect, setSearchState]); + + useClickAway(wrapperRef, () => { + if (!isOpen) { + return; + } + closeSearch(); + }); + + useKeyPressEvent("Escape", () => { + if (!isOpen) { + return; + } + closeSearch(); + }); + + const onInputFocus = () => { + if (disabled) { + return; + } + setIsOpen(true); + setActiveIndex(-1); + }; + + const handleInputChange = (value: string) => { + setSearchState({ + selectedGroupKey: selectedGroup && allowClear ? null : selectedGroupKey, + inputValue: value, + searchCriteria: value, + activeIndex: -1, + }); + setIsOpen(true); + if (selectedGroup && allowClear) { + onSelect(null); + } + }; + + const onOptionSelect = useCallback( + (group: ApiGroupFull) => { + setSearchState({ + selectedGroupKey, + inputValue: group.name, + searchCriteria: group.name, + activeIndex: -1, + }); + onSelect(group); + closeSearch(); + inputRef.current?.focus(); + }, + [closeSearch, inputRef, onSelect, selectedGroupKey, setSearchState] + ); + + const handleKeyDown = useCreateWaveGroupKeyboardNavigation({ + activeIndex, + closeSearch, + isOpen, + onOptionSelect, + setActiveIndex, + setIsOpen, + suggestions, + }); + + const hasValue = inputValue.trim().length > 0; + const helperText = selectedGroup + ? `Selected: ${selectedGroup.name}` + : `Default: ${defaultLabel}`; + const showClearButton = + allowClear && (hasValue || !!selectedGroup) && !disabled; + const showNoResults = !isFetching && isOpen && suggestions.length === 0; + const activeOptionId = + activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined; + + return { + activeIndex, + activeOptionId, + clearSelection, + handleInputChange, + handleKeyDown, + hasValue, + helperText, + inputId, + inputValue, + isFetching, + isOpen, + listboxId, + onInputFocus, + onOptionSelect, + searchCriteria, + setActiveIndex, + showClearButton, + showNoResults, + suggestions, + }; +} diff --git a/components/waves/create-wave/hooks/useWaveConfig.ts b/components/waves/create-wave/hooks/useWaveConfig.ts index bf9a646b19..aabea0efb9 100644 --- a/components/waves/create-wave/hooks/useWaveConfig.ts +++ b/components/waves/create-wave/hooks/useWaveConfig.ts @@ -1,8 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import type { CommunityMemberMinimal } from "@/entities/IProfile"; -import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; import type { CreateWaveConfig, CreateWaveOutcomeType, @@ -17,15 +15,6 @@ import type { Period } from "../types/period"; import type { CREATE_WAVE_VALIDATION_ERROR } from "@/helpers/waves/create-wave.validation"; import { getCreateWaveValidationErrors } from "@/helpers/waves/create-wave.validation"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; -import { - createInitialInlineGroupBuilderMap, - createInitialInlineGroupBuilderState, - dedupeInlineIdentities, - getInlineIdentityAddresses, - type CreateWaveInlineGroupBuilderState, - type CreateWaveInlineGroupPanel, - type CreateWaveInlineGroupRuleType, -} from "../groups/createWaveInlineGroupBuilder"; interface EndDateConfig { time: number | null; @@ -115,9 +104,6 @@ export function useWaveConfig() { const [groupsCache, setGroupsCache] = useState>( {} ); - const [groupBuilders, setGroupBuilders] = useState< - Record - >(createInitialInlineGroupBuilderMap()); // Update end date config when config changes useEffect(() => { @@ -137,7 +123,6 @@ export function useWaveConfig() { ...getInitialConfig({ type: overview.type }), overview, })); - setGroupBuilders(createInitialInlineGroupBuilderMap()); }; const setDates = (dates: CreateWaveConfig["dates"]) => { @@ -262,101 +247,6 @@ export function useWaveConfig() { } }; - const updateGroupBuilder = ( - groupType: CreateWaveGroupConfigType, - updater: ( - current: CreateWaveInlineGroupBuilderState - ) => CreateWaveInlineGroupBuilderState - ) => { - setGroupBuilders((prev) => ({ - ...prev, - [groupType]: updater(prev[groupType]), - })); - }; - - const setGroupBuilderPanel = ( - groupType: CreateWaveGroupConfigType, - panel: CreateWaveInlineGroupPanel - ) => { - updateGroupBuilder(groupType, (current) => ({ - ...current, - panel, - })); - }; - - const setGroupBuilderRule = ( - groupType: CreateWaveGroupConfigType, - rule: CreateWaveInlineGroupRuleType | null - ) => { - updateGroupBuilder(groupType, (current) => ({ - ...current, - activeRule: rule, - panel: rule === null ? current.panel : "rule-editor", - })); - }; - - const setGroupBuilderDraft = ( - groupType: CreateWaveGroupConfigType, - draft: ApiCreateGroup - ) => { - updateGroupBuilder(groupType, (current) => ({ - ...current, - draft, - })); - }; - - const addGroupBuilderIdentity = ( - groupType: CreateWaveGroupConfigType, - identity: CommunityMemberMinimal - ) => { - updateGroupBuilder(groupType, (current) => { - const identities = dedupeInlineIdentities([ - ...current.identities, - identity, - ]); - return { - ...current, - identities, - draft: { - ...current.draft, - group: { - ...current.draft.group, - identity_addresses: getInlineIdentityAddresses(identities), - }, - }, - }; - }); - }; - - const removeGroupBuilderIdentity = ( - groupType: CreateWaveGroupConfigType, - wallet: string - ) => { - const normalizedWallet = wallet.trim().toLowerCase(); - updateGroupBuilder(groupType, (current) => { - const identities = current.identities.filter((identity) => { - const key = identity.wallet.trim().toLowerCase(); - return key !== normalizedWallet; - }); - - return { - ...current, - identities, - draft: { - ...current.draft, - group: { - ...current.draft.group, - identity_addresses: getInlineIdentityAddresses(identities), - }, - }, - }; - }); - }; - - const resetGroupBuilder = (groupType: CreateWaveGroupConfigType) => { - updateGroupBuilder(groupType, () => createInitialInlineGroupBuilderState()); - }; - // Voting type changes const onVotingTypeChange = (type: ApiWaveCreditType) => { setConfig((prev) => ({ @@ -440,7 +330,6 @@ export function useWaveConfig() { selectedOutcomeType, errors, groupsCache, - groupBuilders, // Section updaters setOverview, setDates, @@ -453,12 +342,6 @@ export function useWaveConfig() { onOutcomeTypeChange, // Group handling onGroupSelect, - setGroupBuilderPanel, - setGroupBuilderRule, - setGroupBuilderDraft, - addGroupBuilderIdentity, - removeGroupBuilderIdentity, - resetGroupBuilder, // Voting onVotingTypeChange, onCategoryChange, diff --git a/components/waves/specs/groups/group/edit/WaveGroupChangeDialog.tsx b/components/waves/specs/groups/group/edit/WaveGroupChangeDialog.tsx new file mode 100644 index 0000000000..986f687171 --- /dev/null +++ b/components/waves/specs/groups/group/edit/WaveGroupChangeDialog.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FocusTrap } from "focus-trap-react"; +import { useId, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; +import { useClickAway, useKeyPressEvent } from "react-use"; +import CreateWaveGroupInlinePanel from "@/components/waves/create-wave/groups/CreateWaveGroupInlinePanel"; +import { buildInlineGroupName } from "@/components/waves/create-wave/groups/createWaveInlineGroupBuilder"; +import { + CREATE_WAVE_NONE_GROUP_LABELS, + CREATE_WAVE_SELECT_GROUP_LABELS, +} from "@/helpers/waves/waves.constants"; +import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; +import type { ApiGroup } from "@/generated/models/ApiGroup"; +import { ApiGroupFilterDirection } from "@/generated/models/ApiGroupFilterDirection"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import { ApiGroupTdhInclusionStrategy } from "@/generated/models/ApiGroupTdhInclusionStrategy"; +import type { ApiProfileMin } from "@/generated/models/ApiProfileMin"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { CreateWaveGroupConfigType } from "@/types/waves.types"; +import { WaveGroupType } from "../WaveGroup.types"; + +const WAVE_GROUP_TO_CREATE_GROUP_TYPE = { + [WaveGroupType.VIEW]: CreateWaveGroupConfigType.CAN_VIEW, + [WaveGroupType.DROP]: CreateWaveGroupConfigType.CAN_DROP, + [WaveGroupType.VOTE]: CreateWaveGroupConfigType.CAN_VOTE, + [WaveGroupType.CHAT]: CreateWaveGroupConfigType.CAN_CHAT, + [WaveGroupType.ADMIN]: CreateWaveGroupConfigType.ADMIN, +} satisfies Record; + +const createEmptyGroupDescription = (): ApiGroupFull["group"] => ({ + tdh: { + min: null, + max: null, + inclusion_strategy: ApiGroupTdhInclusionStrategy.Both, + }, + 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: null, + identity_group_identities_count: 0, + excluded_identity_group_id: null, + excluded_identity_group_identities_count: 0, + is_beneficiary_of_grant_id: null, + is_beneficiary_of_grant: null, +}); + +const createUnknownGroupAuthor = (): ApiProfileMin => ({ + id: "unknown", + handle: null, + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 0, + rep: 0, + tdh: 0, + tdh_rate: 0, + xtdh: 0, + xtdh_rate: 0, + level: 0, + primary_address: "", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + profile_wave_id: null, + is_wave_creator: false, +}); + +const getSelectedGroup = (group: ApiGroup | null): ApiGroupFull | null => { + if (!group?.id || !group.name) { + return null; + } + + return { + id: group.id, + name: group.name, + group: createEmptyGroupDescription(), + created_at: group.created_at ?? 0, + created_by: group.author ?? createUnknownGroupAuthor(), + visible: !group.is_hidden, + is_private: false, + is_direct_message: group.is_direct_message ?? false, + }; +}; + +export default function WaveGroupChangeDialog({ + wave, + type, + currentGroup, + onClose, + onGroupChange, + onCreateGroup, +}: { + readonly wave: ApiWave; + readonly type: WaveGroupType; + readonly currentGroup: ApiGroup | null; + readonly onClose: () => void; + readonly onGroupChange: (group: ApiGroupFull | null) => void | Promise; + readonly onCreateGroup: ( + payload: ApiCreateGroup + ) => Promise; +}) { + const modalRef = useRef(null); + const titleId = useId(); + const descriptionId = useId(); + const selectedGroup = useMemo( + () => getSelectedGroup(currentGroup), + [currentGroup] + ); + const groupConfigType = WAVE_GROUP_TO_CREATE_GROUP_TYPE[type]; + const groupLabel = + CREATE_WAVE_SELECT_GROUP_LABELS[wave.wave.type][groupConfigType]; + const defaultLabel = CREATE_WAVE_NONE_GROUP_LABELS[groupConfigType]; + const suggestedName = buildInlineGroupName({ + waveName: wave.name, + groupLabel, + }); + const title = selectedGroup ? "Change group" : "Add group"; + const description = selectedGroup + ? "Create a new group or choose a different existing group." + : "Create a new group or choose an existing group."; + + 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(); + }); + + return createPortal( + modalRef.current ?? document.body, + }} + > +
    +
    +
    +
    + +
    +
    +

    + {title} +

    +

    + {description} +

    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    , + document.body + ); +} diff --git a/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx b/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx index 30ad52da8e..2d8b51d8ba 100644 --- a/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx +++ b/components/waves/specs/groups/group/edit/WaveGroupEditButtons.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useContext } from "react"; +import { useCallback, useContext, useMemo, useRef, useState } 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"; @@ -16,6 +16,14 @@ import { WaveGroupManageIdentitiesMode, type WaveGroupManageIdentitiesConfirmEvent, } from "./WaveGroupManageIdentitiesModal"; +import WaveGroupChangeDialog from "./WaveGroupChangeDialog"; +import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup"; +import type { ApiGroupFull } from "@/generated/models/ApiGroupFull"; +import { useGroupMutations } from "@/hooks/groups/useGroupMutations"; +import { + buildWaveUpdateBody, + getScopedGroup, +} from "./buttons/utils/waveGroupEdit"; interface WaveGroupEditButtonsProps { readonly haveGroup: boolean; @@ -28,9 +36,15 @@ export default function WaveGroupEditButtons({ wave, type, }: WaveGroupEditButtonsProps) { - const { setToast, requestAuth, connectedProfile } = - useContext(AuthContext); - const { onWaveCreated } = useContext(ReactQueryWrapperContext); + const { setToast, requestAuth, connectedProfile } = useContext(AuthContext); + const { onWaveCreated, onGroupCreate } = useContext(ReactQueryWrapperContext); + const [isGroupChangeOpen, setIsGroupChangeOpen] = useState(false); + const skipNextGroupChangeAuthRef = useRef(false); + const scopedGroup = useMemo(() => getScopedGroup(wave, type), [wave, type]); + const { submit: submitInlineGroup } = useGroupMutations({ + requestAuth, + onGroupCreate, + }); const { mutating, @@ -58,28 +72,87 @@ export default function WaveGroupEditButtons({ mode === WaveGroupManageIdentitiesMode.INCLUDE ? WaveGroupIdentitiesModal.INCLUDE : WaveGroupIdentitiesModal.EXCLUDE; - onIdentityConfirm({ identity, mode: normalizedMode }); + void onIdentityConfirm({ identity, mode: normalizedMode }); }, - [onIdentityConfirm], + [onIdentityConfirm] ); const handleIncludeIdentity = useCallback( () => openIdentitiesModal(WaveGroupIdentitiesModal.INCLUDE), - [openIdentitiesModal], + [openIdentitiesModal] ); const handleExcludeIdentity = useCallback( () => openIdentitiesModal(WaveGroupIdentitiesModal.EXCLUDE), - [openIdentitiesModal], + [openIdentitiesModal] + ); + + const handleChangeGroupOpen = useCallback(() => { + setIsGroupChangeOpen(true); + }, []); + + const handleChangeGroupClose = useCallback(() => { + skipNextGroupChangeAuthRef.current = false; + setIsGroupChangeOpen(false); + }, []); + + const handleGroupChange = useCallback( + async (group: ApiGroupFull | null) => { + if (!group) { + return; + } + + const skipAuth = skipNextGroupChangeAuthRef.current; + skipNextGroupChangeAuthRef.current = false; + + try { + await updateWave(buildWaveUpdateBody(wave, type, group.id), { + skipAuth, + }); + setIsGroupChangeOpen(false); + } catch { + // updateWave already surfaces mutation failures through the shared toast. + } + }, + [type, updateWave, wave] + ); + + const handleInlineGroupCreate = useCallback( + async (payload: ApiCreateGroup): Promise => { + const result = await submitInlineGroup({ + payload, + currentHandle: connectedProfile?.handle ?? null, + }); + + if (!result.ok) { + if (result.reason !== "auth") { + setToast({ + message: result.error, + type: "error", + }); + } + return null; + } + + setToast({ + message: "Group created.", + type: "success", + }); + skipNextGroupChangeAuthRef.current = true; + + return result.group; + }, + [connectedProfile?.handle, setToast, submitInlineGroup] ); if (mutating) { return ( + className="tw-inline-flex tw-items-center tw-gap-2" + > - Updating wave group identities + Updating wave group ); } @@ -96,7 +169,18 @@ export default function WaveGroupEditButtons({ canRemoveGroup={canRemoveGroup} onIncludeIdentity={handleIncludeIdentity} onExcludeIdentity={handleExcludeIdentity} + onChangeGroup={handleChangeGroupOpen} /> + {isGroupChangeOpen && ( + + )}