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 ( - - - {t('hashMenu.typeToSearch')} - - - - ); - } - - if (!query) { - return ( - - {t('hashMenu.typeToSearch')} - - ); - } - - if (error || results.length === 0) { - return ( - - {error ? t('search.error') : t('search.noResults')} - - ); - } return ( - setHoveredIndex(null) : undefined} - > - {results.map((wp, index) => ( - setHoveredIndex(index) : undefined} - role="option" - aria-selected={selectedIndex === index} - onMouseDown={(e) => e.preventDefault()} - {...tapProps(() => pick(index))} - > - - - ))} - + 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 {loadingMessage}; + } + + if (emptyMessage !== null) { + return {emptyMessage}; + } + + const highlightedIndex = hoveredIndex ?? selectedIndex; + + return ( + setHoveredIndex(null) : undefined} + > + {options.map((option, index) => ( + setHoveredIndex(index) : undefined} + role="option" + aria-selected={selectedIndex === index} + onMouseDown={(event) => event.preventDefault()} + {...tapProps(() => pick(index))} + > + {renderOption(option)} + + ))} + + ); +} 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