diff --git a/.gitignore b/.gitignore
index 5aa7e6c8..9f3a440e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@ test/lib/components/integration/__screenshots__/*
# test attachments
.vitest-attachments
+.vitest/
diff --git a/README.md b/README.md
index 029e029d..5cdc78a2 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,16 @@ initializeOpBlockNoteExtensions({
});
```
+Optionally, you can pass a `currentUserId`: the id of the user editing the document. With `UserMentionAuthorExtension` registered on the editor (see below), every user mention inserted there - picked, pasted or duplicated - is stored with a `data-mentioned-by` attribute naming that user, while moved mentions and those from collaborators keep their author. Without a `currentUserId`, inserted mentions get no author.
+
+```js
+initializeOpBlockNoteExtensions({
+ baseUrl: 'https://my.openproject.url',
+ locale: 'en',
+ currentUserId: 4,
+});
+```
+
Then set up a BlockNote schema extending it with the block and inline specs:
```tsx
@@ -71,15 +81,16 @@ const schema = BlockNoteSchema.create().extend({
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
+ openProjectUserMention: openProjectUserMentionSpec,
},
});
type EditorType = typeof schema.BlockNoteEditor;
```
-Create the editor:
+Create the editor with `UserMentionAuthorExtension`, which BlockNote does not pick up from the inline content specs:
```tsx
-const editor = useCreateBlockNote({ schema });
+const editor = useCreateBlockNote({ schema, extensions: [UserMentionAuthorExtension] });
```
Build the slash menu:
@@ -110,6 +121,8 @@ Where the host builds the `#` menu itself, `useHashWpMenu(editor)` hands back th
The create form is built from the work package form endpoint of the API, so the attributes it asks for - and their labels - come from the OpenProject instance: subject, project, type, assignee, plus every other attribute the selected type requires. Attributes the API already has a default for (status and priority, for instance) are left to it and are not shown, required or not.
+`OpenProjectMentionMenu` is the `@` command for mentioning a user: it searches the users who are not locked and are members of the document's project by name, login or email. It opens only where a word starts, so an `@` within an email address stays text. The picked user becomes an inline mention, which is stored as OpenProject's `` tag. Render it beside the editor.
+
Include everything in a `BlockNoteView`:
```tsx
@@ -121,6 +134,7 @@ return (
getItems={getSlashItems}
/>
+
);
```
diff --git a/lib/components/BlockWorkPackage/BlockCards.tsx b/lib/components/BlockWorkPackage/BlockCards.tsx
index 8cb7e87a..517bc861 100644
--- a/lib/components/BlockWorkPackage/BlockCards.tsx
+++ b/lib/components/BlockWorkPackage/BlockCards.tsx
@@ -1,8 +1,8 @@
import styled from 'styled-components';
import type { WorkPackage } from '../../openProjectTypes';
import { linkToWorkPackage } from '../../services/openProjectApi';
+import { editorThemeVariables } from '../shared/theme';
import {
- defaultWpVariables,
WorkPackageId,
WorkPackageType,
WorkPackageStatus,
@@ -50,7 +50,7 @@ function buildTitle(workPackage:WorkPackage, linkTitle:boolean) {
}
const CardBase = styled.div<{ $inDropdown:boolean }>`
- ${defaultWpVariables}
+ ${editorThemeVariables}
padding: var(--spacer-m) var(--spacer-l);
border-radius: var(--bn-border-radius);
diff --git a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx
index 8799f886..53d1f608 100644
--- a/lib/components/BlockWorkPackage/BlockWorkPackage.tsx
+++ b/lib/components/BlockWorkPackage/BlockWorkPackage.tsx
@@ -1,6 +1,6 @@
import type { BlockNoteEditor, SideMenuExtension } from '@blocknote/core';
import { useSelectedBlocks } from '@blocknote/react';
-import { useEffect, useRef, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { useWorkPackage } from '../../hooks/useWorkPackage';
import { useColors } from '../../services/colors';
@@ -14,20 +14,20 @@ import { UnavailableCard } from '../WorkPackage/UnavailableCard';
import { WpOptionsPopover } from '../WorkPackage/OptionsPopover';
import { WorkPackageSearchPopover } from '../Search/WorkPackageSearchPopover';
import { CreateWorkPackageModal } from '../CreateWorkPackage';
-import { defaultWpVariables, nonSelectableStyles } from '../WorkPackage/atoms';
-import { CHIP_STYLES } from '../WorkPackage/tokens';
+import { BLOCK_FOCUS_SHADOW, editorThemeVariables, nonSelectableStyles } from '../shared/theme';
import { moveCursorAfterBlock } from '../../utils/cursor';
import { hideSafariPhantomSelection, selectBlockNode } from '../../utils/selection';
import { pendingBlockRegistry } from './pendingBlockRegistry';
import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar';
+import { usePressOutside } from '../../hooks/usePressOutside';
import { useTapActivation } from '../../utils/tapActivation';
const Block = styled.div.attrs({ className: 'op-bn-extensions', 'data-testid': 'block-wp-wrapper' })<{ $pending?:boolean; $selected?:boolean }>`
- ${defaultWpVariables}
+ ${editorThemeVariables}
background-color: ${({ $pending }) => ($pending ? 'transparent' : 'var(--op-chip-bg)')};
${nonSelectableStyles}
border-radius: var(--bn-border-radius);
- box-shadow: ${({ $selected }) => ($selected ? CHIP_STYLES.focusShadow : 'none')};
+ box-shadow: ${({ $selected }) => ($selected ? BLOCK_FOCUS_SHADOW : 'none')};
${({ $pending }) => $pending && 'position: relative;'}
`;
@@ -69,6 +69,7 @@ export const BlockWorkPackageComponent = ({
const selectedBlocks = useSelectedBlocks(editor);
const isBlockSelected = selectedBlocks.some((b) => b.id === block.id);
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
+ const closeOptions = useCallback(() => setIsOptionsOpen(false), []);
useEffect(() => {
if (!isBlockSelected) return;
@@ -140,23 +141,7 @@ export const BlockWorkPackageComponent = ({
sideMenu?.blockDragStart(e.nativeEvent, block as any);
};
- // Touch is listened for in its own right: a tap another element answers
- // never becomes a mousedown.
- useEffect(() => {
- if (!isOptionsOpen) return;
- const handlePressOutside = (e:Event) => {
- const path = e.composedPath();
- if (cardRef.current && !path.includes(cardRef.current)) {
- setIsOptionsOpen(false);
- }
- };
- document.addEventListener('mousedown', handlePressOutside);
- document.addEventListener('touchstart', handlePressOutside);
- return () => {
- document.removeEventListener('mousedown', handlePressOutside);
- document.removeEventListener('touchstart', handlePressOutside);
- };
- }, [isOptionsOpen]);
+ usePressOutside(cardRef, isOptionsOpen, closeOptions);
const handleConvertToInline = (size:InlineWpSize) => {
if (!block.props.wpid) return;
@@ -185,7 +170,7 @@ export const BlockWorkPackageComponent = ({
currentBlockSize={cardSize}
// eslint-disable-next-line react-hooks/refs
anchorEl={cardRef.current}
- onClose={() => setIsOptionsOpen(false)}
+ onClose={closeOptions}
onConvertToInline={handleConvertToInline}
onConvertToBlock={handleResizeBlock}
onResizeBlock={handleResizeBlock}
diff --git a/lib/components/BlockWorkPackage/blockConfig.ts b/lib/components/BlockWorkPackage/blockConfig.ts
index 4be495c5..e8ec2763 100644
--- a/lib/components/BlockWorkPackage/blockConfig.ts
+++ b/lib/components/BlockWorkPackage/blockConfig.ts
@@ -1,10 +1,11 @@
import { createBlockConfig } from '@blocknote/core';
+import { BLOCK_WP_TYPE } from '../../utils/nodeTypes';
// The cast keeps the generics wide - inferred ones narrow props to the literal
// prop schema, which the spec renderers and externalHtml are not typed for.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
export const blockConfig = createBlockConfig((() => ({
- type: 'openProjectWorkPackageBlock' as const,
+ type: BLOCK_WP_TYPE,
propSchema: {
wpid: { default: undefined, type: 'number' },
size: { default: 'm', type: 'string' },
diff --git a/lib/components/CreateWorkPackage/Suggestions.tsx b/lib/components/CreateWorkPackage/Suggestions.tsx
index 8f2ba09b..84283608 100644
--- a/lib/components/CreateWorkPackage/Suggestions.tsx
+++ b/lib/components/CreateWorkPackage/Suggestions.tsx
@@ -8,7 +8,7 @@ import {
XCircleIcon,
} from '@primer/octicons-react';
import { wantsMotion } from '../../utils/motion';
-import { useAnchoredPopover } from '../WorkPackage/anchoredPopover';
+import { useAnchoredPopover } from '../shared/anchoredPopover';
import { useActiveOptionInView } from '../../hooks/useActiveOptionInView';
import type { AllowedValue, ListedValue } from './formSchema';
import {
diff --git a/lib/components/CreateWorkPackage/atoms.ts b/lib/components/CreateWorkPackage/atoms.ts
index 0d52ba84..e625d87d 100644
--- a/lib/components/CreateWorkPackage/atoms.ts
+++ b/lib/components/CreateWorkPackage/atoms.ts
@@ -1,5 +1,5 @@
import styled, { css, keyframes } from 'styled-components';
-import { defaultWpVariables } from '../WorkPackage/atoms';
+import { editorThemeVariables } from '../shared/theme';
import { defaultColorStyles, typeTextColor } from '../../services/colors';
const surfaceColor = 'var(--op-create-wp-surface)';
@@ -83,7 +83,7 @@ export const Overlay = styled.div.attrs({
className: 'op-bn-create-wp-overlay',
'data-testid': 'create-wp-overlay',
})`
- ${defaultWpVariables}
+ ${editorThemeVariables}
--op-create-wp-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
--op-create-wp-surface: var(--bgColor-default, #ffffff);
diff --git a/lib/components/HashMenu/HashWpMenu.tsx b/lib/components/HashMenu/HashWpMenu.tsx
index 77d9fcf5..e379c6a0 100644
--- a/lib/components/HashMenu/HashWpMenu.tsx
+++ b/lib/components/HashMenu/HashWpMenu.tsx
@@ -1,128 +1,27 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
-import type { FC, RefObject } from 'react';
-import type { SuggestionMenuProps } from '@blocknote/react';
-import styled from 'styled-components';
-import { BlockCard } from '../BlockWorkPackage/BlockCard';
-import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms';
-import { SearchMessage } from '../Search/SearchContainer';
-import { Spinner } from '../Spinner';
-import { supportsHover } from '../../utils/device';
-import { useActiveOptionInView } from '../../hooks/useActiveOptionInView';
-import { useTapActivation } from '../../utils/tapActivation';
-import type { HashMenuItem, HashSearchState } from './types';
+import type { RefObject } from 'react';
import { useTranslation } from 'react-i18next';
-
-/*
- * BlockNote's GenericPopover wrapper (data-floating-ui-focusable) is the
- * element FloatingUI applies max-height to via its `size` middleware, but
- * this `Menu` element is the painted surface (white background, shadow,
- * rounded corners). Without our own height limit and overflow constraint,
- * the result rows render outside the painted card and ghost over the
- * editor content behind — visible on iOS Safari in particular.
- *
- * The painted surface and the overflow container must be the same
- * element, so the rounded corners clip the scroll area cleanly. The
- * `min-height: 0` line is the iOS Safari flex-child-with-overflow quirk:
- * a flex item won't actually clip / scroll unless its min-height is
- * explicitly zero. (Harmless on non-flex layouts.)
- */
-const Menu = styled.div.attrs({ className: 'op-bn-hash-menu', 'data-testid': 'hash-menu' })`
- ${defaultWpVariables}
- ${menuSurfaceStyles}
- box-shadow: var(--bn-shadow-medium);
- border-radius: var(--bn-border-radius-large);
- padding: var(--spacer-s);
- min-width: 320px;
- max-width: 480px;
- overflow-y: auto;
- min-height: 0;
-`;
-
-const MenuItem = styled.div.attrs({ className: 'op-bn-hash-menu-item' })<{ $highlighted:boolean }>`
- border-radius: var(--bn-border-radius-small);
- background: ${({ $highlighted }) =>
- $highlighted ? 'var(--op-item-hover-bg)' : 'transparent'};
- cursor: pointer;
- padding: 0 var(--spacer-s);
-`;
+import type { WorkPackage } from '../../openProjectTypes';
+import type { SuggestionMenuComponent, SuggestionSearchState } from '../../hooks/useSuggestionSearch';
+import { BlockCard } from '../BlockWorkPackage/BlockCard';
+import { SuggestionListbox } from '../shared/SuggestionListbox';
export function createHashWpMenuComponent(
- searchStateRef:RefObject,
-):FC> {
- const HashWpMenuComponent:FC> = ({
- items,
- loadingState,
- selectedIndex,
- onItemClick,
- }) => {
+ searchStateRef:RefObject>,
+):SuggestionMenuComponent {
+ const HashWpMenuComponent:SuggestionMenuComponent = (props) => {
const { t } = useTranslation();
- const { query, results, error } = searchStateRef.current;
-
- const canHover = useMemo(() => supportsHover(), []);
- const [hoveredIndex, setHoveredIndex] = useState(null);
- const menuRef = useRef(null);
- const tapProps = useTapActivation();
-
- const pick = (index:number) => {
- if (items[index]) onItemClick?.(items[index]);
- };
-
- useActiveOptionInView(menuRef, selectedIndex ?? -1, results);
-
- useEffect(() => {
- setHoveredIndex(null);
- }, [selectedIndex, items]);
-
- const highlightedIndex = hoveredIndex ?? selectedIndex;
-
- if (loadingState !== 'loaded') {
- return (
-
- );
- }
-
- if (!query) {
- return (
-
- );
- }
-
- if (error || results.length === 0) {
- return (
-
- );
- }
return (
-
+ workPackage.id}
+ renderOption={(workPackage) => }
+ />
);
};
diff --git a/lib/components/HashMenu/OpenProjectHashMenu.tsx b/lib/components/HashMenu/OpenProjectHashMenu.tsx
index 1941f85e..54d95d83 100644
--- a/lib/components/HashMenu/OpenProjectHashMenu.tsx
+++ b/lib/components/HashMenu/OpenProjectHashMenu.tsx
@@ -1,18 +1,16 @@
import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react';
-import { canOpenHashMenu } from './hashTrigger';
+import { HASH_TRIGGER } from './hashTrigger';
import { useHashWpMenu } from './useHashWpMenu';
-const HASH_TRIGGER_CHARACTER = '#';
-
export const OpenProjectHashMenu = () => {
const editor = useBlockNoteEditor();
- const { getHashItems, HashWpMenu } = useHashWpMenu(editor);
+ const { getHashItems, HashWpMenu, shouldOpenHashMenu } = useHashWpMenu(editor);
return (
);
diff --git a/lib/components/HashMenu/editorUtils.ts b/lib/components/HashMenu/editorUtils.ts
index 3fe990d5..b7f7b40d 100644
--- a/lib/components/HashMenu/editorUtils.ts
+++ b/lib/components/HashMenu/editorUtils.ts
@@ -1,6 +1,7 @@
import type { AnyEditor } from '../../editorTypes';
import type { WorkPackage } from '../../openProjectTypes';
import { chipContentOf, promoteInlineChipToBlockAt } from '../../utils/inlineChipActions';
+import { insertInline } from '../../utils/insertInline';
import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types';
import type { HashTarget } from './hashTrigger';
@@ -60,17 +61,11 @@ function insertChipAtCursor(
if (withTrailingSpace) content.push({ type: 'text', text: ' ', styles: {} });
const chipPosition = editor.transact((tr) => tr.selection.from);
- (editor.insertInlineContent as (content:unknown[]) => void)(content);
+ insertInline(editor, content);
return removeTriggerBeforeChip(editor, chipPosition);
}
-export function restoreHashQuery(editor:AnyEditor, query:string):void {
- (editor.insertInlineContent as (content:unknown[]) => void)([
- { type: 'text', text: `#${query}`, styles: {} },
- ]);
-}
-
/**
* Removes the leftover trigger hashes (`#`/`##`/`###`) that BlockNote's
* suggestion menu leaves directly before the chip for `##`-and-longer triggers,
diff --git a/lib/components/HashMenu/hashTrigger.ts b/lib/components/HashMenu/hashTrigger.ts
index a4cbcdbd..ec7113b0 100644
--- a/lib/components/HashMenu/hashTrigger.ts
+++ b/lib/components/HashMenu/hashTrigger.ts
@@ -2,6 +2,9 @@ import type { Node as ProsemirrorNode } from 'prosemirror-model';
import type { Transaction } from 'prosemirror-state';
import type { AnyEditor } from '../../editorTypes';
import type { BlockWpSize, InlineWpSize } from '../WorkPackage/types';
+import { textBefore } from '../../utils/suggestionTrigger';
+
+export const HASH_TRIGGER = '#';
/**
* What the hash menu inserts for the work package the user picks. How many `#`
@@ -46,20 +49,9 @@ export function canOpenHashMenu(transaction:Transaction):boolean {
return trailingHashes(transaction.doc, transaction.selection.from) < MAX_TRIGGER_HASHES;
}
-// Keeps hash runs on either side of an atom node (an inline chip) apart.
-const LEAF_PLACEHOLDER = '\uFFFC';
-
function trailingHashes(doc:ProsemirrorNode, position:number):number {
- const resolved = doc.resolve(position);
- if (!resolved.parent.isTextblock) return 0;
-
- const textBefore = resolved.parent.textBetween(
- 0,
- resolved.parentOffset,
- undefined,
- LEAF_PLACEHOLDER
- );
- return /#*$/.exec(textBefore)?.[0].length ?? 0;
+ const text = textBefore(doc.resolve(position)) ?? '';
+ return /#*$/.exec(text)?.[0].length ?? 0;
}
function leadingHashes(query:string):number {
diff --git a/lib/components/HashMenu/index.ts b/lib/components/HashMenu/index.ts
index bc02f640..24f9a325 100644
--- a/lib/components/HashMenu/index.ts
+++ b/lib/components/HashMenu/index.ts
@@ -1,5 +1,6 @@
export { OpenProjectHashMenu } from './OpenProjectHashMenu';
export { createHashWpMenuComponent } from './HashWpMenu';
export { useHashWpMenu } from './useHashWpMenu';
+export { HASH_TRIGGER } from './hashTrigger';
export { isHashWpQuery } from './types';
-export type { HashMenuItem, HashSearchState } from './types';
+export type { HashMenuItem } from './types';
diff --git a/lib/components/HashMenu/types.ts b/lib/components/HashMenu/types.ts
index 7b9d21e7..9f68b9dc 100644
--- a/lib/components/HashMenu/types.ts
+++ b/lib/components/HashMenu/types.ts
@@ -1,16 +1,7 @@
-import type { WorkPackage } from '../../openProjectTypes';
+import type { SuggestionMenuItem } from '../../hooks/useSuggestionSearch';
-export interface HashMenuItem {
- title:string;
- onItemClick:() => void;
-}
-
-export interface HashSearchState {
- query:string;
- results:WorkPackage[];
- error:string | null;
-}
+export type HashMenuItem = SuggestionMenuItem;
export function isHashWpQuery(query:string):boolean {
return query.trim().length > 0;
-}
\ No newline at end of file
+}
diff --git a/lib/components/HashMenu/useHashWpMenu.ts b/lib/components/HashMenu/useHashWpMenu.ts
index 44b71c2e..3e01d532 100644
--- a/lib/components/HashMenu/useHashWpMenu.ts
+++ b/lib/components/HashMenu/useHashWpMenu.ts
@@ -1,81 +1,38 @@
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback } from 'react';
+import type { AnyEditor } from '../../editorTypes';
+import type { WorkPackage } from '../../openProjectTypes';
import { MAX_SEARCH_RESULTS, useWorkPackageSearch } from '../../hooks/useWorkPackageSearch';
+import { useSuggestionSearch } from '../../hooks/useSuggestionSearch';
+import type { SuggestionPlan } from '../../hooks/useSuggestionSearch';
+import { cacheColors } from '../../services/colors';
import { createHashWpMenuComponent } from './HashWpMenu';
+import { insertWpForTarget } from './editorUtils';
+import { canOpenHashMenu, HASH_TRIGGER, hashTargetFor } from './hashTrigger';
import { isHashWpQuery } from './types';
-import { canOpenHashMenu, hashTargetFor } from './hashTrigger';
-import { insertWpForTarget, restoreHashQuery } from './editorUtils';
-import type { HashMenuItem, HashSearchState } from './types';
-import type { AnyEditor } from '../../editorTypes';
-import { cacheColors } from '../../services/colors';
-import { closeSuggestionMenu } from '../../utils/suggestionMenu';
export function useHashWpMenu(editor:AnyEditor) {
- const { search } = useWorkPackageSearch();
- const searchStateRef = useRef({ query: '', results: [], error: null });
- const latestQueryRef = useRef('');
-
- const placeholderItems = useCallback(
- (query:string):HashMenuItem[] => [{
- title: query,
- onItemClick: () => {
- restoreHashQuery(editor, query);
- },
- }],
- [editor]
- );
-
- const getHashItems = useCallback(
- async (query:string):Promise => {
- latestQueryRef.current = query;
-
- const target = hashTargetFor(editor, query);
- if (target.kind === 'none') {
- closeSuggestionMenu(editor);
- return [];
- }
-
- if (!isHashWpQuery(query)) {
- searchStateRef.current = { query, results: [], error: null };
- return placeholderItems(query);
- }
-
- await cacheColors();
-
- try {
- const results = (await search(query)).slice(0, MAX_SEARCH_RESULTS);
-
- if (latestQueryRef.current !== query) return [];
- searchStateRef.current = { query, results, error: null };
-
- if (results.length === 0) return placeholderItems(query);
-
- return results.map((wp) => ({
- title: query,
- onItemClick: () => {
- insertWpForTarget(editor, wp, target);
- },
- }));
- } catch (error) {
- console.error('[work package search] Failed to load work packages from OpenProject:', error);
- if (latestQueryRef.current === query) {
- searchStateRef.current = {
- query,
- results: [],
- error: error instanceof Error ? error.message : 'Unknown error',
- };
- }
- return placeholderItems(query);
- }
- },
- [editor, search, placeholderItems]
- );
-
- /* eslint-disable react-hooks/refs */
- const HashWpMenu = useMemo(
- () => createHashWpMenuComponent(searchStateRef),
- []
- );
- /* eslint-enable react-hooks/refs */
-
- return { getHashItems, HashWpMenu, shouldOpenHashMenu: canOpenHashMenu };
+ const { search, cancelSearch } = useWorkPackageSearch();
+
+ const planFor = useCallback((query:string):SuggestionPlan => {
+ const target = hashTargetFor(editor, query);
+ if (target.kind === 'none') return { kind: 'close' };
+ if (!isHashWpQuery(query)) return { kind: 'prompt' };
+ return { kind: 'search', pick: (workPackage) => insertWpForTarget(editor, workPackage, target) };
+ }, [editor]);
+
+ const searchWithColors = useCallback(async (query:string) => {
+ await cacheColors();
+ return (await search(query)).slice(0, MAX_SEARCH_RESULTS);
+ }, [search]);
+
+ const { getItems, Menu } = useSuggestionSearch(editor, {
+ trigger: HASH_TRIGGER,
+ planFor,
+ search: searchWithColors,
+ cancelSearch,
+ logPrefix: '[work package search] Failed to load work packages from OpenProject:',
+ createMenu: createHashWpMenuComponent,
+ });
+
+ return { getHashItems: getItems, HashWpMenu: Menu, shouldOpenHashMenu: canOpenHashMenu };
}
diff --git a/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx b/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx
index c1ffa019..d8420684 100644
--- a/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx
+++ b/lib/components/InlineWorkPackage/InlineWorkPackageChip.tsx
@@ -13,18 +13,15 @@ import { WpPreviewPopover } from '../WorkPackage/PreviewPopover';
import { getPendingCallbacks, clearInlineWpCallbacks } from './callbacks';
import type { InlineWpSize } from '../WorkPackage/types';
import type { WorkPackage } from '../../openProjectTypes';
-import {
- findInlineChipAtDOM,
- selectInlineChipAt,
- removeInlineChipAt,
- promoteInlineChipToBlockAt,
-} from '../../utils/inlineChipActions';
+import { promoteInlineChipToBlockAt } from '../../utils/inlineChipActions';
+import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
import { BlockCard } from '../BlockWorkPackage/BlockCard';
import { useTranslation } from 'react-i18next';
import { formatWorkPackageId } from '../../utils/id';
import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection';
import { useSuppressFormattingToolbar } from '../../hooks/useSuppressFormattingToolbar';
-import { useTapActivation } from '../../utils/tapActivation';
+import { useInlineNodeOptions } from '../../hooks/useInlineNodeOptions';
+import { usePressOutside } from '../../hooks/usePressOutside';
import type { BlockNoteEditor } from '@blocknote/core';
export interface InlineWorkPackageChipProps {
@@ -34,7 +31,7 @@ export interface InlineWorkPackageChipProps {
editor?:BlockNoteEditor;
// Provided by BlockNote's node view; updates exactly this node instance.
updateInlineContent?:(update:{
- type:'openProjectWorkPackageInline';
+ type:typeof INLINE_WP_TYPE;
props:{ wpid:string; size:string; displayId:string };
}) => void;
}
@@ -55,24 +52,27 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
useEffect(() => {
if (!wp || !updateInlineContent) return;
if (wp.displayId === inlineContent.props.displayId) return;
- updateInlineContent({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, displayId: wp.displayId } });
+ updateInlineContent({ type: INLINE_WP_TYPE, props: { ...inlineContent.props, displayId: wp.displayId } });
}, [wp?.displayId]); // eslint-disable-line react-hooks/exhaustive-deps
- const [isSelected, setIsSelected] = useState(false);
const chipRef = useRef(null);
const [chipEl, setChipEl] = useState(null);
+ const { optionsOpen, closeOptions, activationProps, findNode, removeNode } =
+ useInlineNodeOptions(editor, chipRef, INLINE_WP_TYPE);
+
const preview = useWorkPackagePreview({
enabled: size === 'xxs',
- suppressed: isSelected,
+ suppressed: optionsOpen,
// The indicator shows the preview instead of the options menu, not over it.
- onOpen: () => setIsSelected(false),
+ onOpen: closeOptions,
});
const { previewOpen, closePreview, triggerProps, cardProps } = preview;
const isEditorSelected = useIsNodeInSelection(chipRef, editor);
- useSuppressFormattingToolbar(editor, isSelected || previewOpen);
+ useSuppressFormattingToolbar(editor, previewOpen);
+ usePressOutside(chipRef, previewOpen, closePreview);
const setRef = (node:HTMLElement | null) => {
chipRef.current = node;
@@ -80,45 +80,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
contentRef(node);
};
- const selectWorkPackageNode = () => {
- if (!editor || !chipRef.current) return;
- const chip = findInlineChipAtDOM(editor, chipRef.current);
- if (chip) selectInlineChipAt(editor, chip.position);
- editor.getExtension('formattingToolbar')?.store?.setState(false);
- };
-
- const toggleOptions = () => {
- closePreview();
- setIsSelected((prev) => !prev);
- selectWorkPackageNode();
- };
-
- const tapProps = useTapActivation();
- // The closure is handed to the element, not run while rendering.
- // eslint-disable-next-line react-hooks/refs
- const onChipActivation = tapProps((event) => {
- event?.preventDefault();
- event?.stopPropagation();
- toggleOptions();
- });
-
- // Close the options popover and the preview when the user taps outside the chip
- useEffect(() => {
- if (!isSelected && !previewOpen) return;
- const onPressOutside = (e:Event) => {
- if (chipRef.current && !chipRef.current.contains(e.target as Node)) {
- setIsSelected(false);
- closePreview();
- }
- };
- // Touch as well: a tap another element answers never becomes a mousedown.
- document.addEventListener('mousedown', onPressOutside);
- document.addEventListener('touchstart', onPressOutside);
- return () => {
- document.removeEventListener('mousedown', onPressOutside);
- document.removeEventListener('touchstart', onPressOutside);
- };
- }, [isSelected, previewOpen, closePreview]);
+ const onChipActivation = activationProps(closePreview);
const optionsPopover = (
setIsSelected(false)}
+ onClose={closeOptions}
onResize={(newSize) => {
- updateInlineContent?.({ type: 'openProjectWorkPackageInline', props: { ...inlineContent.props, size: newSize } });
+ updateInlineContent?.({ type: INLINE_WP_TYPE, props: { ...inlineContent.props, size: newSize } });
}}
onConvertToBlock={(blockSize) => {
- if (!editor || !chipRef.current) return;
- const chip = findInlineChipAtDOM(editor, chipRef.current);
- if (chip) promoteInlineChipToBlockAt(editor, chip.position, blockSize);
- }}
- onRemove={() => {
- if (!editor || !chipRef.current) return;
- const chip = findInlineChipAtDOM(editor, chipRef.current);
- if (chip) removeInlineChipAt(editor, chip.position);
+ const chip = findNode();
+ if (editor && chip) promoteInlineChipToBlockAt(editor, chip.position, blockSize);
}}
+ onRemove={removeNode}
/>
);
@@ -191,7 +148,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
// Resolved
if (wpid && wp) {
// Hidden while the options menu is open so the two popovers never stack.
- const showPreview = size === 'xxs' && previewOpen && !isSelected;
+ const showPreview = size === 'xxs' && previewOpen && !optionsOpen;
const chipLabel = t('options.chipAriaLabel', { id: formatWorkPackageId(wp.displayId) });
const hasIndicator = preview.indicatorProps !== undefined;
@@ -201,7 +158,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
role={hasIndicator ? undefined : 'button'}
aria-label={hasIndicator ? undefined : chipLabel}
ref={setRef}
- selected={isSelected || isEditorSelected}
+ selected={optionsOpen || isEditorSelected}
{...triggerProps}
{...onChipActivation}
>
@@ -219,7 +176,7 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
)}
- {isSelected && optionsPopover}
+ {optionsOpen && optionsPopover}
);
}
@@ -234,10 +191,10 @@ export const InlineWorkPackageChip = ({ inlineContent, contentRef, editor, updat
setRef={setRef}
// eslint-disable-next-line react-hooks/refs
anchorEl={chipRef.current}
- selected={isSelected || isEditorSelected}
+ selected={optionsOpen || isEditorSelected}
preview={preview}
onActivation={onChipActivation}
- optionsPopover={isSelected && optionsPopover}
+ optionsPopover={optionsOpen && optionsPopover}
/>
);
}
diff --git a/lib/components/InlineWorkPackage/chipLayouts.tsx b/lib/components/InlineWorkPackage/chipLayouts.tsx
index 76973a00..3deef3b5 100644
--- a/lib/components/InlineWorkPackage/chipLayouts.tsx
+++ b/lib/components/InlineWorkPackage/chipLayouts.tsx
@@ -1,38 +1,20 @@
import { css } from 'styled-components';
import styled from 'styled-components';
-import { CHIP_STYLES } from '../WorkPackage/tokens';
-import { defaultWpVariables, nonSelectableStyles } from '../WorkPackage/atoms';
-
-const chipBaseStyles = css`
- display: inline;
- border-radius: ${CHIP_STYLES.radius};
- background: ${CHIP_STYLES.bg};
- font-size: ${CHIP_STYLES.fontSize};
- box-decoration-break: clone;
- -webkit-box-decoration-break: clone;
-
- & > * {
- vertical-align: baseline;
- }
-
- & > *:not(:last-child) {
- margin-right: ${CHIP_STYLES.gap};
- }
-`;
+import { INLINE_NODE_STYLES, inlineNodeBaseStyles, inlineNodeContainerStyles } from '../shared/inlineNodeStyles';
export const ChipBaseXXS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })`
- ${chipBaseStyles}
- padding: ${CHIP_STYLES.padding.xxs};
+ ${inlineNodeBaseStyles}
+ padding: ${INLINE_NODE_STYLES.padding.xxs};
`;
export const ChipBaseXS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })`
- ${chipBaseStyles}
- padding: ${CHIP_STYLES.padding.xs};
+ ${inlineNodeBaseStyles}
+ padding: ${INLINE_NODE_STYLES.padding.xs};
`;
export const ChipBaseS = styled.span.attrs({ className: 'op-bn-inline-wp-base' })`
- ${chipBaseStyles}
- padding: ${CHIP_STYLES.padding.s};
+ ${inlineNodeBaseStyles}
+ padding: ${INLINE_NODE_STYLES.padding.s};
`;
export const ChipBase = ChipBaseS;
@@ -41,13 +23,7 @@ export const InlineChip = styled.span.attrs({
className: 'op-bn-inline-wp',
contentEditable: false,
})<{ selected?:boolean }>`
- ${defaultWpVariables}
- display: inline;
- cursor: pointer;
- ${nonSelectableStyles}
- border-radius: ${CHIP_STYLES.radius};
- position: relative;
- line-height: 1;
+ ${inlineNodeContainerStyles}
&:active {
cursor: grabbing;
@@ -57,7 +33,7 @@ export const InlineChip = styled.span.attrs({
selected &&
css`
& > .op-bn-inline-wp-base {
- box-shadow: ${CHIP_STYLES.inlineFocusShadow};
+ box-shadow: ${INLINE_NODE_STYLES.inlineFocusShadow};
}
`}
`;
diff --git a/lib/components/InlineWorkPackage/externalHtml.ts b/lib/components/InlineWorkPackage/externalHtml.ts
index 788c102f..8f6c3045 100644
--- a/lib/components/InlineWorkPackage/externalHtml.ts
+++ b/lib/components/InlineWorkPackage/externalHtml.ts
@@ -6,6 +6,7 @@
import { buildExternalDOM, hashPrefixForSize } from '../WorkPackage/externalHtml';
import { linkToWorkPackage } from '../../services/openProjectApi';
+import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
export interface WorkPackageInlineProps {
wpid?:string;
@@ -15,7 +16,7 @@ export interface WorkPackageInlineProps {
export interface WorkPackageInlineExternalData {
attrs:{
- 'data-inline-content-type':'openProjectWorkPackageInline';
+ 'data-inline-content-type':typeof INLINE_WP_TYPE;
'data-wpid':string;
'data-size':string;
'data-display-id':string;
@@ -33,7 +34,7 @@ export function computeWorkPackageInlineExternalData(
const displayId = props.displayId || wpid;
return {
attrs: {
- 'data-inline-content-type': 'openProjectWorkPackageInline',
+ 'data-inline-content-type': INLINE_WP_TYPE,
'data-wpid': wpid,
'data-size': size ?? 's',
'data-display-id': displayId,
@@ -53,7 +54,7 @@ export function buildWorkPackageInlineExternalDOM(
export function parseWorkPackageInlineExternalHTML(
element:HTMLElement,
):WorkPackageInlineProps | undefined {
- if (element.getAttribute('data-inline-content-type') !== 'openProjectWorkPackageInline') {
+ if (element.getAttribute('data-inline-content-type') !== INLINE_WP_TYPE) {
return undefined;
}
return {
diff --git a/lib/components/InlineWorkPackage/inlineConfig.ts b/lib/components/InlineWorkPackage/inlineConfig.ts
index f88aaf1f..bb4b03a5 100644
--- a/lib/components/InlineWorkPackage/inlineConfig.ts
+++ b/lib/components/InlineWorkPackage/inlineConfig.ts
@@ -1,5 +1,7 @@
+import { INLINE_WP_TYPE } from '../../utils/nodeTypes';
+
export const inlineConfig = {
- type: 'openProjectWorkPackageInline' as const,
+ type: INLINE_WP_TYPE,
propSchema: {
wpid: { default: '' },
size: { default: 's' },
diff --git a/lib/components/MentionMenu/MentionMenu.tsx b/lib/components/MentionMenu/MentionMenu.tsx
new file mode 100644
index 00000000..1d19a075
--- /dev/null
+++ b/lib/components/MentionMenu/MentionMenu.tsx
@@ -0,0 +1,45 @@
+import type { RefObject } from 'react';
+import styled from 'styled-components';
+import { useTranslation } from 'react-i18next';
+import type { Principal } from '../../openProjectTypes';
+import type { SuggestionMenuComponent, SuggestionSearchState } from '../../hooks/useSuggestionSearch';
+import { Avatar } from '../shared/Avatar';
+import { SuggestionListbox } from '../shared/SuggestionListbox';
+
+const AVATAR_SIZE = 24;
+
+const Row = styled.span`
+ display: flex;
+ align-items: center;
+ gap: var(--spacer-m);
+ padding: var(--spacer-s) 0;
+ color: var(--bn-colors-editor-text);
+`;
+
+export function createMentionMenuComponent(
+ searchStateRef:RefObject>,
+):SuggestionMenuComponent {
+ const MentionMenu:SuggestionMenuComponent = (props) => {
+ const { t } = useTranslation();
+
+ return (
+ principal.id}
+ renderOption={(principal) => (
+
+
+ {principal.name}
+
+ )}
+ />
+ );
+ };
+
+ MentionMenu.displayName = 'MentionMenu';
+ return MentionMenu;
+}
diff --git a/lib/components/MentionMenu/OpenProjectMentionMenu.tsx b/lib/components/MentionMenu/OpenProjectMentionMenu.tsx
new file mode 100644
index 00000000..842d5ef4
--- /dev/null
+++ b/lib/components/MentionMenu/OpenProjectMentionMenu.tsx
@@ -0,0 +1,17 @@
+import { SuggestionMenuController, useBlockNoteEditor } from '@blocknote/react';
+import { MENTION_TRIGGER } from './mentionTrigger';
+import { useMentionMenu } from './useMentionMenu';
+
+export const OpenProjectMentionMenu = () => {
+ const editor = useBlockNoteEditor();
+ const { getMentionItems, MentionMenu, shouldOpenMentionMenu } = useMentionMenu(editor);
+
+ return (
+
+ );
+};
diff --git a/lib/components/MentionMenu/index.ts b/lib/components/MentionMenu/index.ts
new file mode 100644
index 00000000..925bd86b
--- /dev/null
+++ b/lib/components/MentionMenu/index.ts
@@ -0,0 +1,4 @@
+export { OpenProjectMentionMenu } from './OpenProjectMentionMenu';
+export { createMentionMenuComponent } from './MentionMenu';
+export { useMentionMenu } from './useMentionMenu';
+export { canOpenMentionMenu, MENTION_TRIGGER } from './mentionTrigger';
diff --git a/lib/components/MentionMenu/mentionTrigger.ts b/lib/components/MentionMenu/mentionTrigger.ts
new file mode 100644
index 00000000..cf986c64
--- /dev/null
+++ b/lib/components/MentionMenu/mentionTrigger.ts
@@ -0,0 +1,13 @@
+import type { Transaction } from 'prosemirror-state';
+import { textBefore } from '../../utils/suggestionTrigger';
+
+export const MENTION_TRIGGER = '@';
+
+// Like CKEditor's mention feature: "@" only starts a mention where a word would start.
+const OPENS_AFTER = /^$|[\s([{"']$/;
+
+/** Runs before the typed "@" is inserted. */
+export function canOpenMentionMenu(transaction:Transaction):boolean {
+ const characterBefore = textBefore(transaction.selection.$from, 1);
+ return characterBefore !== null && OPENS_AFTER.test(characterBefore);
+}
diff --git a/lib/components/MentionMenu/useMentionMenu.ts b/lib/components/MentionMenu/useMentionMenu.ts
new file mode 100644
index 00000000..3894c498
--- /dev/null
+++ b/lib/components/MentionMenu/useMentionMenu.ts
@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+import type { AnyEditor } from '../../editorTypes';
+import type { Principal } from '../../openProjectTypes';
+import { searchMentionableUsers } from '../../services/openProjectApi';
+import { useDebouncedSearch } from '../../hooks/useDebouncedSearch';
+import { useSuggestionSearch } from '../../hooks/useSuggestionSearch';
+import type { SuggestionPlan } from '../../hooks/useSuggestionSearch';
+import { insertInline } from '../../utils/insertInline';
+import { USER_MENTION_TYPE } from '../../utils/nodeTypes';
+import { canOpenMentionMenu, MENTION_TRIGGER } from './mentionTrigger';
+import { createMentionMenuComponent } from './MentionMenu';
+
+function insertMention(editor:AnyEditor, principal:Principal):void {
+ insertInline(editor, [
+ { type: USER_MENTION_TYPE, props: { userId: String(principal.id), name: principal.name } },
+ { type: 'text', text: ' ', styles: {} },
+ ]);
+ editor.focus();
+}
+
+export function useMentionMenu(editor:AnyEditor) {
+ const { search, cancel } = useDebouncedSearch(searchMentionableUsers, { searchBlank: true });
+
+ const planFor = useCallback(
+ ():SuggestionPlan => ({ kind: 'search', pick: (principal) => insertMention(editor, principal) }),
+ [editor]
+ );
+
+ const { getItems, Menu } = useSuggestionSearch(editor, {
+ trigger: MENTION_TRIGGER,
+ planFor,
+ search,
+ cancelSearch: cancel,
+ logPrefix: '[mention search] Failed to load users from OpenProject:',
+ createMenu: createMentionMenuComponent,
+ });
+
+ return { getMentionItems: getItems, MentionMenu: Menu, shouldOpenMentionMenu: canOpenMentionMenu };
+}
diff --git a/lib/components/Search/SearchContainer.tsx b/lib/components/Search/SearchContainer.tsx
index 0f123e0a..3d2a34ba 100644
--- a/lib/components/Search/SearchContainer.tsx
+++ b/lib/components/Search/SearchContainer.tsx
@@ -1,5 +1,5 @@
import styled from 'styled-components';
-import { defaultWpVariables, menuSurfaceStyles } from '../WorkPackage/atoms';
+import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme';
import { FLOATING_Z_INDEX } from '../../utils/zIndex';
export const SEARCH_INPUT_ID = 'op-bn-wp-search-input';
@@ -14,7 +14,7 @@ export const SEARCH_INPUT_ID = 'op-bn-wp-search-input';
export const SearchContainer = styled.div.attrs({
className: 'op-bn-search',
})<{ $flipped:boolean }>`
- ${defaultWpVariables}
+ ${editorThemeVariables}
position: absolute;
z-index: ${FLOATING_Z_INDEX.search};
top: 1.6em;
@@ -67,19 +67,6 @@ export const SearchInput = styled.input.attrs({
box-sizing: border-box;
`;
-export const SearchMessage = styled.div.attrs({
- className: 'op-bn-search--message',
- role: 'status',
-})`
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--spacer-m);
- padding: var(--spacer-m) var(--spacer-l);
- font-size: 0.85em;
- color: var(--bn-colors-highlights-gray-text, #888);
-`;
-
export const DropdownList = styled.div`
min-height: 0;
overflow-y: auto;
diff --git a/lib/components/Search/SearchDropdown.tsx b/lib/components/Search/SearchDropdown.tsx
index 72eeb674..d5aa0dd1 100644
--- a/lib/components/Search/SearchDropdown.tsx
+++ b/lib/components/Search/SearchDropdown.tsx
@@ -11,10 +11,10 @@ import {
SearchInput,
SearchHeader,
SearchLabel,
- SearchMessage,
DropdownList,
DropdownItem,
} from './SearchContainer';
+import { SearchMessage } from '../shared/SearchMessage';
import { Spinner } from '../Spinner';
interface SearchDropdownProps {
diff --git a/lib/components/Search/WorkPackageSearchPopover.tsx b/lib/components/Search/WorkPackageSearchPopover.tsx
index 1ba3cf0a..f7ddaef3 100644
--- a/lib/components/Search/WorkPackageSearchPopover.tsx
+++ b/lib/components/Search/WorkPackageSearchPopover.tsx
@@ -3,7 +3,7 @@ import type { WorkPackage } from '../../openProjectTypes';
import { SearchContainer } from './SearchContainer';
import { SearchDropdown } from './SearchDropdown';
import { BlockCard } from '../BlockWorkPackage/BlockCard';
-import { useAnchoredPopover, PopoverPortal } from '../WorkPackage/anchoredPopover';
+import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover';
import { useElementHeight } from '../../hooks/useElementHeight';
const MAX_POPOVER_HEIGHT = 360;
diff --git a/lib/components/UserMention/UserMentionPill.tsx b/lib/components/UserMention/UserMentionPill.tsx
new file mode 100644
index 00000000..d58d4f98
--- /dev/null
+++ b/lib/components/UserMention/UserMentionPill.tsx
@@ -0,0 +1,84 @@
+import { useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import styled, { css } from 'styled-components';
+import type { AnyEditor } from '../../editorTypes';
+import { useIsNodeInSelection } from '../../hooks/useIsNodeInSelection';
+import { useInlineNodeOptions } from '../../hooks/useInlineNodeOptions';
+import { linkToUser } from '../../services/openProjectApi';
+import { newTabLinkProps } from '../../utils/links';
+import { USER_MENTION_TYPE } from '../../utils/nodeTypes';
+import { OptionsPopover } from '../shared/OptionsPopover';
+import { INLINE_NODE_STYLES, inlineNodeBaseStyles, inlineNodeContainerStyles } from '../shared/inlineNodeStyles';
+import { Avatar } from '../shared/Avatar';
+import type { UserMentionProps } from './externalHtml';
+
+const PILL_PADDING = '2.5px 6px';
+const AVATAR_SIZE = 16;
+const AVATAR_INSET = '2px';
+const AVATAR_GAP = '4px';
+
+const Pill = styled.span.attrs({ className: 'op-bn-user-mention', contentEditable: false })<{ $selected:boolean }>`
+ ${inlineNodeContainerStyles}
+ ${inlineNodeBaseStyles}
+ padding: ${PILL_PADDING};
+ padding-left: ${AVATAR_INSET};
+
+ /* text-bottom aligns the avatar's own box, so it does not move when the picture replaces the initials */
+ & > .op-bn-avatar {
+ vertical-align: text-bottom;
+ margin-right: ${AVATAR_GAP};
+ }
+
+ ${({ $selected }) => $selected && css`box-shadow: ${INLINE_NODE_STYLES.inlineFocusShadow};`}
+`;
+
+const Name = styled.a`
+ color: var(--bn-colors-editor-text);
+ font-weight: 500;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+`;
+
+interface UserMentionPillProps {
+ props:UserMentionProps;
+ editor:AnyEditor;
+}
+
+export function UserMentionPill({ props: { userId, name }, editor }:UserMentionPillProps) {
+ const { t } = useTranslation();
+ const pillRef = useRef(null);
+ const selected = useIsNodeInSelection(pillRef, editor);
+ const { optionsOpen, closeOptions, activationProps, removeNode } =
+ useInlineNodeOptions(editor, pillRef, USER_MENTION_TYPE);
+ const profileHref = linkToUser(userId);
+
+ return (
+
+
+
+ {name}
+
+ {optionsOpen && (
+
+ )}
+
+ );
+}
diff --git a/lib/components/UserMention/externalHtml.ts b/lib/components/UserMention/externalHtml.ts
new file mode 100644
index 00000000..9b8c5b06
--- /dev/null
+++ b/lib/components/UserMention/externalHtml.ts
@@ -0,0 +1,59 @@
+import { escapeHtmlAttribute, escapeHtmlText } from '../../utils/html';
+import { USER_MENTION_TYPE } from '../../utils/nodeTypes';
+
+export interface UserMentionProps {
+ userId:string;
+ name:string;
+ /** Hocuspocus saves with the first connected user's token, so OpenProject reads who mentioned from here. */
+ mentionedBy?:string;
+}
+
+/** The exact markup OpenProject stores for a user mention (see Users::ReplaceMentionsService). */
+export function userMentionTag({ userId, name, mentionedBy }:UserMentionProps):string {
+ const text = `@${name}`;
+ const mentionedByAttribute = mentionedBy ? ` data-mentioned-by="${escapeHtmlAttribute(mentionedBy)}"` : '';
+ return `${escapeHtmlText(text)}`;
+}
+
+function wrapperAttributes({ userId, name, mentionedBy }:UserMentionProps):Record {
+ return {
+ 'data-inline-content-type': USER_MENTION_TYPE,
+ 'data-user-id': userId,
+ 'data-name': name,
+ ...(mentionedBy ? { 'data-mentioned-by': mentionedBy } : {}),
+ };
+}
+
+export function buildUserMentionStoredDOM(props:UserMentionProps, document:Document):HTMLElement {
+ const wrapper = document.createElement('span');
+ for (const [attribute, value] of Object.entries(wrapperAttributes(props))) {
+ wrapper.setAttribute(attribute, value);
+ }
+ wrapper.textContent = userMentionTag(props);
+ return wrapper;
+}
+
+export function parseUserMentionExternalHTML(element:HTMLElement):UserMentionProps | undefined {
+ const mentionedBy = element.getAttribute('data-mentioned-by') ?? '';
+
+ // BlockNote's own wrapper omits the props at their default, so one without a user id is left to its content.
+ if (element.getAttribute('data-inline-content-type') === USER_MENTION_TYPE && element.hasAttribute('data-user-id')) {
+ return {
+ userId: element.getAttribute('data-user-id') ?? '',
+ name: element.getAttribute('data-name') ?? '',
+ mentionedBy,
+ };
+ }
+
+ const userId = element.getAttribute('data-id');
+ if (element.tagName.toLowerCase() === 'mention' && element.getAttribute('data-type') === 'user' && userId) {
+ return {
+ userId,
+ name: (element.getAttribute('data-text') ?? '').replace(/^@/, ''),
+ mentionedBy,
+ };
+ }
+
+ return undefined;
+}
diff --git a/lib/components/UserMention/index.ts b/lib/components/UserMention/index.ts
new file mode 100644
index 00000000..3c4feb17
--- /dev/null
+++ b/lib/components/UserMention/index.ts
@@ -0,0 +1 @@
+export { openProjectUserMentionSpec } from './spec';
diff --git a/lib/components/UserMention/spec.tsx b/lib/components/UserMention/spec.tsx
new file mode 100644
index 00000000..e95edb79
--- /dev/null
+++ b/lib/components/UserMention/spec.tsx
@@ -0,0 +1,24 @@
+import { createReactInlineContentSpec } from '@blocknote/react';
+import { userMentionConfig } from './userMentionConfig';
+import { userMentionTag, parseUserMentionExternalHTML } from './externalHtml';
+import { UserMentionPill } from './UserMentionPill';
+
+export const openProjectUserMentionSpec = createReactInlineContentSpec(
+ userMentionConfig,
+ {
+ render: ({ inlineContent, editor }) => ,
+
+ // A root for the same reason as the work package chip: an or
+ // unknown root lets the Link mark claim the node on paste.
+ toExternalHTML: ({ inlineContent }) => {
+ if (!inlineContent.props.userId) return <>>;
+ return ;
+ },
+
+ parse: (element) => parseUserMentionExternalHTML(element),
+
+ meta: {
+ draggable: true,
+ },
+ }
+);
diff --git a/lib/components/UserMention/staticSpec.ts b/lib/components/UserMention/staticSpec.ts
new file mode 100644
index 00000000..b752583f
--- /dev/null
+++ b/lib/components/UserMention/staticSpec.ts
@@ -0,0 +1,22 @@
+import { createInlineContentSpec } from '@blocknote/core';
+import { userMentionConfig } from './userMentionConfig';
+import { buildUserMentionStoredDOM, parseUserMentionExternalHTML } from './externalHtml';
+
+// Only the hocuspocus server uses this spec, and only to store markdown.
+export const openProjectUserMentionStaticSpec = createInlineContentSpec(
+ userMentionConfig,
+ {
+ render: (inlineContent) => {
+ const dom = document.createElement('span');
+ dom.textContent = `@${inlineContent.props.name}`;
+ return { dom };
+ },
+
+ toExternalHTML: (inlineContent) => {
+ if (!inlineContent.props.userId) return undefined;
+ return { dom: buildUserMentionStoredDOM(inlineContent.props, document) };
+ },
+
+ parse: (element) => parseUserMentionExternalHTML(element),
+ }
+);
diff --git a/lib/components/UserMention/userMentionConfig.ts b/lib/components/UserMention/userMentionConfig.ts
new file mode 100644
index 00000000..00e16450
--- /dev/null
+++ b/lib/components/UserMention/userMentionConfig.ts
@@ -0,0 +1,11 @@
+import { USER_MENTION_TYPE } from '../../utils/nodeTypes';
+
+export const userMentionConfig = {
+ type: USER_MENTION_TYPE,
+ propSchema: {
+ userId: { default: '' },
+ name: { default: '' },
+ mentionedBy: { default: '' },
+ },
+ content: 'none' as const,
+};
diff --git a/lib/components/WorkPackage/OptionsPopover.tsx b/lib/components/WorkPackage/OptionsPopover.tsx
index 22f8e464..28af2194 100644
--- a/lib/components/WorkPackage/OptionsPopover.tsx
+++ b/lib/components/WorkPackage/OptionsPopover.tsx
@@ -1,20 +1,14 @@
-import { useState, useRef } from 'react';
+import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { WorkPackage } from '../../openProjectTypes';
import { linkToWorkPackage } from '../../services/openProjectApi';
import type { InlineWpSize, BlockWpSize } from './types';
import styled from 'styled-components';
-import { defaultWpVariables, menuSurfaceStyles } from './atoms';
-import { useAnchoredPopover, PopoverPortal } from './anchoredPopover';
import { SizeMenu } from './SizeMenu';
-import { FLOATING_Z_INDEX } from '../../utils/zIndex';
-import {
- LinkExternalIcon,
- TrashIcon,
- ChevronDownIcon,
-} from '@primer/octicons-react';
+import { ChevronDownIcon } from '@primer/octicons-react';
import {formatWorkPackageId} from '../../utils/id';
import { useTapActivation } from '../../utils/tapActivation';
+import { OptionsButton, OptionsPopover } from '../shared/OptionsPopover';
export interface WpOptionsProps {
wp?:WorkPackage;
@@ -30,62 +24,10 @@ export interface WpOptionsProps {
onResizeBlock?:(size:BlockWpSize) => void;
}
-const Popover = styled.div.attrs({
- className: 'op-bn-inline-options',
- 'data-testid': 'popover-content',
-})`
- ${defaultWpVariables}
- position: absolute;
- z-index: ${FLOATING_Z_INDEX.options};
- ${menuSurfaceStyles}
- box-shadow: var(--bn-shadow-medium);
- border-radius: var(--bn-border-radius-large);
- padding: var(--spacer-s);
- display: flex;
- align-items: center;
- gap: 2px;
- bottom: calc(100% + 6px);
- left: 0;
- white-space: nowrap;
-`;
-
-const PopBtn = styled.button<{ $danger?:boolean }>`
- background: none;
- border: none;
- border-radius: var(--bn-border-radius-small);
- padding: var(--spacer-s) var(--spacer-m);
- cursor: pointer;
- font-size: 0.82em;
- color: ${({ $danger }) =>
- $danger
- ? 'var(--mantine-color-red-8)'
- : 'var(--bn-colors-editor-text, #333)'};
- display: flex;
- align-items: center;
- gap: var(--spacer-s);
- line-height: 1;
- &:hover {
- background-color: var(
- --bn-colors-highlights-gray-background,
- #f5f5f5
- );
- }
- svg { flex-shrink: 0; }
-`;
-
-const Divider = styled.div`
- width: 1px;
- height: 18px;
- background: var(--mantine-color-default-border);
- margin: 0 2px;
-`;
-
const SizeButtonWrapper = styled.div`
position: relative;
`;
-const IcOpen = () => ;
-const IcDelete = () => ;
const IcChevron = () => ;
export const WpOptionsPopover = ({
@@ -105,8 +47,6 @@ export const WpOptionsPopover = ({
const [showSizes, setShowSizes] = useState(false);
const tapProps = useTapActivation();
- const popoverRef = useRef(null);
- useAnchoredPopover({ anchorEl, popoverRef, placement: 'above' });
const [sizeButtonEl, setSizeButtonEl] = useState(null);
@@ -140,34 +80,17 @@ export const WpOptionsPopover = ({
closeMenu();
};
- const content = (
- // stopPropagation stops the outside-tap handlers from closing the popover.
- // Do NOT add preventDefault: on iOS it suppresses the first tap's click, so
- // every button then needs a priming tap.
- e.stopPropagation()}
- onTouchStart={(e) => e.stopPropagation()}
+ return (
+
- {openId && (
- <>
- {
- event?.stopPropagation();
- window.open(linkToWorkPackage(openId), '_blank', 'noopener,noreferrer');
- })}
- >
- {t('options.open')}
-
-
-
- >
- )}
-
-
{displayedSize}
-
+
{showSizes && (
)}
-
-
-
- {
- event?.stopPropagation();
- onRemove?.();
- onClose();
- })}
- >
- {t('options.remove')}
-
-
+
);
-
- return {content};
};
diff --git a/lib/components/WorkPackage/PreviewPopover.tsx b/lib/components/WorkPackage/PreviewPopover.tsx
index ce5c6806..4c956adb 100644
--- a/lib/components/WorkPackage/PreviewPopover.tsx
+++ b/lib/components/WorkPackage/PreviewPopover.tsx
@@ -1,14 +1,14 @@
import { useRef, type ReactNode } from 'react';
import styled from 'styled-components';
-import { defaultWpVariables, menuSurfaceStyles } from './atoms';
-import { useAnchoredPopover, PopoverPortal } from './anchoredPopover';
+import { editorThemeVariables, menuSurfaceStyles } from '../shared/theme';
+import { useAnchoredPopover, PopoverPortal } from '../shared/anchoredPopover';
import { FLOATING_Z_INDEX } from '../../utils/zIndex';
const PreviewContainer = styled.div.attrs({
className: 'op-bn-wp-preview',
'data-testid': 'wp-preview',
})`
- ${defaultWpVariables}
+ ${editorThemeVariables}
position: absolute;
z-index: ${FLOATING_Z_INDEX.preview};
top: calc(100% + 6px);
diff --git a/lib/components/WorkPackage/SizeMenu.tsx b/lib/components/WorkPackage/SizeMenu.tsx
index 4b479af7..9b744b5d 100644
--- a/lib/components/WorkPackage/SizeMenu.tsx
+++ b/lib/components/WorkPackage/SizeMenu.tsx
@@ -1,8 +1,8 @@
import { Fragment, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
-import { menuSurfaceStyles } from './atoms';
-import { useAnchoredPopover } from './anchoredPopover';
+import { menuSurfaceStyles } from '../shared/theme';
+import { useAnchoredPopover } from '../shared/anchoredPopover';
import { useTapActivation } from '../../utils/tapActivation';
import type { BlockWpSize, InlineWpSize, WpSize } from './types';
diff --git a/lib/components/WorkPackage/UnavailableCard.tsx b/lib/components/WorkPackage/UnavailableCard.tsx
index 71720fe0..b3939a7c 100644
--- a/lib/components/WorkPackage/UnavailableCard.tsx
+++ b/lib/components/WorkPackage/UnavailableCard.tsx
@@ -1,13 +1,12 @@
import type { ReactNode } from 'react';
import styled from 'styled-components';
import { useTranslation } from 'react-i18next';
+import { editorThemeVariables, NODE_CONTENT_GAP } from '../shared/theme';
import {
- defaultWpVariables,
WorkPackageId,
WorkPackageTitleLink,
workPackageLinkProps,
} from './atoms';
-import { CHIP_STYLES } from './tokens';
import { formatWorkPackageId } from '../../utils/id';
interface UnavailableCardProps {
@@ -21,7 +20,7 @@ interface UnavailableCardProps {
const UnavailableWorkPackage = styled.div.attrs({
className: 'op-bn-work-package-placeholder',
})`
- ${defaultWpVariables}
+ ${editorThemeVariables}
padding: var(--spacer-m) var(--spacer-l);
border-radius: var(--bn-border-radius-small);
`;
@@ -39,7 +38,7 @@ const UnavailableMessageHeader = styled.div.attrs({
color: var(--bn-colors-editor-text) !important;
display: flex;
align-items: center;
- gap: ${CHIP_STYLES.gap};
+ gap: ${NODE_CONTENT_GAP};
`;
export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHeader }:UnavailableCardProps) => {
@@ -55,7 +54,14 @@ export const UnavailableCard = ({ headerKey, messageKey, icon, displayId, linkHe
{displayId && {formatWorkPackageId(displayId)}}
{linkHeader && displayId
- ? {header}
+ ? (
+ event.stopPropagation()}
+ >
+ {header}
+
+ )
: header}
diff --git a/lib/components/WorkPackage/atoms.tsx b/lib/components/WorkPackage/atoms.tsx
index bd5b3a00..8df78a19 100644
--- a/lib/components/WorkPackage/atoms.tsx
+++ b/lib/components/WorkPackage/atoms.tsx
@@ -1,55 +1,14 @@
-import type { MouseEvent } from 'react';
import styled, { css } from 'styled-components';
import {
defaultColorStyles,
- metaTextColor,
typeTextColor,
} from '../../services/colors';
import { linkToWorkPackage } from '../../services/openProjectApi';
+import { newTabLinkProps } from '../../utils/links';
/* lets a line break between two meta parts that are each kept unbroken */
export const WRAP_OPPORTUNITY = '\u200B';
-export const defaultWpVariables = css`
- --spacer-s: 4px;
- --spacer-m: 8px;
- --spacer-l: 12px;
- --spacer-xl: 16px;
- --spacer-xxl: 20px;
-
- /* BlockNote's node-selection outline color; not exposed by BlockNote as a variable, so defined here */
- --blocknote-focus-color: rgb(100, 160, 255);
-
- --lightness-threshold: 0.453;
- --background-alpha: 0.18;
-
- --op-chip-bg: var(--bgColor-muted, #f6f8fa);
- --op-item-hover-bg: var(--bn-colors-highlights-gray-background, #f0f0f0);
- --op-wp-meta-color: ${metaTextColor};
-
- [data-color-scheme="dark"] & {
- --lightness-threshold: 0.6;
- --background-alpha: 0.10;
- --op-chip-bg: var(--bgColor-muted, #151b23);
- --op-item-hover-bg: rgba(255, 255, 255, 0.12);
- }
-`;
-
-export const menuSurfaceStyles = css`
- background-color: var(--bn-colors-menu-background, #fff);
- color: var(--bn-colors-menu-text, #3f3f3f);
-`;
-
-// The -webkit- prefix is not redundant: Safari (including iOS) implements only the prefixed
-// property and styled-components v6 no longer auto-prefixes, so unprefixed alone leaves the
-// chip text selectable there. Belongs on the container only: `user-select` is not inherited,
-// but `auto` on a descendant resolves to `none` under a non-selectable parent.
-export const nonSelectableStyles = css`
- -webkit-user-select: none;
- user-select: none;
- -webkit-touch-callout: none;
-`;
-
export const WorkPackageId = styled.span.attrs({
className: 'op-bn-work-package--id',
})<{ $compact?:boolean }>`
@@ -117,13 +76,7 @@ export const WorkPackageTitle = styled.span.attrs({
overflow-wrap: anywhere;
`;
-export const workPackageLinkProps = (displayId:string) => ({
- href: linkToWorkPackage(displayId),
- target: '_blank' as const,
- rel: 'noopener noreferrer',
- /* keep link clicks from toggling the surrounding chip/card popover */
- onClick: (e:MouseEvent) => e.stopPropagation(),
-});
+export const workPackageLinkProps = (displayId:string) => newTabLinkProps(linkToWorkPackage(displayId));
export const WorkPackageTitleLink = styled.a<{ $compact?:boolean }>`
cursor: pointer;
diff --git a/lib/components/WorkPackage/index.ts b/lib/components/WorkPackage/index.ts
index 17d656e8..3f360abf 100644
--- a/lib/components/WorkPackage/index.ts
+++ b/lib/components/WorkPackage/index.ts
@@ -5,7 +5,6 @@ export {
WorkPackageTitle,
WorkPackageTitleLink,
workPackageLinkProps,
- defaultWpVariables,
} from './atoms';
export { WpOptionsPopover } from './OptionsPopover';
export { WpPreviewPopover } from './PreviewPopover';
diff --git a/lib/components/WorkPackage/tokens.ts b/lib/components/WorkPackage/tokens.ts
deleted file mode 100644
index 57b4e89e..00000000
--- a/lib/components/WorkPackage/tokens.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-export const CHIP_STYLES = {
- bg: 'var(--op-chip-bg)',
- radius: 'var(--bn-border-radius)',
- gap: '6px',
- padding: {
- xxs: '2.5px 6px',
- xs: '1.5px 6px',
- s: '1.5px 6px',
- },
-
- fontSize: '12px',
-
- focusShadow: '0 0 0 4px var(--blocknote-focus-color)',
- // Inline chips draw the selection ring mostly inward (so it doesn't cover adjacent characters and
- // the chip stays flush at line starts) plus a thin 1px outward band so it reads like a slightly
- // smaller version of the block card ring. Keeping it mostly inset is what removes the need for a
- // horizontal margin that would otherwise indent the chip at line starts.
- inlineFocusShadow:
- 'inset 0 0 0 2px var(--blocknote-focus-color), 0 0 0 1px var(--blocknote-focus-color)',
-} as const;
\ No newline at end of file
diff --git a/lib/components/index.ts b/lib/components/index.ts
index cdd737b5..4a4ff0f2 100644
--- a/lib/components/index.ts
+++ b/lib/components/index.ts
@@ -4,3 +4,4 @@ export { getOpenProjectSlashMenuItems } from './SlashMenu';
export { ShadowDomWrapper } from './ShadowDomWrapper';
export { OpenProjectFormattingToolbar, useCreateWorkPackageFromSelection } from './FormattingToolbar';
export type { CreateWorkPackageFromSelection } from './FormattingToolbar';
+export { openProjectUserMentionSpec } from './UserMention';
diff --git a/lib/components/shared/Avatar/Avatar.tsx b/lib/components/shared/Avatar/Avatar.tsx
new file mode 100644
index 00000000..d970ea60
--- /dev/null
+++ b/lib/components/shared/Avatar/Avatar.tsx
@@ -0,0 +1,55 @@
+import { useState } from 'react';
+import styled from 'styled-components';
+import { avatarUrlOf, userApiPath } from '../../../services/openProjectApi';
+import { avatarColorOf, initialsOf } from './avatars';
+
+interface AvatarProps {
+ userId:string | number;
+ name:string;
+ size:number;
+}
+
+const Circle = styled.span.attrs({ className: 'op-bn-avatar' })<{ $size:number; $color:string }>`
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: ${({ $size }) => $size}px;
+ height: ${({ $size }) => $size}px;
+ border-radius: 50%;
+ overflow: hidden;
+ background: ${({ $color }) => $color};
+ color: #fff;
+ font-size: ${({ $size }) => Math.round($size * 0.45)}px;
+ font-weight: 600;
+ line-height: 1;
+ vertical-align: middle;
+`;
+
+const Image = styled.img.attrs({ alt: '', draggable: false })`
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+`;
+
+export function Avatar({ userId, name, size }:AvatarProps) {
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const href = userApiPath(userId);
+
+ return (
+
+ setImageLoaded(true)}
+ />
+ {!imageLoaded && initialsOf(name)}
+
+ );
+}
diff --git a/lib/components/shared/Avatar/avatars.ts b/lib/components/shared/Avatar/avatars.ts
new file mode 100644
index 00000000..e27a2b24
--- /dev/null
+++ b/lib/components/shared/Avatar/avatars.ts
@@ -0,0 +1,25 @@
+export interface AvatarPrincipal {
+ name:string;
+ href?:string | null;
+}
+
+// Both follow OpenProject's own avatars, so a person looks the same in the editor as elsewhere.
+
+export function initialsOf(name:string):string {
+ // Spread to iterate by code points, so an emoji is not split in half.
+ const characters = [...name];
+ const lastSpace = characters.lastIndexOf(' ');
+ const first = characters[0]?.toUpperCase() ?? '';
+ if (lastSpace === -1) return first;
+ return first + (characters[lastSpace + 1]?.toUpperCase() ?? '');
+}
+
+export function avatarColorOf(principal:AvatarPrincipal):string {
+ const id = principal.href?.split('/').pop() ?? '';
+ const text = `${id}${principal.name}`;
+ let hash = 0;
+ for (let index = 0; index < text.length; index++) {
+ hash = text.charCodeAt(index) + ((hash << 5) - hash);
+ }
+ return `hsl(${hash % 360}, 50%, 30%)`;
+}
diff --git a/lib/components/shared/Avatar/index.ts b/lib/components/shared/Avatar/index.ts
new file mode 100644
index 00000000..d3fb6dfa
--- /dev/null
+++ b/lib/components/shared/Avatar/index.ts
@@ -0,0 +1 @@
+export { Avatar } from './Avatar';
diff --git a/lib/components/shared/OptionsPopover.tsx b/lib/components/shared/OptionsPopover.tsx
new file mode 100644
index 00000000..43aa206a
--- /dev/null
+++ b/lib/components/shared/OptionsPopover.tsx
@@ -0,0 +1,137 @@
+import { useRef } from 'react';
+import type { ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
+import styled from 'styled-components';
+import { LinkExternalIcon, TrashIcon } from '@primer/octicons-react';
+import { editorThemeVariables, menuSurfaceStyles } from './theme';
+import { useAnchoredPopover, PopoverPortal } from './anchoredPopover';
+import { FLOATING_Z_INDEX } from '../../utils/zIndex';
+import { useTapActivation } from '../../utils/tapActivation';
+
+export interface OptionsPopoverProps {
+ anchorEl?:HTMLElement | null;
+ // Without an href there is nothing to open, so the Open button is left out.
+ openHref?:string;
+ openAriaLabel?:string;
+ removeAriaLabel:string;
+ onRemove?:() => void;
+ onClose:() => void;
+ // Extra sections between Open and Remove.
+ children?:ReactNode;
+}
+
+const Popover = styled.div.attrs({
+ className: 'op-bn-inline-options',
+ 'data-testid': 'popover-content',
+})`
+ ${editorThemeVariables}
+ position: absolute;
+ z-index: ${FLOATING_Z_INDEX.options};
+ ${menuSurfaceStyles}
+ box-shadow: var(--bn-shadow-medium);
+ border-radius: var(--bn-border-radius-large);
+ padding: var(--spacer-s);
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ bottom: calc(100% + 6px);
+ left: 0;
+ white-space: nowrap;
+`;
+
+export const OptionsButton = styled.button<{ $danger?:boolean }>`
+ background: none;
+ border: none;
+ border-radius: var(--bn-border-radius-small);
+ padding: var(--spacer-s) var(--spacer-m);
+ cursor: pointer;
+ font-size: 0.82em;
+ color: ${({ $danger }) =>
+ $danger
+ ? 'var(--mantine-color-red-8)'
+ : 'var(--bn-colors-editor-text, #333)'};
+ display: flex;
+ align-items: center;
+ gap: var(--spacer-s);
+ line-height: 1;
+ &:hover {
+ background-color: var(
+ --bn-colors-highlights-gray-background,
+ #f5f5f5
+ );
+ }
+ svg { flex-shrink: 0; }
+`;
+
+const Divider = styled.div`
+ width: 1px;
+ height: 18px;
+ background: var(--mantine-color-default-border);
+ margin: 0 2px;
+`;
+
+export const OptionsPopover = ({
+ anchorEl,
+ openHref,
+ openAriaLabel,
+ removeAriaLabel,
+ onRemove,
+ onClose,
+ children,
+}:OptionsPopoverProps) => {
+ const { t } = useTranslation();
+ const tapProps = useTapActivation();
+ const popoverRef = useRef(null);
+ useAnchoredPopover({ anchorEl, popoverRef, placement: 'above' });
+
+ const content = (
+ // stopPropagation stops the outside-tap handlers from closing the popover.
+ // Do NOT add preventDefault: on iOS it suppresses the first tap's click, so
+ // every button then needs a priming tap.
+ e.stopPropagation()}
+ onTouchStart={(e) => e.stopPropagation()}
+ >
+ {openHref && (
+ <>
+ {
+ event?.stopPropagation();
+ window.open(openHref, '_blank', 'noopener,noreferrer');
+ })}
+ >
+ {t('options.open')}
+
+
+
+ >
+ )}
+
+ {children && (
+ <>
+ {children}
+
+ >
+ )}
+
+ {
+ event?.stopPropagation();
+ onRemove?.();
+ onClose();
+ })}
+ >
+ {t('options.remove')}
+
+
+ );
+
+ return {content};
+};
diff --git a/lib/components/shared/SearchMessage.tsx b/lib/components/shared/SearchMessage.tsx
new file mode 100644
index 00000000..6e87c838
--- /dev/null
+++ b/lib/components/shared/SearchMessage.tsx
@@ -0,0 +1,14 @@
+import styled from 'styled-components';
+
+export const SearchMessage = styled.div.attrs({
+ className: 'op-bn-search--message',
+ role: 'status',
+})`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacer-m);
+ padding: var(--spacer-m) var(--spacer-l);
+ font-size: 0.85em;
+ color: var(--bn-colors-highlights-gray-text, #888);
+`;
diff --git a/lib/components/shared/SuggestionListbox.tsx b/lib/components/shared/SuggestionListbox.tsx
new file mode 100644
index 00000000..686b775e
--- /dev/null
+++ b/lib/components/shared/SuggestionListbox.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useMemo, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import styled from 'styled-components';
+import { useTranslation } from 'react-i18next';
+import type { SuggestionMenuProps } from '@blocknote/react';
+import type { SuggestionMenuItem, SuggestionSearchState } from '../../hooks/useSuggestionSearch';
+import { editorThemeVariables, menuSurfaceStyles } from './theme';
+import { SearchMessage } from './SearchMessage';
+import { Spinner } from '../Spinner';
+import { supportsHover } from '../../utils/device';
+import { useActiveOptionInView } from '../../hooks/useActiveOptionInView';
+import { useTapActivation } from '../../utils/tapActivation';
+
+const Menu = styled.div`
+ ${editorThemeVariables}
+ ${menuSurfaceStyles}
+ box-shadow: var(--bn-shadow-medium);
+ border-radius: var(--bn-border-radius-large);
+ padding: var(--spacer-s);
+ min-width: 320px;
+ max-width: 480px;
+ overflow-y: auto;
+ min-height: 0;
+`;
+
+const MenuItem = styled.div<{ $highlighted:boolean }>`
+ border-radius: var(--bn-border-radius-small);
+ background: ${({ $highlighted }) => ($highlighted ? 'var(--op-item-hover-bg)' : 'transparent')};
+ cursor: pointer;
+ padding: 0 var(--spacer-s);
+`;
+
+interface SuggestionListboxProps extends SuggestionMenuProps {
+ // Names the classes and test id: `op-bn-`, `op-bn--item`, ``.
+ name:string;
+ ariaLabel:string;
+ loadingMessage:string;
+ searchState:SuggestionSearchState;
+ // Shown for an empty query instead of the search outcome.
+ promptMessage?:string;
+ optionKey:(option:T) => string | number;
+ renderOption:(option:T) => ReactNode;
+}
+
+export function SuggestionListbox({
+ items, loadingState, selectedIndex, onItemClick,
+ name, ariaLabel, loadingMessage, searchState, promptMessage, optionKey, renderOption,
+}:SuggestionListboxProps) {
+ const { t } = useTranslation();
+ const { query, results: options, error } = searchState;
+ const canHover = useMemo(() => supportsHover(), []);
+ const [hoveredIndex, setHoveredIndex] = useState(null);
+ const menuRef = useRef(null);
+ const tapProps = useTapActivation();
+
+ useActiveOptionInView(menuRef, selectedIndex ?? -1, options);
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setHoveredIndex(null);
+ }, [selectedIndex, options]);
+
+ const surface = { className: `op-bn-${name}`, 'data-testid': name };
+
+ let emptyMessage:string | null = null;
+ if (promptMessage !== undefined && !query) emptyMessage = promptMessage;
+ else if (error) emptyMessage = t('search.error');
+ else if (options.length === 0) emptyMessage = t('search.noResults');
+
+ const pick = (index:number) => {
+ const item = items[index];
+ if (item) onItemClick?.(item);
+ };
+
+ if (loadingState !== 'loaded') {
+ return ;
+ }
+
+ if (emptyMessage !== null) {
+ return ;
+ }
+
+ const highlightedIndex = hoveredIndex ?? selectedIndex;
+
+ return (
+
+ );
+}
diff --git a/lib/components/WorkPackage/anchoredPopover.tsx b/lib/components/shared/anchoredPopover.tsx
similarity index 100%
rename from lib/components/WorkPackage/anchoredPopover.tsx
rename to lib/components/shared/anchoredPopover.tsx
diff --git a/lib/components/shared/inlineNodeStyles.ts b/lib/components/shared/inlineNodeStyles.ts
new file mode 100644
index 00000000..dfb66493
--- /dev/null
+++ b/lib/components/shared/inlineNodeStyles.ts
@@ -0,0 +1,48 @@
+import { css } from 'styled-components';
+import { editorThemeVariables, NODE_CONTENT_GAP, nonSelectableStyles } from './theme';
+
+export const INLINE_NODE_STYLES = {
+ bg: 'var(--op-chip-bg)',
+ radius: 'var(--bn-border-radius)',
+ padding: {
+ xxs: '2.5px 6px',
+ xs: '1.5px 6px',
+ s: '1.5px 6px',
+ },
+
+ fontSize: '12px',
+
+ // Inline chips draw the selection ring mostly inward (so it doesn't cover adjacent characters and
+ // the chip stays flush at line starts) plus a thin 1px outward band so it reads like a slightly
+ // smaller version of the block card ring. Keeping it mostly inset is what removes the need for a
+ // horizontal margin that would otherwise indent the chip at line starts.
+ inlineFocusShadow:
+ 'inset 0 0 0 2px var(--blocknote-focus-color), 0 0 0 1px var(--blocknote-focus-color)',
+} as const;
+
+export const inlineNodeBaseStyles = css`
+ display: inline;
+ border-radius: ${INLINE_NODE_STYLES.radius};
+ background: ${INLINE_NODE_STYLES.bg};
+ font-size: ${INLINE_NODE_STYLES.fontSize};
+ box-decoration-break: clone;
+ -webkit-box-decoration-break: clone;
+
+ & > * {
+ vertical-align: baseline;
+ }
+
+ & > *:not(:last-child) {
+ margin-right: ${NODE_CONTENT_GAP};
+ }
+`;
+
+export const inlineNodeContainerStyles = css`
+ ${editorThemeVariables}
+ display: inline;
+ ${nonSelectableStyles}
+ border-radius: ${INLINE_NODE_STYLES.radius};
+ position: relative;
+ line-height: 1;
+ cursor: pointer;
+`;
diff --git a/lib/components/shared/theme.ts b/lib/components/shared/theme.ts
new file mode 100644
index 00000000..7d16f558
--- /dev/null
+++ b/lib/components/shared/theme.ts
@@ -0,0 +1,42 @@
+import { css } from 'styled-components';
+import { metaTextColor } from '../../services/colors';
+
+export const editorThemeVariables = css`
+ --spacer-s: 4px;
+ --spacer-m: 8px;
+ --spacer-l: 12px;
+ --spacer-xl: 16px;
+ --spacer-xxl: 20px;
+
+ /* BlockNote's node-selection outline color; not exposed by BlockNote as a variable, so defined here */
+ --blocknote-focus-color: rgb(100, 160, 255);
+
+ --lightness-threshold: 0.453;
+ --background-alpha: 0.18;
+
+ --op-chip-bg: var(--bgColor-muted, #f6f8fa);
+ --op-item-hover-bg: var(--bn-colors-highlights-gray-background, #f0f0f0);
+ --op-wp-meta-color: ${metaTextColor};
+
+ [data-color-scheme="dark"] & {
+ --lightness-threshold: 0.6;
+ --background-alpha: 0.10;
+ --op-chip-bg: var(--bgColor-muted, #151b23);
+ --op-item-hover-bg: rgba(255, 255, 255, 0.12);
+ }
+`;
+
+// Shared by work package cards and inline nodes.
+export const NODE_CONTENT_GAP = '6px';
+export const BLOCK_FOCUS_SHADOW = '0 0 0 4px var(--blocknote-focus-color)';
+
+export const menuSurfaceStyles = css`
+ background-color: var(--bn-colors-menu-background, #fff);
+ color: var(--bn-colors-menu-text, #3f3f3f);
+`;
+
+export const nonSelectableStyles = css`
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+`;
diff --git a/lib/hooks/useDebouncedSearch.ts b/lib/hooks/useDebouncedSearch.ts
new file mode 100644
index 00000000..d685b349
--- /dev/null
+++ b/lib/hooks/useDebouncedSearch.ts
@@ -0,0 +1,47 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+interface DebouncedSearchOptions {
+ debounce?:number;
+ searchBlank?:boolean;
+}
+
+/**
+ * For BlockNote's `getItems`, which awaits every call: a call superseded by a
+ * newer one, or cut off by unmounting, must still settle, so it resolves empty
+ * instead of hanging.
+ */
+export function useDebouncedSearch(
+ search:(query:string) => Promise,
+ { debounce = 300, searchBlank = false }:DebouncedSearchOptions = {},
+):{ search:(query:string) => Promise; cancel:() => void } {
+ const timerRef = useRef | null>(null);
+ const pendingResolveRef = useRef<((results:T[]) => void) | null>(null);
+
+ const cancel = useCallback(() => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = null;
+ pendingResolveRef.current?.([]);
+ pendingResolveRef.current = null;
+ }, []);
+
+ useEffect(() => cancel, [cancel]);
+
+ const debouncedSearch = useCallback((query:string) => {
+ cancel();
+
+ if (!searchBlank && !query.trim()) return Promise.resolve([]);
+
+ return new Promise((resolve, reject) => {
+ pendingResolveRef.current = resolve;
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ pendingResolveRef.current = null;
+ Promise.resolve().then(() => search(query)).then(resolve, (error:unknown) => {
+ reject(error instanceof Error ? error : new Error(String(error)));
+ });
+ }, debounce);
+ });
+ }, [search, debounce, searchBlank, cancel]);
+
+ return { search: debouncedSearch, cancel };
+}
diff --git a/lib/hooks/useInlineNodeOptions.ts b/lib/hooks/useInlineNodeOptions.ts
new file mode 100644
index 00000000..07483bff
--- /dev/null
+++ b/lib/hooks/useInlineNodeOptions.ts
@@ -0,0 +1,60 @@
+import { useCallback, useState } from 'react';
+import type { RefObject } from 'react';
+import type { AnyEditor } from '../editorTypes';
+import { findInlineNodeAtDOM, removeInlineNodeAt, selectInlineNodeAt } from '../utils/inlineNodes';
+import type { FoundInlineNode } from '../utils/inlineNodes';
+import { useTapActivation } from '../utils/tapActivation';
+import type { TapActivationProps } from '../utils/tapActivation';
+import { usePressOutside } from './usePressOutside';
+import { useSuppressFormattingToolbar } from './useSuppressFormattingToolbar';
+
+export interface InlineNodeOptions {
+ optionsOpen:boolean;
+ closeOptions:() => void;
+ // `beforeToggle` is for a caller with a popover of its own to close first.
+ activationProps:(beforeToggle?:() => void) => TapActivationProps;
+ findNode:() => FoundInlineNode | null;
+ removeNode:() => void;
+}
+
+// The options popover of an atom inline node (work package chip, user mention):
+// activating the node selects it and toggles the popover, a press outside closes it.
+export function useInlineNodeOptions(
+ editor:AnyEditor | undefined,
+ nodeRef:RefObject,
+ nodeType:string,
+):InlineNodeOptions {
+ const [optionsOpen, setOptionsOpen] = useState(false);
+ const closeOptions = useCallback(() => setOptionsOpen(false), []);
+
+ useSuppressFormattingToolbar(editor, optionsOpen);
+ usePressOutside(nodeRef, optionsOpen, closeOptions);
+
+ const findNode = () => {
+ if (!editor || !nodeRef.current) return null;
+ return findInlineNodeAtDOM(editor, nodeRef.current, nodeType);
+ };
+
+ const selectNode = () => {
+ if (!editor) return;
+ const found = findNode();
+ if (found) selectInlineNodeAt(editor, found.position, nodeType);
+ editor.getExtension('formattingToolbar')?.store?.setState(false);
+ };
+
+ const removeNode = () => {
+ const found = findNode();
+ if (editor && found) removeInlineNodeAt(editor, found.position, nodeType);
+ };
+
+ const tapProps = useTapActivation();
+ const activationProps = (beforeToggle?:() => void) => tapProps((event) => {
+ event?.preventDefault();
+ event?.stopPropagation();
+ beforeToggle?.();
+ setOptionsOpen((open) => !open);
+ selectNode();
+ });
+
+ return { optionsOpen, closeOptions, activationProps, findNode, removeNode };
+}
diff --git a/lib/hooks/usePressOutside.ts b/lib/hooks/usePressOutside.ts
new file mode 100644
index 00000000..68be876e
--- /dev/null
+++ b/lib/hooks/usePressOutside.ts
@@ -0,0 +1,25 @@
+import { useEffect } from 'react';
+import type { RefObject } from 'react';
+
+// Touch is listened for in its own right: a tap another element answers never
+// becomes a mousedown. composedPath() sees through a shadow root, where the
+// target is retargeted to the host.
+export function usePressOutside(
+ elementRef:RefObject,
+ active:boolean,
+ onPressOutside:() => void,
+):void {
+ useEffect(() => {
+ if (!active) return;
+ const handlePress = (event:Event) => {
+ const element = elementRef.current;
+ if (element && !event.composedPath().includes(element)) onPressOutside();
+ };
+ document.addEventListener('mousedown', handlePress);
+ document.addEventListener('touchstart', handlePress);
+ return () => {
+ document.removeEventListener('mousedown', handlePress);
+ document.removeEventListener('touchstart', handlePress);
+ };
+ }, [elementRef, active, onPressOutside]);
+}
diff --git a/lib/hooks/useSuggestionSearch.ts b/lib/hooks/useSuggestionSearch.ts
new file mode 100644
index 00000000..c1b9fb4d
--- /dev/null
+++ b/lib/hooks/useSuggestionSearch.ts
@@ -0,0 +1,84 @@
+import { useCallback, useMemo, useRef } from 'react';
+import type { FC, RefObject } from 'react';
+import type { SuggestionMenuProps } from '@blocknote/react';
+import type { AnyEditor } from '../editorTypes';
+import { restoreTypedQuery } from '../utils/insertInline';
+import { closeSuggestionMenu } from '../utils/suggestionMenu';
+
+export interface SuggestionMenuItem {
+ title:string;
+ onItemClick:() => void;
+}
+
+export interface SuggestionSearchState {
+ query:string;
+ results:T[];
+ error:string | null;
+}
+
+export type SuggestionPlan =
+ | { kind:'close' }
+ | { kind:'prompt' }
+ | { kind:'search'; pick:(result:T) => void };
+
+export type SuggestionMenuComponent = FC>;
+
+interface SuggestionSearchOptions {
+ trigger:string;
+ // Callers pass stable functions: a new `getItems` makes BlockNote search again.
+ planFor:(query:string) => SuggestionPlan;
+ search:(query:string) => Promise;
+ cancelSearch:() => void;
+ logPrefix:string;
+ createMenu:(searchStateRef:RefObject>) => SuggestionMenuComponent;
+}
+
+export function useSuggestionSearch(
+ editor:AnyEditor,
+ { trigger, planFor, search, cancelSearch, logPrefix, createMenu }:SuggestionSearchOptions,
+) {
+ const searchStateRef = useRef>({ query: '', results: [], error: null });
+ const latestRequestRef = useRef(0);
+
+ const getItems = useCallback(async (query:string):Promise => {
+ latestRequestRef.current += 1;
+ const request = latestRequestRef.current;
+ const typedQueryItems = [{ title: query, onItemClick: () => restoreTypedQuery(editor, trigger, query) }];
+
+ const plan = planFor(query);
+ if (plan.kind !== 'search') cancelSearch();
+ if (plan.kind === 'close') {
+ closeSuggestionMenu(editor);
+ return [];
+ }
+ if (plan.kind === 'prompt') {
+ searchStateRef.current = { query, results: [], error: null };
+ return typedQueryItems;
+ }
+
+ try {
+ const results = await search(query);
+ if (request !== latestRequestRef.current) return [];
+ searchStateRef.current = { query, results, error: null };
+
+ if (results.length === 0) return typedQueryItems;
+ return results.map((result) => ({ title: query, onItemClick: () => plan.pick(result) }));
+ } catch (error) {
+ console.error(logPrefix, error);
+ if (request === latestRequestRef.current) {
+ searchStateRef.current = {
+ query,
+ results: [],
+ error: error instanceof Error ? error.message : 'Unknown error',
+ };
+ }
+ return typedQueryItems;
+ }
+ }, [editor, trigger, planFor, search, cancelSearch, logPrefix]);
+
+ /* eslint-disable react-hooks/refs */
+ const Menu = useMemo(() => createMenu(searchStateRef), [createMenu]);
+ /* eslint-enable react-hooks/refs */
+
+ return { getItems, Menu };
+}
diff --git a/lib/hooks/useWorkPackageSearch.ts b/lib/hooks/useWorkPackageSearch.ts
index 2c6609a8..eedd02a6 100644
--- a/lib/hooks/useWorkPackageSearch.ts
+++ b/lib/hooks/useWorkPackageSearch.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import type { WorkPackage } from '../openProjectTypes';
import { searchWorkPackages } from '../services/openProjectApi';
+import { useDebouncedSearch } from './useDebouncedSearch';
export const MAX_SEARCH_RESULTS = 5;
@@ -18,10 +19,6 @@ export function useWorkPackageSearch(
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
- // Used to cancel debounce in imperative search()
- const debounceTimerRef = useRef | null>(null);
- const pendingResolveRef = useRef<((results:WorkPackage[]) => void) | null>(null);
-
// Reactive search (used by SearchDropdown)
useEffect(() => {
let active = true;
@@ -63,40 +60,23 @@ export function useWorkPackageSearch(
};
}, [searchQuery, debounce]);
- // Imperative search (used by BlockNote getItems — must return results immediately)
- const search = useCallback(
- (query:string):Promise => {
- // A superseded call must still settle, otherwise its caller awaits forever
- if (debounceTimerRef.current !== null) {
- clearTimeout(debounceTimerRef.current);
- debounceTimerRef.current = null;
- }
- pendingResolveRef.current?.([]);
- pendingResolveRef.current = null;
+ const { search: debouncedSearch, cancel: cancelSearch } = useDebouncedSearch(searchWorkPackages, { debounce });
- if (!query.trim()) {
- setSearchResults([]);
- return Promise.resolve([]);
- }
+ // Imperative search (used by BlockNote getItems — must return results immediately)
+ const latestCallRef = useRef(0);
- return new Promise((resolve, reject) => {
- pendingResolveRef.current = resolve;
- debounceTimerRef.current = setTimeout(async () => {
- debounceTimerRef.current = null;
- pendingResolveRef.current = null;
- try {
- const results = await searchWorkPackages(query);
- setSearchResults(results);
- resolve(results);
- } catch (error) {
- setSearchResults([]);
- reject(error instanceof Error ? error : new Error(String(error)));
- }
- }, debounce);
- });
- },
- [debounce]
- );
+ const search = useCallback(async (query:string):Promise => {
+ latestCallRef.current += 1;
+ const call = latestCallRef.current;
+ try {
+ const results = await debouncedSearch(query);
+ if (call === latestCallRef.current) setSearchResults(results);
+ return results;
+ } catch (error) {
+ if (call === latestCallRef.current) setSearchResults([]);
+ throw error;
+ }
+ }, [debouncedSearch]);
return {
searchQuery,
@@ -105,5 +85,6 @@ export function useWorkPackageSearch(
loading,
error,
search,
+ cancelSearch,
};
}
diff --git a/lib/index.ts b/lib/index.ts
index 2d7b89ff..e6b7a89e 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -2,6 +2,7 @@ import './services/i18n.ts';
export {
openProjectWorkPackageBlockSpec,
openProjectWorkPackageInlineSpec,
+ openProjectUserMentionSpec,
getOpenProjectSlashMenuItems,
OpenProjectFormattingToolbar,
useCreateWorkPackageFromSelection,
@@ -9,8 +10,11 @@ export {
} from './components';
export type { CreateWorkPackageFromSelection } from './components';
export { initializeOpBlockNoteExtensions } from './initialize';
+export { UserMentionAuthorExtension } from './plugins/userMentionAuthorExtension';
export type { InlineWpSize, BlockWpSize, WpSize } from './components/WorkPackage/types';
-export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu } from './components/HashMenu';
+export { OpenProjectHashMenu, createHashWpMenuComponent, isHashWpQuery, useHashWpMenu, HASH_TRIGGER } from './components/HashMenu';
export type { HashMenuItem } from './components/HashMenu';
export { useWorkPackageSearch } from './hooks/useWorkPackageSearch';
export type { WorkPackage } from './openProjectTypes';
+export { OpenProjectMentionMenu, MENTION_TRIGGER } from './components/MentionMenu';
+export type { SuggestionMenuItem, SuggestionSearchState } from './hooks/useSuggestionSearch';
diff --git a/lib/initialize.ts b/lib/initialize.ts
index 8b49322b..f77aa97e 100644
--- a/lib/initialize.ts
+++ b/lib/initialize.ts
@@ -7,9 +7,10 @@ export function initializeOpBlockNoteExtensions(config:{
proxyUrl?:string,
locale:string,
projectId?:string | number,
+ currentUserId?:string | number,
}) {
initOpenProjectApi({ baseUrl: config.baseUrl, proxyUrl: config.proxyUrl });
void probeCreateWorkPackagePermission();
initLanguage(config.locale);
- initEditorContext({ projectId: config.projectId });
+ initEditorContext({ projectId: config.projectId, currentUserId: config.currentUserId });
}
diff --git a/lib/locales/en.ts b/lib/locales/en.ts
index d27a95c0..e0f2a1ce 100644
--- a/lib/locales/en.ts
+++ b/lib/locales/en.ts
@@ -97,6 +97,15 @@ export const en = {
'hashMenu': {
'typeToSearch': 'Type to search work packages…'
},
+ 'mentionMenu': {
+ 'loading': 'Searching people…',
+ 'ariaLabel': 'People to mention'
+ },
+ 'mention': {
+ 'ariaLabel': 'User {{name}}',
+ 'openAriaLabel': "Open {{name}}'s profile in new tab",
+ 'removeAriaLabel': 'Remove mention'
+ },
'spinner': {
'ariaLabel': 'Loading'
},
diff --git a/lib/openProjectTypes.ts b/lib/openProjectTypes.ts
index 698810bf..b3827362 100644
--- a/lib/openProjectTypes.ts
+++ b/lib/openProjectTypes.ts
@@ -124,3 +124,8 @@ export interface OpenProjectApiErrorBody extends OpenProjectApiErrorEntry {
}
export type OpColorMode = 'light' | 'dark';
+
+export interface Principal {
+ id:number;
+ name:string;
+}
diff --git a/lib/plugins/userMentionAuthorExtension.ts b/lib/plugins/userMentionAuthorExtension.ts
new file mode 100644
index 00000000..428eb86c
--- /dev/null
+++ b/lib/plugins/userMentionAuthorExtension.ts
@@ -0,0 +1,8 @@
+import { createExtension } from '@blocknote/core';
+import { userMentionAuthorPlugin } from './userMentionAuthorPlugin';
+
+// Has to be passed in `extensions`: BlockNote only registers the extensions of block specs, not of inline content specs.
+export const UserMentionAuthorExtension = createExtension(() => ({
+ key: 'userMentionAuthor',
+ prosemirrorPlugins: [userMentionAuthorPlugin()],
+}))();
diff --git a/lib/plugins/userMentionAuthorPlugin.ts b/lib/plugins/userMentionAuthorPlugin.ts
new file mode 100644
index 00000000..81fb62c5
--- /dev/null
+++ b/lib/plugins/userMentionAuthorPlugin.ts
@@ -0,0 +1,87 @@
+import { Plugin, PluginKey } from 'prosemirror-state';
+import type { Transaction } from 'prosemirror-state';
+import type { Node as ProsemirrorNode } from 'prosemirror-model';
+import { contextCurrentUserId } from '../services/editorContext';
+import { USER_MENTION_TYPE } from '../utils/nodeTypes';
+
+// y-prosemirror's `ySyncPluginKey`, set on every transaction from Yjs (collaborators, initial sync, undo).
+// Matched by name, as BlockNote does, so the bundle does not need y-prosemirror.
+const Y_SYNC_META_KEY = 'y-sync$';
+
+interface InsertedRange { from:number; to:number }
+
+interface LocalChanges {
+ insertedRanges:InsertedRange[];
+ removedMentions:Map;
+}
+
+function isFromYjs(transaction:Transaction):boolean {
+ return transaction.getMeta(Y_SYNC_META_KEY) !== undefined;
+}
+
+function isUserMention(node:ProsemirrorNode):boolean {
+ return node.type.name === USER_MENTION_TYPE;
+}
+
+function mentionKey(node:ProsemirrorNode):string {
+ return JSON.stringify([node.attrs.userId, node.attrs.mentionedBy]);
+}
+
+/** The ranges of the final document that local transactions inserted, and the mentions they removed. */
+function localChanges(transactions:readonly Transaction[]):LocalChanges {
+ let insertedRanges:InsertedRange[] = [];
+ const removedMentions = new Map();
+ for (const transaction of transactions) {
+ transaction.steps.forEach((step, index) => {
+ const stepMap = step.getMap();
+ insertedRanges = insertedRanges
+ .map(({ from, to }) => ({ from: stepMap.map(from, 1), to: stepMap.map(to, -1) }))
+ .filter(({ from, to }) => from < to);
+ if (isFromYjs(transaction)) return;
+
+ stepMap.forEach((oldStart, oldEnd, newStart, newEnd) => {
+ if (newStart < newEnd) insertedRanges.push({ from: newStart, to: newEnd });
+ transaction.docs[index].nodesBetween(oldStart, oldEnd, (node) => {
+ if (!isUserMention(node)) return;
+ const key = mentionKey(node);
+ removedMentions.set(key, (removedMentions.get(key) ?? 0) + 1);
+ });
+ });
+ });
+ }
+ return { insertedRanges, removedMentions };
+}
+
+/**
+ * Records the current user as `mentionedBy` on every user mention a local transaction inserts, unless the
+ * same transaction removed it (a move). The prop travels with the node, so a copy would keep the first author.
+ */
+export function userMentionAuthorPlugin():Plugin {
+ return new Plugin({
+ key: new PluginKey('userMentionAuthor'),
+ appendTransaction(transactions, _oldState, newState) {
+ if (!transactions.some((transaction) => transaction.docChanged)) return null;
+
+ // No author is better than a wrong one.
+ const mentionedBy = contextCurrentUserId() ?? '';
+ const { insertedRanges, removedMentions } = localChanges(transactions);
+ const stamp = newState.tr;
+ const seen = new Set();
+ for (const { from, to } of insertedRanges) {
+ newState.doc.nodesBetween(from, to, (node, position) => {
+ if (!isUserMention(node) || seen.has(position)) return;
+ seen.add(position);
+
+ const key = mentionKey(node);
+ const moved = removedMentions.get(key) ?? 0;
+ if (moved > 0) {
+ removedMentions.set(key, moved - 1);
+ } else if (node.attrs.mentionedBy !== mentionedBy) {
+ stamp.setNodeAttribute(position, 'mentionedBy', mentionedBy);
+ }
+ });
+ }
+ return stamp.docChanged ? stamp : null;
+ },
+ });
+}
diff --git a/lib/server.ts b/lib/server.ts
index 43dae833..fc065c8e 100644
--- a/lib/server.ts
+++ b/lib/server.ts
@@ -32,3 +32,12 @@ export type {
WorkPackageInlineExternalData,
WorkPackageInlineProps,
} from './components/InlineWorkPackage/externalHtml';
+
+export { userMentionConfig } from './components/UserMention/userMentionConfig';
+export { openProjectUserMentionStaticSpec } from './components/UserMention/staticSpec';
+export {
+ buildUserMentionStoredDOM,
+ userMentionTag,
+ parseUserMentionExternalHTML,
+} from './components/UserMention/externalHtml';
+export type { UserMentionProps } from './components/UserMention/externalHtml';
diff --git a/lib/services/editorContext.ts b/lib/services/editorContext.ts
index 46e0a9a1..160d45f2 100644
--- a/lib/services/editorContext.ts
+++ b/lib/services/editorContext.ts
@@ -1,10 +1,19 @@
let projectId:string | number | undefined;
+let currentUserId:string | undefined;
-/** `projectId` is the project the edited document belongs to, where the application knows it. */
-export function initEditorContext(context:{ projectId?:string | number }):void {
+/**
+ * `projectId` is the project the edited document belongs to, where the application knows it.
+ * `currentUserId` is the user editing, recorded on the user mentions they insert.
+ */
+export function initEditorContext(context:{ projectId?:string | number, currentUserId?:string | number }):void {
projectId = context.projectId === '' ? undefined : context.projectId;
+ currentUserId = context.currentUserId === undefined || context.currentUserId === '' ? undefined : String(context.currentUserId);
}
export function contextProjectId():string | number | undefined {
return projectId;
}
+
+export function contextCurrentUserId():string | undefined {
+ return currentUserId;
+}
diff --git a/lib/services/openProjectApi.ts b/lib/services/openProjectApi.ts
index 4f7d8976..4fcb2123 100644
--- a/lib/services/openProjectApi.ts
+++ b/lib/services/openProjectApi.ts
@@ -4,12 +4,14 @@ import type {
HalResource,
OpenProjectApiErrorBody,
OpenProjectResponse,
+ Principal,
StatusCollection,
TypeCollection,
WorkPackage,
WorkPackageForm,
WorkPackagePayload,
} from '../openProjectTypes';
+import { contextProjectId } from './editorContext';
let baseUrl = 'https://openproject.local';
let proxyUrl = 'https://openproject.local';
@@ -111,6 +113,21 @@ export function linkToNewWorkPackage(projectId?:string):string {
: `${baseUrl}/work_packages/new`;
}
+export function linkToUser(userId:string | number):string {
+ return `${baseUrl}/users/${encodeURIComponent(String(userId))}`;
+}
+
+export function userApiPath(userId:string | number):string {
+ return `/api/v3/users/${encodeURIComponent(String(userId))}`;
+}
+
+const USER_HREF = /^\/api\/v3\/users\/\d+$/;
+
+/** Only users have a picture; groups and placeholder users stay with their initials. */
+export function avatarUrlOf(principalHref:string | null | undefined):string | undefined {
+ return principalHref && USER_HREF.test(principalHref) ? `${proxyUrl}${principalHref}/avatar` : undefined;
+}
+
const WP_ID_URL_PATTERN = '\\d+|[A-Za-z][A-Za-z0-9_]*-\\d+';
const WP_ID_REGEX = new RegExp(`^(?:${WP_ID_URL_PATTERN})$`);
@@ -313,3 +330,28 @@ export async function searchWorkPackages(query:string):Promise {
const data = await get(`/api/v3/work_packages?${params.toString()}`);
return data?._embedded?.elements as unknown as WorkPackage[] ?? [];
}
+
+const MAX_MENTION_RESULTS = 5;
+
+const LOCKED_USER_STATUS = '3';
+
+/** The people a document can mention: active users, members of its project where it has one. */
+export async function searchMentionableUsers(query:string):Promise {
+ const projectId = contextProjectId();
+ const trimmedQuery = query.trim();
+
+ const filters:HalFilter[] = [
+ { status: { operator: '!', values: [LOCKED_USER_STATUS] } },
+ { type: { operator: '=', values: ['User'] } },
+ ...(projectId === undefined ? [] : [{ member: { operator: '=', values: [String(projectId)] } }]),
+ ...(trimmedQuery ? [TYPEAHEAD_FILTER(trimmedQuery)] : []),
+ ];
+ const params = new URLSearchParams({
+ filters: JSON.stringify(filters),
+ sortBy: JSON.stringify([['name', 'asc']]),
+ pageSize: String(MAX_MENTION_RESULTS),
+ });
+
+ const data = await get>(`/api/v3/principals?${params.toString()}`);
+ return (data._embedded?.elements ?? []).map(({ id, name }) => ({ id, name }));
+}
diff --git a/lib/utils/html.ts b/lib/utils/html.ts
new file mode 100644
index 00000000..05d0c8ee
--- /dev/null
+++ b/lib/utils/html.ts
@@ -0,0 +1,7 @@
+export function escapeHtmlText(value:string):string {
+ return value.replace(/&/g, '&').replace(//g, '>');
+}
+
+export function escapeHtmlAttribute(value:string):string {
+ return escapeHtmlText(value).replace(/"/g, '"');
+}
diff --git a/lib/utils/inlineChipActions.ts b/lib/utils/inlineChipActions.ts
index 4154303f..dbe2a8ba 100644
--- a/lib/utils/inlineChipActions.ts
+++ b/lib/utils/inlineChipActions.ts
@@ -1,70 +1,30 @@
import type { InlineContentFromConfig } from '@blocknote/core';
import type { Node as ProsemirrorNode } from 'prosemirror-model';
-import { NodeSelection } from 'prosemirror-state';
import type { AnyEditor } from '../editorTypes';
import type { WorkPackage } from '../openProjectTypes';
import type { InlineWpSize, BlockWpSize } from '../components/WorkPackage/types';
import { moveCursorAfterBlock } from './cursor';
import { BLOCK_WP_TYPE, INLINE_WP_TYPE } from './nodeTypes';
-import { hideSafariPhantomSelection } from './selection';
+import { inlineNodeAt } from './inlineNodes';
+import type { FoundInlineNode } from './inlineNodes';
import { PENDING_PREFIX } from '../components/InlineWorkPackage/callbacks';
-// Direct, position-based operations on inline work package chips.
-//
-// The chip's React component locates its own ProseMirror node via its DOM
-// element (`findInlineChipAtDOM`) and mutates the document directly. This
-// replaces the former event bridge (`wpBridge`) that addressed chips by a
-// persisted `instanceId` prop — which leaked into clipboard HTML and required
-// paste-time deduplication. A position uniquely identifies one node instance,
-// so copies are independent by construction.
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyInlineNode = InlineContentFromConfig;
-export interface FoundInlineChip {
- position:number;
- node:ProsemirrorNode;
-}
-
export interface ChipContent {
type:typeof INLINE_WP_TYPE;
props:{ wpid:string; size:InlineWpSize; displayId:string };
}
-/**
- * Resolves the chip's own DOM element to its ProseMirror node and position.
- *
- * `posAtDOM` may return the position directly before the atom node or the
- * position inside its node-view wrapper (off by one), so both candidates are
- * checked and verified by node type.
- */
-export function findInlineChipAtDOM(editor:AnyEditor, chipDom:HTMLElement):FoundInlineChip | null {
- const view = editor.prosemirrorView;
- if (!view) return null;
-
- let basePosition:number;
- try {
- basePosition = view.posAtDOM(chipDom, 0);
- } catch {
- return null;
- }
-
- for (const position of [basePosition, basePosition - 1]) {
- if (position < 0) continue;
- const node = view.state.doc.nodeAt(position);
- if (node?.type.name === INLINE_WP_TYPE) return { position, node };
- }
- return null;
-}
-
/**
* Finds a pending chip by its wpid. Only `pending:` placeholder wpids
* are unique in the document, so only those can be found reliably.
*/
-export function findPendingInlineChip(doc:ProsemirrorNode, wpid:string):FoundInlineChip | null {
+export function findPendingInlineChip(doc:ProsemirrorNode, wpid:string):FoundInlineNode | null {
if (!wpid.startsWith(PENDING_PREFIX)) return null;
- let found:FoundInlineChip | null = null;
+ let found:FoundInlineNode | null = null;
doc.descendants((node, position) => {
if (found) return false;
if (node.type.name === INLINE_WP_TYPE && node.attrs.wpid === wpid) {
@@ -84,29 +44,6 @@ export function chipContentOf(workPackage:WorkPackage, size:InlineWpSize):ChipCo
};
}
-function chipAt(editor:AnyEditor, position:number):ProsemirrorNode | null {
- return editor.transact((tr) => {
- const node = tr.doc.nodeAt(position);
- return node?.type.name === INLINE_WP_TYPE ? node : null;
- });
-}
-
-export function selectInlineChipAt(editor:AnyEditor, position:number):void {
- if (!chipAt(editor, position)) return;
- editor.transact((tr) => {
- tr.setSelection(NodeSelection.create(tr.doc, position));
- });
- hideSafariPhantomSelection(editor);
-}
-
-export function removeInlineChipAt(editor:AnyEditor, position:number):void {
- const node = chipAt(editor, position);
- if (!node) return;
- editor.transact((tr) => {
- tr.delete(position, position + node.nodeSize);
- });
-}
-
/**
* Replaces the inline chip at `position` with a block work package card,
* splitting the surrounding paragraph content around it.
@@ -116,7 +53,7 @@ export function promoteInlineChipToBlockAt(
position:number,
size:BlockWpSize = 'm'
):void {
- const node = chipAt(editor, position);
+ const node = inlineNodeAt(editor, position, INLINE_WP_TYPE);
if (!node) return;
// wpid must be a positive integer
diff --git a/lib/utils/inlineNodes.ts b/lib/utils/inlineNodes.ts
new file mode 100644
index 00000000..19d07c55
--- /dev/null
+++ b/lib/utils/inlineNodes.ts
@@ -0,0 +1,67 @@
+import type { Node as ProsemirrorNode } from 'prosemirror-model';
+import { NodeSelection } from 'prosemirror-state';
+import type { AnyEditor } from '../editorTypes';
+import { hideSafariPhantomSelection } from './selection';
+
+// Position-based operations on atom inline nodes (work package chips, user
+// mentions). A node's React component finds its own position from its DOM
+// element, and a position names exactly one node instance, so copies of a node
+// stay independent without an id prop that would leak into clipboard HTML.
+
+export interface FoundInlineNode {
+ position:number;
+ node:ProsemirrorNode;
+}
+
+/**
+ * Resolves a node's own DOM element to its ProseMirror node and position.
+ *
+ * `posAtDOM` may return the position directly before the atom node or the
+ * position inside its node-view wrapper (off by one), so both candidates are
+ * checked and verified by node type.
+ */
+export function findInlineNodeAtDOM(
+ editor:AnyEditor,
+ dom:HTMLElement,
+ nodeType:string,
+):FoundInlineNode | null {
+ const view = editor.prosemirrorView;
+ if (!view) return null;
+
+ let basePosition:number;
+ try {
+ basePosition = view.posAtDOM(dom, 0);
+ } catch {
+ return null;
+ }
+
+ for (const position of [basePosition, basePosition - 1]) {
+ if (position < 0) continue;
+ const node = view.state.doc.nodeAt(position);
+ if (node?.type.name === nodeType) return { position, node };
+ }
+ return null;
+}
+
+export function inlineNodeAt(editor:AnyEditor, position:number, nodeType:string):ProsemirrorNode | null {
+ return editor.transact((tr) => {
+ const node = tr.doc.nodeAt(position);
+ return node?.type.name === nodeType ? node : null;
+ });
+}
+
+export function selectInlineNodeAt(editor:AnyEditor, position:number, nodeType:string):void {
+ if (!inlineNodeAt(editor, position, nodeType)) return;
+ editor.transact((tr) => {
+ tr.setSelection(NodeSelection.create(tr.doc, position));
+ });
+ hideSafariPhantomSelection(editor);
+}
+
+export function removeInlineNodeAt(editor:AnyEditor, position:number, nodeType:string):void {
+ const node = inlineNodeAt(editor, position, nodeType);
+ if (!node) return;
+ editor.transact((tr) => {
+ tr.delete(position, position + node.nodeSize);
+ });
+}
diff --git a/lib/utils/insertInline.ts b/lib/utils/insertInline.ts
new file mode 100644
index 00000000..9a5da1cb
--- /dev/null
+++ b/lib/utils/insertInline.ts
@@ -0,0 +1,11 @@
+import type { AnyEditor } from '../editorTypes';
+
+// insertInlineContent is typed against the editor's own schema; callers here hold an AnyEditor.
+export function insertInline(editor:AnyEditor, content:unknown[]):void {
+ (editor.insertInlineContent as (content:unknown[]) => void)(content);
+}
+
+/** Puts back what the suggestion menu removed when its placeholder item is picked. */
+export function restoreTypedQuery(editor:AnyEditor, trigger:string, query:string):void {
+ insertInline(editor, [{ type: 'text', text: `${trigger}${query}`, styles: {} }]);
+}
diff --git a/lib/utils/links.ts b/lib/utils/links.ts
new file mode 100644
index 00000000..1b0add27
--- /dev/null
+++ b/lib/utils/links.ts
@@ -0,0 +1,3 @@
+export function newTabLinkProps(href:string) {
+ return { href, target: '_blank' as const, rel: 'noopener noreferrer' };
+}
diff --git a/lib/utils/nodeTypes.ts b/lib/utils/nodeTypes.ts
index c9f8dbe1..f0a4c54f 100644
--- a/lib/utils/nodeTypes.ts
+++ b/lib/utils/nodeTypes.ts
@@ -1,4 +1,6 @@
-export const INLINE_WP_TYPE = 'openProjectWorkPackageInline';
-export const BLOCK_WP_TYPE = 'openProjectWorkPackageBlock';
+export const INLINE_WP_TYPE = 'openProjectWorkPackageInline' as const;
+export const BLOCK_WP_TYPE = 'openProjectWorkPackageBlock' as const;
export const WORK_PACKAGE_NODE_TYPES:string[] = [INLINE_WP_TYPE, BLOCK_WP_TYPE];
+
+export const USER_MENTION_TYPE = 'openProjectUserMention' as const;
diff --git a/lib/utils/suggestionTrigger.ts b/lib/utils/suggestionTrigger.ts
new file mode 100644
index 00000000..6ec2b518
--- /dev/null
+++ b/lib/utils/suggestionTrigger.ts
@@ -0,0 +1,12 @@
+import type { Node as ProsemirrorNode, ResolvedPos } from 'prosemirror-model';
+
+// An atom node (an inline chip, a mention) reads as one object character, so
+// the text on either side of it stays apart.
+const leafText = (leaf:ProsemirrorNode) => (leaf.type.name === 'hardBreak' ? '\n' : '');
+
+/** Up to `maxLength` characters of the textblock before `position`; null outside a textblock. */
+export function textBefore(position:ResolvedPos, maxLength = Number.POSITIVE_INFINITY):string | null {
+ if (!position.parent.isTextblock) return null;
+ const end = position.parentOffset;
+ return position.parent.textBetween(Math.max(0, end - maxLength), end, undefined, leafText);
+}
diff --git a/lib/utils/tapActivation.ts b/lib/utils/tapActivation.ts
index c4b4e454..d90244d9 100644
--- a/lib/utils/tapActivation.ts
+++ b/lib/utils/tapActivation.ts
@@ -27,19 +27,24 @@ export interface TapActivationProps {
*
* A touch that landed in a React portal (a popover anchored to the element)
* bubbles up the React tree to the element it was rendered from, and a nested
- * link or button answers for itself - neither is a tap on the element.
+ * link or button answers for itself - neither is a tap on the element. The
+ * nested control is skipped on click too, so a link keeps its navigation.
*/
export function useTapActivation():(activate:TapAction) => TapActivationProps {
const startedAt = useRef<{ x:number; y:number } | null>(null);
const activatedAt = useRef(Number.NEGATIVE_INFINITY);
- const isOwnTouch = (event:ReactTouchEvent) => {
+ const isNestedControl = (event:ReactTouchEvent | ReactMouseEvent) => {
const target = event.target;
if (!(target instanceof Element)) return false;
- if (!event.currentTarget.contains(target)) return false;
-
const nested = target.closest('a, button');
- return !nested || nested === event.currentTarget;
+ return nested !== null && nested !== event.currentTarget && event.currentTarget.contains(nested);
+ };
+
+ const isOwnTouch = (event:ReactTouchEvent) => {
+ const target = event.target;
+ if (!(target instanceof Element)) return false;
+ return event.currentTarget.contains(target) && !isNestedControl(event);
};
return (activate:TapAction) => ({
@@ -59,6 +64,7 @@ export function useTapActivation():(activate:TapAction) => TapActivationProps {
activate();
},
onClick: (event:ReactMouseEvent) => {
+ if (isNestedControl(event)) return;
if (event.timeStamp - activatedAt.current < GHOST_CLICK_WINDOW) return;
activate(event);
},
diff --git a/package-lock.json b/package-lock.json
index c67dacbf..7c9e7e74 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -46,7 +46,9 @@
"typescript-eslint": "^8.71.0",
"vite": "^8.3.2",
"vitest": "^5.0.3",
- "vitest-browser-react": "^2.3.0"
+ "vitest-browser-react": "^2.3.0",
+ "y-prosemirror": "^1.3.7",
+ "yjs": "^13.6.33"
},
"peerDependencies": {
"@blocknote/core": ">=0.54.2",
@@ -3638,6 +3640,17 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/isomorphic.js": {
+ "version": "0.2.5",
+ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
+ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3774,6 +3787,28 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lib0": {
+ "version": "0.2.119",
+ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
+ "integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "isomorphic.js": "^0.2.4"
+ },
+ "bin": {
+ "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
+ "0gentesthtml": "bin/gentesthtml.js",
+ "0serve": "bin/0serve.js"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -5811,6 +5846,53 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/y-prosemirror": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.3.7.tgz",
+ "integrity": "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.109"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.7.1",
+ "prosemirror-state": "^1.2.3",
+ "prosemirror-view": "^1.9.10",
+ "y-protocols": "^1.0.1",
+ "yjs": "^13.5.38"
+ }
+ },
+ "node_modules/y-protocols": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
+ "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "lib0": "^0.2.85"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ },
+ "peerDependencies": {
+ "yjs": "^13.0.0"
+ }
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -5857,6 +5939,24 @@
"node": ">=12"
}
},
+ "node_modules/yjs": {
+ "version": "13.6.33",
+ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
+ "integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lib0": "^0.2.99"
+ },
+ "engines": {
+ "node": ">=16.0.0",
+ "npm": ">=8.0.0"
+ },
+ "funding": {
+ "type": "GitHub Sponsors ❤",
+ "url": "https://github.com/sponsors/dmonad"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 1b2f2956..98e612f4 100644
--- a/package.json
+++ b/package.json
@@ -72,7 +72,9 @@
"typescript-eslint": "^8.71.0",
"vite": "^8.3.2",
"vitest": "^5.0.3",
- "vitest-browser-react": "^2.3.0"
+ "vitest-browser-react": "^2.3.0",
+ "y-prosemirror": "^1.3.7",
+ "yjs": "^13.6.33"
},
"peerDependencies": {
"@blocknote/core": ">=0.54.2",
diff --git a/src/App.tsx b/src/App.tsx
index 4e3be69b..0de2672f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -16,6 +16,9 @@ import {
getOpenProjectSlashMenuItems,
OpenProjectFormattingToolbar,
OpenProjectHashMenu,
+ OpenProjectMentionMenu,
+ openProjectUserMentionSpec,
+ UserMentionAuthorExtension,
} from '../lib';
import './fetchOverride';
@@ -25,6 +28,7 @@ const schema = BlockNoteSchema.create().extend({
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
+ openProjectUserMention: openProjectUserMentionSpec,
},
});
@@ -44,7 +48,7 @@ function buildSlashMenuItems(editor:EditorType) {
}
export default function App() {
- const editor = useCreateBlockNote({ schema });
+ const editor = useCreateBlockNote({ schema, extensions: [UserMentionAuthorExtension] });
const getSlashItems = useCallback(
// eslint-disable-next-line @typescript-eslint/require-await
@@ -62,6 +66,7 @@ export default function App() {
/>
+
);
}
diff --git a/test/helpers/content.ts b/test/helpers/content.ts
new file mode 100644
index 00000000..16894e0b
--- /dev/null
+++ b/test/helpers/content.ts
@@ -0,0 +1,17 @@
+import { INLINE_WP_TYPE, USER_MENTION_TYPE } from '../../lib/utils/nodeTypes';
+
+export function chipContent(wpid:string, size = 's') {
+ return { type: INLINE_WP_TYPE, props: { wpid, size } };
+}
+
+export function mentionContent(userId:string, name:string, mentionedBy = '') {
+ return { type: USER_MENTION_TYPE, props: { userId, name, mentionedBy } };
+}
+
+export function textContent(value:string) {
+ return { type: 'text', text: value, styles: {} };
+}
+
+export function paragraphWith(...content:unknown[]) {
+ return { type: 'paragraph', content };
+}
diff --git a/test/helpers/editorHelpers.ts b/test/helpers/editorHelpers.ts
index 06a65ef4..0ac5cd4d 100644
--- a/test/helpers/editorHelpers.ts
+++ b/test/helpers/editorHelpers.ts
@@ -19,12 +19,38 @@ export function touchStartElement(element:Element, touch = touchAt(element)) {
}));
}
-export function tapElement(element:Element) {
+export function tapElement(element:Element):TouchEvent {
const touch = touchAt(element);
touchStartElement(element, touch);
- element.dispatchEvent(new TouchEvent('touchend', {
+ const touchEnd = new TouchEvent('touchend', {
bubbles: true, cancelable: true, changedTouches: [touch], touches: [],
- }));
+ });
+ element.dispatchEvent(touchEnd);
+ return touchEnd;
+}
+
+export interface RecordedClick {
+ defaultPrevented:boolean;
+ href:string | undefined;
+}
+
+// Records each click as the page saw it, then cancels it so a link does not open a tab.
+export async function withNavigationPrevented(act:() => Promise):Promise {
+ const clicks:RecordedClick[] = [];
+ const recordClick = (event:MouseEvent) => {
+ clicks.push({
+ defaultPrevented: event.defaultPrevented,
+ href: (event.target as Element).closest('a')?.href,
+ });
+ event.preventDefault();
+ };
+ window.addEventListener('click', recordClick);
+ try {
+ await act();
+ } finally {
+ window.removeEventListener('click', recordClick);
+ }
+ return clicks;
}
// Insert
diff --git a/test/helpers/headlessEditor.ts b/test/helpers/headlessEditor.ts
index bbd5506f..33fee525 100644
--- a/test/helpers/headlessEditor.ts
+++ b/test/helpers/headlessEditor.ts
@@ -1,11 +1,16 @@
import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
-import { TextSelection } from 'prosemirror-state';
+import { withCollaboration } from '@blocknote/core/yjs';
+import { NodeSelection, TextSelection } from 'prosemirror-state';
+import { onTestFinished } from 'vitest';
+import * as Y from 'yjs';
import {
openProjectWorkPackageBlockSpec,
openProjectWorkPackageInlineSpec,
+ openProjectUserMentionSpec,
+ UserMentionAuthorExtension,
} from '../../lib';
import type { AnyEditor } from '../../lib/editorTypes';
-import { INLINE_WP_TYPE } from '../../lib/utils/nodeTypes';
+import { INLINE_WP_TYPE, USER_MENTION_TYPE } from '../../lib/utils/nodeTypes';
const schema = BlockNoteSchema.create().extend({
blockSpecs: {
@@ -13,6 +18,7 @@ const schema = BlockNoteSchema.create().extend({
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
+ openProjectUserMention: openProjectUserMentionSpec,
},
});
@@ -22,8 +28,38 @@ export interface InlineNode {
props?:Record;
}
+const extensions = [UserMentionAuthorExtension];
+
export function createHeadlessEditorWithBlocks(blocks:unknown[]) {
- return BlockNoteEditor.create({ schema, initialContent: blocks as never });
+ return BlockNoteEditor.create({ schema, extensions, initialContent: blocks as never });
+}
+
+// ProseMirror plugins only run once the editor is mounted.
+export function mountHeadlessEditor(editor:Editor):Editor {
+ editor.mount(document.createElement('div'));
+ onTestFinished(() => editor.unmount());
+ return editor;
+}
+
+// Two editors on separate Y.Docs that only exchange changes when `sync` is called,
+// so a test controls what the editor is doing while a collaborator's change arrives.
+export function createCollaborativeHeadlessEditors() {
+ const documents = [new Y.Doc(), new Y.Doc()];
+ const editors = documents.map((document) => {
+ const editor = BlockNoteEditor.create(withCollaboration({
+ schema,
+ extensions,
+ collaboration: { fragment: document.getXmlFragment('document-store'), user: { name: 'Collaborator', color: '#000000' } },
+ }));
+ return mountHeadlessEditor(editor);
+ });
+
+ const sync = () => {
+ Y.applyUpdate(documents[1], Y.encodeStateAsUpdate(documents[0]));
+ Y.applyUpdate(documents[0], Y.encodeStateAsUpdate(documents[1]));
+ };
+
+ return { editors, sync };
}
export function createHeadlessEditor(content:unknown[] | string) {
@@ -36,14 +72,6 @@ export function createHeadlessEditorWithText(text:string) {
return editor;
}
-export function chipContent(wpid:string, size = 's') {
- return { type: INLINE_WP_TYPE, props: { wpid, size } };
-}
-
-export function textContent(value:string) {
- return { type: 'text', text: value, styles: {} };
-}
-
export function blockContent(editor:AnyEditor, blockIndex = 0):InlineNode[] {
return (editor.document[blockIndex]?.content ?? []) as InlineNode[];
}
@@ -56,12 +84,34 @@ export function blockText(editor:AnyEditor, blockIndex = 0):string {
return blockContent(editor, blockIndex).map((node) => node.text ?? '').join('');
}
+export function blockTextWithNodes(editor:AnyEditor, blockIndex = 0):string {
+ return blockContent(editor, blockIndex)
+ .map((node) => (node.type === 'text' ? node.text : `[${node.type}]`))
+ .join('');
+}
+
+export function mentionsIn(editor:AnyEditor):InlineNode['props'][] {
+ return editor.document
+ .flatMap((block) => (Array.isArray(block.content) ? block.content as InlineNode[] : []))
+ .filter((node) => node.type === USER_MENTION_TYPE)
+ .map((node) => node.props);
+}
+
+export function selectedNodeType(editor:AnyEditor):string | undefined {
+ const { selection } = editor.prosemirrorState;
+ return selection instanceof NodeSelection ? selection.node.type.name : undefined;
+}
+
export function nthChipPosition(editor:AnyEditor, ordinal = 0):number {
+ return nthInlineNodePosition(editor, INLINE_WP_TYPE, ordinal);
+}
+
+export function nthInlineNodePosition(editor:AnyEditor, nodeType:string, ordinal = 0):number {
let seen = -1;
let found = -1;
editor.prosemirrorState.doc.descendants((node, position) => {
if (found !== -1) return false;
- if (node.type.name === INLINE_WP_TYPE) {
+ if (node.type.name === nodeType) {
seen += 1;
if (seen === ordinal) {
found = position;
diff --git a/test/helpers/renderEditor.tsx b/test/helpers/renderEditor.tsx
index 620ad597..1007b656 100644
--- a/test/helpers/renderEditor.tsx
+++ b/test/helpers/renderEditor.tsx
@@ -9,11 +9,15 @@ import { render } from 'vitest-browser-react';
import {
openProjectWorkPackageBlockSpec,
openProjectWorkPackageInlineSpec,
+ openProjectUserMentionSpec,
getOpenProjectSlashMenuItems,
OpenProjectFormattingToolbar,
OpenProjectHashMenu,
+ OpenProjectMentionMenu,
ShadowDomWrapper,
+ UserMentionAuthorExtension,
} from '../../lib';
+import type { AnyEditor } from '../../lib/editorTypes';
import '@blocknote/core/fonts/inter.css';
import '@blocknote/mantine/style.css';
@@ -25,6 +29,7 @@ const defaultSchema = BlockNoteSchema.create().extend({
},
inlineContentSpecs: {
openProjectWorkPackageInline: openProjectWorkPackageInlineSpec,
+ openProjectUserMention: openProjectUserMentionSpec,
},
});
@@ -38,6 +43,7 @@ interface EditorOptions {
function Editor({ onEditor, schema, editable = true, initialContent }:EditorOptions) {
const editor = useCreateBlockNote({
schema: schema ?? defaultSchema,
+ extensions: [UserMentionAuthorExtension],
...(initialContent ? { initialContent } : {}),
});
onEditor?.(editor);
@@ -60,6 +66,7 @@ function Editor({ onEditor, schema, editable = true, initialContent }:EditorOpti
+
);
@@ -69,6 +76,13 @@ export function renderEditor(opts?:EditorOptions) {
return render();
}
+export async function renderEditorWithHandle(opts?:Omit):Promise {
+ let editor = undefined as AnyEditor | undefined;
+ await renderEditor({ ...opts, onEditor: (created) => { editor = created; } });
+ if (!editor) throw new Error('The editor was not created');
+ return editor;
+}
+
export async function renderEditorInShadowDom(opts?:EditorOptions) {
const host = document.createElement('div');
document.body.appendChild(host);
diff --git a/test/helpers/session.ts b/test/helpers/session.ts
new file mode 100644
index 00000000..177fa126
--- /dev/null
+++ b/test/helpers/session.ts
@@ -0,0 +1,10 @@
+import { onTestFinished } from 'vitest';
+import { contextCurrentUserId, contextProjectId, initEditorContext } from '../../lib/services/editorContext';
+
+/** Makes `userId` the current user for the rest of the test. */
+export function signInAs(userId:string | number):void {
+ const projectId = contextProjectId();
+ const previousUserId = contextCurrentUserId();
+ initEditorContext({ projectId, currentUserId: userId });
+ onTestFinished(() => initEditorContext({ projectId, currentUserId: previousUserId }));
+}
diff --git a/test/lib/components/HashMenu/editorUtils.test.ts b/test/lib/components/HashMenu/editorUtils.test.ts
index 8057e71c..522f0796 100644
--- a/test/lib/components/HashMenu/editorUtils.test.ts
+++ b/test/lib/components/HashMenu/editorUtils.test.ts
@@ -11,13 +11,12 @@ import {
blockContent,
blockText,
blockTypes,
- chipContent,
createHeadlessEditor,
createHeadlessEditorWithText,
nthChipPosition,
placeCursorAfterText,
- textContent,
} from '../../../helpers/headlessEditor';
+import { chipContent, textContent } from '../../../helpers/content';
const workPackage = { id: 1, displayId: '1', subject: 'Fix bug' } as unknown as WorkPackage;
diff --git a/test/lib/components/HashMenu/hashTrigger.test.ts b/test/lib/components/HashMenu/hashTrigger.test.ts
index b42ef836..6c7b6957 100644
--- a/test/lib/components/HashMenu/hashTrigger.test.ts
+++ b/test/lib/components/HashMenu/hashTrigger.test.ts
@@ -7,11 +7,10 @@ import {
} from '../../../../lib/components/HashMenu/hashTrigger';
import type { HashTarget } from '../../../../lib/components/HashMenu/hashTrigger';
import {
- chipContent,
createHeadlessEditor,
createHeadlessEditorWithText,
- textContent,
} from '../../../helpers/headlessEditor';
+import { chipContent, textContent } from '../../../helpers/content';
function targetFor(blockText:string, query:string):HashTarget {
return hashTargetFor(createHeadlessEditorWithText(blockText), query);
diff --git a/test/lib/components/MentionMenu/mentionTrigger.test.ts b/test/lib/components/MentionMenu/mentionTrigger.test.ts
new file mode 100644
index 00000000..ee95cc9e
--- /dev/null
+++ b/test/lib/components/MentionMenu/mentionTrigger.test.ts
@@ -0,0 +1,42 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import { canOpenMentionMenu } from '../../../../lib/components/MentionMenu/mentionTrigger';
+import {
+ createHeadlessEditor,
+ createHeadlessEditorWithText,
+} from '../../../helpers/headlessEditor';
+import { chipContent, mentionContent, textContent } from '../../../helpers/content';
+
+function opensAt(editor:ReturnType):boolean {
+ return editor.transact((transaction) => canOpenMentionMenu(transaction));
+}
+
+function opensAfter(text:string):boolean {
+ return opensAt(createHeadlessEditorWithText(text));
+}
+
+function opensAfterContent(...content:unknown[]):boolean {
+ const editor = createHeadlessEditor(content);
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ return opensAt(editor);
+}
+
+describe('canOpenMentionMenu', () => {
+ it('opens at the start of a block', () => expect(opensAfter('')).toBe(true));
+ it('opens after a space', () => expect(opensAfter('Thanks ')).toBe(true));
+ it('opens after an opening bracket or quote', () => {
+ expect(opensAfter('(')).toBe(true);
+ expect(opensAfter('"')).toBe(true);
+ });
+ it('opens after a line break', () => {
+ const editor = createHeadlessEditorWithText('line\n');
+ expect(editor.prosemirrorState.selection.$from.nodeBefore?.type.name).toBe('hardBreak');
+ expect(opensAt(editor)).toBe(true);
+ });
+ it('stays closed inside a word, e.g. an email address', () => expect(opensAfter('ihor')).toBe(false));
+ it('stays closed after punctuation that does not open a phrase', () => expect(opensAfter('a.')).toBe(false));
+ it('stays closed directly after an inline node', () => {
+ expect(opensAfterContent(textContent('Hi '), mentionContent('7', 'Anna Kovalenko'))).toBe(false);
+ expect(opensAfterContent(textContent('See '), chipContent('42'))).toBe(false);
+ });
+});
diff --git a/test/lib/components/UserMention/externalHtml.test.ts b/test/lib/components/UserMention/externalHtml.test.ts
new file mode 100644
index 00000000..3208b0fe
--- /dev/null
+++ b/test/lib/components/UserMention/externalHtml.test.ts
@@ -0,0 +1,87 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest';
+import {
+ buildUserMentionStoredDOM,
+ userMentionTag,
+ parseUserMentionExternalHTML,
+} from '../../../../lib/components/UserMention/externalHtml';
+
+const judith = { userId: '5', name: 'Judith Roth', mentionedBy: '' };
+const judithByKarl = { ...judith, mentionedBy: '4' };
+
+describe('user mention external HTML', () => {
+ it('builds the canonical tag', () => {
+ expect(userMentionTag(judith))
+ .toBe('@Judith Roth');
+ });
+
+ it('appends who mentioned as the last attribute', () => {
+ expect(userMentionTag(judithByKarl))
+ .toBe('@Judith Roth');
+ });
+
+ it('escapes who mentioned like the other attributes', () => {
+ expect(userMentionTag({ ...judith, mentionedBy: '4">');
+ });
+
+ it('wraps the tag as text for the stored markdown', () => {
+ const wrapper = buildUserMentionStoredDOM(judith, document);
+
+ expect(wrapper.children).toHaveLength(0);
+ expect(wrapper.textContent).toBe(userMentionTag(judith));
+ });
+
+ it('parses its own wrapper back', () => {
+ expect(parseUserMentionExternalHTML(buildUserMentionStoredDOM(judith, document)))
+ .toEqual(judith);
+ });
+
+ it('parses who mentioned back from its own wrapper', () => {
+ expect(parseUserMentionExternalHTML(buildUserMentionStoredDOM(judithByKarl, document)))
+ .toEqual(judithByKarl);
+ });
+
+ it('parses who mentioned from a bare mention tag', () => {
+ const host = document.createElement('div');
+ host.innerHTML = userMentionTag(judithByKarl);
+
+ expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toEqual(judithByKarl);
+ });
+
+ it('parses a mention copied from a CKEditor field', () => {
+ const host = document.createElement('div');
+ host.innerHTML = userMentionTag(judith);
+
+ expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toEqual(judith);
+ });
+
+ it('reads back a name with quotes, ampersands and angle brackets from its own tag', () => {
+ const ann = { userId: '9', name: 'Ann "A&B" ', mentionedBy: '' };
+ const host = document.createElement('div');
+ host.innerHTML = userMentionTag(ann);
+
+ expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toEqual(ann);
+ });
+
+ it('leaves a type-only wrapper to the mention inside it', () => {
+ const wrapper = document.createElement('span');
+ wrapper.setAttribute('data-inline-content-type', 'openProjectUserMention');
+
+ expect(parseUserMentionExternalHTML(wrapper)).toBeUndefined();
+ });
+
+ it('ignores group and work package mentions', () => {
+ const host = document.createElement('div');
+ host.innerHTML = '@Team';
+
+ expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toBeUndefined();
+ });
+
+ it('ignores a user mention without an id', () => {
+ const host = document.createElement('div');
+ host.innerHTML = '@Judith Roth';
+
+ expect(parseUserMentionExternalHTML(host.firstElementChild as HTMLElement)).toBeUndefined();
+ });
+});
diff --git a/test/lib/components/UserMention/markdown.test.ts b/test/lib/components/UserMention/markdown.test.ts
new file mode 100644
index 00000000..eea7222e
--- /dev/null
+++ b/test/lib/components/UserMention/markdown.test.ts
@@ -0,0 +1,46 @@
+// @vitest-environment jsdom
+import { describe, it, expect } from 'vitest';
+import { BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
+import { openProjectUserMentionStaticSpec } from '../../../../lib/server';
+import { mentionContent, paragraphWith } from '../../../helpers/content';
+
+// Not the shared headless editor: that one uses the React specs, while the stored markdown comes from the server-side static spec.
+const schema = BlockNoteSchema.create().extend({
+ inlineContentSpecs: { openProjectUserMention: openProjectUserMentionStaticSpec },
+});
+
+async function markdownOf(blocks:unknown[]) {
+ const editor = BlockNoteEditor.create({ schema, initialContent: blocks as never });
+ return (await editor.blocksToMarkdownLossy()).trim();
+}
+
+const judith = '@Judith Roth';
+
+describe('user mention markdown export', () => {
+ it('writes the OpenProject mention tag into a paragraph', async () => {
+ expect(await markdownOf([paragraphWith('Thanks ', mentionContent('5', 'Judith Roth'), ' for the review')]))
+ .toBe(`Thanks ${judith} for the review`);
+ });
+
+ it('keeps the tag in a list item and after bold text', async () => {
+ expect(await markdownOf([
+ { type: 'bulletListItem', content: [mentionContent('5', 'Judith Roth')] },
+ paragraphWith({ type: 'text', text: 'Owner ', styles: { bold: true } }, mentionContent('5', 'Judith Roth')),
+ ])).toBe(`* ${judith}\n\n**Owner** ${judith}`);
+ });
+
+ it('records who mentioned in the tag', async () => {
+ expect(await markdownOf([paragraphWith(mentionContent('5', 'Judith Roth', '4'))]))
+ .toBe('@Judith Roth');
+ });
+
+ it('escapes a name so OpenProject reads the same name back', async () => {
+ expect(await markdownOf([paragraphWith(mentionContent('9', 'Ann "A&B" '))]))
+ .toBe('@Ann "A&B" <Lee>');
+ });
+
+ it('writes nothing for a mention without a user', async () => {
+ expect(await markdownOf([paragraphWith('Hi ', mentionContent('', ''), 'there')]))
+ .toBe('Hi there');
+ });
+});
diff --git a/test/lib/components/integration/editor.drag.browser.test.tsx b/test/lib/components/integration/editor.drag.browser.test.tsx
index 3aba3291..74b60a2d 100644
--- a/test/lib/components/integration/editor.drag.browser.test.tsx
+++ b/test/lib/components/integration/editor.drag.browser.test.tsx
@@ -1,8 +1,11 @@
import { describe, it, expect, afterEach } from 'vitest';
import { page, userEvent } from 'vitest/browser';
import { http, HttpResponse } from 'msw';
-import { renderEditor } from '../../../helpers/renderEditor';
+import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor';
+import { mentionContent, paragraphWith } from '../../../helpers/content';
+import { mentionsIn } from '../../../helpers/headlessEditor';
import { insertInlineWorkPackageViaSlashMenu, convertToCompactCard } from '../../../helpers/editorHelpers';
+import { signInAs } from '../../../helpers/session';
import { worker } from '../../../mocks/browser';
import { mockWorkPackage } from '../../../mocks/handlers';
@@ -134,3 +137,62 @@ describe('Drag and drop - block card', () => {
expect(fetchCount).toBe(0);
});
});
+
+describe('Drag and drop - user mention', () => {
+ const peterByAnna = mentionContent('8', 'Peter Lang', '7');
+
+ async function renderMentionBetweenLines() {
+ signInAs(4);
+ const editor = await renderEditorWithHandle({
+ initialContent: [paragraphWith('First line'), paragraphWith('Thanks ', peterByAnna), paragraphWith('Last line')],
+ });
+ await expect.element(page.getByRole('link', { name: 'Peter Lang' })).toBeVisible();
+ return editor;
+ }
+
+ function blockTextOfMention() {
+ return document.querySelector('.op-bn-user-mention')!.closest('[data-node-type="blockOuter"]')?.textContent;
+ }
+
+ it('moves the mention node, keeping its author, when dragged by the pill', async () => {
+ const editor = await renderMentionBetweenLines();
+
+ await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('Last line'));
+
+ await expect.poll(blockTextOfMention).toContain('Last');
+ expect(mentionsIn(editor)).toEqual([peterByAnna.props]);
+ });
+
+ it('moves the mention node, not a copy of its link, when dragged by the name', async () => {
+ const editor = await renderMentionBetweenLines();
+
+ await userEvent.dragAndDrop(page.getByRole('link', { name: 'Peter Lang' }), page.getByText('Last line'));
+
+ await expect.poll(blockTextOfMention).toContain('Last');
+ expect(mentionsIn(editor)).toEqual([peterByAnna.props]);
+ expect(editor.document.flatMap((block) => block.content as { type:string }[]).map((node) => node.type))
+ .not.toContain('link');
+ });
+
+ it('can be dragged again after a drop', async () => {
+ const editor = await renderMentionBetweenLines();
+
+ await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('Last line'));
+ await expect.poll(blockTextOfMention).toContain('Last');
+ await userEvent.dragAndDrop(document.querySelector('.op-bn-user-mention')!, page.getByText('First line'));
+
+ await expect.poll(blockTextOfMention).toContain('First');
+ expect(mentionsIn(editor)).toEqual([peterByAnna.props]);
+ });
+
+ it('keeps the author when the block holding the mention is moved with the keyboard', async () => {
+ const editor = await renderMentionBetweenLines();
+
+ await userEvent.click(page.getByText('Thanks'));
+ await userEvent.keyboard('{Control>}{Shift>}{ArrowDown}{/Shift}{/Control}');
+
+ await expect.poll(() => editor.document.map((block) => (block.content as { text?:string }[])[0]?.text))
+ .toEqual(['First line', 'Last line', 'Thanks ']);
+ expect(mentionsIn(editor)).toEqual([peterByAnna.props]);
+ });
+});
diff --git a/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx b/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx
new file mode 100644
index 00000000..33b9da18
--- /dev/null
+++ b/test/lib/components/integration/inlineNodeOptionsShadowDom.browser.test.tsx
@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import type { Locator } from 'vitest/browser';
+import { renderEditorInShadowDom } from '../../../helpers/renderEditor';
+import { chipContent, mentionContent, paragraphWith } from '../../../helpers/content';
+
+// Inside a shadow root a document listener sees every press retargeted to the
+// host, so these guard that a press on the node itself is not taken as outside.
+
+const popover = () => page.getByTestId('popover-content');
+
+describe('User mention options inside a shadow root', () => {
+ async function renderMention() {
+ await renderEditorInShadowDom({
+ initialContent: [
+ paragraphWith('Thanks ', mentionContent('7', 'Anna Kovalenko'), ' for the review'),
+ paragraphWith('Somewhere else'),
+ ],
+ });
+ const pill = page.getByRole('button', { name: 'User Anna Kovalenko' });
+ await expect.element(pill).toBeVisible();
+ return { pill, avatar: pill.getByTestId('avatar') };
+ }
+
+ async function openOptions(avatar:Locator) {
+ await userEvent.click(avatar);
+ await expect.element(popover()).toBeVisible();
+ }
+
+ it('opens the popover when the pill is clicked', async () => {
+ const { avatar } = await renderMention();
+
+ await openOptions(avatar);
+
+ await expect.element(page.getByTestId('remove-btn')).toBeVisible();
+ });
+
+ it('closes the popover when the pill is clicked again', async () => {
+ const { avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(avatar);
+
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('removes the mention from the Remove button', async () => {
+ const { pill, avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(page.getByTestId('remove-btn'));
+
+ await expect.element(pill).not.toBeInTheDocument();
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('closes the popover on a click outside the pill', async () => {
+ const { avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(page.getByText('Somewhere else'));
+
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+});
+
+describe('Inline work package options inside a shadow root', () => {
+ it('closes the popover when the chip is clicked again', async () => {
+ await renderEditorInShadowDom({ initialContent: [paragraphWith('See ', chipContent('123'))] });
+ const chip = page.getByText('#123', { exact: true });
+ await expect.element(chip).toBeVisible();
+
+ await userEvent.click(chip);
+ await expect.element(popover()).toBeVisible();
+
+ await userEvent.click(chip);
+
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+});
diff --git a/test/lib/components/integration/userMention.browser.test.tsx b/test/lib/components/integration/userMention.browser.test.tsx
new file mode 100644
index 00000000..d27a0179
--- /dev/null
+++ b/test/lib/components/integration/userMention.browser.test.tsx
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor';
+import { mentionContent, paragraphWith } from '../../../helpers/content';
+import { mentionsIn } from '../../../helpers/headlessEditor';
+import { withNavigationPrevented } from '../../../helpers/editorHelpers';
+import { signInAs } from '../../../helpers/session';
+
+const anna = mentionContent('7', 'Anna Kovalenko');
+
+describe('User mention', () => {
+ it('shows the avatar and the name, linking to the user in a new tab', async () => {
+ renderEditor({ initialContent: [paragraphWith('Thanks ', anna)] });
+
+ const link = page.getByRole('link', { name: 'Anna Kovalenko' });
+ await expect.element(link).toHaveAttribute('href', 'http://localhost:3000/users/7');
+ await expect.element(link).toHaveAttribute('target', '_blank');
+ await expect.element(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('lets the browser follow the name link on click', async () => {
+ renderEditor({ initialContent: [paragraphWith('Thanks ', anna)] });
+ const link = page.getByRole('link', { name: 'Anna Kovalenko' });
+ await expect.element(link).toBeVisible();
+
+ const clicks = await withNavigationPrevented(() => userEvent.click(link));
+
+ expect(clicks).toEqual([{ defaultPrevented: false, href: 'http://localhost:3000/users/7' }]);
+ });
+
+ it('serialises to a mention element and parses it back', async () => {
+ const editor = await renderEditorWithHandle({ initialContent: [paragraphWith(anna)] });
+ await expect.element(page.getByRole('link', { name: 'Anna Kovalenko' })).toBeVisible();
+
+ const html = await editor.blocksToHTMLLossy(editor.document);
+ expect(html).toContain('@Anna Kovalenko');
+
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ editor.pasteHTML(html);
+ expect(mentionsIn(editor)).toEqual([anna.props, anna.props]);
+ });
+
+ it('pastes a CKEditor mention as a mention', async () => {
+ const editor = await renderEditorWithHandle({ initialContent: [paragraphWith('Hello')] });
+ await expect.element(page.getByText('Hello')).toBeVisible();
+
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ editor.pasteHTML('@Peter Lang
');
+ expect(mentionsIn(editor)).toEqual([mentionContent('8', 'Peter Lang').props]);
+ });
+
+ it('records the user who pastes a mention instead of the one who first mentioned', async () => {
+ signInAs(4);
+ const editor = await renderEditorWithHandle({ initialContent: [paragraphWith('Hello')] });
+ await expect.element(page.getByText('Hello')).toBeVisible();
+
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ editor.pasteHTML('@Peter Lang
');
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('8', 'Peter Lang', '4').props]);
+ expect(await editor.blocksToHTMLLossy(editor.document)).toContain('data-mentioned-by="4">@Peter Lang');
+ });
+});
diff --git a/test/lib/components/integration/userMentionGeometry.browser.test.tsx b/test/lib/components/integration/userMentionGeometry.browser.test.tsx
new file mode 100644
index 00000000..f3ff232a
--- /dev/null
+++ b/test/lib/components/integration/userMentionGeometry.browser.test.tsx
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import { page } from 'vitest/browser';
+import { renderEditor } from '../../../helpers/renderEditor';
+import { mentionContent, paragraphWith } from '../../../helpers/content';
+
+function mentionParts(name:string) {
+ const link = page.getByRole('link', { name }).element();
+ const pill = link.closest('.op-bn-user-mention');
+ const avatar = pill?.querySelector('.op-bn-avatar');
+ if (!pill || !avatar) throw new Error(`mention of ${name} did not render`);
+
+ const pillBox = pill.getBoundingClientRect();
+ const avatarBox = avatar.getBoundingClientRect();
+ return {
+ above: pillBox.top - avatarBox.top,
+ below: avatarBox.bottom - pillBox.bottom,
+ offset: avatarBox.top - pillBox.top,
+ gap: link.getBoundingClientRect().left - avatarBox.right,
+ };
+}
+
+describe('User mention - the avatar sits inside the pill', () => {
+ it('keeps picture and initials avatars at the same height, inside the pill, 4px from the name', async () => {
+ renderEditor({
+ initialContent: [paragraphWith(mentionContent('7', 'Anna Kovalenko'), ' and ', mentionContent('8', 'Peter Lang'))],
+ });
+
+ await expect.element(page.getByRole('link', { name: 'Peter Lang' })).toBeVisible();
+ const annaPicture = page.getByRole('button', { name: 'User Anna Kovalenko' }).element().querySelector('img')!;
+ await expect.element(annaPicture).toBeVisible();
+ await document.fonts.load('500 12px Inter');
+ await document.fonts.load('600 7px Inter');
+ expect(document.fonts.check('500 12px Inter')).toBe(true);
+
+ const picture = mentionParts('Anna Kovalenko');
+ const initials = mentionParts('Peter Lang');
+
+ for (const [label, parts] of [['picture', picture], ['initials', initials]] as const) {
+ expect.soft(parts.above, `${label} avatar sticks ${parts.above.toFixed(2)}px above the pill`).toBeLessThanOrEqual(0.5);
+ expect.soft(parts.below, `${label} avatar hangs ${parts.below.toFixed(2)}px below the pill`).toBeLessThanOrEqual(0.5);
+ expect.soft(Math.abs(parts.gap - 4), `${label} avatar is ${parts.gap.toFixed(2)}px from the name`).toBeLessThanOrEqual(0.5);
+ }
+ expect.soft(
+ Math.abs(picture.offset - initials.offset),
+ `picture avatar at ${picture.offset.toFixed(2)}px, initials at ${initials.offset.toFixed(2)}px`,
+ ).toBeLessThanOrEqual(0.5);
+ });
+});
diff --git a/test/lib/components/integration/userMentionMenu.browser.test.tsx b/test/lib/components/integration/userMentionMenu.browser.test.tsx
new file mode 100644
index 00000000..68b5b774
--- /dev/null
+++ b/test/lib/components/integration/userMentionMenu.browser.test.tsx
@@ -0,0 +1,93 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import { http, HttpResponse } from 'msw';
+import { worker } from '../../../mocks/browser';
+import { renderEditor, renderEditorWithHandle } from '../../../helpers/renderEditor';
+import { openEditorAndType } from '../../../helpers/editorHelpers';
+import { mentionContent, textContent } from '../../../helpers/content';
+import { blockContent } from '../../../helpers/headlessEditor';
+import { signInAs } from '../../../helpers/session';
+
+const mentionMenu = () => page.getByTestId('mention-menu');
+
+describe('@ mention menu', () => {
+ afterEach(() => worker.resetHandlers());
+
+ it('lists people as soon as @ is typed', async () => {
+ renderEditor();
+ await openEditorAndType('Hi @');
+
+ await expect.element(mentionMenu().getByText('Anna Kovalenko')).toBeVisible();
+ await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible();
+ });
+
+ it('replaces the query with the picked person followed by a space', async () => {
+ const editor = await renderEditorWithHandle();
+ await openEditorAndType('Hi @pet');
+ await userEvent.click(mentionMenu().getByText('Peter Lang'));
+ await userEvent.keyboard('there');
+
+ await expect.poll(() => blockContent(editor)).toEqual([
+ textContent('Hi '),
+ mentionContent('8', 'Peter Lang'),
+ textContent(' there'),
+ ]);
+ });
+
+ it('records the signed-in user as the one who mentioned', async () => {
+ signInAs(4);
+ const editor = await renderEditorWithHandle();
+ await openEditorAndType('Hi @pet');
+ await userEvent.click(mentionMenu().getByText('Peter Lang'));
+
+ await expect.poll(() => blockContent(editor)[1]).toEqual(mentionContent('8', 'Peter Lang', '4'));
+ });
+
+ it('picks with the keyboard', async () => {
+ renderEditor();
+ await openEditorAndType('@');
+ await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible();
+ await userEvent.keyboard('{ArrowDown}{Enter}');
+
+ await expect.element(page.getByRole('textbox').getByRole('link', { name: 'Peter Lang' })).toBeVisible();
+ });
+
+ it('keeps the typed text when Enter is pressed and nobody matches', async () => {
+ renderEditor();
+ await openEditorAndType('@zzz');
+ await expect.element(mentionMenu()).toHaveTextContent('No results');
+ await userEvent.keyboard('{Enter}');
+
+ await expect.element(page.getByRole('textbox')).toMatchTextContent(/^@zzz$/);
+ await expect.element(mentionMenu()).not.toBeInTheDocument();
+ });
+
+ it('closes on Escape and keeps the typed query', async () => {
+ renderEditor();
+ await openEditorAndType('@pet');
+ await expect.element(mentionMenu().getByText('Peter Lang')).toBeVisible();
+
+ await userEvent.keyboard('{Escape}');
+
+ await expect.element(mentionMenu()).not.toBeInTheDocument();
+ await expect.element(page.getByRole('textbox')).toMatchTextContent(/^@pet$/);
+ });
+
+ it('does not open inside an email address', async () => {
+ renderEditor();
+ await openEditorAndType('ihor@op');
+ await expect.element(page.getByRole('textbox')).toMatchTextContent(/^ihor@op$/);
+ await expect.element(mentionMenu()).not.toBeInTheDocument();
+
+ await userEvent.keyboard(' @');
+ await expect.element(mentionMenu()).toBeVisible();
+ });
+
+ it('says so when the search fails', async () => {
+ worker.use(http.get('http://localhost:3000/api/v3/principals', () => new HttpResponse(null, { status: 500 })));
+ renderEditor();
+ await openEditorAndType('@a');
+
+ await expect.element(mentionMenu()).toHaveTextContent('Error. Unable to load content.');
+ });
+});
diff --git a/test/lib/components/integration/userMentionOptions.browser.test.tsx b/test/lib/components/integration/userMentionOptions.browser.test.tsx
new file mode 100644
index 00000000..13170adc
--- /dev/null
+++ b/test/lib/components/integration/userMentionOptions.browser.test.tsx
@@ -0,0 +1,143 @@
+import { describe, expect, it, vi } from 'vitest';
+import { page, userEvent } from 'vitest/browser';
+import type { Locator } from 'vitest/browser';
+import { renderEditorWithHandle } from '../../../helpers/renderEditor';
+import { mentionContent, paragraphWith } from '../../../helpers/content';
+import { blockTextWithNodes, selectedNodeType } from '../../../helpers/headlessEditor';
+import { tapElement, withNavigationPrevented } from '../../../helpers/editorHelpers';
+import { USER_MENTION_TYPE } from '../../../../lib/utils/nodeTypes';
+
+const anna = { userId: '7', name: 'Anna Kovalenko' };
+const peter = { userId: '8', name: 'Peter Lang' };
+const OPEN_LABEL = "Open Anna Kovalenko's profile in new tab";
+
+const popover = () => page.getByTestId('popover-content');
+
+async function renderMention({ person = anna, textAfter = ' for the review' } = {}) {
+ const editor = await renderEditorWithHandle({
+ initialContent: [
+ paragraphWith('Thanks ', mentionContent(person.userId, person.name), ...(textAfter ? [textAfter] : [])),
+ paragraphWith('Somewhere else'),
+ ],
+ });
+ const link = page.getByRole('link', { name: person.name });
+ await expect.element(link).toBeVisible();
+ const pill = page.getByRole('button', { name: `User ${person.name}` });
+ return { editor, link, pill, avatar: pill.getByTestId('avatar') };
+}
+
+async function openOptions(avatar:Locator) {
+ await userEvent.click(avatar);
+ await expect.element(popover()).toBeVisible();
+}
+
+describe('User mention options', () => {
+ it('selects the mention and shows Open and Remove when the pill is clicked', async () => {
+ const { editor, avatar } = await renderMention();
+ await expect.element(avatar.element().querySelector('img')!).toBeVisible();
+
+ await openOptions(avatar);
+
+ await expect.element(page.getByRole('button', { name: OPEN_LABEL })).toBeVisible();
+ await expect.element(page.getByTestId('remove-btn')).toBeVisible();
+ expect(selectedNodeType(editor)).toBe(USER_MENTION_TYPE);
+ });
+
+ it('opens the popover from the initials of a user without a picture', async () => {
+ const { avatar } = await renderMention({ person: peter });
+ await expect.element(avatar).toHaveTextContent('PL');
+
+ await openOptions(avatar);
+ });
+
+ it('closes the popover when the pill is clicked again', async () => {
+ const { avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(avatar);
+
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('removes the selected mention with Backspace', async () => {
+ const { editor, avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.keyboard('{Backspace}');
+
+ await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review');
+ });
+
+ it('removes the selected mention with Delete', async () => {
+ const { editor, avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.keyboard('{Delete}');
+
+ await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review');
+ });
+
+ it('removes the mention with Backspace from the caret right after it', async () => {
+ const { editor } = await renderMention({ textAfter: '' });
+ editor.focus();
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ expect(selectedNodeType(editor)).toBeUndefined();
+
+ await userEvent.keyboard('{Backspace}');
+
+ await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks ');
+ });
+
+ it('removes the mention and closes the popover from the Remove button', async () => {
+ const { editor, avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(page.getByTestId('remove-btn'));
+
+ await expect.poll(() => blockTextWithNodes(editor)).toBe('Thanks for the review');
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('opens the profile in a new tab from the Open button', async () => {
+ const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
+ try {
+ const { avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(page.getByRole('button', { name: OPEN_LABEL }));
+
+ expect(openSpy).toHaveBeenCalledWith('http://localhost:3000/users/7', '_blank', 'noopener,noreferrer');
+ } finally {
+ openSpy.mockRestore();
+ }
+ });
+
+ it('closes the popover on a click outside the pill', async () => {
+ const { avatar } = await renderMention();
+ await openOptions(avatar);
+
+ await userEvent.click(page.getByText('Somewhere else'));
+
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('does not open the popover when the name link is clicked', async () => {
+ const { link } = await renderMention();
+
+ const clicks = await withNavigationPrevented(() => userEvent.click(link));
+
+ expect(clicks).toEqual([{ defaultPrevented: false, href: 'http://localhost:3000/users/7' }]);
+ await expect.element(popover()).not.toBeInTheDocument();
+ });
+
+ it('opens the popover from one tap on the pill, but not from a tap on the name', async () => {
+ const { link, avatar } = await renderMention();
+
+ const nameTap = tapElement(link.element());
+ expect(nameTap.defaultPrevented).toBe(false);
+ await expect.element(popover()).not.toBeInTheDocument();
+
+ tapElement(avatar.element());
+ await expect.element(popover()).toBeVisible();
+ });
+});
diff --git a/test/lib/components/shared/Avatar/avatar.browser.test.tsx b/test/lib/components/shared/Avatar/avatar.browser.test.tsx
new file mode 100644
index 00000000..107659cd
--- /dev/null
+++ b/test/lib/components/shared/Avatar/avatar.browser.test.tsx
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { render } from 'vitest-browser-react';
+import { Avatar } from '../../../../../lib/components/shared/Avatar';
+
+async function renderAvatar(userId:number, name:string) {
+ const { container } = await render();
+ const avatar = container.querySelector('.op-bn-avatar')!;
+ return { avatar, picture: avatar.querySelector('img')! };
+}
+
+describe('Avatar', () => {
+ it('shows the uploaded picture instead of the initials once it has loaded', async () => {
+ const { avatar, picture } = await renderAvatar(7, 'Anna Kovalenko');
+
+ await expect.element(picture).toBeVisible();
+ await expect.element(avatar).not.toHaveTextContent('AK');
+ });
+
+ it('keeps the initials when the user has no avatar', async () => {
+ const { avatar, picture } = await renderAvatar(8, 'Peter Lang');
+
+ await expect.poll(() => picture.complete).toBe(true);
+ expect(picture.naturalWidth).toBe(0);
+
+ await expect.element(avatar).toHaveTextContent('PL');
+ await expect.element(picture).not.toBeVisible();
+ });
+
+ it('hides the avatar from screen readers', async () => {
+ const { avatar } = await renderAvatar(8, 'Peter Lang');
+
+ expect(avatar.getAttribute('aria-hidden')).toBe('true');
+ expect(avatar.hasAttribute('role')).toBe(false);
+ expect(avatar.hasAttribute('aria-label')).toBe(false);
+ expect(avatar.hasAttribute('title')).toBe(false);
+ });
+});
diff --git a/test/lib/components/shared/Avatar/avatars.test.ts b/test/lib/components/shared/Avatar/avatars.test.ts
new file mode 100644
index 00000000..1a46a3df
--- /dev/null
+++ b/test/lib/components/shared/Avatar/avatars.test.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { avatarColorOf, initialsOf } from '../../../../../lib/components/shared/Avatar/avatars';
+
+describe('initialsOf', () => {
+ it('takes the first letters of the first and the last name', () => {
+ expect(initialsOf('Mira Hofmann')).toBe('MH');
+ expect(initialsOf('jean de la cruz')).toBe('JC');
+ });
+
+ it('takes one letter of a single name', () => {
+ expect(initialsOf('admin')).toBe('A');
+ });
+
+ it('keeps an emoji whole', () => {
+ expect(initialsOf('🦊 Fox')).toBe('🦊F');
+ });
+});
+
+describe('avatarColorOf', () => {
+ it('gives the color OpenProject gives the same person', () => {
+ expect(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/3' })).toBe('hsl(-183, 50%, 30%)');
+ });
+
+ it('tells two people of the same name apart', () => {
+ expect(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/4' }))
+ .not.toBe(avatarColorOf({ name: 'Mira Hofmann', href: '/api/v3/users/3' }));
+ });
+});
diff --git a/test/lib/hooks/useDebouncedSearch.test.ts b/test/lib/hooks/useDebouncedSearch.test.ts
new file mode 100644
index 00000000..094602e0
--- /dev/null
+++ b/test/lib/hooks/useDebouncedSearch.test.ts
@@ -0,0 +1,71 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { renderHook } from '@testing-library/react';
+import { useDebouncedSearch } from '../../../lib/hooks/useDebouncedSearch';
+
+describe('useDebouncedSearch', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('only fetches the last query and settles the superseded one empty', async () => {
+ const search = vi.fn(async (query:string) => [query]);
+ const { result } = renderHook(() => useDebouncedSearch(search, { debounce: 300 }));
+
+ const first = result.current.search('a');
+ const second = result.current.search('ab');
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(await first).toEqual([]);
+ expect(await second).toEqual(['ab']);
+ expect(search).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips blank queries unless asked to search them', async () => {
+ const search = vi.fn(async () => ['x']);
+ const skipping = renderHook(() => useDebouncedSearch(search, { debounce: 0 })).result.current.search;
+ const searching = renderHook(() => useDebouncedSearch(search, { debounce: 0, searchBlank: true })).result.current.search;
+
+ expect(await skipping(' ')).toEqual([]);
+ const blank = searching('');
+ await vi.advanceTimersByTimeAsync(0);
+ expect(await blank).toEqual(['x']);
+ expect(search).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects with the search error', async () => {
+ const { result } = renderHook(() => useDebouncedSearch(async () => { throw new Error('boom'); }, { debounce: 0 }));
+
+ const failing = result.current.search('a');
+ await Promise.all([
+ expect(failing).rejects.toThrow('boom'),
+ vi.advanceTimersByTimeAsync(0),
+ ]);
+ });
+
+ it('rejects when the search throws synchronously', async () => {
+ const { result } = renderHook(() => useDebouncedSearch(() => { throw new Error('sync boom'); }, { debounce: 0 }));
+
+ const failing = result.current.search('a');
+ await Promise.all([
+ expect(failing).rejects.toThrow('sync boom'),
+ vi.advanceTimersByTimeAsync(0),
+ ]);
+ });
+
+ it('settles a cancelled search empty without fetching', async () => {
+ const search = vi.fn(async (query:string) => [query]);
+ const { result } = renderHook(() => useDebouncedSearch(search, { debounce: 300 }));
+
+ const pending = result.current.search('a');
+ result.current.cancel();
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(await pending).toEqual([]);
+ expect(search).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/lib/hooks/useSuggestionSearch.test.ts b/test/lib/hooks/useSuggestionSearch.test.ts
new file mode 100644
index 00000000..906b6d03
--- /dev/null
+++ b/test/lib/hooks/useSuggestionSearch.test.ts
@@ -0,0 +1,132 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { RefObject } from 'react';
+import { renderHook } from '@testing-library/react';
+import { useDebouncedSearch } from '../../../lib/hooks/useDebouncedSearch';
+import { useSuggestionSearch } from '../../../lib/hooks/useSuggestionSearch';
+import type { SuggestionPlan, SuggestionSearchState } from '../../../lib/hooks/useSuggestionSearch';
+import { createHeadlessEditorWithText } from '../../helpers/headlessEditor';
+
+interface PendingSearch {
+ resolve:(results:string[]) => void;
+ reject:(error:Error) => void;
+}
+
+const searchPlan = ():SuggestionPlan => ({ kind: 'search', pick: () => {} });
+
+function renderSuggestionSearch() {
+ const pending = new Map();
+ const search = (query:string) => new Promise((resolve, reject) => {
+ pending.set(query, { resolve, reject });
+ });
+ let searchState!:RefObject>;
+ const createMenu = (ref:RefObject>) => {
+ searchState = ref;
+ return () => null;
+ };
+
+ const { result } = renderHook(() => useSuggestionSearch(createHeadlessEditorWithText('@'), {
+ trigger: '@',
+ planFor: searchPlan,
+ search,
+ cancelSearch: () => {},
+ logPrefix: '[test search]',
+ createMenu,
+ }));
+
+ return {
+ getItems: result.current.getItems,
+ searchFor: (query:string) => pending.get(query)!,
+ state: () => searchState.current,
+ };
+}
+
+describe('useSuggestionSearch', () => {
+ beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('lets only the latest query write the search state', async () => {
+ const { getItems, searchFor, state } = renderSuggestionSearch();
+ const stale = getItems('a');
+ const latest = getItems('an');
+
+ searchFor('an').resolve(['Anna']);
+ expect(await latest).toHaveLength(1);
+ searchFor('a').resolve(['Adam', 'Anna']);
+
+ expect(await stale).toEqual([]);
+ expect(state()).toEqual({ query: 'an', results: ['Anna'], error: null });
+ });
+
+ it('does not let a stale error overwrite the latest results', async () => {
+ const { getItems, searchFor, state } = renderSuggestionSearch();
+ const stale = getItems('a');
+ const latest = getItems('an');
+
+ searchFor('an').resolve(['Anna']);
+ await latest;
+ searchFor('a').reject(new Error('offline'));
+ await stale;
+
+ expect(console.error).toHaveBeenCalledWith('[test search]', new Error('offline'));
+ expect(state()).toEqual({ query: 'an', results: ['Anna'], error: null });
+ });
+
+ it('lets only the latest call write the state when the same query is asked twice', async () => {
+ const pending:PendingSearch[] = [];
+ const search = () => new Promise((resolve, reject) => { pending.push({ resolve, reject }); });
+ let searchState!:RefObject>;
+ const { result } = renderHook(() => useSuggestionSearch(createHeadlessEditorWithText('@'), {
+ trigger: '@',
+ planFor: searchPlan,
+ search,
+ cancelSearch: () => {},
+ logPrefix: '[test search]',
+ createMenu: (ref) => { searchState = ref; return () => null; },
+ }));
+
+ const first = result.current.getItems('a');
+ const second = result.current.getItems('a');
+ pending[1].resolve(['Anna']);
+ expect(await second).toHaveLength(1);
+ pending[0].resolve(['Adam']);
+
+ expect(await first).toEqual([]);
+ expect(searchState.current).toEqual({ query: 'a', results: ['Anna'], error: null });
+ });
+
+ it('cancels the pending search when the query stops being searchable', async () => {
+ vi.useFakeTimers();
+ try {
+ const search = vi.fn(async (query:string) => [query]);
+ const planFor = (query:string):SuggestionPlan => (
+ query === '12' ? searchPlan() : { kind: 'prompt' }
+ );
+ const { result } = renderHook(() => {
+ const debounced = useDebouncedSearch(search, { debounce: 300 });
+ return useSuggestionSearch(createHeadlessEditorWithText('#'), {
+ trigger: '#',
+ planFor,
+ search: debounced.search,
+ cancelSearch: debounced.cancel,
+ logPrefix: '[test search]',
+ createMenu: () => () => null,
+ });
+ });
+
+ const searching = result.current.getItems('12');
+ await result.current.getItems('');
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(search).not.toHaveBeenCalled();
+ expect(await searching).toEqual([]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/test/lib/hooks/useWorkPackageSearch.test.ts b/test/lib/hooks/useWorkPackageSearch.test.ts
new file mode 100644
index 00000000..911c1ec6
--- /dev/null
+++ b/test/lib/hooks/useWorkPackageSearch.test.ts
@@ -0,0 +1,95 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, renderHook } from '@testing-library/react';
+import type { RenderHookResult } from '@testing-library/react';
+import { useWorkPackageSearch } from '../../../lib/hooks/useWorkPackageSearch';
+import { searchWorkPackages } from '../../../lib/services/openProjectApi';
+import type { WorkPackage } from '../../../lib/openProjectTypes';
+
+vi.mock('../../../lib/services/openProjectApi', () => ({
+ searchWorkPackages: vi.fn(),
+}));
+
+type SearchHook = RenderHookResult, unknown>;
+
+const workPackage = { id: 12 } as WorkPackage;
+
+function startSearch({ result }:SearchHook, query:string):Promise {
+ let call!:Promise;
+ act(() => {
+ call = result.current.search(query);
+ });
+ return call;
+}
+
+const passDebounce = () => act(() => vi.advanceTimersByTimeAsync(300));
+
+describe('useWorkPackageSearch imperative search', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.mocked(searchWorkPackages).mockReset();
+ vi.mocked(searchWorkPackages).mockResolvedValue([workPackage]);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('cancels a pending search when called with a blank query', async () => {
+ const hook = renderHook(() => useWorkPackageSearch());
+
+ const pending = startSearch(hook, '12');
+ const blank = startSearch(hook, '');
+ await passDebounce();
+
+ expect(await pending).toEqual([]);
+ expect(await blank).toEqual([]);
+ expect(searchWorkPackages).not.toHaveBeenCalled();
+ expect(hook.result.current.searchResults).toEqual([]);
+ });
+
+ it('does not touch state when a call is superseded', async () => {
+ const hook = renderHook(() => useWorkPackageSearch());
+
+ const initial = startSearch(hook, '1');
+ await passDebounce();
+ expect(await initial).toEqual([workPackage]);
+ expect(hook.result.current.searchResults).toEqual([workPackage]);
+
+ const superseded = startSearch(hook, '2');
+ const latest = startSearch(hook, '3');
+ expect(await superseded).toEqual([]);
+ expect(hook.result.current.searchResults).toEqual([workPackage]);
+
+ await passDebounce();
+ expect(await latest).toEqual([workPackage]);
+ });
+
+ it('rethrows a failed search, but only the latest call clears the results', async () => {
+ const hook = renderHook(() => useWorkPackageSearch());
+ const initial = startSearch(hook, '1');
+ await passDebounce();
+ expect(await initial).toEqual([workPackage]);
+
+ let failSuperseded:(error:Error) => void = () => {};
+ vi.mocked(searchWorkPackages)
+ .mockImplementationOnce(() => new Promise((_resolve, reject) => { failSuperseded = reject; }))
+ .mockRejectedValueOnce(new Error('latest failed'));
+
+ const superseded = startSearch(hook, '2');
+ await passDebounce();
+ const latest = startSearch(hook, '3');
+
+ await act(async () => {
+ failSuperseded(new Error('superseded failed'));
+ await expect(superseded).rejects.toThrow('superseded failed');
+ });
+ expect(hook.result.current.searchResults).toEqual([workPackage]);
+
+ await Promise.all([
+ expect(latest).rejects.toThrow('latest failed'),
+ passDebounce(),
+ ]);
+ expect(hook.result.current.searchResults).toEqual([]);
+ });
+});
diff --git a/test/lib/plugins/userMentionAuthor.test.ts b/test/lib/plugins/userMentionAuthor.test.ts
new file mode 100644
index 00000000..5e916a94
--- /dev/null
+++ b/test/lib/plugins/userMentionAuthor.test.ts
@@ -0,0 +1,154 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest';
+import type { Transaction } from 'prosemirror-state';
+import type { Schema } from 'prosemirror-model';
+import { ySyncPluginKey } from 'y-prosemirror';
+import { initEditorContext } from '../../../lib/services/editorContext';
+import { userMentionAuthorPlugin } from '../../../lib/plugins/userMentionAuthorPlugin';
+import { USER_MENTION_TYPE } from '../../../lib/utils/nodeTypes';
+import type { AnyEditor } from '../../../lib/editorTypes';
+import {
+ blockText,
+ blockTextWithNodes,
+ createCollaborativeHeadlessEditors,
+ createHeadlessEditor,
+ createHeadlessEditorWithBlocks,
+ createHeadlessEditorWithText,
+ mentionsIn,
+ mountHeadlessEditor,
+ nthInlineNodePosition,
+ placeCursorAfterText,
+} from '../../helpers/headlessEditor';
+import { mentionContent, paragraphWith, textContent } from '../../helpers/content';
+
+function judithNode(schema:Schema, mentionedBy:string) {
+ return schema.nodes[USER_MENTION_TYPE].create({ userId: '5', name: 'Judith Roth', mentionedBy });
+}
+
+function insertMentionNode(editor:AnyEditor, mentionedBy:string, meta?:(transaction:Transaction) => void) {
+ editor.transact((transaction) => {
+ transaction.insert(transaction.selection.from, judithNode(transaction.doc.type.schema, mentionedBy));
+ meta?.(transaction);
+ });
+}
+
+describe('UserMentionAuthorExtension', () => {
+ afterEach(() => initEditorContext({}));
+
+ it('records the current user on an inserted mention', () => {
+ initEditorContext({ currentUserId: 4 });
+ const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks '));
+
+ editor.insertInlineContent([mentionContent('5', 'Judith Roth')]);
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '4').props]);
+ });
+
+ it('replaces the author a pasted mention brings along', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks '));
+
+ insertMentionNode(editor, '99');
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '4').props]);
+ });
+
+ it('leaves a mention that arrives through Yjs alone', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks '));
+
+ insertMentionNode(editor, '99', (transaction) => transaction.setMeta(ySyncPluginKey, { isChangeOrigin: true }));
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '99').props]);
+ });
+
+ it('clears the author a pasted mention brings along while the current user is unknown', () => {
+ const editor = mountHeadlessEditor(createHeadlessEditorWithText('Thanks '));
+
+ insertMentionNode(editor, '99');
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth').props]);
+ });
+
+ it('records the author once when several steps of a transaction cover the same mention', () => {
+ initEditorContext({ currentUserId: '4' });
+ // Left unmounted, so applying the transaction does not already run the plugin.
+ const { prosemirrorState: state } = createHeadlessEditorWithText('Thanks ');
+ const { from } = state.selection;
+ const transaction = state.tr.insertText('ab', from);
+ transaction.insert(from + 1, judithNode(state.schema, '99'));
+
+ const stamp = userMentionAuthorPlugin().spec.appendTransaction?.([transaction], state, state.apply(transaction));
+
+ expect(stamp?.steps).toHaveLength(1);
+ });
+
+ it('keeps the author of mentions an edit does not touch', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditor([
+ textContent('Thanks '), mentionContent('5', 'Judith Roth', '7'), textContent(' and more'),
+ ]));
+
+ placeCursorAfterText(editor, 'more');
+ editor.insertInlineContent(['!']);
+ editor.updateBlock(editor.document[0], { type: 'heading' });
+
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '7').props]);
+ });
+
+ it('keeps the author of a mention in a moved block', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditorWithBlocks([
+ paragraphWith(mentionContent('5', 'Judith Roth', '7')), paragraphWith('Last line'),
+ ]));
+
+ editor.setTextCursorPosition(editor.document[0], 'end');
+ editor.moveBlocksDown();
+
+ expect(editor.document.map((_block, index) => blockText(editor, index))).toEqual(['Last line', '']);
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '7').props]);
+ });
+
+ it('keeps the author of a mention moved within the text', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditor([mentionContent('5', 'Judith Roth', '7'), textContent(' and more')]));
+ const mentionPosition = nthInlineNodePosition(editor, USER_MENTION_TYPE);
+
+ editor.transact((transaction) => {
+ const mention = transaction.doc.nodeAt(mentionPosition)!;
+ transaction.delete(mentionPosition, mentionPosition + mention.nodeSize);
+ transaction.insert(mentionPosition + ' and'.length, mention);
+ });
+
+ expect(blockTextWithNodes(editor)).toEqual(' and[openProjectUserMention] more');
+ expect(mentionsIn(editor)).toEqual([mentionContent('5', 'Judith Roth', '7').props]);
+ });
+
+ it('records the current user on a duplicated mention only', () => {
+ initEditorContext({ currentUserId: '4' });
+ const editor = mountHeadlessEditor(createHeadlessEditor([mentionContent('5', 'Judith Roth', '7'), textContent(' and')]));
+ const mentionPosition = nthInlineNodePosition(editor, USER_MENTION_TYPE);
+
+ placeCursorAfterText(editor, 'and');
+ editor.transact((transaction) => {
+ transaction.insert(transaction.selection.from, transaction.doc.nodeAt(mentionPosition)!);
+ });
+
+ expect(mentionsIn(editor)).toEqual([
+ mentionContent('5', 'Judith Roth', '7').props,
+ mentionContent('5', 'Judith Roth', '4').props,
+ ]);
+ });
+
+ it('does not take over a mention another collaborator inserted', () => {
+ const { editors: [mine, theirs], sync } = createCollaborativeHeadlessEditors();
+ initEditorContext({ currentUserId: '7' });
+ theirs.setTextCursorPosition(theirs.document[0], 'end');
+ theirs.insertInlineContent([mentionContent('5', 'Judith Roth')]);
+
+ initEditorContext({ currentUserId: '4' });
+ sync();
+
+ expect(mentionsIn(mine)).toEqual([mentionContent('5', 'Judith Roth', '7').props]);
+ });
+});
diff --git a/test/lib/services/openProjectApi.test.ts b/test/lib/services/openProjectApi.test.ts
index 3f2b2d2a..8d40c8a5 100644
--- a/test/lib/services/openProjectApi.test.ts
+++ b/test/lib/services/openProjectApi.test.ts
@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { MockInstance } from 'vitest';
import {
+ avatarUrlOf,
canCreateWorkPackages,
createWorkPackage,
fetchAllowedValues,
@@ -10,11 +12,15 @@ import {
probeCreateWorkPackagePermission,
initOpenProjectApi,
linkToNewWorkPackage,
+ linkToUser,
linkToWorkPackage,
OpenProjectApiError,
parseWorkPackageUrl,
- searchWorkPackages
+ searchMentionableUsers,
+ searchWorkPackages,
+ userApiPath,
} from '../../../lib/services/openProjectApi';
+import { initEditorContext } from '../../../lib/services/editorContext';
function mockResponse(props:Partial):Response {
return props as Response;
@@ -735,6 +741,77 @@ describe('openProjectApi', () => {
});
});
+ describe('user links', () => {
+ it('links to the user page, and loads the avatar through the proxy', () => {
+ initOpenProjectApi({ baseUrl: 'https://example.com/', proxyUrl: 'https://proxy.example.com' });
+
+ expect(linkToUser(5)).toBe('https://example.com/users/5');
+ expect(userApiPath('5')).toBe('/api/v3/users/5');
+ expect(avatarUrlOf(userApiPath(5))).toBe('https://proxy.example.com/api/v3/users/5/avatar');
+ });
+
+ it('has no avatar picture for groups and placeholder users', () => {
+ expect(avatarUrlOf('/api/v3/groups/3')).toBeUndefined();
+ expect(avatarUrlOf('/api/v3/placeholder_users/3')).toBeUndefined();
+ expect(avatarUrlOf(undefined)).toBeUndefined();
+ });
+ });
+
+ describe('searchMentionableUsers', () => {
+ const anna = { id: 7, name: 'Anna Kovalenko', login: 'anna', _links: { self: { href: '/api/v3/users/7' } } };
+ let fetchSpy:MockInstance;
+
+ const requestedUrl = () => new URL(calledUrl(fetchSpy.mock.calls));
+ const filtersOf = (url:URL) => JSON.parse(url.searchParams.get('filters')!) as unknown[];
+
+ beforeEach(() => {
+ initOpenProjectApi({ baseUrl: 'http://localhost:3000' });
+ fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse({
+ ok: true,
+ json: async () => ({ _embedded: { elements: [anna] } }),
+ }));
+ });
+
+ afterEach(() => {
+ fetchSpy.mockRestore();
+ initEditorContext({});
+ });
+
+ it('asks for active users of the document project matching the query, sorted by name', async () => {
+ initEditorContext({ projectId: 12 });
+ await searchMentionableUsers('jud');
+
+ const url = requestedUrl();
+ expect(url.pathname).toBe('/api/v3/principals');
+ expect(filtersOf(url)).toEqual([
+ { status: { operator: '!', values: ['3'] } },
+ { type: { operator: '=', values: ['User'] } },
+ { member: { operator: '=', values: ['12'] } },
+ { typeahead: { operator: '**', values: ['jud'] } },
+ ]);
+ expect(url.searchParams.get('sortBy')).toBe('[["name","asc"]]');
+ });
+
+ it('asks for at most 5 people', async () => {
+ await searchMentionableUsers('jud');
+
+ expect(requestedUrl().searchParams.get('pageSize')).toBe('5');
+ });
+
+ it('lists users without a project or a query', async () => {
+ await searchMentionableUsers(' ');
+
+ expect(filtersOf(requestedUrl())).toEqual([
+ { status: { operator: '!', values: ['3'] } },
+ { type: { operator: '=', values: ['User'] } },
+ ]);
+ });
+
+ it('keeps only the id and the name of each user', async () => {
+ expect(await searchMentionableUsers('ann')).toEqual([{ id: 7, name: 'Anna Kovalenko' }]);
+ });
+ });
+
describe('fetchTypes', () => {
it('resolves with data on success', async () => {
initOpenProjectApi({ baseUrl: 'http://localhost:3000' });
diff --git a/test/lib/utils/html.test.ts b/test/lib/utils/html.test.ts
new file mode 100644
index 00000000..45b242b4
--- /dev/null
+++ b/test/lib/utils/html.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { escapeHtmlAttribute, escapeHtmlText } from '../../../lib/utils/html';
+
+describe('html escaping', () => {
+ it('escapes text so it cannot open a tag', () => {
+ expect(escapeHtmlText('A & B