-
-
Notifications
You must be signed in to change notification settings - Fork 25
fix(publishing): abandon publications when challenges close #829
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| // @vitest-environment jsdom | ||
|
|
||
| import * as React from 'react'; | ||
| import { createElement } from 'react'; | ||
| import { createRoot, type Root } from 'react-dom/client'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import ChallengeModal from './challenge-modal'; | ||
| import useChallengesStore from '../../stores/use-challenges-store'; | ||
|
|
||
| (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; | ||
| const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>; | ||
|
|
||
| vi.mock('react-i18next', () => ({ | ||
| useTranslation: () => ({ | ||
| t: (key: string, options?: Record<string, unknown>) => (key === 'challenge_counter' ? `${options?.index}/${options?.total}` : key), | ||
| }), | ||
| })); | ||
|
|
||
| vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ | ||
| useAccount: () => ({ author: { address: '0xabc' } }), | ||
| useComment: () => undefined, | ||
| })); | ||
|
|
||
| vi.mock('../../hooks/use-theme', () => ({ | ||
| default: () => ['light'], | ||
| })); | ||
|
|
||
| let container: HTMLDivElement; | ||
| let root: Root; | ||
|
|
||
| const createChallenge = (prompt: string, publishChallengeAnswers = vi.fn()) => | ||
| [{ challenges: [{ challenge: prompt, type: 'text/plain' }] }, { communityAddress: 'example.bso', content: prompt, publishChallengeAnswers }] as never; | ||
|
|
||
| const renderModal = async () => { | ||
| await act(async () => { | ||
| root.render(createElement(ChallengeModal)); | ||
| }); | ||
| }; | ||
|
|
||
| const clickButton = async (text: string) => { | ||
| const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === text); | ||
| expect(button).toBeDefined(); | ||
| await act(async () => { | ||
| button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); | ||
| await Promise.resolve(); | ||
| }); | ||
| }; | ||
|
|
||
| const enterAnswer = async (value: string) => { | ||
| const input = container.querySelector<HTMLInputElement>('input'); | ||
| expect(input).not.toBeNull(); | ||
| await act(async () => { | ||
| const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; | ||
| valueSetter?.call(input, value); | ||
| input?.dispatchEvent(new Event('input', { bubbles: true })); | ||
| input?.dispatchEvent(new Event('change', { bubbles: true })); | ||
| }); | ||
| }; | ||
|
|
||
| describe('ChallengeModal', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| useChallengesStore.setState({ challenges: [] }); | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| act(() => root.unmount()); | ||
| container.remove(); | ||
| useChallengesStore.setState({ challenges: [] }); | ||
| }); | ||
|
|
||
| it('abandons exactly the current challenge when Cancel is clicked', async () => { | ||
| const firstAbandon = vi.fn().mockResolvedValue(undefined); | ||
| const secondAbandon = vi.fn().mockResolvedValue(undefined); | ||
| useChallengesStore.getState().addChallenge(createChallenge('first prompt'), firstAbandon); | ||
| useChallengesStore.getState().addChallenge(createChallenge('second prompt'), secondAbandon); | ||
| await renderModal(); | ||
|
|
||
| await clickButton('cancel'); | ||
|
|
||
| expect(firstAbandon).toHaveBeenCalledOnce(); | ||
| expect(secondAbandon).not.toHaveBeenCalled(); | ||
| expect(useChallengesStore.getState().challenges).toHaveLength(1); | ||
| expect(container.textContent).toContain('second prompt'); | ||
| }); | ||
|
|
||
| it('abandons exactly the current challenge once when Escape is pressed', async () => { | ||
| const firstAbandon = vi.fn().mockResolvedValue(undefined); | ||
| const secondAbandon = vi.fn().mockResolvedValue(undefined); | ||
| useChallengesStore.getState().addChallenge(createChallenge('first prompt'), firstAbandon); | ||
| useChallengesStore.getState().addChallenge(createChallenge('second prompt'), secondAbandon); | ||
| await renderModal(); | ||
|
|
||
| await act(async () => { | ||
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | ||
| await Promise.resolve(); | ||
| }); | ||
|
|
||
| expect(firstAbandon).toHaveBeenCalledOnce(); | ||
| expect(secondAbandon).not.toHaveBeenCalled(); | ||
| expect(useChallengesStore.getState().challenges).toHaveLength(1); | ||
| }); | ||
|
|
||
| it('submits a text answer without abandoning and resets state for the next queue entry', async () => { | ||
| const publishChallengeAnswers = vi.fn(); | ||
| const firstAbandon = vi.fn().mockResolvedValue(undefined); | ||
| useChallengesStore.getState().addChallenge(createChallenge('first prompt', publishChallengeAnswers), firstAbandon); | ||
| useChallengesStore.getState().addChallenge(createChallenge('second prompt')); | ||
| await renderModal(); | ||
|
|
||
| await enterAnswer('four'); | ||
| await clickButton('submit'); | ||
|
|
||
| expect(publishChallengeAnswers).toHaveBeenCalledWith(['four']); | ||
| expect(firstAbandon).not.toHaveBeenCalled(); | ||
| expect(useChallengesStore.getState().challenges).toHaveLength(1); | ||
| expect(container.textContent).toContain('second prompt'); | ||
| expect(container.querySelector<HTMLInputElement>('input')?.value).toBe(''); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| // @vitest-environment jsdom | ||
|
|
||
| import * as React from 'react'; | ||
| import { createElement } from 'react'; | ||
| import { createRoot, type Root } from 'react-dom/client'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import usePublishReply from './use-publish-reply'; | ||
| import useChallengesStore from '../stores/use-challenges-store'; | ||
| import usePublishReplyStore from '../stores/use-publish-reply-store'; | ||
|
|
||
| (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; | ||
| const act = (React as { act?: (callback: () => void | Promise<void>) => void | Promise<void> }).act as (callback: () => void | Promise<void>) => void | Promise<void>; | ||
|
|
||
| const testState = vi.hoisted(() => ({ | ||
| abandonPublish: vi.fn().mockResolvedValue(undefined), | ||
| lastOptions: undefined as Record<string, any> | undefined, | ||
| })); | ||
|
|
||
| vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ | ||
| usePublishComment: (options: Record<string, any>) => { | ||
| testState.lastOptions = options; | ||
| return { abandonPublish: testState.abandonPublish, index: undefined, publishComment: vi.fn() }; | ||
| }, | ||
| })); | ||
|
|
||
| let container: HTMLDivElement; | ||
| let latestValue: ReturnType<typeof usePublishReply>; | ||
| let root: Root; | ||
|
|
||
| const HookHarness = () => { | ||
| latestValue = usePublishReply({ cid: 'parent-cid', communityAddress: 'example.bso', postCid: 'post-cid' }); | ||
| return null; | ||
| }; | ||
|
|
||
| describe('usePublishReply', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| testState.lastOptions = undefined; | ||
| useChallengesStore.setState({ challenges: [] }); | ||
| usePublishReplyStore.getState().resetReplyStore('parent-cid'); | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
| act(() => root.render(createElement(HookHarness))); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| act(() => root.unmount()); | ||
| container.remove(); | ||
| useChallengesStore.setState({ challenges: [] }); | ||
| usePublishReplyStore.getState().resetReplyStore('parent-cid'); | ||
| }); | ||
|
|
||
| it('routes challenge cancellation to the current usePublishComment abandonPublish', async () => { | ||
| await act(async () => { | ||
| latestValue.setPublishReplyOptions.content('Reply body'); | ||
| }); | ||
|
|
||
| await act(async () => { | ||
| await testState.lastOptions?.onChallenge({ challenges: [] }, { content: 'Reply body' }); | ||
| }); | ||
|
|
||
| expect(useChallengesStore.getState().challenges).toHaveLength(1); | ||
|
|
||
| await act(async () => { | ||
| await useChallengesStore.getState().abandonCurrentChallenge(); | ||
| }); | ||
|
|
||
| expect(testState.abandonPublish).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import { useMemo } from 'react'; | ||
| import { useCallback, useEffect, useMemo, useRef } from 'react'; | ||
| import { usePublishComment } from '@bitsocial/bitsocial-react-hooks'; | ||
| import usePublishReplyStore from '../stores/use-publish-reply-store'; | ||
| import useChallengesStore from '../stores/use-challenges-store'; | ||
|
|
||
| const usePublishReply = ({ cid, communityAddress, postCid }: { cid: string; communityAddress: string; postCid: string | undefined }) => { | ||
| const parentCid = cid; | ||
|
|
@@ -15,6 +16,11 @@ const usePublishReply = ({ cid, communityAddress, postCid }: { cid: string; comm | |
|
|
||
| const setReplyStore = usePublishReplyStore((state) => state.setReplyStore); | ||
| const resetReplyStore = usePublishReplyStore((state) => state.resetReplyStore); | ||
| const addChallenge = useChallengesStore((state) => state.addChallenge); | ||
| const abandonPublishRef = useRef<(() => Promise<void>) | undefined>(undefined); | ||
| const abandonCurrentPublish = useCallback(async () => { | ||
| await abandonPublishRef.current?.(); | ||
| }, []); | ||
|
|
||
| const setPublishReplyOptions = useMemo( | ||
| () => ({ | ||
|
|
@@ -64,7 +70,20 @@ const usePublishReply = ({ cid, communityAddress, postCid }: { cid: string; comm | |
|
|
||
| const resetPublishReplyOptions = useMemo(() => () => resetReplyStore(parentCid), [parentCid, resetReplyStore]); | ||
|
|
||
| const { index, publishComment } = usePublishComment(publishCommentOptions); | ||
| const publishOptionsWithAbandon = useMemo( | ||
| () => ({ | ||
| ...publishCommentOptions, | ||
| onChallenge: async (...args: any[]) => { | ||
| addChallenge(args, abandonCurrentPublish); | ||
| }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale abandon on queued challengesMedium Severity Each queued challenge stores the same Additional Locations (1)Reviewed by Cursor Bugbot for commit 2a790f8. Configure here. |
||
| }), | ||
| [abandonCurrentPublish, addChallenge, publishCommentOptions], | ||
| ); | ||
|
|
||
| const { index, publishComment, abandonPublish } = usePublishComment(publishOptionsWithAbandon); | ||
| useEffect(() => { | ||
| abandonPublishRef.current = abandonPublish; | ||
| }, [abandonPublish]); | ||
|
|
||
| return { setPublishReplyOptions, resetPublishReplyOptions, replyIndex: index, publishReply: publishComment, publishReplyOptions }; | ||
| }; | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.