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');