Merge branch 'codex/fix/option-board-support-alert'

This commit is contained in:
Tommaso Casaburi
2026-05-24 19:17:55 +07:00
9 changed files with 220 additions and 50 deletions
@@ -621,11 +621,11 @@ describe('PostForm', () => {
expect(textarea).toBeTruthy();
await dispatchInput(optionsInput as HTMLInputElement, 'x y z');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: x, y, z');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'unsupported options: x, y, z');
expect(container.textContent).toContain('Unsupported options: x, y, z.');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'Unsupported options: x, y, z.');
expect(delayedOptionsError?.className).toContain('error');
expect(delayedOptionsError?.className).toContain('formError');
@@ -636,7 +636,7 @@ describe('PostForm', () => {
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
expect(testState.publishPostOptions.content).toBe('fortune body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>');
await clickByText(table as HTMLTableElement, 'post');
@@ -660,7 +660,7 @@ describe('PostForm', () => {
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'silly fortune');
await clickByText(table as HTMLTableElement, 'post');
@@ -684,7 +684,7 @@ describe('PostForm', () => {
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6+3');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'dice body');
await clickByText(table as HTMLTableElement, 'post');
@@ -705,10 +705,12 @@ describe('PostForm', () => {
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: fortune');
expect(container.textContent).toContain('Unsupported options: fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain body');
await clickByText(table as HTMLTableElement, 'post');
@@ -717,6 +719,24 @@ describe('PostForm', () => {
expect(testState.publishPostOptions.content).toBe('plain body');
});
it('combines unavailable and unknown options in one clear post-form message', async () => {
testState.resolvedCommunityAddress = 'politically-incorrect.bso';
testState.directories = [...testState.directories, { address: 'politically-incorrect.bso', features: {}, title: '/pol/ - Politically Incorrect' }];
await renderPostForm('/pol');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table');
const optionsInput = table?.querySelector<HTMLInputElement>('input[aria-label="options"]');
await dispatchInput(optionsInput as HTMLInputElement, 'sage fortune');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: sage, fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
});
it('treats dice rolls as unsupported outside /tg/ and /qst/', async () => {
testState.resolvedCommunityAddress = 'random-nsfw.bso';
@@ -728,10 +748,12 @@ describe('PostForm', () => {
const textarea = table?.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput as HTMLInputElement, 'dice+1d6');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: dice+1d6');
expect(container.textContent).toContain('Unsupported options: dice+1d6. Option "dice+1d6" is supported on: /qst/, /tg/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/qst"]')?.textContent).toBe('/qst/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/tg"]')?.textContent).toBe('/tg/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice');
await clickByText(table as HTMLTableElement, 'post');
@@ -233,6 +233,12 @@
color: red;
}
.error a,
.error a:visited {
color: inherit;
text-decoration: underline;
}
.formError {
margin: 8px 0;
padding: 6px 5px;
+15 -8
View File
@@ -10,13 +10,14 @@ import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation
import {
type DiceRoll,
type FortuneEntry,
type PostOptionsValidationError,
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getNonokoPendingRouteState,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
getPostOptionsValidationError,
hasNonokoOption,
isUnsupportedPostOptionsMessage,
isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils';
import { truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
import { getPublishURLFilename, isValidPublishURL, isValidURL } from '../../lib/utils/url-utils';
@@ -39,6 +40,7 @@ import useMediaHostingStore from '../../stores/use-media-hosting-store';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import LoadingEllipsis from '../loading-ellipsis';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce';
@@ -446,7 +448,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const showBbcodeToolbar = hasModQueueAccessRole(accountRole) || (!effectiveBoardAddress && isInModView && accountCommunityAddresses.length > 0);
const [lengthError, setLengthError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | PostOptionsValidationError | null>(null);
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
const [bbcodePreviewContent, setBbcodePreviewContent] = useState('');
@@ -463,7 +465,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const checkPostOptions = useRef(
debounce((options: string, directoryCode: string | undefined) => {
const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode);
const nextOptionsError = getPostOptionsValidationError(options, directoryCode);
if (nextOptionsError) {
setFormError(nextOptionsError);
}
@@ -487,6 +489,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
checkPostOptions.cancel();
fortuneEntryRef.current = null;
diceRollRef.current = null;
setFormError(null);
setIsBbcodePreviewing(false);
setBbcodePreviewContent('');
};
@@ -504,7 +507,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
@@ -600,7 +603,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const options = e.target.value;
handleContentValueChange(textRef.current?.value || '', options);
setFormError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError));
setFormError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
checkPostOptions(options, postOptionsDirectoryCode);
};
@@ -618,7 +621,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
const onPublishReply = () => {
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(textRef.current?.value || '', currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLength.cancel();
@@ -746,7 +749,11 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
</tbody>
</table>
{showBbcodeToolbar ? <div className={`${styles.error} ${styles.formError}`}>warning: posting as moderator</div> : null}
{formError && <div className={`${styles.error} ${styles.formError}`}>{formError}</div>}
{formError ? (
<div className={`${styles.error} ${styles.formError}`}>
{isPostOptionsValidationError(formError) ? <PostOptionsErrorMessage error={formError} directories={directories} /> : formError}
</div>
) : null}
{publishPostError && <div className={`${styles.error} ${styles.formError}`}>{publishPostError}</div>}
{publishReplyError && <div className={`${styles.error} ${styles.formError}`}>{publishReplyError}</div>}
{publishReplyStateMessage && <div className={styles.status}>{publishReplyStateMessage}</div>}
@@ -0,0 +1,50 @@
import { Fragment } from 'react';
import { Link } from 'react-router-dom';
import type { DirectoryCommunity } from '../../hooks/use-directories';
import type { PostOptionsValidationError } from '../../lib/utils/post-options-utils';
interface PostOptionsErrorMessageProps {
directories?: DirectoryCommunity[];
error: PostOptionsValidationError;
}
const EMPTY_DIRECTORIES: DirectoryCommunity[] = [];
const DIRECTORY_TITLE_CODE_PATTERN = /^\/([^/]+)\//;
const DIRECTORY_TITLE_LABEL_PATTERN = /^\/[^/]+\//;
const getDirectoryCode = (directory: DirectoryCommunity): string | undefined => directory.directoryCode ?? directory.title?.match(DIRECTORY_TITLE_CODE_PATTERN)?.[1];
const getDirectoryLabel = (code: string, directories: DirectoryCommunity[]): string => {
const directory = directories.find((entry) => getDirectoryCode(entry) === code);
return directory?.title?.match(DIRECTORY_TITLE_LABEL_PATTERN)?.[0] ?? `/${code}/`;
};
const DirectoryLinks = ({ codes, directories }: { codes: string[]; directories: DirectoryCommunity[] }) => (
<>
{codes.map((code, index) => {
const separator = index === 0 ? '' : ', ';
return (
<Fragment key={code}>
{separator}
<Link to={`/${code}`}>{getDirectoryLabel(code, directories)}</Link>
</Fragment>
);
})}
</>
);
const PostOptionsErrorMessage = ({ directories = EMPTY_DIRECTORIES, error }: PostOptionsErrorMessageProps) => {
return (
<>
Unsupported options: {error.unsupportedOptions.join(', ')}.
{error.supportedDirectoryCodesByOption.map(({ option, directoryCodes }) => (
<Fragment key={option}>
{' '}
Option "{option}" is supported on: <DirectoryLinks codes={directoryCodes} directories={directories} />.
</Fragment>
))}
</>
);
};
export default PostOptionsErrorMessage;
@@ -141,6 +141,7 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
vi.mock('../../../hooks/use-directories', () => ({
findDirectoryByAddress: (directories: Array<{ address: string; features?: Record<string, unknown>; title?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address),
useDirectories: () => Object.values(testState.directoryByAddress),
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
}));
@@ -508,11 +509,11 @@ describe('ReplyModal', () => {
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'x y z');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: x, y, z');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'unsupported options: x, y, z');
expect(container.textContent).toContain('Unsupported options: x, y, z.');
const delayedOptionsError = Array.from(container.querySelectorAll('div')).find((element) => element.textContent === 'Unsupported options: x, y, z.');
expect(delayedOptionsError?.className).toContain('error');
await dispatchInput(textarea as HTMLTextAreaElement, 'reply body');
@@ -522,7 +523,7 @@ describe('ReplyModal', () => {
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({
content: 'reply body<span class="fortune" style="color:#fd4d32"><br><br><b>Your fortune: Excellent Luck</b></span>',
});
@@ -547,7 +548,7 @@ describe('ReplyModal', () => {
await dispatchInput(optionsInput, 'fortune');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'silly reply');
await clickButtonByText('post');
@@ -572,7 +573,7 @@ describe('ReplyModal', () => {
await dispatchInput(optionsInput, 'dice+2d6');
await waitForOptionsValidation();
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await dispatchInput(textarea as HTMLTextAreaElement, 'dice reply');
await clickButtonByText('post');
@@ -594,10 +595,12 @@ describe('ReplyModal', () => {
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'dice+1d6');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: dice+1d6');
expect(container.textContent).toContain('Unsupported options: dice+1d6. Option "dice+1d6" is supported on: /qst/, /tg/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/qst"]')?.textContent).toBe('/qst/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/tg"]')?.textContent).toBe('/tg/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice reply');
await clickButtonByText('post');
@@ -616,10 +619,12 @@ describe('ReplyModal', () => {
const textarea = container.querySelector<HTMLTextAreaElement>('textarea');
await dispatchInput(optionsInput, 'fortune');
expect(container.textContent).not.toContain('unsupported options');
expect(container.textContent).not.toContain('Unsupported options');
await waitForOptionsValidation();
expect(container.textContent).toContain('unsupported options: fortune');
expect(container.textContent).toContain('Unsupported options: fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply');
await clickButtonByText('post');
@@ -155,6 +155,12 @@
text-shadow: 0 1px rgba(0, 0, 0, 0.20);
}
.error a,
.error a:visited {
color: inherit;
text-decoration: underline;
}
.status {
width: 294px;
font-family: monospace;
+12 -9
View File
@@ -7,12 +7,13 @@ import { getExpiringMediaLinkAlert } from '../../lib/utils/media-link-validation
import {
type DiceRoll,
type FortuneEntry,
type PostOptionsValidationError,
POST_OPTIONS_VALIDATION_DELAY_MS,
getContentWithPostOptionState as getContentWithOptions,
getPostOptionsDirectoryCode,
getUnsupportedPostOptionsMessage,
getPostOptionsValidationError,
hasNonokoOption,
isUnsupportedPostOptionsMessage,
isPostOptionsValidationError,
} from '../../lib/utils/post-options-utils';
import { getPublishURLFilename, isValidPublishURL } from '../../lib/utils/url-utils';
import { hasModQueueAccessRole } from '../../lib/utils/mod-access';
@@ -21,7 +22,7 @@ import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import { useDirectoryByAddress } from '../../hooks/use-directories';
import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload';
@@ -29,6 +30,7 @@ import { useCommunityField } from '../../hooks/use-stable-community';
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import LoadingEllipsis from '../loading-ellipsis';
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce';
@@ -56,7 +58,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const isInAllView = isAllView(location.pathname);
const isInModView = isModView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const directoryEntry = useDirectoryByAddress(communityAddress);
const directories = useDirectories();
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
@@ -98,7 +101,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const quoteInsertNumber = useReplyModalStore((state) => state.quoteInsertNumber);
const quoteInsertSelectedText = useReplyModalStore((state) => state.quoteInsertSelectedText);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | PostOptionsValidationError | null>(null);
const [lengthError, setLengthError] = useState<string | null>(null);
const [url, setUrl] = useState('');
const [isBbcodePreviewing, setIsBbcodePreviewing] = useState(false);
@@ -118,7 +121,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const checkPostOptionsRef = useRef(
debounce((options: string, directoryCode: string | undefined) => {
const nextOptionsError = getUnsupportedPostOptionsMessage(options, directoryCode);
const nextOptionsError = getPostOptionsValidationError(options, directoryCode);
if (nextOptionsError) {
setLengthError(null);
setError(nextOptionsError);
@@ -130,7 +133,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const currentContent = textRef.current?.value || '';
const currentUrl = urlRef.current?.value.trim() || '';
const currentOptions = optionsRef.current?.value || '';
const currentOptionsError = getUnsupportedPostOptionsMessage(currentOptions, postOptionsDirectoryCode);
const currentOptionsError = getPostOptionsValidationError(currentOptions, postOptionsDirectoryCode);
const publishContent = getContentWithOptions(currentContent, currentOptions, fortuneEntryRef, diceRollRef, postOptionsDirectoryCode);
checkContentLengthRef.current.cancel();
@@ -345,7 +348,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
const handleOptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const options = e.target.value;
handleContentValueChange(textRef.current?.value || '', undefined, undefined, options);
setError((currentError) => (isUnsupportedPostOptionsMessage(currentError) ? null : currentError));
setError((currentError) => (isPostOptionsValidationError(currentError) ? null : currentError));
checkPostOptionsRef.current(options, postOptionsDirectoryCode);
};
@@ -556,7 +559,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
{lengthError ? (
<div className={styles.error}>{lengthError}</div>
) : error ? (
<div className={styles.error}>{error}</div>
<div className={styles.error}>{isPostOptionsValidationError(error) ? <PostOptionsErrorMessage error={error} directories={directories} /> : error}</div>
) : (
publishReplyError && <div className={styles.error}>{publishReplyError}</div>
)}