fix(pending posts): show publish errors

This commit is contained in:
Tommaso Casaburi
2026-04-20 16:15:12 +07:00
parent b196670cda
commit 338f415f2a
5 changed files with 100 additions and 4 deletions
@@ -22,6 +22,8 @@ type TestComment = {
edit?: {
timestamp: number;
};
error?: unknown;
errors?: unknown[];
number?: number;
original?: {
content?: string;
@@ -126,6 +128,19 @@ vi.mock('../../loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../error-display/error-display', () => ({
default: ({ error, inline, showImmediately }: { error?: Error; inline?: boolean; showImmediately?: boolean }) =>
createElement(
'button',
{
'data-testid': 'error-display',
'data-inline': String(Boolean(inline)),
'data-show-immediately': String(Boolean(showImmediately)),
},
error?.message || String(error),
),
}));
vi.mock('../../reply-quote-preview', () => ({
default: ({
isOP,
@@ -393,4 +408,20 @@ describe('CommentContent', () => {
});
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Publishing');
});
it('renders failed unpublished comment errors through ErrorDisplay', async () => {
testState.stateString = 'Failed';
await renderContent({
content: 'still pending',
errors: [new Error('spam blocker server error')],
postCid: 'post-2',
state: 'failed',
});
const errorDisplay = container.querySelector('[data-testid="error-display"]');
expect(errorDisplay?.textContent).toBe('spam blocker server error');
expect(errorDisplay?.getAttribute('data-inline')).toBe('true');
expect(errorDisplay?.getAttribute('data-show-immediately')).toBe('true');
expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull();
});
});
@@ -11,6 +11,7 @@ import { isPostPageView } from '../../lib/utils/view-utils';
import useIsMobile from '../../hooks/use-is-mobile';
import useStateString from '../../hooks/use-state-string';
import LoadingEllipsis from '../../components/loading-ellipsis';
import ErrorDisplay from '../../components/error-display/error-display';
import ReplyQuotePreview from '../../components/reply-quote-preview';
import Markdown from '../../components/markdown';
import Tooltip from '../../components/tooltip';
@@ -63,6 +64,12 @@ const useScopedCidToNumber = (cids: string[]) => {
return cidToNumber;
};
const getFailedCommentError = (comment: Comment | undefined): unknown => {
if (comment?.state !== 'failed') return undefined;
if (comment.error) return comment.error;
return Array.isArray(comment.errors) ? comment.errors.find(Boolean) : undefined;
};
const CommentContent = ({ comment: post, prependContent }: { comment: Comment; prependContent?: ReactNode }) => {
const { t } = useTranslation();
const params = useParams();
@@ -123,9 +130,18 @@ const CommentContent = ({ comment: post, prependContent }: { comment: Comment; p
const stateString = useStateString(resolvedPost);
const hasFailedState = state === 'failed';
const failedError = getFailedCommentError(resolvedPost);
const loadingString = (
<div className={styles.stateString}>{!hasFailedState ? <LoadingEllipsis string={stateString || t('loading')} /> : stateString || capitalize(t('failed'))}</div>
<div className={styles.stateString}>
{failedError ? (
<ErrorDisplay error={failedError} inline={true} showImmediately={true} />
) : !hasFailedState ? (
<LoadingEllipsis string={stateString || t('loading')} />
) : (
stateString || capitalize(t('failed'))
)}
</div>
);
return (
@@ -102,6 +102,39 @@ describe('ErrorDisplay', () => {
expect(container.textContent).toContain('copy failed');
});
it('copies native Error instances with message, stack, and extra fields', async () => {
testState.copyToClipboardMock.mockResolvedValue(undefined);
const error = Object.assign(new Error('native failure'), {
details: { status: 504 },
});
error.stack = 'Error: native failure\n at publish';
await renderDisplay(error);
act(() => {
vi.advanceTimersByTime(1000);
});
const button = container.querySelector('button');
expect(button?.textContent).toContain('error: native failure');
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.copyToClipboardMock).toHaveBeenCalledWith(
JSON.stringify(
{
name: 'Error',
message: 'native failure',
stack: 'Error: native failure\n at publish',
details: { status: 504 },
},
null,
2,
),
);
});
it('supports compact custom labels that copy plain string errors immediately', async () => {
testState.copyToClipboardMock.mockResolvedValue(undefined);
+15 -3
View File
@@ -17,8 +17,20 @@ const serializeErrorForClipboard = (error: unknown): string => {
return error;
}
const serializableError =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
...Object.fromEntries(Object.entries(error)),
...('cause' in error && error.cause ? { cause: error.cause } : {}),
}
: error;
try {
return JSON.stringify(error, null, 2);
const serializedError = JSON.stringify(serializableError, null, 2);
return serializedError && serializedError !== '{}' ? serializedError : String(error);
} catch {
return String(error);
}
@@ -54,8 +66,8 @@ const ErrorDisplay = ({ error, displayMessage, inline = false, showImmediately =
return null;
}
const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : null);
const canCopyError = !!error && (!!displayMessage || !!error?.message);
const originalDisplayMessage = displayMessage || (error?.message ? `${t('error')}: ${error.message}` : typeof error === 'string' ? error : error ? t('error') : null);
const canCopyError = !!error && !!originalDisplayMessage;
const handleMessageClick = async () => {
if (!canCopyError || state.feedbackMessageKey) return;
+4
View File
@@ -235,6 +235,10 @@ export const Post = memo(
prev?.postNumber === next?.postNumber &&
prev?.replyCount === next?.replyCount &&
prev?.updatedAt === next?.updatedAt &&
prev?.state === next?.state &&
prev?.publishingState === next?.publishingState &&
prev?.error === next?.error &&
prev?.errors === next?.errors &&
prev?.approved === next?.approved &&
prev?.locked === next?.locked &&
prev?.pinned === next?.pinned &&