fix(post options): link sage warning to FAQ

This commit is contained in:
Tommaso Casaburi
2026-06-02 12:16:22 +07:00
parent 342a8f53a7
commit 75c805c822
13 changed files with 165 additions and 21 deletions
+2 -12
View File
@@ -7,7 +7,7 @@ import { usePostPageNumber } from '../../hooks/use-post-page-number';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { getBoardPath, isDirectoryRoute, isFlashBoardRoute } from '../../lib/utils/route-utils';
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
@@ -30,6 +30,7 @@ import Tooltip from '../tooltip';
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
import { shouldShowCatalogButton } from './catalog-button-utils';
import styles from './board-buttons.module.css';
import capitalize from 'lodash/capitalize';
@@ -45,17 +46,6 @@ interface BoardButtonsProps {
const EMPTY_COMMUNITY_ADDRESSES: string[] = [];
export const shouldShowCatalogButton = (
boardIdentifier: string | undefined,
directories: ReturnType<typeof useDirectories>,
{ isInAllView, isInSubscriptionsView, isInModView }: Pick<BoardButtonsProps, 'isInAllView' | 'isInSubscriptionsView' | 'isInModView'>,
): boolean => {
if (isInAllView || isInSubscriptionsView || isInModView) {
return true;
}
return !isFlashBoardRoute(boardIdentifier, directories);
};
const getMultiboardPath = ({
isInAllView,
isInCatalogView,
@@ -0,0 +1,19 @@
import type { DirectoryCommunity } from '../../hooks/use-directories';
import { isFlashBoardRoute } from '../../lib/utils/route-utils';
interface CatalogButtonVisibilityContext {
isInAllView?: boolean;
isInSubscriptionsView?: boolean;
isInModView?: boolean;
}
export const shouldShowCatalogButton = (
boardIdentifier: string | undefined,
directories: DirectoryCommunity[],
{ isInAllView, isInSubscriptionsView, isInModView }: CatalogButtonVisibilityContext,
): boolean => {
if (isInAllView || isInSubscriptionsView || isInModView) {
return true;
}
return !isFlashBoardRoute(boardIdentifier, directories);
};
@@ -70,7 +70,6 @@ vi.mock('../../board-buttons/board-buttons', () => ({
isInModView?: boolean;
isInSubscriptionsView?: boolean;
}) => createElement('button', { 'data-testid': 'catalog-button', type: 'button' }, `${address}|${isInAllView}|${isInSubscriptionsView}|${isInModView}`),
shouldShowCatalogButton: () => true,
PostPageStats: () => createElement('div', { 'data-testid': 'post-page-stats' }, 'post-page-stats'),
RefreshButton: () => createElement('button', { type: 'button' }, 'refresh-button'),
ReturnButton: ({
@@ -88,6 +87,10 @@ vi.mock('../../board-buttons/board-buttons', () => ({
UpdateButton: () => createElement('button', { type: 'button' }, 'update-button'),
}));
vi.mock('../../board-buttons/catalog-button-utils', () => ({
shouldShowCatalogButton: () => true,
}));
vi.mock('../../../stores/use-reply-modal-store', () => ({
default: () => ({
openReplyModalEmpty: testState.openReplyModalEmptyMock,
+1 -1
View File
@@ -8,13 +8,13 @@ import {
CatalogSearchResultsLabel,
ReturnButton,
CatalogButton,
shouldShowCatalogButton,
TopButton,
UpdateButton,
AutoButton,
PostPageStats,
RefreshButton,
} from '../board-buttons/board-buttons';
import { shouldShowCatalogButton } from '../board-buttons/catalog-button-utils';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
@@ -930,7 +930,8 @@ describe('PostForm', () => {
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.textContent).toContain('Unsupported options: sage [learn why], fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(container.querySelector<HTMLAnchorElement>('a[href="/faq#sage"]')?.textContent).toBe('learn why');
expect(container.querySelector<HTMLAnchorElement>('a[href="/b"]')?.textContent).toBe('/b/');
expect(container.querySelector<HTMLAnchorElement>('a[href="/s5s"]')?.textContent).toBe('/s5s/');
});
@@ -1,7 +1,8 @@
import { Fragment } from 'react';
import { HashLink } from 'react-router-hash-link';
import { Link } from 'react-router-dom';
import type { DirectoryCommunity } from '../../hooks/use-directories';
import type { PostOptionsValidationError } from '../../lib/utils/post-options-utils';
import { SAGE_FAQ_LINK_LABEL, SAGE_FAQ_PATH, SAGE_OPTION, type PostOptionsValidationError } from '../../lib/utils/post-options-utils';
interface PostOptionsErrorMessageProps {
directories?: DirectoryCommunity[];
@@ -33,10 +34,33 @@ const DirectoryLinks = ({ codes, directories }: { codes: string[]; directories:
</>
);
const UnsupportedOption = ({ option }: { option: string }) => (
<>
{option}
{option === SAGE_OPTION && (
<>
{' '}
[<HashLink to={SAGE_FAQ_PATH}>{SAGE_FAQ_LINK_LABEL}</HashLink>]
</>
)}
</>
);
const UnsupportedOptions = ({ options }: { options: string[] }) => (
<>
{options.map((option, index) => (
<Fragment key={`${option}-${index}`}>
{index === 0 ? '' : ', '}
<UnsupportedOption option={option} />
</Fragment>
))}
</>
);
const PostOptionsErrorMessage = ({ directories = EMPTY_DIRECTORIES, error }: PostOptionsErrorMessageProps) => {
return (
<>
Unsupported options: {error.unsupportedOptions.join(', ')}.
Unsupported options: <UnsupportedOptions options={error.unsupportedOptions} />.
{error.supportedDirectoryCodesByOption.map(({ option, directoryCodes }) => (
<Fragment key={option}>
{' '}
@@ -657,6 +657,21 @@ describe('ReplyModal', () => {
randomSpy.mockRestore();
});
it('links the unsupported sage option to its FAQ entry in reply modal', async () => {
testState.openEmpty = true;
testState.selectedText = '';
await renderReplyModal('/b/thread/post-1', 'random-nsfw.bso');
const optionsInput = container.querySelectorAll<HTMLInputElement>('input[type="text"]')[1];
await dispatchInput(optionsInput, 'sage');
await waitForOptionsValidation();
expect(container.textContent).toContain('Unsupported options: sage [learn why].');
expect(container.querySelector<HTMLAnchorElement>('a[href="/faq#sage"]')?.textContent).toBe('learn why');
});
it('supports fortune on the /s5s/ route when directory metadata is not loaded', async () => {
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.25);
testState.openEmpty = true;
@@ -14,7 +14,7 @@ describe('post-options-utils', () => {
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 [learn why].');
expect(hasNonokoOption('fortune nonoko')).toBe(true);
expect(hasNonokoOption('nonokosage')).toBe(false);
});
@@ -25,7 +25,7 @@ describe('post-options-utils', () => {
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(getUnsupportedPostOptionsMessage('sage fortune', 'pol')).toBe('Unsupported options: sage [learn why], fortune. Option "fortune" is supported on: /b/, /s5s/.');
expect(getPostOptionsValidationError('fortune dice+1d6', 'mu')).toEqual({
unsupportedOptions: ['fortune', 'dice+1d6'],
supportedDirectoryCodesByOption: [
+5 -1
View File
@@ -1,4 +1,7 @@
export const POST_OPTIONS_VALIDATION_DELAY_MS = 700;
export const SAGE_FAQ_LINK_LABEL = 'learn why';
export const SAGE_FAQ_PATH = '/faq#sage';
export const SAGE_OPTION = 'sage';
const FORTUNE_DIRECTORY_CODES = new Set(['b', 's5s']);
const DICE_DIRECTORY_CODES = new Set(['qst', 'tg']);
const POST_OPTION_ROUTE_DIRECTORY_CODES = new Set([...FORTUNE_DIRECTORY_CODES, ...DICE_DIRECTORY_CODES]);
@@ -150,7 +153,8 @@ export const getUnsupportedPostOptionsMessage = (value: string, directoryCode: s
return null;
}
let message = `Unsupported options: ${error.unsupportedOptions.join(', ')}.`;
const unsupportedOptionsLabel = error.unsupportedOptions.map((option) => (option === SAGE_OPTION ? `${option} [${SAGE_FAQ_LINK_LABEL}]` : option)).join(', ');
let message = `Unsupported options: ${unsupportedOptionsLabel}.`;
for (const { option, directoryCodes } of error.supportedDirectoryCodesByOption) {
message += ` Option "${option}" is supported on: ${directoryCodes.map((code) => `/${code}/`).join(', ')}.`;
@@ -79,6 +79,7 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
}));
vi.mock('../../../components/board-buttons/board-buttons', () => ({
BracketedCatalogButton: () => createElement('span', null, '[', createElement('button', { type: 'button' }, 'catalog'), ']'),
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
CatalogButton: () => createElement('button', { type: 'button' }, 'catalog'),
ReturnButton: () => createElement('button', { type: 'button' }, 'return'),
@@ -68,6 +68,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
}));
vi.mock('../../../components/board-buttons/board-buttons', () => ({
BracketedCatalogButton: () => createElement('span', null, '[', createElement('a', null, 'catalog'), ']'),
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
CatalogButton: () => createElement('a', null, 'catalog'),
ReturnButton: () => createElement('a', null, 'return'),