Merge pull request #4635 from JorikSchellekens/joriks/field-ts

Move Field to Typescript
This commit is contained in:
Jorik Schellekens 2020-05-26 16:13:03 +01:00 committed by GitHub
commit 076a3e058d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 632 additions and 535 deletions

View File

@ -119,10 +119,12 @@
"@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10",
"@types/flux": "^3.1.9",
"@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3",
"@types/node": "^12.12.41",
"@types/qrcode": "^1.3.4",
"@types/react": "16.9",
"@types/react": "^16.9",
"@types/react-dom": "^16.9.8",
"@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",

View File

@ -69,7 +69,7 @@ export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, {
this.matcher = new QueryMatcher<IEmojiShort>(EMOJI_SHORTNAMES, {
keys: ['emoji.emoticon', 'shortname'],
funcs: [
(o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases

View File

@ -45,7 +45,7 @@ interface IOptions<T extends {}> {
* @param {function[]} options.funcs List of functions that when called with the
* object as an arg will return a string to use as an index
*/
export default class QueryMatcher<T> {
export default class QueryMatcher<T extends Object> {
private _options: IOptions<T>;
private _keys: IOptions<T>["keys"];
private _funcs: Required<IOptions<T>["funcs"]>;
@ -75,7 +75,11 @@ export default class QueryMatcher<T> {
this._items = new Map();
for (const object of objects) {
const keyValues = _at(object, this._keys);
// Need to use unsafe coerce here because the objects can have any
// type for their values. We assume that those values who's keys have
// been specified will be string. Also, we cannot infer all the
// types of the keys of the objects at compile.
const keyValues = _at<string>(<any>object, this._keys);
for (const f of this._funcs) {
keyValues.push(f(object));

View File

@ -36,7 +36,7 @@ interface IProps {
labelStrongPassword?: string;
labelAllowedButUnsafe?: string;
onChange(ev: KeyboardEvent);
onChange(ev: React.FormEvent<HTMLElement>);
onValidate(result: IValidationResult);
}

View File

@ -15,10 +15,10 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import * as sdk from '../../../index';
import { debounce } from 'lodash';
import {IFieldState, IValidationResult} from "../elements/Validation";
// Invoke validation from user input (when typing, etc.) at most once every N ms.
const VALIDATION_THROTTLE_MS = 200;
@ -29,58 +29,93 @@ function getId() {
return `${BASE_ID}_${count++}`;
}
export default class Field extends React.PureComponent {
static propTypes = {
// The field's ID, which binds the input and label together. Immutable.
id: PropTypes.string,
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
element: PropTypes.oneOf(["input", "select", "textarea"]),
// The field's type (when used as an <input>). Defaults to "text".
type: PropTypes.string,
// id of a <datalist> element for suggestions
list: PropTypes.string,
// The field's label string.
label: PropTypes.string,
// The field's placeholder string. Defaults to the label.
placeholder: PropTypes.string,
// The field's value.
// This is a controlled component, so the value is required.
value: PropTypes.string.isRequired,
// Optional component to include inside the field before the input.
prefix: PropTypes.node,
// Optional component to include inside the field after the input.
postfix: PropTypes.node,
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
// to the user.
onValidate: PropTypes.func,
// If specified, overrides the value returned by onValidate.
flagInvalid: PropTypes.bool,
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent: PropTypes.node,
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName: PropTypes.string,
// If specified, an additional class name to apply to the field container
className: PropTypes.string,
// All other props pass through to the <input>.
};
interface IProps extends React.InputHTMLAttributes<HTMLSelectElement | HTMLInputElement> {
// The field's ID, which binds the input and label together. Immutable.
id?: string,
// The element to create. Defaults to "input".
// To define options for a select, use <Field><option ... /></Field>
element?: "input" | " select" | "textarea",
// The field's type (when used as an <input>). Defaults to "text".
type?: string,
// id of a <datalist> element for suggestions
list?: string,
// The field's label string.
label?: string,
// The field's placeholder string. Defaults to the label.
placeholder?: string,
// The field's value.
// This is a controlled component, so the value is required.
value: string,
// Optional component to include inside the field before the input.
prefixComponent?: React.ReactNode,
// Optional component to include inside the field after the input.
postfixComponent?: React.ReactNode,
// The callback called whenever the contents of the field
// changes. Returns an object with `valid` boolean field
// and a `feedback` react component field to provide feedback
// to the user.
onValidate?: (input: IFieldState) => Promise<IValidationResult>,
// If specified, overrides the value returned by onValidate.
flagInvalid?: boolean,
// If specified, contents will appear as a tooltip on the element and
// validation feedback tooltips will be suppressed.
tooltipContent?: React.ReactNode,
// If specified alongside tooltipContent, the class name to apply to the
// tooltip itself.
tooltipClassName?: string,
// If specified, an additional class name to apply to the field container
className?: string,
// All other props pass through to the <input>.
}
interface IState {
valid: boolean,
feedback: React.ReactNode,
feedbackVisible: boolean,
focused: boolean,
}
export default class Field extends React.PureComponent<IProps, IState> {
private id: string;
private input: HTMLInputElement;
private static defaultProps = {
element: "input",
type: "text",
}
/*
* This was changed from throttle to debounce: this is more traditional for
* form validation since it means that the validation doesn't happen at all
* until the user stops typing for a bit (debounce defaults to not running on
* the leading edge). If we're doing an HTTP hit on each validation, we have more
* incentive to prevent validating input that's very unlikely to be valid.
* We may find that we actually want different behaviour for registration
* fields, in which case we can add some options to control it.
*/
private validateOnChange = debounce(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
constructor(props) {
super(props);
this.state = {
valid: undefined,
feedback: undefined,
feedbackVisible: false,
focused: false,
};
this.id = this.props.id || getId();
}
onFocus = (ev) => {
public focus() {
this.input.focus();
}
private onFocus = (ev) => {
this.setState({
focused: true,
});
@ -93,7 +128,7 @@ export default class Field extends React.PureComponent {
}
};
onChange = (ev) => {
private onChange = (ev) => {
this.validateOnChange();
// Parent component may have supplied its own `onChange` as well
if (this.props.onChange) {
@ -101,7 +136,7 @@ export default class Field extends React.PureComponent {
}
};
onBlur = (ev) => {
private onBlur = (ev) => {
this.setState({
focused: false,
});
@ -114,11 +149,7 @@ export default class Field extends React.PureComponent {
}
};
focus() {
this.input.focus();
}
async validate({ focused, allowEmpty = true }) {
private async validate({ focused, allowEmpty = true }: {focused: boolean, allowEmpty?: boolean}) {
if (!this.props.onValidate) {
return;
}
@ -149,56 +180,42 @@ export default class Field extends React.PureComponent {
}
}
/*
* This was changed from throttle to debounce: this is more traditional for
* form validation since it means that the validation doesn't happen at all
* until the user stops typing for a bit (debounce defaults to not running on
* the leading edge). If we're doing an HTTP hit on each validation, we have more
* incentive to prevent validating input that's very unlikely to be valid.
* We may find that we actually want different behaviour for registration
* fields, in which case we can add some options to control it.
*/
validateOnChange = debounce(() => {
this.validate({
focused: true,
});
}, VALIDATION_THROTTLE_MS);
render() {
public render() {
const {
element, prefix, postfix, className, onValidate, children,
element, prefixComponent, postfixComponent, className, onValidate, children,
tooltipContent, flagInvalid, tooltipClassName, list, ...inputProps} = this.props;
const inputElement = element || "input";
// Set some defaults for the <input> element
inputProps.type = inputProps.type || "text";
inputProps.ref = input => this.input = input;
const ref = input => this.input = input;
inputProps.placeholder = inputProps.placeholder || inputProps.label;
inputProps.id = this.id; // this overwrites the id from props
inputProps.onFocus = this.onFocus;
inputProps.onChange = this.onChange;
inputProps.onBlur = this.onBlur;
inputProps.list = list;
const fieldInput = React.createElement(inputElement, inputProps, children);
// Appease typescript's inference
const inputProps_ = {...inputProps, ref, list};
const fieldInput = React.createElement(this.props.element, inputProps_, children);
let prefixContainer = null;
if (prefix) {
prefixContainer = <span className="mx_Field_prefix">{prefix}</span>;
if (prefixComponent) {
prefixContainer = <span className="mx_Field_prefix">{prefixComponent}</span>;
}
let postfixContainer = null;
if (postfix) {
postfixContainer = <span className="mx_Field_postfix">{postfix}</span>;
if (postfixComponent) {
postfixContainer = <span className="mx_Field_postfix">{postfixComponent}</span>;
}
const hasValidationFlag = flagInvalid !== null && flagInvalid !== undefined;
const fieldClasses = classNames("mx_Field", `mx_Field_${inputElement}`, className, {
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
// If we have a prefix element, leave the label always at the top left and
// don't animate it, as it looks a bit clunky and would add complexity to do
// properly.
mx_Field_labelAlwaysTopLeft: prefix,
mx_Field_labelAlwaysTopLeft: prefixComponent,
mx_Field_valid: onValidate && this.state.valid === true,
mx_Field_invalid: hasValidationFlag
? flagInvalid

View File

@ -2,7 +2,7 @@
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,67 +18,68 @@ limitations under the License.
*/
import React from 'react';
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import dis from '../../../dispatcher/dispatcher';
import classNames from 'classnames';
import { ViewTooltipPayload } from '../../../dispatcher/payloads/ViewTooltipPayload';
import { Action } from '../../../dispatcher/actions';
const MIN_TOOLTIP_HEIGHT = 25;
export default createReactClass({
displayName: 'Tooltip',
propTypes: {
interface IProps {
// Class applied to the element used to position the tooltip
className: PropTypes.string,
className: string,
// Class applied to the tooltip itself
tooltipClassName: PropTypes.string,
tooltipClassName?: string,
// Whether the tooltip is visible or hidden.
// The hidden state allows animating the tooltip away via CSS.
// Defaults to visible if unset.
visible: PropTypes.bool,
visible?: boolean,
// the react element to put into the tooltip
label: PropTypes.node,
},
label: React.ReactNode,
}
getDefaultProps() {
return {
visible: true,
};
},
export default class Tooltip extends React.Component<IProps> {
private tooltipContainer: HTMLElement;
private tooltip: void | Element | Component<Element, any, any>;
private parent: Element;
public static readonly defaultProps = {
visible: true,
};
// Create a wrapper for the tooltip outside the parent and attach it to the body element
componentDidMount: function() {
public componentDidMount() {
this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this._renderTooltip, true);
window.addEventListener('scroll', this.renderTooltip, true);
this.parent = ReactDOM.findDOMNode(this).parentNode;
this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
this._renderTooltip();
},
this.renderTooltip();
}
componentDidUpdate: function() {
this._renderTooltip();
},
public componentDidUpdate() {
this.renderTooltip();
}
// Remove the wrapper element, as the tooltip has finished using it
componentWillUnmount: function() {
dis.dispatch({
action: 'view_tooltip',
public componentWillUnmount() {
dis.dispatch<ViewTooltipPayload>({
action: Action.ViewTooltip,
tooltip: null,
parent: null,
});
ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this._renderTooltip, true);
},
window.removeEventListener('scroll', this.renderTooltip, true);
}
_updatePosition(style) {
private updatePosition(style: {[key: string]: any}) {
const parentBox = this.parent.getBoundingClientRect();
let offset = 0;
if (parentBox.height > MIN_TOOLTIP_HEIGHT) {
@ -91,16 +92,15 @@ export default createReactClass({
style.top = (parentBox.top - 2) + window.pageYOffset + offset;
style.left = 6 + parentBox.right + window.pageXOffset;
return style;
},
}
_renderTooltip: function() {
private renderTooltip() {
// Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom
// NOTE: The additional 6 pixels for the left position, is to take account of the
// tooltips chevron
const parent = ReactDOM.findDOMNode(this).parentNode;
let style = {};
style = this._updatePosition(style);
const parent = ReactDOM.findDOMNode(this).parentNode as Element;
const style = this.updatePosition({});
// Hide the entire container when not visible. This prevents flashing of the tooltip
// if it is not meant to be visible on first mount.
style.display = this.props.visible ? "block" : "none";
@ -118,21 +118,21 @@ export default createReactClass({
);
// Render the tooltip manually, as we wish it not to be rendered within the parent
this.tooltip = ReactDOM.render(tooltip, this.tooltipContainer);
this.tooltip = ReactDOM.render<Element>(tooltip, this.tooltipContainer);
// Tell the roomlist about us so it can manipulate us if it wishes
dis.dispatch({
action: 'view_tooltip',
dis.dispatch<ViewTooltipPayload>({
action: Action.ViewTooltip,
tooltip: this.tooltip,
parent: parent,
});
},
}
render: function() {
public render() {
// Render a placeholder
return (
<div className={this.props.className} >
</div>
);
},
});
}
}

View File

@ -214,7 +214,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
let tooltip = null;
if (false) { // isCollapsed
if (this.state.hover) {
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto"/>
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
}
}

View File

@ -267,7 +267,7 @@ export default class PhoneNumbers extends React.Component {
label={_t("Phone Number")}
autoComplete="off"
disabled={this.state.verifying}
prefix={phoneCountry}
prefixComponent={phoneCountry}
value={this.state.newPhoneNumber}
onChange={this._onChangeNewPhoneNumber}
/>

View File

@ -38,5 +38,10 @@ export enum Action {
* Open the user settings. No additional payload information required.
*/
ViewUserSettings = "view_user_settings",
/**
* Sets the current tooltip. Should be use with ViewTooltipPayload.
*/
ViewTooltip = "view_tooltip",
}

View File

@ -0,0 +1,35 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ActionPayload } from "../payloads";
import { Action } from "../actions";
import { Component } from "react";
export interface ViewTooltipPayload extends ActionPayload {
action: Action.ViewTooltip,
/*
* The tooltip to render. If it's null the tooltip will not be rendered
* We need the void type because of typescript headaches.
*/
tooltip: null | void | Element | Component<Element, any, any>;
/*
* The parent under which to render the tooltip. Can be null to remove
* the parent type.
*/
parent: null | Element
}

850
yarn.lock

File diff suppressed because it is too large Load Diff