18284: Add regex pattern option to chips-input for better tokenization of login group...
[arvados-workbench2.git] / src / components / chips-input / chips-input.tsx
index c35db1b37cd556cf2624c3324c16c6525b182a61..cbb1fb1283b31246152f2c32b6a961cb5f256248 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as React from 'react';
-import { Chips } from '~/components/chips/chips';
-import { Input } from '@material-ui/core';
+import React from 'react';
+import { Chips } from 'components/chips/chips';
+import { Input as MuiInput, withStyles, WithStyles } from '@material-ui/core';
+import { StyleRulesCallback } from '@material-ui/core/styles';
+import { InputProps } from '@material-ui/core/Input';
 
 interface ChipsInputProps<Value> {
     values: Value[];
     getLabel?: (value: Value) => string;
     onChange: (value: Value[]) => void;
+    handleFocus?: (e: any) => void;
+    handleBlur?: (e: any) => void;
+    chipsClassName?: string;
     createNewValue: (value: string) => Value;
+    inputComponent?: React.ComponentType<InputProps>;
+    inputProps?: InputProps;
+    deletable?: boolean;
+    orderable?: boolean;
+    disabled?: boolean;
+    pattern?: RegExp;
 }
 
-export class ChipsInput<Value> extends React.Component<ChipsInputProps<Value>> {
+type CssRules = 'chips' | 'input' | 'inputContainer';
 
-    state = {
-        text: '',
-    };
+const styles: StyleRulesCallback = ({ spacing }) => ({
+    chips: {
+        minHeight: spacing.unit * 5,
+        zIndex: 1,
+        position: 'relative',
+    },
+    input: {
+        zIndex: 1,
+        marginBottom: 8,
+        position: 'relative',
+    },
+    inputContainer: {
+        marginTop: -34
+    },
+});
+
+export const ChipsInput = withStyles(styles)(
+    class ChipsInput<Value> extends React.Component<ChipsInputProps<Value> & WithStyles<CssRules>> {
 
-    filler = React.createRef<HTMLDivElement>();
-    timeout = -1;
+        state = {
+            text: '',
+        };
 
-    setText = (event: React.ChangeEvent<HTMLInputElement>) => {
-        this.setState({ text: event.target.value });
-    }
+        filler = React.createRef<HTMLDivElement>();
+        timeout = -1;
 
-    handleKeyPress = ({ key }: React.KeyboardEvent<HTMLInputElement>) => {
-        if (key === 'Enter') {
-            this.createNewValue();
-        } else if (key === 'Backspace') {
-            this.deleteLastValue();
+        setText = (event: React.ChangeEvent<HTMLInputElement>) => {
+            this.setState({ text: event.target.value }, () => {
+                // If pattern is provided, check for delimiter
+                if (this.props.pattern) {
+                    const matches = this.state.text.match(this.props.pattern);
+                    // Only create values if 1 match and the last character is a delimiter
+                    //   (user pressed an invalid character at the end of a token)
+                    //   or if multiple matches (user pasted text)
+                    if (matches &&
+                            (
+                                matches.length > 1 ||
+                                (matches.length === 1 && !this.state.text.endsWith(matches[0]))
+                            )) {
+                        this.createNewValue(matches.map((i) => i));
+                    }
+                }
+            });
         }
-    }
 
-    createNewValue = () => {
-        if (this.state.text) {
-            const newValue = this.props.createNewValue(this.state.text);
-            this.setState({ text: '' });
-            this.props.onChange([...this.props.values, newValue]);
+        handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
+            // Handle special keypresses
+            if (e.key === 'Enter') {
+                this.createNewValue();
+                e.preventDefault();
+            } else if (e.key === 'Backspace') {
+                this.deleteLastValue();
+            }
         }
-    }
 
-    deleteLastValue = () => {
-        if (this.state.text.length === 0 && this.props.values.length > 0) {
-            this.props.onChange(this.props.values.slice(0, -1));
+        createNewValue = (matches?: string[]) => {
+            if (this.state.text) {
+                if (matches && matches.length > 0) {
+                    const newValues = matches.map((v) => this.props.createNewValue(v));
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, ...newValues]);
+                } else {
+                    const newValue = this.props.createNewValue(this.state.text);
+                    this.setState({ text: '' });
+                    this.props.onChange([...this.props.values, newValue]);
+                }
+            }
         }
-    }
 
-    updateStyles = () => {
-        if(this.timeout){
-            clearTimeout(this.timeout);
+        deleteLastValue = () => {
+            if (this.state.text.length === 0 && this.props.values.length > 0) {
+                this.props.onChange(this.props.values.slice(0, -1));
+            }
+        }
+
+        updateCursorPosition = () => {
+            if (this.timeout) {
+                clearTimeout(this.timeout);
+            }
+            this.timeout = window.setTimeout(() => this.setState({ ...this.state }));
+        }
+
+        getInputStyles = (): React.CSSProperties => ({
+            width: this.filler.current
+                ? this.filler.current.offsetWidth
+                : '100%',
+            right: this.filler.current
+                ? `calc(${this.filler.current.offsetWidth}px - 100%)`
+                : 0,
+
+        })
+
+        componentDidMount() {
+            this.updateCursorPosition();
         }
-        this.timeout = setTimeout(() => this.forceUpdate());
-    }
-
-    render() {
-        this.updateStyles();
-        return <>
-            <div style={{ minHeight: '40px', zIndex: 1, position: 'relative' }}>
-                <Chips {...this.props} filler={<div ref={this.filler} />} />
-            </div>
-            <Input
+
+        render() {
+            return <>
+                {this.renderChips()}
+                {this.renderInput()}
+            </>;
+        }
+
+        renderChips() {
+            const { classes, ...props } = this.props;
+            return <div className={[classes.chips, this.props.chipsClassName].join(' ')}>
+                <Chips
+                    {...props}
+                    clickable={!props.disabled}
+                    filler={<div ref={this.filler} />}
+                />
+            </div>;
+        }
+
+        renderInput() {
+            const { inputProps: InputProps, inputComponent: Input = MuiInput, classes } = this.props;
+            return <Input
+                {...InputProps}
                 value={this.state.text}
                 onChange={this.setText}
+                disabled={this.props.disabled}
                 onKeyDown={this.handleKeyPress}
-                style={{ top: '-24px' }}
-                inputProps={{ style: this.getInputStyles(), }}
-                fullWidth />
-        </>;
-    }
-
-    getInputStyles = (): React.CSSProperties => ({
-        width: this.filler.current
-            ? this.filler.current.offsetWidth + 8
-            : '100%',
-        position: 'relative',
-        right: this.filler.current
-            ? `calc(${this.filler.current.offsetWidth}px - 100%)`
-            : 0,
-        top: '-5px',
-        zIndex: 1,
-    })
-}
\ No newline at end of file
+                onFocus={this.props.handleFocus}
+                onBlur={this.props.handleBlur}
+                inputProps={{
+                    ...(InputProps && InputProps.inputProps),
+                    className: classes.input,
+                    style: this.getInputStyles(),
+                }}
+                fullWidth
+                className={classes.inputContainer} />;
+        }
+
+        componentDidUpdate(prevProps: ChipsInputProps<Value>) {
+            if (prevProps.values !== this.props.values) {
+                this.updateCursorPosition();
+            }
+        }
+        componentWillUnmount() {
+            clearTimeout(this.timeout);
+        }
+    });