diff --git a/.npmpackagejsonlintrc.json b/.npmpackagejsonlintrc.json deleted file mode 100644 index d88d5ffb00..0000000000 --- a/.npmpackagejsonlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rules": { - "no-caret-version-dependencies": "error", - "no-caret-version-devDependencies": "error", - "no-tilde-version-dependencies": "error", - "no-tilde-version-devDependencies": "error" - } -} diff --git a/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml b/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml new file mode 100644 index 0000000000..26c1b8c153 --- /dev/null +++ b/.playwright-mcp/page-2026-03-31T06-27-58-535Z.yml @@ -0,0 +1,9 @@ +- generic [active]: + - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: + - generic [ref=e9]: + - text: Compiling + - generic [ref=e10]: + - generic [ref=e11]: . + - generic [ref=e12]: . + - generic [ref=e13]: . + - alert [ref=e14] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png b/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png new file mode 100644 index 0000000000..2b62688cb1 Binary files /dev/null and b/.playwright-mcp/page-2026-03-31T06-28-02-990Z.png differ diff --git a/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml b/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml new file mode 100644 index 0000000000..8055a87948 --- /dev/null +++ b/.playwright-mcp/page-2026-03-31T13-04-45-965Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: + - img [ref=e7] + - alert [ref=e10] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml b/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml new file mode 100644 index 0000000000..8055a87948 --- /dev/null +++ b/.playwright-mcp/page-2026-03-31T13-15-19-347Z.yml @@ -0,0 +1,4 @@ +- generic [active]: + - button "Open Next.js Dev Tools" [ref=e6] [cursor=pointer]: + - img [ref=e7] + - alert [ref=e10] \ No newline at end of file diff --git a/__tests__/components/DefaultWinnerDrop.test.tsx b/__tests__/components/DefaultWinnerDrop.test.tsx index 4674a21b61..af48ddd758 100644 --- a/__tests__/components/DefaultWinnerDrop.test.tsx +++ b/__tests__/components/DefaultWinnerDrop.test.tsx @@ -16,9 +16,14 @@ jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => ( + + +)); +jest.mock("@/components/waves/CreateDropInput", () => () => ( +
+)); +jest.mock("@/components/waves/CreateDropContentRequirements", () => () => ( +
+)); +jest.mock("@/components/waves/CreateDropMetadata", () => () => ( +
+)); +jest.mock("@/components/waves/CreateDropContentFiles", () => ({ + CreateDropContentFiles: () =>
, +})); +jest.mock("@/components/waves/CreateDropSubmit", () => ({ + CreateDropSubmit: (props: any) => ( + + ), +})); +jest.mock("@/components/waves/CreateDropDropModeToggle", () => ({ + CreateDropDropModeToggle: (props: any) => ( + + ), +})); + +jest.mock("@/components/waves/CreateDropIdentityField", () => (props: any) => ( +
+ + {props.selectedIdentity?.label ?? props.selfIdentity?.label ?? "none"} + + +
+)); + +jest.mock( + "@/components/waves/CreateDropIdentityPickerModal", + () => (props: any) => + props.isOpen ? ( +
+
{props.errorMessage}
+ + + +
+ ) : null +); + +jest.mock("@/components/waves/hooks/useDropMetadata", () => ({ + generateMetadataId: jest.fn(() => "metadata-id"), + useDropMetadata: jest.fn(() => ({ + metadata: [], + setMetadata: jest.fn(), + initialMetadata: [], + })), +})); + +jest.mock("@/components/auth/Auth", () => ({ + useAuth: jest.fn(() => ({ + requestAuth: jest.fn(async () => ({ success: true })), + setToast: jest.fn(), + connectedProfile: { + id: mockViewerSelection.profileId, + handle: mockViewerSelection.label, + display: mockViewerSelection.label, + primary_wallet: mockViewerSelection.value, + pfp: null, + }, + })), +})); + +jest.mock("@/contexts/wave/MyStreamContext", () => ({ + useMyStream: jest.fn(() => ({ + processIncomingDrop: jest.fn(), + })), +})); + +jest.mock("@/hooks/drops/useDropSignature", () => ({ + useDropSignature: jest.fn(() => ({ + signDrop: jest.fn(), + })), +})); + +jest.mock("@/services/websocket", () => ({ + useWebSocket: jest.fn(() => ({ + send: jest.fn(), + })), +})); + +jest.mock("@/components/auth/SeizeConnectContext", () => ({ + useSeizeConnectContext: jest.fn(() => ({ + isSafeWallet: false, + address: null, + })), +})); + +describe("CreateDropContent identity picker flow", () => { + beforeEach(() => { + jest.clearAllMocks(); + (global as any).ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })); + }); + + const baseWave = { + id: "wave-1", + wave: { type: ApiWaveType.Rank }, + chat: { enabled: true }, + participation: { + submission_strategy: { + config: { + who_can_be_submitted: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }, + }, + required_metadata: [], + required_media: [], + }, + } as any; + + const createWave = ({ + id = baseWave.id, + mode = ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }: { + readonly id?: string; + readonly mode?: ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted; + } = {}) => + ({ + ...baseWave, + id, + participation: { + ...baseWave.participation, + submission_strategy: { + ...baseWave.participation.submission_strategy, + config: { + ...baseWave.participation.submission_strategy.config, + who_can_be_submitted: mode, + }, + }, + }, + }) as any; + + const createStoredDrop = () => + ({ + title: null, + parts: [ + { + content: "queued part", + quoted_drop: null, + media: [], + }, + ], + mentioned_users: [], + mentioned_waves: [], + referenced_nfts: [], + metadata: [], + signature: null, + }) as any; + + const renderSubject = ({ + isDropMode = true, + wave = createWave(), + drop = null, + submitDrop = jest.fn(), + }: { + readonly isDropMode?: boolean; + readonly wave?: any; + readonly drop?: any; + readonly submitDrop?: jest.Mock; + } = {}) => { + const onDropModeChange = jest.fn(); + const utils = render( + + + + ); + + return { + ...utils, + onDropModeChange, + submitDrop, + }; + }; + + it("auto-opens the picker and exits Drop mode when closed without a selection", async () => { + const { onDropModeChange } = renderSubject(); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + + await userEvent.click(screen.getByText("close picker")); + + expect(onDropModeChange).toHaveBeenCalledWith(false); + }); + + it("stores the selected identity, closes the picker, and reopens on change", async () => { + renderSubject(); + + await userEvent.click(screen.getByText("select other")); + + await waitFor(() => { + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + }); + expect(screen.getByTestId("identity-field")).toHaveTextContent("other"); + + await userEvent.click(screen.getByText("change identity")); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + }); + + it("clears the selected identity after submit without auto-reopening the picker", async () => { + const { submitDrop } = renderSubject({ drop: createStoredDrop() }); + + await userEvent.click(screen.getByText("select other")); + + await waitFor(() => { + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + }); + expect(screen.getByTestId("identity-field")).toHaveTextContent("other"); + + await userEvent.click(screen.getByText("submit")); + + await waitFor(() => { + expect(submitDrop).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByTestId("identity-field")).toHaveTextContent("none"); + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + }); + + await userEvent.click(screen.getByText("change identity")); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + }); + + it("does not suppress picker auto-open after a chat submit on an identity wave", async () => { + const chatSubmit = jest.fn(); + const { rerender } = renderSubject({ + isDropMode: false, + drop: createStoredDrop(), + submitDrop: chatSubmit, + }); + + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("submit")); + + await waitFor(() => { + expect(chatSubmit).toHaveBeenCalledTimes(1); + }); + + rerender( + + + + ); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + }); + + it("keeps the selected identity when leaving Drop mode is rejected by the parent", async () => { + const { onDropModeChange } = renderSubject(); + + await userEvent.click(screen.getByText("select other")); + + expect(screen.getByTestId("identity-field")).toHaveTextContent("other"); + + await userEvent.click(screen.getByText("toggle")); + + expect(onDropModeChange).toHaveBeenCalledWith(false); + expect(screen.getByTestId("identity-field")).toHaveTextContent("other"); + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + }); + + it("keeps the picker open and shows an error when selecting the viewer identity in OnlyOthers mode", async () => { + renderSubject(); + + await userEvent.click(screen.getByText("select self")); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + expect( + screen.getByText("Select someone else to nominate.") + ).toBeInTheDocument(); + }); + + it("clears the selected identity after leaving Drop mode and reopens the picker on re-entry", async () => { + const { rerender } = renderSubject(); + + await userEvent.click(screen.getByText("select other")); + expect(screen.getByTestId("identity-field")).toHaveTextContent("other"); + + rerender( + + + + ); + + rerender( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("identity-field")).toHaveTextContent("none"); + }); + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + }); + + it("closes metadata after leaving Drop mode and keeps it closed on re-entry", async () => { + const { rerender } = renderSubject(); + + expect(screen.queryByTestId("metadata")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("open metadata")); + + expect(screen.getByTestId("metadata")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.queryByTestId("metadata")).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.queryByTestId("metadata")).not.toBeInTheDocument(); + }); + + it("does not leak open metadata into a new wave while staying in Drop mode", async () => { + const { rerender } = renderSubject(); + + expect(screen.queryByTestId("metadata")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText("open metadata")); + + expect(screen.getByTestId("metadata")).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.queryByTestId("metadata")).not.toBeInTheDocument(); + }); + + it("clears stale identity-picker errors after leaving Drop mode and re-entering", async () => { + const { rerender } = renderSubject(); + + await userEvent.click(screen.getByText("select self")); + + expect( + screen.getByText("Select someone else to nominate.") + ).toBeInTheDocument(); + + rerender( + + + + ); + + expect( + screen.queryByText("Select someone else to nominate.") + ).not.toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + expect( + screen.queryByText("Select someone else to nominate.") + ).not.toBeInTheDocument(); + }); + + it("does not leak an explicit picker-open state into a new wave", async () => { + const { rerender } = renderSubject(); + + await userEvent.click(screen.getByText("select other")); + await userEvent.click(screen.getByText("change identity")); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + + rerender( + + + + ); + + expect( + screen.queryByTestId("identity-picker-modal") + ).not.toBeInTheDocument(); + expect(screen.getByTestId("identity-field")).toHaveTextContent("viewer"); + }); + + it("clears stale identity-picker errors when the wave changes", async () => { + const { rerender } = renderSubject(); + + await userEvent.click(screen.getByText("select self")); + + expect( + screen.getByText("Select someone else to nominate.") + ).toBeInTheDocument(); + + rerender( + + + + ); + + expect(screen.getByTestId("identity-picker-modal")).toBeInTheDocument(); + expect( + screen.queryByText("Select someone else to nominate.") + ).not.toBeInTheDocument(); + }); + + it("does not leak mobile options state into a new wave", async () => { + const { rerender } = renderSubject(); + + await userEvent.click(screen.getByText("open options")); + + expect(screen.getByTestId("actions")).toHaveAttribute( + "data-show-options", + "true" + ); + + rerender( + + + + ); + + expect(screen.getByTestId("actions")).toHaveAttribute( + "data-show-options", + "false" + ); + }); +}); diff --git a/__tests__/components/waves/CreateDropContent.utils.test.ts b/__tests__/components/waves/CreateDropContent.utils.test.ts index e68cb00e61..39188a5f35 100644 --- a/__tests__/components/waves/CreateDropContent.utils.test.ts +++ b/__tests__/components/waves/CreateDropContent.utils.test.ts @@ -1,26 +1,206 @@ -import { convertMetadataToDropMetadata } from '@/components/waves/utils/convertMetadataToDropMetadata'; -import { ApiWaveMetadataType } from '@/generated/models/ApiWaveMetadataType'; +import { buildDropSubmissionMetadata } from "@/components/waves/utils/buildDropSubmissionMetadata"; +import { convertMetadataToDropMetadata } from "@/components/waves/utils/convertMetadataToDropMetadata"; +import { getIdentitySubmissionMetadataErrors } from "@/components/waves/utils/identitySubmissionMetadataValidation"; +import { + getEffectiveIdentitySubmitAttempt, + getEffectiveSelectedIdentity, + getIdentitySubmissionScopeKey, +} from "@/components/waves/utils/identitySubmissionState"; +import type { SelectableIdentityOption } from "@/components/utils/input/profile-search/getSelectableIdentity"; +import { IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR } from "@/helpers/waves/identity-submission-metadata"; +import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; +import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; -describe('CreateDropContent utilities', () => { - describe('convertMetadataToDropMetadata', () => { - it('filters out entries without key or value', () => { +describe("CreateDropContent utilities", () => { + const viewerIdentity: SelectableIdentityOption = { + value: "0xabc", + label: "alice", + secondaryLabel: "0xabc", + avatarUrl: null, + profileId: "viewer-1", + }; + const selectedIdentity: SelectableIdentityOption = { + value: "0xdef", + label: "bob", + secondaryLabel: "0xdef", + avatarUrl: null, + profileId: "viewer-2", + }; + + describe("convertMetadataToDropMetadata", () => { + it("filters out entries without key or value", () => { const result = convertMetadataToDropMetadata([ - { key: 'a', type: ApiWaveMetadataType.String, value: '1', required: true }, + { + key: "a", + type: ApiWaveMetadataType.String, + value: "1", + required: true, + }, { key: null, type: null, value: null, required: false }, - { key: 'b', type: ApiWaveMetadataType.Number, value: 2, required: false }, + { + key: "b", + type: ApiWaveMetadataType.Number, + value: 2, + required: false, + }, ]); expect(result).toEqual([ - { data_key: 'a', data_value: '1' }, - { data_key: 'b', data_value: '2' }, + { data_key: "a", data_value: "1" }, + { data_key: "b", data_value: "2" }, ]); }); }); - it('handles numeric metadata values', () => { + it("handles numeric metadata values", () => { const out = convertMetadataToDropMetadata([ - { key: 'num', type: ApiWaveMetadataType.Number, value: 10, required: true }, + { + key: "num", + type: ApiWaveMetadataType.Number, + value: 10, + required: true, + }, + ]); + + expect(out).toEqual([{ data_key: "num", data_value: "10" }]); + }); + + it("appends strategy-owned identity metadata without duplicating the key", () => { + const out = buildDropSubmissionMetadata({ + metadata: [ + { + key: "identity", + type: ApiWaveMetadataType.String, + value: "manual-value", + required: false, + }, + { + key: "title", + type: ApiWaveMetadataType.String, + value: "drop title", + required: false, + }, + ] as any, + identity: "0xabc", + }); + + expect(out).toEqual([ + { data_key: "title", data_value: "drop title" }, + { data_key: "identity", data_value: "0xabc" }, ]); + }); - expect(out).toEqual([{ data_key: 'num', data_value: '10' }]); + it("preserves manual identity metadata when no strategy identity is provided", () => { + const out = buildDropSubmissionMetadata({ + metadata: [ + { + key: "identity", + type: ApiWaveMetadataType.String, + value: "manual-value", + required: false, + }, + ] as any, + identity: null, + }); + + expect(out).toEqual([{ data_key: "identity", data_value: "manual-value" }]); + }); + + it("flags reserved metadata keys during identity submissions", () => { + const errors = getIdentitySubmissionMetadataErrors({ + isIdentitySubmissionExperience: true, + metadata: [ + { id: "reserved", key: " Identity " }, + { id: "title", key: "title" }, + ], + }); + + expect(errors).toEqual({ + reserved: IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR, + }); + }); + + it("allows reserved metadata keys outside identity submissions", () => { + const errors = getIdentitySubmissionMetadataErrors({ + isIdentitySubmissionExperience: false, + metadata: [{ id: "reserved", key: "identity" }], + }); + + expect(errors).toEqual({}); + }); + + describe("identity submission state", () => { + it("derives OnlyMyself from the viewer identity without draft state", () => { + const scopeKey = getIdentitySubmissionScopeKey({ + waveId: "wave-1", + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyMyself, + }); + + expect( + getEffectiveSelectedIdentity({ + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyMyself, + viewerIdentity, + selectedIdentityState: null, + scopeKey, + }) + ).toEqual(viewerIdentity); + }); + + it("ignores stale draft identity from a different scope", () => { + const staleScopeKey = getIdentitySubmissionScopeKey({ + waveId: "wave-1", + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }); + const currentScopeKey = getIdentitySubmissionScopeKey({ + waveId: "wave-2", + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }); + + expect( + getEffectiveSelectedIdentity({ + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + viewerIdentity, + selectedIdentityState: { + scopeKey: staleScopeKey, + value: selectedIdentity, + }, + scopeKey: currentScopeKey, + }) + ).toBeNull(); + }); + + it("keeps submit-attempt state scoped to the current wave and mode", () => { + const staleScopeKey = getIdentitySubmissionScopeKey({ + waveId: "wave-1", + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }); + const currentScopeKey = getIdentitySubmissionScopeKey({ + waveId: "wave-2", + isIdentitySubmissionExperience: true, + identitySubmissionMode: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }); + + expect( + getEffectiveIdentitySubmitAttempt({ + attemptState: { + scopeKey: staleScopeKey, + value: true, + }, + scopeKey: currentScopeKey, + }) + ).toBe(false); + }); }); }); diff --git a/__tests__/components/waves/CreateDropIdentityField.test.tsx b/__tests__/components/waves/CreateDropIdentityField.test.tsx new file mode 100644 index 0000000000..3f4f34c735 --- /dev/null +++ b/__tests__/components/waves/CreateDropIdentityField.test.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import CreateDropIdentityField from "@/components/waves/CreateDropIdentityField"; +import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes) => ( + {props.alt + ), +})); + +const selfIdentity = { + value: "0xviewer", + label: "viewer", + secondaryLabel: "0xviewer", + avatarUrl: null, + profileId: "viewer-id", +}; + +describe("CreateDropIdentityField", () => { + it("shows the viewer identity as a read-only selection in OnlyMyself mode", () => { + render( + + ); + + expect( + screen.getByText( + "Your identity will be used automatically for this submission." + ) + ).toBeInTheDocument(); + expect(screen.getByText("viewer")).toBeInTheDocument(); + expect(screen.getByText("0xviewer")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /change identity/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /select identity/i }) + ).not.toBeInTheDocument(); + }); + + it("shows an unavailable read-only state in OnlyMyself mode when the viewer identity is missing", () => { + render( + + ); + + expect(screen.getByText("Identity unavailable")).toBeInTheDocument(); + expect( + screen.getByText( + "We couldn't determine your identity for this submission." + ) + ).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/waves/CreateDropMetadata.test.tsx b/__tests__/components/waves/CreateDropMetadata.test.tsx index c78080dcbc..7b6902d67b 100644 --- a/__tests__/components/waves/CreateDropMetadata.test.tsx +++ b/__tests__/components/waves/CreateDropMetadata.test.tsx @@ -1,22 +1,28 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import CreateDropMetadata from '@/components/waves/CreateDropMetadata'; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import CreateDropMetadata from "@/components/waves/CreateDropMetadata"; +import { IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR } from "@/helpers/waves/identity-submission-metadata"; -jest.mock('@/components/waves/CreateDropMetadataRow', () => ({ +jest.mock("@/components/waves/CreateDropMetadataRow", () => ({ __esModule: true, - default: ({ index }: any) =>
row-{index}
, + default: ({ index, isError, errorMessage }: any) => ( +
+ row-{index}-{isError ? "error" : "ok"}-{errorMessage ?? "none"} +
+ ), })); -describe('CreateDropMetadata', () => { - const base = { id: 'meta-1', key: 'k', value: 'v' } as any; +describe("CreateDropMetadata", () => { + const base = { id: "meta-1", key: "k", value: "v" } as any; - it('renders rows and triggers actions', () => { + it("renders rows and triggers actions", () => { const close = jest.fn(); const onAdd = jest.fn(); render( { onRemoveMetadata={jest.fn()} /> ); - expect(screen.getAllByTestId('row')).toHaveLength(2); - const buttons = screen.getAllByRole('button'); + expect(screen.getAllByTestId("row")).toHaveLength(2); + const buttons = screen.getAllByRole("button"); fireEvent.click(buttons[0]); expect(close).toHaveBeenCalled(); - fireEvent.click(screen.getByText('Add new')); + fireEvent.click(screen.getByText("Add new")); expect(onAdd).toHaveBeenCalled(); }); + + it("passes reserved metadata key errors to rows", () => { + render( + + ); + + expect(screen.getByTestId("row").textContent).toContain( + IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR + ); + }); }); diff --git a/__tests__/components/waves/CreateDropMetadataRow.test.tsx b/__tests__/components/waves/CreateDropMetadataRow.test.tsx index 015aac74bd..158186d123 100644 --- a/__tests__/components/waves/CreateDropMetadataRow.test.tsx +++ b/__tests__/components/waves/CreateDropMetadataRow.test.tsx @@ -1,16 +1,16 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import CreateDropMetadataRow from '@/components/waves/CreateDropMetadataRow'; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import CreateDropMetadataRow from "@/components/waves/CreateDropMetadataRow"; const baseMeta = { - id: 'meta-1', - key: 'a', - value: '1', - type: 'TEXT', + id: "meta-1", + key: "a", + value: "1", + type: "TEXT", required: false, } as any; -test('calls handlers for key and value changes', () => { +test("calls handlers for key and value changes", () => { const onKey = jest.fn(); const onValue = jest.fn(); render( @@ -21,31 +21,56 @@ test('calls handlers for key and value changes', () => { onChangeValue={onValue} onRemove={jest.fn()} isError={false} + errorMessage={null} disabled={false} /> ); - fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'x' } }); - expect(onKey).toHaveBeenCalledWith({ index: 0, newKey: 'x' }); - fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'foo' } }); - expect(onValue).toHaveBeenCalledWith({ index: 0, newValue: 'foo' }); + fireEvent.change(screen.getAllByRole("textbox")[0], { + target: { value: "x" }, + }); + expect(onKey).toHaveBeenCalledWith({ index: 0, newKey: "x" }); + fireEvent.change(screen.getAllByRole("textbox")[1], { + target: { value: "foo" }, + }); + expect(onValue).toHaveBeenCalledWith({ index: 0, newValue: "foo" }); }); -test('handles numeric value parsing', () => { +test("handles numeric value parsing", () => { const onValue = jest.fn(); render( ); - const input = screen.getAllByRole('textbox')[1]; - fireEvent.change(input, { target: { value: '3' } }); + const input = screen.getAllByRole("textbox")[1]; + fireEvent.change(input, { target: { value: "3" } }); expect(onValue).toHaveBeenCalledWith({ index: 1, newValue: 3 }); - fireEvent.change(input, { target: { value: '-' } }); + fireEvent.change(input, { target: { value: "-" } }); expect(onValue).toHaveBeenCalledWith({ index: 1, newValue: null }); }); + +test("renders reserved metadata key errors", () => { + render( + + ); + + expect( + screen.getByText("Metadata name is reserved for identity nominations") + ).toBeInTheDocument(); +}); diff --git a/__tests__/components/waves/create-wave/CreateWave.test.tsx b/__tests__/components/waves/create-wave/CreateWave.test.tsx index 181adb8c58..e8ca0a890c 100644 --- a/__tests__/components/waves/create-wave/CreateWave.test.tsx +++ b/__tests__/components/waves/create-wave/CreateWave.test.tsx @@ -185,6 +185,7 @@ describe("CreateWave", () => { noOfApplicationsAllowedPerParticipant: null, requiredTypes: [], requiredMetadata: [], + submissionStrategy: null, terms: null, signatureRequired: false, adminCanDeleteDrops: false, diff --git a/__tests__/components/waves/create-wave/drops/CreateWaveDrops.test.tsx b/__tests__/components/waves/create-wave/drops/CreateWaveDrops.test.tsx index bb82a199f2..ebfcdbb00c 100644 --- a/__tests__/components/waves/create-wave/drops/CreateWaveDrops.test.tsx +++ b/__tests__/components/waves/create-wave/drops/CreateWaveDrops.test.tsx @@ -1,22 +1,56 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import CreateWaveDrops from '@/components/waves/create-wave/drops/CreateWaveDrops'; -import { ApiWaveType } from '@/generated/models/ApiWaveType'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateWaveDrops from "@/components/waves/create-wave/drops/CreateWaveDrops"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; -jest.mock('@/components/waves/create-wave/drops/types/CreateWaveDropsTypes', () => (props: any) => ( -
props.onRequiredTypeChange(['A'])} /> -)); +jest.mock( + "@/components/waves/create-wave/drops/types/CreateWaveDropsTypes", + () => (props: any) => ( +
props.onRequiredTypeChange(["A"])} + /> + ) +); -jest.mock('@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadata', () => (props: any) => ( -
props.onRequiredMetadataChange([{foo:'bar'}])} /> -)); +jest.mock( + "@/components/waves/create-wave/drops/submission-mode/CreateWaveDropsSubmissionMode", + () => (props: any) => ( +
-)); +jest.mock( + "@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow", + () => + ({ item, index, errorMessage, onItemRemove }: any) => ( +
+ {item.key}-{errorMessage ?? "ok"} + +
+ ) +); -jest.mock('@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataAddRowButton', () => ({ onAddNewRow }: any) => ( - -)); +jest.mock( + "@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataAddRowButton", + () => + ({ onAddNewRow }: any) => ( + + ) +); -describe('CreateWaveDropsMetadata', () => { - it('adds rows and marks non unique', async () => { +describe("CreateWaveDropsMetadata", () => { + it("adds rows and marks non unique", async () => { const user = userEvent.setup(); const change = jest.fn(); - const items = [{ key:'a', type:ApiWaveMetadataType.String },{ key:'a', type:ApiWaveMetadataType.String }]; - render(); - expect(screen.getByTestId('row-0').textContent).toContain('bad'); - await user.click(screen.getByTestId('add')); - expect(change).toHaveBeenCalledWith([...items, { key:'', type: ApiWaveMetadataType.String }]); - await user.click(screen.getAllByText('rem')[0]); + const items = [ + { key: "a", type: ApiWaveMetadataType.String }, + { key: "a", type: ApiWaveMetadataType.String }, + ]; + render( + + ); + expect(screen.getByTestId("row-0").textContent).toContain( + "Metadata name must be unique" + ); + await user.click(screen.getByTestId("add")); + expect(change).toHaveBeenCalledWith([ + ...items, + { key: "", type: ApiWaveMetadataType.String }, + ]); + await user.click(screen.getAllByText("rem")[0]); expect(change).toHaveBeenCalled(); }); - it('shows placeholder when empty', () => { - render({}} />); - expect(screen.getByText('No required metadata added')).toBeInTheDocument(); + it("marks reserved identity metadata keys when validation fails", () => { + render( + {}} + /> + ); + + expect(screen.getByTestId("row-0").textContent).toContain( + IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR + ); + }); + + it("shows placeholder when empty", () => { + render( + {}} + /> + ); + expect(screen.getByText("No required metadata added")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow.test.tsx b/__tests__/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow.test.tsx index 7954228374..ad063b37ed 100644 --- a/__tests__/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow.test.tsx +++ b/__tests__/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow.test.tsx @@ -1,32 +1,47 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import CreateWaveDropsMetadataRow from '@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow'; -import { ApiWaveMetadataType } from '@/generated/models/ApiWaveMetadataType'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateWaveDropsMetadataRow from "@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRow"; +import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; +import { IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR } from "@/helpers/waves/identity-submission-metadata"; -jest.mock('@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRowType', () => (props: any) => ( -
props.onTypeChange(ApiWaveMetadataType.Number)} /> -)); +jest.mock( + "@/components/waves/create-wave/drops/metadata/CreateWaveDropsMetadataRowType", + () => (props: any) => ( +
props.onTypeChange(ApiWaveMetadataType.Number)} + /> + ) +); -describe('CreateWaveDropsMetadataRow', () => { - it('calls onItemChange when input changes', async () => { +describe("CreateWaveDropsMetadataRow", () => { + it("calls onItemChange when input changes", async () => { const user = userEvent.setup(); const onItemChange = jest.fn(); const onItemRemove = jest.fn(); render( ); - await user.type(screen.getByRole('textbox'), 'a'); - expect(onItemChange).toHaveBeenCalledWith({ index: 0, key: 'namea', type: ApiWaveMetadataType.String }); - await user.click(screen.getByTestId('type')); - expect(onItemChange).toHaveBeenLastCalledWith(expect.objectContaining({ type: ApiWaveMetadataType.Number })); - await user.click(screen.getByRole('button', { name: 'Remove item' })); + await user.type(screen.getByRole("textbox"), "a"); + expect(onItemChange).toHaveBeenCalledWith({ + index: 0, + key: "namea", + type: ApiWaveMetadataType.String, + }); + await user.click(screen.getByTestId("type")); + expect(onItemChange).toHaveBeenLastCalledWith( + expect.objectContaining({ type: ApiWaveMetadataType.Number }) + ); + await user.click(screen.getByRole("button", { name: "Remove item" })); expect(onItemRemove).toHaveBeenCalledWith(0); - expect(screen.getByText('Metadata name must be unique')).toBeInTheDocument(); + expect( + screen.getByText(IDENTITY_SUBMISSION_RESERVED_METADATA_ERROR) + ).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/create-wave/drops/submission-mode/CreateWaveDropsSubmissionMode.test.tsx b/__tests__/components/waves/create-wave/drops/submission-mode/CreateWaveDropsSubmissionMode.test.tsx new file mode 100644 index 0000000000..9e08f3535c --- /dev/null +++ b/__tests__/components/waves/create-wave/drops/submission-mode/CreateWaveDropsSubmissionMode.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import CreateWaveDropsSubmissionMode from "@/components/waves/create-wave/drops/submission-mode/CreateWaveDropsSubmissionMode"; +import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from "@/generated/models/ApiWaveParticipationIdentitySubmissionAllowDuplicates"; +import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; + +describe("CreateWaveDropsSubmissionMode", () => { + it("renders clearer identity nomination copy", () => { + render( + {}} + /> + ); + + expect(screen.getByText("Submission type")).toBeInTheDocument(); + expect(screen.getByText("Standard drops")).toBeInTheDocument(); + expect(screen.getByText("Identity nominations")).toBeInTheDocument(); + expect( + screen.getByText("Which identities can a participant submit?") + ).toBeInTheDocument(); + expect(screen.getByText("Own identity only")).toBeInTheDocument(); + const ownIdentityDescription = screen.getByText( + "A participant can submit only themselves." + ); + expect(ownIdentityDescription).toBeInTheDocument(); + expect(ownIdentityDescription.closest("label")).toBeInTheDocument(); + expect(ownIdentityDescription).toHaveAttribute( + "id", + "who-can-be-submitted-only_myself-description" + ); + expect( + screen.getByRole("radio", { name: "Own identity only" }) + ).toHaveAttribute( + "aria-describedby", + "who-can-be-submitted-only_myself-description" + ); + expect( + screen.getByText("When can the same identity be submitted again?") + ).toBeInTheDocument(); + expect(screen.getByText("Never again")).toBeInTheDocument(); + const neverAgainDescription = screen.getByText( + "The same identity can be submitted only once, even after a win." + ); + expect(neverAgainDescription).toBeInTheDocument(); + expect(neverAgainDescription.closest("label")).toBeInTheDocument(); + expect(neverAgainDescription).toHaveAttribute( + "id", + "duplicate-submissions-never_allow-description" + ); + expect(screen.getByRole("radio", { name: "Never again" })).toHaveAttribute( + "aria-describedby", + "duplicate-submissions-never_allow-description" + ); + }); + + it("updates nomination rules when a new option is selected", async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + + render( + + ); + + await user.click( + screen.getByText( + "A participant can submit someone else, but not themselves." + ) + ); + expect(onChange).toHaveBeenLastCalledWith({ + type: ApiWaveParticipationSubmissionStrategyType.Identity, + config: { + duplicates: + ApiWaveParticipationIdentitySubmissionAllowDuplicates.NeverAllow, + who_can_be_submitted: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }, + }); + + await user.click(screen.getByRole("radio", { name: "After it wins" })); + expect(onChange).toHaveBeenLastCalledWith({ + type: ApiWaveParticipationSubmissionStrategyType.Identity, + config: { + duplicates: + ApiWaveParticipationIdentitySubmissionAllowDuplicates.AllowAfterWin, + who_can_be_submitted: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.Everyone, + }, + }); + }); +}); diff --git a/__tests__/components/waves/create-wave/hooks/useWaveConfig.test.ts b/__tests__/components/waves/create-wave/hooks/useWaveConfig.test.ts index ed49e22c61..9c6f52fd3f 100644 --- a/__tests__/components/waves/create-wave/hooks/useWaveConfig.test.ts +++ b/__tests__/components/waves/create-wave/hooks/useWaveConfig.test.ts @@ -85,6 +85,7 @@ describe("useWaveConfig", () => { noOfApplicationsAllowedPerParticipant: null, requiredTypes: [], requiredMetadata: [], + submissionStrategy: null, terms: null, signatureRequired: false, adminCanDeleteDrops: true, @@ -173,6 +174,7 @@ describe("useWaveConfig", () => { noOfApplicationsAllowedPerParticipant: 3, requiredTypes: [] as any[], requiredMetadata: [{ key: "title", type: "STRING" as any }], + submissionStrategy: null, terms: "Accept terms", signatureRequired: true, adminCanDeleteDrops: true, diff --git a/__tests__/components/waves/drop/SingleWaveDropContent.test.tsx b/__tests__/components/waves/drop/SingleWaveDropContent.test.tsx index 2e606b9eed..14c7d20e86 100644 --- a/__tests__/components/waves/drop/SingleWaveDropContent.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropContent.test.tsx @@ -1,25 +1,102 @@ -import { render } from '@testing-library/react'; -import { SingleWaveDropContent } from '@/components/waves/drop/SingleWaveDropContent'; +import { render } from "@testing-library/react"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { SingleWaveDropContent } from "@/components/waves/drop/SingleWaveDropContent"; -const waveMock = jest.fn(); -const metaMock = jest.fn(); +const waveContentMock = jest.fn(); +const metadataMock = jest.fn(); +const identityMock = jest.fn(); -jest.mock('@/components/waves/drops/WaveDropContent', () => (props: any) => { waveMock(props); return
; }); -jest.mock('@/components/waves/drop/SingleWaveDropContentMetadata', () => ({ SingleWaveDropContentMetadata: (props: any) => { metaMock(props); return
; } })); +jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => { + waveContentMock(props); + return
; +}); + +jest.mock("@/components/waves/drop/SingleWaveDropContentMetadata", () => ({ + SingleWaveDropContentMetadata: (props: any) => { + metadataMock(props); + return
; + }, +})); -describe('SingleWaveDropContent', () => { - const baseDrop:any = { metadata: [{},{}], parts: [] }; +jest.mock("@/components/waves/drops/identity/WaveDropIdentity", () => ({ + WaveDropIdentity: (props: any) => { + identityMock(props); + return
; + }, +})); - beforeEach(() => { jest.clearAllMocks(); }); +describe("SingleWaveDropContent", () => { + const baseDrop: any = { + wave: { submission_type: null }, + metadata: [ + { data_key: "title", data_value: "Drop title" }, + { data_key: "description", data_value: "Drop description" }, + ], + parts: [], + }; - it('renders metadata when available', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders metadata when available", () => { render(); - expect(metaMock).toHaveBeenCalled(); - expect(waveMock).toHaveBeenCalledWith(expect.objectContaining({ activePartIndex: 0 })); + + expect(identityMock).toHaveBeenCalledWith( + expect.objectContaining({ + drop: baseDrop, + variant: "full", + }) + ); + expect(metadataMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: baseDrop.metadata, + }) + ); + expect(waveContentMock).toHaveBeenCalledWith( + expect.objectContaining({ activePartIndex: 0 }) + ); }); - it('hides metadata when none', () => { - render(); - expect(metaMock).not.toHaveBeenCalled(); + it("hides metadata when none remain after identity filtering", () => { + render( + + ); + + expect(identityMock).toHaveBeenCalledTimes(1); + expect(metadataMock).not.toHaveBeenCalled(); + }); + + it("filters reserved identity metadata before rendering metadata cards", () => { + render( + + ); + + expect(metadataMock).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: [{ data_key: "title", data_value: "Drop title" }], + }) + ); }); }); diff --git a/__tests__/components/waves/drop/SingleWaveDropContentMetadata.test.tsx b/__tests__/components/waves/drop/SingleWaveDropContentMetadata.test.tsx index c0c14e8eed..baecfe98f5 100644 --- a/__tests__/components/waves/drop/SingleWaveDropContentMetadata.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropContentMetadata.test.tsx @@ -1,25 +1,29 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { SingleWaveDropContentMetadata } from '@/components/waves/drop/SingleWaveDropContentMetadata'; +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { SingleWaveDropContentMetadata } from "@/components/waves/drop/SingleWaveDropContentMetadata"; -jest.mock('@/hooks/isMobileDevice', () => ({ __esModule: true, default: jest.fn(() => false) })); +jest.mock("@/hooks/isMobileDevice", () => ({ + __esModule: true, + default: jest.fn(() => false), +})); -const drop = { - metadata: [ - { data_key: 'a', data_value: '1' }, - { data_key: 'b', data_value: '2' }, - { data_key: 'c', data_value: '3' } - ] -} as any; +const metadata = [ + { data_key: "a", data_value: "1" }, + { data_key: "b", data_value: "2" }, + { data_key: "c", data_value: "3" }, +] as any; -describe('SingleWaveDropContentMetadata', () => { - it('toggles additional metadata', () => { - render(); - expect(screen.getByText('a:')).toBeInTheDocument(); - expect(screen.queryByText('c:')).toBeNull(); - fireEvent.click(screen.getByText('Show all')); - expect(screen.getByText('c:')).toBeInTheDocument(); - fireEvent.click(screen.getByText('Show less')); - expect(screen.queryByText('c:')).toBeNull(); +describe("SingleWaveDropContentMetadata", () => { + it("toggles additional metadata", () => { + render(); + + expect(screen.getByText("a:")).toBeInTheDocument(); + expect(screen.queryByText("c:")).toBeNull(); + + fireEvent.click(screen.getByText("Show all")); + expect(screen.getByText("c:")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Show less")); + expect(screen.queryByText("c:")).toBeNull(); }); }); diff --git a/__tests__/components/waves/drops/OngoingParticipationDrop.test.tsx b/__tests__/components/waves/drops/OngoingParticipationDrop.test.tsx index 2b12e771a1..a8fa04a8bd 100644 --- a/__tests__/components/waves/drops/OngoingParticipationDrop.test.tsx +++ b/__tests__/components/waves/drops/OngoingParticipationDrop.test.tsx @@ -1,52 +1,99 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import OngoingParticipationDrop from '@/components/waves/drops/participation/OngoingParticipationDrop'; -import type { ExtendedDrop } from '@/helpers/waves/drop.helpers'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import OngoingParticipationDrop from "@/components/waves/drops/participation/OngoingParticipationDrop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; // Mock hooks and child components const useIsMobileDevice = jest.fn(); -jest.mock('@/hooks/isMobileDevice', () => ({ __esModule: true, default: (...args: any[]) => useIsMobileDevice(...args) })); +jest.mock("@/hooks/isMobileDevice", () => ({ + __esModule: true, + default: (...args: any[]) => useIsMobileDevice(...args), +})); -jest.mock('@/components/waves/drops/WaveDropActions', () => (props: any) => ( +jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => (
)); let longPressCb: () => void; -jest.mock('@/components/waves/drops/participation/ParticipationDropContent', () => (props: any) => { - longPressCb = props.onLongPress; - return ( - - ); -}); +jest.mock( + "@/components/waves/drops/participation/ParticipationDropContent", + () => (props: any) => { + longPressCb = props.onLongPress; + return ( + + ); + } +); let mobileMenuProps: any; -jest.mock('@/components/waves/drops/WaveDropMobileMenu', () => (props: any) => { +jest.mock("@/components/waves/drops/WaveDropMobileMenu", () => (props: any) => { mobileMenuProps = props; return
; }); -jest.mock('@/components/waves/drops/participation/ParticipationDropHeader', () => () =>
); -jest.mock('@/components/waves/drops/participation/ParticipationDropMetadata', () => () =>
); -jest.mock('@/components/waves/drops/participation/ParticipationDropFooter', () => () =>
); -jest.mock('@/components/waves/drops/participation/ParticipationDropContainer', () => (props: any) =>
{props.children}
); -jest.mock('@/components/waves/drops/WaveDropAuthorPfp', () => () =>
); +jest.mock( + "@/components/waves/drops/participation/ParticipationDropHeader", + () => () =>
+); +const ParticipationDropMetadataMock = jest.fn(() => ( +
+)); +jest.mock( + "@/components/waves/drops/participation/ParticipationDropMetadata", + () => (props: any) => { + ParticipationDropMetadataMock(props); + return
; + } +); +jest.mock( + "@/components/waves/drops/participation/ParticipationDropFooter", + () => () =>
+); +jest.mock( + "@/components/waves/drops/participation/ParticipationDropContainer", + () => (props: any) =>
{props.children}
+); +jest.mock("@/components/waves/drops/WaveDropAuthorPfp", () => () =>
); +const ParticipationIdentityProfileCardMock = jest.fn(({ profile }: any) => ( +
+ {profile.handle ?? profile.primary_address} +
+)); +jest.mock( + "@/components/waves/drops/participation/ParticipationIdentityProfileCard", + () => (props: any) => { + ParticipationIdentityProfileCardMock(props); + return ( +
+ {props.profile.handle ?? props.profile.primary_address} +
+ ); + } +); const drop: ExtendedDrop = { - id: 'd1', - parts: [{ part_id: 'p1' }], + id: "d1", + parts: [{ part_id: "p1" }], metadata: [], - wave: { id: 'w1' } as any, + wave: { id: "w1", submission_type: null } as any, } as any; -const renderComp = (mobile = false) => { +const renderComp = ({ + mobile = false, + dropOverride = drop, +}: { + readonly mobile?: boolean; + readonly dropOverride?: ExtendedDrop; +} = {}) => { const onReply = jest.fn(); const onQuote = jest.fn(); useIsMobileDevice.mockReturnValue(mobile); render( { return { onReply, onQuote }; }; -describe('OngoingParticipationDrop', () => { - it('shows actions on desktop', () => { - renderComp(false); - expect(screen.getByTestId('actions')).toBeInTheDocument(); +describe("OngoingParticipationDrop", () => { + beforeEach(() => { + ParticipationDropMetadataMock.mockClear(); + ParticipationIdentityProfileCardMock.mockClear(); + }); + + it("shows actions on desktop", () => { + renderComp(); + expect(screen.getByTestId("actions")).toBeInTheDocument(); }); - it('opens mobile menu on long press and handles reply', async () => { + it("opens mobile menu on long press and handles reply", async () => { const user = userEvent.setup(); - const { onReply } = renderComp(true); - await user.click(screen.getByTestId('content')); // triggers long press + const { onReply } = renderComp({ mobile: true }); + await user.click(screen.getByTestId("content")); // triggers long press expect(mobileMenuProps.isOpen).toBe(true); - await user.click(screen.getByTestId('mobile-menu')); + await user.click(screen.getByTestId("mobile-menu")); mobileMenuProps.onReply(); - expect(onReply).toHaveBeenCalledWith({ drop, partId: 'p1' }); + expect(onReply).toHaveBeenCalledWith({ drop, partId: "p1" }); expect(mobileMenuProps.setOpen).toBeDefined(); }); -}); + it("renders the identity profile card and filters identity metadata", () => { + const identityDrop = { + ...drop, + metadata: [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: { + id: "p1", + handle: "bob", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 12, + rep: 34, + tdh: 56, + tdh_rate: 1, + xtdh: 78, + xtdh_rate: 2, + level: 3, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }, + }, + { data_key: "title", data_value: "drop title" }, + ], + wave: { + ...drop.wave, + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + }, + } as ExtendedDrop; + + renderComp({ dropOverride: identityDrop }); + + expect(screen.getByTestId("identity-card")).toHaveTextContent("bob"); + expect(ParticipationIdentityProfileCardMock).toHaveBeenCalledTimes(1); + expect( + ParticipationDropMetadataMock.mock.calls.at(-1)?.[0]?.metadata + ).toEqual([{ data_key: "title", data_value: "drop title" }]); + }); +}); diff --git a/__tests__/components/waves/drops/identityDisplay.helpers.test.ts b/__tests__/components/waves/drops/identityDisplay.helpers.test.ts new file mode 100644 index 0000000000..91977c78bf --- /dev/null +++ b/__tests__/components/waves/drops/identityDisplay.helpers.test.ts @@ -0,0 +1,114 @@ +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { + getDropIdentityFallbackValue, + getDropIdentityProfile, + getDropVisibleMetadata, +} from "@/components/waves/drops/identityDisplay.helpers"; + +describe("identityDisplay helpers", () => { + const resolvedProfile = { + id: "p1", + handle: "alice", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 1, + rep: 2, + tdh: 3, + tdh_rate: 4, + xtdh: 5, + xtdh_rate: 6, + level: 7, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }; + + it("returns the resolved profile for identity waves", () => { + expect( + getDropIdentityProfile({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: resolvedProfile, + }, + ] as any, + }) + ).toEqual(resolvedProfile); + }); + + it("returns a raw identity fallback when the profile is unresolved", () => { + expect( + getDropIdentityFallbackValue({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: [ + { + data_key: "identity", + data_value: " 0xabc ", + }, + ] as any, + }) + ).toBe("0xabc"); + }); + + it("returns null for whitespace-only identity fallback values", () => { + expect( + getDropIdentityFallbackValue({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: [ + { + data_key: "identity", + data_value: " ", + }, + ] as any, + }) + ).toBeNull(); + }); + + it("filters the reserved identity metadata for identity waves", () => { + expect( + getDropVisibleMetadata({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: resolvedProfile, + }, + { + data_key: "title", + data_value: "drop title", + }, + ] as any, + }) + ).toEqual([{ data_key: "title", data_value: "drop title" }]); + }); + + it("keeps metadata unchanged for non-identity waves", () => { + const metadata = [ + { data_key: "identity", data_value: "0xabc" }, + { data_key: "title", data_value: "drop title" }, + ]; + + expect( + getDropVisibleMetadata({ + wave: { submission_type: null } as any, + metadata: metadata as any, + }) + ).toEqual(metadata); + }); +}); diff --git a/__tests__/components/waves/drops/participation/EndedParticipationDrop.test.tsx b/__tests__/components/waves/drops/participation/EndedParticipationDrop.test.tsx index 68daeeb095..3680b17f5b 100644 --- a/__tests__/components/waves/drops/participation/EndedParticipationDrop.test.tsx +++ b/__tests__/components/waves/drops/participation/EndedParticipationDrop.test.tsx @@ -1,25 +1,61 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import EndedParticipationDrop from '@/components/waves/drops/participation/EndedParticipationDrop'; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import EndedParticipationDrop from "@/components/waves/drops/participation/EndedParticipationDrop"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; -jest.mock('next/navigation', () => ({ useRouter: jest.fn(() => ({ push: jest.fn() })) })); -jest.mock('@/hooks/isMobileDevice', () => jest.fn(() => true)); +jest.mock("next/navigation", () => ({ + useRouter: jest.fn(() => ({ push: jest.fn() })), +})); +jest.mock("@/hooks/isMobileDevice", () => jest.fn(() => true)); const WaveDropContentMock = jest.fn(() => null); const WaveDropMobileMenuMock = jest.fn(() => null); -jest.mock('@/components/waves/drops/WaveDropContent', () => (props: any) => { +const WaveDropMetadataMock = jest.fn(() => null); +const ParticipationIdentityProfileCardMock = jest.fn(({ profile }: any) => ( +
+ {profile.handle ?? profile.primary_address} +
+)); +jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => { WaveDropContentMock(props); return
; }); -jest.mock('@/components/waves/drops/WaveDropMobileMenu', () => (props: any) => { +jest.mock("@/components/waves/drops/WaveDropMobileMenu", () => (props: any) => { WaveDropMobileMenuMock(props); return
; }); +jest.mock("@/components/waves/drops/WaveDropMetadata", () => (props: any) => { + WaveDropMetadataMock(props); + return
; +}); +jest.mock( + "@/components/waves/drops/participation/ParticipationIdentityProfileCard", + () => (props: any) => { + ParticipationIdentityProfileCardMock(props); + return ( +
+ {props.profile.handle ?? props.profile.primary_address} +
+ ); + } +); + +const drop: any = { + id: "d", + created_at: 1, + wave: { id: "w", name: "W", submission_type: null }, + author: { handle: "alice", level: 1, cic: {} }, + parts: [{ part_id: 1 }], + metadata: [], +}; -const drop: any = { id: 'd', created_at: 1, wave:{ id:'w', name:'W'}, author:{ handle:'alice', level:1, cic:{} }, parts:[{part_id:1}], metadata:[] }; +describe("EndedParticipationDrop", () => { + beforeEach(() => { + WaveDropMetadataMock.mockClear(); + ParticipationIdentityProfileCardMock.mockClear(); + }); -describe('EndedParticipationDrop', () => { - it('opens mobile menu on long press', () => { + it("opens mobile menu on long press", () => { const { rerender } = render( { ); // Initially menu should be closed expect(WaveDropMobileMenuMock.mock.calls[0][0]?.isOpen).toBe(false); - + // Trigger onLongPress prop from WaveDropContent const onLongPress = WaveDropContentMock.mock.calls[0][0]?.onLongPress; onLongPress(); - + // Force a re-render to check updated state rerender( { onQuoteClick={jest.fn()} /> ); - + // After long press, menu should be open expect(WaveDropMobileMenuMock.mock.calls[1][0]?.isOpen).toBe(true); }); + + it("renders the identity profile card and filters identity metadata", () => { + const identityDrop = { + ...drop, + wave: { + ...drop.wave, + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + }, + metadata: [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: { + id: "p1", + handle: "bob", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 12, + rep: 34, + tdh: 56, + tdh_rate: 1, + xtdh: 78, + xtdh_rate: 2, + level: 3, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }, + }, + { data_key: "title", data_value: "drop title" }, + ], + }; + + render( + + ); + + expect(screen.getByTestId("identity-card")).toHaveTextContent("bob"); + expect(ParticipationIdentityProfileCardMock).toHaveBeenCalledTimes(1); + expect(WaveDropMetadataMock.mock.calls.at(-1)?.[0]?.metadata).toEqual([ + { data_key: "title", data_value: "drop title" }, + ]); + }); }); diff --git a/__tests__/components/waves/drops/participation/ParticipationIdentityProfileCard.test.tsx b/__tests__/components/waves/drops/participation/ParticipationIdentityProfileCard.test.tsx new file mode 100644 index 0000000000..85d8d6dab7 --- /dev/null +++ b/__tests__/components/waves/drops/participation/ParticipationIdentityProfileCard.test.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import ParticipationIdentityProfileCard from "@/components/waves/drops/participation/ParticipationIdentityProfileCard"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + href, + children, + onClick, + className, + "aria-label": ariaLabel, + }: any) => ( + + {children} + + ), +})); + +jest.mock("@/components/common/profile/ProfileAvatar", () => ({ + __esModule: true, + ProfileBadgeSize: { + SMALL: "SMALL", + MEDIUM: "MEDIUM", + LARGE: "LARGE", + }, + default: () =>
, +})); + +jest.mock("@/components/utils/tooltip/UserProfileTooltipWrapper", () => ({ + __esModule: true, + default: ({ children }: any) => ( +
{children}
+ ), +})); + +jest.mock("@/components/waves/drops/DropAuthorBadges", () => ({ + DropAuthorBadges: () =>
, +})); + +jest.mock("@/components/user/utils/UserCICAndLevel", () => ({ + __esModule: true, + UserCICAndLevelSize: { + SMALL: "SMALL", + MEDIUM: "MEDIUM", + }, + default: () =>
, +})); + +const buildProfile = (overrides: Record = {}) => ({ + id: "profile-1", + handle: "simo", + pfp: null, + banner1_color: "#111111", + banner2_color: "#222222", + cic: 120, + rep: 200, + tdh: 15969, + tdh_rate: 21, + xtdh: 264, + xtdh_rate: 0, + level: 12, + primary_address: "0x3a867c9b39c940e9467f5b3b43fa0e5a2bd1e6e", + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: ["a", "b"], + winner_main_stage_drop_ids: ["winner-1"], + artist_of_prevote_cards: [1], + is_wave_creator: true, + ...overrides, +}); + +describe("ParticipationIdentityProfileCard", () => { + it("removes the secondary address and keeps richer inline profile content", () => { + render( + + ); + + expect(screen.getByText("Identity")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /view simo's profile/i }) + ).toHaveAttribute("href", "/simo"); + expect( + screen.queryByText("0x3a867c9b39c940e9467f5b3b43fa0e5a2bd1e6e") + ).not.toBeInTheDocument(); + expect(screen.getByText("2 submissions")).toBeInTheDocument(); + expect(screen.getByText("2 minted memes")).toBeInTheDocument(); + expect(screen.getByText("+21")).toBeInTheDocument(); + }); + + it("keeps the address as the primary label when there is no handle", () => { + render( + + ); + + expect(screen.getByText("0xabc123")).toBeInTheDocument(); + expect(screen.queryByText("Archived")).not.toBeInTheDocument(); + }); + + it("renders the archived chip only when the profile is archived", () => { + render( + + ); + + expect(screen.getByText("Archived")).toBeInTheDocument(); + expect(screen.queryByText("2 submissions")).not.toBeInTheDocument(); + }); + + it("keeps the four stat links with their existing destinations", () => { + render( + + ); + + expect(screen.getByRole("link", { name: /tdh/i })).toHaveAttribute( + "href", + "/simo/collected" + ); + expect(screen.getByRole("link", { name: /xtdh/i })).toHaveAttribute( + "href", + "/simo/xtdh" + ); + expect(screen.getByRole("link", { name: /nic/i })).toHaveAttribute( + "href", + "/simo" + ); + expect(screen.getByRole("link", { name: /rep/i })).toHaveAttribute( + "href", + "/simo" + ); + }); +}); diff --git a/__tests__/components/waves/drops/participation/participationIdentityProfile.helpers.test.ts b/__tests__/components/waves/drops/participation/participationIdentityProfile.helpers.test.ts new file mode 100644 index 0000000000..e45f483e29 --- /dev/null +++ b/__tests__/components/waves/drops/participation/participationIdentityProfile.helpers.test.ts @@ -0,0 +1,72 @@ +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { + getParticipationIdentityProfile, + getParticipationVisibleMetadata, +} from "@/components/waves/drops/participation/participationIdentityProfile.helpers"; + +describe("participationIdentityProfile helpers", () => { + const resolvedProfile = { + id: "p1", + handle: "alice", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 1, + rep: 2, + tdh: 3, + tdh_rate: 4, + xtdh: 5, + xtdh_rate: 6, + level: 7, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }; + + const metadata = [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: resolvedProfile, + }, + { + data_key: "title", + data_value: "drop title", + }, + ]; + + it("returns the resolved profile for identity waves", () => { + expect( + getParticipationIdentityProfile({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: metadata as any, + }) + ).toEqual(resolvedProfile); + }); + + it("filters the reserved identity metadata for identity waves", () => { + expect( + getParticipationVisibleMetadata({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: metadata as any, + }) + ).toEqual([{ data_key: "title", data_value: "drop title" }]); + }); + + it("keeps identity metadata untouched for non-identity waves", () => { + expect( + getParticipationVisibleMetadata({ + wave: { submission_type: null } as any, + metadata: metadata as any, + }) + ).toEqual(metadata); + }); +}); diff --git a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx index bfa0aa423d..e0e08db3c4 100644 --- a/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx +++ b/__tests__/components/waves/leaderboard/content/WaveLeaderboardDropContent.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { WaveLeaderboardDropContent } from "@/components/waves/leaderboard/content/WaveLeaderboardDropContent"; import { useRouter } from "next/navigation"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; jest.mock("next/navigation", () => ({ useRouter: jest.fn() })); jest.mock("@/components/waves/drops/WaveDropContent", () => ({ @@ -16,17 +17,38 @@ jest.mock("@/components/waves/drops/WaveDropMetadata", () => ({
{metadata.length}
), })); +jest.mock("@/components/waves/drops/WaveDropReactions", () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock( + "@/components/waves/leaderboard/identity/WaveLeaderboardIdentity", + () => ({ + WaveLeaderboardIdentity: () =>
, + }) +); const routerMock = useRouter as jest.Mock; describe("WaveLeaderboardDropContent", () => { - it("navigates on drop click and shows metadata", () => { + it("navigates on drop click, renders identity, and filters reserved metadata", () => { const push = jest.fn(); routerMock.mockReturnValue({ push }); - const drop = { wave: { id: "w" }, serial_no: 5, metadata: ["m"] } as any; + const drop = { + wave: { + id: "w", + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + }, + serial_no: 5, + metadata: [ + { data_key: "identity", data_value: "0xabc" }, + { data_key: "title", data_value: "m" }, + ], + } as any; render(); fireEvent.click(screen.getByTestId("content")); expect(push).toHaveBeenCalledWith("/waves/w?serialNo=5"); + expect(screen.getByTestId("identity")).toBeInTheDocument(); expect(screen.getByTestId("meta")).toHaveTextContent("1"); }); }); diff --git a/__tests__/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItem.test.tsx b/__tests__/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItem.test.tsx index e016b67417..d957eb198a 100644 --- a/__tests__/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItem.test.tsx +++ b/__tests__/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItem.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { WaveLeaderboardGalleryItem } from "@/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItem"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; jest.mock( "@/components/drops/view/item/content/media/MediaDisplay", @@ -11,6 +12,15 @@ jest.mock( "@/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItemVotes", () => (props: any) =>
); +jest.mock( + "@/components/waves/leaderboard/identity/WaveLeaderboardIdentity", + () => ({ + WaveLeaderboardIdentity: ({ drop, variant }: any) => + drop.wave?.submission_type === "IDENTITY" ? ( +
+ ) : null, + }) +); jest.mock("@/components/waves/drops/winner/WinnerDropBadge", () => () => (
)); @@ -36,10 +46,12 @@ jest.mock("@/helpers/image.helpers", () => ({ describe("WaveLeaderboardGalleryItem", () => { const drop: any = { + id: "d1", + metadata: [], parts: [{ media: [{ url: "img", mime_type: "image/png" }] }], raters_count: 3, rank: 1, - wave: { voting_credit_type: "NIC" }, + wave: { id: "w1", voting_credit_type: "NIC", submission_type: null }, context_profile_context: { rating: 1 }, author: { handle: "alice" }, }; @@ -75,4 +87,25 @@ describe("WaveLeaderboardGalleryItem", () => { ); expect(container.firstChild).not.toHaveClass("active:tw-bg-iron-900"); }); + + it("renders a condensed identity summary for identity waves", () => { + render( + + ); + + expect(screen.getByTestId("identity")).toHaveAttribute( + "data-variant", + "condensed" + ); + }); }); diff --git a/__tests__/components/waves/leaderboard/grid/WaveLeaderboardGridItem.test.tsx b/__tests__/components/waves/leaderboard/grid/WaveLeaderboardGridItem.test.tsx index 2c638ccb23..e5d1c0d119 100644 --- a/__tests__/components/waves/leaderboard/grid/WaveLeaderboardGridItem.test.tsx +++ b/__tests__/components/waves/leaderboard/grid/WaveLeaderboardGridItem.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; import { WaveLeaderboardGridItem } from "@/components/waves/leaderboard/grid/WaveLeaderboardGridItem"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; const startDropOpen = jest.fn(); let markdownProps: any; @@ -33,6 +34,15 @@ jest.mock( "@/components/waves/leaderboard/gallery/WaveLeaderboardGalleryItemVotes", () => () =>
); +jest.mock( + "@/components/waves/leaderboard/identity/WaveLeaderboardIdentity", + () => ({ + WaveLeaderboardIdentity: ({ drop, variant }: any) => + drop.wave?.submission_type === "IDENTITY" ? ( +
+ ) : null, + }) +); jest.mock("@/components/waves/drops/DropCurationButton", () => ({ __esModule: true, @@ -118,7 +128,12 @@ describe("WaveLeaderboardGridItem", () => { content: "hello", }, ], - wave: { id: "w1" }, + wave: { + id: "w1", + voting_credit_type: "NIC", + submission_type: null, + }, + author: { handle: "alice" }, context_profile_context: { curatable: true, curated: false }, mentioned_users: [], mentioned_waves: [], @@ -267,6 +282,50 @@ describe("WaveLeaderboardGridItem", () => { ); }); + it("renders a condensed identity summary in compact mode for identity waves", () => { + render( + + ); + + expect(screen.getByTestId("identity")).toHaveAttribute( + "data-variant", + "condensed" + ); + }); + + it("renders a responsive identity block in content-only mode for identity waves", () => { + render( + + ); + + expect(screen.getByTestId("identity")).toHaveAttribute( + "data-variant", + "responsive" + ); + }); + it("does not open drop when clicking links or action buttons", () => { const onDropClick = jest.fn(); diff --git a/__tests__/components/waves/leaderboard/identity/WaveLeaderboardIdentity.test.tsx b/__tests__/components/waves/leaderboard/identity/WaveLeaderboardIdentity.test.tsx new file mode 100644 index 0000000000..145aed5f6c --- /dev/null +++ b/__tests__/components/waves/leaderboard/identity/WaveLeaderboardIdentity.test.tsx @@ -0,0 +1,127 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { WaveLeaderboardIdentity } from "@/components/waves/leaderboard/identity/WaveLeaderboardIdentity"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; + +jest.mock( + "@/components/waves/drops/participation/ParticipationIdentityProfileCard", + () => ({ + __esModule: true, + default: ({ profile }: any) => ( +
{profile.handle}
+ ), + }) +); + +jest.mock("@/components/waves/drops/DropAuthorBadges", () => ({ + DropAuthorBadges: () =>
, +})); + +describe("WaveLeaderboardIdentity", () => { + const resolvedProfile = { + id: "p1", + handle: "alice", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 1, + rep: 2, + tdh: 3, + tdh_rate: 4, + xtdh: 5, + xtdh_rate: 6, + level: 7, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }; + + it("renders the condensed summary for resolved identities", () => { + render( + + ); + + expect(screen.getByText("Identity")).toBeInTheDocument(); + expect(screen.getByText("alice")).toBeInTheDocument(); + expect(screen.getByText("0xabc")).toBeInTheDocument(); + expect(screen.getByTestId("identity-badges")).toBeInTheDocument(); + }); + + it("renders the full card in responsive mode when a profile is resolved", () => { + render( + + ); + + expect(screen.getByTestId("identity-full-card")).toHaveTextContent("alice"); + expect( + screen.getByTestId("wave-leaderboard-identity-summary") + ).toBeInTheDocument(); + }); + + it("renders a plain fallback when the identity is unresolved", () => { + render( + + ); + + expect(screen.getByText("0xdef")).toBeInTheDocument(); + expect(screen.queryByTestId("identity-badges")).not.toBeInTheDocument(); + expect(screen.queryByTestId("identity-full-card")).not.toBeInTheDocument(); + }); +}); diff --git a/__tests__/components/waves/specs/WaveIdentitySubmissionSpecs.test.tsx b/__tests__/components/waves/specs/WaveIdentitySubmissionSpecs.test.tsx new file mode 100644 index 0000000000..5b11f47efb --- /dev/null +++ b/__tests__/components/waves/specs/WaveIdentitySubmissionSpecs.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import WaveIdentitySubmissionSpecs from "@/components/waves/specs/WaveIdentitySubmissionSpecs"; +import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from "@/generated/models/ApiWaveParticipationIdentitySubmissionAllowDuplicates"; +import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; + +describe("WaveIdentitySubmissionSpecs", () => { + const baseWave: any = { + participation: { + submission_strategy: null, + }, + }; + + it("renders compact identity submission summaries when configured", () => { + render( + + ); + + expect(screen.getByText("Identity submissions")).toBeInTheDocument(); + expect(screen.getByText("Eligible identities")).toBeInTheDocument(); + expect(screen.getByText("Others only")).toBeInTheDocument(); + expect(screen.getByText("Repeat submissions")).toBeInTheDocument(); + expect(screen.getByText("After it wins")).toBeInTheDocument(); + }); + + it("renders nothing when the wave is not identity-based", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/__tests__/components/waves/winners/DefaultWaveWinnerDropSmall.test.tsx b/__tests__/components/waves/winners/DefaultWaveWinnerDropSmall.test.tsx index 90e12476fa..46098ce12b 100644 --- a/__tests__/components/waves/winners/DefaultWaveWinnerDropSmall.test.tsx +++ b/__tests__/components/waves/winners/DefaultWaveWinnerDropSmall.test.tsx @@ -1,20 +1,49 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { DefaultWaveWinnerDropSmall } from '@/components/waves/winners/DefaultWaveWinnerDropSmall'; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { DefaultWaveWinnerDropSmall } from "@/components/waves/winners/DefaultWaveWinnerDropSmall"; +import { screen } from "@testing-library/react"; -jest.mock('@/components/waves/winners/drops/DropContentSmall', () => ({ DropContentSmall: () =>
})); -jest.mock('@/components/waves/winners/WaveWinnersSmallOutcome', () => ({ WaveWinnersSmallOutcome: () =>
})); -jest.mock('@/components/waves/drops/winner/WinnerDropBadge', () => ({ __esModule: true, default: (props: any) =>
{props.rank}
})); +jest.mock("@/components/waves/winners/drops/DropContentSmall", () => ({ + DropContentSmall: () =>
, +})); +jest.mock("@/components/waves/winners/WaveWinnersSmallOutcome", () => ({ + WaveWinnersSmallOutcome: () =>
, +})); +jest.mock("@/components/waves/drops/winner/WinnerDropBadge", () => ({ + __esModule: true, + default: (props: any) =>
{props.rank}
, +})); +jest.mock("@/components/waves/winners/identity/WaveWinnerIdentity", () => ({ + WaveWinnerIdentity: () =>
, +})); -const baseDrop = { rating: 5, raters_count: 1, author: { handle: 'alice', pfp: null }, wave: { voting_credit_type: 'REP' }, parts: [{}], context_profile_context: { rating: 2 }, metadata: [], mentioned_users: [], referenced_nfts: [], created_at: 0 } as any; +const baseDrop = { + rating: 5, + raters_count: 1, + author: { handle: "alice", pfp: null }, + wave: { voting_credit_type: "REP" }, + parts: [{}], + context_profile_context: { rating: 2 }, + metadata: [], + mentioned_users: [], + referenced_nfts: [], + created_at: 0, +} as any; const wave = {} as any; -describe('DefaultWaveWinnerDropSmall', () => { - it('handles click and shows user vote', async () => { +describe("DefaultWaveWinnerDropSmall", () => { + it("handles click and shows user vote", async () => { const onDropClick = jest.fn(); const user = userEvent.setup(); - const { container } = render(); + const { container } = render( + + ); await user.click(container.firstElementChild as HTMLElement); expect(onDropClick).toHaveBeenCalled(); + expect(screen.getByTestId("identity")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/winners/MemesWaveWinnerDropSmall.test.tsx b/__tests__/components/waves/winners/MemesWaveWinnerDropSmall.test.tsx index ffb14ce870..1121b77d10 100644 --- a/__tests__/components/waves/winners/MemesWaveWinnerDropSmall.test.tsx +++ b/__tests__/components/waves/winners/MemesWaveWinnerDropSmall.test.tsx @@ -1,37 +1,70 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import React from 'react'; -import { MemesWaveWinnerDropSmall } from '@/components/waves/winners/MemesWaveWinnerDropSmall'; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { MemesWaveWinnerDropSmall } from "@/components/waves/winners/MemesWaveWinnerDropSmall"; -jest.mock('next/link', () => ({ __esModule: true, default: ({ href, children, onClick, className }: any) => {children} })); -jest.mock('@/helpers/Helpers', () => ({ formatNumberWithCommas: (n: number) => String(n) })); -jest.mock('@/helpers/image.helpers', () => ({ getScaledImageUri: (u: string) => 'scaled-' + u, ImageScale: { W_AUTO_H_50: '50' } })); -jest.mock('@/components/waves/winners/drops/DropContentSmall', () => ({ DropContentSmall: () =>
})); -jest.mock('@/components/waves/winners/WaveWinnersSmallOutcome', () => ({ WaveWinnersSmallOutcome: () =>
})); -jest.mock('@/components/waves/drops/winner/WinnerDropBadge', () => ({ __esModule: true, default: ({ rank }: any) =>
{rank}
})); -jest.mock('@/components/waves/drops/time/WaveDropTime', () => ({ __esModule: true, default: () => })); +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children, onClick, className }: any) => ( + + {children} + + ), +})); +jest.mock("@/helpers/Helpers", () => ({ + formatNumberWithCommas: (n: number) => String(n), +})); +jest.mock("@/helpers/image.helpers", () => ({ + getScaledImageUri: (u: string) => "scaled-" + u, + ImageScale: { W_AUTO_H_50: "50" }, +})); +jest.mock("@/components/waves/winners/drops/DropContentSmall", () => ({ + DropContentSmall: () =>
, +})); +jest.mock("@/components/waves/winners/WaveWinnersSmallOutcome", () => ({ + WaveWinnersSmallOutcome: () =>
, +})); +jest.mock("@/components/waves/drops/winner/WinnerDropBadge", () => ({ + __esModule: true, + default: ({ rank }: any) =>
{rank}
, +})); +jest.mock("@/components/waves/drops/time/WaveDropTime", () => ({ + __esModule: true, + default: () => , +})); +jest.mock("@/components/waves/winners/identity/WaveWinnerIdentity", () => ({ + WaveWinnerIdentity: () =>
, +})); -describe('MemesWaveWinnerDropSmall', () => { - const wave = { voting_credit_type: 'REP' } as any; +describe("MemesWaveWinnerDropSmall", () => { + const wave = { voting_credit_type: "REP" } as any; const baseDrop = { rank: 1, rating: -5, raters_count: 2, - author: { handle: 'alice', pfp: null }, + author: { handle: "alice", pfp: null }, wave, context_profile_context: { rating: -1 }, created_at: 0, } as any; - it('handles click and displays rank override', async () => { + it("handles click and displays rank override", async () => { const onClick = jest.fn(); const user = userEvent.setup(); - const { container } = render(); + const { container } = render( + + ); - expect(screen.getByTestId('badge').textContent).toBe('2'); + expect(screen.getByTestId("badge").textContent).toBe("2"); await user.click(container.firstElementChild as HTMLElement); expect(onClick).toHaveBeenCalled(); - expect(screen.getByText('-5').className).toContain('rose'); - expect(screen.getByText('-1 REP').className).toContain('rose'); + expect(screen.getByText("-5").className).toContain("rose"); + expect(screen.getByText("-1 REP").className).toContain("rose"); + expect(screen.getByTestId("identity")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/winners/drops/DefaultWaveWinnerDrop.test.tsx b/__tests__/components/waves/winners/drops/DefaultWaveWinnerDrop.test.tsx index acc9ec2251..d0c48cd90e 100644 --- a/__tests__/components/waves/winners/drops/DefaultWaveWinnerDrop.test.tsx +++ b/__tests__/components/waves/winners/drops/DefaultWaveWinnerDrop.test.tsx @@ -1,7 +1,81 @@ -import * as mod from '@/components/waves/winners/drops/DefaultWaveWinnerDrop'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { DefaultWaveWinnersDrop } from "@/components/waves/winners/drops/DefaultWaveWinnerDrop"; -describe('DefaultWaveWinnerDrop module', () => { - it('exports component', () => { - expect(typeof mod.DefaultWaveWinnersDrop).toBe('function'); +jest.mock("@/helpers/waves/drop.helpers", () => ({ + convertApiDropToExtendedDrop: jest.fn(() => ({ id: "ext-drop" })), +})); + +jest.mock( + "@/components/waves/winners/drops/header/WaveWinnersDropHeader", + () => ({ + WaveWinnersDropHeader: () =>
, + }) +); +jest.mock( + "@/components/waves/winners/drops/header/WaveWinnersDropHeaderAuthorPfp", + () => () =>
+); +jest.mock( + "@/components/waves/winners/drops/header/WaveWinnersDropHeaderTotalVotes", + () => () =>
+); +jest.mock( + "@/components/waves/winners/drops/header/WaveWinnersDropHeaderVoters", + () => () =>
+); +jest.mock( + "@/components/waves/winners/drops/header/WaveWinnersDropOutcome", + () => () =>
+); +jest.mock("@/components/waves/winners/drops/WaveWinnersDropContent", () => ({ + WaveWinnersDropContent: () =>
, +})); +jest.mock("@/components/waves/winners/identity/WaveWinnerIdentity", () => ({ + WaveWinnerIdentity: () =>
, +})); +jest.mock("@/components/waves/drops/WaveDropActionsOpen", () => () => ( +
+)); +jest.mock("@/components/waves/drops/WaveDropMobileMenuOpen", () => () => ( +
+)); +jest.mock( + "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper", + () => (props: any) =>
{props.children}
+); +jest.mock("@/hooks/useDeviceInfo", () => ({ + __esModule: true, + default: () => ({ hasTouchScreen: false }), +})); +jest.mock("@/hooks/useLongPressInteraction", () => ({ + __esModule: true, + default: () => ({ + isActive: false, + setIsActive: jest.fn(), + touchHandlers: {}, + }), +})); + +describe("DefaultWaveWinnerDrop", () => { + it("renders the winner identity section", () => { + render( + + ); + + expect(screen.getByTestId("identity")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx b/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx index 664cf7916b..88432c3606 100644 --- a/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx +++ b/__tests__/components/waves/winners/drops/MemesWaveWinnerDrop.test.tsx @@ -71,6 +71,9 @@ jest.mock("@/components/waves/drops/WaveDropMobileMenuOpen", () => () => ( jest.mock("@/components/waves/drops/time/WaveDropTime", () => () => ( )); +jest.mock("@/components/waves/winners/identity/WaveWinnerIdentity", () => ({ + WaveWinnerIdentity: () =>
, +})); const winner: ApiWaveDecisionWinner = { drop: { @@ -109,6 +112,7 @@ describe("MemesWaveWinnersDrop", () => { expect(onClick).toHaveBeenCalledWith({ id: "ext" }); expect(screen.getByText("5")).toBeInTheDocument(); expect(screen.getByTestId("author-badges")).toBeInTheDocument(); + expect(screen.getByTestId("identity")).toBeInTheDocument(); expect(screen.getByAltText("alice's profile picture")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/winners/drops/WaveWinnersDrops.test.tsx b/__tests__/components/waves/winners/drops/WaveWinnersDrops.test.tsx index f5f615b2c7..2073d687c9 100644 --- a/__tests__/components/waves/winners/drops/WaveWinnersDrops.test.tsx +++ b/__tests__/components/waves/winners/drops/WaveWinnersDrops.test.tsx @@ -1,28 +1,58 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { WaveWinnersDrops } from '@/components/waves/winners/drops/WaveWinnersDrops'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { WaveWinnersDrops } from "@/components/waves/winners/drops/WaveWinnersDrops"; -jest.mock('@/components/waves/winners/drops/WaveWinnersDrop', () => ({ - WaveWinnersDrop: (props: any) =>
, +jest.mock("@/components/waves/winners/drops/WaveWinnersDrop", () => ({ + WaveWinnersDrop: (props: any) => ( +
+ ), })); -describe('WaveWinnersDrops', () => { - const wave = { id: 'w1' } as any; - const winners = [{ drop: { id: 'd1' } }, { drop: { id: 'd2' } }] as any; +describe("WaveWinnersDrops", () => { + const wave = { id: "w1" } as any; + const winners = [{ drop: { id: "d1" } }, { drop: { id: "d2" } }] as any; - it('shows loading bar when loading', () => { - render(); - expect(document.querySelector(".tw-animate-loading-bar")).toBeInTheDocument(); + it("shows loading bar when loading", () => { + render( + + ); + expect( + document.querySelector(".tw-animate-loading-bar") + ).toBeInTheDocument(); }); - it('returns empty fragment when no winners', () => { - const { container } = render(); + it("returns empty fragment when no winners", () => { + const { container } = render( + + ); expect(container.firstChild).toBeNull(); }); - it('renders a drop component for each winner', () => { - render(); - expect(screen.getByTestId('drop-d1')).toBeInTheDocument(); - expect(screen.getByTestId('drop-d2')).toBeInTheDocument(); + it("renders a drop component for each winner", () => { + render( + + ); + expect(screen.getByTestId("drop-d1")).toBeInTheDocument(); + expect(screen.getByTestId("drop-d2")).toBeInTheDocument(); + }); + + it("skips winners without drop data and shows a dev warning", () => { + render( + + ); + + expect(screen.getByTestId("drop-d1")).toBeInTheDocument(); + expect( + screen.getByText("Hidden 1 winner with missing drop data.") + ).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/winners/identity/WaveWinnerIdentity.test.tsx b/__tests__/components/waves/winners/identity/WaveWinnerIdentity.test.tsx new file mode 100644 index 0000000000..a20be358d1 --- /dev/null +++ b/__tests__/components/waves/winners/identity/WaveWinnerIdentity.test.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { WaveWinnerIdentity } from "@/components/waves/winners/identity/WaveWinnerIdentity"; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children, onClick, className }: any) => ( + + {children} + + ), +})); + +jest.mock( + "@/components/waves/drops/participation/ParticipationIdentityProfileCard", + () => ({ + __esModule: true, + default: ({ profile }: any) => ( +
{profile.handle}
+ ), + }) +); + +describe("WaveWinnerIdentity", () => { + const resolvedProfile = { + id: "p1", + handle: "alice", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 1, + rep: 2, + tdh: 3, + tdh_rate: 4, + xtdh: 5, + xtdh_rate: 6, + level: 7, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }; + + const identityWave = { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any; + + it("renders the full profile card when the identity resolves", () => { + render( + + ); + + expect(screen.getByTestId("identity-profile-card")).toHaveTextContent( + "alice" + ); + }); + + it("renders a fallback card when the identity is unresolved", () => { + render( + + ); + + expect(screen.getByTestId("wave-winner-identity-full")).toHaveTextContent( + "0xdef" + ); + }); + + it("renders a compact linked identity row for resolved identities", () => { + render( + + ); + + expect( + screen.getByTestId("wave-winner-identity-compact") + ).toHaveTextContent("Identity"); + expect(screen.getByRole("link", { name: "alice" })).toHaveAttribute( + "href", + "/alice" + ); + }); + + it("renders nothing for non-identity waves", () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/__tests__/components/waves/winners/identity/winnerIdentity.helpers.test.ts b/__tests__/components/waves/winners/identity/winnerIdentity.helpers.test.ts new file mode 100644 index 0000000000..3b0d633ef5 --- /dev/null +++ b/__tests__/components/waves/winners/identity/winnerIdentity.helpers.test.ts @@ -0,0 +1,75 @@ +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; +import { + getWinnerIdentityFallbackValue, + getWinnerIdentityProfile, + getWinnerVisibleMetadata, +} from "@/components/waves/winners/identity/winnerIdentity.helpers"; + +describe("winnerIdentity helpers", () => { + const resolvedProfile = { + id: "p1", + handle: "alice", + primary_address: "0xabc", + pfp: null, + banner1_color: null, + banner2_color: null, + cic: 1, + rep: 2, + tdh: 3, + tdh_rate: 4, + xtdh: 5, + xtdh_rate: 6, + level: 7, + subscribed_actions: [], + archived: false, + active_main_stage_submission_ids: [], + winner_main_stage_drop_ids: [], + artist_of_prevote_cards: [], + is_wave_creator: false, + }; + + const metadata = [ + { + data_key: "identity", + data_value: "0xabc", + resolved_profile: resolvedProfile, + }, + { + data_key: "title", + data_value: "winner title", + }, + ]; + + it("returns the resolved profile for identity winner drops", () => { + expect( + getWinnerIdentityProfile({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: metadata as any, + }) + ).toEqual(resolvedProfile); + }); + + it("returns an unresolved identity fallback value", () => { + expect( + getWinnerIdentityFallbackValue({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: [{ data_key: "identity", data_value: " 0xdef " }] as any, + }) + ).toBe("0xdef"); + }); + + it("filters the reserved identity metadata for identity winner drops", () => { + expect( + getWinnerVisibleMetadata({ + wave: { + submission_type: ApiWaveParticipationSubmissionStrategyType.Identity, + } as any, + metadata: metadata as any, + }) + ).toEqual([{ data_key: "title", data_value: "winner title" }]); + }); +}); diff --git a/__tests__/components/waves/winners/podium/WavePodiumItem.test.tsx b/__tests__/components/waves/winners/podium/WavePodiumItem.test.tsx index b55d787360..ea082b10b5 100644 --- a/__tests__/components/waves/winners/podium/WavePodiumItem.test.tsx +++ b/__tests__/components/waves/winners/podium/WavePodiumItem.test.tsx @@ -1,29 +1,63 @@ -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { WavePodiumItem } from '@/components/waves/winners/podium/WavePodiumItem'; +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { WavePodiumItem } from "@/components/waves/winners/podium/WavePodiumItem"; -jest.mock('next/link', () => ({ __esModule: true, default: ({ href, children }: any) => {children} })); -jest.mock('@/helpers/image.helpers', () => ({ getScaledImageUri: (u: string) => `scaled:${u}`, ImageScale: { W_AUTO_H_50: 'x' } })); -jest.mock('@/components/waves/winners/podium/WavePodiumItemContentOutcomes', () => ({ WavePodiumItemContentOutcomes: () =>
})); -jest.mock('@/components/waves/winners/podium/WaveWinnersPodiumPlaceholder', () => ({ WaveWinnersPodiumPlaceholder: (props: any) =>
})); +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ href, children }: any) => {children}, +})); +jest.mock("@/helpers/image.helpers", () => ({ + getScaledImageUri: (u: string) => `scaled:${u}`, + ImageScale: { W_AUTO_H_50: "x" }, +})); +jest.mock( + "@/components/waves/winners/podium/WavePodiumItemContentOutcomes", + () => ({ + WavePodiumItemContentOutcomes: () =>
, + }) +); +jest.mock( + "@/components/waves/winners/podium/WaveWinnersPodiumPlaceholder", + () => ({ + WaveWinnersPodiumPlaceholder: (props: any) => ( +
+ ), + }) +); +jest.mock("@/components/waves/winners/identity/WaveWinnerIdentity", () => ({ + WaveWinnerIdentity: () =>
, +})); const drop: any = { - author: { handle: 'alice', pfp: 'pfp.png' }, + author: { handle: "alice", pfp: "pfp.png" }, rating: 5, raters_count: 1, - wave: { voting_credit_type: 'CIC' }, + wave: { voting_credit_type: "CIC" }, parts: [], - metadata: [] + metadata: [], }; -it('renders placeholder when no winner', () => { - render(); - expect(screen.getByTestId('placeholder')).toHaveAttribute('data-position','second'); +it("renders placeholder when no winner", () => { + render(); + expect(screen.getByTestId("placeholder")).toHaveAttribute( + "data-position", + "second" + ); }); -it('calls onDropClick when clicked', () => { +it("calls onDropClick when clicked", () => { const onDropClick = jest.fn(); - render(); - fireEvent.click(screen.getByRole('link', { name: /alice/i }).parentElement!.parentElement!.parentElement!.parentElement!); + render( + + ); + expect(screen.getByTestId("identity")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("link", { name: /alice/i }).parentElement!.parentElement! + .parentElement!.parentElement! + ); expect(onDropClick).toHaveBeenCalledWith(drop); }); diff --git a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts index 6484cadf77..01af864ec0 100644 --- a/__tests__/helpers/waves/create-wave.helpers.extra.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.extra.test.ts @@ -1,67 +1,209 @@ -import { getCreateNewWaveBody } from '@/helpers/waves/create-wave.helpers'; -import { ApiWaveType } from '@/generated/models/ApiWaveType'; -import { ApiWaveMetadataType } from '@/generated/models/ApiWaveMetadataType'; +import { getCreateNewWaveBody } from "@/helpers/waves/create-wave.helpers"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; -describe('create-wave.helpers extra', () => { - it('clamps time weighted lock duration', () => { +describe("create-wave.helpers extra", () => { + it("clamps time weighted lock duration", () => { const config: any = { - overview: { type: ApiWaveType.Rank, name: 'Wave' }, - groups: { canView:'1', canDrop:'2', canVote:'3', canChat:'4', admin:'5' }, - dates: { submissionStartDate:1, votingStartDate:2, endDate:null, firstDecisionTime:2, subsequentDecisions:[3], isRolling:false }, - drops: { noOfApplicationsAllowedPerParticipant:1, requiredTypes:[], requiredMetadata:[], terms:null, signatureRequired:false, adminCanDeleteDrops:false }, - chat: { enabled:true }, - voting: { type:null, category:null, profileId:null, timeWeighted:{ enabled:true, averagingInterval:1, averagingIntervalUnit:'minutes' } }, + overview: { type: ApiWaveType.Rank, name: "Wave" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: null, + firstDecisionTime: 2, + subsequentDecisions: [3], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: true }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: true, + averagingInterval: 1, + averagingIntervalUnit: "minutes", + }, + }, outcomes: [], - approval: { threshold:null, thresholdTimeMs:null } + approval: { threshold: null, thresholdTimeMs: null }, }; - const drop: any = { parts:[], referenced_nfts:[], mentioned_users:[], metadata:[] }; - const body = getCreateNewWaveBody({ drop, picture:null, config }); + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }; + const body = getCreateNewWaveBody({ drop, picture: null, config }); expect(body.wave.time_lock_ms).toBe(300000); // clamped to min 5 minutes }); - it('throws when rolling without end date', () => { + it("throws when rolling without end date", () => { const config: any = { - overview: { type: ApiWaveType.Rank, name: 'W' }, - groups: { canView:'1', canDrop:'2', canVote:'3', canChat:'4', admin:'5' }, - dates: { submissionStartDate:1, votingStartDate:2, endDate:null, firstDecisionTime:2, subsequentDecisions:[3], isRolling:true }, - drops: { noOfApplicationsAllowedPerParticipant:1, requiredTypes:[], requiredMetadata:[{key:'k', type:ApiWaveMetadataType.String}], terms:null, signatureRequired:false, adminCanDeleteDrops:false }, - chat: { enabled:false }, - voting: { type:null, category:null, profileId:null, timeWeighted:{ enabled:false, averagingInterval:5, averagingIntervalUnit:'minutes' } }, + overview: { type: ApiWaveType.Rank, name: "W" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: null, + firstDecisionTime: 2, + subsequentDecisions: [3], + isRolling: true, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [{ key: "k", type: ApiWaveMetadataType.String }], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: false }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, outcomes: [], - approval: { threshold:null, thresholdTimeMs:null } + approval: { threshold: null, thresholdTimeMs: null }, }; - const drop: any = { parts:[], referenced_nfts:[], mentioned_users:[], metadata:[] }; - expect(() => getCreateNewWaveBody({ drop, picture:null, config })).toThrow('End date must be explicitly set when isRolling is true'); - }); -}); - it('calculates rolling end date correctly', () => { - const config: any = { - overview: { type: ApiWaveType.Rank, name: 'Wave' }, - groups: { canView:'1', canDrop:'2', canVote:'3', canChat:'4', admin:'5' }, - dates: { submissionStartDate:0, votingStartDate:0, endDate:65, firstDecisionTime:0, subsequentDecisions:[10,20], isRolling:true }, - drops: { noOfApplicationsAllowedPerParticipant:1, requiredTypes:[], requiredMetadata:[], terms:null, signatureRequired:false, adminCanDeleteDrops:false }, - chat: { enabled:false }, - voting: { type:null, category:null, profileId:null, timeWeighted:{ enabled:false, averagingInterval:5, averagingIntervalUnit:'minutes' } }, - outcomes: [], - approval: { threshold:null, thresholdTimeMs:null } + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], }; - const drop:any = { parts:[], referenced_nfts:[], mentioned_users:[], metadata:[] }; - const body = getCreateNewWaveBody({ drop, picture:null, config }); - expect(body.voting.period.max).toBe(60); // last decision before 65 + expect(() => getCreateNewWaveBody({ drop, picture: null, config })).toThrow( + "End date must be explicitly set when isRolling is true" + ); }); +}); +it("calculates rolling end date correctly", () => { + const config: any = { + overview: { type: ApiWaveType.Rank, name: "Wave" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 0, + votingStartDate: 0, + endDate: 65, + firstDecisionTime: 0, + subsequentDecisions: [10, 20], + isRolling: true, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: false }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: null, thresholdTimeMs: null }, + }; + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }; + const body = getCreateNewWaveBody({ drop, picture: null, config }); + expect(body.voting.period.max).toBe(60); // last decision before 65 +}); - it('sets winning thresholds for approve waves', () => { - const config: any = { - overview:{ type: ApiWaveType.Approve, name:'A' }, - groups:{ canView:'1', canDrop:'2', canVote:'3', canChat:'4', admin:'5' }, - dates:{ submissionStartDate:1, votingStartDate:2, endDate:null, firstDecisionTime:2, subsequentDecisions:[], isRolling:false }, - drops:{ noOfApplicationsAllowedPerParticipant:1, requiredTypes:[], requiredMetadata:[], terms:null, signatureRequired:false, adminCanDeleteDrops:false }, - chat:{ enabled:false }, - voting:{ type:null, category:null, profileId:null, timeWeighted:{ enabled:false, averagingInterval:5, averagingIntervalUnit:'minutes' } }, - outcomes: [], - approval:{ threshold:3, thresholdTimeMs:null } - }; - const drop:any = { parts:[], referenced_nfts:[], mentioned_users:[], metadata:[] }; - const body = getCreateNewWaveBody({ drop, picture:null, config }); - expect(body.wave.winning_thresholds).toEqual({ min:3, max:3 }); - }); +it("sets winning thresholds for approve waves", () => { + const config: any = { + overview: { type: ApiWaveType.Approve, name: "A" }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: null, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: null, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: false, + }, + chat: { enabled: false }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: 3, thresholdTimeMs: null }, + }; + const drop: any = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + }; + const body = getCreateNewWaveBody({ drop, picture: null, config }); + expect(body.wave.winning_thresholds).toEqual({ min: 3, max: 3 }); +}); diff --git a/__tests__/helpers/waves/create-wave.helpers.test.ts b/__tests__/helpers/waves/create-wave.helpers.test.ts index e1d242ddea..d422a5f73b 100644 --- a/__tests__/helpers/waves/create-wave.helpers.test.ts +++ b/__tests__/helpers/waves/create-wave.helpers.test.ts @@ -3,6 +3,9 @@ import { getCreateWavePreviousStep, calculateLastDecisionTime, } from "@/helpers/waves/create-wave.helpers"; +import { ApiWaveParticipationIdentitySubmissionAllowDuplicates } from "@/generated/models/ApiWaveParticipationIdentitySubmissionAllowDuplicates"; +import { ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted } from "@/generated/models/ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted"; +import { ApiWaveParticipationSubmissionStrategyType } from "@/generated/models/ApiWaveParticipationSubmissionStrategyType"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { ApiWaveMetadataType } from "@/generated/models/ApiWaveMetadataType"; import { CreateWaveStep } from "@/types/waves.types"; @@ -99,6 +102,7 @@ describe("create-wave.helpers", () => { noOfApplicationsAllowedPerParticipant: 1, requiredTypes: [], requiredMetadata: [{ key: "m", type: ApiWaveMetadataType.String }], + submissionStrategy: null, terms: null, signatureRequired: false, adminCanDeleteDrops: true, @@ -132,5 +136,69 @@ describe("create-wave.helpers", () => { expect(res.voting.period.max).toBe(2 + 3 + 5); expect(res.wave.admin_drop_deletion_enabled).toBe(true); }); + + it("includes identity submission strategy when configured", () => { + const { + getCreateNewWaveBody, + } = require("@/helpers/waves/create-wave.helpers"); + const config = { + overview: { type: ApiWaveType.Rank, name: "W", image: null }, + groups: { + canView: "1", + canDrop: "2", + canVote: "3", + canChat: "4", + admin: "5", + }, + dates: { + submissionStartDate: 1, + votingStartDate: 2, + endDate: 10, + firstDecisionTime: 2, + subsequentDecisions: [], + isRolling: false, + }, + drops: { + noOfApplicationsAllowedPerParticipant: 1, + requiredTypes: [], + requiredMetadata: [], + submissionStrategy: { + type: ApiWaveParticipationSubmissionStrategyType.Identity, + config: { + duplicates: + ApiWaveParticipationIdentitySubmissionAllowDuplicates.AllowAfterWin, + who_can_be_submitted: + ApiWaveParticipationIdentitySubmissionWhoCanBeSubmitted.OnlyOthers, + }, + }, + terms: null, + signatureRequired: false, + adminCanDeleteDrops: true, + }, + chat: { enabled: true }, + voting: { + type: null, + category: null, + profileId: null, + timeWeighted: { + enabled: false, + averagingInterval: 5, + averagingIntervalUnit: "minutes", + }, + }, + outcomes: [], + approval: { threshold: null, thresholdTimeMs: null }, + } as any; + const drop = { + parts: [], + referenced_nfts: [], + mentioned_users: [], + metadata: [], + } as any; + const res = getCreateNewWaveBody({ drop, picture: "pic", config }); + expect(res.participation.submission_strategy).toEqual( + config.drops.submissionStrategy + ); + }); }); }); diff --git a/__tests__/helpers/waves/create-wave.validation.test.ts b/__tests__/helpers/waves/create-wave.validation.test.ts index ebe89b0f5c..57dba6e3cb 100644 --- a/__tests__/helpers/waves/create-wave.validation.test.ts +++ b/__tests__/helpers/waves/create-wave.validation.test.ts @@ -28,6 +28,7 @@ describe("create-wave.validation", () => { noOfApplicationsAllowedPerParticipant: null, requiredTypes: [], requiredMetadata: [], + submissionStrategy: null, terms: null, signatureRequired: false, adminCanDeleteDrops: false, @@ -128,6 +129,72 @@ describe("create-wave.validation", () => { ); }); + it("rejects reserved identity metadata keys for identity nomination waves", () => { + const config = { + ...baseConfig, + drops: { + ...baseConfig.drops, + requiredMetadata: [{ key: " Identity ", type: "t" }], + submissionStrategy: { + type: "IDENTITY", + config: { + duplicates: "NEVER_ALLOW", + who_can_be_submitted: "EVERYONE", + }, + }, + }, + }; + const errors = getCreateWaveValidationErrors({ + step: CreateWaveStep.DROPS, + config, + }); + expect(errors).toContain( + CREATE_WAVE_VALIDATION_ERROR.DROPS_REQUIRED_METADATA_RESERVED_IDENTITY_KEY + ); + }); + + it("allows identity metadata keys for standard drop waves", () => { + const config = { + ...baseConfig, + drops: { + ...baseConfig.drops, + requiredMetadata: [{ key: "IDENTITY", type: "t" }], + submissionStrategy: null, + }, + }; + const errors = getCreateWaveValidationErrors({ + step: CreateWaveStep.DROPS, + config, + }); + expect(errors).not.toContain( + CREATE_WAVE_VALIDATION_ERROR.DROPS_REQUIRED_METADATA_RESERVED_IDENTITY_KEY + ); + }); + + it("chat waves cannot have submission strategy", () => { + const config = { + ...baseConfig, + overview: { type: ApiWaveType.Chat, name: "n", image: null }, + drops: { + ...baseConfig.drops, + submissionStrategy: { + type: "IDENTITY", + config: { + duplicates: "NEVER_ALLOW", + who_can_be_submitted: "EVERYONE", + }, + }, + }, + }; + const errors = getCreateWaveValidationErrors({ + step: CreateWaveStep.DROPS, + config, + }); + expect(errors).toContain( + CREATE_WAVE_VALIDATION_ERROR.DROPS_SUBMISSION_STRATEGY_INVALID + ); + }); + it("approval threshold time must be smaller than duration", () => { const config = { ...baseConfig, diff --git a/__tests__/helpers/waves/wave-submission-experience.helpers.test.ts b/__tests__/helpers/waves/wave-submission-experience.helpers.test.ts new file mode 100644 index 0000000000..aa8a6365ae --- /dev/null +++ b/__tests__/helpers/waves/wave-submission-experience.helpers.test.ts @@ -0,0 +1,64 @@ +import { + resolveWaveSubmissionExperience, + WaveSubmissionExperience, +} from "@/helpers/waves/wave-submission-experience.helpers"; + +describe("resolveWaveSubmissionExperience", () => { + it("prefers memes legacy behavior over submission strategy", () => { + expect( + resolveWaveSubmissionExperience({ + isMemesWave: true, + isCurationWave: false, + submissionStrategy: { + type: "IDENTITY" as any, + config: { + who_can_be_submitted: "EVERYONE" as any, + duplicates: "NEVER_ALLOW" as any, + }, + }, + }) + ).toBe(WaveSubmissionExperience.MEMES_LEGACY); + }); + + it("prefers curation legacy behavior over submission strategy", () => { + expect( + resolveWaveSubmissionExperience({ + isMemesWave: false, + isCurationWave: true, + submissionStrategy: { + type: "IDENTITY" as any, + config: { + who_can_be_submitted: "EVERYONE" as any, + duplicates: "NEVER_ALLOW" as any, + }, + }, + }) + ).toBe(WaveSubmissionExperience.CURATION_LEGACY); + }); + + it("uses identity experience for non-legacy waves with submission strategy", () => { + expect( + resolveWaveSubmissionExperience({ + isMemesWave: false, + isCurationWave: false, + submissionStrategy: { + type: "IDENTITY" as any, + config: { + who_can_be_submitted: "EVERYONE" as any, + duplicates: "NEVER_ALLOW" as any, + }, + }, + }) + ).toBe(WaveSubmissionExperience.IDENTITY); + }); + + it("falls back to default when no special behavior applies", () => { + expect( + resolveWaveSubmissionExperience({ + isMemesWave: false, + isCurationWave: false, + submissionStrategy: null, + }) + ).toBe(WaveSubmissionExperience.DEFAULT); + }); +}); diff --git a/__tests__/hooks/waves/useWaveDecisions.test.ts b/__tests__/hooks/waves/useWaveDecisions.test.ts index e64210bede..a047c04ba8 100644 --- a/__tests__/hooks/waves/useWaveDecisions.test.ts +++ b/__tests__/hooks/waves/useWaveDecisions.test.ts @@ -11,6 +11,10 @@ jest.mock("@/services/api/common-api"); const useQueryMock = useQuery as jest.Mock; const fetchMock = commonApiFetch as jest.Mock; +const makeWinner = (place: number, id = `drop-${place}`) => ({ + place, + drop: { id }, +}); describe("useWaveDecisions", () => { beforeEach(() => { @@ -27,15 +31,15 @@ describe("useWaveDecisions", () => { it("configures a single-page query and sorts loaded decisions", () => { const unsortedDecision = { decision_time: 2, - winners: [{ place: 2 }, { place: 1 }], + winners: [makeWinner(2), makeWinner(1)], }; useQueryMock.mockReturnValue({ data: { data: [ - { decision_time: 3, winners: [{ place: 3 }, { place: 1 }] }, + { decision_time: 3, winners: [makeWinner(3), makeWinner(1)] }, unsortedDecision, - { decision_time: 1, winners: [{ place: 1 }] }, + { decision_time: 1, winners: [makeWinner(1)] }, ], }, isError: false, @@ -60,6 +64,36 @@ describe("useWaveDecisions", () => { ).toEqual([1, 2]); }); + it("warns when winners are missing drop data and keeps sorted winners", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + useQueryMock.mockReturnValue({ + data: { + data: [ + { + decision_time: 2, + winners: [makeWinner(2), { place: 1 }], + }, + ], + }, + isError: false, + error: null, + refetch: jest.fn(), + isFetching: false, + }); + + const { result } = renderHook(() => useWaveDecisions({ waveId: "w1" })); + + expect( + result.current.decisionPoints[0]?.winners.map((winner) => winner.place) + ).toEqual([1, 2]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Found 1 winner(s) without drop data") + ); + + warnSpy.mockRestore(); + }); + it("requests the first page of decisions", async () => { fetchMock.mockResolvedValue({ data: [], diff --git a/components/brain/my-stream/MyStreamWaveChat.tsx b/components/brain/my-stream/MyStreamWaveChat.tsx index c2c6058fee..2a41b0510e 100644 --- a/components/brain/my-stream/MyStreamWaveChat.tsx +++ b/components/brain/my-stream/MyStreamWaveChat.tsx @@ -17,6 +17,10 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiWave } from "@/generated/models/ApiWave"; import { getHomeRoute } from "@/helpers/navigation.helpers"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { + resolveWaveSubmissionExperience, + WaveSubmissionExperience, +} from "@/helpers/waves/wave-submission-experience.helpers"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useWave } from "@/hooks/useWave"; import type { WaveViewMode } from "@/hooks/useWaveViewMode"; @@ -96,7 +100,12 @@ const MyStreamWaveChat: React.FC = ({ const searchParams = useSearchParams(); const pathname = usePathname(); const containerRef = useRef(null); - const { isMemesWave } = useWave(wave); + const { isMemesWave, isCurationWave } = useWave(wave); + const submissionExperience = resolveWaveSubmissionExperience({ + isMemesWave, + isCurationWave, + submissionStrategy: wave.participation.submission_strategy ?? null, + }); const editingDropId = useSelector(selectEditingDropId); const { isApp } = useDeviceInfo(); @@ -264,7 +273,9 @@ const MyStreamWaveChat: React.FC = ({
)} - {isMemesWave && } + {submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && ( + + )}
); diff --git a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx index a1dabd70b4..a4deeb2e76 100644 --- a/components/brain/my-stream/MyStreamWaveLeaderboard.tsx +++ b/components/brain/my-stream/MyStreamWaveLeaderboard.tsx @@ -32,6 +32,10 @@ import MemesArtSubmissionModal from "@/components/waves/memes/MemesArtSubmission import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useWaveCurationGroups } from "@/hooks/waves/useWaveCurationGroups"; import { getWaveDropEligibility } from "@/components/waves/leaderboard/dropEligibility"; +import { + resolveWaveSubmissionExperience, + WaveSubmissionExperience, +} from "@/helpers/waves/wave-submission-experience.helpers"; interface MyStreamWaveLeaderboardProps { readonly wave: ApiWave; @@ -48,6 +52,11 @@ const MyStreamWaveLeaderboard: React.FC = ({ const { connectedProfile, activeProfileProxy } = useContext(AuthContext); const { isMemesWave, isCurationWave, participation } = useWave(wave); const { leaderboardViewStyle } = useLayout(); // Get pre-calculated style from context + const submissionExperience = resolveWaveSubmissionExperience({ + isMemesWave, + isCurationWave, + submissionStrategy: wave.participation.submission_strategy ?? null, + }); // Track mount status const mountedRef = useRef(true); @@ -79,19 +88,21 @@ const MyStreamWaveLeaderboard: React.FC = ({ [activeProfileProxy, isCurationWave, isLoggedIn, participation] ); const showToggleableDropInput = - !isMemesWave && !isCurationWave && isCreateDropOpen; + submissionExperience !== WaveSubmissionExperience.MEMES_LEGACY && + submissionExperience !== WaveSubmissionExperience.CURATION_LEGACY && + isCreateDropOpen; const onCreateDrop = useCallback(() => { if (!mountedRef.current) { return; } - if (isMemesWave) { + if (submissionExperience === WaveSubmissionExperience.MEMES_LEGACY) { setIsMemesCreateOpen(true); return; } - if (isCurationWave) { + if (submissionExperience === WaveSubmissionExperience.CURATION_LEGACY) { if (!canCreateDrop) { return; } @@ -99,10 +110,8 @@ const MyStreamWaveLeaderboard: React.FC = ({ return; } - if (!isCurationWave) { - setIsCreateDropOpen(true); - } - }, [canCreateDrop, isCurationWave, isMemesWave]); + setIsCreateDropOpen(true); + }, [canCreateDrop, submissionExperience]); // Generate a unique preference key for this wave const viewPreferenceKey = `waveViewMode_${wave.id}`; @@ -313,20 +322,22 @@ const MyStreamWaveLeaderboard: React.FC = ({ )} - {isMemesWave && isMemesCreateOpen && ( - setIsMemesCreateOpen(false)} - /> - )} - {isCurationWave && isCurationDropModalOpen && ( - setIsCurationDropModalOpen(false)} - /> - )} + {submissionExperience === WaveSubmissionExperience.MEMES_LEGACY && + isMemesCreateOpen && ( + setIsMemesCreateOpen(false)} + /> + )} + {submissionExperience === WaveSubmissionExperience.CURATION_LEGACY && + isCurationDropModalOpen && ( + setIsCurationDropModalOpen(false)} + /> + )} {leaderboardContent}
diff --git a/components/brain/right-sidebar/BrainRightSidebarContent.tsx b/components/brain/right-sidebar/BrainRightSidebarContent.tsx index 4591530c00..aae7eeef04 100644 --- a/components/brain/right-sidebar/BrainRightSidebarContent.tsx +++ b/components/brain/right-sidebar/BrainRightSidebarContent.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { ApiWave } from "@/generated/models/ApiWave"; import WaveSpecs from "@/components/waves/specs/WaveSpecs"; +import WaveIdentitySubmissionSpecs from "@/components/waves/specs/WaveIdentitySubmissionSpecs"; import WaveGroups from "@/components/waves/groups/WaveGroups"; import { WaveLeaderboardRightSidebarBoostedDrops } from "@/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops"; @@ -17,6 +18,7 @@ const BrainRightSidebarContent: React.FC = ({
+
); diff --git a/components/common/profile/ProfileAvatar.tsx b/components/common/profile/ProfileAvatar.tsx index 5377692e21..37e0c0e3f8 100644 --- a/components/common/profile/ProfileAvatar.tsx +++ b/components/common/profile/ProfileAvatar.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; export enum ProfileBadgeSize { + COMPACT = "COMPACT", SMALL = "SMALL", MEDIUM = "MEDIUM", LARGE = "LARGE", @@ -16,6 +17,7 @@ interface ProfileAvatarProps { } const AVATAR_SIZE_CLASSES: Record = { + [ProfileBadgeSize.COMPACT]: "tw-h-8 tw-w-8", [ProfileBadgeSize.SMALL]: "tw-h-7 tw-w-7", [ProfileBadgeSize.MEDIUM]: "tw-h-10 tw-w-10", [ProfileBadgeSize.LARGE]: "tw-h-12 tw-w-12", @@ -29,15 +31,18 @@ export default function ProfileAvatar({ }: ProfileAvatarProps) { return (
-
-
-
+ className={`${AVATAR_SIZE_CLASSES[size]} tw-relative tw-flex-shrink-0 tw-rounded-lg tw-bg-iron-900`} + > +
+
+
{pfpUrl ? ( + // Profile avatars can come from arbitrary remote hosts, so this stays unoptimized. + // eslint-disable-next-line @next/next/no-img-element {alt} ) : ( fallbackContent diff --git a/components/common/profile/ProfileBadge.tsx b/components/common/profile/ProfileBadge.tsx index 16fae7ceaa..59ff87c4a1 100644 --- a/components/common/profile/ProfileBadge.tsx +++ b/components/common/profile/ProfileBadge.tsx @@ -21,6 +21,7 @@ interface ProfileBadgeProps { } const LEVEL_SIZE_MAP: Record = { + [ProfileBadgeSize.COMPACT]: ProfileLevelSize.SMALL, [ProfileBadgeSize.SMALL]: ProfileLevelSize.SMALL, [ProfileBadgeSize.MEDIUM]: ProfileLevelSize.MEDIUM, [ProfileBadgeSize.LARGE]: ProfileLevelSize.LARGE, diff --git a/components/common/profile/ProfileHandle.tsx b/components/common/profile/ProfileHandle.tsx index 56975931b8..de449c4b8c 100644 --- a/components/common/profile/ProfileHandle.tsx +++ b/components/common/profile/ProfileHandle.tsx @@ -14,6 +14,7 @@ interface ProfileHandleProps { } const TEXT_SIZE_CLASSES: Record = { + [ProfileBadgeSize.COMPACT]: "tw-text-sm", [ProfileBadgeSize.SMALL]: "tw-text-sm", [ProfileBadgeSize.MEDIUM]: "tw-text-md", [ProfileBadgeSize.LARGE]: "tw-text-md", @@ -27,7 +28,9 @@ export default function ProfileHandle({ highlightSearchParam = "user", }: ProfileHandleProps) { const searchParams = useSearchParams(); - const paramValue = (searchParams?.get(highlightSearchParam) ?? "").toLowerCase(); + const paramValue = ( + searchParams.get(highlightSearchParam) ?? "" + ).toLowerCase(); const amISubject = (handle ?? "").toLowerCase() === paramValue; const textClasses = TEXT_SIZE_CLASSES[size]; const isLinkEnabled = !!(asLink && href && !amISubject); @@ -36,7 +39,8 @@ export default function ProfileHandle({ e.stopPropagation()} href={href} - className="tw-no-underline hover:tw-underline hover:tw-text-iron-500 tw-transition tw-duration-300 tw-ease-out"> + className="tw-no-underline tw-transition tw-duration-300 tw-ease-out hover:tw-text-iron-500 hover:tw-underline" + > {handle} ) : ( @@ -44,7 +48,9 @@ export default function ProfileHandle({ ); return ( -

+

{content}

); diff --git a/components/drops/create/utils/DropPfp.tsx b/components/drops/create/utils/DropPfp.tsx index 00a6cd7ad1..7660317270 100644 --- a/components/drops/create/utils/DropPfp.tsx +++ b/components/drops/create/utils/DropPfp.tsx @@ -1,4 +1,6 @@ -import ProfileAvatar, { ProfileBadgeSize } from "@/components/common/profile/ProfileAvatar"; +import ProfileAvatar, { + ProfileBadgeSize, +} from "@/components/common/profile/ProfileAvatar"; import { DropPartSize } from "@/components/drops/view/part/DropPart.types"; const AVATAR_SIZE_MAP: Record = { @@ -10,15 +12,18 @@ const AVATAR_SIZE_MAP: Record = { export default function DropPfp({ pfpUrl, size, + profileSize, }: { readonly pfpUrl: string | null | undefined; readonly size?: DropPartSize | undefined; + readonly profileSize?: ProfileBadgeSize | undefined; }) { - const effectiveSize = size ?? DropPartSize.MEDIUM; + const effectiveSize = + profileSize ?? AVATAR_SIZE_MAP[size ?? DropPartSize.MEDIUM]; return ( ); diff --git a/components/drops/view/utils/DropVoteProgressing.tsx b/components/drops/view/utils/DropVoteProgressing.tsx index ad0fa39d86..a2ac327803 100644 --- a/components/drops/view/utils/DropVoteProgressing.tsx +++ b/components/drops/view/utils/DropVoteProgressing.tsx @@ -8,12 +8,14 @@ interface DropVoteProgressingProps { readonly current: number | null | undefined; readonly projected: number | null | undefined; readonly subtle?: boolean | undefined; + readonly compact?: boolean | undefined; } export default function DropVoteProgressing({ current, projected, subtle = false, + compact = false, }: DropVoteProgressingProps): ReactElement | null { if (typeof current !== "number" || typeof projected !== "number") { return null; @@ -29,20 +31,36 @@ export default function DropVoteProgressing({ let color: string; let arrowColor: string; + let wrapperClasses: string; + let valueClasses: string; + if (subtle) { - color = isPositiveProgressing ? "tw-text-iron-400 tw-font-mono" : "tw-text-iron-600 tw-font-mono"; + color = isPositiveProgressing + ? "tw-text-iron-400 tw-font-mono" + : "tw-text-iron-600 tw-font-mono"; arrowColor = "tw-text-iron-600"; + wrapperClasses = "tw-flex tw-items-center tw-gap-2"; + valueClasses = "tw-text-sm tw-font-medium tw-tracking-tight"; + } else if (compact) { + color = isPositiveProgressing + ? "tw-text-emerald-400 tw-bg-emerald-500/10 tw-px-1.5 tw-py-0.5 tw-rounded-md tw-border tw-border-solid tw-border-emerald-500/15" + : "tw-text-rose-400 tw-bg-rose-500/10 tw-px-1.5 tw-py-0.5 tw-rounded-md tw-border tw-border-solid tw-border-rose-500/15"; + arrowColor = "tw-text-iron-500"; + wrapperClasses = "tw-ml-0.5 tw-flex tw-items-center tw-gap-1.5"; + valueClasses = "tw-text-sm tw-font-medium tw-leading-5 tw-tabular-nums"; } else { color = isPositiveProgressing ? "tw-text-emerald-500 tw-bg-emerald-500/10 tw-px-2 tw-py-0.5 tw-rounded tw-border tw-border-solid tw-border-emerald-500/20 tw-font-mono" : "tw-text-rose-500 tw-bg-rose-500/10 tw-px-2 tw-py-0.5 tw-rounded tw-border tw-border-solid tw-border-rose-500/20 tw-font-mono"; arrowColor = "tw-text-iron-600"; + wrapperClasses = "tw-ml-0.5 tw-flex tw-items-center tw-gap-2"; + valueClasses = "tw-text-sm tw-font-medium tw-tracking-tight"; } return ( <> - {formatNumberWithCommas(projected)} + + {formatNumberWithCommas(projected)} + { - const rank = drop.rank && drop.rank <= 3 ? drop.rank : null; + const rank = + typeof drop.rank === "number" && drop.rank <= 3 ? drop.rank : null; const baseClasses = "tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-transition-all tw-duration-200 tw-ease-out tw-overflow-hidden"; if (isActiveDrop) { return `${baseClasses} desktop-hover:hover:tw-border-[#3CCB7F]/40 tw-bg-[#3CCB7F]/5`; - } else if (rank === 1) { - return `${baseClasses} desktop-hover:hover:tw-border-[#fbbf24]/40`; - } else if (rank === 2) { - return `${baseClasses} desktop-hover:hover:tw-border-[#94a3b8]/40`; - } else if (rank === 3) { - return `${baseClasses} desktop-hover:hover:tw-border-[#CD7F32]/40`; - } else { - return `${baseClasses} tw-border-iron-800`; } + + if (rank === 1) { + return `${baseClasses} ${getRankHoverBorderClass(1)}`; + } + + if (rank === 2) { + return `${baseClasses} ${getRankHoverBorderClass(2)}`; + } + + if (rank === 3) { + return `${baseClasses} ${getRankHoverBorderClass(3)}`; + } + + return `${baseClasses} tw-border-iron-800`; }; export default function MemeParticipationDrop({ @@ -63,10 +71,10 @@ export default function MemeParticipationDrop({ // Extract metadata const title = - drop.metadata?.find((m) => m.data_key === "title")?.data_value ?? + drop.metadata.find((m) => m.data_key === "title")?.data_value ?? "Artwork Title"; const description = - drop.metadata?.find((m) => m.data_key === "description")?.data_value ?? + drop.metadata.find((m) => m.data_key === "description")?.data_value ?? "This is an artwork submission for The Memes collection."; // Get artwork media URL if available @@ -133,7 +141,7 @@ export default function MemeParticipationDrop({ projected={drop.rating_prediction} votingCreditType={drop.wave.voting_credit_type} ratersCount={drop.raters_count} - topVoters={drop.top_raters ?? []} + topVoters={drop.top_raters} userContext={drop.context_profile_context} />
diff --git a/components/memes/drops/MemeWinnerDrop.tsx b/components/memes/drops/MemeWinnerDrop.tsx index 4ada38b995..e56ab0f3b2 100644 --- a/components/memes/drops/MemeWinnerDrop.tsx +++ b/components/memes/drops/MemeWinnerDrop.tsx @@ -2,8 +2,12 @@ import { useCallback } from "react"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import type { DropInteractionParams } from "@/components/waves/drops/Drop"; -import { DropLocation } from "@/components/waves/drops/Drop"; +import { + getRankHoverBorderClass, + getRankStaticBorderClass, +} from "@/components/waves/drops/dropRankStyles"; +import type { DropInteractionParams } from "@/components/waves/drops/drop.types"; +import { DropLocation } from "@/components/waves/drops/drop.types"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import WaveDropActions from "@/components/waves/drops/WaveDropActions"; import MemeWinnerHeader from "./MemeWinnerHeader"; @@ -13,6 +17,7 @@ import MemeDropTraits from "./MemeDropTraits"; import DropMobileMenuHandler from "@/components/waves/drops/DropMobileMenuHandler"; import DropListItemContentMedia from "@/components/drops/view/item/content/media/DropListItemContentMedia"; import { useDropContext } from "@/components/waves/drops/DropContext"; +import { WaveWinnerIdentity } from "@/components/waves/winners/identity/WaveWinnerIdentity"; interface MemeWinnerDropProps { readonly drop: ExtendedDrop; @@ -20,6 +25,10 @@ interface MemeWinnerDropProps { readonly onReply: (param: DropInteractionParams) => void; } +const getRankHoverClass = (rank: number | null): string => { + return getRankHoverBorderClass(rank); +}; + export default function MemeWinnerDrop({ drop, showReplyAndQuote, @@ -30,22 +39,19 @@ export default function MemeWinnerDrop({ // Extract metadata const title = - drop.metadata?.find((m) => m.data_key === "title")?.data_value ?? + drop.metadata.find((m) => m.data_key === "title")?.data_value ?? "Artwork Title"; const description = - drop.metadata?.find((m) => m.data_key === "description")?.data_value ?? + drop.metadata.find((m) => m.data_key === "description")?.data_value ?? "This is an artwork submission for The Memes collection."; // Get artwork media URL if available - const artworkMedia = drop.parts.at(0)?.media?.at(0); + const artworkMedia = drop.parts.at(0)?.media.at(0); const handleOnReply = useCallback(() => { onReply({ drop, partId: drop.parts[0]?.part_id! }); }, [onReply, drop]); - - // First place shadow class from DefaultWaveWinnerDrop - const firstPlaceShadow = - "tw-shadow-[inset_1px_0_0_rgba(251,191,36,0.5),inset_0_1px_0_rgba(251,191,36,0.2),inset_-1px_0_0_rgba(251,191,36,0.2),inset_0_-1px_0_rgba(251,191,36,0.2)]"; + const effectiveRank = drop.winning_context?.place ?? drop.rank; return (
@@ -55,11 +61,13 @@ export default function MemeWinnerDrop({ } tw-group tw-relative`} >
+ + {artworkMedia && (
{ - const rank = drop.rank && drop.rank <= 3 ? drop.rank : null; + const rank = + typeof drop.rank === "number" && drop.rank <= 3 ? drop.rank : null; const baseClasses = "tw-rounded-xl tw-border tw-border-solid tw-border-iron-800 tw-transition-all tw-duration-200 tw-ease-out tw-overflow-hidden"; if (rank === 1) { - return `${baseClasses} desktop-hover:hover:tw-border-yellow-500/20`; - } else if (rank === 2) { - return `${baseClasses} desktop-hover:hover:tw-border-iron-400/25`; - } else if (rank === 3) { - return `${baseClasses} desktop-hover:hover:tw-border-amber-600/20`; - } else { - // More subtle hover effect for ranks 4+ - return `${baseClasses} desktop-hover:hover:tw-border-iron-700`; + return `${baseClasses} ${getRankHoverBorderClass(1)}`; } + + if (rank === 2) { + return `${baseClasses} ${getRankHoverBorderClass(2)}`; + } + + if (rank === 3) { + return `${baseClasses} ${getRankHoverBorderClass(3)}`; + } + + return `${baseClasses} desktop-hover:hover:tw-border-iron-700`; }; const MemesLeaderboardDropCard: React.FC = ({ @@ -32,12 +37,10 @@ const MemesLeaderboardDropCard: React.FC = ({ const borderClasses = getBorderClasses(drop); return ( -
-
- {children} -
+
+
{children}
); }; -export default MemesLeaderboardDropCard; \ No newline at end of file +export default MemesLeaderboardDropCard; diff --git a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx index eb6687660f..546208b238 100644 --- a/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx +++ b/components/mobile-wrapper-dialog/MobileWrapperDialog.tsx @@ -21,7 +21,9 @@ export default function MobileWrapperDialog({ fixedHeight, tabletModal, showScrollbar, + allowOverflow, maxWidthClass, + dismissible = true, }: { readonly title?: string | undefined; readonly isOpen: boolean; @@ -34,9 +36,16 @@ export default function MobileWrapperDialog({ readonly fixedHeight?: boolean | undefined; readonly tabletModal?: boolean | undefined; readonly showScrollbar?: boolean | undefined; + readonly allowOverflow?: boolean | undefined; readonly maxWidthClass?: string | undefined; + readonly dismissible?: boolean | undefined; }) { const { isCapacitor, isIos } = useCapacitor(); + const handleClose = () => { + if (dismissible) { + onClose(); + } + }; const bottomPadding = noPadding ? "env(safe-area-inset-bottom,0px)" @@ -59,7 +68,7 @@ export default function MobileWrapperDialog({ const containerClassNames = clsx( "tw-pointer-events-none tw-fixed tw-inset-x-0 tw-bottom-0 tw-flex tw-max-w-full tw-justify-center tw-pt-10", - tabletModal && "md:tw-inset-0 md:tw-items-center md:tw-pt-0 md:tw-p-6" + tabletModal && "md:tw-inset-0 md:tw-items-center md:tw-p-6 md:tw-pt-0" ); const slideTransition = { @@ -84,7 +93,7 @@ export default function MobileWrapperDialog({ { e.stopPropagation(); - onClose(); + handleClose(); }} >
e.stopPropagation()} > - -
- -
-
+ + +
+
+ )}
= { + [UserCICAndLevelSize.COMPACT]: "tw-h-[18px] tw-w-[18px] tw-text-[7.5px]", [UserCICAndLevelSize.SMALL]: "tw-h-5 tw-w-5 tw-text-[8.5px]", [UserCICAndLevelSize.MEDIUM]: "tw-h-6 tw-w-6 tw-text-[0.65rem]", [UserCICAndLevelSize.LARGE]: "tw-h-8 tw-w-8 tw-text-[0.8rem]", diff --git a/components/utils/input/identity/IdentitySearch.tsx b/components/utils/input/identity/IdentitySearch.tsx index 436834685d..58a80df719 100644 --- a/components/utils/input/identity/IdentitySearch.tsx +++ b/components/utils/input/identity/IdentitySearch.tsx @@ -1,16 +1,23 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import type { KeyboardEvent} from "react"; +import type { KeyboardEvent } from "react"; import { useEffect, useRef, useState, useId } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faCircleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCircleExclamation, + faXmark, +} from "@fortawesome/free-solid-svg-icons"; import { useClickAway, useDebounce, useKeyPressEvent } from "react-use"; import type { CommunityMemberMinimal } from "@/entities/IProfile"; import { commonApiFetch } from "@/services/api/common-api"; import CommonProfileSearchItems from "../profile-search/CommonProfileSearchItems"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { getSelectableIdentity } from "@/components/utils/input/profile-search/getSelectableIdentity"; +import { + getSelectableIdentity, + getSelectableIdentityOption, + type SelectableIdentityOption, +} from "@/components/utils/input/profile-search/getSelectableIdentity"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; export enum IdentitySearchSize { @@ -20,19 +27,37 @@ export enum IdentitySearchSize { const MIN_SEARCH_LENGTH = 3; +type IdentitySearchDraft = { + readonly value: string; + readonly baseResolvedDisplayValue: string | null; + readonly preservedResolvedValues: readonly (string | null)[]; +}; + export default function IdentitySearch({ identity, size = IdentitySearchSize.MD, label = "Identity", error = false, + errorMessage, autoFocus = false, + selectedDisplayValue, + clearable = true, + dropdownListClassName, + onSelectionChange, setIdentity, }: { readonly identity: string | null; readonly size?: IdentitySearchSize | undefined; readonly error?: boolean | undefined; + readonly errorMessage?: string | null | undefined; readonly label?: string | undefined; readonly autoFocus?: boolean | undefined; + readonly selectedDisplayValue?: string | null | undefined; + readonly clearable?: boolean | undefined; + readonly dropdownListClassName?: string | undefined; + readonly onSelectionChange?: + | ((selection: SelectableIdentityOption | null) => void) + | undefined; readonly setIdentity: (identity: string | null) => void; }) { @@ -53,8 +78,18 @@ export default function IdentitySearch({ const inputId = useId(); const listboxId = `${inputId}-listbox`; - - const [searchCriteria, setSearchCriteria] = useState(identity); + const resolvedDisplayValue = selectedDisplayValue ?? identity ?? null; + const [searchCriteriaDraft, setSearchCriteriaDraft] = + useState(null); + const shouldUseDraft = + searchCriteriaDraft !== null && + (searchCriteriaDraft.baseResolvedDisplayValue === resolvedDisplayValue || + searchCriteriaDraft.preservedResolvedValues.some( + (value) => value === resolvedDisplayValue + )); + const searchCriteria = shouldUseDraft + ? searchCriteriaDraft.value + : resolvedDisplayValue; const [debouncedValue, setDebouncedValue] = useState( searchCriteria ); @@ -78,16 +113,6 @@ export default function IdentitySearch({ enabled: !!debouncedValue && debouncedValue.length >= MIN_SEARCH_LENGTH, }); - const selectionUpdateRef = useRef(false); - - useEffect(() => { - if (selectionUpdateRef.current) { - selectionUpdateRef.current = false; - return; - } - setSearchCriteria(identity); - }, [identity]); - const [isOpen, setIsOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(null); const [highlightedOptionId, setHighlightedOptionId] = useState< @@ -96,11 +121,19 @@ export default function IdentitySearch({ const [shouldSubmit, setShouldSubmit] = useState(false); const onValueChange = ( newValue: string | null, - displayValue?: string | null + options?: { + readonly displayValue?: string | null; + readonly selection?: SelectableIdentityOption | null; + } ) => { - selectionUpdateRef.current = true; + const draftValue = options?.displayValue ?? newValue ?? ""; setIdentity(newValue); - setSearchCriteria(displayValue ?? newValue); + onSelectionChange?.(options?.selection ?? null); + setSearchCriteriaDraft({ + value: draftValue, + baseResolvedDisplayValue: resolvedDisplayValue, + preservedResolvedValues: [newValue, options?.displayValue ?? null], + }); setIsOpen(false); setHighlightedIndex(null); }; @@ -116,24 +149,30 @@ export default function IdentitySearch({ }; const onSearchCriteriaChange = (newV: string | null) => { - setSearchCriteria(newV); + setSearchCriteriaDraft({ + value: newV ?? "", + baseResolvedDisplayValue: resolvedDisplayValue, + preservedResolvedValues: [], + }); const len = newV?.length ?? 0; setIsOpen(len >= MIN_SEARCH_LENGTH); - if (!newV) { + if (!newV && clearable) { setIdentity(null); + onSelectionChange?.(null); } setHighlightedIndex(null); }; const selectProfile = (profile: CommunityMemberMinimal) => { - const nextIdentity = getSelectableIdentity(profile); - if (!nextIdentity) { + const nextSelection = getSelectableIdentityOption(profile); + if (!nextSelection) { return false; } - const displayValue = - profile.handle ?? profile.display ?? nextIdentity; - onValueChange(nextIdentity, displayValue); + onValueChange(nextSelection.value, { + displayValue: nextSelection.label, + selection: nextSelection, + }); return true; }; @@ -232,8 +271,10 @@ export default function IdentitySearch({ ); }, [data, identity]); + const hasIdentity = identity !== null && identity.length > 0; + return ( -
+