Explorar o código

Merge branch 'panel-edit-in-react' into various-style-fixes

Torkel Ödegaard %!s(int64=7) %!d(string=hai) anos
pai
achega
cc210b11fb

+ 67 - 0
public/app/core/components/CopyToClipboard/CopyToClipboard.tsx

@@ -0,0 +1,67 @@
+import React, { PureComponent, ReactNode } from 'react';
+import ClipboardJS from 'clipboard';
+
+interface Props {
+  text: () => string;
+  elType?: string;
+  onSuccess?: (evt: any) => void;
+  onError?: (evt: any) => void;
+  className?: string;
+  children?: ReactNode;
+}
+
+export class CopyToClipboard extends PureComponent<Props> {
+  clipboardjs: any;
+  myRef: any;
+
+  constructor(props) {
+    super(props);
+    this.myRef = React.createRef();
+  }
+
+  componentDidMount() {
+    const { text, onSuccess, onError } = this.props;
+
+    this.clipboardjs = new ClipboardJS(this.myRef.current, {
+      text: text,
+    });
+
+    if (onSuccess) {
+      this.clipboardjs.on('success', evt => {
+        evt.clearSelection();
+        onSuccess(evt);
+      });
+    }
+
+    if (onError) {
+      this.clipboardjs.on('error', evt => {
+        console.error('Action:', evt.action);
+        console.error('Trigger:', evt.trigger);
+        onError(evt);
+      });
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.clipboardjs) {
+      this.clipboardjs.destroy();
+    }
+  }
+
+  getElementType = () => {
+    return this.props.elType || 'button';
+  };
+
+  render() {
+    const { elType, text, children, onError, onSuccess, ...restProps } = this.props;
+
+    return React.createElement(
+      this.getElementType(),
+      {
+        ref: this.myRef,
+        ...restProps,
+      },
+      this.props.children
+    );
+  }
+}

+ 43 - 0
public/app/core/components/Form/Element.tsx

@@ -0,0 +1,43 @@
+import React, { PureComponent, ReactNode, ReactElement } from 'react';
+import { Label } from './Label';
+import { uniqueId } from 'lodash';
+
+interface Props {
+  label?: ReactNode;
+  labelClassName?: string;
+  id?: string;
+  children: ReactElement<any>;
+}
+
+export class Element extends PureComponent<Props> {
+  elementId: string = this.props.id || uniqueId('form-element-');
+
+  get elementLabel() {
+    const { label, labelClassName } = this.props;
+
+    if (label) {
+      return (
+        <Label htmlFor={this.elementId} className={labelClassName}>
+          {label}
+        </Label>
+      );
+    }
+
+    return null;
+  }
+
+  get children() {
+    const { children } = this.props;
+
+    return React.cloneElement(children, { id: this.elementId });
+  }
+
+  render() {
+    return (
+      <div className="our-custom-wrapper-class">
+        {this.elementLabel}
+        {this.children}
+      </div>
+    );
+  }
+}

+ 86 - 0
public/app/core/components/Form/Input.tsx

