From 5fad3c73ba3346730680de6f2696ec4288a0a7cc Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sun, 24 May 2026 19:17:50 +0700 Subject: [PATCH] fix(post): link board-specific option errors to supported boards When fortune or dice options are used on the wrong board, show which boards support them with clickable links in the post form and reply modal. --- .../post-form/__tests__/post-form.test.tsx | 42 ++++++++--- src/components/post-form/post-form.module.css | 6 ++ src/components/post-form/post-form.tsx | 23 ++++--- .../post-options-error-message.tsx | 50 ++++++++++++++ .../__tests__/reply-modal.test.tsx | 25 ++++--- .../reply-modal/reply-modal.module.css | 6 ++ src/components/reply-modal/reply-modal.tsx | 21 +++--- .../__tests__/post-options-utils.test.ts | 28 +++++++- src/lib/utils/post-options-utils.ts | 69 ++++++++++++++++--- 9 files changed, 220 insertions(+), 50 deletions(-) create mode 100644 src/components/post-options-error-message/post-options-error-message.tsx diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 69449bb8..864b35b0 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -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

Your fortune: Excellent Luck
'); 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('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('a[href="/b"]')?.textContent).toBe('/b/'); + expect(container.querySelector('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('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('a[href="/b"]')?.textContent).toBe('/b/'); + expect(container.querySelector('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('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('a[href="/qst"]')?.textContent).toBe('/qst/'); + expect(container.querySelector('a[href="/tg"]')?.textContent).toBe('/tg/'); await dispatchInput(textarea as HTMLTextAreaElement, 'plain dice'); await clickByText(table as HTMLTableElement, 'post'); diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index 57289ad7..64dea8e0 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -233,6 +233,12 @@ color: red; } +.error a, +.error a:visited { + color: inherit; + text-decoration: underline; +} + .formError { margin: 8px 0; padding: 6px 5px; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index ec794c19..bb05b769 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -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(null); - const [formError, setFormError] = useState(null); + const [formError, setFormError] = useState(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) => { 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: {showBbcodeToolbar ?
warning: posting as moderator
: null} - {formError &&
{formError}
} + {formError ? ( +
+ {isPostOptionsValidationError(formError) ? : formError} +
+ ) : null} {publishPostError &&
{publishPostError}
} {publishReplyError &&
{publishReplyError}
} {publishReplyStateMessage &&
{publishReplyStateMessage}
} diff --git a/src/components/post-options-error-message/post-options-error-message.tsx b/src/components/post-options-error-message/post-options-error-message.tsx new file mode 100644 index 00000000..5b14db9c --- /dev/null +++ b/src/components/post-options-error-message/post-options-error-message.tsx @@ -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 ( + + {separator} + {getDirectoryLabel(code, directories)} + + ); + })} + +); + +const PostOptionsErrorMessage = ({ directories = EMPTY_DIRECTORIES, error }: PostOptionsErrorMessageProps) => { + return ( + <> + Unsupported options: {error.unsupportedOptions.join(', ')}. + {error.supportedDirectoryCodesByOption.map(({ option, directoryCodes }) => ( + + {' '} + Option "{option}" is supported on: . + + ))} + + ); +}; + +export default PostOptionsErrorMessage; diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index c5d6c8c7..984d72d0 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -141,6 +141,7 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({ vi.mock('../../../hooks/use-directories', () => ({ findDirectoryByAddress: (directories: Array<{ address: string; features?: Record; 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('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

Your fortune: Excellent Luck
', }); @@ -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('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('a[href="/qst"]')?.textContent).toBe('/qst/'); + expect(container.querySelector('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('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('a[href="/b"]')?.textContent).toBe('/b/'); + expect(container.querySelector('a[href="/s5s"]')?.textContent).toBe('/s5s/'); await dispatchInput(textarea as HTMLTextAreaElement, 'plain reply'); await clickButtonByText('post'); diff --git a/src/components/reply-modal/reply-modal.module.css b/src/components/reply-modal/reply-modal.module.css index 2aa34743..b9d8939a 100644 --- a/src/components/reply-modal/reply-modal.module.css +++ b/src/components/reply-modal/reply-modal.module.css @@ -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; diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 72ed41ae..5861ab10 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -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(null); + const [error, setError] = useState(null); const [lengthError, setLengthError] = useState(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) => { 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 ? (
{lengthError}
) : error ? ( -
{error}
+
{isPostOptionsValidationError(error) ? : error}
) : ( publishReplyError &&
{publishReplyError}
)} diff --git a/src/lib/utils/__tests__/post-options-utils.test.ts b/src/lib/utils/__tests__/post-options-utils.test.ts index 6961d73d..95f923f3 100644 --- a/src/lib/utils/__tests__/post-options-utils.test.ts +++ b/src/lib/utils/__tests__/post-options-utils.test.ts @@ -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); diff --git a/src/lib/utils/post-options-utils.ts b/src/lib/utils/post-options-utils.ts index 61ad3942..978ceb1a 100644 --- a/src/lib/utils/post-options-utils.ts +++ b/src/lib/utils/post-options-utils.ts @@ -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; + 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');