mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/fix/option-board-support-alert'
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNonokoPendingAccountCommentIndex, getNonokoPendingRouteState, getUnsupportedPostOptionsMessage, hasNonokoOption } from '../post-options-utils';
|
||||
import {
|
||||
getNonokoPendingAccountCommentIndex,
|
||||
getNonokoPendingRouteState,
|
||||
getPostOptionsValidationError,
|
||||
getUnsupportedPostOptionsMessage,
|
||||
hasNonokoOption,
|
||||
} from '../post-options-utils';
|
||||
|
||||
describe('post-options-utils', () => {
|
||||
it('rejects additional dice options instead of dropping them', () => {
|
||||
expect(getUnsupportedPostOptionsMessage('dice+1d6 dice+1d20', 'qst')).toBe('unsupported options: dice+1d20');
|
||||
expect(getUnsupportedPostOptionsMessage('dice+1d6 dice+1d20', 'qst')).toBe('Unsupported options: dice+1d20.');
|
||||
});
|
||||
|
||||
it('supports nonoko while keeping sage unsupported', () => {
|
||||
expect(getUnsupportedPostOptionsMessage('nonoko', undefined)).toBeNull();
|
||||
expect(getUnsupportedPostOptionsMessage('sage', 'b')).toBe('unsupported options: sage');
|
||||
expect(getUnsupportedPostOptionsMessage('sage', 'b')).toBe('Unsupported options: sage.');
|
||||
expect(hasNonokoOption('fortune nonoko')).toBe(true);
|
||||
expect(hasNonokoOption('nonokosage')).toBe(false);
|
||||
});
|
||||
|
||||
it('describes board-specific options with supported directories', () => {
|
||||
expect(getPostOptionsValidationError('fortune', 'mu')).toEqual({
|
||||
unsupportedOptions: ['fortune'],
|
||||
supportedDirectoryCodesByOption: [{ option: 'fortune', directoryCodes: ['b', 's5s'] }],
|
||||
});
|
||||
expect(getUnsupportedPostOptionsMessage('fortune', 'mu')).toBe('Unsupported options: fortune. Option "fortune" is supported on: /b/, /s5s/.');
|
||||
expect(getUnsupportedPostOptionsMessage('sage fortune', 'pol')).toBe('Unsupported options: sage, fortune. Option "fortune" is supported on: /b/, /s5s/.');
|
||||
expect(getPostOptionsValidationError('fortune dice+1d6', 'mu')).toEqual({
|
||||
unsupportedOptions: ['fortune', 'dice+1d6'],
|
||||
supportedDirectoryCodesByOption: [
|
||||
{ option: 'fortune', directoryCodes: ['b', 's5s'] },
|
||||
{ option: 'dice+1d6', directoryCodes: ['qst', 'tg'] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the nonoko pending account comment index from direct and wrapped route state', () => {
|
||||
expect(getNonokoPendingRouteState(7)).toEqual({ nonokoPendingAccountCommentIndex: 7 });
|
||||
expect(getNonokoPendingAccountCommentIndex({ nonokoPendingAccountCommentIndex: 7 })).toBe(7);
|
||||
|
||||
@@ -19,6 +19,20 @@ export interface DiceRoll {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PostOptionsValidationError {
|
||||
unsupportedOptions: string[];
|
||||
supportedDirectoryCodesByOption: Array<{ option: string; directoryCodes: string[] }>;
|
||||
}
|
||||
|
||||
export const isPostOptionsValidationError = (error: unknown): error is PostOptionsValidationError => {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = error as Partial<PostOptionsValidationError>;
|
||||
return Array.isArray(value.unsupportedOptions) && Array.isArray(value.supportedDirectoryCodesByOption);
|
||||
};
|
||||
|
||||
interface PostOptionsDirectory {
|
||||
directoryCode?: string;
|
||||
title?: string;
|
||||
@@ -88,27 +102,62 @@ const isSupportedPostOption = (option: string, directoryCode: string | undefined
|
||||
return !!parseDiceOption(option) && !!directoryCode && DICE_DIRECTORY_CODES.has(directoryCode);
|
||||
};
|
||||
|
||||
const getUnsupportedPostOptions = (value: string, directoryCode: string | undefined): string[] => {
|
||||
let hasDiceOption = false;
|
||||
const getSupportedDirectoryCodes = (option: string): string[] => {
|
||||
if (option === 'fortune') {
|
||||
return [...FORTUNE_DIRECTORY_CODES];
|
||||
}
|
||||
|
||||
return parsePostOptions(value).filter((option) => {
|
||||
return parseDiceOption(option) ? [...DICE_DIRECTORY_CODES] : [];
|
||||
};
|
||||
|
||||
export const getPostOptionsValidationError = (value: string, directoryCode: string | undefined): PostOptionsValidationError | null => {
|
||||
let hasDiceOption = false;
|
||||
const unsupportedOptions: string[] = [];
|
||||
const supportedDirectoryCodesByOption: Array<{ option: string; directoryCodes: string[] }> = [];
|
||||
|
||||
for (const option of parsePostOptions(value)) {
|
||||
const diceOption = parseDiceOption(option);
|
||||
if (diceOption && directoryCode && DICE_DIRECTORY_CODES.has(directoryCode)) {
|
||||
const isExtraDiceOption = hasDiceOption;
|
||||
hasDiceOption = true;
|
||||
return isExtraDiceOption;
|
||||
if (isExtraDiceOption) {
|
||||
unsupportedOptions.push(option);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
return !isSupportedPostOption(option, directoryCode);
|
||||
});
|
||||
if (isSupportedPostOption(option, directoryCode)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const optionDirectoryCodes = getSupportedDirectoryCodes(option);
|
||||
if (optionDirectoryCodes.length > 0) {
|
||||
unsupportedOptions.push(option);
|
||||
if (!supportedDirectoryCodesByOption.some((entry) => entry.option === option)) {
|
||||
supportedDirectoryCodesByOption.push({ option, directoryCodes: optionDirectoryCodes });
|
||||
}
|
||||
} else {
|
||||
unsupportedOptions.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
return unsupportedOptions.length > 0 ? { unsupportedOptions, supportedDirectoryCodesByOption } : null;
|
||||
};
|
||||
|
||||
export const getUnsupportedPostOptionsMessage = (value: string, directoryCode: string | undefined): string | null => {
|
||||
const unsupportedOptions = getUnsupportedPostOptions(value, directoryCode);
|
||||
return unsupportedOptions.length > 0 ? `unsupported options: ${unsupportedOptions.join(', ')}` : null;
|
||||
};
|
||||
const error = getPostOptionsValidationError(value, directoryCode);
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const isUnsupportedPostOptionsMessage = (message: string | null): boolean => message?.startsWith('unsupported options:') === true;
|
||||
let message = `Unsupported options: ${error.unsupportedOptions.join(', ')}.`;
|
||||
|
||||
for (const { option, directoryCodes } of error.supportedDirectoryCodesByOption) {
|
||||
message += ` Option "${option}" is supported on: ${directoryCodes.map((code) => `/${code}/`).join(', ')}.`;
|
||||
}
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
export const hasNonokoOption = (value: string): boolean => parsePostOptions(value).includes('nonoko');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user