@@ -0,0 +1,86 @@
+import React, { PureComponent } from 'react';
+import { ValidationEvents, ValidationRule } from 'app/types';
+import { validate } from 'app/core/utils/validate';
+
+export enum InputStatus {
+  Invalid = 'invalid',
+  Valid = 'valid',
+}
+
+export enum InputTypes {
+  Text = 'text',
+  Number = 'number',
+  Password = 'password',
+  Email = 'email',
+}
+
+export enum EventsWithValidation {
+  onBlur = 'onBlur',
+  onFocus = 'onFocus',
+  onChange = 'onChange',
+}
+
+interface Props extends React.HTMLProps<HTMLInputElement> {
+  validationEvents: ValidationEvents;
+  hideErrorMessage?: boolean;
+
+  // Override event props and append status as argument
+  onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
+}
+
+export class Input extends PureComponent<Props> {
+  state = {
+    error: null,
+  };
+
+  get status() {
+    return this.state.error ? InputStatus.Invalid : InputStatus.Valid;
+  }
+
+  get isInvalid() {
+    return this.status === InputStatus.Invalid;
+  }
+
+  validatorAsync = (validationRules: ValidationRule[]) => {
+    return evt => {
+      const errors = validate(evt.currentTarget.value, validationRules);
+      this.setState(prevState => {
+        return {
+          ...prevState,
+          error: errors ? errors[0] : null,
+        };
+      });
+    };
+  };
+
+  populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
+    const inputElementProps = { ...restProps };
+    Object.keys(EventsWithValidation).forEach(eventName => {
+      inputElementProps[eventName] = async evt => {
+        if (validationEvents[eventName]) {
+          await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
+        }
+        if (restProps[eventName]) {
+          restProps[eventName].apply(null, [evt, this.status]);
+        }
+      };
+    });
+    return inputElementProps;
+  };
+
+  render() {
+    const { validationEvents, className, hideErrorMessage, ...restProps } = this.props;
+    const { error } = this.state;
+    const inputClassName = 'gf-form-input' + (this.isInvalid ? ' invalid' : '');
+    const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
+
+    return (
+      <div className="our-custom-wrapper-class">
+        <input {...inputElementProps} className={inputClassName} />
+        {error && !hideErrorMessage && <span>{error}</span>}
+      </div>
+    );
+  }
+}

+ 19 - 0
public/app/core/components/Form/Label.tsx

@@ -0,0 +1,19 @@
+import React, { PureComponent, ReactNode } from 'react';
+
+interface Props {
+  children: ReactNode;
+  htmlFor?: string;
+  className?: string;
+}
+
+export class Label extends PureComponent<Props> {
+  render() {
+    const { children, htmlFor, className } = this.props;
+
+    return (
+      <label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
+        {children}
+      </label>
+    );
+  }
+}

+ 3 - 0
public/app/core/components/Form/index.ts

@@ -0,0 +1,3 @@
+export { Element } from './Element';
+export { Input } from './Input';
+export { Label } from './Label';

+ 51 - 0
public/app/core/components/JSONFormatter/JSONFormatter.tsx

@@ -0,0 +1,51 @@
+import React, { PureComponent, createRef } from 'react';
+// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js';
+import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
+
+interface Props {
+  className?: string;
+  json: {};
+  config?: any;
+  open?: number;
+  onDidRender?: (formattedJson: any) => void;
+}
+
+export class JSONFormatter extends PureComponent<Props> {
+  private wrapperRef = createRef<HTMLDivElement>();
+
+  static defaultProps = {
+    open: 3,
+    config: {
+      animateOpen: true,
+    },
+  };
+
+  componentDidMount() {
+    this.renderJson();
+  }
+
+  componentDidUpdate() {
+    this.renderJson();
+  }
+
+  renderJson = () => {
+    const { json, config, open, onDidRender } = this.props;
+    const wrapperEl = this.wrapperRef.current;
+    const formatter = new JsonExplorer(json, open, config);
+    const hasChildren: boolean = wrapperEl.hasChildNodes();
+    if (hasChildren) {
+      wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
+    } else {
+      wrapperEl.appendChild(formatter.render());
+    }
+
+    if (onDidRender) {
+      onDidRender(formatter.json);
+    }
+  };
+
+  render() {
+    const { className } = this.props;
+    return <div className={className} ref={this.wrapperRef} />;
+  }
+}

+ 9 - 0
public/app/core/utils/rangeutil.ts

@@ -159,3 +159,12 @@ export function describeTimeRange(range: RawTimeRange): string {
 
   return range.from.toString() + ' to ' + range.to.toString();
 }
+
+export const isValidTimeSpan = (value: string) => {
+  if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
+    return true;
+  }
+
+  const info = describeTextRange(value);
+  return info.invalid !== true;
+};

+ 11 - 0
public/app/core/utils/validate.ts

