diff --git a/__tests__/components/waves/create-wave/CreateWave.test.tsx b/__tests__/components/waves/create-wave/CreateWave.test.tsx
index e8ca0a890c..8cdd1ec40f 100644
--- a/__tests__/components/waves/create-wave/CreateWave.test.tsx
+++ b/__tests__/components/waves/create-wave/CreateWave.test.tsx
@@ -56,6 +56,10 @@ jest.mock("@/components/waves/create-wave/services/multiPartUpload", () => ({
multiPartUpload: jest.fn(),
}));
+jest.mock("@/hooks/groups/useGroupMutations", () => ({
+ useGroupMutations: jest.fn(),
+}));
+
// Mock step components
jest.mock("@/components/waves/create-wave/overview/CreateWaveOverview", () => {
return function MockCreateWaveOverview() {
@@ -119,6 +123,7 @@ import { useAddWaveMutation } from "@/components/waves/create-wave/services/wave
import { getAdminGroupId } from "@/components/waves/create-wave/services/waveGroupService";
import { generateDropPart } from "@/components/waves/create-wave/services/waveMediaService";
import { getCreateNewWaveBody } from "@/helpers/waves/create-wave.helpers";
+import { useGroupMutations } from "@/hooks/groups/useGroupMutations";
const mockedUseRouter = useRouter as jest.Mock;
const mockedUseWaveConfig = useWaveConfig as jest.Mock;
@@ -127,6 +132,7 @@ const mockedGetCreateNewWaveBody = getCreateNewWaveBody as jest.Mock;
const mockedGenerateDropPart = generateDropPart as jest.Mock;
const mockedGetAdminGroupId = getAdminGroupId as jest.Mock;
const mockedMultiPartUpload = multiPartUpload as jest.Mock;
+const mockedUseGroupMutations = useGroupMutations as jest.Mock;
describe("CreateWave", () => {
const mockRouter = {
@@ -157,6 +163,7 @@ describe("CreateWave", () => {
const mockQueryContext = {
waitAndInvalidateDrops: jest.fn(),
onWaveCreated: jest.fn(),
+ onGroupCreate: jest.fn(),
};
const mockWaveConfig = {
@@ -211,6 +218,13 @@ 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(),
@@ -219,6 +233,12 @@ 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(),
@@ -243,6 +263,9 @@ describe("CreateWave", () => {
mockedMultiPartUpload.mockResolvedValue({
url: "https://example.com/image.jpg",
});
+ mockedUseGroupMutations.mockReturnValue({
+ submit: jest.fn(),
+ });
mockAuthContext.requestAuth.mockResolvedValue({ success: true });
// Mock URL.createObjectURL
diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx
index acd5164648..835f20229b 100644
--- a/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx
+++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroup.test.tsx
@@ -7,13 +7,7 @@ import { CreateWaveGroupConfigType } from "@/types/waves.types";
import { ApiWaveType } from "@/generated/models/ApiWaveType";
import type { ApiGroupFull } from "@/generated/models/ApiGroupFull";
-jest.mock("@tanstack/react-query", () => {
- const actual = jest.requireActual("@tanstack/react-query");
- return {
- ...actual,
- useQuery: jest.fn(),
- };
-});
+let inlinePanelProps: any;
jest.mock("@/components/waves/create-wave/utils/CreateWaveToggle", () => {
return function CreateWaveToggle({
@@ -41,54 +35,24 @@ jest.mock("@/components/waves/create-wave/utils/CreateWaveToggle", () => {
};
});
-jest.mock("@/helpers/waves/waves.constants", () => {
- const { CreateWaveGroupConfigType } = jest.requireActual(
- "../../../../../types/waves.types"
- );
- const { ApiWaveType } = jest.requireActual(
- "../../../../../generated/models/ApiWaveType"
- );
-
- return {
- CREATE_WAVE_NONE_GROUP_LABELS: {
- [CreateWaveGroupConfigType.ADMIN]: "Only me",
- [CreateWaveGroupConfigType.CAN_VIEW]: "Anyone",
- [CreateWaveGroupConfigType.CAN_DROP]: "Anyone",
- [CreateWaveGroupConfigType.CAN_VOTE]: "Anyone",
- [CreateWaveGroupConfigType.CAN_CHAT]: "Anyone",
- },
- CREATE_WAVE_SELECT_GROUP_LABELS: {
- [ApiWaveType.Approve]: {
- [CreateWaveGroupConfigType.ADMIN]: "Admin",
- [CreateWaveGroupConfigType.CAN_VIEW]: "Who can view",
- [CreateWaveGroupConfigType.CAN_DROP]: "Who can drop",
- [CreateWaveGroupConfigType.CAN_VOTE]: "Who can vote",
- [CreateWaveGroupConfigType.CAN_CHAT]: "Who can chat",
- },
- [ApiWaveType.Rank]: {
- [CreateWaveGroupConfigType.ADMIN]: "Admin",
- [CreateWaveGroupConfigType.CAN_VIEW]: "Who can view",
- [CreateWaveGroupConfigType.CAN_DROP]: "Who can drop",
- [CreateWaveGroupConfigType.CAN_VOTE]: "Who can vote",
- [CreateWaveGroupConfigType.CAN_CHAT]: "Who can chat",
- },
- [ApiWaveType.Chat]: {
- [CreateWaveGroupConfigType.ADMIN]: "Admin",
- [CreateWaveGroupConfigType.CAN_VIEW]: "Who can view",
- [CreateWaveGroupConfigType.CAN_DROP]: "Who can drop",
- [CreateWaveGroupConfigType.CAN_VOTE]: "Who can rate",
- [CreateWaveGroupConfigType.CAN_CHAT]: "Who can chat",
- },
- },
- };
-});
-
-const { useQuery } = jest.requireMock("@tanstack/react-query");
+jest.mock(
+ "@/components/waves/create-wave/groups/CreateWaveGroupInlinePanel",
+ () =>
+ function MockCreateWaveGroupInlinePanel(props: any) {
+ inlinePanelProps = props;
+ return (
+
+ {props.selectedGroup?.name ?? "none"}
+
+ );
+ }
+);
describe("CreateWaveGroup", () => {
const mockOnGroupSelect = jest.fn();
const mockSetChatEnabled = jest.fn();
const mockSetDropsAdminCanDelete = jest.fn();
+ const mockOnInlineGroupCreate = jest.fn();
const exampleGroup: ApiGroupFull = {
id: "group-1",
@@ -110,37 +74,57 @@ describe("CreateWaveGroup", () => {
};
const defaultProps = {
+ waveName: "Test Wave",
waveType: ApiWaveType.Approve,
groupType: CreateWaveGroupConfigType.CAN_DROP,
chatEnabled: true,
adminCanDeleteDrops: false,
setChatEnabled: mockSetChatEnabled,
onGroupSelect: mockOnGroupSelect,
+ 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,
};
beforeEach(() => {
jest.clearAllMocks();
- (useQuery as jest.Mock).mockReturnValue({
- data: [exampleGroup],
- isFetching: false,
- });
+ inlinePanelProps = null;
});
- const renderComponent = (props = {}) => {
- return render();
- };
+ const renderComponent = (props = {}) =>
+ render();
it("shows the scope title", () => {
renderComponent();
expect(screen.getByText("Who can drop")).toBeInTheDocument();
});
- it("renders the search field label", () => {
- renderComponent();
- expect(screen.getByLabelText("Search groups…")).toBeInTheDocument();
+ it("passes the resolved selected group to the inline panel", () => {
+ renderComponent({
+ groups: {
+ ...defaultGroups,
+ canDrop: exampleGroup.id,
+ },
+ groupsCache: {
+ [exampleGroup.id]: exampleGroup,
+ },
+ });
+
+ expect(screen.getByTestId("inline-panel")).toHaveTextContent("Alpha Group");
+ expect(inlinePanelProps.selectedGroup).toEqual(exampleGroup);
});
it("shows the chat toggle for non-chat waves when editing chat scope", async () => {
@@ -149,7 +133,6 @@ describe("CreateWaveGroup", () => {
groupType: CreateWaveGroupConfigType.CAN_CHAT,
});
- expect(screen.getByText("Enable chat")).toBeInTheDocument();
const chatToggle = screen.getByLabelText("Enable chat");
await user.click(chatToggle);
expect(mockSetChatEnabled).toHaveBeenCalledWith(false);
@@ -160,6 +143,7 @@ describe("CreateWaveGroup", () => {
groupType: CreateWaveGroupConfigType.CAN_CHAT,
waveType: ApiWaveType.Chat,
});
+
expect(screen.queryByTestId("wave-toggle")).not.toBeInTheDocument();
});
@@ -168,98 +152,17 @@ describe("CreateWaveGroup", () => {
renderComponent({
groupType: CreateWaveGroupConfigType.ADMIN,
});
- const toggle = screen.getByLabelText("Allow admins to delete posts");
- await user.click(toggle);
- expect(mockSetDropsAdminCanDelete).toHaveBeenCalledWith(true);
- });
-
- it("renders the admin delete toggle for chat waves", () => {
- renderComponent({
- groupType: CreateWaveGroupConfigType.ADMIN,
- waveType: ApiWaveType.Chat,
- });
-
- expect(
- screen.getByLabelText("Allow admins to delete posts")
- ).toBeInTheDocument();
- });
-
- it("does not render helper text under the admin toggle", () => {
- renderComponent({
- groupType: CreateWaveGroupConfigType.ADMIN,
- adminCanDeleteDrops: true,
- });
-
- expect(
- screen.queryByText("Admins will be able to delete posts.")
- ).not.toBeInTheDocument();
- });
- it("displays the helper text for defaults", () => {
- renderComponent();
- expect(screen.getByText("Default: Anyone")).toBeInTheDocument();
+ await user.click(screen.getByLabelText("Allow admins to delete posts"));
+ expect(mockSetDropsAdminCanDelete).toHaveBeenCalledWith(true);
});
- it("disables the search input when chat is disabled", () => {
+ it("passes disabled to the inline panel when chat is disabled", () => {
renderComponent({
groupType: CreateWaveGroupConfigType.CAN_CHAT,
chatEnabled: false,
});
- expect(screen.getByLabelText("Search groups…")).toBeDisabled();
- });
-
- it("pre-populates the field when a selection exists in cache", () => {
- renderComponent({
- groups: {
- ...defaultGroups,
- canDrop: exampleGroup.id,
- },
- groupsCache: {
- [exampleGroup.id]: exampleGroup,
- },
- });
-
- expect(screen.getByDisplayValue("Alpha Group")).toBeInTheDocument();
- expect(screen.getByText("Selected: Alpha Group")).toBeInTheDocument();
- });
-
- it("calls onGroupSelect when a suggestion is chosen", async () => {
- const user = userEvent.setup();
- renderComponent();
-
- await user.click(screen.getByLabelText("Search groups…"));
- await user.click(screen.getByText("Alpha Group"));
-
- expect(mockOnGroupSelect).toHaveBeenCalledWith(exampleGroup);
- });
-
- it("clears the selected group when using the clear button", async () => {
- const user = userEvent.setup();
- renderComponent({
- groups: {
- ...defaultGroups,
- canDrop: exampleGroup.id,
- },
- groupsCache: {
- [exampleGroup.id]: exampleGroup,
- },
- });
-
- const clearButton = screen.getByLabelText("Clear selected group");
- await user.click(clearButton);
- expect(mockOnGroupSelect).toHaveBeenCalledWith(null);
- });
-
- it("shows an empty state when no groups match", async () => {
- const user = userEvent.setup();
- (useQuery as jest.Mock).mockReturnValue({
- data: [],
- isFetching: false,
- });
-
- renderComponent();
- await user.click(screen.getByLabelText("Search groups…"));
- expect(await screen.findByText("No groups found")).toBeInTheDocument();
+ expect(inlinePanelProps.disabled).toBe(true);
});
});
diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx
new file mode 100644
index 0000000000..b340c8b2be
--- /dev/null
+++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.test.tsx
@@ -0,0 +1,468 @@
+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",
+ () =>
+ function MockCreateWaveInlineGroupIdentities(props: any) {
+ return (
+
+
+
+ );
+ }
+);
+
+jest.mock(
+ "@/components/waves/create-wave/groups/CreateWaveInlineGroupXtdhGrant",
+ () =>
+ function MockCreateWaveInlineGroupXtdhGrant() {
+ return xTDH Grant
;
+ }
+);
+
+jest.mock(
+ "@/components/waves/create-wave/groups/CreateWaveGroupSearchField",
+ () =>
+ function MockCreateWaveGroupSearchField(props: any) {
+ return (
+
+
{props.selectedGroup?.name ?? "No group selected"}
+
+
+ );
+ }
+);
+
+jest.mock("@/components/groups/page/create/config/GroupCreateLevel", () => {
+ return function MockGroupCreateLevel() {
+ return Level
;
+ };
+});
+
+jest.mock("@/components/groups/page/create/config/GroupCreateTDH", () => {
+ return function MockGroupCreateTDH() {
+ return TDH
;
+ };
+});
+
+jest.mock("@/components/groups/page/create/config/GroupCreateCIC", () => {
+ return function MockGroupCreateCIC() {
+ return NIC
;
+ };
+});
+
+jest.mock("@/components/groups/page/create/config/GroupCreateRep", () => {
+ return function MockGroupCreateRep() {
+ return Rep
;
+ };
+});
+
+jest.mock(
+ "@/components/groups/page/create/config/nfts/GroupCreateCollections",
+ () => {
+ return function MockGroupCreateCollections() {
+ return Collections
;
+ };
+ }
+);
+
+jest.mock("@/components/groups/page/create/config/nfts/GroupCreateNfts", () => {
+ return function MockGroupCreateNfts() {
+ return NFTs
;
+ };
+});
+
+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),
+ selectedGroup = null,
+ disabled = false,
+}: {
+ readonly initialBuilder?: CreateWaveInlineGroupBuilderState;
+ readonly onGroupSelect?: jest.Mock;
+ readonly onInlineGroupCreate?: 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,
+ }));
+ };
+
+ const updateRule = (rule: CreateWaveInlineGroupRuleType | null) => {
+ setBuilder((prev) => ({
+ ...prev,
+ activeRule: rule,
+ panel: rule ? "rule-editor" : prev.panel,
+ }));
+ };
+
+ 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())
+ }
+ />
+ );
+}
+
+describe("CreateWaveGroupInlinePanel", () => {
+ it("renders the current state and primary actions", () => {
+ render();
+
+ expect(screen.getByText("Current state")).toBeInTheDocument();
+ expect(screen.getByText("Anyone")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Add identity" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Add rule" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Use existing group" })
+ ).toBeInTheDocument();
+ });
+
+ it("opens the identity panel", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add identity" }));
+
+ expect(
+ screen.getByRole("button", { name: "Add identity" })
+ ).toHaveAttribute("aria-pressed", "true");
+ expect(
+ screen.queryByRole("button", { name: "Back to options" })
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId("identities-panel")).toBeInTheDocument();
+ });
+
+ it("returns to options when the active identity pill is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add identity" }));
+ await user.click(screen.getByRole("button", { name: "Add identity" }));
+
+ expect(screen.queryByTestId("identities-panel")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Use existing group" })
+ ).toBeInTheDocument();
+ });
+
+ it("opens a quick rule editor", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add rule" }));
+ await user.click(screen.getByRole("button", { name: "TDH" }));
+
+ expect(screen.getByTestId("rule-tdh")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Add rule" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(screen.getByRole("button", { name: "TDH" })).toHaveAttribute(
+ "aria-pressed",
+ "true"
+ );
+ expect(
+ screen.queryByRole("button", { name: "Back to rules" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("returns to rule options when the active rule pill is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add rule" }));
+ await user.click(screen.getByRole("button", { name: "TDH" }));
+ await user.click(screen.getByRole("button", { name: "TDH" }));
+
+ expect(screen.queryByTestId("rule-tdh")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "TDH" })).not.toHaveAttribute(
+ "aria-pressed"
+ );
+ });
+
+ it("shows all rule options without an extra more-rules step", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add rule" }));
+
+ expect(
+ screen.getByRole("button", { name: "Required NFTs" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Collection Access" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "xTDH Grant" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "More rules" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("returns to options when the active existing group pill is clicked", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Use existing group" })
+ );
+ expect(screen.getByTestId("group-search")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Use existing group" })
+ ).toHaveAttribute("aria-pressed", "true");
+
+ await user.click(
+ screen.getByRole("button", { name: "Use existing group" })
+ );
+
+ expect(screen.queryByTestId("group-search")).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Add identity" })
+ ).toBeInTheDocument();
+ });
+
+ it("returns to the actions view after selecting an existing group", async () => {
+ const user = userEvent.setup();
+ const onGroupSelect = jest.fn();
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: "Use existing group" })
+ );
+ await user.click(screen.getByRole("button", { name: "select group" }));
+
+ expect(onGroupSelect).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "Selected Group" })
+ );
+ expect(
+ screen.getByRole("button", { name: "Add identity" })
+ ).toBeInTheDocument();
+ });
+
+ it("creates and attaches a valid inline group draft", 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,
+ };
+
+ render(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Create + use" }));
+
+ await waitFor(() => {
+ expect(onInlineGroupCreate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "My Wave Who can view",
+ })
+ );
+ });
+ expect(onGroupSelect).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(
+
+ );
+
+ expect(screen.getByRole("button", { name: "Create + use" })).toBeDisabled();
+ const startOverButton = screen.getByRole("button", { name: "Start over" });
+ expect(startOverButton).toBeEnabled();
+
+ await user.click(startOverButton);
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText("Ready to create this inline group")
+ ).not.toBeInTheDocument();
+ });
+ expect(
+ screen.getByRole("button", { name: "Add identity" })
+ ).toBeInTheDocument();
+ });
+
+ 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(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Rep" }));
+
+ expect(screen.getByTestId("rule-rep")).toBeInTheDocument();
+ });
+
+ it("updates the draft summary after adding an identity", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Add identity" }));
+ await user.click(screen.getByRole("button", { name: "add identity" }));
+
+ expect(
+ screen.getByRole("button", { name: "1 identity" })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Add rule" })
+ ).toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx
index 396eaa6ae4..e4ce2e8108 100644
--- a/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx
+++ b/__tests__/components/waves/create-wave/groups/CreateWaveGroups.test.tsx
@@ -1,31 +1,52 @@
-import { render, screen } from '@testing-library/react';
-import CreateWaveGroups from '@/components/waves/create-wave/groups/CreateWaveGroups';
-import { ApiWaveType } from '@/generated/models/ApiWaveType';
-import { CREATE_WAVE_GROUPS } from '@/helpers/waves/waves.constants';
+import { render, screen } from "@testing-library/react";
+import CreateWaveGroups from "@/components/waves/create-wave/groups/CreateWaveGroups";
+import { ApiWaveType } from "@/generated/models/ApiWaveType";
+import { CREATE_WAVE_GROUPS } from "@/helpers/waves/waves.constants";
-jest.mock('@/components/waves/create-wave/groups/CreateWaveGroup', () => (props: any) => (
- {props.groupType}
-));
-jest.mock('@/components/waves/create-wave/utils/CreateWaveWarning', () => (props: any) => (
- {props.title}
-));
+jest.mock(
+ "@/components/waves/create-wave/groups/CreateWaveGroup",
+ () => (props: any) => {props.groupType}
+);
+jest.mock(
+ "@/components/waves/create-wave/utils/CreateWaveWarning",
+ () => (props: any) => {props.title}
+);
-describe('CreateWaveGroups', () => {
- it('renders groups and warning when restricted', () => {
- const groups = { admin: '1', canView: '2' } as any;
+describe("CreateWaveGroups", () => {
+ it("renders groups and warning when restricted", () => {
+ const groups = { admin: "1", canView: "2" } as any;
render(
);
- expect(screen.getAllByTestId('group')).toHaveLength(CREATE_WAVE_GROUPS[ApiWaveType.Rank].length);
- expect(screen.getByTestId('warning')).toBeInTheDocument();
+ expect(screen.getAllByTestId("group")).toHaveLength(
+ CREATE_WAVE_GROUPS[ApiWaveType.Rank].length
+ );
+ expect(screen.getByTestId("warning")).toBeInTheDocument();
});
});
diff --git a/__tests__/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.test.tsx b/__tests__/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.test.tsx
new file mode 100644
index 0000000000..ee2ef08cd0
--- /dev/null
+++ b/__tests__/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.test.tsx
@@ -0,0 +1,178 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { AuthContext } from "@/components/auth/Auth";
+import CreateWaveInlineGroupIdentities from "@/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities";
+import type { CommunityMemberMinimal } from "@/entities/IProfile";
+import { ProfileConnectedStatus } from "@/entities/IProfile";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
+
+jest.mock(
+ "@/components/groups/page/create/config/identities/select/GroupCreateIdentitiesSearch",
+ () =>
+ function MockGroupCreateIdentitiesSearch(props: {
+ readonly selectedWallets: string[];
+ }) {
+ return (
+
+ {props.selectedWallets.join(",")}
+
+ );
+ }
+);
+
+jest.mock(
+ "@/components/groups/page/create/config/GroupCreateIdentitySelectedItems",
+ () =>
+ function MockGroupCreateIdentitySelectedItems() {
+ return ;
+ }
+);
+
+const connectedProfile = {
+ id: "profile-me",
+ handle: "me",
+ normalised_handle: "me",
+ primary_wallet: "0xME",
+ display: "Me",
+ tdh: 42,
+ level: 3,
+ cic: 5,
+ pfp: "me.png",
+} as ApiIdentity;
+
+const selectedCurrentUserIdentity: CommunityMemberMinimal = {
+ profile_id: "profile-me",
+ handle: "me",
+ normalised_handle: "me",
+ primary_wallet: "0xME",
+ display: "Me",
+ tdh: 42,
+ level: 3,
+ cic_rating: 5,
+ wallet: "0xme",
+ pfp: "me.png",
+};
+
+function renderWithProfile({
+ identities = [],
+ onIdentitySelect = jest.fn(),
+ onRemove = jest.fn(),
+ profile = connectedProfile,
+}: {
+ readonly identities?: readonly CommunityMemberMinimal[];
+ readonly onIdentitySelect?: jest.Mock;
+ readonly onRemove?: jest.Mock;
+ readonly profile?: ApiIdentity | null;
+} = {}) {
+ render(
+
+
+
+ );
+
+ return { onIdentitySelect, onRemove };
+}
+
+describe("CreateWaveInlineGroupIdentities", () => {
+ it("passes selected identity wallets to the search field", () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId("identities-search")).toHaveTextContent(
+ "0xAAA1,0xAAA2"
+ );
+ });
+
+ it("adds the connected profile when Include me is switched on", async () => {
+ const user = userEvent.setup();
+ const { onIdentitySelect } = renderWithProfile();
+
+ await user.click(screen.getByRole("switch", { name: "Include me" }));
+
+ expect(onIdentitySelect).toHaveBeenCalledWith({
+ profile_id: "profile-me",
+ handle: "me",
+ normalised_handle: "me",
+ primary_wallet: "0xME",
+ display: "Me",
+ tdh: 42,
+ level: 3,
+ cic_rating: 5,
+ wallet: "0xME",
+ pfp: "me.png",
+ });
+ });
+
+ it("checks Include me when the connected profile is already selected", () => {
+ renderWithProfile({
+ identities: [selectedCurrentUserIdentity],
+ });
+
+ expect(screen.getByRole("switch", { name: "Include me" })).toBeChecked();
+ });
+
+ it("removes the connected profile when Include me is switched off", async () => {
+ const user = userEvent.setup();
+ const { onRemove } = renderWithProfile({
+ identities: [selectedCurrentUserIdentity],
+ });
+
+ await user.click(screen.getByRole("switch", { name: "Include me" }));
+
+ expect(onRemove).toHaveBeenCalledWith("0xME");
+ });
+
+ it("hides Include me when no connected profile primary wallet exists", () => {
+ renderWithProfile({ profile: null });
+
+ expect(
+ screen.queryByRole("switch", { name: "Include me" })
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/__tests__/components/waves/create-wave/groups/createWaveInlineGroupBuilder.test.ts b/__tests__/components/waves/create-wave/groups/createWaveInlineGroupBuilder.test.ts
new file mode 100644
index 0000000000..fc50eeb505
--- /dev/null
+++ b/__tests__/components/waves/create-wave/groups/createWaveInlineGroupBuilder.test.ts
@@ -0,0 +1,94 @@
+import {
+ buildInlineGroupName,
+ createEmptyInlineGroupPayload,
+ dedupeInlineIdentities,
+ getInlineGroupConfiguredRules,
+ getInlineGroupDraftSummary,
+ getInlineIdentityAddresses,
+ getInlineGroupRuleCount,
+ CreateWaveInlineGroupRuleType,
+} from "@/components/waves/create-wave/groups/createWaveInlineGroupBuilder";
+
+describe("createWaveInlineGroupBuilder", () => {
+ it("builds a deterministic default group name", () => {
+ expect(
+ buildInlineGroupName({
+ waveName: "My Wave",
+ groupLabel: "Who can view",
+ })
+ ).toBe("My Wave Who can view");
+ });
+
+ it("counts configured rule types once per rule family", () => {
+ const draft = createEmptyInlineGroupPayload();
+ draft.group.level = { min: 3, max: null };
+ draft.group.owns_nfts = [
+ { name: "Memes" as any, tokens: [] },
+ { name: "Gradients" as any, tokens: ["12"] },
+ ];
+
+ expect(getInlineGroupRuleCount(draft)).toBe(3);
+ });
+
+ it("returns configured rules in display order", () => {
+ const draft = createEmptyInlineGroupPayload();
+ draft.group.tdh = { ...draft.group.tdh, min: 10 };
+ draft.group.cic = { ...draft.group.cic, max: 200 };
+ draft.group.owns_nfts = [
+ { name: "Memes" as any, tokens: [] },
+ { name: "Gradients" as any, tokens: ["12"] },
+ ];
+ draft.group.is_beneficiary_of_grant_id = "grant-1";
+
+ expect(getInlineGroupConfiguredRules(draft)).toEqual([
+ CreateWaveInlineGroupRuleType.TDH,
+ CreateWaveInlineGroupRuleType.CIC,
+ CreateWaveInlineGroupRuleType.NFTS,
+ CreateWaveInlineGroupRuleType.COLLECTIONS,
+ CreateWaveInlineGroupRuleType.XTDH_GRANT,
+ ]);
+ });
+
+ it("builds a compact draft summary", () => {
+ const draft = createEmptyInlineGroupPayload();
+ draft.group.rep = {
+ ...draft.group.rep,
+ min: 5,
+ };
+
+ expect(
+ getInlineGroupDraftSummary({
+ draft,
+ identityCount: 2,
+ })
+ ).toBe("2 identities · 1 rule");
+ });
+
+ it("dedupes and serializes inline identities by selected wallet", () => {
+ const firstSelectedWallet = {
+ profile_id: "profile-1",
+ handle: "alpha",
+ normalised_handle: "alpha",
+ primary_wallet: "0xPRIMARY",
+ display: "Alpha",
+ tdh: 0,
+ level: 0,
+ cic_rating: 0,
+ wallet: "0xAAA1",
+ pfp: null,
+ };
+ const secondSelectedWallet = {
+ ...firstSelectedWallet,
+ wallet: "0xAAA2",
+ };
+
+ expect(
+ dedupeInlineIdentities([firstSelectedWallet, secondSelectedWallet]).map(
+ (identity) => identity.wallet
+ )
+ ).toEqual(["0xAAA1", "0xAAA2"]);
+ expect(
+ getInlineIdentityAddresses([firstSelectedWallet, secondSelectedWallet])
+ ).toEqual(["0xaaa1", "0xaaa2"]);
+ });
+});
diff --git a/__tests__/hooks/useWaveConfig.test.ts b/__tests__/hooks/useWaveConfig.test.ts
index edb0f0b6c1..c996e67acf 100644
--- a/__tests__/hooks/useWaveConfig.test.ts
+++ b/__tests__/hooks/useWaveConfig.test.ts
@@ -1,23 +1,150 @@
-import { renderHook, act } from '@testing-library/react';
-import { useWaveConfig } from '@/components/waves/create-wave/hooks/useWaveConfig';
-import { CreateWaveStep } from '@/types/waves.types';
+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,
+};
-describe('useWaveConfig', () => {
- it('prevents step change when validation fails', () => {
+const secondSelectedWalletIdentity = {
+ ...exampleIdentity,
+ profile_id: "profile-2",
+ handle: "beta",
+ normalised_handle: "beta",
+ display: "Beta",
+ wallet: "0xDEF",
+};
+
+describe("useWaveConfig", () => {
+ it("prevents step change when validation fails", () => {
const { result } = renderHook(() => useWaveConfig());
act(() => {
- result.current.onStep({ step: CreateWaveStep.GROUPS, direction: 'forward' });
+ result.current.onStep({
+ step: CreateWaveStep.GROUPS,
+ direction: "forward",
+ });
});
expect(result.current.step).toBe(CreateWaveStep.OVERVIEW);
expect(result.current.errors.length).toBeGreaterThan(0);
});
- it('updates drops admin delete flag', () => {
+ it("updates drops admin delete flag", () => {
const { result } = renderHook(() => useWaveConfig());
act(() => {
result.current.setDropsAdminCanDelete(true);
});
expect(result.current.config.drops.adminCanDeleteDrops).toBe(true);
});
+
+ it("stores inline group draft state per slot", () => {
+ const { result } = renderHook(() => useWaveConfig());
+
+ 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,
+ });
+ });
+
+ expect(
+ result.current.groupBuilders[CreateWaveGroupConfigType.ADMIN].identities
+ ).toHaveLength(0);
+ expect(
+ result.current.groupBuilders[CreateWaveGroupConfigType.ADMIN].draft.group
+ .identity_addresses
+ ).toBeNull();
+ });
});
diff --git a/components/auth/Auth.tsx b/components/auth/Auth.tsx
index 4844fb229e..b14cb320fc 100644
--- a/components/auth/Auth.tsx
+++ b/components/auth/Auth.tsx
@@ -898,7 +898,7 @@ export default function Auth({
}}
>
{children}
-
+
{enableWalletAuthentication && (
=> {
+ 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 and attached.",
+ type: "success",
+ });
+
+ return result.group;
+ };
+
const onComplete = async () => {
setSubmitting(true);
const { success } = await requestAuth();
@@ -205,13 +245,22 @@ export default function CreateWave({
),
[CreateWaveStep.GROUPS]: (
),
diff --git a/components/waves/create-wave/groups/CreateWaveGroup.tsx b/components/waves/create-wave/groups/CreateWaveGroup.tsx
index 032c1f5dbd..6ff8f5b6b7 100644
--- a/components/waves/create-wave/groups/CreateWaveGroup.tsx
+++ b/components/waves/create-wave/groups/CreateWaveGroup.tsx
@@ -6,30 +6,59 @@ 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 CreateWaveGroupSearchField from "./CreateWaveGroupSearchField";
+import type {
+ CreateWaveInlineGroupBuilderState,
+ CreateWaveInlineGroupPanel,
+ CreateWaveInlineGroupRuleType,
+} from "./createWaveInlineGroupBuilder";
+import CreateWaveGroupInlinePanel from "./CreateWaveGroupInlinePanel";
export default function CreateWaveGroup({
+ waveName,
waveType,
groupType,
chatEnabled,
adminCanDeleteDrops,
setChatEnabled,
onGroupSelect,
+ onInlineGroupCreate,
groupsCache,
groups,
+ groupBuilder,
+ setGroupBuilderPanel,
+ setGroupBuilderRule,
+ setGroupBuilderDraft,
+ addGroupBuilderIdentity,
+ removeGroupBuilderIdentity,
+ resetGroupBuilder,
setDropsAdminCanDelete,
}: {
+ readonly waveName: string;
readonly waveType: ApiWaveType;
readonly groupType: CreateWaveGroupConfigType;
readonly chatEnabled: boolean;
readonly adminCanDeleteDrops: boolean;
readonly setChatEnabled: (enabled: boolean) => void;
readonly onGroupSelect: (group: ApiGroupFull | null) => void;
+ readonly onInlineGroupCreate: (
+ payload: ApiCreateGroup
+ ) => 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 = () => {
@@ -61,12 +90,20 @@ export default function CreateWaveGroup({
groupType === CreateWaveGroupConfigType.CAN_CHAT &&
!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;
return (
- {CREATE_WAVE_SELECT_GROUP_LABELS[waveType][groupType]}
+ {groupLabel}
{isNotChatWave && groupType === CreateWaveGroupConfigType.CAN_CHAT && (
-
);
diff --git a/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx b/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx
new file mode 100644
index 0000000000..47784502f1
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveGroupInlinePanel.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { CommunityMemberMinimal } from "@/entities/IProfile";
+import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup";
+import type { ApiGroupFull } from "@/generated/models/ApiGroupFull";
+import { validateGroupPayload } from "@/services/groups/groupMutations";
+import {
+ buildInlineGroupName,
+ getInlineGroupConfiguredRules,
+ getInlineGroupDraftSummary,
+} from "./createWaveInlineGroupBuilder";
+import type {
+ CreateWaveInlineGroupBuilderState,
+ CreateWaveInlineGroupPanel,
+ CreateWaveInlineGroupRuleType,
+} from "./createWaveInlineGroupBuilder";
+import CreateWaveInlineGroupActions from "./CreateWaveInlineGroupActions";
+import CreateWaveInlineGroupDraftSummary from "./CreateWaveInlineGroupDraftSummary";
+import CreateWaveInlineGroupHeader from "./CreateWaveInlineGroupHeader";
+import CreateWaveInlineGroupIdentities from "./CreateWaveInlineGroupIdentities";
+import CreateWaveInlineGroupRuleEditor from "./CreateWaveInlineGroupRuleEditor";
+import {
+ CreateWaveInlineGroupRuleEditorPanel,
+ CreateWaveInlineGroupRuleList,
+} from "./CreateWaveInlineGroupRules";
+import CreateWaveInlineGroupSearch from "./CreateWaveInlineGroupSearch";
+
+export default function CreateWaveGroupInlinePanel({
+ waveName,
+ groupLabel,
+ defaultLabel,
+ disabled,
+ selectedGroup,
+ groupBuilder,
+ onGroupSelect,
+ onInlineGroupCreate,
+ setGroupBuilderPanel,
+ setGroupBuilderRule,
+ setGroupBuilderDraft,
+ addGroupBuilderIdentity,
+ removeGroupBuilderIdentity,
+ resetGroupBuilder,
+}: {
+ readonly waveName: string;
+ readonly groupLabel: string;
+ readonly defaultLabel: string;
+ readonly disabled: boolean;
+ readonly selectedGroup: ApiGroupFull | null;
+ readonly groupBuilder: CreateWaveInlineGroupBuilderState;
+ readonly onGroupSelect: (group: ApiGroupFull | null) => void;
+ readonly onInlineGroupCreate: (
+ 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 draftSummary = useMemo(
+ () =>
+ getInlineGroupDraftSummary({
+ draft: groupBuilder.draft,
+ identityCount: groupBuilder.identities.length,
+ }),
+ [groupBuilder.draft, groupBuilder.identities.length]
+ );
+ const configuredRules = useMemo(
+ () => getInlineGroupConfiguredRules(groupBuilder.draft),
+ [groupBuilder.draft]
+ );
+ const validation = validateGroupPayload(groupBuilder.draft);
+ const canCreateDraft = validation.valid && !disabled && !isCreating;
+ const canResetDraft = !!draftSummary && !disabled && !isCreating;
+ const currentStateLabel = selectedGroup?.name ?? defaultLabel;
+ const identityCount = groupBuilder.identities.length;
+ const identityLabel = identityCount === 1 ? "identity" : "identities";
+ const isIdentityPanel = groupBuilder.panel === "identity";
+ const isRulePanel =
+ groupBuilder.panel === "rule-list" || groupBuilder.panel === "rule-editor";
+ const isSearchPanel = groupBuilder.panel === "search";
+ const showModeChips = !!draftSummary || groupBuilder.panel !== "actions";
+ const identityChipLabel =
+ identityCount > 0 ? `${identityCount} ${identityLabel}` : "Add identity";
+
+ const openPanel = (panel: CreateWaveInlineGroupPanel) => {
+ setGroupBuilderRule(null);
+ setGroupBuilderPanel(panel);
+ };
+
+ const togglePanel = (
+ panel: CreateWaveInlineGroupPanel,
+ isActive: boolean
+ ) => {
+ openPanel(isActive ? "actions" : panel);
+ };
+
+ const openRule = (rule: CreateWaveInlineGroupRuleType) => {
+ setGroupBuilderRule(rule);
+ };
+
+ const toggleRule = (rule: CreateWaveInlineGroupRuleType) => {
+ if (
+ groupBuilder.panel === "rule-editor" &&
+ groupBuilder.activeRule === rule
+ ) {
+ setGroupBuilderRule(null);
+ setGroupBuilderPanel("rule-list");
+ return;
+ }
+
+ openRule(rule);
+ };
+
+ const onCreateAndUse = async () => {
+ if (!canCreateDraft) {
+ return;
+ }
+
+ setIsCreating(true);
+ try {
+ const nextPayload: ApiCreateGroup = {
+ ...groupBuilder.draft,
+ name: buildInlineGroupName({
+ waveName,
+ groupLabel,
+ }),
+ };
+
+ const createdGroup = await onInlineGroupCreate(nextPayload);
+ if (!createdGroup) {
+ return;
+ }
+
+ onGroupSelect(createdGroup);
+ resetGroupBuilder();
+ setGroupBuilderRule(null);
+ setGroupBuilderPanel("actions");
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const onStartOver = () => {
+ if (!canResetDraft) {
+ return;
+ }
+
+ resetGroupBuilder();
+ };
+
+ const onExistingGroupSelect = (group: ApiGroupFull | null) => {
+ onGroupSelect(group);
+ if (group) {
+ resetGroupBuilder();
+ setGroupBuilderRule(null);
+ setGroupBuilderPanel("actions");
+ }
+ };
+
+ return (
+
+
+
togglePanel("identity", isIdentityPanel)}
+ onRuleOpen={openRule}
+ onRulesToggle={() => togglePanel("rule-list", isRulePanel)}
+ onSearchToggle={() => togglePanel("search", isSearchPanel)}
+ />
+
+ {groupBuilder.panel === "actions" && !draftSummary && (
+ openPanel("identity")}
+ onAddRule={() => openPanel("rule-list")}
+ onUseExistingGroup={() => openPanel("search")}
+ />
+ )}
+
+ {groupBuilder.panel === "identity" && (
+
+
+
+ )}
+
+ {groupBuilder.panel === "rule-list" && (
+
+ )}
+
+ {groupBuilder.panel === "rule-editor" &&
+ groupBuilder.activeRule !== null && (
+
+
+
+ )}
+
+ {groupBuilder.panel === "search" && (
+
+ )}
+
+ {groupBuilder.panel !== "search" && draftSummary && (
+
+ )}
+
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveGroups.tsx b/components/waves/create-wave/groups/CreateWaveGroups.tsx
index d73c5d7950..c66b33ead5 100644
--- a/components/waves/create-wave/groups/CreateWaveGroups.tsx
+++ b/components/waves/create-wave/groups/CreateWaveGroups.tsx
@@ -7,45 +7,106 @@ 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,
waveType,
groups,
onGroupSelect,
+ onInlineGroupCreate,
chatEnabled,
adminCanDeleteDrops,
groupsCache,
+ groupBuilders,
setChatEnabled,
+ setGroupBuilderPanel,
+ setGroupBuilderRule,
+ setGroupBuilderDraft,
+ addGroupBuilderIdentity,
+ removeGroupBuilderIdentity,
+ resetGroupBuilder,
setDropsAdminCanDelete,
}: {
+ readonly waveName: string;
readonly waveType: ApiWaveType;
readonly groups: WaveGroupsConfig;
readonly onGroupSelect: (param: {
group: ApiGroupFull | null;
groupType: CreateWaveGroupConfigType;
}) => void;
+ readonly onInlineGroupCreate: (
+ payload: ApiCreateGroup
+ ) => Promise;
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;
-
return (
{CREATE_WAVE_GROUPS[waveType].map((groupType) => (
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/CreateWaveInlineGroupActions.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupActions.tsx
new file mode 100644
index 0000000000..8e4e760e60
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupActions.tsx
@@ -0,0 +1,29 @@
+import { ActionButton } from "./CreateWaveInlineGroupButtons";
+
+export default function CreateWaveInlineGroupActions({
+ disabled,
+ onAddIdentity,
+ onAddRule,
+ onUseExistingGroup,
+}: {
+ readonly disabled: boolean;
+ readonly onAddIdentity: () => void;
+ readonly onAddRule: () => void;
+ readonly onUseExistingGroup: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupButtons.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupButtons.tsx
new file mode 100644
index 0000000000..0bf43f9ebc
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupButtons.tsx
@@ -0,0 +1,55 @@
+export function ActionButton({
+ label,
+ onClick,
+ disabled = false,
+}: {
+ readonly label: string;
+ readonly onClick: () => void;
+ readonly disabled?: boolean | undefined;
+}) {
+ return (
+
+ );
+}
+
+export function DraftChipButton({
+ label,
+ onClick,
+ disabled = false,
+ active = false,
+ compact = false,
+ isToggle = false,
+}: {
+ readonly label: string;
+ readonly onClick: () => void;
+ readonly disabled?: boolean | undefined;
+ readonly active?: boolean | undefined;
+ readonly compact?: boolean | undefined;
+ readonly isToggle?: boolean | undefined;
+}) {
+ const stateClasses = active
+ ? "tw-border-primary-400 tw-bg-primary-500/15 tw-text-primary-200 desktop-hover:hover:tw-border-primary-300 desktop-hover:hover:tw-bg-primary-500/20"
+ : "tw-border-iron-700 tw-bg-iron-950 tw-text-iron-200 desktop-hover:hover:tw-border-iron-600 desktop-hover:hover:tw-bg-iron-800";
+ const sizeClasses = compact
+ ? "tw-px-2.5 tw-py-1 tw-text-xs"
+ : "tw-px-3 tw-py-1.5 tw-text-xs";
+
+ return (
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupDraftSummary.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupDraftSummary.tsx
new file mode 100644
index 0000000000..0820ef6096
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupDraftSummary.tsx
@@ -0,0 +1,50 @@
+export default function CreateWaveInlineGroupDraftSummary({
+ draftSummary,
+ isValid,
+ canResetDraft,
+ canCreateDraft,
+ isCreating,
+ onStartOver,
+ onCreateAndUse,
+}: {
+ readonly draftSummary: string;
+ readonly isValid: boolean;
+ readonly canResetDraft: boolean;
+ readonly canCreateDraft: boolean;
+ readonly isCreating: boolean;
+ readonly onStartOver: () => void;
+ readonly onCreateAndUse: () => void;
+}) {
+ return (
+
+
+
+ Ready to create this inline group
+
+
+ {isValid
+ ? draftSummary
+ : "Draft is incomplete. Check the selected rules before creating it."}
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupHeader.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupHeader.tsx
new file mode 100644
index 0000000000..f7e08b4877
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupHeader.tsx
@@ -0,0 +1,81 @@
+import {
+ CREATE_WAVE_INLINE_GROUP_RULE_LABELS,
+ type CreateWaveInlineGroupRuleType,
+} from "./createWaveInlineGroupBuilder";
+import { DraftChipButton } from "./CreateWaveInlineGroupButtons";
+
+export default function CreateWaveInlineGroupHeader({
+ currentStateLabel,
+ showModeChips,
+ identityChipLabel,
+ disabled,
+ isIdentityPanel,
+ isRulePanel,
+ isSearchPanel,
+ configuredRules,
+ onIdentityToggle,
+ onRuleOpen,
+ onRulesToggle,
+ onSearchToggle,
+}: {
+ readonly currentStateLabel: string;
+ readonly showModeChips: boolean;
+ readonly identityChipLabel: string;
+ readonly disabled: boolean;
+ readonly isIdentityPanel: boolean;
+ readonly isRulePanel: boolean;
+ readonly isSearchPanel: boolean;
+ readonly configuredRules: readonly CreateWaveInlineGroupRuleType[];
+ readonly onIdentityToggle: () => void;
+ readonly onRuleOpen: (rule: CreateWaveInlineGroupRuleType) => void;
+ readonly onRulesToggle: () => void;
+ readonly onSearchToggle: () => void;
+}) {
+ return (
+
+
+ Current state
+
+
+ {currentStateLabel}
+
+ {showModeChips && (
+
+
+ Draft
+
+
+ {!isRulePanel &&
+ configuredRules.map((rule) => (
+ onRuleOpen(rule)}
+ />
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.tsx
new file mode 100644
index 0000000000..f055bf49c5
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupIdentities.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type { CommunityMemberMinimal } from "@/entities/IProfile";
+import { useAuth } from "@/components/auth/Auth";
+import GroupCreateIdentitySelectedItems from "@/components/groups/page/create/config/GroupCreateIdentitySelectedItems";
+import GroupCreateIdentitiesSearch from "@/components/groups/page/create/config/identities/select/GroupCreateIdentitiesSearch";
+import { areEqualAddresses } from "@/helpers/Helpers";
+
+export default function CreateWaveInlineGroupIdentities({
+ identities,
+ onIdentitySelect,
+ onRemove,
+}: {
+ readonly identities: readonly CommunityMemberMinimal[];
+ readonly onIdentitySelect: (identity: CommunityMemberMinimal) => void;
+ readonly onRemove: (wallet: string) => void;
+}) {
+ const { connectedProfile } = useAuth();
+ const selectedWallets = identities.map((identity) => identity.wallet);
+ const currentUserIdentity: CommunityMemberMinimal | null =
+ connectedProfile?.primary_wallet
+ ? {
+ profile_id: connectedProfile.id,
+ handle: connectedProfile.handle,
+ normalised_handle: connectedProfile.normalised_handle,
+ primary_wallet: connectedProfile.primary_wallet,
+ display: connectedProfile.display,
+ tdh: connectedProfile.tdh,
+ level: connectedProfile.level,
+ cic_rating: connectedProfile.cic,
+ wallet: connectedProfile.primary_wallet,
+ pfp: connectedProfile.pfp,
+ }
+ : null;
+ const isCurrentUserSelected =
+ !!currentUserIdentity &&
+ selectedWallets.some((wallet) =>
+ areEqualAddresses(wallet, currentUserIdentity.wallet)
+ );
+
+ const onCurrentUserToggle = (checked: boolean) => {
+ if (!currentUserIdentity) {
+ return;
+ }
+
+ if (checked) {
+ if (!isCurrentUserSelected) {
+ onIdentitySelect(currentUserIdentity);
+ }
+ return;
+ }
+
+ if (isCurrentUserSelected) {
+ onRemove(currentUserIdentity.wallet);
+ }
+ };
+
+ return (
+
+
+
+ {currentUserIdentity && (
+
+ )}
+
+ {identities.length ? (
+
+ ) : (
+
+ Add identities one by one. Each selected identity becomes part of the
+ new group.
+
+ )}
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupRuleEditor.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupRuleEditor.tsx
new file mode 100644
index 0000000000..68206c81d8
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupRuleEditor.tsx
@@ -0,0 +1,116 @@
+import GroupCreateCIC from "@/components/groups/page/create/config/GroupCreateCIC";
+import GroupCreateLevel from "@/components/groups/page/create/config/GroupCreateLevel";
+import GroupCreateRep from "@/components/groups/page/create/config/GroupCreateRep";
+import GroupCreateTDH from "@/components/groups/page/create/config/GroupCreateTDH";
+import GroupCreateCollections from "@/components/groups/page/create/config/nfts/GroupCreateCollections";
+import GroupCreateNfts from "@/components/groups/page/create/config/nfts/GroupCreateNfts";
+import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup";
+import { assertUnreachable } from "@/helpers/AllowlistToolHelpers";
+import { CreateWaveInlineGroupRuleType } from "./createWaveInlineGroupBuilder";
+import CreateWaveInlineGroupXtdhGrant from "./CreateWaveInlineGroupXtdhGrant";
+
+export default function CreateWaveInlineGroupRuleEditor({
+ draft,
+ activeRule,
+ onDraftChange,
+}: {
+ readonly draft: ApiCreateGroup;
+ readonly activeRule: CreateWaveInlineGroupRuleType | null;
+ readonly onDraftChange: (draft: ApiCreateGroup) => void;
+}) {
+ if (activeRule === null) {
+ return null;
+ }
+
+ switch (activeRule) {
+ case CreateWaveInlineGroupRuleType.LEVEL:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, level },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.TDH:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, tdh },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.CIC:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, cic },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.REP:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, rep },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.NFTS:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, owns_nfts },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.COLLECTIONS:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: { ...draft.group, owns_nfts },
+ })
+ }
+ />
+ );
+ case CreateWaveInlineGroupRuleType.XTDH_GRANT:
+ return (
+
+ onDraftChange({
+ ...draft,
+ group: {
+ ...draft.group,
+ is_beneficiary_of_grant_id: is_beneficiary_of_grant_id ?? null,
+ },
+ })
+ }
+ />
+ );
+ default:
+ return assertUnreachable(activeRule);
+ }
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupRules.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupRules.tsx
new file mode 100644
index 0000000000..71534f0e51
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupRules.tsx
@@ -0,0 +1,68 @@
+import type { ReactNode } from "react";
+import {
+ CREATE_WAVE_INLINE_GROUP_MORE_RULES,
+ CREATE_WAVE_INLINE_GROUP_QUICK_RULES,
+ CREATE_WAVE_INLINE_GROUP_RULE_LABELS,
+ type CreateWaveInlineGroupRuleType,
+} from "./createWaveInlineGroupBuilder";
+import { DraftChipButton } from "./CreateWaveInlineGroupButtons";
+
+const CREATE_WAVE_INLINE_GROUP_RULE_OPTIONS = [
+ ...CREATE_WAVE_INLINE_GROUP_QUICK_RULES,
+ ...CREATE_WAVE_INLINE_GROUP_MORE_RULES,
+] as const;
+
+export function CreateWaveInlineGroupRuleList({
+ disabled,
+ onRuleOpen,
+}: {
+ readonly disabled: boolean;
+ readonly onRuleOpen: (rule: CreateWaveInlineGroupRuleType) => void;
+}) {
+ return (
+
+
+ {CREATE_WAVE_INLINE_GROUP_RULE_OPTIONS.map((rule) => (
+ onRuleOpen(rule)}
+ />
+ ))}
+
+
+ );
+}
+
+export function CreateWaveInlineGroupRuleEditorPanel({
+ activeRule,
+ disabled,
+ onRuleToggle,
+ children,
+}: {
+ readonly activeRule: CreateWaveInlineGroupRuleType;
+ readonly disabled: boolean;
+ readonly onRuleToggle: (rule: CreateWaveInlineGroupRuleType) => void;
+ readonly children: ReactNode;
+}) {
+ return (
+
+
+ {CREATE_WAVE_INLINE_GROUP_RULE_OPTIONS.map((rule) => (
+ onRuleToggle(rule)}
+ />
+ ))}
+
+ {children}
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx
new file mode 100644
index 0000000000..10a7dc57de
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupSearch.tsx
@@ -0,0 +1,26 @@
+import type { ApiGroupFull } from "@/generated/models/ApiGroupFull";
+import CreateWaveGroupSearchField from "./CreateWaveGroupSearchField";
+
+export default function CreateWaveInlineGroupSearch({
+ defaultLabel,
+ disabled,
+ selectedGroup,
+ onSelect,
+}: {
+ readonly defaultLabel: string;
+ readonly disabled: boolean;
+ readonly selectedGroup: ApiGroupFull | null;
+ readonly onSelect: (group: ApiGroupFull | null) => void;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/components/waves/create-wave/groups/CreateWaveInlineGroupXtdhGrant.tsx b/components/waves/create-wave/groups/CreateWaveInlineGroupXtdhGrant.tsx
new file mode 100644
index 0000000000..52c2491ee2
--- /dev/null
+++ b/components/waves/create-wave/groups/CreateWaveInlineGroupXtdhGrant.tsx
@@ -0,0 +1,327 @@
+"use client";
+
+import { useState } from "react";
+import { useDebounce } from "react-use";
+import IdentitySearch, {
+ IdentitySearchSize,
+} from "@/components/utils/input/identity/IdentitySearch";
+import type { ApiCreateGroupDescription } from "@/generated/models/ApiCreateGroupDescription";
+import { ApiXTdhGrantStatus } from "@/generated/models/ApiXTdhGrantStatus";
+import { useXtdhGrantQuery } from "@/hooks/useXtdhGrantQuery";
+import { useXtdhGrantsSearchQuery } from "@/hooks/useXtdhGrantsSearchQuery";
+import GroupCreateXtdhGrantRow from "@/components/groups/page/create/config/xtdh-grant/subcomponents/GroupCreateXtdhGrantRow";
+import {
+ isSelectableNonGrantedStatus,
+ toShortGrantId,
+} from "@/components/groups/page/create/config/xtdh-grant/utils";
+
+const STATUS_OPTIONS = [
+ ApiXTdhGrantStatus.Granted,
+ ApiXTdhGrantStatus.Pending,
+ ApiXTdhGrantStatus.Disabled,
+ ApiXTdhGrantStatus.Failed,
+] as const;
+
+const STATUS_LABELS: Record = {
+ [ApiXTdhGrantStatus.Granted]: "Granted",
+ [ApiXTdhGrantStatus.Pending]: "Pending",
+ [ApiXTdhGrantStatus.Disabled]: "Revoked",
+ [ApiXTdhGrantStatus.Failed]: "Failed",
+};
+
+export default function CreateWaveInlineGroupXtdhGrant({
+ beneficiaryGrantId,
+ setBeneficiaryGrantId,
+}: {
+ readonly beneficiaryGrantId: ApiCreateGroupDescription["is_beneficiary_of_grant_id"];
+ readonly setBeneficiaryGrantId: (
+ grantId: ApiCreateGroupDescription["is_beneficiary_of_grant_id"]
+ ) => void;
+}) {
+ const normalizedGrantId = beneficiaryGrantId?.trim() ?? "";
+ const hasSelectedGrant = normalizedGrantId.length > 0;
+
+ const [showGrantFinder, setShowGrantFinder] = useState(false);
+ const [lookupGrantId, setLookupGrantId] = useState(
+ hasSelectedGrant ? normalizedGrantId : null
+ );
+ const [selectedGrantor, setSelectedGrantor] = useState(null);
+ const [targetCollectionInput, setTargetCollectionInput] = useState("");
+ const [targetCollectionFilter, setTargetCollectionFilter] = useState("");
+ const [selectedStatus, setSelectedStatus] = useState(
+ ApiXTdhGrantStatus.Granted
+ );
+
+ useDebounce(
+ () => setTargetCollectionFilter(targetCollectionInput.trim()),
+ 250,
+ [targetCollectionInput]
+ );
+
+ useDebounce(
+ () => setLookupGrantId(hasSelectedGrant ? normalizedGrantId : null),
+ 250,
+ [hasSelectedGrant, normalizedGrantId]
+ );
+
+ const { grant, isFetching, isError, errorMessage } = useXtdhGrantQuery({
+ grantId: lookupGrantId,
+ enabled: !!lookupGrantId,
+ });
+
+ const {
+ grants,
+ totalCount,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading,
+ isError: isSearchError,
+ errorMessage: searchErrorMessage,
+ refetch,
+ } = useXtdhGrantsSearchQuery({
+ grantor: selectedGrantor,
+ targetCollectionName: targetCollectionFilter || null,
+ statuses: [selectedStatus],
+ enabled: showGrantFinder,
+ pageSize: 20,
+ });
+
+ const isLookupFresh = lookupGrantId === normalizedGrantId;
+ const showLookupError = isLookupFresh && Boolean(lookupGrantId && isError);
+ const showNonGrantedWarning =
+ isLookupFresh &&
+ grant?.status !== undefined &&
+ isSelectableNonGrantedStatus(grant.status);
+
+ const onInputChange = (nextValue: string) => {
+ const normalized = nextValue.trim();
+ setBeneficiaryGrantId(normalized.length ? normalized : null);
+ };
+
+ const onClearSelection = () => {
+ setLookupGrantId(null);
+ setBeneficiaryGrantId(null);
+ };
+
+ const onResetFilters = () => {
+ setSelectedGrantor(null);
+ setTargetCollectionInput("");
+ setTargetCollectionFilter("");
+ setSelectedStatus(ApiXTdhGrantStatus.Granted);
+ };
+
+ return (
+
+
+
+ xTDH Grant Beneficiary
+
+
+ Require identities to be beneficiaries of a selected xTDH grant.
+
+
+
+
+
+
+
+
+
+ {isFetching && !!lookupGrantId && (
+
+ Validating grant...
+
+ )}
+
+ {showLookupError && (
+
+
+ {errorMessage ?? "Unable to resolve grant ID."}
+
+
+ The ID will still be submitted as entered:{" "}
+
+ {toShortGrantId(lookupGrantId)}
+
+
+
+ )}
+
+ {isLookupFresh && !!grant && (
+
+ )}
+
+ {showNonGrantedWarning && (
+
+
+ Selected grant status is not GRANTED. This filter is still allowed
+ and will be submitted.
+
+
+ )}
+
+ {showGrantFinder && (
+
+
+
+ setSelectedGrantor(identity ? identity.toLowerCase() : null)
+ }
+ />
+ setTargetCollectionInput(event.target.value)}
+ placeholder="Collection name"
+ aria-label="Collection name"
+ className="tw-form-input tw-block tw-w-full tw-rounded-lg tw-border-0 tw-bg-iron-900 tw-px-3 tw-py-2.5 tw-text-sm tw-text-iron-50 tw-ring-1 tw-ring-inset tw-ring-iron-700 placeholder:tw-text-iron-500 focus:tw-ring-primary-400"
+ />
+
+
+
+
+
+ Status
+
+
+ {STATUS_OPTIONS.map((status) => {
+ const isActive = selectedStatus === status;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+ Results
+
+
+ {totalCount} total
+
+
+
+
+ {isLoading && !grants.length && (
+
+ Loading grants...
+
+ )}
+
+ {isSearchError && !grants.length && (
+
+
+ {searchErrorMessage ?? "Unable to load grants."}
+
+
+
+ )}
+
+ {!isLoading && !isSearchError && !grants.length && (
+
+ No grants matched the selected filters.
+
+ )}
+
+ {!!grants.length && (
+
+ {grants.map((grantItem) => (
+ {
+ setBeneficiaryGrantId(selectedGrant.id);
+ setLookupGrantId(selectedGrant.id);
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {hasNextPage && (
+
+
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts b/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts
new file mode 100644
index 0000000000..ff57f83da9
--- /dev/null
+++ b/components/waves/create-wave/groups/createWaveInlineGroupBuilder.ts
@@ -0,0 +1,237 @@
+"use client";
+
+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"
+ | "identity"
+ | "rule-list"
+ | "rule-editor"
+ | "search";
+
+export enum CreateWaveInlineGroupRuleType {
+ LEVEL = "level",
+ TDH = "tdh",
+ CIC = "cic",
+ REP = "rep",
+ NFTS = "nfts",
+ COLLECTIONS = "collections",
+ XTDH_GRANT = "xtdh-grant",
+}
+
+export interface CreateWaveInlineGroupBuilderState {
+ readonly draft: ApiCreateGroup;
+ readonly identities: readonly CommunityMemberMinimal[];
+ readonly panel: CreateWaveInlineGroupPanel;
+ readonly activeRule: CreateWaveInlineGroupRuleType | null;
+}
+
+const QUICK_RULES = [
+ CreateWaveInlineGroupRuleType.LEVEL,
+ CreateWaveInlineGroupRuleType.TDH,
+ CreateWaveInlineGroupRuleType.CIC,
+ CreateWaveInlineGroupRuleType.REP,
+] as const;
+
+const MORE_RULES = [
+ CreateWaveInlineGroupRuleType.NFTS,
+ CreateWaveInlineGroupRuleType.COLLECTIONS,
+ CreateWaveInlineGroupRuleType.XTDH_GRANT,
+] as const;
+
+export const CREATE_WAVE_INLINE_GROUP_RULE_LABELS: Record<
+ CreateWaveInlineGroupRuleType,
+ string
+> = {
+ [CreateWaveInlineGroupRuleType.LEVEL]: "Level",
+ [CreateWaveInlineGroupRuleType.TDH]: "TDH",
+ [CreateWaveInlineGroupRuleType.CIC]: "NIC",
+ [CreateWaveInlineGroupRuleType.REP]: "Rep",
+ [CreateWaveInlineGroupRuleType.NFTS]: "Required NFTs",
+ [CreateWaveInlineGroupRuleType.COLLECTIONS]: "Collection Access",
+ [CreateWaveInlineGroupRuleType.XTDH_GRANT]: "xTDH Grant",
+};
+
+export const CREATE_WAVE_INLINE_GROUP_QUICK_RULES = QUICK_RULES;
+export const CREATE_WAVE_INLINE_GROUP_MORE_RULES = MORE_RULES;
+
+export const createEmptyInlineGroupPayload = (): ApiCreateGroup => ({
+ name: "",
+ 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_addresses: null,
+ excluded_identity_addresses: null,
+ is_beneficiary_of_grant_id: null,
+ },
+ is_private: false,
+});
+
+export const createInitialInlineGroupBuilderState =
+ (): CreateWaveInlineGroupBuilderState => ({
+ draft: createEmptyInlineGroupPayload(),
+ identities: [],
+ panel: "actions",
+ 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();
+
+export const dedupeInlineIdentities = (
+ identities: readonly CommunityMemberMinimal[]
+): CommunityMemberMinimal[] => {
+ const seen = new Set();
+ const next: CommunityMemberMinimal[] = [];
+
+ for (const identity of identities) {
+ const key = normalizeAddress(identity.wallet);
+ if (seen.has(key)) {
+ continue;
+ }
+ seen.add(key);
+ next.push(identity);
+ }
+
+ return next;
+};
+
+export const getInlineIdentityAddresses = (
+ identities: readonly CommunityMemberMinimal[]
+): string[] | null => {
+ const addresses = dedupeInlineIdentities(identities)
+ .map((identity) => normalizeAddress(identity.wallet))
+ .filter((address) => address.length > 0);
+
+ return addresses.length ? addresses : null;
+};
+
+const hasRepRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.rep.min !== null ||
+ draft.group.rep.max !== null ||
+ draft.group.rep.user_identity !== null ||
+ draft.group.rep.category !== null;
+
+const hasCicRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.cic.min !== null ||
+ draft.group.cic.max !== null ||
+ draft.group.cic.user_identity !== null;
+
+const hasLevelRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.level.min !== null || draft.group.level.max !== null;
+
+const hasTdhRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.tdh.min !== null || draft.group.tdh.max !== null;
+
+const hasCollectionRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.owns_nfts.some((group) => group.tokens.length === 0);
+
+const hasNftRule = (draft: ApiCreateGroup): boolean =>
+ draft.group.owns_nfts.some((group) => group.tokens.length > 0);
+
+const hasGrantRule = (draft: ApiCreateGroup): boolean =>
+ typeof draft.group.is_beneficiary_of_grant_id === "string" &&
+ draft.group.is_beneficiary_of_grant_id.trim().length > 0;
+
+export const getInlineGroupRuleCount = (draft: ApiCreateGroup): number =>
+ [
+ hasLevelRule(draft),
+ hasTdhRule(draft),
+ hasCicRule(draft),
+ hasRepRule(draft),
+ hasCollectionRule(draft),
+ hasNftRule(draft),
+ hasGrantRule(draft),
+ ].filter(Boolean).length;
+
+export const getInlineGroupConfiguredRules = (
+ draft: ApiCreateGroup
+): CreateWaveInlineGroupRuleType[] => {
+ const ruleChecks: Array = [
+ [CreateWaveInlineGroupRuleType.LEVEL, hasLevelRule(draft)],
+ [CreateWaveInlineGroupRuleType.TDH, hasTdhRule(draft)],
+ [CreateWaveInlineGroupRuleType.CIC, hasCicRule(draft)],
+ [CreateWaveInlineGroupRuleType.REP, hasRepRule(draft)],
+ [CreateWaveInlineGroupRuleType.NFTS, hasNftRule(draft)],
+ [CreateWaveInlineGroupRuleType.COLLECTIONS, hasCollectionRule(draft)],
+ [CreateWaveInlineGroupRuleType.XTDH_GRANT, hasGrantRule(draft)],
+ ];
+
+ return ruleChecks.filter(([, hasRule]) => hasRule).map(([rule]) => rule);
+};
+
+export const getInlineGroupDraftSummary = ({
+ draft,
+ identityCount,
+}: {
+ readonly draft: ApiCreateGroup;
+ readonly identityCount: number;
+}): string | null => {
+ const ruleCount = getInlineGroupRuleCount(draft);
+ const parts: string[] = [];
+
+ if (identityCount > 0) {
+ parts.push(
+ `${identityCount} ${identityCount === 1 ? "identity" : "identities"}`
+ );
+ }
+ if (ruleCount > 0) {
+ parts.push(`${ruleCount} ${ruleCount === 1 ? "rule" : "rules"}`);
+ }
+
+ return parts.length ? parts.join(" · ") : null;
+};
+
+export const buildInlineGroupName = ({
+ waveName,
+ groupLabel,
+}: {
+ readonly waveName: string;
+ readonly groupLabel: string;
+}): string => {
+ const normalizedWaveName = waveName.trim();
+ const normalizedGroupLabel = groupLabel.trim();
+
+ if (!normalizedWaveName.length) {
+ return normalizedGroupLabel || "Wave Group";
+ }
+
+ if (!normalizedGroupLabel.length) {
+ return normalizedWaveName;
+ }
+
+ return `${normalizedWaveName} ${normalizedGroupLabel}`;
+};
diff --git a/components/waves/create-wave/hooks/useWaveConfig.ts b/components/waves/create-wave/hooks/useWaveConfig.ts
index aabea0efb9..bf9a646b19 100644
--- a/components/waves/create-wave/hooks/useWaveConfig.ts
+++ b/components/waves/create-wave/hooks/useWaveConfig.ts
@@ -1,6 +1,8 @@
"use client";
import { useEffect, useState } from "react";
+import type { CommunityMemberMinimal } from "@/entities/IProfile";
+import type { ApiCreateGroup } from "@/generated/models/ApiCreateGroup";
import type {
CreateWaveConfig,
CreateWaveOutcomeType,
@@ -15,6 +17,15 @@ 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;
@@ -104,6 +115,9 @@ export function useWaveConfig() {
const [groupsCache, setGroupsCache] = useState>(
{}
);
+ const [groupBuilders, setGroupBuilders] = useState<
+ Record
+ >(createInitialInlineGroupBuilderMap());
// Update end date config when config changes
useEffect(() => {
@@ -123,6 +137,7 @@ export function useWaveConfig() {
...getInitialConfig({ type: overview.type }),
overview,
}));
+ setGroupBuilders(createInitialInlineGroupBuilderMap());
};
const setDates = (dates: CreateWaveConfig["dates"]) => {
@@ -247,6 +262,101 @@ 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) => ({
@@ -330,6 +440,7 @@ export function useWaveConfig() {
selectedOutcomeType,
errors,
groupsCache,
+ groupBuilders,
// Section updaters
setOverview,
setDates,
@@ -342,6 +453,12 @@ export function useWaveConfig() {
onOutcomeTypeChange,
// Group handling
onGroupSelect,
+ setGroupBuilderPanel,
+ setGroupBuilderRule,
+ setGroupBuilderDraft,
+ addGroupBuilderIdentity,
+ removeGroupBuilderIdentity,
+ resetGroupBuilder,
// Voting
onVotingTypeChange,
onCategoryChange,