blob: 27fdc321338145353041a803e1ee146dce4cf72e [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2021 The Chromium Authors. All rights reserved.
2// 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 }
89 const regex = _matchRegex(inputValue);
90 const predicate = (option: T) => {
91 return getOptionLabel(option).match(regex) ||
92 getOptionDescription(option).match(regex);
93 }
94 return options.filter(predicate).slice(0, MAX_AUTOCOMPLETE_OPTIONS);
95 }
96}
97
98/**
99 * Computes an onChange handler for Autocomplete. Adds logic to make sure
100 * fixedValues are preserved and wraps whatever onChange handler the parent
101 * passed in.
102 * @param fixedValues Values that display in the edit field but can't be
103 * edited by the user. Usually set by filter rules in Monorail.
104 * @param multiple Whether this input takes multiple values or not.
105 * @param onChange onChange property passed in by parent, used to sync value
106 * changes to parent.
107 * @return Function that's run on Autocomplete changes.
108 */
109function _onChange<T, Multiple, DisableClearable, FreeSolo>(
110 fixedValues: T[],
111 multiple: Multiple,
112 onChange: (
113 event: React.SyntheticEvent,
114 value: Value<T, Multiple, DisableClearable, FreeSolo>,
115 reason: AutocompleteChangeReason,
116 details?: AutocompleteChangeDetails<T>
117 ) => void,
118) {
119 return (
120 event: React.SyntheticEvent,
121 newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
122 reason: AutocompleteChangeReason,
123 details?: AutocompleteChangeDetails<T>
124 ): void => {
125 // Ensure that fixed values can't be removed.
126 if (multiple) {
127 newValue = newValue.filter((option: T) => !fixedValues.includes(option));
128 }
129
130 // Propagate onChange callback.
131 onChange(event, newValue, reason, details);
132 }
133}
134
135/**
136 * Custom keydown handler.
137 * @param e Keyboard event.
138 */
139function _onKeyDown(e: React.KeyboardEvent) {
140 // Convert spaces to Enter events to allow users to type space to create new
141 // chips.
142 if (e.key === ' ') {
143 e.key = 'Enter';
144 }
145}
146
147/**
148 * @param inputType A valid HTML 5 input type for the `input` element.
149 * @param placeholder Placeholder text for the input.
150 * @return A function that renders the input element used by
151 * ReactAutocomplete.
152 */
153function _renderInput(inputType = 'text', placeholder = ''):
154 (params: AutocompleteRenderInputParams) => React.ReactNode {
155 return (params: AutocompleteRenderInputParams): React.ReactNode =>
156 <TextField
157 {...params} variant="standard" size="small"
158 type={inputType} placeholder={placeholder}
159 />;
160}
161
162/**
163 * Renders a single instance of an option for Autocomplete.
164 * @param getOptionDescription Function to get the description text shown.
165 * @param getOptionLabel Function to get the name of the option shown to the
166 * user.
167 * @return ReactNode containing the JSX to be rendered.
168 */
169function _renderOption<T>(
170 getOptionDescription: (option: T) => string,
171 getOptionLabel: (option: T) => string
172): React.ReactNode {
173 return (
174 props: React.HTMLAttributes<HTMLLIElement>,
175 option: T,
176 {inputValue}: AutocompleteRenderOptionState
177 ): React.ReactNode => {
178 // Render the option label.
179 const label = getOptionLabel(option);
180 const matchValue = label.match(_matchRegex(inputValue));
181 let optionTemplate = <>{label}</>;
182 if (matchValue) {
183 // Highlight the matching text.
184 optionTemplate = <>
185 {matchValue[1]}
186 <strong>{matchValue[2]}</strong>
187 {matchValue[3]}
188 </>;
189 }
190
191 // Render the option description.
192 const description = getOptionDescription(option);
193 const matchDescription =
194 description && description.match(_matchRegex(inputValue));
195 let descriptionTemplate = <>{description}</>;
196 if (matchDescription) {
197 // Highlight the matching text.
198 descriptionTemplate = <>
199 {matchDescription[1]}
200 <strong>{matchDescription[2]}</strong>
201 {matchDescription[3]}
202 </>;
203 }
204
205 // Put the label and description together into one <li>.
206 return <li
207 {...props}
208 className={`${props.className} autocomplete-option`}
209 style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
210 >
211 <span style={{display: 'block', width: (description ? '40%' : '100%')}}>
212 {optionTemplate}
213 </span>
214 {description &&
215 <span style={{display: 'block', boxSizing: 'border-box',
216 paddingLeft: '8px', width: '60%'}}>
217 {descriptionTemplate}
218 </span>
219 }
220 </li>;
221 };
222}
223
224/**
225 * Helper to render the Chips elements used by Autocomplete. Ensures that
226 * fixedValues are disabled.
227 * @param fixedValues Undeleteable values in an issue usually set by filter
228 * rules.
229 * @param getOptionLabel Function to compute text for the option.
230 * @return Function to render the ReactNode for all the chips.
231 */
232function _renderTags<T>(
233 fixedValues: T[], getOptionLabel: (option: T) => string
234) {
235 return (
236 value: T[],
237 getTagProps: AutocompleteRenderGetTagProps
238 ): React.ReactNode => {
239 return value.map((option, index) => {
240 const props: ChipProps = {...getTagProps({index})};
241 const disabled = fixedValues.includes(option);
242 if (disabled) {
243 delete props.onDelete;
244 }
245
246 const label = getOptionLabel(option);
247 return <Chip
248 {...props}
249 key={label}
250 label={label}
251 disabled={disabled}
252 size="small"
253 />;
254 });
255 }
256}
257
258/**
259 * Generates a RegExp to match autocomplete values.
260 * @param needle The string the user is searching for.
261 * @return A RegExp to find matching values.
262 */
263function _matchRegex(needle: string): RegExp {
264 // This code copied from ac.js.
265 // Since we use needle to build a regular expression, we need to escape RE
266 // characters. We match '-', '{', '$' and others in the needle and convert
267 // them into "\-", "\{", "\$".
268 const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
269 const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
270
271 // Match the modifiedPrefix anywhere as long as it is either at the very
272 // beginning "Th" -> "The Hobbit", or comes immediately after a word separator
273 // such as "Ga" -> "The-Great-Gatsby".
274 const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
275 return new RegExp(patternRegex, 'i' /* ignore case */);
276}