@@ -0,0 +1,11 @@
+import { ValidationRule } from 'app/types';
+
+export const validate = (value: string, validationRules: ValidationRule[]) => {
+  const errors = validationRules.reduce((acc, currRule) => {
+    if (!currRule.rule(value)) {
+      return acc.concat(currRule.errorMessage);
+    }
+    return acc;
+  }, []);
+  return errors.length > 0 ? errors : null;
+};

+ 124 - 7
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -1,13 +1,18 @@
-import React, { PureComponent } from 'react';
+import React, { SFC, PureComponent } from 'react';
 import DataSourceOption from './DataSourceOption';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { EditorTabBody } from './EditorTabBody';
 import { DataSourcePicker } from './DataSourcePicker';
-
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 import './../../panel/metrics_tab';
 import config from 'app/core/config';
+import { QueryInspector } from './QueryInspector';
+import { Switch } from 'app/core/components/Switch/Switch';
+import { Input } from 'app/core/components/Form';
+import { InputStatus, EventsWithValidation } from 'app/core/components/Form/Input';
+import { isValidTimeSpan } from 'app/core/utils/rangeutil';
+import { ValidationEvents } from 'app/types';
 
 // Services
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@@ -29,8 +34,29 @@ interface Help {
 interface State {
   currentDatasource: DataSourceSelectItem;
   help: Help;
+  hideTimeOverride: boolean;
+}
+
+interface LoadingPlaceholderProps {
+  text: string;
 }
 
+const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
+
+const timeRangeValidationEvents: ValidationEvents = {
+  [EventsWithValidation.onBlur]: [
+    {
+      rule: value => {
+        if (!value) {
+          return true;
+        }
+        return isValidTimeSpan(value);
+      },
+      errorMessage: 'Not a valid timespan',
+    },
+  ],
+};
+
 export class QueriesTab extends PureComponent<Props, State> {
   element: any;
   component: AngularComponent;
@@ -47,6 +73,7 @@ export class QueriesTab extends PureComponent<Props, State> {
         isLoading: false,
         helpHtml: null,
       },
+      hideTimeOverride: false,
     };
   }
 
@@ -199,9 +226,50 @@ export class QueriesTab extends PureComponent<Props, State> {
     });
   };
 
