blob: 1e0ae4f4d935baa99461e52d5d6772ce21da420a [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2021 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import React from 'react';
6
7import {FilterOptionsState} from '@material-ui/core';
8import Autocomplete, {
9 AutocompleteChangeDetails, AutocompleteChangeReason,
10 AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
11 AutocompleteRenderOptionState,
12} from '@material-ui/core/Autocomplete';
13import Chip, {ChipProps} from '@material-ui/core/Chip';
14import TextField from '@material-ui/core/TextField';
15import {Value} from '@material-ui/core/useAutocomplete';
16
17export const MAX_AUTOCOMPLETE_OPTIONS = 100;
18
19interface AutocompleteProps<T> {
20 label: string;
21 options: T[];
22 value?: Value<T, boolean, false, true>;
23 fixedValues?: T[];
24 inputType?: React.InputHTMLAttributes<unknown>['type'];
25 multiple?: boolean;
26 placeholder?: string;
27 onChange?: (
28 event: React.SyntheticEvent,
29 value: Value<T, boolean, false, true>,
30 reason: AutocompleteChangeReason,
31 details?: AutocompleteChangeDetails<T>
32 ) => void;
33 getOptionDescription?: (option: T) => string;
34 getOptionLabel?: (option: T) => string;
35}
36
37/**
38 * A wrapper around Material UI Autocomplete that customizes and extends it for
39 * Monorail's theme and options. Adds support for:
40 * - Fixed values that render as disabled chips.
41 * - Option descriptions that render alongside the option labels.
42 * - Matching on word boundaries in both the labels and descriptions.
43 * - Highlighting of the matching substrings.
44 * @return Autocomplete instance with Monorail-specific properties set.
45 */
46export function ReactAutocomplete<T>(
47 {
48 label, options, value = undefined, fixedValues = [], inputType = 'text',
49 multiple = false, placeholder = '', onChange = () => {},
50 getOptionDescription = () => '', getOptionLabel = (o) => String(o)
51 }: AutocompleteProps<T>
52): React.ReactNode {
53 value = value || (multiple ? [] : '');
54
55 return <Autocomplete
56 id={label}
57 autoHighlight
58 autoSelect
59 filterOptions={_filterOptions(getOptionDescription)}
60 filterSelectedOptions={multiple}
61 freeSolo
62 getOptionLabel={getOptionLabel}
63 multiple={multiple}
64 onChange={_onChange(fixedValues, multiple, onChange)}
65 onKeyDown={_onKeyDown}
66 options={options}
67 renderInput={_renderInput(inputType, placeholder)}
68 renderOption={_renderOption(getOptionDescription, getOptionLabel)}
69 renderTags={_renderTags(fixedValues, getOptionLabel)}
70 style={{width: 'var(--mr-edit-field-width)'}}
71 value={multiple ? [...fixedValues, ...value] : value}
72 />;
73}
74
75/**
76 * Modifies the default option matching behavior to match on all Regex word
77 * boundaries and to match on both label and description.
78 * @param getOptionDescription Function to get the description for an option.
79 * @return The text for a given option.
80 */
81function _filterOptions<T>(getOptionDescription: (option: T) => string) {
82 return (
83 options: T[],
84 {inputValue, getOptionLabel}: FilterOptionsState<T>
85 ): T[] => {
86 if (!inputValue.length) {
87 return [];
88 }
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020089 const prefixMatch = (option: T) => {
90 const label = getOptionLabel(option);
91 return label.substring(0, inputValue.length).toLowerCase() === inputValue.toLowerCase();
92 }
93 const prefixMatchOptions = options.filter(prefixMatch);
94
95 const prefixMatchOptionsSet = new Set(prefixMatchOptions);
Copybara854996b2021-09-07 19:36:02 +000096 const regex = _matchRegex(inputValue);
97 const predicate = (option: T) => {
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020098 return !prefixMatchOptionsSet.has(option) && (getOptionLabel(option).match(regex) ||
99 getOptionDescription(option).match(regex));
Copybara854996b2021-09-07 19:36:02 +0000100 }
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200101 const matchOptions = options.filter(predicate);
102 return [...prefixMatchOptions, ...matchOptions].slice(0, MAX_AUTOCOMPLETE_OPTIONS);
Copybara854996b2021-09-07 19:36:02 +0000103 }
104}
105
106/**
107 * Computes an onChange handler for Autocomplete. Adds logic to make sure
108 * fixedValues are preserved and wraps whatever onChange handler the parent
109 * passed in.
110 * @param fixedValues Values that display in the edit field but can't be
111 * edited by the user. Usually set by filter rules in Monorail.
112 * @param multiple Whether this input takes multiple values or not.
113 * @param onChange onChange property passed in by parent, used to sync value
114 * changes to parent.
115 * @return Function that's run on Autocomplete changes.
116 */
117function _onChange<T, Multiple, DisableClearable, FreeSolo>(
118 fixedValues: T[],
119 multiple: Multiple,
120 onChange: (
121 event: React.SyntheticEvent,
122 value: Value<T, Multiple, DisableClearable, FreeSolo>,
123 reason: AutocompleteChangeReason,
124 details?: AutocompleteChangeDetails<T>
125 ) => void,
126) {
127 return (
128 event: React.SyntheticEvent,
129 newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
130 reason: AutocompleteChangeReason,
131 details?: AutocompleteChangeDetails<T>
132 ): void => {
133 // Ensure that fixed values can't be removed.
134 if (multiple) {
135 newValue = newValue.filter((option: T) => !fixedValues.includes(option));
136 }
137
138 // Propagate onChange callback.
139 onChange(event, newValue, reason, details);
140 }
141}
142
143/**
144 * Custom keydown handler.
145 * @param e Keyboard event.
146 */
147function _onKeyDown(e: React.KeyboardEvent) {
148 // Convert spaces to Enter events to allow users to type space to create new
149 // chips.
150 if (e.key === ' ') {
151 e.key = 'Enter';
152 }
153}
154
155/**
156 * @param inputType A valid HTML 5 input type for the `input` element.
157 * @param placeholder Placeholder text for the input.
158 * @return A function that renders the input element used by
159 * ReactAutocomplete.
160 */
161function _renderInput(inputType = 'text', placeholder = ''):
162 (params: AutocompleteRenderInputParams) => React.ReactNode {
163 return (params: AutocompleteRenderInputParams): React.ReactNode =>
164 <TextField
165 {...params} variant="standard" size="small"
166 type={inputType} placeholder={placeholder}
167 />;
168}
169
170/**
171 * Renders a single instance of an option for Autocomplete.
172 * @param getOptionDescription Function to get the description text shown.
173 * @param getOptionLabel Function to get the name of the option shown to the
174 * user.
175 * @return ReactNode containing the JSX to be rendered.
176 */
177function _renderOption<T>(
178 getOptionDescription: (option: T) => string,
179 getOptionLabel: (option: T) => string
180): React.ReactNode {
181 return (
182 props: React.HTMLAttributes<HTMLLIElement>,
183 option: T,
184 {inputValue}: AutocompleteRenderOptionState
185 ): React.ReactNode => {
186 // Render the option label.
187 const label = getOptionLabel(option);
188 const matchValue = label.match(_matchRegex(inputValue));
189 let optionTemplate = <>{label}</>;
190 if (matchValue) {
191 // Highlight the matching text.
192 optionTemplate = <>
193 {matchValue[1]}
194 <strong>{matchValue[2]}</strong>
195 {matchValue[3]}
196 </>;
197 }
198
199 // Render the option description.
200 const description = getOptionDescription(option);
201 const matchDescription =
202 description && description.match(_matchRegex(inputValue));
203 let descriptionTemplate = <>{description}</>;
204 if (matchDescription) {
205 // Highlight the matching text.
206 descriptionTemplate = <>
207 {matchDescription[1]}
208 <strong>{matchDescription[2]}</strong>
209 {matchDescription[3]}
210 </>;
211 }
212
213 // Put the label and description together into one <li>.
214 return <li
215 {...props}
216 className={`${props.className} autocomplete-option`}
217 style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
218 >
219 <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
220 {optionTemplate}
221 </span>
222 {description &&
223 <span style={{display: 'block', boxSizing: 'border-box',
224 paddingLeft: '8px', width: '60%'}}>
225 {descriptionTemplate}
226 </span>
227 }
228 </li>;
229 };
230}
231
232/**
233 * Helper to render the Chips elements used by Autocomplete. Ensures that
234 * fixedValues are disabled.
235 * @param fixedValues Undeleteable values in an issue usually set by filter
236 * rules.
237 * @param getOptionLabel Function to compute text for the option.
238 * @return Function to render the ReactNode for all the chips.
239 */
240function _renderTags<T>(
241 fixedValues: T[], getOptionLabel: (option: T) => string
242) {
243 return (
244 value: T[],
245 getTagProps: AutocompleteRenderGetTagProps
246 ): React.ReactNode => {
247 return value.map((option, index) => {
248 const props: ChipProps = {...getTagProps({index})};
249 const disabled = fixedValues.includes(option);
250 if (disabled) {
251 delete props.onDelete;
252 }
253
254 const label = getOptionLabel(option);
255 return <Chip
256 {...props}
257 key={label}
258 label={label}
259 disabled={disabled}
260 size="small"
261 />;
262 });
263 }
264}
265
266/**
267 * Generates a RegExp to match autocomplete values.
268 * @param needle The string the user is searching for.
269 * @return A RegExp to find matching values.
270 */
271function _matchRegex(needle: string): RegExp {
272 // This code copied from ac.js.
273 // Since we use needle to build a regular expression, we need to escape RE
274 // characters. We match '-', '{', '$' and others in the needle and convert
275 // them into "\-", "\{", "\$".
276 const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
277 const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
278
279 // Match the modifiedPrefix anywhere as long as it is either at the very
280 // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
281 // such as "Ga" -> "The-Great-Gatsby".
282 const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
283 return new RegExp(patternRegex, 'i' /* ignore case */);
284}