Skip to content

Commit 09728c9

Browse files
Fix MarkdownEditor suggestions filtering bug and allow lazy-loading suggestions (#2424)
* Fix test workaround * Allow lazy-loading Markdown suggestions * Update tests to demonstrate fuzzy filtering bug * Fix type error * Fix bug where short items incorrectly appear first in suggestions lists * Create nine-apes-add.md Co-authored-by: Siddharth Kshetrapal <[email protected]>
1 parent 294b4c4 commit 09728c9

File tree

8 files changed

+163
-43
lines changed

8 files changed

+163
-43
lines changed

.changeset/nine-apes-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
Fix `MarkdownEditor` suggestions filtering bug and allow lazy-loading suggestions

src/drafts/MarkdownEditor/MarkdownEditor.stories.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {DiffIcon} from '@primer/octicons-react'
22
import React, {Meta} from '@storybook/react'
3-
import {useState} from 'react'
3+
import {useRef, useState} from 'react'
44
import BaseStyles from '../../BaseStyles'
55
import Box from '../../Box'
66
import MarkdownEditor, {Emoji, Mentionable, Reference, SavedReply} from '.'
@@ -301,3 +301,67 @@ export const CustomButtons = ({
301301
</>
302302
)
303303
}
304+
305+
function useLazySuggestions<T>(suggestions: T[]) {
306+
const promiseRef = useRef<Promise<T[]> | null>(null)
307+
308+
return () => {
309+
// This simulates waiting to make an API request until the first time the suggestions are needed
310+
// Then, once we have made the API request we keep returning the same Promise which will already
311+
// be resolved with the cached data
312+
if (!promiseRef.current) {
313+
promiseRef.current = new Promise(resolve => {
314+
setTimeout(() => resolve(suggestions), 500)
315+
})
316+
}
317+
318+
return promiseRef.current
319+
}
320+
}
321+
322+
export const LazyLoadedSuggestions = ({
323+
disabled,
324+
fullHeight,
325+
monospace,
326+
minHeightLines,
327+
maxHeightLines,
328+
hideLabel,
329+
required,
330+
fileUploadsEnabled,
331+
onSubmit,
332+
savedRepliesEnabled,
333+
pasteUrlsAsPlainText
334+
}: ArgProps) => {
335+
const [value, setValue] = useState('')
336+
337+
const emojiSuggestions = useLazySuggestions(emojis)
338+
const mentionSuggestions = useLazySuggestions(mentionables)
339+
const referenceSuggestions = useLazySuggestions(references)
340+
341+
return (
342+
<>
343+
<MarkdownEditor
344+
value={value}
345+
onChange={setValue}
346+
onPrimaryAction={onSubmit}
347+
disabled={disabled}
348+
fullHeight={fullHeight}
349+
monospace={monospace}
350+
minHeightLines={minHeightLines}
351+
maxHeightLines={maxHeightLines}
352+
placeholder="Enter some Markdown..."
353+
onRenderPreview={renderPreview}
354+
onUploadFile={fileUploadsEnabled ? onUploadFile : undefined}
355+
emojiSuggestions={emojiSuggestions}
356+
mentionSuggestions={mentionSuggestions}
357+
referenceSuggestions={referenceSuggestions}
358+
savedReplies={savedRepliesEnabled ? savedReplies : undefined}
359+
required={required}
360+
pasteUrlsAsPlainText={pasteUrlsAsPlainText}
361+
>
362+
<MarkdownEditor.Label visuallyHidden={hideLabel}>Markdown Editor Example</MarkdownEditor.Label>
363+
</MarkdownEditor>
364+
<p>Note: for demo purposes, files starting with &quot;A&quot; will be rejected.</p>
365+
</>
366+
)
367+
}

src/drafts/MarkdownEditor/MarkdownEditor.test.tsx

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {DiffAddedIcon} from '@primer/octicons-react'
22
import {fireEvent, render as _render, waitFor, within} from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
44
import {UserEvent} from '@testing-library/user-event/dist/types/setup'
5-
import React, {forwardRef, useLayoutEffect, useRef, useState} from 'react'
5+
import React, {forwardRef, useRef, useState} from 'react'
66
import MarkdownEditor, {Emoji, MarkdownEditorHandle, MarkdownEditorProps, Mentionable, Reference, SavedReply} from '.'
77
import ThemeProvider from '../../ThemeProvider'
88

@@ -21,17 +21,6 @@ const UncontrolledEditor = forwardRef<MarkdownEditorHandle, UncontrolledEditorPr
2121

2222
const onRenderPreview = async () => 'Preview'
2323

24-
useLayoutEffect(() => {
25-
// combobox-nav attempts to filter out 'hidden' options by checking if the option has an
26-
// offsetHeight or width > 0. In JSDom, all elements have offsetHeight = offsetWidth = 0,
27-
// so we need to override at least one to make the class recognize that any options exist.
28-
for (const option of document.querySelectorAll('[role=option]'))
29-
Object.defineProperty(option, 'offsetHeight', {
30-
value: 1,
31-
writable: true
32-
})
33-
})
34-
3524
return (
3625
<ThemeProvider>
3726
<MarkdownEditor
@@ -117,6 +106,20 @@ const render = async (ui: React.ReactElement) => {
117106
}
118107

119108
describe('MarkdownEditor', () => {
109+
// combobox-nav attempts to filter out 'hidden' options by checking if the option has an
110+
// offsetHeight or width > 0. In JSDom, all elements have offsetHeight = offsetWidth = 0,
111+
// so we need to override at least one to make the class recognize that any options exist.
112+
const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')
113+
beforeAll(() => {
114+
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
115+
configurable: true,
116+
value: 10
117+
})
118+
})
119+
afterAll(() => {
120+
if (originalOffsetHeight) Object.defineProperty(HTMLElement.prototype, 'offsetHeight', originalOffsetHeight)
121+
})
122+
120123
beforeEach(() => {
121124
jest.mock('@primer/behaviors/utils', () => ({
122125
// for all tests, default to Non-Mac (Ctrl) keybindings
@@ -832,8 +835,6 @@ describe('MarkdownEditor', () => {
832835
})
833836

834837
describe('suggestions', () => {
835-
// we don't test filtering logic here because that's up to the consumer
836-
837838
const emojis: Emoji[] = [
838839
{name: '+1', character: '👍'},
839840
{name: '-1', character: '👎'},
@@ -847,7 +848,10 @@ describe('MarkdownEditor', () => {
847848
{identifier: 'github', description: 'GitHub'},
848849
{identifier: 'primer', description: 'Primer'},
849850
{identifier: 'actions', description: 'Actions'},
850-
{identifier: 'primer-css', description: ''}
851+
{identifier: 'primer-css', description: ''},
852+
{identifier: 'mnl', description: ''},
853+
{identifier: 'gth', description: ''},
854+
{identifier: 'mla', description: ''}
851855
]
852856

853857
const references: Reference[] = [
@@ -982,6 +986,20 @@ describe('MarkdownEditor', () => {
982986
})
983987
})
984988

989+
it('applies suggestion and hides list on %s-press', async () => {
990+
const {queryForSuggestionsList, getAllSuggestions, getInput, user} = await render(<EditorWithSuggestions />)
991+
992+
const input = getInput()
993+
await user.type(input, `hello :`)
994+
expect(queryForSuggestionsList()).toBeInTheDocument()
995+
996+
await waitFor(() => expect(getAllSuggestions()[0]).toHaveAttribute('data-combobox-option-default'))
997+
998+
await user.keyboard(`{Enter}`)
999+
expect(input.value).toBe(`hello 👍 `) // suggestions are inserted with a following space
1000+
expect(queryForSuggestionsList()).not.toBeInTheDocument()
1001+
})
1002+
9851003
it('filters mention suggestions using fuzzy match against name', async () => {
9861004
const {getInput, getAllSuggestions, user} = await render(<EditorWithSuggestions />)
9871005
await user.type(getInput(), '@octct')
@@ -991,9 +1009,9 @@ describe('MarkdownEditor', () => {
9911009

9921010
it('filters mention suggestions using fuzzy match against ID', async () => {
9931011
const {getInput, getAllSuggestions, user} = await render(<EditorWithSuggestions />)
994-
await user.type(getInput(), '@prmrcss')
1012+
await user.type(getInput(), '@git')
9951013
expect(getAllSuggestions()).toHaveLength(1)
996-
expect(getAllSuggestions()[0]).toHaveTextContent('primer-css')
1014+
expect(getAllSuggestions()[0]).toHaveTextContent('github')
9971015
})
9981016

9991017
it('filters reference suggestions using fuzzy match against name', async () => {

src/drafts/MarkdownEditor/MarkdownEditor.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {Emoji} from './suggestions/_useEmojiSuggestions'
2424
import {Mentionable} from './suggestions/_useMentionSuggestions'
2525
import {Reference} from './suggestions/_useReferenceSuggestions'
2626
import {isModifierKey} from './utils'
27+
import {SuggestionOptions} from './suggestions'
2728

2829
export type MarkdownEditorProps = SxProp & {
2930
/** Current value of the editor as a multiline markdown string. */
@@ -69,12 +70,21 @@ export type MarkdownEditorProps = SxProp & {
6970
* @default 35
7071
*/
7172
maxHeightLines?: number
72-
/** Array of all possible emojis to suggest. Leave `undefined` to disable emoji autocomplete. */
73-
emojiSuggestions?: Array<Emoji>
74-
/** Array of all possible mention suggestions. Leave `undefined` to disable `@`-mention autocomplete. */
75-
mentionSuggestions?: Array<Mentionable>
76-
/** Array of all possible references to suggest. Leave `undefined` to disable `#`-reference autocomplete. */
77-
referenceSuggestions?: Array<Reference>
73+
/**
74+
* Array of all possible emojis to suggest. Leave `undefined` to disable emoji autocomplete.
75+
* For lazy-loading suggestions, an async function can be provided instead.
76+
*/
77+
emojiSuggestions?: SuggestionOptions<Emoji>
78+
/**
79+
* Array of all possible mention suggestions. Leave `undefined` to disable `@`-mention autocomplete.
80+
* For lazy-loading suggestions, an async function can be provided instead.
81+
*/
82+
mentionSuggestions?: SuggestionOptions<Mentionable>
83+
/**
84+
* Array of all possible references to suggest. Leave `undefined` to disable `#`-reference autocomplete.
85+
* For lazy-loading suggestions, an async function can be provided instead.
86+
*/
87+
referenceSuggestions?: SuggestionOptions<Reference>
7888
/**
7989
* Uploads a file to a hosting service and returns the URL. If not provided, file uploads
8090
* will be disabled.

src/drafts/MarkdownEditor/_MarkdownInput.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {Emoji, useEmojiSuggestions} from './suggestions/_useEmojiSuggestions'
77
import {Mentionable, useMentionSuggestions} from './suggestions/_useMentionSuggestions'
88
import {Reference, useReferenceSuggestions} from './suggestions/_useReferenceSuggestions'
99
import {useRefObjectAsForwardedRef} from '../../hooks'
10+
import {SuggestionOptions} from './suggestions'
1011

1112
interface MarkdownInputProps extends Omit<TextareaProps, 'onChange'> {
1213
value: string
@@ -18,9 +19,9 @@ interface MarkdownInputProps extends Omit<TextareaProps, 'onChange'> {
1819
maxLength?: number
1920
fullHeight?: boolean
2021
isDraggedOver: boolean
21-
emojiSuggestions?: Array<Emoji>
22-
mentionSuggestions?: Array<Mentionable>
23-
referenceSuggestions?: Array<Reference>
22+
emojiSuggestions?: SuggestionOptions<Emoji>
23+
mentionSuggestions?: SuggestionOptions<Mentionable>
24+
referenceSuggestions?: SuggestionOptions<Reference>
2425
minHeightLines: number
2526
maxHeightLines: number
2627
monospace: boolean
@@ -70,13 +71,14 @@ export const MarkdownInput = forwardRef<HTMLTextAreaElement, MarkdownInputProps>
7071
[mentionsTrigger, referencesTrigger, emojiTrigger]
7172
)
7273

73-
const onShowSuggestions = (event: ShowSuggestionsEvent) => {
74+
const onShowSuggestions = async (event: ShowSuggestionsEvent) => {
75+
setSuggestions('loading')
7476
if (event.trigger.triggerChar === emojiTrigger.triggerChar) {
75-
setSuggestions(calculateEmojiSuggestions(event.query))
77+
setSuggestions(await calculateEmojiSuggestions(event.query))
7678
} else if (event.trigger.triggerChar === mentionsTrigger.triggerChar) {
77-
setSuggestions(calculateMentionSuggestions(event.query))
79+
setSuggestions(await calculateMentionSuggestions(event.query))
7880
} else if (event.trigger.triggerChar === referencesTrigger.triggerChar) {
79-
setSuggestions(calculateReferenceSuggestions(event.query))
81+
setSuggestions(await calculateReferenceSuggestions(event.query))
8082
}
8183
}
8284

@@ -97,7 +99,7 @@ export const MarkdownInput = forwardRef<HTMLTextAreaElement, MarkdownInputProps>
9799
<InlineAutocomplete
98100
triggers={triggers}
99101
suggestions={suggestions}
100-
onShowSuggestions={onShowSuggestions}
102+
onShowSuggestions={e => onShowSuggestions(e)}
101103
onHideSuggestions={() => setSuggestions(null)}
102104
sx={{flex: 'auto'}}
103105
tabInsertsSuggestions

src/drafts/MarkdownEditor/suggestions/_useMentionSuggestions.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,16 @@ const mentionableToSuggestion = (mentionable: Mentionable): Suggestion => ({
2626
)
2727
})
2828

29-
const scoreSuggestion = (query: string, mentionable: Mentionable): number =>
30-
score(query, `${mentionable.identifier} ${mentionable.description}`.trim().toLowerCase())
29+
const scoreSuggestion = (query: string, mentionable: Mentionable): number => {
30+
const fzyScore = score(query, `${mentionable.identifier} ${mentionable.description}`.trim().toLowerCase())
31+
32+
// fzy unintuitively returns Infinity if the length of the item is less than or equal to the length of the query
33+
// All users have an identifier but some have empty descriptions; in those cases the query might equal the identifier
34+
// and we'd still want to show the suggestion in that case.
35+
if (fzyScore === Infinity && query.toLowerCase() !== mentionable.identifier.toLowerCase()) return -Infinity
36+
37+
return fzyScore
38+
}
3139

3240
export const useMentionSuggestions: UseSuggestionsHook<Mentionable> = mentionables => ({
3341
calculateSuggestions: suggestionsCalculator(mentionables, scoreSuggestion, mentionableToSuggestion),

src/drafts/MarkdownEditor/suggestions/_useReferenceSuggestions.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,16 @@ const referenceToSuggestion = (reference: Reference): Suggestion => ({
4343
)
4444
})
4545

46-
const scoreSuggestion = (query: string, reference: Reference): number =>
47-
score(query, `${reference.id} ${reference.titleText}`)
46+
const scoreSuggestion = (query: string, reference: Reference): number => {
47+
// fzy unituitively returns Infinity if the length of the item is less than or equal to the length of the query
48+
const fzyScore = score(query, `${reference.id} ${reference.titleText}`)
49+
// Here, unlike for mentionables, we don't need to check for equality because the user's query
50+
// can never equal the search string (we don't do filtering if the query is in "#123 some text" form)
51+
return fzyScore === Infinity ? -Infinity : fzyScore
52+
}
4853

4954
export const useReferenceSuggestions: UseSuggestionsHook<Reference> = references => ({
50-
calculateSuggestions: (query: string) => {
55+
calculateSuggestions: async (query: string) => {
5156
if (/^\d+\s/.test(query)) return [] // don't return anything if the query is in the form #123 ..., assuming they already have the number they want
5257
return suggestionsCalculator(references, scoreSuggestion, referenceToSuggestion)(query)
5358
},

src/drafts/MarkdownEditor/suggestions/index.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,31 @@ import {Suggestion, Trigger} from '../../InlineAutocomplete'
22

33
const MAX_SUGGESTIONS = 5
44

5-
export type UseSuggestionsHook<T> = (options: T[]) => {
5+
export type SuggestionOptions<T> = T[] | (() => Promise<T[]>)
6+
7+
export type UseSuggestionsHook<T> = (options: SuggestionOptions<T>) => {
68
trigger: Trigger
7-
calculateSuggestions: (query: string) => Suggestion[]
9+
calculateSuggestions: (query: string) => Promise<Suggestion[]>
810
}
911

1012
export const suggestionsCalculator =
11-
<T>(options: T[], score: (query: string, option: T) => number, toSuggestion: (option: T) => Suggestion) =>
12-
(query: string) => {
13+
<T>(
14+
options: SuggestionOptions<T>,
15+
score: (query: string, option: T) => number,
16+
toSuggestion: (option: T) => Suggestion
17+
) =>
18+
async (query: string) => {
19+
const optionsArray = Array.isArray(options) ? options : await options()
20+
1321
// If the query is empty, scores will be -INFINITY
1422
const scoredAndSorted = query
15-
? options
23+
? optionsArray
1624
.map(o => [score(query, o), o] as const)
1725
.filter(([s]) => s > 0)
1826
.sort(([a], [b]) => b - a)
1927
.slice(0, MAX_SUGGESTIONS)
2028
.map(([, o]) => o)
21-
: options.slice(0, MAX_SUGGESTIONS)
29+
: optionsArray.slice(0, MAX_SUGGESTIONS)
2230

2331
return scoredAndSorted.map(toSuggestion)
2432
}

0 commit comments

Comments
 (0)