+  renderQueryInspector = () => {
+    const { panel } = this.props;
+    return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
+  };
+
+  renderHelp = () => {
+    const { helpHtml, isLoading } = this.state.help;
+    return isLoading ? <LoadingPlaceholder text="Loading help..." /> : helpHtml;
+  };
+
+  emptyToNull = (value: string) => {
+    return value === '' ? null : value;
+  };
+
+  onOverrideTime = (evt, status: InputStatus) => {
+    const { value } = evt.target;
+    const { panel } = this.props;
+    const emptyToNullValue = this.emptyToNull(value);
+    if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
+      panel.timeFrom = emptyToNullValue;
+      panel.refresh();
+    }
+  };
+
+  onTimeShift = (evt, status: InputStatus) => {
+    const { value } = evt.target;
+    const { panel } = this.props;
+    const emptyToNullValue = this.emptyToNull(value);
+    if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
+      panel.timeShift = emptyToNullValue;
+      panel.refresh();
+    }
+  };
+
+  onToggleTimeOverride = () => {
+    const { panel } = this.props;
+    panel.hideTimeOverride = !panel.hideTimeOverride;
+    panel.refresh();
+  };
+
   render() {
     const { currentDatasource } = this.state;
-    const { helpHtml } = this.state.help;
+    const hideTimeOverride = this.props.panel.hideTimeOverride;
+    console.log('hideTimeOverride', hideTimeOverride);
     const { hasQueryHelp, queryOptions } = currentDatasource.meta;
     const hasQueryOptions = !!queryOptions;
     const dsInformation = {
@@ -220,7 +288,7 @@ export class QueriesTab extends PureComponent<Props, State> {
 
     const queryInspector = {
       title: 'Query Inspector',
-      render: () => <h2>hello</h2>,
+      render: this.renderQueryInspector,
     };
 
     const dsHelp = {
@@ -228,18 +296,67 @@ export class QueriesTab extends PureComponent<Props, State> {
       icon: 'fa fa-question',
       disabled: !hasQueryHelp,
       onClick: this.loadHelp,
-      render: () => helpHtml,
+      render: this.renderHelp,
     };
 
     const options = {
-      title: 'Options',
+      title: '',
+      icon: 'fa fa-cog',
       disabled: !hasQueryOptions,
       render: this.renderOptions,
     };
 
     return (
       <EditorTabBody heading="Queries" main={dsInformation} toolbarItems={[options, queryInspector, dsHelp]}>
-        <div ref={element => (this.element = element)} style={{ width: '100%' }} />
+        <>
+          <div ref={element => (this.element = element)} style={{ width: '100%' }} />
+
+          <h5 className="section-heading">Time Range</h5>
+
+          <div className="gf-form-group">
+            <div className="gf-form">
+              <span className="gf-form-label">
+                <i className="fa fa-clock-o" />
+              </span>
+
+              <span className="gf-form-label width-12">Override relative time</span>
+              <span className="gf-form-label width-6">Last</span>
+              <Input
+                type="text"
+                className="gf-form-input max-width-8"
+                placeholder="1h"
+                onBlur={this.onOverrideTime}
+                validationEvents={timeRangeValidationEvents}
+                hideErrorMessage={true}
+              />
+            </div>
+
+            <div className="gf-form">
+              <span className="gf-form-label">
+                <i className="fa fa-clock-o" />
+              </span>
+              <span className="gf-form-label width-12">Add time shift</span>
+              <span className="gf-form-label width-6">Amount</span>
+              <Input
+                type="text"
+                className="gf-form-input max-width-8"
+                placeholder="1h"
+                onBlur={this.onTimeShift}
+                validationEvents={timeRangeValidationEvents}
+                hideErrorMessage={true}
+              />
+            </div>
+
+            <div className="gf-form-inline">
+              <div className="gf-form">
+                <span className="gf-form-label">
+                  <i className="fa fa-clock-o" />
+                </span>
+              </div>
+              <Switch label="Hide time override info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
+            </div>
+          </div>
+        </>
       </EditorTabBody>
     );
   }

+ 226 - 0
public/app/features/dashboard/dashgrid/QueryInspector.tsx

@@ -0,0 +1,226 @@
+import React, { PureComponent } from 'react';
+import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
+import appEvents from 'app/core/app_events';
+import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
+
+interface DsQuery {
+  isLoading: boolean;
+  response: {};
+}
+
+interface Props {
+  panel: any;
+  LoadingPlaceholder: any;
+}
+
+interface State {
+  allNodesExpanded: boolean;
+  isMocking: boolean;
+  mockedResponse: string;
+  dsQuery: DsQuery;
+}
+
+export class QueryInspector extends PureComponent<Props, State> {
+  formattedJson: any;
+  clipboard: any;
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      allNodesExpanded: null,
+      isMocking: false,
+      mockedResponse: '',
+      dsQuery: {
+        isLoading: false,
+        response: {},
+      },
+    };
+  }
+
+  componentDidMount() {
+    const { panel } = this.props;
+    panel.events.on('refresh', this.onPanelRefresh);
+    appEvents.on('ds-request-response', this.onDataSourceResponse);
+    panel.refresh();
+  }
+
+  componentWillUnmount() {
+    const { panel } = this.props;
+    appEvents.off('ds-request-response', this.onDataSourceResponse);
+    panel.events.off('refresh', this.onPanelRefresh);
+  }
+
+  handleMocking(response) {
+    const { mockedResponse } = this.state;
+    let mockedData;
+    try {
+      mockedData = JSON.parse(mockedResponse);
+    } catch (err) {
+      appEvents.emit('alert-error', ['R: Failed to parse mocked response']);
+      return;
+    }
+
+    response.data = mockedData;
+  }
+
+  onPanelRefresh = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      dsQuery: {
+        isLoading: true,
+        response: {},
+      },
+    }));
+  };
+
+  onDataSourceResponse = (response: any = {}) => {
+    if (this.state.isMocking) {
+      this.handleMocking(response);
+      return;
+    }
+
+    response = { ...response }; // clone - dont modify the response
+
+    if (response.headers) {
+      delete response.headers;
+    }
+
+    if (response.config) {
+      response.request = response.config;
+      delete response.config;
+      delete response.request.transformRequest;
+      delete response.request.transformResponse;
+      delete response.request.paramSerializer;
+      delete response.request.jsonpCallbackParam;
+      delete response.request.headers;
+      delete response.request.requestId;
+      delete response.request.inspect;
+      delete response.request.retry;
+      delete response.request.timeout;
+    }
+
+    if (response.data) {
+      response.response = response.data;
+
+      delete response.data;
+      delete response.status;
+      delete response.statusText;
+      delete response.$$config;
+    }
+    this.setState(prevState => ({
+      ...prevState,
+      dsQuery: {
+        isLoading: false,
+        response: response,
+      },
+    }));
+  };
+
+  setFormattedJson = formattedJson => {
+    this.formattedJson = formattedJson;
+  };
+
+  getTextForClipboard = () => {
+    return JSON.stringify(this.formattedJson, null, 2);
+  };
+
+  onClipboardSuccess = () => {
+    appEvents.emit('alert-success', ['Content copied to clipboard']);
+  };
+
+  onToggleExpand = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      allNodesExpanded: !this.state.allNodesExpanded,
+    }));
+  };
+
+  onToggleMocking = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      isMocking: !this.state.isMocking,
+    }));
+  };
+
+  getNrOfOpenNodes = () => {
+    if (this.state.allNodesExpanded === null) {
+      return 3; // 3 is default, ie when state is null
+    } else if (this.state.allNodesExpanded) {
+      return 20;
+    }
+    return 1;
+  };
+
+  setMockedResponse = evt => {
+    const mockedResponse = evt.target.value;
+    this.setState(prevState => ({
+      ...prevState,
+      mockedResponse,
+    }));
+  };
+
+  renderExpandCollapse = () => {
+    const { allNodesExpanded } = this.state;
+
+    const collapse = (
+      <>
+        <i className="fa fa-minus-square-o" /> Collapse All
+      </>
+    );
+    const expand = (
+      <>
+        <i className="fa fa-plus-square-o" /> Expand All
+      </>
+    );
+    return allNodesExpanded ? collapse : expand;
+  };
+
+  render() {
+    const { response, isLoading } = this.state.dsQuery;
+    const { LoadingPlaceholder } = this.props;
+    const { isMocking } = this.state;
+    const openNodes = this.getNrOfOpenNodes();
+
+    if (isLoading) {
+      return <LoadingPlaceholder text="Loading query inspector..." />;
+    }
+
+    return (
+      <>
+        <div>
+          {/*
+          <button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleMocking}>
+            Mock response
+          </button>
+          */}
+          <button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleExpand}>
+            {this.renderExpandCollapse()}
+          </button>
+
+          <CopyToClipboard
+            className="btn btn-transparent btn-p-x-0"
+            text={this.getTextForClipboard}
+            onSuccess={this.onClipboardSuccess}
+          >
+            <i className="fa fa-clipboard" /> Copy to Clipboard
+          </CopyToClipboard>
+        </div>
+
+        {!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />}
+        {isMocking && (
+          <div className="query-troubleshooter__body">
+            <div className="gf-form p-l-1 gf-form--v-stretch">
+              <textarea
+                className="gf-form-input"
+                style={{ width: '95%' }}
+                rows={10}
+                onInput={this.setMockedResponse}
+                placeholder="JSON"
+              />
+            </div>
+          </div>
+        )}
+      </>
+    );
+  }
+}

