18284: Add regex pattern option to chips-input for better tokenization of login group...
[arvados-workbench2.git] / src / components / chips-input / chips-input.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React from 'react';
6 import { Chips } from 'components/chips/chips';
7 import { Input as MuiInput, withStyles, WithStyles } from '@material-ui/core';
8 import { StyleRulesCallback } from '@material-ui/core/styles';
9 import { InputProps } from '@material-ui/core/Input';
10
11 interface ChipsInputProps<Value> {
12     values: Value[];
13     getLabel?: (value: Value) => string;
14     onChange: (value: Value[]) => void;
15     handleFocus?: (e: any) => void;
16     handleBlur?: (e: any) => void;
17     chipsClassName?: string;
18     createNewValue: (value: string) => Value;
19     inputComponent?: React.ComponentType<InputProps>;
20     inputProps?: InputProps;
21     deletable?: boolean;
22     orderable?: boolean;
23     disabled?: boolean;
24     pattern?: RegExp;
25 }
26
27 type CssRules = 'chips' | 'input' | 'inputContainer';
28
29 const styles: StyleRulesCallback = ({ spacing }) => ({
30     chips: {
31         minHeight: spacing.unit * 5,
32         zIndex: 1,
33         position: 'relative',
34     },
35     input: {
36         zIndex: 1,
37         marginBottom: 8,
38         position: 'relative',
39     },
40     inputContainer: {
41         marginTop: -34
42     },
43 });
44
45 export const ChipsInput = withStyles(styles)(
46     class ChipsInput<Value> extends React.Component<ChipsInputProps<Value> & WithStyles<CssRules>> {
47
48         state = {
49             text: '',
50         };
51
52         filler = React.createRef<HTMLDivElement>();
53         timeout = -1;
54
55         setText = (event: React.ChangeEvent<HTMLInputElement>) => {
56             this.setState({ text: event.target.value }, () => {
57                 // If pattern is provided, check for delimiter
58                 if (this.props.pattern) {
59                     const matches = this.state.text.match(this.props.pattern);
60                     // Only create values if 1 match and the last character is a delimiter
61                     //   (user pressed an invalid character at the end of a token)
62                     //   or if multiple matches (user pasted text)
63                     if (matches &&
64                             (
65                                 matches.length > 1 ||
66                                 (matches.length === 1 && !this.state.text.endsWith(matches[0]))
67                             )) {
68                         this.createNewValue(matches.map((i) => i));
69                     }
70                 }
71             });
72         }
73
74         handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
75             // Handle special keypresses
76             if (e.key === 'Enter') {
77                 this.createNewValue();
78                 e.preventDefault();
79             } else if (e.key === 'Backspace') {
80                 this.deleteLastValue();
81             }
82         }
83
84         createNewValue = (matches?: string[]) => {
85             if (this.state.text) {
86                 if (matches && matches.length > 0) {
87                     const newValues = matches.map((v) => this.props.createNewValue(v));
88                     this.setState({ text: '' });
89                     this.props.onChange([...this.props.values, ...newValues]);
90                 } else {
91                     const newValue = this.props.createNewValue(this.state.text);
92                     this.setState({ text: '' });
93                     this.props.onChange([...this.props.values, newValue]);
94                 }
95             }
96         }
97
98         deleteLastValue = () => {
99             if (this.state.text.length === 0 && this.props.values.length > 0) {
100                 this.props.onChange(this.props.values.slice(0, -1));
101             }
102         }
103
104         updateCursorPosition = () => {
105             if (this.timeout) {
106                 clearTimeout(this.timeout);
107             }
108             this.timeout = window.setTimeout(() => this.setState({ ...this.state }));
109         }
110
111         getInputStyles = (): React.CSSProperties => ({
112             width: this.filler.current
113                 ? this.filler.current.offsetWidth
114                 : '100%',
115             right: this.filler.current
116                 ? `calc(${this.filler.current.offsetWidth}px - 100%)`
117                 : 0,
118
119         })
120
121         componentDidMount() {
122             this.updateCursorPosition();
123         }
124
125         render() {
126             return <>
127                 {this.renderChips()}
128                 {this.renderInput()}
129             </>;
130         }
131
132         renderChips() {
133             const { classes, ...props } = this.props;
134             return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
135                 <Chips
136                     {...props}
137                     clickable={!props.disabled}
138                     filler={<div ref={this.filler} />}
139                 />
140             </div>;
141         }
142
143         renderInput() {
144             const { inputProps: InputProps, inputComponent: Input = MuiInput, classes } = this.props;
145             return <Input
146                 {...InputProps}
147                 value={this.state.text}
148                 onChange={this.setText}
149                 disabled={this.props.disabled}
150                 onKeyDown={this.handleKeyPress}
151                 onFocus={this.props.handleFocus}
152                 onBlur={this.props.handleBlur}
153                 inputProps={{
154                     ...(InputProps && InputProps.inputProps),
155                     className: classes.input,
156                     style: this.getInputStyles(),
157                 }}
158                 fullWidth
159                 className={classes.inputContainer} />;
160         }
161
162         componentDidUpdate(prevProps: ChipsInputProps<Value>) {
163             if (prevProps.values !== this.props.values) {
164                 this.updateCursorPosition();
165             }
166         }
167         componentWillUnmount() {
168             clearTimeout(this.timeout);
169         }
170     });