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,
+ }}
+ >
+
+
+
+
+
+
+
+
+ ,
+ 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 (
);
}
@@ -96,7 +169,18 @@ export default function WaveGroupEditButtons({
canRemoveGroup={canRemoveGroup}
onIncludeIdentity={handleIncludeIdentity}
onExcludeIdentity={handleExcludeIdentity}
+ onChangeGroup={handleChangeGroupOpen}
/>
+ {isGroupChangeOpen && (
+
+ )}