[MM-50352] Improve URL validation and add/edit server experience (#2720)

* [MM-50352] Improve URL validation and add/edit server experience

* Fix build

* Fix translations

* First pass of fixes

* Some changes to avoid 2 clicks, tests

* PR feedback

* Update translations

* PR feedback

* Fix translations

* PR feedback

* E2E test fixes
This commit is contained in:
Devin Binnie
2023-05-24 09:04:38 -04:00
committed by GitHub
parent a87e770c73
commit 1239add076
25 changed files with 712 additions and 275 deletions

View File

@@ -3,18 +3,20 @@
// See LICENSE.txt for license information.
import React from 'react';
import {Modal, Button, FormGroup, FormControl, FormLabel, FormText} from 'react-bootstrap';
import {Modal, Button, FormGroup, FormControl, FormLabel, FormText, Spinner} from 'react-bootstrap';
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
import {UniqueServer} from 'types/config';
import {URLValidationResult} from 'types/server';
import {isValidURL} from 'common/utils/url';
import {URLValidationStatus} from 'common/utils/constants';
import 'renderer/css/components/NewServerModal.scss';
type Props = {
onClose?: () => void;
onSave?: (server: UniqueServer) => void;
server?: UniqueServer;
currentServers?: UniqueServer[];
editMode?: boolean;
show?: boolean;
restoreFocus?: boolean;
@@ -29,11 +31,15 @@ type State = {
serverId?: string;
serverOrder: number;
saveStarted: boolean;
validationStarted: boolean;
validationResult?: URLValidationResult;
}
class NewServerModal extends React.PureComponent<Props, State> {
wasShown?: boolean;
serverUrlInputRef?: HTMLInputElement;
validationTimeout?: NodeJS.Timeout;
mounted: boolean;
static defaultProps = {
restoreFocus: true,
@@ -43,48 +49,37 @@ class NewServerModal extends React.PureComponent<Props, State> {
super(props);
this.wasShown = false;
this.mounted = false;
this.state = {
serverName: '',
serverUrl: '',
serverOrder: props.currentOrder || 0,
saveStarted: false,
validationStarted: false,
};
}
initializeOnShow() {
componentDidMount(): void {
this.mounted = true;
}
componentWillUnmount(): void {
this.mounted = false;
}
initializeOnShow = () => {
this.setState({
serverName: this.props.server ? this.props.server.name : '',
serverUrl: this.props.server ? this.props.server.url : '',
serverId: this.props.server?.id,
saveStarted: false,
validationStarted: false,
validationResult: undefined,
});
}
getServerNameValidationError() {
if (!this.state.saveStarted) {
return null;
if (this.props.editMode && this.props.server) {
this.validateServerURL(this.props.server.url);
}
if (this.props.currentServers) {
const currentServers = [...this.props.currentServers];
if (currentServers.find((server) => server.id !== this.state.serverId && server.name === this.state.serverName)) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.serverNameExists'
defaultMessage='A server with the same name already exists.'
/>
);
}
}
return this.state.serverName.length > 0 ? null : (
<FormattedMessage
id='renderer.components.newServerModal.error.nameRequired'
defaultMessage='Name is required.'
/>
);
}
getServerNameValidationState() {
return this.getServerNameValidationError() === null ? null : 'error';
}
handleServerNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -93,108 +88,205 @@ class NewServerModal extends React.PureComponent<Props, State> {
});
}
getServerUrlValidationError() {
if (!this.state.saveStarted) {
return null;
}
if (this.props.currentServers) {
const currentServers = [...this.props.currentServers];
if (currentServers.find((server) => server.id !== this.state.serverId && server.url === this.state.serverUrl)) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.serverUrlExists'
defaultMessage='A server with the same URL already exists.'
/>
);
}
}
if (this.state.serverUrl.length === 0) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlRequired'
defaultMessage='URL is required.'
/>
);
}
if (!(/^https?:\/\/.*/).test(this.state.serverUrl.trim())) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlNeedsHttp'
defaultMessage='URL should start with http:// or https://.'
/>
);
}
if (!isValidURL(this.state.serverUrl.trim())) {
return (
<FormattedMessage
id='renderer.components.newServerModal.error.urlIncorrectFormatting'
defaultMessage='URL is not formatted correctly.'
/>
);
}
return null;
}
getServerUrlValidationState() {
return this.getServerUrlValidationError() === null ? null : 'error';
}
handleServerUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const serverUrl = e.target.value;
this.setState({serverUrl});
this.setState({serverUrl, validationResult: undefined});
this.validateServerURL(serverUrl);
}
addProtocolToUrl = (serverUrl: string): Promise<void> => {
if (serverUrl.startsWith('http://') || serverUrl.startsWith('https://')) {
return Promise.resolve(undefined);
validateServerURL = (serverUrl: string) => {
clearTimeout(this.validationTimeout as unknown as number);
this.validationTimeout = setTimeout(() => {
if (!this.mounted) {
return;
}
const currentTimeout = this.validationTimeout;
this.setState({validationStarted: true});
window.desktop.validateServerURL(serverUrl, this.props.server?.id).then((validationResult) => {
if (!this.mounted) {
return;
}
if (currentTimeout !== this.validationTimeout) {
return;
}
this.setState({validationResult, validationStarted: false, serverUrl: validationResult.validatedURL ?? serverUrl, serverName: this.state.serverName ? this.state.serverName : validationResult.serverName ?? ''});
});
}, 1000);
}
isServerURLErrored = () => {
return this.state.validationResult?.status === URLValidationStatus.Invalid ||
this.state.validationResult?.status === URLValidationStatus.Missing;
}
getServerURLMessage = () => {
if (this.state.validationStarted) {
return (
<div>
<Spinner
className='NewServerModal-validationSpinner'
animation='border'
size='sm'
/>
<FormattedMessage
id='renderer.components.newServerModal.validating'
defaultMessage='Validating...'
/>
</div>
);
}
return window.desktop.modals.pingDomain(serverUrl).
then((result: string) => {
this.setState({serverUrl: `${result}://${this.state.serverUrl}`});
}).
catch(() => {
console.error(`Could not ping url: ${serverUrl}`);
});
if (!this.state.validationResult) {
return null;
}
switch (this.state.validationResult?.status) {
case URLValidationStatus.Missing:
return (
<div
id='urlValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.urlRequired'
defaultMessage='URL is required.'
/>
</div>
);
case URLValidationStatus.Invalid:
return (
<div
id='urlValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.urlIncorrectFormatting'
defaultMessage='URL is not formatted correctly.'
/>
</div>
);
case URLValidationStatus.URLExists:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.error.serverUrlExists'
defaultMessage='A server named {serverName} with the same Site URL already exists.'
values={{serverName: this.state.validationResult.existingServerName}}
/>
</div>
);
case URLValidationStatus.Insecure:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.insecure'
defaultMessage='Your server URL is potentially insecure. For best results, use a URL with the HTTPS protocol.'
/>
</div>
);
case URLValidationStatus.NotMattermost:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.notMattermost'
defaultMessage='The server URL provided does not appear to point to a valid Mattermost server. Please verify the URL and check your connection.'
/>
</div>
);
case URLValidationStatus.URLNotMatched:
return (
<div
id='urlValidation'
className='warning'
>
<i className='icon-alert-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.urlNotMatched'
defaultMessage='The server URL does not match the configured Site URL on your Mattermost server. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
case URLValidationStatus.URLUpdated:
return (
<div
id='urlValidation'
className='info'
>
<i className='icon-information-outline'/>
<FormattedMessage
id='renderer.components.newServerModal.warning.urlUpdated'
defaultMessage='The server URL provided has been updated to match the configured Site URL on your Mattermost server. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
}
return (
<div
id='urlValidation'
className='success'
>
<i className='icon-check-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.success.ok'
defaultMessage='Server URL is valid. Server version: {serverVersion}'
values={{serverVersion: this.state.validationResult.serverVersion}}
/>
</div>
);
}
getError() {
const nameError = this.getServerNameValidationError();
const urlError = this.getServerUrlValidationError();
if (nameError && urlError) {
getServerNameMessage = () => {
if (!this.state.serverName.length) {
return (
<>
{nameError}
<br/>
{urlError}
</>
<div
id='nameValidation'
className='error'
>
<i className='icon-close-circle'/>
<FormattedMessage
id='renderer.components.newServerModal.error.nameRequired'
defaultMessage='Name is required.'
/>
</div>
);
} else if (nameError) {
return nameError;
} else if (urlError) {
return urlError;
}
return null;
}
validateForm() {
return this.getServerNameValidationState() === null &&
this.getServerUrlValidationState() === null;
}
save = () => {
if (!this.state.validationResult) {
return;
}
if (this.isServerURLErrored()) {
return;
}
save = async () => {
await this.addProtocolToUrl(this.state.serverUrl);
this.setState({
saveStarted: true,
}, () => {
if (this.validateForm()) {
this.props.onSave?.({
url: this.state.serverUrl,
name: this.state.serverName,
id: this.state.serverId,
});
}
this.props.onSave?.({
url: this.state.serverUrl,
name: this.state.serverName,
id: this.state.serverId,
});
});
}
@@ -291,7 +383,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
this.props.setInputRef(ref);
}
}}
isInvalid={Boolean(this.getServerUrlValidationState())}
isInvalid={this.isServerURLErrored()}
autoFocus={true}
/>
<FormControl.Feedback/>
@@ -318,7 +410,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
onClick={(e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
}}
isInvalid={Boolean(this.getServerNameValidationState())}
isInvalid={!this.state.serverName.length}
/>
<FormControl.Feedback/>
<FormText className='NewServerModal-noBottomSpace'>
@@ -329,15 +421,15 @@ class NewServerModal extends React.PureComponent<Props, State> {
</FormText>
</FormGroup>
</form>
<div
className='NewServerModal-validation'
>
{this.getServerNameMessage()}
{this.getServerURLMessage()}
</div>
</Modal.Body>
<Modal.Footer>
<div
className='pull-left modal-error'
>
{this.getError()}
</div>
{this.props.onClose &&
<Button
id='cancelNewServerModal'
@@ -354,7 +446,7 @@ class NewServerModal extends React.PureComponent<Props, State> {
<Button
id='saveNewServerModal'
onClick={this.save}
disabled={!this.validateForm()}
disabled={!this.state.serverName.length || !this.state.validationResult || this.isServerURLErrored()}
variant='primary'
>
{this.getSaveButtonLabel()}