ReactMigration: Migrate DataSource HTTP Settings to React (#19452)
* Basic components for HTTP settings migration WIP * Add secureJsonFields to DataSourceSettings * Introduce datasource-http-settings-next directive for backward compatibility * fix lint * renames * rename fix * TagsInput component * move tags from app to grafana/ui * implement tagsinput on datasourcesettings * capitalize * new file for react directive for testing * some layout touch ups * FormField story * Minor touch ups * add url validation * using prevent default to prevent updating datasource when adding tag * using Stylefactory and fix tslint issue on MouseEvent * only show tlsauthsettings if tls or ca cert * fix url input length * fix for showAccessOptions * Implemented CertTextArea, removed commented code * removed commented / not used code * Rename and add more elements to Certification component * fixing newSecureJsonData * spelling * Fix issue with checkboxes being undefined * Removed old partials and minor fix * removed unused props from story
This commit is contained in:
committed by
Torkel Ödegaard
parent
cb0e80e7b9
commit
c9b11bfc7a
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { HttpSettingsProps } from './types';
|
||||
import { FormField } from '../FormField/FormField';
|
||||
import { SecretFormField } from '../SecretFormFied/SecretFormField';
|
||||
|
||||
export const BasicAuthSettings: React.FC<HttpSettingsProps> = ({ dataSourceConfig, onChange }) => {
|
||||
const password = dataSourceConfig.secureJsonData ? dataSourceConfig.secureJsonData.basicAuthPassword : '';
|
||||
|
||||
const onPasswordReset = () => {
|
||||
onChange({
|
||||
...dataSourceConfig,
|
||||
basicAuthPassword: '',
|
||||
secureJsonData: {
|
||||
...dataSourceConfig.secureJsonData,
|
||||
basicAuthPassword: '',
|
||||
},
|
||||
secureJsonFields: {
|
||||
...dataSourceConfig.secureJsonFields,
|
||||
basicAuthPassword: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onPasswordChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onChange({
|
||||
...dataSourceConfig,
|
||||
secureJsonData: {
|
||||
...dataSourceConfig.secureJsonData,
|
||||
basicAuthPassword: event.currentTarget.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form">
|
||||
<FormField
|
||||
label="User"
|
||||
labelWidth={10}
|
||||
inputWidth={18}
|
||||
placeholder="user"
|
||||
value={dataSourceConfig.basicAuthUser}
|
||||
onChange={event => onChange({ ...dataSourceConfig, basicAuthUser: event.currentTarget.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<SecretFormField
|
||||
isConfigured={
|
||||
!!dataSourceConfig.basicAuthPassword ||
|
||||
!!(dataSourceConfig.secureJsonFields && dataSourceConfig.secureJsonFields.basicAuthPassword)
|
||||
}
|
||||
value={password || ''}
|
||||
inputWidth={18}
|
||||
labelWidth={10}
|
||||
onReset={onPasswordReset}
|
||||
onChange={onPasswordChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React, { ChangeEvent, MouseEvent, FC } from 'react';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
hasCert: boolean;
|
||||
placeholder: string;
|
||||
|
||||
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export const CertificationKey: FC<Props> = ({ hasCert, label, onChange, onClick, placeholder }) => {
|
||||
return (
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form gf-form--v-stretch">
|
||||
<label className="gf-form-label width-7">{label}</label>
|
||||
</div>
|
||||
{!hasCert && (
|
||||
<div className="gf-form gf-form--grow">
|
||||
<textarea
|
||||
rows={7}
|
||||
className="gf-form-input gf-form-textarea"
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCert && (
|
||||
<div className="gf-form">
|
||||
<input type="text" className="gf-form-input max-width-12" disabled value="configured" />
|
||||
<a className="btn btn-secondary gf-form-btn" onClick={onClick}>
|
||||
reset
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { DataSourceHttpSettings } from './DataSourceHttpSettings';
|
||||
import { DataSourceSettings } from '../../types';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
|
||||
const settingsMock: DataSourceSettings<any, any> = {
|
||||
id: 4,
|
||||
orgId: 1,
|
||||
name: 'gdev-influxdb',
|
||||
type: 'influxdb',
|
||||
typeLogoUrl: '',
|
||||
access: 'direct',
|
||||
url: 'http://localhost:8086',
|
||||
password: '',
|
||||
user: 'grafana',
|
||||
database: 'site',
|
||||
basicAuth: false,
|
||||
basicAuthUser: '',
|
||||
basicAuthPassword: '',
|
||||
withCredentials: false,
|
||||
isDefault: false,
|
||||
jsonData: {
|
||||
timeInterval: '15s',
|
||||
httpMode: 'GET',
|
||||
keepCookies: ['cookie1', 'cookie2'],
|
||||
},
|
||||
secureJsonData: {
|
||||
password: true,
|
||||
},
|
||||
readOnly: true,
|
||||
};
|
||||
|
||||
const DataSourceHttpSettingsStories = storiesOf('UI/DataSource/DataSourceHttpSettings', module);
|
||||
|
||||
DataSourceHttpSettingsStories.add('default', () => {
|
||||
return (
|
||||
<UseState initialState={settingsMock} logState>
|
||||
{(dataSourceSettings, updateDataSourceSettings) => {
|
||||
return (
|
||||
<DataSourceHttpSettings
|
||||
defaultUrl="http://localhost:9999"
|
||||
dataSourceConfig={dataSourceSettings}
|
||||
onChange={updateDataSourceSettings}
|
||||
showAccessOptions={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
import { FormField, FormLabel, Input, Select, Switch, TagsInput } from '..';
|
||||
import { useTheme } from '../../themes';
|
||||
import { BasicAuthSettings } from './BasicAuthSettings';
|
||||
import { HttpProxySettings } from './HttpProxySettings';
|
||||
import { TLSAuthSettings } from './TLSAuthSettings';
|
||||
import { DataSourceSettings } from '../../types';
|
||||
import { HttpSettingsProps } from './types';
|
||||
|
||||
const ACCESS_OPTIONS: Array<SelectableValue<string>> = [
|
||||
{
|
||||
label: 'Server (default)',
|
||||
value: 'proxy',
|
||||
},
|
||||
{
|
||||
label: 'Browser',
|
||||
value: 'direct',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_ACCESS_OPTION = {
|
||||
label: 'Server (default)',
|
||||
value: 'proxy',
|
||||
};
|
||||
|
||||
const HttpAccessHelp = () => (
|
||||
<div className="grafana-info-box m-t-2">
|
||||
<p>
|
||||
Access mode controls how requests to the data source will be handled.
|
||||
<strong>
|
||||
<i>Server</i>
|
||||
</strong>{' '}
|
||||
should be the preferred way if nothing else stated.
|
||||
</p>
|
||||
<div className="alert-title">Server access mode (Default):</div>
|
||||
<p>
|
||||
All requests will be made from the browser to Grafana backend/server which in turn will forward the requests to
|
||||
the data source and by that circumvent possible Cross-Origin Resource Sharing (CORS) requirements. The URL needs
|
||||
to be accessible from the grafana backend/server if you select this access mode.
|
||||
</p>
|
||||
<div className="alert-title">Browser access mode:</div>
|
||||
<p>
|
||||
All requests will be made from the browser directly to the data source and may be subject to Cross-Origin Resource
|
||||
Sharing (CORS) requirements. The URL needs to be accessible from the browser if you select this access mode.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const DataSourceHttpSettings: React.FC<HttpSettingsProps> = props => {
|
||||
const { defaultUrl, dataSourceConfig, onChange, showAccessOptions } = props;
|
||||
let urlTooltip;
|
||||
const [isAccessHelpVisible, setIsAccessHelpVisible] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const onSettingsChange = useCallback(
|
||||
(change: Partial<DataSourceSettings<any, any>>) => {
|
||||
onChange({
|
||||
...dataSourceConfig,
|
||||
...change,
|
||||
});
|
||||
},
|
||||
[dataSourceConfig]
|
||||
);
|
||||
|
||||
switch (dataSourceConfig.access) {
|
||||
case 'direct':
|
||||
urlTooltip = (
|
||||
<>
|
||||
Your access method is <em>Browser</em>, this means the URL needs to be accessible from the browser.
|
||||
</>
|
||||
);
|
||||
break;
|
||||
case 'proxy':
|
||||
urlTooltip = (
|
||||
<>
|
||||
Your access method is <em>Server</em>, this means the URL needs to be accessible from the grafana
|
||||
backend/server.
|
||||
</>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
urlTooltip = 'Specify a complete HTTP URL (for example http://your_server:8080)';
|
||||
}
|
||||
|
||||
const accessSelect = (
|
||||
<Select
|
||||
width={20}
|
||||
options={ACCESS_OPTIONS}
|
||||
value={ACCESS_OPTIONS.filter(o => o.value === dataSourceConfig.access)[0] || DEFAULT_ACCESS_OPTION}
|
||||
onChange={selectedValue => onSettingsChange({ access: selectedValue.value })}
|
||||
/>
|
||||
);
|
||||
|
||||
const isValidUrl = /^(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?$/.test(
|
||||
dataSourceConfig.url
|
||||
);
|
||||
|
||||
const notValidStyle = css`
|
||||
box-shadow: inset 0 0px 5px ${theme.colors.red};
|
||||
`;
|
||||
|
||||
const inputStyle = cx({ [`width-20`]: true, [notValidStyle]: !isValidUrl });
|
||||
|
||||
const urlInput = (
|
||||
<Input
|
||||
className={inputStyle}
|
||||
placeholder={defaultUrl}
|
||||
value={dataSourceConfig.url}
|
||||
onChange={event => onSettingsChange({ url: event.currentTarget.value })}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<>
|
||||
<h3 className="page-heading">HTTP</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<FormField label="URL" labelWidth={11} tooltip={urlTooltip} inputEl={urlInput} />
|
||||
</div>
|
||||
|
||||
{showAccessOptions && (
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<div className="gf-form">
|
||||
<FormField label="Access" labelWidth={11} inputWidth={20} inputEl={accessSelect} />
|
||||
</div>
|
||||
<div className="gf-form">
|
||||
<label
|
||||
className="gf-form-label query-keyword pointer"
|
||||
onClick={() => setIsAccessHelpVisible(isVisible => !isVisible)}
|
||||
>
|
||||
Help
|
||||
<i className={`fa fa-caret-${isAccessHelpVisible ? 'down' : 'right'}`} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{isAccessHelpVisible && <HttpAccessHelp />}
|
||||
</>
|
||||
)}
|
||||
{dataSourceConfig.access === 'proxy' && (
|
||||
<div className="gf-form">
|
||||
<FormLabel
|
||||
width={11}
|
||||
tooltip="Grafana Proxy deletes forwarded cookies by default. Specify cookies by name that should be forwarded to the data source."
|
||||
>
|
||||
Whitelisted Cookies
|
||||
</FormLabel>
|
||||
<TagsInput
|
||||
tags={dataSourceConfig.jsonData.keepCookies}
|
||||
onChange={cookies =>
|
||||
onSettingsChange({ jsonData: { ...dataSourceConfig.jsonData, keepCookies: cookies } })
|
||||
}
|
||||
width={20}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
<>
|
||||
<h3 className="page-heading">Auth</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form-inline">
|
||||
<Switch
|
||||
label="Basic auth"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.basicAuth}
|
||||
onChange={event => {
|
||||
onSettingsChange({ basicAuth: event!.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
label="With Credentials"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.withCredentials}
|
||||
onChange={event => {
|
||||
onSettingsChange({ withCredentials: event!.currentTarget.checked });
|
||||
}}
|
||||
tooltip="Whether credentials such as cookies or auth headers should be sent with cross-site requests."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dataSourceConfig.access === 'proxy' && (
|
||||
<HttpProxySettings
|
||||
dataSourceConfig={dataSourceConfig}
|
||||
onChange={jsonData => onSettingsChange({ jsonData })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{dataSourceConfig.basicAuth && (
|
||||
<>
|
||||
<h6>Basic Auth Details</h6>
|
||||
<div className="gf-form-group">
|
||||
<BasicAuthSettings {...props} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && (
|
||||
<TLSAuthSettings dataSourceConfig={dataSourceConfig} onChange={onChange} />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { HttpSettingsBaseProps } from './types';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
|
||||
export const HttpProxySettings: React.FC<HttpSettingsBaseProps> = ({ dataSourceConfig, onChange }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="gf-form-inline">
|
||||
<Switch
|
||||
label="TLS Client Auth"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.jsonData.tlsAuth || false}
|
||||
onChange={event => onChange({ ...dataSourceConfig.jsonData, tlsAuth: event!.currentTarget.checked })}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="With CA Cert"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.jsonData.tlsAuthWithCACert || false}
|
||||
onChange={event =>
|
||||
onChange({ ...dataSourceConfig.jsonData, tlsAuthWithCACert: event!.currentTarget.checked })
|
||||
}
|
||||
tooltip="Needed for verifying self-signed TLS Certs"
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form-inline">
|
||||
<Switch
|
||||
label="Skip TLS Verify"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.jsonData.tlsSkipVerify || false}
|
||||
onChange={event => onChange({ ...dataSourceConfig.jsonData, tlsSkipVerify: event!.currentTarget.checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="gf-form-inline">
|
||||
<Switch
|
||||
label="Forward OAuth Identity"
|
||||
labelClass="width-13"
|
||||
checked={dataSourceConfig.jsonData.oauthPassThru || false}
|
||||
onChange={event => onChange({ ...dataSourceConfig.jsonData, oauthPassThru: event!.currentTarget.checked })}
|
||||
tooltip="Forward the user's upstream OAuth identity to the data source (Their access token gets passed along)."
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { KeyValue } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
import { Tooltip } from '..';
|
||||
import { CertificationKey } from './CertificationKey';
|
||||
import { HttpSettingsBaseProps } from './types';
|
||||
|
||||
export const TLSAuthSettings: React.FC<HttpSettingsBaseProps> = ({ dataSourceConfig, onChange }) => {
|
||||
const hasTLSCACert = dataSourceConfig.secureJsonFields && dataSourceConfig.secureJsonFields.tlsCACert;
|
||||
const hasTLSClientCert = dataSourceConfig.secureJsonFields && dataSourceConfig.secureJsonFields.tlsClientCert;
|
||||
const hasTLSClientKey = dataSourceConfig.secureJsonFields && dataSourceConfig.secureJsonFields.tlsClientKey;
|
||||
|
||||
const onResetClickFactory = (field: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
event.preventDefault();
|
||||
const newSecureJsonFields: KeyValue<boolean> = { ...dataSourceConfig.secureJsonFields };
|
||||
newSecureJsonFields[field] = false;
|
||||
onChange({
|
||||
...dataSourceConfig,
|
||||
secureJsonFields: newSecureJsonFields,
|
||||
});
|
||||
};
|
||||
|
||||
const onCertificateChangeFactory = (field: string) => (event: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const newSecureJsonData = { ...dataSourceConfig.secureJsonData };
|
||||
newSecureJsonData[field] = event.currentTarget.value;
|
||||
|
||||
onChange({
|
||||
...dataSourceConfig,
|
||||
secureJsonData: newSecureJsonData,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div
|
||||
className={cx(
|
||||
'gf-form',
|
||||
css`
|
||||
align-items: baseline;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<h6>TLS Auth Details</h6>
|
||||
<Tooltip
|
||||
placement="right-end"
|
||||
content="TLS Certs are encrypted and stored in the Grafana database."
|
||||
theme="info"
|
||||
>
|
||||
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
|
||||
<i className="fa fa-info-circle" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
{dataSourceConfig.jsonData.tlsAuthWithCACert && (
|
||||
<CertificationKey
|
||||
hasCert={!!hasTLSCACert}
|
||||
onChange={onCertificateChangeFactory('tlsCACert')}
|
||||
placeholder="Begins with -----BEGIN CERTIFICATE-----"
|
||||
label="CA Cert"
|
||||
onClick={onResetClickFactory('tlsCACert')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dataSourceConfig.jsonData.tlsAuth && (
|
||||
<>
|
||||
<CertificationKey
|
||||
hasCert={!!hasTLSClientCert}
|
||||
label="Client Cert"
|
||||
onChange={onCertificateChangeFactory('tlsClientCert')}
|
||||
placeholder="Begins with -----BEGIN CERTIFICATE-----"
|
||||
onClick={onResetClickFactory('tlsClientCert')}
|
||||
/>
|
||||
|
||||
<CertificationKey
|
||||
hasCert={!!hasTLSClientKey}
|
||||
label="Client Key"
|
||||
placeholder="Begins with -----BEGIN RSA PRIVATE KEY-----"
|
||||
onChange={onCertificateChangeFactory('tlsClientKey')}
|
||||
onClick={onResetClickFactory('tlsClientKey')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { DataSourceSettings } from '../../types';
|
||||
|
||||
export interface HttpSettingsBaseProps {
|
||||
dataSourceConfig: DataSourceSettings<any, any>;
|
||||
onChange: (config: DataSourceSettings) => void;
|
||||
}
|
||||
|
||||
export interface HttpSettingsProps extends HttpSettingsBaseProps {
|
||||
defaultUrl: string;
|
||||
showAccessOptions?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { number, text } from '@storybook/addon-knobs';
|
||||
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { FormField } from './FormField';
|
||||
|
||||
const getKnobs = () => {
|
||||
return {
|
||||
label: text('label', 'Test'),
|
||||
tooltip: text('tooltip', 'This is a tooltip with information about this FormField'),
|
||||
labelWidth: number('labelWidth', 10),
|
||||
inputWidth: number('inputWidth', 20),
|
||||
};
|
||||
};
|
||||
|
||||
const FormFieldStories = storiesOf('UI/FormField', module);
|
||||
|
||||
FormFieldStories.addDecorator(withCenteredStory);
|
||||
|
||||
FormFieldStories.add('default', () => {
|
||||
const { inputWidth, label, labelWidth } = getKnobs();
|
||||
return <FormField label={label} labelWidth={labelWidth} inputWidth={inputWidth} />;
|
||||
});
|
||||
|
||||
FormFieldStories.add('with tooltip', () => {
|
||||
const { inputWidth, label, labelWidth, tooltip } = getKnobs();
|
||||
return <FormField label={label} labelWidth={labelWidth} inputWidth={inputWidth} tooltip={tooltip} />;
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { getTagColorsFromName } from '../../utils';
|
||||
import { stylesFactory } from '../../themes';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
|
||||
onRemove: (tag: string) => void;
|
||||
}
|
||||
|
||||
export const TagItem: FC<Props> = ({ name, onRemove }) => {
|
||||
const { color, borderColor } = getTagColorsFromName(name);
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
itemStyle: css`
|
||||
background-color: ${color};
|
||||
border: 1px solid ${borderColor};
|
||||
border-radius: 3px;
|
||||
padding: 3px 6px;
|
||||
margin: 3px;
|
||||
white-space: nowrap;
|
||||
text-shadow: none;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`,
|
||||
|
||||
nameStyle: css`
|
||||
margin-right: 3px;
|
||||
`,
|
||||
|
||||
removeStyle: cx([
|
||||
'fa fa-times',
|
||||
css`
|
||||
cursor: pointer;
|
||||
`,
|
||||
]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={getStyles().itemStyle}>
|
||||
<span className={getStyles().nameStyle}>{name}</span>
|
||||
<i className={getStyles().removeStyle} onClick={() => onRemove(name)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { TagsInput } from './TagsInput';
|
||||
|
||||
const TagsInputStories = storiesOf('UI/TagsInput', module);
|
||||
const mockTags = ['Some', 'Tags', 'With', 'This', 'New', 'Component'];
|
||||
|
||||
TagsInputStories.addDecorator(withCenteredStory);
|
||||
|
||||
TagsInputStories.add('default', () => {
|
||||
return <TagsInput tags={[]} onChange={tags => action('tags updated')(tags)} />;
|
||||
});
|
||||
|
||||
TagsInputStories.add('with mock tags', () => {
|
||||
return (
|
||||
<UseState initialState={mockTags}>
|
||||
{tags => {
|
||||
return <TagsInput tags={tags} onChange={tags => action('tags updated')(tags)} />;
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { ChangeEvent, KeyboardEvent, PureComponent } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { stylesFactory } from '../../themes/stylesFactory';
|
||||
import { Button, Input } from '..';
|
||||
import { TagItem } from './TagItem';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
width?: number;
|
||||
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
newTag: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export class TagsInput extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
newTag: '',
|
||||
tags: this.props.tags || [],
|
||||
};
|
||||
}
|
||||
|
||||
onNameChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
newTag: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
onRemove = (tagToRemove: string) => {
|
||||
this.setState(
|
||||
(prevState: State) => ({
|
||||
...prevState,
|
||||
tags: prevState.tags.filter(tag => tagToRemove !== tag),
|
||||
}),
|
||||
() => this.onChange()
|
||||
);
|
||||
};
|
||||
|
||||
// Using React.MouseEvent to avoid tslint error
|
||||
onAdd = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
if (this.state.newTag !== '') {
|
||||
this.setNewTags();
|
||||
}
|
||||
};
|
||||
|
||||
onKeyboardAdd = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.key === 'Enter' && this.state.newTag !== '') {
|
||||
this.setNewTags();
|
||||
}
|
||||
};
|
||||
|
||||
setNewTags = () => {
|
||||
// We don't want to duplicate tags, clearing the input if
|
||||
// the user is trying to add the same tag.
|
||||
if (!this.state.tags.includes(this.state.newTag)) {
|
||||
this.setState(
|
||||
(prevState: State) => ({
|
||||
...prevState,
|
||||
tags: [...prevState.tags, prevState.newTag],
|
||||
newTag: '',
|
||||
}),
|
||||
() => this.onChange()
|
||||
);
|
||||
} else {
|
||||
this.setState({ newTag: '' });
|
||||
}
|
||||
};
|
||||
|
||||
onChange = () => {
|
||||
this.props.onChange(this.state.tags);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { tags, newTag } = this.state;
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
tagsCloudStyle: css`
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
`,
|
||||
|
||||
addButtonStyle: css`
|
||||
margin-left: 8px;
|
||||
margin-top: 2px;
|
||||
`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="width-20">
|
||||
<div
|
||||
className={cx(
|
||||
['gf-form-inline'],
|
||||
css`
|
||||
margin-bottom: 4px;
|
||||
`
|
||||
)}
|
||||
>
|
||||
<Input placeholder="Add Name" onChange={this.onNameChange} value={newTag} onKeyUp={this.onKeyboardAdd} />
|
||||
<Button className={getStyles().addButtonStyle} onClick={this.onAdd} variant="secondary" size="md">
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className={getStyles().tagsCloudStyle}>
|
||||
{tags &&
|
||||
tags.map((tag: string, index: number) => {
|
||||
return <TagItem key={`${tag}-${index}`} name={tag} onRemove={this.onRemove} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export { RefreshPicker } from './RefreshPicker/RefreshPicker';
|
||||
export { TimePicker } from './TimePicker/TimePicker';
|
||||
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
|
||||
export { List } from './List/List';
|
||||
export { TagsInput } from './TagsInput/TagsInput';
|
||||
export { Modal } from './Modal/Modal';
|
||||
|
||||
// Renderless
|
||||
@@ -87,8 +88,8 @@ export { JSONFormatter } from './JSONFormatter/JSONFormatter';
|
||||
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
|
||||
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
|
||||
export { AlphaNotice } from './AlphaNotice/AlphaNotice';
|
||||
export { DataSourceHttpSettings } from './DataSourceSettings/DataSourceHttpSettings';
|
||||
export { Spinner } from './Spinner/Spinner';
|
||||
export { FadeTransition } from './transitions/FadeTransition';
|
||||
export { SlideOutTransition } from './transitions/SlideOutTransition';
|
||||
// Segment
|
||||
export { Segment, SegmentAsync, SegmentSelect } from './Segment/';
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DataFrameDTO,
|
||||
AnnotationEvent,
|
||||
ScopedVars,
|
||||
KeyValue,
|
||||
} from '@grafana/data';
|
||||
import { PluginMeta, GrafanaPlugin } from './plugin';
|
||||
import { PanelData } from './panel';
|
||||
@@ -504,6 +505,7 @@ export interface DataSourceSettings<T extends DataSourceJsonData = DataSourceJso
|
||||
isDefault: boolean;
|
||||
jsonData: T;
|
||||
secureJsonData?: S;
|
||||
secureJsonFields?: KeyValue<boolean>;
|
||||
readOnly: boolean;
|
||||
withCredentials: boolean;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from './validate';
|
||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
||||
export * from './slate';
|
||||
export * from './dataLinks';
|
||||
export * from './tags';
|
||||
export { default as ansicolor } from './ansicolor';
|
||||
|
||||
// Export with a namespace
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
interface StateHolderProps<T> {
|
||||
logState?: boolean;
|
||||
initialState: T;
|
||||
children: (currentState: T, updateState: (nextState: T) => void) => React.ReactNode;
|
||||
}
|
||||
@@ -32,6 +34,9 @@ export class UseState<T> extends React.Component<StateHolderProps<T>, { value: T
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.props.logState) {
|
||||
action('UseState current state')(this.state.value);
|
||||
}
|
||||
return this.props.children(this.state.value, this.handleStateUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const TAG_COLORS = [
|
||||
'#E24D42',
|
||||
'#1F78C1',
|
||||
'#BA43A9',
|
||||
'#705DA0',
|
||||
'#466803',
|
||||
'#508642',
|
||||
'#447EBC',
|
||||
'#C15C17',
|
||||
'#890F02',
|
||||
'#757575',
|
||||
'#0A437C',
|
||||
'#6D1F62',
|
||||
'#584477',
|
||||
'#629E51',
|
||||
'#2F4F4F',
|
||||
'#BF1B00',
|
||||
'#806EB7',
|
||||
'#8a2eb8',
|
||||
'#699e00',
|
||||
'#000000',
|
||||
'#3F6833',
|
||||
'#2F575E',
|
||||
'#99440A',
|
||||
'#E0752D',
|
||||
'#0E4AB4',
|
||||
'#58140C',
|
||||
'#052B51',
|
||||
'#511749',
|
||||
'#3F2B5B',
|
||||
];
|
||||
|
||||
const TAG_BORDER_COLORS = [
|
||||
'#FF7368',
|
||||
'#459EE7',
|
||||
'#E069CF',
|
||||
'#9683C6',
|
||||
'#6C8E29',
|
||||
'#76AC68',
|
||||
'#6AA4E2',
|
||||
'#E7823D',
|
||||
'#AF3528',
|
||||
'#9B9B9B',
|
||||
'#3069A2',
|
||||
'#934588',
|
||||
'#7E6A9D',
|
||||
'#88C477',
|
||||
'#557575',
|
||||
'#E54126',
|
||||
'#A694DD',
|
||||
'#B054DE',
|
||||
'#8FC426',
|
||||
'#262626',
|
||||
'#658E59',
|
||||
'#557D84',
|
||||
'#BF6A30',
|
||||
'#FF9B53',
|
||||
'#3470DA',
|
||||
'#7E3A32',
|
||||
'#2B5177',
|
||||
'#773D6F',
|
||||
'#655181',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns tag badge background and border colors based on hashed tag name.
|
||||
* @param name tag name
|
||||
*/
|
||||
export function getTagColorsFromName(name: string): { color: string; borderColor: string } {
|
||||
const hash = djb2(name.toLowerCase());
|
||||
const color = TAG_COLORS[Math.abs(hash % TAG_COLORS.length)];
|
||||
const borderColor = TAG_BORDER_COLORS[Math.abs(hash % TAG_BORDER_COLORS.length)];
|
||||
return { color, borderColor };
|
||||
}
|
||||
|
||||
function djb2(str: string) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export default {
|
||||
getTagColorsFromName,
|
||||
};
|
||||
Reference in New Issue
Block a user