Skip to content

Commit

Permalink
Add popover and prevent submit if duration params are invalid (jaeger…
Browse files Browse the repository at this point in the history
…tracing#244) (jaegertracing#291)

* Add validation for duration fields in SearchForm (jaegertracing#244)

Signed-off-by: Everett Ross <reverett@uber.com>

* Add tests for redux-form-field-adapter

Signed-off-by: Everett Ross <reverett@uber.com>

* Fix boolean prop type

Signed-off-by: Everett Ross <reverett@uber.com>

* Add boolean for input validation, change popover to show when inactive

Signed-off-by: Everett Ross <reverett@uber.com>

* Add tests for onChangeAdapter

Signed-off-by: Everett Ross <reverett@uber.com>

* Create separate ValidatedAdaptedInput for duration fields

Signed-off-by: Everett Ross <reverett@uber.com>

* Remove unnecessary curly braces

Signed-off-by: Everett Ross <reverett@uber.com>
Signed-off-by: Everett Ross <reverett@uber.com>
  • Loading branch information
everett980 committed Jan 16, 2019
1 parent 29b340d commit e0f34e4
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 14 deletions.
43 changes: 36 additions & 7 deletions packages/jaeger-ui/src/components/SearchTracePage/SearchForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@ import './SearchForm.css';
const FormItem = Form.Item;
const Option = Select.Option;

const AdaptedInput = reduxFormFieldAdapter(Input);
const AdaptedSelect = reduxFormFieldAdapter(Select);
const AdaptedVirtualSelect = reduxFormFieldAdapter(VirtSelect, option => (option ? option.value : null));
const AdaptedInput = reduxFormFieldAdapter({ AntInputComponent: Input });
const AdaptedSelect = reduxFormFieldAdapter({ AntInputComponent: Select });
const AdaptedVirtualSelect = reduxFormFieldAdapter({
AntInputComponent: VirtSelect,
onChangeAdapter: option => (option ? option.value : null),
});
const ValidatedAdaptedInput = reduxFormFieldAdapter({ AntInputComponent: Input, isValidatedInput: true });

export function getUnixTimeStampInMSFromForm({ startDate, startDateTime, endDate, endDateTime }) {
const start = `${startDate} ${startDateTime}`;
Expand Down Expand Up @@ -74,6 +78,17 @@ export function traceIDsToQuery(traceIDs) {
return traceIDs.split(',');
}

export const placeholderDurationFields = 'e.g. 1.2s, 100ms, 500us';
export function validateDurationFields(value) {
if (!value) return undefined;
return /\d[\d\\.]*(us|ms|s|m|h)$/.test(value)
? undefined
: {
content: `Please enter a number followed by a duration unit, ${placeholderDurationFields}`,
title: 'Please match the requested format.',
};
}

export function convertQueryParamsToFormDates({ start, end }) {
let queryStartDate;
let queryStartDateTime;
Expand Down Expand Up @@ -155,6 +170,7 @@ export class SearchFormImpl extends React.PureComponent {
render() {
const {
handleSubmit,
invalid,
selectedLookback,
selectedService = '-',
services,
Expand Down Expand Up @@ -322,14 +338,21 @@ export class SearchFormImpl extends React.PureComponent {
<FormItem label="Min Duration">
<Field
name="minDuration"
component={AdaptedInput}
placeholder="e.g. 1.2s, 100ms, 500us"
component={ValidatedAdaptedInput}
placeholder={placeholderDurationFields}
props={{ disabled }}
validate={validateDurationFields}
/>
</FormItem>

<FormItem label="Max Duration">
<Field name="maxDuration" component={AdaptedInput} placeholder="e.g. 1.1s" props={{ disabled }} />
<Field
name="maxDuration"
component={ValidatedAdaptedInput}
placeholder={placeholderDurationFields}
props={{ disabled }}
validate={validateDurationFields}
/>
</FormItem>

<FormItem label="Limit Results">
Expand All @@ -342,7 +365,11 @@ export class SearchFormImpl extends React.PureComponent {
/>
</FormItem>

<Button htmlType="submit" disabled={disabled || noSelectedService} data-test={markers.SUBMIT_BTN}>
<Button
htmlType="submit"
disabled={disabled || noSelectedService || invalid}
data-test={markers.SUBMIT_BTN}
>
Find Traces
</Button>
</Form>
Expand All @@ -352,6 +379,7 @@ export class SearchFormImpl extends React.PureComponent {

SearchFormImpl.propTypes = {
handleSubmit: PropTypes.func.isRequired,
invalid: PropTypes.bool,
submitting: PropTypes.bool,
services: PropTypes.arrayOf(
PropTypes.shape({
Expand All @@ -364,6 +392,7 @@ SearchFormImpl.propTypes = {
};

SearchFormImpl.defaultProps = {
invalid: false,
services: [],
submitting: false,
selectedService: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
submitForm,
traceIDsToQuery,
SearchFormImpl as SearchForm,
validateDurationFields,
} from './SearchForm';
import * as markers from './SearchForm.markers';

Expand Down Expand Up @@ -275,6 +276,36 @@ describe('<SearchForm>', () => {
btn = wrapper.find(`[data-test="${markers.SUBMIT_BTN}"]`);
expect(btn.prop('disabled')).toBeFalsy();
});

it('disables the submit button when the form has invalid data', () => {
wrapper = shallow(<SearchForm {...defaultProps} selectedService="svcA" />);
let btn = wrapper.find(`[data-test="${markers.SUBMIT_BTN}"]`);
// If this test fails on the following expect statement, this may be a false negative caused by a separate
// regression.
expect(btn.prop('disabled')).toBeFalsy();
wrapper.setProps({ invalid: true });
btn = wrapper.find(`[data-test="${markers.SUBMIT_BTN}"]`);
expect(btn.prop('disabled')).toBeTruthy();
});
});

describe('validation', () => {
it('should return `undefined` if the value is falsy', () => {
expect(validateDurationFields('')).toBeUndefined();
expect(validateDurationFields(null)).toBeUndefined();
expect(validateDurationFields(undefined)).toBeUndefined();
});

it('should return Popover-compliant error object if the value is a populated string that does not adhere to expected format', () => {
expect(validateDurationFields('100')).toEqual({
content: 'Please enter a number followed by a duration unit, e.g. 1.2s, 100ms, 500us',
title: 'Please match the requested format.',
});
});

it('should return `undefined` if the value is a populated string that adheres to expected format', () => {
expect(validateDurationFields('100ms')).toBeUndefined();
});
});

describe('mapStateToProps()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ function SelectSortImpl() {
return (
<label>
Sort:{' '}
<Field name="sortBy" component={reduxFormFieldAdapter(Select)}>
<Field name="sortBy" component={reduxFormFieldAdapter({ AntInputComponent: Select })}>
<Option value={orderBy.MOST_RECENT}>Most Recent</Option>
<Option value={orderBy.LONGEST_FIRST}>Longest First</Option>
<Option value={orderBy.SHORTEST_FIRST}>Shortest First</Option>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`reduxFormFieldAdapter not validated input should render as expected 1`] = `
<Input
className=""
disabled={false}
meta={
Object {
"active": false,
"error": null,
}
}
onBlur={null}
onChange={[Function]}
onFocus={null}
prefixCls="ant-input"
type="text"
value="inputValue"
/>
`;

exports[`reduxFormFieldAdapter validate input should render Popover as invisible if there is an error but the field is active 1`] = `
<Popover
content="error content"
mouseEnterDelay={0.1}
mouseLeaveDelay={0.1}
overlayStyle={Object {}}
placement="bottomLeft"
prefixCls="ant-popover"
title="error title"
transitionName="zoom-big"
trigger="hover"
visible={false}
>
<Input
className="AdaptedReduxFormField--isValidatedInput"
disabled={false}
meta={
Object {
"active": true,
"error": Object {
"content": "error content",
"title": "error title",
},
}
}
onBlur={[Function]}
onChange={[Function]}
prefixCls="ant-input"
type="text"
value="inputValue"
/>
</Popover>
`;

exports[`reduxFormFieldAdapter validate input should render Popover as visible if there is an error and the field is not active 1`] = `
<Popover
content="error content"
mouseEnterDelay={0.1}
mouseLeaveDelay={0.1}
overlayStyle={Object {}}
placement="bottomLeft"
prefixCls="ant-popover"
title="error title"
transitionName="zoom-big"
trigger="hover"
visible={true}
>
<Input
className="is-invalid AdaptedReduxFormField--isValidatedInput"
disabled={false}
meta={
Object {
"active": false,
"error": Object {
"content": "error content",
"title": "error title",
},
}
}
onBlur={[Function]}
onChange={[Function]}
prefixCls="ant-input"
type="text"
value="inputValue"
/>
</Popover>
`;

exports[`reduxFormFieldAdapter validate input should render as expected when there is not an error 1`] = `
<Popover
mouseEnterDelay={0.1}
mouseLeaveDelay={0.1}
overlayStyle={Object {}}
placement="bottomLeft"
prefixCls="ant-popover"
transitionName="zoom-big"
trigger="hover"
visible={false}
>
<Input
className="AdaptedReduxFormField--isValidatedInput"
disabled={false}
meta={
Object {
"active": false,
"error": null,
}
}
onBlur={[Function]}
onChange={[Function]}
prefixCls="ant-input"
type="text"
value="inputValue"
/>
</Popover>
`;
20 changes: 20 additions & 0 deletions packages/jaeger-ui/src/utils/redux-form-field-adapter.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
Copyright (c) 2018 Uber Technologies, Inc.
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.
*/

.AdaptedReduxFormField--isValidatedInput.is-invalid {
background: #fff9f8;
border: 1px solid #c00;
}
35 changes: 29 additions & 6 deletions packages/jaeger-ui/src/utils/redux-form-field-adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,45 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { Popover } from 'antd';
import cx from 'classnames';
import * as React from 'react';

export default function reduxFormFieldAdapter(
import './redux-form-field-adapter.css';

export default function reduxFormFieldAdapter({
AntInputComponent,
onChangeAdapter,
isValidatedInput = false,
}: {
AntInputComponent: Class<React.Component<*, *>>,
onChangeAdapter: () => void
) {
onChangeAdapter: () => void,
isValidatedInput: boolean,
}) {
return function _reduxFormFieldAdapter(props: any) {
const { input: { value, onChange }, children, ...rest } = props;
return (
const { input: { onBlur, onChange, onFocus, value }, children, ...rest } = props;
const isInvalid = !rest.meta.active && Boolean(rest.meta.error);
const content = (
<AntInputComponent
value={value}
className={cx({
'is-invalid': isInvalid,
'AdaptedReduxFormField--isValidatedInput': isValidatedInput,
})}
onBlur={isValidatedInput ? onBlur : null}
onFocus={isValidatedInput ? onFocus : null}
onChange={onChangeAdapter ? (...args) => onChange(onChangeAdapter(...args)) : onChange}
value={value}
{...rest}
>
{children}
</AntInputComponent>
);
return isValidatedInput ? (
<Popover placement="bottomLeft" visible={isInvalid} {...rest.meta.error}>
{content}
</Popover>
) : (
content
);
};
}
Loading

0 comments on commit e0f34e4

Please sign in to comment.