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,