+ 2 - 2
public/app/features/explore/Explore.tsx

@@ -144,7 +144,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     if (!datasourceSrv) {
       throw new Error('No datasource service passed as props.');
     }
-    const datasources = datasourceSrv.getExploreSources();
+    const datasources = datasourceSrv.getAll();
     const exploreDatasources = datasources.map(ds => ({
       value: ds.name,
       label: ds.name,
@@ -718,7 +718,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       try {
         const now = Date.now();
         const res = await datasource.query(transaction.options);
-        this.exploreEvents.emit('data-received', res);
+        this.exploreEvents.emit('data-received', res.data || []);
         const latency = Date.now() - now;
         const results = resultGetter ? resultGetter(res.data) : res.data;
         this.completeQueryTransaction(transaction.id, results, latency, queries, datasourceId);

+ 2 - 7
public/app/features/plugins/datasource_srv.ts

@@ -74,7 +74,8 @@ export class DatasourceSrv {
   }
 
   getAll() {
-    return config.datasources;
+    const { datasources } = config;
+    return Object.keys(datasources).map(name => datasources[name]);
   }
 
   getAnnotationSources() {
@@ -91,12 +92,6 @@ export class DatasourceSrv {
     return sources;
   }
 
-  getExploreSources() {
-    const { datasources } = config;
-    const es = Object.keys(datasources).map(name => datasources[name]);
-    return _.sortBy(es, ['name']);
-  }
-
   getMetricSources(options?) {
     const metricSources: DataSourceSelectItem[] = [];
 

+ 0 - 26
public/app/features/plugins/specs/datasource_srv.test.ts

@@ -18,32 +18,6 @@ const templateSrv = {
 describe('datasource_srv', () => {
   const _datasourceSrv = new DatasourceSrv({}, {}, {}, templateSrv);
 
-  describe('when loading explore sources', () => {
-    beforeEach(() => {
-      config.datasources = {
-        explore1: {
-          name: 'explore1',
-          meta: { explore: true, metrics: true },
-        },
-        explore2: {
-          name: 'explore2',
-          meta: { explore: true, metrics: false },
-        },
-        nonExplore: {
-          name: 'nonExplore',
-          meta: { explore: false, metrics: true },
-        },
-      };
-    });
-
-    it('should return list of explore sources', () => {
-      const exploreSources = _datasourceSrv.getExploreSources();
-      expect(exploreSources.length).toBe(2);
-      expect(exploreSources[0].name).toBe('explore1');
-      expect(exploreSources[1].name).toBe('explore2');
-    });
-  });
-
   describe('when loading metric sources', () => {
     let metricSources;
     const unsortedDatasources = {

+ 8 - 0
public/app/types/form.ts

@@ -0,0 +1,8 @@
+export interface ValidationRule {
+  rule: (valueToValidate: string) => boolean;
+  errorMessage: string;
+}
+
+export interface ValidationEvents {
+  [eventName: string]: ValidationRule[];
+}

+ 3 - 1
public/app/types/index.ts

@@ -30,7 +30,7 @@ import {
   AppNotificationTimeout,
 } from './appNotifications';
 import { DashboardSearchHit } from './search';
-
+import { ValidationEvents, ValidationRule } from './form';
 export {
   Team,
   TeamsState,
@@ -89,6 +89,8 @@ export {
   AppNotificationTimeout,
   DashboardSearchHit,
   UserState,
+  ValidationEvents,
+  ValidationRule,
 };
 
 export interface StoreState {

+ 6 - 0
public/sass/components/_buttons.scss

@@ -172,6 +172,12 @@
   padding-right: 20px;
 }
 
+// No horizontal padding
+.btn-p-x-0 {
+  padding-left: 0;
+  padding-right: 0;
+}
+
 // External services
 // Usage:
 // <div class="btn btn-service btn-service--facebook">Button text</div>

+ 5 - 1
public/sass/utils/_validation.scss

@@ -1,7 +1,11 @@
-input[type="text"].ng-dirty.ng-invalid {
+input[type='text'].ng-dirty.ng-invalid {
 }
 
 input.validation-error,
 input.ng-dirty.ng-invalid {
   box-shadow: inset 0 0px 5px $red;
 }
+
+input.invalid {
+  box-shadow: inset 0 0px 5px $red;
+}