mirror of
https://github.com/suitenumerique/docs.git
synced 2026-10-21 07:21:45 +02:00
♻️(frontend) extract the mention search from the interlinking
The search opened by the slash menu and by typing @ was a state of the interlinking inline content, so it could only ever propose docs. Give it its own transient mentionSearchInline inline content, hosted in a new Mention folder, parent of every kind of mention. The interlinking becomes one of its kinds and only keeps the doc search group. The persisted interlinkingLinkInline stays unchanged: documents already saved keep their links, and the former search states render nothing. Signed-off-by: Anthony LC <anthony.le-courric@mail.numerique.gouv.fr>
This commit is contained in:
@@ -448,7 +448,7 @@ test.describe('Doc Editor', () => {
|
||||
await page.getByText('Link a doc').first().click();
|
||||
|
||||
const input = page.locator(
|
||||
"span[data-inline-content-type='interlinkingLinkInline'] input",
|
||||
"span[data-inline-content-type='mentionSearchInline'] input",
|
||||
);
|
||||
const searchContainer = page.locator('.quick-search-container');
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ const localesBNAI = BlockNoteAI?.localesAI || {};
|
||||
import { createSafeCodeBlockSpec } from './custom-blocks/CodeBlock';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
MentionSearchInlineContent,
|
||||
getPastedDocInterlink,
|
||||
} from './custom-inline-content';
|
||||
import XLMultiColumn from './xl-multi-column';
|
||||
@@ -93,6 +94,7 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
interlinkingLinkInline: InterlinkingLinkInlineContent,
|
||||
mentionSearchInline: MentionSearchInlineContent,
|
||||
math: createReactInlineMathSpec(),
|
||||
},
|
||||
}),
|
||||
|
||||
+66
-53
@@ -11,11 +11,14 @@ import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management';
|
||||
|
||||
import { LinkSelected } from './LinkSelected';
|
||||
import { SearchPage } from './SearchPage';
|
||||
|
||||
export type InterlinkingLinkInlineContentType = {
|
||||
type: 'interlinkingLinkInline';
|
||||
propSchema: {
|
||||
/**
|
||||
* @deprecated Only set by the former search state of this inline content,
|
||||
* now `mentionSearchInline`. Kept to read the documents saved before.
|
||||
*/
|
||||
disabled?: {
|
||||
default: false;
|
||||
values: [true, false];
|
||||
@@ -26,6 +29,9 @@ export type InterlinkingLinkInlineContentType = {
|
||||
blockId?: {
|
||||
default: '';
|
||||
};
|
||||
/**
|
||||
* @deprecated See `disabled`.
|
||||
*/
|
||||
trigger?: {
|
||||
default: '/';
|
||||
values: readonly ['/', '@'];
|
||||
@@ -59,56 +65,63 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec<
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
/**
|
||||
* Can have 3 render states:
|
||||
* 1. Disabled state: when the inline content is disabled, it renders nothing
|
||||
* 2. Search state: when the inline content has no docId, it renders the search page
|
||||
* 3. Linked state: when the inline content has a docId, it renders the linked doc.
|
||||
*
|
||||
* Info: We keep everything in the same inline content to easily preserve
|
||||
* the element position when switching between states
|
||||
*/
|
||||
render: (props) => {
|
||||
const { disabled, docId, blockId } = props.inlineContent.props;
|
||||
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (docId) {
|
||||
/**
|
||||
* Should not happen
|
||||
*/
|
||||
if (!uuidValidate(docId)) {
|
||||
return (
|
||||
<DisableInvalidInterlink
|
||||
docId={docId}
|
||||
onUpdateInlineContent={() => {
|
||||
props.updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSelected
|
||||
docId={docId}
|
||||
blockId={blockId}
|
||||
isEditable={props.editor.isEditable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SearchPage {...props} />;
|
||||
},
|
||||
render: (props) => (
|
||||
<InterlinkingLink
|
||||
docId={props.inlineContent.props.docId}
|
||||
blockId={props.inlineContent.props.blockId}
|
||||
disabled={props.inlineContent.props.disabled}
|
||||
isEditable={props.editor.isEditable}
|
||||
onDisable={() => {
|
||||
props.updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
interface InterlinkingLinkProps {
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
disabled?: boolean;
|
||||
isEditable: boolean;
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can have 2 render states:
|
||||
* 1. Linked state: when the inline content has a docId, it renders the linked doc.
|
||||
* 2. Empty state: otherwise, it renders nothing. It is what remains in the
|
||||
* documents saved while this inline content was also the search of the
|
||||
* mentions, now `mentionSearchInline`.
|
||||
*/
|
||||
export const InterlinkingLink = ({
|
||||
docId,
|
||||
blockId,
|
||||
disabled,
|
||||
isEditable,
|
||||
onDisable,
|
||||
}: InterlinkingLinkProps) => {
|
||||
if (disabled || !docId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should not happen
|
||||
*/
|
||||
if (!uuidValidate(docId)) {
|
||||
return <DisableInvalidInterlink docId={docId} onDisable={onDisable} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSelected docId={docId} blockId={blockId} isEditable={isEditable} />
|
||||
);
|
||||
};
|
||||
|
||||
export const getInterlinkinghMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
@@ -121,7 +134,7 @@ export const getInterlinkinghMenuItems = (
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: {
|
||||
trigger: '/',
|
||||
},
|
||||
@@ -156,18 +169,18 @@ export const useGetInterlinkingMenuItems = () => {
|
||||
|
||||
const DisableInvalidInterlink = ({
|
||||
docId,
|
||||
onUpdateInlineContent,
|
||||
onDisable,
|
||||
}: {
|
||||
docId: string;
|
||||
onUpdateInlineContent: () => void;
|
||||
onDisable: () => void;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
Sentry.captureException(new Error(`Invalid docId: ${docId}`), {
|
||||
extra: { info: 'InterlinkingInlineContent' },
|
||||
});
|
||||
|
||||
onUpdateInlineContent();
|
||||
}, [docId, onUpdateInlineContent]);
|
||||
onDisable();
|
||||
}, [docId, onDisable]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import DocIcon from '@/assets/icons/ui-kit/doc.svg';
|
||||
import ArrowIcon from '@/assets/icons/ui-kit/keyboard_return.svg';
|
||||
import { Box } from '@/components/Box';
|
||||
import { Text } from '@/components/Text';
|
||||
import { QuickSearchItemContent } from '@/components/quick-search/QuickSearchItemContent';
|
||||
import { DocSearchContent } from '@/docs/doc-search';
|
||||
import { type DocSearch } from '@/docs/doc-search/api/useSearchDocs';
|
||||
import { useTrans } from '@/features/docs/doc-management/hooks/useTrans';
|
||||
import { type Doc } from '@/features/docs/doc-management/types';
|
||||
import { getEmojiAndTitle } from '@/features/docs/doc-management/utils';
|
||||
|
||||
import { type MentionedInlineContent } from '../types';
|
||||
|
||||
interface InterlinkingSearchGroupProps {
|
||||
search: string;
|
||||
onPick: (inlineContent: MentionedInlineContent) => void;
|
||||
/** Separate the docs from the section above. */
|
||||
withSeparator?: boolean;
|
||||
onResults?: (results: DocSearch[]) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const InterlinkingSearchGroup = ({
|
||||
search,
|
||||
onPick,
|
||||
withSeparator,
|
||||
onResults,
|
||||
onLoadingChange,
|
||||
}: InterlinkingSearchGroupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { untitledDocument } = useTrans();
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{withSeparator && (
|
||||
<Box
|
||||
role="separator"
|
||||
$margin={{ vertical: 'xxs' }}
|
||||
$css={css`
|
||||
border-top: 1px solid
|
||||
var(--c--contextuals--border--surface--primary);
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
<DocSearchContent
|
||||
groupName={t('Link a doc')}
|
||||
hideGroupNameWhenEmpty
|
||||
onResults={onResults}
|
||||
onLoadingChange={onLoadingChange}
|
||||
search={search}
|
||||
parentDocId={treeContext?.root?.id}
|
||||
isSearchNotMandatory
|
||||
onSelect={(doc) => {
|
||||
onPick({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { docId: doc.id },
|
||||
});
|
||||
}}
|
||||
renderSearchElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.2rem"
|
||||
$align="center"
|
||||
$padding={{
|
||||
vertical: '0.5rem',
|
||||
horizontal: '0.2rem',
|
||||
}}
|
||||
$width="100%"
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
{emoji ? (
|
||||
<Text $size="18px">{emoji}</Text>
|
||||
) : (
|
||||
<DocIcon
|
||||
aria-hidden="true"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
$size="sm"
|
||||
$color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
spellCheck="false"
|
||||
$weight="500"
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<ArrowIcon
|
||||
aria-hidden="true"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="var(--c--contextuals--content--semantic--neutral--tertiary)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { InterlinkingLink } from '../InterlinkingLinkInlineContent';
|
||||
|
||||
vi.mock('../LinkSelected', () => ({
|
||||
LinkSelected: ({ docId, blockId }: { docId: string; blockId?: string }) => (
|
||||
<span data-testid="link-selected">{`${docId}#${blockId ?? ''}`}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
const DOC_ID = '6f1e4c9a-2f6b-4a3e-9d5c-1b2a3c4d5e6f';
|
||||
|
||||
/**
|
||||
* The documents saved before the search of the mentions got its own inline
|
||||
* content still hold `interlinkingLinkInline` in its former search states.
|
||||
*/
|
||||
describe('InterlinkingLink', () => {
|
||||
it('renders the linked doc', () => {
|
||||
render(
|
||||
<InterlinkingLink
|
||||
docId={DOC_ID}
|
||||
blockId="block-1"
|
||||
isEditable
|
||||
onDisable={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('link-selected')).toHaveTextContent(
|
||||
`${DOC_ID}#block-1`,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nothing for the former search state, without docId', () => {
|
||||
const { container } = render(
|
||||
<InterlinkingLink docId="" isEditable onDisable={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing for the former closed search state, disabled', () => {
|
||||
const { container } = render(
|
||||
<InterlinkingLink docId="" disabled isEditable onDisable={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('disables a link to an invalid doc id', async () => {
|
||||
const onDisable = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<InterlinkingLink docId="not-a-uuid" isEditable onDisable={onDisable} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
await waitFor(() => expect(onDisable).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -26,7 +26,7 @@ const t = ((key: string) => key) as unknown as Parameters<
|
||||
>[1];
|
||||
|
||||
describe('getInterlinkinghMenuItems', () => {
|
||||
it('inserts an interlink inline content when the "link-doc" item is clicked', () => {
|
||||
it('inserts the mention search inline content when the "link-doc" item is clicked', () => {
|
||||
const insertInlineContent = vi.fn();
|
||||
const editor = { insertInlineContent } as unknown as DocsBlockNoteEditor;
|
||||
const createPage = vi.fn();
|
||||
@@ -38,7 +38,7 @@ describe('getInterlinkinghMenuItems', () => {
|
||||
|
||||
expect(insertInlineContent).toHaveBeenCalledWith([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: { trigger: '/' },
|
||||
},
|
||||
]);
|
||||
+1
@@ -1,2 +1,3 @@
|
||||
export * from './InterlinkingLinkInlineContent';
|
||||
export * from './InterlinkingSearchGroup';
|
||||
export * from './pasteInterlink';
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { StyleSchema } from '@blocknote/core';
|
||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||
|
||||
import { Search } from './Search';
|
||||
|
||||
export type MentionSearchInlineContentType = {
|
||||
type: 'mentionSearchInline';
|
||||
propSchema: {
|
||||
disabled?: {
|
||||
default: false;
|
||||
values: [true, false];
|
||||
};
|
||||
trigger?: {
|
||||
default: '/';
|
||||
values: readonly ['/', '@'];
|
||||
};
|
||||
};
|
||||
content: 'none';
|
||||
};
|
||||
|
||||
/**
|
||||
* Transient inline content hosting the search of a mention. It replaces
|
||||
* itself by the inline content of the kind of mention the user picked.
|
||||
*
|
||||
* Info: it is the parent of every kind of mention (Interlinking, ...), they
|
||||
* do not know about it. Keep the type of the mentions themselves unchanged,
|
||||
* they are persisted in the documents.
|
||||
*/
|
||||
export const MentionSearchInlineContent = createReactInlineContentSpec<
|
||||
MentionSearchInlineContentType,
|
||||
StyleSchema
|
||||
>(
|
||||
{
|
||||
type: 'mentionSearchInline',
|
||||
propSchema: {
|
||||
disabled: {
|
||||
default: false,
|
||||
values: [true, false],
|
||||
},
|
||||
trigger: {
|
||||
default: '/',
|
||||
values: ['/', '@'],
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
if (props.inlineContent.props.disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Search {...props} />;
|
||||
},
|
||||
},
|
||||
);
|
||||
+29
-99
@@ -1,28 +1,19 @@
|
||||
import { StyleSchema } from '@blocknote/core';
|
||||
import { ReactCustomInlineContentRenderProps } from '@blocknote/react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-components';
|
||||
import { Popover } from '@mantine/core';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import DocIcon from '@/assets/icons/ui-kit/doc.svg';
|
||||
import ArrowIcon from '@/assets/icons/ui-kit/keyboard_return.svg';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
QuickSearch,
|
||||
QuickSearchItemContent,
|
||||
Text,
|
||||
} from '@/components';
|
||||
import { Box, Card, QuickSearch } from '@/components';
|
||||
import { DocsBlockNoteEditor } from '@/docs/doc-editor/types';
|
||||
import { Doc, getEmojiAndTitle, useTrans } from '@/docs/doc-management';
|
||||
import { DocSearchContent } from '@/docs/doc-search';
|
||||
import { useDocSearchFilterStore } from '@/docs/doc-search/stores/useDocSearchFilterStore';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { InterlinkingLinkInlineContentType } from './InterlinkingLinkInlineContent';
|
||||
import { InterlinkingSearchGroup } from './Interlinking';
|
||||
import { MentionSearchInlineContentType } from './MentionSearchInlineContent';
|
||||
import { MentionedInlineContent } from './types';
|
||||
|
||||
const inputStyle = css`
|
||||
background-color: transparent;
|
||||
@@ -34,32 +25,37 @@ const inputStyle = css`
|
||||
font-family: 'Inter';
|
||||
`;
|
||||
|
||||
type ReactInterlinkingSearch = ReactCustomInlineContentRenderProps<
|
||||
InterlinkingLinkInlineContentType,
|
||||
type ReactMentionSearch = ReactCustomInlineContentRenderProps<
|
||||
MentionSearchInlineContentType,
|
||||
StyleSchema
|
||||
>;
|
||||
|
||||
export const SearchPage = ({
|
||||
/**
|
||||
* The search replaces itself by the content the user picked. At runtime
|
||||
* `updateInlineContent` accepts any inline content, but it is typed with the
|
||||
* one of the search.
|
||||
*/
|
||||
type ReplaceByMentioned = (inlineContent: MentionedInlineContent) => void;
|
||||
|
||||
export const Search = ({
|
||||
contentRef,
|
||||
updateInlineContent,
|
||||
editor,
|
||||
inlineContent,
|
||||
}: ReactInterlinkingSearch) => {
|
||||
}: ReactMentionSearch) => {
|
||||
const trigger = inlineContent.props.trigger;
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownId = useId();
|
||||
const [popoverOpened, setPopoverOpened] = useState(false);
|
||||
const { setFilter } = useDocSearchFilterStore();
|
||||
|
||||
/**
|
||||
* When the search page is opened, we set the search
|
||||
* When the search is opened, we set the search
|
||||
* target to 'current' to limit the search to the current
|
||||
* document and its sub-documents.
|
||||
*/
|
||||
@@ -91,7 +87,7 @@ export const SearchPage = ({
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: {
|
||||
disabled: true,
|
||||
},
|
||||
@@ -106,6 +102,17 @@ export const SearchPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
const pick = (mentioned: MentionedInlineContent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
(updateInlineContent as unknown as ReplaceByMentioned)(mentioned);
|
||||
|
||||
contentRef(null);
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
@@ -260,84 +267,7 @@ export const SearchPage = ({
|
||||
$margin="sm"
|
||||
$padding="none"
|
||||
>
|
||||
<DocSearchContent
|
||||
groupName={t('Link a doc')}
|
||||
search={search}
|
||||
parentDocId={treeContext?.root?.id}
|
||||
isSearchNotMandatory
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateInlineContent({
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
docId: doc.id,
|
||||
},
|
||||
});
|
||||
|
||||
contentRef(null);
|
||||
editor.focus();
|
||||
}}
|
||||
renderSearchElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickSearchItemContent
|
||||
left={
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap="0.2rem"
|
||||
$align="center"
|
||||
$padding={{
|
||||
vertical: '0.5rem',
|
||||
horizontal: '0.2rem',
|
||||
}}
|
||||
$width="100%"
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
width: 24px;
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
>
|
||||
{emoji ? (
|
||||
<Text $size="18px">{emoji}</Text>
|
||||
) : (
|
||||
<DocIcon
|
||||
aria-hidden="true"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
$size="sm"
|
||||
$color="var(--c--contextuals--content--semantic--neutral--primary)"
|
||||
spellCheck="false"
|
||||
$weight="500"
|
||||
>
|
||||
{titleWithoutEmoji}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
right={
|
||||
<ArrowIcon
|
||||
aria-hidden="true"
|
||||
width="24px"
|
||||
height="24px"
|
||||
color="var(--c--contextuals--content--semantic--neutral--tertiary)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<InterlinkingSearchGroup search={search} onPick={pick} />
|
||||
</Card>
|
||||
</QuickSearch>
|
||||
</Box>
|
||||
+18
-19
@@ -41,7 +41,7 @@ vi.mock('@/docs/doc-search', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { SearchPage } from '../SearchPage';
|
||||
import { Search } from '../Search';
|
||||
|
||||
// cmdk and Mantine rely on browser APIs jsdom doesn't implement.
|
||||
beforeEach(() => {
|
||||
@@ -75,7 +75,7 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const renderSearchPage = async ({ isEditable = true, trigger = '/' } = {}) => {
|
||||
const renderSearch = async ({ isEditable = true, trigger = '/' } = {}) => {
|
||||
const updateInlineContent = vi.fn();
|
||||
const contentRef = vi.fn();
|
||||
const insertInlineContent = vi.fn();
|
||||
@@ -88,12 +88,12 @@ const renderSearchPage = async ({ isEditable = true, trigger = '/' } = {}) => {
|
||||
} as unknown as DocsBlockNoteEditor;
|
||||
|
||||
render(
|
||||
<SearchPage
|
||||
<Search
|
||||
editor={editor as any}
|
||||
inlineContent={
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: { trigger, disabled: false, docId: '' },
|
||||
type: 'mentionSearchInline',
|
||||
props: { trigger, disabled: false },
|
||||
} as any
|
||||
}
|
||||
updateInlineContent={updateInlineContent}
|
||||
@@ -105,13 +105,13 @@ const renderSearchPage = async ({ isEditable = true, trigger = '/' } = {}) => {
|
||||
{ wrapper: Wrapper },
|
||||
);
|
||||
|
||||
// SearchPage focuses the input and opens the popover after a 100ms timeout.
|
||||
// Search focuses the input and opens the popover after a 100ms timeout.
|
||||
await waitFor(() => expect(screen.getByRole('combobox')).toHaveFocus());
|
||||
|
||||
return { updateInlineContent, contentRef, insertInlineContent, focus };
|
||||
};
|
||||
|
||||
describe('SearchPage', () => {
|
||||
describe('Search', () => {
|
||||
beforeEach(() => {
|
||||
capturedProps.length = 0;
|
||||
useDocSearchFilterStore.setState({ filter: 'all' });
|
||||
@@ -122,20 +122,20 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('limits the search to the current doc subtree on mount', async () => {
|
||||
await renderSearchPage();
|
||||
await renderSearch();
|
||||
|
||||
expect(useDocSearchFilterStore.getState().filter).toBe('current');
|
||||
});
|
||||
|
||||
it('renders the trigger character and focuses the search input', async () => {
|
||||
await renderSearchPage({ trigger: '@' });
|
||||
await renderSearch({ trigger: '@' });
|
||||
|
||||
expect(screen.getByText('@')).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('forwards the typed text to the search results', async () => {
|
||||
await renderSearchPage();
|
||||
await renderSearch();
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'my query' },
|
||||
@@ -150,7 +150,7 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('selects a result and inserts the interlink', async () => {
|
||||
const { updateInlineContent, contentRef, focus } = await renderSearchPage();
|
||||
const { updateInlineContent, contentRef, focus } = await renderSearch();
|
||||
|
||||
fireEvent.click(await screen.findByText('First result'));
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('ignores a selection when the editor is not editable', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage({
|
||||
const { updateInlineContent } = await renderSearch({
|
||||
isEditable: false,
|
||||
});
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('SearchPage', () => {
|
||||
|
||||
it('closes and re-inserts the trigger and typed text on Escape', async () => {
|
||||
const { updateInlineContent, insertInlineContent, focus } =
|
||||
await renderSearchPage({ trigger: '/' });
|
||||
await renderSearch({ trigger: '/' });
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'abc' },
|
||||
@@ -182,7 +182,7 @@ describe('SearchPage', () => {
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Escape' });
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: { disabled: true },
|
||||
});
|
||||
expect(focus).toHaveBeenCalled();
|
||||
@@ -190,20 +190,19 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('closes without inserting anything on Backspace when the search is empty', async () => {
|
||||
const { updateInlineContent, insertInlineContent } =
|
||||
await renderSearchPage();
|
||||
const { updateInlineContent, insertInlineContent } = await renderSearch();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'Backspace' });
|
||||
|
||||
expect(updateInlineContent).toHaveBeenCalledWith({
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: { disabled: true },
|
||||
});
|
||||
expect(insertInlineContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a Backspace with existing text fall through to normal editing', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage();
|
||||
const { updateInlineContent } = await renderSearch();
|
||||
|
||||
fireEvent.input(screen.getByRole('combobox'), {
|
||||
target: { value: 'abc' },
|
||||
@@ -214,7 +213,7 @@ describe('SearchPage', () => {
|
||||
});
|
||||
|
||||
it('moves the highlighted result with ArrowDown/ArrowUp and selects it on Enter', async () => {
|
||||
const { updateInlineContent } = await renderSearchPage();
|
||||
const { updateInlineContent } = await renderSearch();
|
||||
|
||||
await screen.findByText('First result');
|
||||
const input = screen.getByRole('combobox');
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from './Interlinking';
|
||||
export * from './MentionSearchInlineContent';
|
||||
export * from './types';
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* What the mention search can be replaced by once the user picked an item.
|
||||
* Each kind of mention (Interlinking, ...) adds its own inline content here.
|
||||
*/
|
||||
export type MentionedInlineContent = {
|
||||
type: 'interlinkingLinkInline';
|
||||
props: { docId: string };
|
||||
};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export * from './Interlinking';
|
||||
export * from './Mention';
|
||||
|
||||
@@ -102,7 +102,7 @@ export const useShortcuts = (
|
||||
event.preventDefault();
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
type: 'mentionSearchInline',
|
||||
props: {
|
||||
trigger: '@',
|
||||
},
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { TextRun } from 'docx';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
inlineContentMappingMentionSearchDocx,
|
||||
inlineContentMappingMentionSearchODT,
|
||||
inlineContentMappingMentionSearchPDF,
|
||||
} from '../inline-content-mapping';
|
||||
|
||||
const search = {
|
||||
type: 'mentionSearchInline' as const,
|
||||
props: { trigger: '@', disabled: false },
|
||||
};
|
||||
|
||||
// The exporter is only needed by the mappings that render nested content.
|
||||
const exporter = {} as never;
|
||||
|
||||
describe('mention search export mappings', () => {
|
||||
it('exports nothing in ODT', () => {
|
||||
expect(
|
||||
inlineContentMappingMentionSearchODT(search as never, exporter),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('exports an empty text run in Docx', () => {
|
||||
const run = inlineContentMappingMentionSearchDocx(
|
||||
search as never,
|
||||
exporter,
|
||||
);
|
||||
|
||||
expect(run).toBeInstanceOf(TextRun);
|
||||
expect(JSON.stringify(run)).not.toContain('@');
|
||||
});
|
||||
|
||||
it('exports nothing in PDF', () => {
|
||||
const element = inlineContentMappingMentionSearchPDF(
|
||||
search as never,
|
||||
exporter,
|
||||
) as unknown as React.ReactElement<{ children?: unknown }>;
|
||||
|
||||
expect(element.props.children).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from './interlinkingLinkPDF';
|
||||
export * from './interlinkingLinkDocx';
|
||||
export * from './interlinkingLinkODT';
|
||||
export * from './mentionSearchPDF';
|
||||
export * from './mentionSearchDocx';
|
||||
export * from './mentionSearchODT';
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { TextRun } from 'docx';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
|
||||
/**
|
||||
* The search of a mention is transient, there is nothing to export.
|
||||
*/
|
||||
export const inlineContentMappingMentionSearchDocx: DocsExporterDocx['mappings']['inlineContentMapping']['mentionSearchInline'] =
|
||||
() => new TextRun('');
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { DocsExporterODT } from '../types';
|
||||
|
||||
/**
|
||||
* The search of a mention is transient, there is nothing to export.
|
||||
*/
|
||||
export const inlineContentMappingMentionSearchODT: DocsExporterODT['mappings']['inlineContentMapping']['mentionSearchInline'] =
|
||||
() => null;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
/**
|
||||
* The search of a mention is transient, there is nothing to export.
|
||||
*/
|
||||
export const inlineContentMappingMentionSearchPDF: DocsExporterPDF['mappings']['inlineContentMapping']['mentionSearchInline'] =
|
||||
() => <></>;
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
blockMappingQuoteDocx,
|
||||
blockMappingUploadLoaderDocx,
|
||||
} from './blocks-mapping';
|
||||
import { createInlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping';
|
||||
import {
|
||||
createInlineContentMappingInterlinkingLinkDocx,
|
||||
inlineContentMappingMentionSearchDocx,
|
||||
} from './inline-content-mapping';
|
||||
import { DocsExporterDocx } from './types';
|
||||
|
||||
export const getDocxDocsSchemaMappings = (
|
||||
@@ -60,6 +63,7 @@ export const getDocxDocsSchemaMappings = (
|
||||
...docxDefaultSchemaMappings.inlineContentMapping,
|
||||
interlinkingLinkInline:
|
||||
createInlineContentMappingInterlinkingLinkDocx(interlinkTitles),
|
||||
mentionSearchInline: inlineContentMappingMentionSearchDocx,
|
||||
// Renders inline math as a native (editable) Word equation.
|
||||
math: inlineMathMapping,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
blockMappingImageODT,
|
||||
blockMappingUploadLoaderODT,
|
||||
} from './blocks-mapping';
|
||||
import { createInlineContentMappingInterlinkingLinkODT } from './inline-content-mapping';
|
||||
import {
|
||||
createInlineContentMappingInterlinkingLinkODT,
|
||||
inlineContentMappingMentionSearchODT,
|
||||
} from './inline-content-mapping';
|
||||
import { DocsExporterODT } from './types';
|
||||
|
||||
// Align default inline mappings to our editor inline schema without using `any`
|
||||
@@ -40,6 +43,7 @@ export const getOdtDocsSchemaMappings = (
|
||||
...baseInlineMappings,
|
||||
interlinkingLinkInline:
|
||||
createInlineContentMappingInterlinkingLinkODT(interlinkTitles),
|
||||
mentionSearchInline: inlineContentMappingMentionSearchODT,
|
||||
// Renders inline math as a native (editable) ODF formula object.
|
||||
math: inlineMathMapping,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
blockMappingTablePDF,
|
||||
blockMappingUploadLoaderPDF,
|
||||
} from './blocks-mapping';
|
||||
import { createInlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping';
|
||||
import {
|
||||
createInlineContentMappingInterlinkingLinkPDF,
|
||||
inlineContentMappingMentionSearchPDF,
|
||||
} from './inline-content-mapping';
|
||||
import { DocsExporterPDF } from './types';
|
||||
|
||||
export const getPdfDocsSchemaMappings = (
|
||||
@@ -43,6 +46,7 @@ export const getPdfDocsSchemaMappings = (
|
||||
...pdfDefaultSchemaMappings.inlineContentMapping,
|
||||
interlinkingLinkInline:
|
||||
createInlineContentMappingInterlinkingLinkPDF(interlinkTitles),
|
||||
mentionSearchInline: inlineContentMappingMentionSearchPDF,
|
||||
// Inline math is rasterized to an image that flows with the text.
|
||||
math: inlineMathMapping,
|
||||
},
|
||||
|
||||
+5
-1
@@ -13,6 +13,7 @@ import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchContentProps = {
|
||||
groupName?: string;
|
||||
hideGroupNameWhenEmpty?: boolean;
|
||||
search: string;
|
||||
filterResults?: (doc: DocSearch) => boolean;
|
||||
isSearchNotMandatory?: boolean;
|
||||
@@ -25,6 +26,7 @@ type DocSearchContentProps = {
|
||||
|
||||
export const DocSearchContent = ({
|
||||
groupName,
|
||||
hideGroupNameWhenEmpty,
|
||||
search,
|
||||
filterResults,
|
||||
onResults,
|
||||
@@ -82,7 +84,8 @@ export const DocSearchContent = ({
|
||||
onResults?.(elements);
|
||||
|
||||
setDocsData({
|
||||
groupName: groupName,
|
||||
groupName:
|
||||
hideGroupNameWhenEmpty && elements.length === 0 ? '' : groupName,
|
||||
groupKey: 'docs',
|
||||
elements,
|
||||
endActions: hasNextPage
|
||||
@@ -111,6 +114,7 @@ export const DocSearchContent = ({
|
||||
data?.pages,
|
||||
filterResults,
|
||||
groupName,
|
||||
hideGroupNameWhenEmpty,
|
||||
isSearchNotMandatory,
|
||||
loading,
|
||||
hasNextPage,
|
||||
|
||||
Reference in New Issue
Block a user