Merge remote branch origin master to icons-unicons

This commit is contained in:
Ivana 2020-04-07 18:05:18 +02:00
parent bc468e4b92
commit 3f25d50a39
232 changed files with 4282 additions and 2076 deletions

View File

@ -101,7 +101,10 @@ import { MyComponent } from "./MyComponent";
### MDX file without a relationship to a component ### MDX file without a relationship to a component
An MDX file can exist by itself without any connection to a story. This can be good for writing things such as a general guidelines page. Something that is required when the MDX file has no relation to a component is a `Meta` tag that says where in the hierarchy the component will live. It can look like this: An MDX file can exist by itself without any connection to a story. This can be good for writing things such as a general guidelines page. Two things are required for this to work:
- The file needs to be named `*.story.mdx`
- A `Meta` tag must exist that says where in the hierarchy the component lives. It can look like this:
```jsx ```jsx
<Meta title="Docs Overview/Color Palettes"/> <Meta title="Docs Overview/Color Palettes"/>

View File

@ -8,7 +8,8 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
# Grafana documentation # Grafana documentation
<h2>Installing Grafana</h2> ## Installing Grafana
<div class="nav-cards"> <div class="nav-cards">
<a href="{{< relref "installation/debian.md" >}}" class="nav-cards__item nav-cards__item--install"> <a href="{{< relref "installation/debian.md" >}}" class="nav-cards__item nav-cards__item--install">
<div class="nav-cards__icon fa fa-linux"> <div class="nav-cards__icon fa fa-linux">
@ -26,7 +27,7 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
<h5>Install on Windows</h5> <h5>Install on Windows</h5>
</a> </a>
<a href="{{< relref "installation/docker.md" >}}" class="nav-cards__item nav-cards__item--install"> <a href="{{< relref "installation/docker.md" >}}" class="nav-cards__item nav-cards__item--install">
<img src="/img/docs/logos/icon_docker.png"> <img src="/static/img/logos/logo-docker.svg">
<h5>Run Docker image</h5> <h5>Run Docker image</h5>
</a> </a>
<a href="https://grafana.com/cloud/grafana" class="nav-cards__item nav-cards__item--install"> <a href="https://grafana.com/cloud/grafana" class="nav-cards__item nav-cards__item--install">
@ -41,7 +42,7 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
</a> </a>
</div> </div>
<h2>Guides</h2> ## Guides
<div class="nav-cards"> <div class="nav-cards">
<a href="{{< relref "guides/what-is-grafana.md" >}}" class="nav-cards__item nav-cards__item--guide"> <a href="{{< relref "guides/what-is-grafana.md" >}}" class="nav-cards__item nav-cards__item--guide">
@ -71,7 +72,8 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
</div> </div>
<h2>Data source guides</h2> ## Data source guides
<div class="nav-cards"> <div class="nav-cards">
<a href="{{< relref "features/datasources/graphite.md" >}}" class="nav-cards__item nav-cards__item--ds"> <a href="{{< relref "features/datasources/graphite.md" >}}" class="nav-cards__item nav-cards__item--ds">
<img src="/img/docs/logos/icon_graphite.svg" > <img src="/img/docs/logos/icon_graphite.svg" >

View File

@ -122,6 +122,12 @@ only give access to members of the group `example` which has Id `8bab1c86-8fba-3
allowed_groups = 8bab1c86-8fba-33e5-2089-1d1c80ec267d allowed_groups = 8bab1c86-8fba-33e5-2089-1d1c80ec267d
``` ```
You'll need to ensure that you've [enabled group attributes](https://docs.microsoft.com/en-us/azure/active-directory/hybrid/how-to-connect-fed-group-claims#configure-the-azure-ad-application-registration-for-group-attributes) in your Azure AD Application Registration manifest file (Azure Portal -> Azure Active Directory -> Application Registrations -> Select Application -> Manifest)
```json
"groupMembershipClaims": "ApplicationGroup"
```
The `allowed_domains` option limits access to the users belonging to the specific domains. Domains should be separated by space or comma. The `allowed_domains` option limits access to the users belonging to the specific domains. Domains should be separated by space or comma.
```ini ```ini

View File

@ -54,24 +54,6 @@ Check for the presence of a role using the [JMESPath](http://jmespath.org/exampl
See [JMESPath examples](#jmespath-examples) for more information. See [JMESPath examples](#jmespath-examples) for more information.
## Set up OAuth2 with Okta
First set up Grafana as an OpenId client "webapplication" in Okta. Then set the Base URIs to `https://<grafana domain>/` and set the Login redirect URIs to `https://<grafana domain>/login/generic_oauth`.
Finally set up the generic oauth module like this:
```bash
[auth.generic_oauth]
name = Okta
enabled = true
scopes = openid profile email
client_id = <okta application Client ID>
client_secret = <okta application Client Secret>
auth_url = https://<okta domain>/oauth2/v1/authorize
token_url = https://<okta domain>/oauth2/v1/token
api_url = https://<okta domain>/oauth2/v1/userinfo
```
## Set up OAuth2 with Bitbucket ## Set up OAuth2 with Bitbucket
```bash ```bash
@ -150,46 +132,6 @@ allowed_organizations =
api_url = https://<domain>/userinfo api_url = https://<domain>/userinfo
``` ```
## Set up OAuth2 with Azure Active Directory
1. Log in to portal.azure.com and click "Azure Active Directory" in the side menu, then click the "Properties" sub-menu item.
2. Copy the "Directory ID", this is needed for setting URLs later
3. Click "App Registrations" and add a new application registration:
- Name: Grafana
- Application type: Web app / API
- Sign-on URL: `https://<grafana domain>/login/generic_oauth`
4. Click the name of the new application to open the application details page.
5. Note down the "Application ID", this will be the OAuth client id.
6. Click "Certificates & secrets" and add a new entry under Client secrets
- Description: Grafana OAuth
- Expires: Never
7. Click Add then copy the key value, this will be the OAuth client secret.
8. Configure Grafana as follows:
```bash
[auth.generic_oauth]
name = Azure AD
enabled = true
allow_sign_up = true
client_id = <application id>
client_secret = <key value>
scopes = openid email name
auth_url = https://login.microsoftonline.com/<directory id>/oauth2/authorize
token_url = https://login.microsoftonline.com/<directory id>/oauth2/token
api_url =
team_ids =
allowed_organizations =
```
> Note: It's important to ensure that the [root_url]({{< relref "../installation/configuration/#root-url" >}}) in Grafana is set in your Azure Application Reply URLs (App -> Settings -> Reply URLs)
## Set up OAuth2 with Centrify ## Set up OAuth2 with Centrify
1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard. 1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard.

View File

@ -38,7 +38,7 @@ domain = foo.bar
Nginx is a high performance load balancer, web server and reverse proxy: https://www.nginx.com/ Nginx is a high performance load balancer, web server and reverse proxy: https://www.nginx.com/
#### Nginx configuration with HTTP and Reverse Proxy enabled #### Nginx configuration with HTTP and Reverse Proxy enabled
```bash ```nginx
server { server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
@ -62,7 +62,7 @@ root_url = https://foo.bar
Instead of http://foo.bar:3000/?orgId=1, this configuration will redirect all HTTP requests to HTTPS and re-write the URL so that port 3000 isn't visible and will result in https://foo.bar/?orgId=1 Instead of http://foo.bar:3000/?orgId=1, this configuration will redirect all HTTP requests to HTTPS and re-write the URL so that port 3000 isn't visible and will result in https://foo.bar/?orgId=1
```bash ```nginx
server { server {
listen 80; listen 80;
server_name foo.bar; server_name foo.bar;
@ -98,7 +98,7 @@ root_url = %(protocol)s://%(domain)s/grafana/
``` ```
#### Nginx configuration with sub path #### Nginx configuration with sub path
```bash ```nginx
server { server {
listen 80; listen 80;
root /usr/share/nginx/www; root /usr/share/nginx/www;

2
go.mod
View File

@ -30,7 +30,7 @@ require (
github.com/gorilla/websocket v1.4.1 github.com/gorilla/websocket v1.4.1
github.com/gosimple/slug v1.4.2 github.com/gosimple/slug v1.4.2
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4
github.com/grafana/grafana-plugin-sdk-go v0.33.0 github.com/grafana/grafana-plugin-sdk-go v0.35.0
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd
github.com/hashicorp/go-plugin v1.0.1 github.com/hashicorp/go-plugin v1.0.1
github.com/hashicorp/go-version v1.1.0 github.com/hashicorp/go-version v1.1.0

5
go.sum
View File

@ -128,8 +128,8 @@ github.com/gosimple/slug v1.4.2 h1:jDmprx3q/9Lfk4FkGZtvzDQ9Cj9eAmsjzeQGp24PeiQ=
github.com/gosimple/slug v1.4.2/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0= github.com/gosimple/slug v1.4.2/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4 h1:SPdxCL9BChFTlyi0Khv64vdCW4TMna8+sxL7+Chx+Ag=
github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To= github.com/grafana/grafana-plugin-model v0.0.0-20190930120109-1fc953a61fb4/go.mod h1:nc0XxBzjeGcrMltCDw269LoWF9S8ibhgxolCdA1R8To=
github.com/grafana/grafana-plugin-sdk-go v0.33.0 h1:+eFcOV/KioHTTRNimENZeajlkw31B+m92RNRShooPEQ= github.com/grafana/grafana-plugin-sdk-go v0.35.0 h1:IxNaNq8hN3ShQ804FURFOd1ehbKOmFROztY+8vohhW8=
github.com/grafana/grafana-plugin-sdk-go v0.33.0/go.mod h1:4rVPIvfv7SzFC0AA/8T5tmDRxIsrvDJOF9p4SrQGS1M= github.com/grafana/grafana-plugin-sdk-go v0.35.0/go.mod h1:zX/Zz/HYDAkL1NxffOZeixqPqIVVoCTWI2AuFy4J+V4=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd h1:rNuUHR+CvK1IS89MMtcF0EpcVMZtjKfPRp4MEmt/aTs= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd h1:rNuUHR+CvK1IS89MMtcF0EpcVMZtjKfPRp4MEmt/aTs=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
github.com/hashicorp/go-plugin v1.0.1 h1:4OtAfUGbnKC6yS48p0CtMX2oFYtzFZVv6rok3cRWgnE= github.com/hashicorp/go-plugin v1.0.1 h1:4OtAfUGbnKC6yS48p0CtMX2oFYtzFZVv6rok3cRWgnE=
@ -180,6 +180,7 @@ github.com/linkedin/goavro/v2 v2.9.7 h1:Vd++Rb/RKcmNJjM0HP/JJFMEWa21eUBVKPYlKehO
github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA= github.com/linkedin/goavro/v2 v2.9.7/go.mod h1:UgQUb2N/pmueQYH9bfqFioWxzYCZXSfF8Jw03O5sjqA=
github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ= github.com/lunny/log v0.0.0-20160921050905-7887c61bf0de/go.mod h1:3q8WtuPQsoRbatJuy3nvq/hRSvuBJrHHr+ybPPiNvHQ=
github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0= github.com/lunny/nodb v0.0.0-20160621015157-fc1ef06ad4af/go.mod h1:Cqz6pqow14VObJ7peltM+2n3PWOz7yTrfUuGbVFkzN0=
github.com/magefile/mage v1.9.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattetti/filebuffer v1.0.0 h1:ixTvQ0JjBTwWbdpDZ98lLrydo7KRi8xNRIi5RFszsbY= github.com/mattetti/filebuffer v1.0.0 h1:ixTvQ0JjBTwWbdpDZ98lLrydo7KRi8xNRIi5RFszsbY=
github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI= github.com/mattetti/filebuffer v1.0.0/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=

View File

@ -282,7 +282,8 @@
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [
"packages/*" "packages/*",
"plugins-bundled/internal/*"
], ],
"nohoist": [ "nohoist": [
"**/@types/*", "**/@types/*",

View File

@ -0,0 +1,4 @@
import { Registry } from '../utils';
import { FieldConfigPropertyItem } from '../types';
export class FieldConfigOptionsRegistry extends Registry<FieldConfigPropertyItem> {}

View File

@ -2,46 +2,50 @@ import {
FieldOverrideEnv, FieldOverrideEnv,
findNumericFieldMinMax, findNumericFieldMinMax,
setFieldConfigDefaults, setFieldConfigDefaults,
setDynamicConfigValue,
applyFieldOverrides, applyFieldOverrides,
} from './fieldOverrides'; } from './fieldOverrides';
import { MutableDataFrame } from '../dataframe'; import { MutableDataFrame, toDataFrame } from '../dataframe';
import { import {
FieldConfig, FieldConfig,
FieldConfigEditorRegistry, FieldConfigPropertyItem,
FieldOverrideContext,
FieldPropertyEditorItem,
GrafanaTheme, GrafanaTheme,
FieldType, FieldType,
DataFrame,
FieldConfigSource,
InterpolateFunction,
} from '../types'; } from '../types';
import { Registry } from '../utils'; import { Registry } from '../utils';
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
import { FieldMatcherID } from '../transformations';
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
const property1 = { const property1 = {
id: 'property1', // Match field properties id: 'custom.property1', // Match field properties
path: 'property1', // Match field properties
isCustom: true,
process: (value: any) => value, process: (value: any) => value,
shouldApply: () => true, shouldApply: () => true,
} as any; } as any;
const property2 = { const property2 = {
id: 'property2', // Match field properties id: 'custom.property2', // Match field properties
path: 'property2', // Match field properties
isCustom: true,
process: (value: any) => value, process: (value: any) => value,
shouldApply: () => true, shouldApply: () => true,
} as any; } as any;
const unit = { const property3 = {
id: 'unit', // Match field properties id: 'custom.property3.nested', // Match field properties
path: 'property3.nested', // Match field properties
isCustom: true,
process: (value: any) => value, process: (value: any) => value,
shouldApply: () => true, shouldApply: () => true,
} as any; } as any;
export const customFieldRegistry: FieldConfigEditorRegistry = new Registry<FieldPropertyEditorItem>(() => { export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
return [property1, property2]; return [property1, property2, property3, ...mockStandardProperties()];
});
// For the need of this test we need to mock the standard registry
// as we cannot imporrt from grafana/ui
standardFieldConfigEditorRegistry.setInit(() => {
return [unit];
}); });
describe('Global MinMax', () => { describe('Global MinMax', () => {
@ -59,6 +63,32 @@ describe('Global MinMax', () => {
}); });
describe('applyFieldOverrides', () => { describe('applyFieldOverrides', () => {
const f0 = new MutableDataFrame();
f0.add({ title: 'AAA', value: 100, value2: 1234 }, true);
f0.add({ title: 'BBB', value: -20 }, true);
f0.add({ title: 'CCC', value: 200, value2: 1000 }, true);
expect(f0.length).toEqual(3);
// Hardcode the max value
f0.fields[1].config.max = 0;
f0.fields[1].config.decimals = 6;
const src: FieldConfigSource = {
defaults: {
unit: 'xyz',
decimals: 2,
},
overrides: [
{
matcher: { id: FieldMatcherID.numeric },
properties: [
{ id: 'decimals', value: 1 }, // Numeric
{ id: 'title', value: 'Kittens' }, // Text
],
},
],
};
describe('given multiple data frames', () => { describe('given multiple data frames', () => {
const f0 = new MutableDataFrame({ const f0 = new MutableDataFrame({
name: 'A', name: 'A',
@ -72,12 +102,13 @@ describe('applyFieldOverrides', () => {
it('should add scopedVars to fields', () => { it('should add scopedVars to fields', () => {
const withOverrides = applyFieldOverrides({ const withOverrides = applyFieldOverrides({
data: [f0, f1], data: [f0, f1],
fieldOptions: { fieldConfig: {
defaults: {}, defaults: {},
overrides: [], overrides: [],
}, },
replaceVariables: (value: any) => value, replaceVariables: (value: any) => value,
theme: {} as GrafanaTheme, theme: {} as GrafanaTheme,
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
}); });
expect(withOverrides[0].fields[0].config.scopedVars).toMatchInlineSnapshot(` expect(withOverrides[0].fields[0].config.scopedVars).toMatchInlineSnapshot(`
@ -115,6 +146,83 @@ describe('applyFieldOverrides', () => {
`); `);
}); });
}); });
it('will merge FieldConfig with default values', () => {
const field: FieldConfig = {
min: 0,
max: 100,
};
const f1 = {
unit: 'ms',
dateFormat: '', // should be ignored
max: parseFloat('NOPE'), // should be ignored
min: null, // should alo be ignored!
};
const f: DataFrame = toDataFrame({
fields: [{ type: FieldType.number, name: 'x', config: field, values: [] }],
});
const processed = applyFieldOverrides({
data: [f],
fieldConfig: {
defaults: f1 as FieldConfig,
overrides: [],
},
fieldConfigRegistry: customFieldRegistry,
replaceVariables: v => v,
theme: {} as GrafanaTheme,
})[0];
const out = processed.fields[0].config;
expect(out.min).toEqual(0);
expect(out.max).toEqual(100);
expect(out.unit).toEqual('ms');
});
it('will apply field overrides', () => {
const data = applyFieldOverrides({
data: [f0], // the frame
fieldConfig: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
theme: (undefined as any) as GrafanaTheme,
fieldConfigRegistry: customFieldRegistry,
})[0];
const valueColumn = data.fields[1];
const config = valueColumn.config;
// Keep max from the original setting
expect(config.max).toEqual(0);
// Don't Automatically pick the min value
expect(config.min).toEqual(undefined);
// The default value applied
expect(config.unit).toEqual('xyz');
// The default value applied
expect(config.title).toEqual('Kittens');
// The override applied
expect(config.decimals).toEqual(1);
});
it('will apply set min/max when asked', () => {
const data = applyFieldOverrides({
data: [f0], // the frame
fieldConfig: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
theme: (undefined as any) as GrafanaTheme,
autoMinMax: true,
})[0];
const valueColumn = data.fields[1];
const config = valueColumn.config;
// Keep max from the original setting
expect(config.max).toEqual(0);
// Don't Automatically pick the min value
expect(config.min).toEqual(-20);
});
}); });
describe('setFieldConfigDefaults', () => { describe('setFieldConfigDefaults', () => {
@ -132,10 +240,11 @@ describe('setFieldConfigDefaults', () => {
unit: 'km', unit: 'km',
}; };
const context: FieldOverrideContext = { const context: FieldOverrideEnv = {
data: [] as any, data: [] as any,
field: { type: FieldType.number } as any, field: { type: FieldType.number } as any,
dataFrameIndex: 0, dataFrameIndex: 0,
fieldConfigRegistry: customFieldRegistry,
}; };
// we mutate dsFieldConfig // we mutate dsFieldConfig
@ -143,6 +252,7 @@ describe('setFieldConfigDefaults', () => {
expect(dsFieldConfig).toMatchInlineSnapshot(` expect(dsFieldConfig).toMatchInlineSnapshot(`
Object { Object {
"custom": Object {},
"decimals": 2, "decimals": 2,
"max": 100, "max": 100,
"min": 0, "min": 0,
@ -169,7 +279,7 @@ describe('setFieldConfigDefaults', () => {
data: [] as any, data: [] as any,
field: { type: FieldType.number } as any, field: { type: FieldType.number } as any,
dataFrameIndex: 0, dataFrameIndex: 0,
custom: customFieldRegistry, fieldConfigRegistry: customFieldRegistry,
}; };
// we mutate dsFieldConfig // we mutate dsFieldConfig
@ -185,3 +295,118 @@ describe('setFieldConfigDefaults', () => {
`); `);
}); });
}); });
describe('setDynamicConfigValue', () => {
it('applies dynamic config values', () => {
const config = {
title: 'test',
// custom: {
// property1: 1,
// },
};
setDynamicConfigValue(
config,
{
id: 'title',
value: 'applied',
},
{
fieldConfigRegistry: customFieldRegistry,
data: [] as any,
field: { type: FieldType.number } as any,
dataFrameIndex: 0,
}
);
expect(config.title).toEqual('applied');
});
it('applies custom dynamic config values', () => {
const config = {
custom: {
property1: 1,
},
};
setDynamicConfigValue(
config,
{
id: 'custom.property1',
value: 'applied',
},
{
fieldConfigRegistry: customFieldRegistry,
data: [] as any,
field: { type: FieldType.number } as any,
dataFrameIndex: 0,
}
);
expect(config.custom.property1).toEqual('applied');
});
it('applies nested custom dynamic config values', () => {
const config = {
custom: {
property3: {
nested: 1,
},
},
};
setDynamicConfigValue(
config,
{
id: 'custom.property3.nested',
value: 'applied',
},
{
fieldConfigRegistry: customFieldRegistry,
data: [] as any,
field: { type: FieldType.number } as any,
dataFrameIndex: 0,
}
);
expect(config.custom.property3.nested).toEqual('applied');
});
it('removes properties', () => {
const config = {
title: 'title',
custom: {
property3: {
nested: 1,
},
},
};
setDynamicConfigValue(
config,
{
id: 'custom.property3.nested',
value: undefined,
},
{
fieldConfigRegistry: customFieldRegistry,
data: [] as any,
field: { type: FieldType.number } as any,
dataFrameIndex: 0,
}
);
setDynamicConfigValue(
config,
{
id: 'title',
value: undefined,
},
{
fieldConfigRegistry: customFieldRegistry,
data: [] as any,
field: { type: FieldType.number } as any,
dataFrameIndex: 0,
}
);
expect(config.custom.property3).toEqual({});
expect(config.title).toBeUndefined();
});
});

View File

@ -7,17 +7,21 @@ import {
ThresholdsMode, ThresholdsMode,
FieldColorMode, FieldColorMode,
ColorScheme, ColorScheme,
FieldConfigEditorRegistry,
FieldOverrideContext, FieldOverrideContext,
ScopedVars, ScopedVars,
ApplyFieldOverrideOptions, ApplyFieldOverrideOptions,
FieldConfigPropertyItem,
} from '../types'; } from '../types';
import { fieldMatchers, ReducerID, reduceField } from '../transformations'; import { fieldMatchers, ReducerID, reduceField } from '../transformations';
import { FieldMatcher } from '../types/transformations'; import { FieldMatcher } from '../types/transformations';
import isNumber from 'lodash/isNumber'; import isNumber from 'lodash/isNumber';
import set from 'lodash/set';
import unset from 'lodash/unset';
import get from 'lodash/get';
import { getDisplayProcessor } from './displayProcessor'; import { getDisplayProcessor } from './displayProcessor';
import { guessFieldTypeForField } from '../dataframe'; import { guessFieldTypeForField } from '../dataframe';
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
interface OverrideProps { interface OverrideProps {
match: FieldMatcher; match: FieldMatcher;
@ -59,11 +63,13 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
return []; return [];
} }
const source = options.fieldOptions; const source = options.fieldConfig;
if (!source) { if (!source) {
return options.data; return options.data;
} }
const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry;
let range: GlobalMinMax | undefined = undefined; let range: GlobalMinMax | undefined = undefined;
// Prepare the Matchers // Prepare the Matchers
@ -105,7 +111,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
data: options.data!, data: options.data!,
dataFrameIndex: index, dataFrameIndex: index,
replaceVariables: options.replaceVariables, replaceVariables: options.replaceVariables,
custom: options.custom, fieldConfigRegistry: fieldConfigRegistry,
}; };
// Anything in the field config that's not set by the datasource // Anything in the field config that's not set by the datasource
@ -188,13 +194,12 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
} }
export interface FieldOverrideEnv extends FieldOverrideContext { export interface FieldOverrideEnv extends FieldOverrideContext {
custom?: FieldConfigEditorRegistry; fieldConfigRegistry: FieldConfigOptionsRegistry;
} }
function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) { export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) {
const reg = value.custom ? context.custom : standardFieldConfigEditorRegistry; const reg = context.fieldConfigRegistry;
const item = reg.getIfExists(value.id);
const item = reg?.getIfExists(value.prop);
if (!item || !item.shouldApply(context.field!)) { if (!item || !item.shouldApply(context.field!)) {
return; return;
} }
@ -204,19 +209,19 @@ function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, c
const remove = val === undefined || val === null; const remove = val === undefined || val === null;
if (remove) { if (remove) {
if (value.custom && config.custom) { if (item.isCustom && config.custom) {
delete config.custom[value.prop]; unset(config.custom, item.path);
} else { } else {
delete (config as any)[value.prop]; unset(config, item.path);
} }
} else { } else {
if (value.custom) { if (item.isCustom) {
if (!config.custom) { if (!config.custom) {
config.custom = {}; config.custom = {};
} }
config.custom[value.prop] = val; set(config.custom, item.path, val);
} else { } else {
(config as any)[value.prop] = val; set(config, item.path, val);
} }
} }
} }
@ -224,48 +229,39 @@ function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, c
// config -> from DS // config -> from DS
// defaults -> from Panel config // defaults -> from Panel config
export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) { export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) {
if (defaults) { for (const fieldConfigProperty of context.fieldConfigRegistry.list()) {
const keys = Object.keys(defaults); if (fieldConfigProperty.isCustom && !config.custom) {
for (const key of keys) { config.custom = {};
if (key === 'custom') {
if (!context.custom) {
continue;
}
if (!config.custom) {
config.custom = {};
}
const customKeys = Object.keys(defaults.custom!);
for (const customKey of customKeys) {
processFieldConfigValue(config.custom!, defaults.custom!, customKey, context.custom, context);
}
} else {
// when config from ds exists for a given field -> use it
processFieldConfigValue(config, defaults, key, standardFieldConfigEditorRegistry, context);
}
} }
processFieldConfigValue(
fieldConfigProperty.isCustom ? config.custom : config,
fieldConfigProperty.isCustom ? defaults.custom : defaults,
fieldConfigProperty,
context
);
} }
validateFieldConfig(config); validateFieldConfig(config);
} }
const processFieldConfigValue = ( const processFieldConfigValue = (
destination: Record<string, any>, // it's mutable destination: Record<string, any>, // it's mutable
source: Record<string, any>, source: Record<string, any>,
key: string, fieldConfigProperty: FieldConfigPropertyItem,
registry: FieldConfigEditorRegistry, context: FieldOverrideEnv
context: FieldOverrideContext
) => { ) => {
const currentConfig = destination[key]; const currentConfig = get(destination, fieldConfigProperty.path);
if (currentConfig === null || currentConfig === undefined) { if (currentConfig === null || currentConfig === undefined) {
const item = registry.getIfExists(key); const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id);
if (!item) { if (!item) {
return; return;
} }
if (item && item.shouldApply(context.field!)) { if (item && item.shouldApply(context.field!)) {
const val = item.process(source[key], context, item.settings); const val = item.process(get(source, item.path), context, item.settings);
if (val !== undefined && val !== null) { if (val !== undefined && val !== null) {
destination[key] = val; set(destination, item.path, val);
} }
} }
} }

View File

@ -3,5 +3,6 @@ export * from './displayProcessor';
export * from './scale'; export * from './scale';
export * from './standardFieldConfigEditorRegistry'; export * from './standardFieldConfigEditorRegistry';
export * from './overrides/processors'; export * from './overrides/processors';
export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
export { applyFieldOverrides, validateFieldConfig } from './fieldOverrides'; export { applyFieldOverrides, validateFieldConfig } from './fieldOverrides';

View File

@ -1,6 +1,6 @@
import { FieldConfigEditorRegistry, FieldPropertyEditorItem } from '../types/fieldOverrides';
import { Registry, RegistryItem } from '../utils/Registry'; import { Registry, RegistryItem } from '../utils/Registry';
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
export interface StandardEditorProps<TValue = any, TSettings = any> { export interface StandardEditorProps<TValue = any, TSettings = any> {
value: TValue; value: TValue;
@ -11,6 +11,6 @@ export interface StandardEditorsRegistryItem<TValue = any, TSettings = any> exte
editor: ComponentType<StandardEditorProps<TValue, TSettings>>; editor: ComponentType<StandardEditorProps<TValue, TSettings>>;
settings?: TSettings; settings?: TSettings;
} }
export const standardFieldConfigEditorRegistry: FieldConfigEditorRegistry = new Registry<FieldPropertyEditorItem>(); export const standardFieldConfigEditorRegistry = new FieldConfigOptionsRegistry();
export const standardEditorsRegistry = new Registry<StandardEditorsRegistryItem<any>>(); export const standardEditorsRegistry = new Registry<StandardEditorsRegistryItem<any>>();

View File

@ -1,11 +1,23 @@
import React from 'react'; import React from 'react';
import { identityOverrideProcessor, standardEditorsRegistry } from '../field'; import { identityOverrideProcessor, standardEditorsRegistry, standardFieldConfigEditorRegistry } from '../field';
import { PanelPlugin, standardFieldConfigProperties } from './PanelPlugin'; import { PanelPlugin } from './PanelPlugin';
import { FieldConfigProperty } from '../types'; import { FieldConfigProperty } from '../types';
describe('PanelPlugin', () => { describe('PanelPlugin', () => {
describe('declarative options', () => { describe('declarative options', () => {
beforeAll(() => { beforeAll(() => {
standardFieldConfigEditorRegistry.setInit(() => {
return [
{
id: 'min',
path: 'min',
},
{
id: 'max',
path: 'max',
},
] as any;
});
standardEditorsRegistry.setInit(() => { standardEditorsRegistry.setInit(() => {
return [ return [
{ {
@ -14,26 +26,29 @@ describe('PanelPlugin', () => {
] as any; ] as any;
}); });
}); });
test('field config UI API', () => { test('field config UI API', () => {
const panel = new PanelPlugin(() => { const panel = new PanelPlugin(() => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.setCustomFieldOptions(builder => { panel.useFieldConfig({
builder.addCustomEditor({ useCustomConfig: builder => {
id: 'custom', builder.addCustomEditor({
name: 'Custom', id: 'custom',
description: 'Custom field config property description', path: 'custom',
editor: () => <div>Editor</div>, name: 'Custom',
override: () => <div>Editor</div>, description: 'Custom field config property description',
process: identityOverrideProcessor, editor: () => <div>Editor</div>,
settings: {}, override: () => <div>Editor</div>,
shouldApply: () => true, process: identityOverrideProcessor,
}); settings: {},
shouldApply: () => true,
});
},
}); });
expect(panel.customFieldConfigs).toBeDefined(); expect(panel.fieldConfigRegistry.list()).toHaveLength(3);
expect(panel.customFieldConfigs!.list()).toHaveLength(1);
}); });
test('options UI API', () => { test('options UI API', () => {
@ -44,6 +59,7 @@ describe('PanelPlugin', () => {
panel.setPanelOptions(builder => { panel.setPanelOptions(builder => {
builder.addCustomEditor({ builder.addCustomEditor({
id: 'option', id: 'option',
path: 'option',
name: 'Option editor', name: 'Option editor',
description: 'Option editor description', description: 'Option editor description',
editor: () => <div>Editor</div>, editor: () => <div>Editor</div>,
@ -66,18 +82,19 @@ describe('PanelPlugin', () => {
panel.setPanelOptions(builder => { panel.setPanelOptions(builder => {
builder builder
.addNumberInput({ .addNumberInput({
id: 'numericOption', path: 'numericOption',
name: 'Option editor', name: 'Option editor',
description: 'Option editor description', description: 'Option editor description',
defaultValue: 10, defaultValue: 10,
}) })
.addNumberInput({ .addNumberInput({
id: 'numericOptionNoDefault', path: 'numericOptionNoDefault',
name: 'Option editor', name: 'Option editor',
description: 'Option editor description', description: 'Option editor description',
}) })
.addCustomEditor({ .addCustomEditor({
id: 'customOption', id: 'customOption',
path: 'customOption',
name: 'Option editor', name: 'Option editor',
description: 'Option editor description', description: 'Option editor description',
editor: () => <div>Editor</div>, editor: () => <div>Editor</div>,
@ -101,7 +118,7 @@ describe('PanelPlugin', () => {
panel.setPanelOptions(builder => { panel.setPanelOptions(builder => {
builder.addNumberInput({ builder.addNumberInput({
id: 'numericOption.nested', path: 'numericOption.nested',
name: 'Option editor', name: 'Option editor',
description: 'Option editor description', description: 'Option editor description',
defaultValue: 10, defaultValue: 10,
@ -122,30 +139,33 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.setCustomFieldOptions(builder => { panel.useFieldConfig({
builder useCustomConfig: builder => {
.addNumberInput({ builder
id: 'numericOption', .addNumberInput({
name: 'Option editor', path: 'numericOption',
description: 'Option editor description', name: 'Option editor',
defaultValue: 10, description: 'Option editor description',
}) defaultValue: 10,
.addNumberInput({ })
id: 'numericOptionNoDefault', .addNumberInput({
name: 'Option editor', path: 'numericOptionNoDefault',
description: 'Option editor description', name: 'Option editor',
}) description: 'Option editor description',
.addCustomEditor({ })
id: 'customOption', .addCustomEditor({
name: 'Option editor', id: 'customOption',
description: 'Option editor description', path: 'customOption',
editor: () => <div>Editor</div>, name: 'Option editor',
override: () => <div>Override editor</div>, description: 'Option editor description',
process: identityOverrideProcessor, editor: () => <div>Editor</div>,
shouldApply: () => true, override: () => <div>Override editor</div>,
settings: {}, process: identityOverrideProcessor,
defaultValue: { value: 'Custom default value' }, shouldApply: () => true,
}); settings: {},
defaultValue: { value: 'Custom default value' },
});
},
}); });
const expectedDefaults = { const expectedDefaults = {
@ -161,13 +181,15 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.setCustomFieldOptions(builder => { panel.useFieldConfig({
builder.addNumberInput({ useCustomConfig: builder => {
id: 'numericOption.nested', builder.addNumberInput({
name: 'Option editor', path: 'numericOption.nested',
description: 'Option editor description', name: 'Option editor',
defaultValue: 10, description: 'Option editor description',
}); defaultValue: 10,
});
},
}); });
const expectedDefaults = { const expectedDefaults = {
@ -184,8 +206,8 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.useStandardFieldConfig(); panel.useFieldConfig();
expect(panel.standardFieldConfigProperties).toEqual(Array.from(standardFieldConfigProperties.keys())); expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
}); });
test('selected standard config', () => { test('selected standard config', () => {
@ -193,8 +215,10 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.useStandardFieldConfig([FieldConfigProperty.Min, FieldConfigProperty.Thresholds]); panel.useFieldConfig({
expect(panel.standardFieldConfigProperties).toEqual(['min', 'thresholds']); standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max],
});
expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
}); });
describe('default values', () => { describe('default values', () => {
@ -203,17 +227,21 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.useStandardFieldConfig([FieldConfigProperty.Color, FieldConfigProperty.Min], { panel.useFieldConfig({
[FieldConfigProperty.Color]: '#ff00ff', standardOptions: [FieldConfigProperty.Max, FieldConfigProperty.Min],
[FieldConfigProperty.Min]: 10, standardOptionsDefaults: {
[FieldConfigProperty.Max]: 20,
[FieldConfigProperty.Min]: 10,
},
}); });
expect(panel.standardFieldConfigProperties).toEqual(['color', 'min']); expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
expect(panel.fieldConfigDefaults).toEqual({ expect(panel.fieldConfigDefaults).toEqual({
defaults: { defaults: {
min: 10, min: 10,
color: '#ff00ff', max: 20,
custom: {},
}, },
overrides: [], overrides: [],
}); });
@ -224,16 +252,20 @@ describe('PanelPlugin', () => {
return <div>Panel</div>; return <div>Panel</div>;
}); });
panel.useStandardFieldConfig([FieldConfigProperty.Color], { panel.useFieldConfig({
[FieldConfigProperty.Color]: '#ff00ff', standardOptions: [FieldConfigProperty.Max],
[FieldConfigProperty.Min]: 10, standardOptionsDefaults: {
[FieldConfigProperty.Max]: 20,
[FieldConfigProperty.Min]: 10,
},
}); });
expect(panel.standardFieldConfigProperties).toEqual(['color']); expect(panel.fieldConfigRegistry.list()).toHaveLength(1);
expect(panel.fieldConfigDefaults).toEqual({ expect(panel.fieldConfigDefaults).toEqual({
defaults: { defaults: {
color: '#ff00ff', max: 20,
custom: {},
}, },
overrides: [], overrides: [],
}); });

View File

@ -1,5 +1,4 @@
import { import {
FieldConfigEditorRegistry,
FieldConfigSource, FieldConfigSource,
GrafanaPlugin, GrafanaPlugin,
PanelEditorProps, PanelEditorProps,
@ -9,55 +8,34 @@ import {
PanelProps, PanelProps,
PanelTypeChangedHandler, PanelTypeChangedHandler,
FieldConfigProperty, FieldConfigProperty,
ThresholdsMode,
} from '../types'; } from '../types';
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
import { ComponentClass, ComponentType } from 'react'; import { ComponentClass, ComponentType } from 'react';
import set from 'lodash/set'; import set from 'lodash/set';
import { deprecationWarning } from '../utils'; import { deprecationWarning } from '../utils';
import { FieldConfigOptionsRegistry, standardFieldConfigEditorRegistry } from '../field';
export const allStandardFieldConfigProperties: FieldConfigProperty[] = [ export interface SetFieldConfigOptionsArgs<TFieldConfigOptions = any> {
FieldConfigProperty.Min, standardOptions?: FieldConfigProperty[];
FieldConfigProperty.Max, standardOptionsDefaults?: Partial<Record<FieldConfigProperty, any>>;
FieldConfigProperty.Title, useCustomConfig?: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void;
FieldConfigProperty.Unit, }
FieldConfigProperty.Decimals,
FieldConfigProperty.NoValue,
FieldConfigProperty.Color,
FieldConfigProperty.Thresholds,
FieldConfigProperty.Mappings,
FieldConfigProperty.Links,
];
export const standardFieldConfigDefaults: Partial<Record<FieldConfigProperty, any>> = {
[FieldConfigProperty.Thresholds]: {
mode: ThresholdsMode.Absolute,
steps: [
{ value: -Infinity, color: 'green' },
{ value: 80, color: 'red' },
],
},
[FieldConfigProperty.Mappings]: [],
};
export const standardFieldConfigProperties = new Map(allStandardFieldConfigProperties.map(p => [p, undefined]));
export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = any> extends GrafanaPlugin< export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = any> extends GrafanaPlugin<
PanelPluginMeta PanelPluginMeta
> { > {
private _defaults?: TOptions; private _defaults?: TOptions;
private _standardFieldConfigProperties?: Map<FieldConfigProperty, any>;
private _fieldConfigDefaults: FieldConfigSource<TFieldConfigOptions> = { private _fieldConfigDefaults: FieldConfigSource<TFieldConfigOptions> = {
defaults: {}, defaults: {},
overrides: [], overrides: [],
}; };
private _customFieldConfigs?: FieldConfigEditorRegistry;
private customFieldConfigsUIBuilder = new FieldConfigEditorBuilder<TFieldConfigOptions>(); private _fieldConfigRegistry?: FieldConfigOptionsRegistry;
private registerCustomFieldConfigs?: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void; private _initConfigRegistry = () => {
return new FieldConfigOptionsRegistry();
};
private _optionEditors?: PanelOptionEditorsRegistry; private _optionEditors?: PanelOptionEditorsRegistry;
private optionsUIBuilder = new PanelOptionsEditorBuilder<TOptions>();
private registerOptionEditors?: (builder: PanelOptionsEditorBuilder<TOptions>) => void; private registerOptionEditors?: (builder: PanelOptionsEditorBuilder<TOptions>) => void;
panel: ComponentType<PanelProps<TOptions>>; panel: ComponentType<PanelProps<TOptions>>;
@ -94,39 +72,21 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
} }
get fieldConfigDefaults(): FieldConfigSource<TFieldConfigOptions> { get fieldConfigDefaults(): FieldConfigSource<TFieldConfigOptions> {
let customPropertiesDefaults = this._fieldConfigDefaults.defaults.custom; const configDefaults = this._fieldConfigDefaults.defaults;
configDefaults.custom = {} as TFieldConfigOptions;
if (!customPropertiesDefaults) { for (const option of this.fieldConfigRegistry.list()) {
customPropertiesDefaults = {} as TFieldConfigOptions; set(configDefaults, option.id, option.defaultValue);
}
const editors = this.customFieldConfigs;
if (editors && editors.list().length !== 0) {
for (const editor of editors.list()) {
set(customPropertiesDefaults, editor.id, editor.defaultValue);
}
} }
return { return {
defaults: { defaults: {
...(this._standardFieldConfigProperties ? Object.fromEntries(this._standardFieldConfigProperties) : {}), ...configDefaults,
custom:
Object.keys(customPropertiesDefaults).length > 0
? {
...customPropertiesDefaults,
}
: undefined,
...this._fieldConfigDefaults.defaults,
}, },
// TODO: not sure yet what about overrides, if anything
overrides: this._fieldConfigDefaults.overrides, overrides: this._fieldConfigDefaults.overrides,
}; };
} }
get standardFieldConfigProperties() {
return this._standardFieldConfigProperties ? Array.from(this._standardFieldConfigProperties.keys()) : [];
}
/** /**
* @deprecated setDefaults is deprecated in favor of setPanelOptions * @deprecated setDefaults is deprecated in favor of setPanelOptions
*/ */
@ -136,19 +96,19 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
return this; return this;
} }
get customFieldConfigs() { get fieldConfigRegistry() {
if (!this._customFieldConfigs && this.registerCustomFieldConfigs) { if (!this._fieldConfigRegistry) {
this.registerCustomFieldConfigs(this.customFieldConfigsUIBuilder); this._fieldConfigRegistry = this._initConfigRegistry();
this._customFieldConfigs = this.customFieldConfigsUIBuilder.getRegistry();
} }
return this._customFieldConfigs; return this._fieldConfigRegistry;
} }
get optionEditors() { get optionEditors() {
if (!this._optionEditors && this.registerOptionEditors) { if (!this._optionEditors && this.registerOptionEditors) {
this.registerOptionEditors(this.optionsUIBuilder); const builder = new PanelOptionsEditorBuilder<TOptions>();
this._optionEditors = this.optionsUIBuilder.getRegistry(); this.registerOptionEditors(builder);
this._optionEditors = builder.getRegistry();
} }
return this._optionEditors; return this._optionEditors;
@ -188,47 +148,6 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
return this; return this;
} }
/**
* Enables custom field properties editor creation
*
* @example
* ```typescript
*
* import { ShapePanel } from './ShapePanel';
*
* interface ShapePanelOptions {}
*
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
* .setCustomFieldOptions(builder => {
* builder
* .addNumberInput({
* id: 'shapeBorderWidth',
* name: 'Border width',
* description: 'Border width of the shape',
* settings: {
* min: 1,
* max: 5,
* },
* })
* .addSelect({
* id: 'displayMode',
* name: 'Display mode',
* description: 'How the shape shout be rendered'
* settings: {
* options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }]
* },
* })
* })
* ```
*
* @public
**/
setCustomFieldOptions(builder: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void) {
// builder is applied lazily when custom field configs are accessed
this.registerCustomFieldConfigs = builder;
return this;
}
/** /**
* Enables panel options editor creation * Enables panel options editor creation
* *
@ -277,44 +196,93 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
* *
* // when plugin should use all standard options * // when plugin should use all standard options
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel) * export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
* .useStandardFieldConfig(); * .useFieldConfig();
* *
* // when plugin should only display specific standard options * // when plugin should only display specific standard options
* // note, that options will be displayed in the order they are provided * // note, that options will be displayed in the order they are provided
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel) * export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
* .useStandardFieldConfig([FieldConfigProperty.Min, FieldConfigProperty.Max, FieldConfigProperty.Links]); * .useFieldConfig({
* standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max]
* });
* *
* // when standard option's default value needs to be provided * // when standard option's default value needs to be provided
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel) * export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
* .useStandardFieldConfig([FieldConfigProperty.Min, FieldConfigProperty.Max], { * .useFieldConfig({
* [FieldConfigProperty.Min]: 20, * standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max],
* [FieldConfigProperty.Max]: 100 * standardOptionsDefaults: {
* [FieldConfigProperty.Min]: 20,
* [FieldConfigProperty.Max]: 100
* }
* });
*
* // when custom field config options needs to be provided
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
* .useFieldConfig({
* useCustomConfig: builder => {
builder
* .addNumberInput({
* id: 'shapeBorderWidth',
* name: 'Border width',
* description: 'Border width of the shape',
* settings: {
* min: 1,
* max: 5,
* },
* })
* .addSelect({
* id: 'displayMode',
* name: 'Display mode',
* description: 'How the shape shout be rendered'
* settings: {
* options: [{value: 'fill', label: 'Fill' }, {value: 'transparent', label: 'Transparent }]
* },
* })
* },
* }); * });
* *
* ``` * ```
* *
* @public * @public
*/ */
useStandardFieldConfig( useFieldConfig(config?: SetFieldConfigOptionsArgs<TFieldConfigOptions>) {
properties?: FieldConfigProperty[] | null, // builder is applied lazily when custom field configs are accessed
customDefaults?: Partial<Record<FieldConfigProperty, any>> this._initConfigRegistry = () => {
) { const registry = new FieldConfigOptionsRegistry();
if (!properties) {
this._standardFieldConfigProperties = standardFieldConfigProperties;
return this;
} else {
this._standardFieldConfigProperties = new Map(properties.map(p => [p, standardFieldConfigProperties.get(p)]));
}
const defaults = customDefaults ?? standardFieldConfigDefaults; // Add custom options
if (config && config.useCustomConfig) {
const builder = new FieldConfigEditorBuilder<TFieldConfigOptions>();
config.useCustomConfig(builder);
if (defaults) { for (const customProp of builder.getRegistry().list()) {
Object.keys(defaults).map(k => { customProp.isCustom = true;
if (properties.indexOf(k as FieldConfigProperty) > -1) { // need to do something to make the custom items not conflict with standard ones
this._standardFieldConfigProperties!.set(k as FieldConfigProperty, defaults[k as FieldConfigProperty]); // problem is id (registry index) is used as property path
// so sort of need a property path on the FieldPropertyEditorItem
customProp.id = 'custom.' + customProp.id;
registry.register(customProp);
} }
}); }
}
if (config && config.standardOptions) {
for (const standardOption of config.standardOptions) {
const standardEditor = standardFieldConfigEditorRegistry.get(standardOption);
registry.register({
...standardEditor,
defaultValue:
(config.standardOptionsDefaults && config.standardOptionsDefaults[standardOption]) ||
standardEditor.defaultValue,
});
}
} else {
for (const fieldConfigProp of standardFieldConfigEditorRegistry.list()) {
registry.register(fieldConfigProp);
}
}
return registry;
};
return this; return this;
} }
} }

View File

@ -7,7 +7,10 @@ import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } f
import { noopTransformer } from './transformers/noop'; import { noopTransformer } from './transformers/noop';
import { DataTransformerConfig, DataTransformerInfo } from '../types/transformations'; import { DataTransformerConfig, DataTransformerInfo } from '../types/transformations';
import { filterFramesByRefIdTransformer } from './transformers/filterByRefId'; import { filterFramesByRefIdTransformer } from './transformers/filterByRefId';
import { orderFieldsTransformer } from './transformers/order';
import { organizeFieldsTransformer } from './transformers/organize';
import { seriesToColumnsTransformer } from './transformers/seriesToColumns'; import { seriesToColumnsTransformer } from './transformers/seriesToColumns';
import { renameFieldsTransformer } from './transformers/rename';
// Initalize the Registry // Initalize the Registry
@ -67,9 +70,12 @@ export const transformersRegistry = new TransformerRegistry(() => [
filterFieldsByNameTransformer, filterFieldsByNameTransformer,
filterFramesTransformer, filterFramesTransformer,
filterFramesByRefIdTransformer, filterFramesByRefIdTransformer,
orderFieldsTransformer,
organizeFieldsTransformer,
appendTransformer, appendTransformer,
reduceTransformer, reduceTransformer,
seriesToColumnsTransformer, seriesToColumnsTransformer,
renameFieldsTransformer,
]); ]);
export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions }; export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions };

View File

@ -3,6 +3,9 @@ export enum DataTransformerID {
append = 'append', // Merge all series together append = 'append', // Merge all series together
// rotate = 'rotate', // Columns to rows // rotate = 'rotate', // Columns to rows
reduce = 'reduce', // Run calculations on fields reduce = 'reduce', // Run calculations on fields
order = 'order', // order fields based on user configuration
organize = 'organize', // order, rename and filter based on user configuration
rename = 'rename', // rename field based on user configuration
seriesToColumns = 'seriesToColumns', // former table transform timeseries_to_columns seriesToColumns = 'seriesToColumns', // former table transform timeseries_to_columns
filterFields = 'filterFields', // Pick some fields (keep all frames) filterFields = 'filterFields', // Pick some fields (keep all frames)

View File

@ -0,0 +1,148 @@
import {
ArrayVector,
DataTransformerConfig,
DataTransformerID,
FieldType,
toDataFrame,
transformDataFrame,
} from '@grafana/data';
import { OrderFieldsTransformerOptions } from './order';
describe('Order Transformer', () => {
describe('when consistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should order according to config', () => {
const cfg: DataTransformerConfig<OrderFieldsTransformerOptions> = {
id: DataTransformerID.order,
options: {
indexByName: {
time: 2,
temperature: 0,
humidity: 1,
},
},
};
const ordered = transformDataFrame([cfg], [data])[0];
expect(ordered.fields).toEqual([
{
config: {},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'humidity',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
{
config: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
]);
});
});
describe('when inconsistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should append fields missing in config at the end', () => {
const cfg: DataTransformerConfig<OrderFieldsTransformerOptions> = {
id: DataTransformerID.order,
options: {
indexByName: {
time: 2,
temperature: 0,
humidity: 1,
},
},
};
const ordered = transformDataFrame([cfg], [data])[0];
expect(ordered.fields).toEqual([
{
config: {},
name: 'humidity',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
{
config: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'pressure',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
]);
});
});
describe('when transforming with empty configuration', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should keep the same order as in the incoming data', () => {
const cfg: DataTransformerConfig<OrderFieldsTransformerOptions> = {
id: DataTransformerID.order,
options: {
indexByName: {},
},
};
const ordered = transformDataFrame([cfg], [data])[0];
expect(ordered.fields).toEqual([
{
config: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'pressure',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'humidity',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
]);
});
});
});

View File

@ -0,0 +1,58 @@
import { DataTransformerID } from './ids';
import { DataTransformerInfo } from '../../types/transformations';
import { DataFrame } from '../..';
import { Field } from '../../types';
export interface OrderFieldsTransformerOptions {
indexByName: Record<string, number>;
}
export const orderFieldsTransformer: DataTransformerInfo<OrderFieldsTransformerOptions> = {
id: DataTransformerID.order,
name: 'Order fields by name',
description: 'Order fields based on configuration given by user',
defaultOptions: {
indexByName: {},
},
/**
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
transformer: (options: OrderFieldsTransformerOptions) => {
const orderer = createFieldsOrderer(options.indexByName);
return (data: DataFrame[]) => {
if (!Array.isArray(data) || data.length === 0) {
return data;
}
return data.map(frame => ({
...frame,
fields: orderer(frame.fields),
}));
};
},
};
export const createFieldsComparer = (indexByName: Record<string, number>) => (a: string, b: string) => {
return indexOfField(a, indexByName) - indexOfField(b, indexByName);
};
const createFieldsOrderer = (indexByName: Record<string, number>) => (fields: Field[]) => {
if (!Array.isArray(fields) || fields.length === 0) {
return fields;
}
if (!indexByName || Object.keys(indexByName).length === 0) {
return fields;
}
const comparer = createFieldsComparer(indexByName);
return fields.sort((a, b) => comparer(a.name, b.name));
};
const indexOfField = (fieldName: string, indexByName: Record<string, number>) => {
if (Number.isInteger(indexByName[fieldName])) {
return indexByName[fieldName];
}
return Number.MAX_SAFE_INTEGER;
};

View File

@ -0,0 +1,105 @@
import {
ArrayVector,
DataTransformerConfig,
DataTransformerID,
FieldType,
toDataFrame,
transformDataFrame,
} from '@grafana/data';
import { OrganizeFieldsTransformerOptions } from './organize';
describe('OrganizeFields Transformer', () => {
describe('when consistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should order and filter according to config', () => {
const cfg: DataTransformerConfig<OrganizeFieldsTransformerOptions> = {
id: DataTransformerID.organize,
options: {
indexByName: {
time: 2,
temperature: 0,
humidity: 1,
},
excludeByName: {
time: true,
},
renameByName: {
humidity: 'renamed_humidity',
},
},
};
const organized = transformDataFrame([cfg], [data])[0];
expect(organized.fields).toEqual([
{
config: {},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'renamed_humidity',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
]);
});
});
describe('when inconsistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should append fields missing in config at the end', () => {
const cfg: DataTransformerConfig<OrganizeFieldsTransformerOptions> = {
id: DataTransformerID.organize,
options: {
indexByName: {
time: 2,
temperature: 0,
humidity: 1,
},
excludeByName: {
humidity: true,
},
renameByName: {
time: 'renamed_time',
},
},
};
const organized = transformDataFrame([cfg], [data])[0];
expect(organized.fields).toEqual([
{
config: {},
name: 'renamed_time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'pressure',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
]);
});
});
});

View File

@ -0,0 +1,53 @@
import { DataTransformerID } from './ids';
import { DataTransformerInfo } from '../../types/transformations';
import { OrderFieldsTransformerOptions, orderFieldsTransformer } from './order';
import { filterFieldsByNameTransformer } from './filterByName';
import { DataFrame } from '../..';
import { RenameFieldsTransformerOptions, renameFieldsTransformer } from './rename';
export interface OrganizeFieldsTransformerOptions
extends OrderFieldsTransformerOptions,
RenameFieldsTransformerOptions {
excludeByName: Record<string, boolean>;
}
export const organizeFieldsTransformer: DataTransformerInfo<OrganizeFieldsTransformerOptions> = {
id: DataTransformerID.organize,
name: 'Organize fields by name',
description: 'Order, filter and rename fields based on configuration given by user',
defaultOptions: {
excludeByName: {},
indexByName: {},
renameByName: {},
},
/**
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
transformer: (options: OrganizeFieldsTransformerOptions) => {
const rename = renameFieldsTransformer.transformer(options);
const order = orderFieldsTransformer.transformer(options);
const filter = filterFieldsByNameTransformer.transformer({
exclude: mapToExcludeRegexp(options.excludeByName),
});
return (data: DataFrame[]) => rename(order(filter(data)));
},
};
const mapToExcludeRegexp = (excludeByName: Record<string, boolean>): string | undefined => {
if (!excludeByName) {
return undefined;
}
const fieldsToExclude = Object.keys(excludeByName)
.filter(name => excludeByName[name])
.join('|');
if (fieldsToExclude.length === 0) {
return undefined;
}
return `^(${fieldsToExclude})$`;
};

View File

@ -0,0 +1,148 @@
import {
ArrayVector,
DataTransformerConfig,
DataTransformerID,
FieldType,
toDataFrame,
transformDataFrame,
} from '@grafana/data';
import { RenameFieldsTransformerOptions } from './rename';
describe('Rename Transformer', () => {
describe('when consistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'temperature', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should rename according to config', () => {
const cfg: DataTransformerConfig<RenameFieldsTransformerOptions> = {
id: DataTransformerID.rename,
options: {
renameByName: {
time: 'Total time',
humidity: 'Moistiness',
temperature: 'how cold is it?',
},
},
};
const renamed = transformDataFrame([cfg], [data])[0];
expect(renamed.fields).toEqual([
{
config: {},
name: 'Total time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'how cold is it?',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'Moistiness',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
]);
});
});
describe('when inconsistent data is received', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should not rename fields missing in config', () => {
const cfg: DataTransformerConfig<RenameFieldsTransformerOptions> = {
id: DataTransformerID.rename,
options: {
renameByName: {
time: 'ttl',
temperature: 'temp',
humidity: 'hum',
},
},
};
const renamed = transformDataFrame([cfg], [data])[0];
expect(renamed.fields).toEqual([
{
config: {},
name: 'ttl',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'pressure',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'hum',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
]);
});
});
describe('when transforming with empty configuration', () => {
const data = toDataFrame({
name: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: [3000, 4000, 5000, 6000] },
{ name: 'pressure', type: FieldType.number, values: [10.3, 10.4, 10.5, 10.6] },
{ name: 'humidity', type: FieldType.number, values: [10000.3, 10000.4, 10000.5, 10000.6] },
],
});
it('should keep the same names as in the incoming data', () => {
const cfg: DataTransformerConfig<RenameFieldsTransformerOptions> = {
id: DataTransformerID.rename,
options: {
renameByName: {},
},
};
const renamed = transformDataFrame([cfg], [data])[0];
expect(renamed.fields).toEqual([
{
config: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([3000, 4000, 5000, 6000]),
},
{
config: {},
name: 'pressure',
type: FieldType.number,
values: new ArrayVector([10.3, 10.4, 10.5, 10.6]),
},
{
config: {},
name: 'humidity',
type: FieldType.number,
values: new ArrayVector([10000.3, 10000.4, 10000.5, 10000.6]),
},
]);
});
});
});

View File

@ -0,0 +1,54 @@
import { DataTransformerID } from './ids';
import { DataTransformerInfo } from '../../types/transformations';
import { DataFrame, Field } from '../..';
export interface RenameFieldsTransformerOptions {
renameByName: Record<string, string>;
}
export const renameFieldsTransformer: DataTransformerInfo<RenameFieldsTransformerOptions> = {
id: DataTransformerID.rename,
name: 'Rename fields by name',
description: 'Rename fields based on configuration given by user',
defaultOptions: {
renameByName: {},
},
/**
* Return a modified copy of the series. If the transform is not or should not
* be applied, just return the input series
*/
transformer: (options: RenameFieldsTransformerOptions) => {
const renamer = createRenamer(options.renameByName);
return (data: DataFrame[]) => {
if (!Array.isArray(data) || data.length === 0) {
return data;
}
return data.map(frame => ({
...frame,
fields: renamer(frame.fields),
}));
};
},
};
const createRenamer = (renameByName: Record<string, string>) => (fields: Field[]): Field[] => {
if (!renameByName || Object.keys(renameByName).length === 0) {
return fields;
}
return fields.map(field => {
const renameTo = renameByName[field.name];
if (typeof renameTo !== 'string' || renameTo.length === 0) {
return field;
}
return {
...field,
name: renameTo,
};
});
};

View File

@ -6,7 +6,7 @@ import { NumberFieldConfigSettings, SelectFieldConfigSettings, StringFieldConfig
* Option editor registry item * Option editor registry item
*/ */
export interface OptionsEditorItem<TOptions, TSettings, TEditorProps, TValue> extends RegistryItem { export interface OptionsEditorItem<TOptions, TSettings, TEditorProps, TValue> extends RegistryItem {
id: (keyof TOptions & string) | string; path: (keyof TOptions & string) | string;
editor: ComponentType<TEditorProps>; editor: ComponentType<TEditorProps>;
settings?: TSettings; settings?: TSettings;
defaultValue?: TValue; defaultValue?: TValue;

View File

@ -15,9 +15,8 @@ import { StandardEditorProps } from '../field';
import { OptionsEditorItem } from './OptionsUIRegistryBuilder'; import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
export interface DynamicConfigValue { export interface DynamicConfigValue {
prop: string; id: string;
value?: any; value?: any;
custom?: boolean;
} }
export interface ConfigOverrideRule { export interface ConfigOverrideRule {
@ -43,19 +42,19 @@ export interface FieldOverrideContext {
export interface FieldConfigEditorProps<TValue, TSettings> export interface FieldConfigEditorProps<TValue, TSettings>
extends Omit<StandardEditorProps<TValue, TSettings>, 'item'> { extends Omit<StandardEditorProps<TValue, TSettings>, 'item'> {
item: FieldPropertyEditorItem<TValue, TSettings>; // The property info item: FieldConfigPropertyItem<TValue, TSettings>; // The property info
value: TValue; value: TValue;
context: FieldOverrideContext; context: FieldOverrideContext;
onChange: (value?: TValue) => void; onChange: (value?: TValue) => void;
} }
export interface FieldOverrideEditorProps<TValue, TSettings> extends Omit<StandardEditorProps<TValue>, 'item'> { export interface FieldOverrideEditorProps<TValue, TSettings> extends Omit<StandardEditorProps<TValue>, 'item'> {
item: FieldPropertyEditorItem<TValue, TSettings>; item: FieldConfigPropertyItem<TValue, TSettings>;
context: FieldOverrideContext; context: FieldOverrideContext;
} }
export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any> { export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any> {
id: (keyof TOptions & string) | string; path: (keyof TOptions & string) | string;
name: string; name: string;
description: string; description: string;
settings?: TSettings; settings?: TSettings;
@ -63,11 +62,14 @@ export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any
defaultValue?: TValue; defaultValue?: TValue;
} }
export interface FieldPropertyEditorItem<TOptions = any, TValue = any, TSettings extends {} = any> export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings extends {} = any>
extends OptionsEditorItem<TOptions, TSettings, FieldConfigEditorProps<TValue, TSettings>, TValue> { extends OptionsEditorItem<TOptions, TSettings, FieldConfigEditorProps<TValue, TSettings>, TValue> {
// An editor that can be filled in with context info (template variables etc) // An editor that can be filled in with context info (template variables etc)
override: ComponentType<FieldOverrideEditorProps<TValue, TSettings>>; override: ComponentType<FieldOverrideEditorProps<TValue, TSettings>>;
/** true for plugin field config properties */
isCustom?: boolean;
// Convert the override value to a well typed value // Convert the override value to a well typed value
process: (value: any, context: FieldOverrideContext, settings?: TSettings) => TValue | undefined | null; process: (value: any, context: FieldOverrideContext, settings?: TSettings) => TValue | undefined | null;
@ -75,17 +77,14 @@ export interface FieldPropertyEditorItem<TOptions = any, TValue = any, TSettings
shouldApply: (field: Field) => boolean; shouldApply: (field: Field) => boolean;
} }
export type FieldConfigEditorRegistry = Registry<FieldPropertyEditorItem>;
export interface ApplyFieldOverrideOptions { export interface ApplyFieldOverrideOptions {
data?: DataFrame[]; data?: DataFrame[];
fieldOptions: FieldConfigSource; fieldConfig: FieldConfigSource;
replaceVariables: InterpolateFunction; replaceVariables: InterpolateFunction;
theme: GrafanaTheme; theme: GrafanaTheme;
timeZone?: TimeZone; timeZone?: TimeZone;
autoMinMax?: boolean; autoMinMax?: boolean;
standard?: FieldConfigEditorRegistry; fieldConfigRegistry?: Registry<FieldConfigPropertyItem>;
custom?: FieldConfigEditorRegistry;
} }
export enum FieldConfigProperty { export enum FieldConfigProperty {

View File

@ -19,6 +19,7 @@ export * from './datasource';
export * from './panel'; export * from './panel';
export * from './plugin'; export * from './plugin';
export * from './thresholds'; export * from './thresholds';
export * from './templateVars';
export * from './fieldColor'; export * from './fieldColor';
export * from './theme'; export * from './theme';
export * from './orgs'; export * from './orgs';

View File

@ -119,7 +119,7 @@ export interface PanelOptionsEditorItem<TOptions = any, TValue = any, TSettings
extends OptionsEditorItem<TOptions, TSettings, PanelOptionsEditorProps<TValue>, TValue> {} extends OptionsEditorItem<TOptions, TSettings, PanelOptionsEditorProps<TValue>, TValue> {}
export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = any> { export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = any> {
id: (keyof TOptions & string) | string; path: (keyof TOptions & string) | string;
name: string; name: string;
description: string; description: string;
settings?: TSettings; settings?: TSettings;

View File

@ -0,0 +1,7 @@
export type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom';
export interface VariableModel {
type: VariableType;
name: string;
label: string | null;
}

View File

@ -1,7 +1,7 @@
import { import {
FieldType, FieldType,
FieldConfigEditorProps, FieldConfigEditorProps,
FieldPropertyEditorItem, FieldConfigPropertyItem,
PanelOptionsEditorConfig, PanelOptionsEditorConfig,
PanelOptionsEditorItem, PanelOptionsEditorItem,
FieldConfigEditorConfig, FieldConfigEditorConfig,
@ -29,11 +29,12 @@ import {
export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder< export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder<
TOptions, TOptions,
FieldConfigEditorProps<any, any>, FieldConfigEditorProps<any, any>,
FieldPropertyEditorItem<TOptions> FieldConfigPropertyItem<TOptions>
> { > {
addNumberInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) { addNumberInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
override: standardEditorsRegistry.get('number').editor as any, override: standardEditorsRegistry.get('number').editor as any,
editor: standardEditorsRegistry.get('number').editor as any, editor: standardEditorsRegistry.get('number').editor as any,
process: numberOverrideProcessor, process: numberOverrideProcessor,
@ -45,6 +46,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
addTextInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & StringFieldConfigSettings, string>) { addTextInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & StringFieldConfigSettings, string>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
override: standardEditorsRegistry.get('text').editor as any, override: standardEditorsRegistry.get('text').editor as any,
editor: standardEditorsRegistry.get('text').editor as any, editor: standardEditorsRegistry.get('text').editor as any,
process: stringOverrideProcessor, process: stringOverrideProcessor,
@ -58,6 +60,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
override: standardEditorsRegistry.get('select').editor as any, override: standardEditorsRegistry.get('select').editor as any,
editor: standardEditorsRegistry.get('select').editor as any, editor: standardEditorsRegistry.get('select').editor as any,
process: selectOverrideProcessor, process: selectOverrideProcessor,
@ -70,6 +73,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
addRadio<TOption, TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, TOption>) { addRadio<TOption, TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, TOption>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
override: standardEditorsRegistry.get('radio').editor as any, override: standardEditorsRegistry.get('radio').editor as any,
editor: standardEditorsRegistry.get('radio').editor as any, editor: standardEditorsRegistry.get('radio').editor as any,
process: selectOverrideProcessor, process: selectOverrideProcessor,
@ -82,6 +86,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
addBooleanSwitch<TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, boolean>) { addBooleanSwitch<TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, boolean>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('boolean').editor as any, editor: standardEditorsRegistry.get('boolean').editor as any,
override: standardEditorsRegistry.get('boolean').editor as any, override: standardEditorsRegistry.get('boolean').editor as any,
process: booleanOverrideProcessor, process: booleanOverrideProcessor,
@ -95,6 +100,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('color').editor as any, editor: standardEditorsRegistry.get('color').editor as any,
override: standardEditorsRegistry.get('color').editor as any, override: standardEditorsRegistry.get('color').editor as any,
process: identityOverrideProcessor, process: identityOverrideProcessor,
@ -108,6 +114,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('unit').editor as any, editor: standardEditorsRegistry.get('unit').editor as any,
override: standardEditorsRegistry.get('unit').editor as any, override: standardEditorsRegistry.get('unit').editor as any,
process: unitOverrideProcessor, process: unitOverrideProcessor,
@ -128,6 +135,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
addNumberInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) { addNumberInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('number').editor as any, editor: standardEditorsRegistry.get('number').editor as any,
}); });
} }
@ -135,6 +143,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
addTextInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & StringFieldConfigSettings, string>) { addTextInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & StringFieldConfigSettings, string>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('text').editor as any, editor: standardEditorsRegistry.get('text').editor as any,
}); });
} }
@ -144,6 +153,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('select').editor as any, editor: standardEditorsRegistry.get('select').editor as any,
}); });
} }
@ -153,6 +163,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
) { ) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('radio').editor as any, editor: standardEditorsRegistry.get('radio').editor as any,
}); });
} }
@ -160,6 +171,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
addBooleanSwitch<TSettings = any>(config: PanelOptionsEditorConfig<TOptions, TSettings, boolean>) { addBooleanSwitch<TSettings = any>(config: PanelOptionsEditorConfig<TOptions, TSettings, boolean>) {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('boolean').editor as any, editor: standardEditorsRegistry.get('boolean').editor as any,
}); });
} }
@ -169,6 +181,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
): this { ): this {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('color').editor as any, editor: standardEditorsRegistry.get('color').editor as any,
settings: config.settings || {}, settings: config.settings || {},
}); });
@ -179,6 +192,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
): this { ): this {
return this.addCustomEditor({ return this.addCustomEditor({
...config, ...config,
id: config.path,
editor: standardEditorsRegistry.get('unit').editor as any, editor: standardEditorsRegistry.get('unit').editor as any,
}); });
} }

View File

@ -124,7 +124,7 @@ export class Registry<T extends RegistryItem> {
if (!this.initialized) { if (!this.initialized) {
this.getIfExists('xxx'); // will trigger init this.getIfExists('xxx'); // will trigger init
} }
return [...this.ordered]; // copy of everythign just in case return this.ordered; // copy of everythign just in case
} }
register(ext: T) { register(ext: T) {

View File

@ -0,0 +1,170 @@
import { identityOverrideProcessor } from '../../field';
import { ThresholdsMode } from '../../types';
export const mockStandardProperties = () => {
const title = {
id: 'title',
path: 'title',
name: 'Title',
description: "Field's title",
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: 'none',
expandTemplateVars: true,
},
shouldApply: () => true,
};
const unit = {
id: 'unit',
path: 'unit',
name: 'Unit',
description: 'Value units',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: 'none',
},
shouldApply: () => true,
};
const min = {
id: 'min',
path: 'min',
name: 'Min',
description: 'Minimum expected value',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: 'auto',
},
shouldApply: () => true,
};
const max = {
id: 'max',
path: 'max',
name: 'Max',
description: 'Maximum expected value',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: 'auto',
},
shouldApply: () => true,
};
const decimals = {
id: 'decimals',
path: 'decimals',
name: 'Decimals',
description: 'Number of decimal to be shown for a value',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: 'auto',
min: 0,
max: 15,
integer: true,
},
shouldApply: () => true,
};
const thresholds = {
id: 'thresholds',
path: 'thresholds',
name: 'Thresholds',
description: 'Manage thresholds',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {},
defaultValue: {
mode: ThresholdsMode.Absolute,
steps: [
{ value: -Infinity, color: 'green' },
{ value: 80, color: 'red' },
],
},
shouldApply: () => true,
};
const mappings = {
id: 'mappings',
path: 'mappings',
name: 'Value mappings',
description: 'Manage value mappings',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {},
defaultValue: [],
shouldApply: () => true,
};
const noValue = {
id: 'noValue',
path: 'noValue',
name: 'No Value',
description: 'What to show when there is no value',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: '-',
},
// ??? any optionsUi with no value
shouldApply: () => true,
};
const links = {
id: 'links',
path: 'links',
name: 'DataLinks',
description: 'Manage date links',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: '-',
},
shouldApply: () => true,
};
const color = {
id: 'color',
path: 'color',
name: 'Color',
description: 'Customise color',
editor: () => null,
override: () => null,
process: identityOverrideProcessor,
settings: {
placeholder: '-',
},
shouldApply: () => true,
};
return [unit, min, max, decimals, title, noValue, thresholds, mappings, links, color];
};

View File

@ -3,3 +3,4 @@ export * from './AngularLoader';
export * from './dataSourceSrv'; export * from './dataSourceSrv';
export * from './LocationSrv'; export * from './LocationSrv';
export * from './EchoSrv'; export * from './EchoSrv';
export * from './templateSrv';

View File

@ -0,0 +1,13 @@
import { VariableModel } from '@grafana/data';
export interface TemplateSrv {
getVariables(): VariableModel[];
}
let singletonInstance: TemplateSrv;
export const setTemplateSrv = (instance: TemplateSrv) => {
singletonInstance = instance;
};
export const getTemplateSrv = (): TemplateSrv => singletonInstance;

View File

@ -4,10 +4,16 @@ const fs = require('fs');
entrypoint = () => { entrypoint = () => {
const defaultEntryPoint = '../src/cli/index.js'; const defaultEntryPoint = '../src/cli/index.js';
// We are running in dev mode. Don't use compiled binaries, rather use the dev entrypoint. const toolkitDirectory = `${process.env['PWD']}/node_modules/@grafana/toolkit`;
if (fs.existsSync(`${process.env['HOME']}/.config/yarn/link/@grafana/toolkit`)) {
console.log('Running in linked mode'); // IF we have a toolkit directory AND linked grafana toolkit AND the toolkit dir is a symbolic lik
return `${__dirname}/grafana-toolkit.js`; // THEN run everything in linked mode
if (fs.existsSync(toolkitDirectory)) {
const tkStat = fs.lstatSync(toolkitDirectory);
if (tkStat.isSymbolicLink()) {
console.log('Running in linked mode', `${__dirname}/grafana-toolkit.js`);
return `${__dirname}/grafana-toolkit.js`;
}
} }
// We are using npx, and a relative path does not find index.js // We are using npx, and a relative path does not find index.js

View File

@ -67,6 +67,7 @@
"execa": "^1.0.0", "execa": "^1.0.0",
"expect-puppeteer": "4.1.1", "expect-puppeteer": "4.1.1",
"file-loader": "^4.0.0", "file-loader": "^4.0.0",
"fork-ts-checker-webpack-plugin": "1.0.0",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"globby": "^10.0.1", "globby": "^10.0.1",
"html-loader": "0.5.5", "html-loader": "0.5.5",

View File

@ -18,6 +18,7 @@ import { githubPublishTask } from './tasks/plugin.utils';
import { ciBuildPluginTask, ciBuildPluginDocsTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci'; import { ciBuildPluginTask, ciBuildPluginDocsTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci';
import { buildPackageTask } from './tasks/package.build'; import { buildPackageTask } from './tasks/package.build';
import { pluginCreateTask } from './tasks/plugin.create'; import { pluginCreateTask } from './tasks/plugin.create';
import { bundleManagedTask } from './tasks/plugin/bundle.managed';
export const run = (includeInternalScripts = false) => { export const run = (includeInternalScripts = false) => {
if (includeInternalScripts) { if (includeInternalScripts) {
@ -195,6 +196,13 @@ export const run = (includeInternalScripts = false) => {
}); });
}); });
program
.command('plugin:bundle-managed')
.description('Builds managed plugins')
.action(async cmd => {
await execTask(bundleManagedTask)({});
});
program program
.command('plugin:github-publish') .command('plugin:github-publish')
.option('--dryrun', 'Do a dry run only', false) .option('--dryrun', 'Do a dry run only', false)

View File

@ -13,6 +13,7 @@ describe('Manifest', () => {
"manifest.ts", "manifest.ts",
"nodeVersionChecker.ts", "nodeVersionChecker.ts",
"package.build.ts", "package.build.ts",
"plugin/bundle.managed.ts",
"plugin/bundle.ts", "plugin/bundle.ts",
"plugin/create.ts", "plugin/create.ts",
"plugin/tests.ts", "plugin/tests.ts",

View File

@ -10,9 +10,11 @@ import path = require('path');
import execa = require('execa'); import execa = require('execa');
interface Command extends Array<any> {} interface Command extends Array<any> {}
const DEFAULT_EMAIL_ADDRESS = 'eng@grafana.com';
const DEFAULT_USERNAME = 'CircleCI Automation';
const releaseNotes = async (): Promise<string> => { const releaseNotes = async (): Promise<string> => {
const { stdout } = await execa.shell(`awk \'BEGIN {FS="##"; RS=""} FNR==3 {print; exit}\' CHANGELOG.md`); const { stdout } = await execa.shell(`awk 'BEGIN {FS="##"; RS="##"} FNR==3 {print "##" $1; exit}' CHANGELOG.md`);
return stdout; return stdout;
}; };
@ -62,12 +64,10 @@ const prepareRelease = useSpinner<any>('Preparing release', async ({ dryrun, ver
const distContentDir = path.resolve(distDir, getPluginId()); const distContentDir = path.resolve(distDir, getPluginId());
const pluginJsonFile = path.resolve(distContentDir, 'plugin.json'); const pluginJsonFile = path.resolve(distContentDir, 'plugin.json');
const pluginJson = getPluginJson(pluginJsonFile); const pluginJson = getPluginJson(pluginJsonFile);
const GIT_EMAIL = 'eng@grafana.com';
const GIT_USERNAME = 'CircleCI Automation';
const githubPublishScript: Command = [ const githubPublishScript: Command = [
['git', ['config', 'user.email', GIT_EMAIL]], ['git', ['config', 'user.email', DEFAULT_EMAIL_ADDRESS]],
['git', ['config', 'user.name', GIT_USERNAME]], ['git', ['config', 'user.name', DEFAULT_USERNAME]],
await checkoutBranch(`release-${pluginJson.info.version}`), await checkoutBranch(`release-${pluginJson.info.version}`),
['cp', ['-rf', distContentDir, 'dist']], ['cp', ['-rf', distContentDir, 'dist']],
['git', ['add', '--force', distDir], { dryrun }], ['git', ['add', '--force', distDir], { dryrun }],
@ -138,14 +138,14 @@ const prepareRelease = useSpinner<any>('Preparing release', async ({ dryrun, ver
interface GithubPublishReleaseOptions { interface GithubPublishReleaseOptions {
commitHash?: string; commitHash?: string;
githubToken: string; githubToken: string;
gitRepoOwner: string; githubUser: string;
gitRepoName: string; gitRepoName: string;
} }
const createRelease = useSpinner<GithubPublishReleaseOptions>( const createRelease = useSpinner<GithubPublishReleaseOptions>(
'Creating release', 'Creating release',
async ({ commitHash, githubToken, gitRepoName, gitRepoOwner }) => { async ({ commitHash, githubUser, githubToken, gitRepoName }) => {
const gitRelease = new GitHubRelease(githubToken, gitRepoOwner, gitRepoName, await releaseNotes(), commitHash); const gitRelease = new GitHubRelease(githubToken, githubUser, gitRepoName, await releaseNotes(), commitHash);
return gitRelease.release(); return gitRelease.release();
} }
); );
@ -159,16 +159,37 @@ export interface GithubPublishOptions {
const githubPublishRunner: TaskRunner<GithubPublishOptions> = async ({ dryrun, verbose, commitHash }) => { const githubPublishRunner: TaskRunner<GithubPublishOptions> = async ({ dryrun, verbose, commitHash }) => {
if (!process.env['CIRCLE_REPOSITORY_URL']) { if (!process.env['CIRCLE_REPOSITORY_URL']) {
throw `The release plugin requires you specify the repository url as environment variable CIRCLE_REPOSITORY_URL`; // Try and figure it out
const repo = await execa('git', ['config', '--local', 'remote.origin.url']);
if (repo && repo.stdout) {
process.env.CIRCLE_REPOSITORY_URL = repo.stdout;
} else {
throw new Error(
'The release plugin requires you specify the repository url as environment variable CIRCLE_REPOSITORY_URL'
);
}
} }
if (!process.env['GITHUB_TOKEN']) { if (!process.env['GITHUB_ACCESS_TOKEN']) {
throw `Github publish requires that you set the environment variable GITHUB_TOKEN to a valid github api token. // Try to use GITHUB_TOKEN, which may be set.
See: https://github.com/settings/tokens for more details.`; if (process.env['GITHUB_TOKEN']) {
process.env['GITHUB_ACCESS_TOKEN'] = process.env['GITHUB_TOKEN'];
} else {
throw new Error(
`Github publish requires that you set the environment variable GITHUB_ACCESS_TOKEN to a valid github api token.
See: https://github.com/settings/tokens for more details.`
);
}
}
if (!process.env['GITHUB_USERNAME']) {
// We can default this one
process.env['GITHUB_USERNAME'] = DEFAULT_EMAIL_ADDRESS;
} }
const parsedUrl = gitUrlParse(process.env['CIRCLE_REPOSITORY_URL']); const parsedUrl = gitUrlParse(process.env['CIRCLE_REPOSITORY_URL']);
const githubToken = process.env['GITHUB_TOKEN']; const githubToken = process.env['GITHUB_ACCESS_TOKEN'];
const githubUser = parsedUrl.owner;
await prepareRelease({ await prepareRelease({
dryrun, dryrun,
@ -177,8 +198,8 @@ const githubPublishRunner: TaskRunner<GithubPublishOptions> = async ({ dryrun, v
await createRelease({ await createRelease({
commitHash, commitHash,
githubUser,
githubToken, githubToken,
gitRepoOwner: parsedUrl.owner,
gitRepoName: parsedUrl.name, gitRepoName: parsedUrl.name,
}); });
}; };

View File

@ -0,0 +1,38 @@
import { Task, TaskRunner } from '../task';
import { restoreCwd } from '../../utils/cwd';
import execa = require('execa');
const fs = require('fs');
const util = require('util');
const readdirPromise = util.promisify(fs.readdir);
interface BundeManagedOptions {}
const MANAGED_PLUGINS_PATH = `${process.cwd()}/plugins-bundled`;
const MANAGED_PLUGINS_SCOPES = ['internal', 'external'];
const bundleManagedPluginsRunner: TaskRunner<BundeManagedOptions> = async () => {
await Promise.all(
MANAGED_PLUGINS_SCOPES.map(async scope => {
try {
const plugins = await readdirPromise(`${MANAGED_PLUGINS_PATH}/${scope}`);
if (plugins.length > 0) {
for (const plugin of plugins) {
process.chdir(`${MANAGED_PLUGINS_PATH}/${scope}/${plugin}`);
try {
await execa('yarn', ['dev']);
console.log(`[${scope}]: ${plugin} bundled`);
} catch (e) {
console.log(e.stdout);
}
}
}
} catch (e) {
console.log(e);
}
})
);
restoreCwd();
};
export const bundleManagedTask = new Task<BundeManagedOptions>('Bundle managed plugins', bundleManagedPluginsRunner);

View File

@ -0,0 +1,10 @@
import { GitHubRelease } from './githubRelease';
describe('GithubRelease', () => {
it('should initialise a GithubRelease', () => {
process.env.GITHUB_ACCESS_TOKEN = '12345';
process.env.GITHUB_USERNAME = 'test@grafana.com';
const github = new GitHubRelease('A token', 'A username', 'A repo', 'Some release notes');
expect(github).toBeInstanceOf(GitHubRelease);
});
});

View File

@ -9,6 +9,9 @@ import GithubClient from './githubClient';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
const resolveContentType = (extension: string): string => { const resolveContentType = (extension: string): string => {
if (extension.startsWith('.')) {
extension = extension.substr(1);
}
switch (extension) { switch (extension) {
case 'zip': case 'zip':
return 'application/zip'; return 'application/zip';
@ -37,30 +40,23 @@ class GitHubRelease {
this.commitHash = commitHash; this.commitHash = commitHash;
this.git = new GithubClient({ this.git = new GithubClient({
required: true,
repo: repository, repo: repository,
}); });
} }
async publishAssets(srcLocation: string, destUrl: string) { publishAssets(srcLocation: string, destUrl: string) {
// Add the assets. Loop through files in the ci/dist folder and upload each asset. // Add the assets. Loop through files in the ci/dist folder and upload each asset.
fs.readdir(srcLocation, (err: NodeJS.ErrnoException | null, files: string[]) => { const files = fs.readdirSync(srcLocation);
if (err) {
throw err;
}
files.forEach(async (file: string) => { return files.map(async (file: string) => {
const fileStat = fs.statSync(`${srcLocation}/${file}`); const fileStat = fs.statSync(`${srcLocation}/${file}`);
const fileData = fs.readFileSync(`${srcLocation}/${file}`); const fileData = fs.readFileSync(`${srcLocation}/${file}`);
try { return this.git.client.post(`${destUrl}?name=${file}`, fileData, {
await this.git.client.post(`${destUrl}?name=${file}`, fileData, { headers: {
headers: { 'Content-Type': resolveContentType(path.extname(file)),
'Content-Type': resolveContentType(path.extname(file)), 'Content-Length': fileStat.size,
'Content-Length': fileStat.size, },
},
});
} catch (reason) {
console.log('Could not post', reason);
}
}); });
}); });
} }
@ -75,7 +71,7 @@ class GitHubRelease {
const commitHash = this.commitHash || pluginInfo.build?.hash; const commitHash = this.commitHash || pluginInfo.build?.hash;
try { try {
const latestRelease: AxiosResponse<any> = await this.git.client.get('releases/latest'); const latestRelease: AxiosResponse<any> = await this.git.client.get(`releases/tags/v${pluginInfo.version}`);
// Re-release if the version is the same as an existing release // Re-release if the version is the same as an existing release
if (latestRelease.data.tag_name === `v${pluginInfo.version}`) { if (latestRelease.data.tag_name === `v${pluginInfo.version}`) {
@ -92,12 +88,15 @@ class GitHubRelease {
prerelease: false, prerelease: false,
}); });
this.publishAssets( const publishPromises = this.publishAssets(
PUBLISH_DIR, PUBLISH_DIR,
`https://uploads.github.com/repos/${this.username}/${this.repository}/releases/${newReleaseResponse.data.id}/assets` `https://uploads.github.com/repos/${this.username}/${this.repository}/releases/${newReleaseResponse.data.id}/assets`
); );
await Promise.all(publishPromises);
} catch (reason) { } catch (reason) {
console.error('error', reason); console.error(reason.data?.message ?? reason.response.data ?? reason);
// Rethrow the error so that we can trigger a non-zero exit code to circle-ci
throw reason;
} }
} }
} }

View File

@ -7,6 +7,7 @@ const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
const readdirPromise = util.promisify(fs.readdir); const readdirPromise = util.promisify(fs.readdir);
const accessPromise = util.promisify(fs.access); const accessPromise = util.promisify(fs.access);
@ -123,6 +124,11 @@ const getCommonPlugins = (options: WebpackConfigurationOptions) => {
], ],
}, },
]), ]),
new ForkTsCheckerWebpackPlugin({
tsconfig: path.join(process.cwd(), 'tsconfig.json'),
// Only report problems in detected in plugin's code
reportFiles: ['**/*.{ts,tsx}'],
}),
]; ];
}; };
@ -205,7 +211,10 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async options => {
}, },
{ {
loader: 'ts-loader', loader: 'ts-loader',
options: { onlyCompileBundledFiles: true }, options: {
onlyCompileBundledFiles: true,
transpileOnly: true,
},
}, },
], ],
exclude: /(node_modules)/, exclude: /(node_modules)/,

View File

@ -38,6 +38,7 @@ addParameters({
light: GrafanaLight, light: GrafanaLight,
}, },
options: { options: {
theme: GrafanaLight,
showPanel: true, showPanel: true,
showRoots: true, showRoots: true,
panelPosition: 'bottom', panelPosition: 'bottom',

View File

@ -33,6 +33,7 @@
"@grafana/tsconfig": "^1.0.0-rc1", "@grafana/tsconfig": "^1.0.0-rc1",
"@iconscout/react-unicons": "^1.0.0", "@iconscout/react-unicons": "^1.0.0",
"@torkelo/react-select": "3.0.8", "@torkelo/react-select": "3.0.8",
"@types/react-beautiful-dnd": "12.1.2",
"@types/react-color": "3.0.1", "@types/react-color": "3.0.1",
"@types/react-select": "3.0.8", "@types/react-select": "3.0.8",
"@types/react-table": "7.0.12", "@types/react-table": "7.0.12",
@ -52,6 +53,7 @@
"rc-slider": "9.2.3", "rc-slider": "9.2.3",
"rc-time-picker": "^3.7.3", "rc-time-picker": "^3.7.3",
"react": "16.12.0", "react": "16.12.0",
"react-beautiful-dnd": "13.0.0",
"react-calendar": "2.19.2", "react-calendar": "2.19.2",
"react-color": "2.18.0", "react-color": "2.18.0",
"react-custom-scrollbars": "4.2.1", "react-custom-scrollbars": "4.2.1",

View File

@ -59,6 +59,7 @@ const buildCjsPackage = ({ env }) => {
'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'], 'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'],
'../../node_modules/esrever/esrever.js': ['reverse'], '../../node_modules/esrever/esrever.js': ['reverse'],
'../../node_modules/react-table/index.js': ['useTable', 'useSortBy', 'useBlockLayout', 'Cell'], '../../node_modules/react-table/index.js': ['useTable', 'useSortBy', 'useBlockLayout', 'Cell'],
'../../node_modules/react-is/index.js': ['isValidElementType', 'isContextConsumer'],
}, },
}), }),
resolve(), resolve(),

View File

@ -4,7 +4,7 @@ import RCCascader from 'rc-cascader';
import { Select } from '../Select/Select'; import { Select } from '../Select/Select';
import { FormInputSize } from '../Forms/types'; import { FormInputSize } from '../Forms/types';
import { Input } from '../Forms/Input/Input'; import { Input } from '../Input/Input';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { css } from 'emotion'; import { css } from 'emotion';
import { onChangeCascader } from './optionMappings'; import { onChangeCascader } from './optionMappings';

View File

@ -3,7 +3,7 @@ import React, { useState } from 'react';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { ClipboardButton } from './ClipboardButton'; import { ClipboardButton } from './ClipboardButton';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
import { text } from '@storybook/addon-knobs'; import { text } from '@storybook/addon-knobs';
const getKnobs = () => { const getKnobs = () => {

View File

@ -3,7 +3,7 @@ import tinycolor from 'tinycolor2';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { ColorPickerProps } from './ColorPickerPopover'; import { ColorPickerProps } from './ColorPickerPopover';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
interface ColorInputState { interface ColorInputState {
previousColor: string; previousColor: string;

View File

@ -1,6 +1,7 @@
import React, { ChangeEvent, useContext } from 'react'; import React, { ChangeEvent, useContext } from 'react';
import { DataLink, VariableSuggestion, GrafanaTheme } from '@grafana/data'; import { DataLink, VariableSuggestion, GrafanaTheme } from '@grafana/data';
import { FormField, Switch } from '../index'; import { FormField } from '../index';
import { Switch } from '../Switch/Switch';
import { css } from 'emotion'; import { css } from 'emotion';
import { ThemeContext, stylesFactory } from '../../themes/index'; import { ThemeContext, stylesFactory } from '../../themes/index';
import { DataLinkInput } from './DataLinkInput'; import { DataLinkInput } from './DataLinkInput';

View File

@ -9,7 +9,7 @@ import { DataSourceSettings } from '@grafana/data';
import { HttpSettingsProps } from './types'; import { HttpSettingsProps } from './types';
import { CustomHeadersSettings } from './CustomHeadersSettings'; import { CustomHeadersSettings } from './CustomHeadersSettings';
import { Select } from '../Forms/Legacy/Select/Select'; import { Select } from '../Forms/Legacy/Select/Select';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
import { FormField } from '../FormField/FormField'; import { FormField } from '../FormField/FormField';
import { FormLabel } from '../FormLabel/FormLabel'; import { FormLabel } from '../FormLabel/FormLabel';
import { Switch } from '../Switch/Switch'; import { Switch } from '../Switch/Switch';

View File

@ -48,7 +48,7 @@ export const TLSAuthSettings: React.FC<HttpSettingsBaseProps> = ({ dataSourceCon
theme="info" theme="info"
> >
<div className="gf-form-help-icon gf-form-help-icon--right-normal"> <div className="gf-form-help-icon gf-form-help-icon--right-normal">
<Icon name="info-circle" size="sm" /> <Icon name="info-circle" size="sm" style={{ marginBottom: '1px', marginLeft: '8px' }} />
</div> </div>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -1,126 +0,0 @@
import {
FieldConfig,
FieldConfigSource,
InterpolateFunction,
GrafanaTheme,
FieldMatcherID,
MutableDataFrame,
DataFrame,
FieldType,
applyFieldOverrides,
toDataFrame,
standardFieldConfigEditorRegistry,
standardEditorsRegistry,
} from '@grafana/data';
import { getTheme } from '../../themes';
import { getStandardFieldConfigs, getStandardOptionEditors } from '../../utils';
describe('FieldOverrides', () => {
beforeAll(() => {
standardEditorsRegistry.setInit(getStandardOptionEditors);
standardFieldConfigEditorRegistry.setInit(getStandardFieldConfigs);
});
const f0 = new MutableDataFrame();
f0.add({ title: 'AAA', value: 100, value2: 1234 }, true);
f0.add({ title: 'BBB', value: -20 }, true);
f0.add({ title: 'CCC', value: 200, value2: 1000 }, true);
expect(f0.length).toEqual(3);
// Hardcode the max value
f0.fields[1].config.max = 0;
f0.fields[1].config.decimals = 6;
const src: FieldConfigSource = {
defaults: {
unit: 'xyz',
decimals: 2,
},
overrides: [
{
matcher: { id: FieldMatcherID.numeric },
properties: [
{ prop: 'decimals', value: 1 }, // Numeric
{ prop: 'title', value: 'Kittens' }, // Text
],
},
],
};
it('will merge FieldConfig with default values', () => {
const field: FieldConfig = {
min: 0,
max: 100,
};
const f1 = {
unit: 'ms',
dateFormat: '', // should be ignored
max: parseFloat('NOPE'), // should be ignored
min: null, // should alo be ignored!
};
const f: DataFrame = toDataFrame({
fields: [{ type: FieldType.number, name: 'x', config: field, values: [] }],
});
const processed = applyFieldOverrides({
data: [f],
standard: standardFieldConfigEditorRegistry,
fieldOptions: {
defaults: f1 as FieldConfig,
overrides: [],
},
replaceVariables: v => v,
theme: getTheme(),
})[0];
const out = processed.fields[0].config;
expect(out.min).toEqual(0);
expect(out.max).toEqual(100);
expect(out.unit).toEqual('ms');
});
it('will apply field overrides', () => {
const data = applyFieldOverrides({
data: [f0], // the frame
fieldOptions: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
theme: (undefined as any) as GrafanaTheme,
})[0];
const valueColumn = data.fields[1];
const config = valueColumn.config;
// Keep max from the original setting
expect(config.max).toEqual(0);
// Don't Automatically pick the min value
expect(config.min).toEqual(undefined);
// The default value applied
expect(config.unit).toEqual('xyz');
// The default value applied
expect(config.title).toEqual('Kittens');
// The override applied
expect(config.decimals).toEqual(1);
});
it('will apply set min/max when asked', () => {
const data = applyFieldOverrides({
data: [f0], // the frame
fieldOptions: src as FieldConfigSource, // defaults + overrides
replaceVariables: (undefined as any) as InterpolateFunction,
theme: (undefined as any) as GrafanaTheme,
autoMinMax: true,
})[0];
const valueColumn = data.fields[1];
const config = valueColumn.config;
// Keep max from the original setting
expect(config.max).toEqual(0);
// Don't Automatically pick the min value
expect(config.min).toEqual(-20);
});
});

View File

@ -1,5 +1,4 @@
.form-field { .form-field {
margin-bottom: $space-xxs;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;

View File

@ -34,7 +34,7 @@ export const FormLabel: FunctionComponent<Props> = ({
{tooltip && ( {tooltip && (
<Tooltip placement="top" content={tooltip} theme={'info'}> <Tooltip placement="top" content={tooltip} theme={'info'}>
<div className="gf-form-help-icon gf-form-help-icon--right-normal"> <div className="gf-form-help-icon gf-form-help-icon--right-normal">
<Icon name="info-circle" size="sm" style={{ marginBottom: 0 }} /> <Icon name="info-circle" size="sm" style={{ marginBottom: '1px', marginLeft: '8px' }} />
</div> </div>
</Tooltip> </Tooltip>
)} )}

View File

@ -1,7 +1,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { boolean, number, text } from '@storybook/addon-knobs'; import { boolean, number, text } from '@storybook/addon-knobs';
import { Field } from './Field'; import { Field } from './Field';
import { Input } from './Input/Input'; import { Input } from '../Input/Input';
import { Switch } from './Switch'; import { Switch } from './Switch';
import mdx from './Field.mdx'; import mdx from './Field.mdx';

View File

@ -4,7 +4,7 @@ import { Legend } from './Legend';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { withStoryContainer } from '../../utils/storybook/withStoryContainer'; import { withStoryContainer } from '../../utils/storybook/withStoryContainer';
import { Field } from './Field'; import { Field } from './Field';
import { Input } from './Input/Input'; import { Input } from '../Input/Input';
import { Button } from '../Button'; import { Button } from '../Button';
import { Form } from './Form'; import { Form } from './Form';
import { Switch } from './Switch'; import { Switch } from './Switch';

View File

@ -1,6 +0,0 @@
import { Props } from '@storybook/addon-docs/blocks';
import { Input } from './Input';
# Input
<Props of={Input} />

View File

@ -1,110 +0,0 @@
import React, { useState } from 'react';
import { boolean, text, select, number } from '@storybook/addon-knobs';
import { withCenteredStory } from '../../../utils/storybook/withCenteredStory';
import { Input } from './Input';
import { Button } from '../../Button';
import mdx from './Input.mdx';
import { getAvailableIcons, IconName } from '../../../types';
import { KeyValue } from '@grafana/data';
import { Icon } from '../../Icon/Icon';
import { Field } from '../Field';
export default {
title: 'Forms/Input',
component: Input,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
export const simple = () => {
const prefixSuffixOpts = {
None: null,
Text: '$',
...getAvailableIcons().reduce<KeyValue<string>>((prev, c) => {
return {
...prev,
[`Icon: ${c}`]: `icon-${c}`,
};
}, {}),
};
const BEHAVIOUR_GROUP = 'Behaviour props';
// ---
const type = select(
'Type',
{
text: 'text',
password: 'password',
number: 'number',
},
'text',
BEHAVIOUR_GROUP
);
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
const VISUAL_GROUP = 'Visual options';
// ---
const placeholder = text('Placeholder', 'Enter your name here...', VISUAL_GROUP);
const before = boolean('Addon before', false, VISUAL_GROUP);
const after = boolean('Addon after', false, VISUAL_GROUP);
const addonAfter = <Button variant="secondary">Load</Button>;
const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>;
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
const suffix = select('Suffix', prefixSuffixOpts, null, VISUAL_GROUP);
let prefixEl: any = prefix;
if (prefix && prefix.match(/icon-/g)) {
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconName} />;
}
let suffixEl: any = suffix;
if (suffix && suffix.match(/icon-/g)) {
suffixEl = <Icon name={suffix.replace(/icon-/g, '') as IconName} />;
}
const CONTAINER_GROUP = 'Container options';
// ---
const containerWidth = number(
'Container width',
300,
{
range: true,
min: 100,
max: 500,
step: 10,
},
CONTAINER_GROUP
);
return (
<div style={{ width: containerWidth }}>
<Input
disabled={disabled}
invalid={invalid}
prefix={prefixEl}
suffix={suffixEl}
loading={loading}
addonBefore={before && addonBefore}
addonAfter={after && addonAfter}
type={type}
placeholder={placeholder}
/>
</div>
);
};
export const withFieldValidation = () => {
const [value, setValue] = useState('');
return (
<div>
<Field invalid={value === ''} error={value === '' ? 'This input is required' : ''}>
<Input value={value} onChange={e => setValue(e.currentTarget.value)} />
</Field>
</div>
);
};

View File

@ -1,259 +0,0 @@
import React, { HTMLProps, ReactNode } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion';
import { getFocusStyle, inputSizes, sharedInputStyle } from '../commonStyles';
import { stylesFactory, useTheme } from '../../../themes';
import { useClientRect } from '../../../utils/useClientRect';
import { FormInputSize } from '../types';
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'size'> {
/** Show an invalid state around the input */
invalid?: boolean;
/** Show an icon as a prefix in the input */
prefix?: JSX.Element | string | null;
/** Show an icon as a suffix in the input */
suffix?: JSX.Element | string | null;
/** Show a loading indicator as a suffix in the input */
loading?: boolean;
/** Add a component as an addon before the input */
addonBefore?: ReactNode;
/** Add a component as an addon after the input */
addonAfter?: ReactNode;
size?: FormInputSize;
}
interface StyleDeps {
theme: GrafanaTheme;
invalid: boolean;
}
export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDeps) => {
const colors = theme.colors;
const borderRadius = theme.border.radius.sm;
const height = theme.spacing.formInputHeight;
const prefixSuffixStaticWidth = '28px';
const prefixSuffix = css`
position: absolute;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex-grow: 0;
flex-shrink: 0;
font-size: ${theme.typography.size.md};
height: 100%;
/* Min width specified for prefix/suffix classes used outside React component*/
min-width: ${prefixSuffixStaticWidth};
`;
return {
// Wraps inputWrapper and addons
wrapper: cx(
css`
label: input-wrapper;
display: flex;
width: 100%;
height: ${height};
border-radius: ${borderRadius};
&:hover {
> .prefix,
.suffix,
.input {
border-color: ${invalid ? colors.redBase : colors.formInputBorder};
}
// only show number buttons on hover
input[type='number'] {
-moz-appearance: number-input;
-webkit-appearance: number-input;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: inner-spin-button !important;
opacity: 1;
}
}
`
),
// Wraps input and prefix/suffix
inputWrapper: css`
label: input-inputWrapper;
position: relative;
flex-grow: 1;
/* we want input to be above addons, especially for focused state */
z-index: 1;
/* when input rendered with addon before only*/
&:not(:first-child):last-child {
> input {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
/* when input rendered with addon after only*/
&:first-child:not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
/* when rendered with addon before and after */
&:not(:first-child):not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
input {
/* paddings specified for classes used outside React component */
&:not(:first-child) {
padding-left: ${prefixSuffixStaticWidth};
}
&:not(:last-child) {
padding-right: ${prefixSuffixStaticWidth};
}
&[readonly] {
cursor: default;
}
}
`,
input: cx(
getFocusStyle(theme),
sharedInputStyle(theme, invalid),
css`
label: input-input;
position: relative;
z-index: 0;
flex-grow: 1;
border-radius: ${borderRadius};
height: 100%;
width: 100%;
`
),
inputDisabled: css`
background-color: ${colors.formInputBgDisabled};
color: ${colors.formInputDisabledText};
`,
addon: css`
label: input-addon;
display: flex;
justify-content: center;
align-items: center;
flex-grow: 0;
flex-shrink: 0;
position: relative;
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
> :last-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
> :first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
> *:focus {
/* we want anything that has focus and is an addon to be above input */
z-index: 2;
}
`,
prefix: cx(
prefixSuffix,
css`
label: input-prefix;
padding-left: ${theme.spacing.sm};
padding-right: ${theme.spacing.xs};
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
`
),
suffix: cx(
prefixSuffix,
css`
label: input-suffix;
padding-right: ${theme.spacing.sm};
padding-left: ${theme.spacing.xs};
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
right: 0;
`
),
loadingIndicator: css`
& + * {
margin-left: ${theme.spacing.xs};
}
`,
};
});
export const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, size = 'auto', ...restProps } = props;
/**
* Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input
* when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)).
* Thanks to that prefix/suffix do not overflow the input element itself.
*/
const [prefixRect, prefixRef] = useClientRect<HTMLDivElement>();
const [suffixRect, suffixRef] = useClientRect<HTMLDivElement>();
const theme = useTheme();
const styles = getInputStyles({ theme, invalid: !!invalid });
return (
<div className={cx(styles.wrapper, inputSizes()[size], className)}>
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
<div className={styles.inputWrapper}>
{prefix && (
<div className={styles.prefix} ref={prefixRef}>
{prefix}
</div>
)}
<input
ref={ref}
className={styles.input}
{...restProps}
style={{
paddingLeft: prefixRect ? prefixRect.width : undefined,
paddingRight: suffixRect ? suffixRect.width : undefined,
}}
/>
{(suffix || loading) && (
<div className={styles.suffix} ref={suffixRef}>
{loading && <i className={cx('fa fa-spinner fa-spin', styles.loadingIndicator)} />}
{suffix}
</div>
)}
</div>
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
</div>
);
});
Input.displayName = 'Input';

View File

@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { zip, fromPairs } from 'lodash';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../../../utils/storybook/withCenteredStory';
import { Input } from './Input';
import { text, select } from '@storybook/addon-knobs';
import { EventsWithValidation } from '../../../../utils';
const getKnobs = () => {
return {
validation: text('Validation regex (will do a partial match if you do not anchor it)', ''),
validationErrorMessage: text('Validation error message', 'Input not valid'),
validationEvent: select(
'Validation event',
fromPairs(zip(Object.keys(EventsWithValidation), Object.values(EventsWithValidation))),
EventsWithValidation.onBlur
),
};
};
const Wrapper = () => {
const { validation, validationErrorMessage, validationEvent } = getKnobs();
const [value, setValue] = useState('');
const validations = {
[validationEvent]: [
{
rule: (value: string) => {
return !!value.match(validation);
},
errorMessage: validationErrorMessage,
},
],
};
return <Input value={value} onChange={e => setValue(e.currentTarget.value)} validationEvents={validations} />;
};
const story = storiesOf('General/Input', module);
story.addDecorator(withCenteredStory);
story.add('input', () => <Wrapper />);

View File

@ -2,8 +2,8 @@ import React from 'react';
import renderer from 'react-test-renderer'; import renderer from 'react-test-renderer';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Input } from './Input'; import { Input } from './Input';
import { EventsWithValidation } from '../../utils'; import { EventsWithValidation } from '../../../../utils';
import { ValidationEvents } from '../../types'; import { ValidationEvents } from '../../../../types';
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars'; const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
const testBlurValidation: ValidationEvents = { const testBlurValidation: ValidationEvents = {

View File

@ -0,0 +1,86 @@
import React, { PureComponent, ChangeEvent } from 'react';
import classNames from 'classnames';
import { validate, EventsWithValidation, hasValidationEvent } from '../../../../utils';
import { ValidationEvents, ValidationRule } from '../../../../types';
export enum LegacyInputStatus {
Invalid = 'invalid',
Valid = 'valid',
}
interface Props extends React.HTMLProps<HTMLInputElement> {
validationEvents?: ValidationEvents;
hideErrorMessage?: boolean;
inputRef?: React.LegacyRef<HTMLInputElement>;
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
}
interface State {
error: string | null;
}
export class Input extends PureComponent<Props, State> {
static defaultProps = {
className: '',
};
state: State = {
error: null,
};
get status() {
return this.state.error ? LegacyInputStatus.Invalid : LegacyInputStatus.Valid;
}
get isInvalid() {
return this.status === LegacyInputStatus.Invalid;
}
validatorAsync = (validationRules: ValidationRule[]) => {
return (evt: ChangeEvent<HTMLInputElement>) => {
const errors = validate(evt.target.value, validationRules);
this.setState(prevState => {
return { ...prevState, error: errors ? errors[0] : null };
});
};
};
populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
const inputElementProps = { ...restProps };
if (!validationEvents) {
return inputElementProps;
}
Object.keys(EventsWithValidation).forEach(eventName => {
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
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, inputRef, ...restProps } = this.props;
const { error } = this.state;
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className);
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
return (
<div style={{ flexGrow: 1 }}>
<input {...inputElementProps} ref={inputRef} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>}
</div>
);
}
}

View File

@ -1,4 +1,4 @@
$select-input-height: 35px; $select-input-height: 32px;
$select-input-bg-disabled: $input-bg-disabled; $select-input-bg-disabled: $input-bg-disabled;
@mixin select-control() { @mixin select-control() {
@ -122,7 +122,7 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__value-container { .gf-form-select-box__value-container {
display: inline-block; display: inline-block;
padding: 8px 16px 8px 10px; padding: 6px 16px 6px 10px;
vertical-align: middle; vertical-align: middle;
> div { > div {

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useTheme, stylesFactory, selectThemeVariant as stv } from '../../../themes'; import { useTheme, stylesFactory } from '../../../themes';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { getFocusCss, getPropertiesForButtonSize } from '../commonStyles'; import { getFocusCss, getPropertiesForButtonSize } from '../commonStyles';
@ -21,15 +21,15 @@ const getRadioButtonStyles = stylesFactory((theme: GrafanaTheme, size: RadioButt
const horizontalPadding = theme.spacing[size] ?? theme.spacing.md; const horizontalPadding = theme.spacing[size] ?? theme.spacing.md;
const c = theme.colors; const c = theme.colors;
const textColor = stv({ light: c.gray33, dark: c.gray70 }, theme.type); const textColor = theme.isLight ? c.gray33 : c.gray70;
const textColorHover = stv({ light: c.blueShade, dark: c.blueLight }, theme.type); const textColorHover = theme.isLight ? c.blueShade : c.blueLight;
const textColorActive = stv({ light: c.blueShade, dark: c.blueLight }, theme.type); const textColorActive = theme.isLight ? c.blueShade : c.blueLight;
const borderColor = stv({ light: c.gray4, dark: c.gray25 }, theme.type); const borderColor = theme.isLight ? c.gray4 : c.gray25;
const borderColorHover = stv({ light: c.gray70, dark: c.gray33 }, theme.type); const borderColorHover = theme.isLight ? c.gray70 : c.gray33;
const borderColorActive = stv({ light: c.blueShade, dark: c.blueLight }, theme.type); const borderColorActive = theme.isLight ? c.blueShade : c.blueLight;
const bg = stv({ light: c.gray98, dark: c.gray10 }, theme.type); const bg = c.pageBg;
const bgDisabled = stv({ light: c.gray95, dark: c.gray15 }, theme.type); const bgDisabled = theme.isLight ? c.gray95 : c.gray15;
const bgActive = stv({ light: c.white, dark: c.gray05 }, theme.type); const bgActive = theme.isLight ? c.white : c.gray05;
const border = `1px solid ${borderColor}`; const border = `1px solid ${borderColor}`;
const borderActive = `1px solid ${borderColorActive}`; const borderActive = `1px solid ${borderColorActive}`;

View File

@ -12,16 +12,18 @@ export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
size?: FormInputSize; size?: FormInputSize;
} }
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => { export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(
const theme = useTheme(); ({ invalid, size = 'auto', className, ...props }, ref) => {
const styles = getTextAreaStyle(theme, invalid); const theme = useTheme();
const styles = getTextAreaStyle(theme, invalid);
return ( return (
<div className={inputSizes()[size]}> <div className={inputSizes()[size]}>
<textarea className={styles.textarea} {...props} ref={ref} /> <textarea {...props} className={cx(styles.textarea, className)} ref={ref} />
</div> </div>
); );
}); }
);
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => { const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
return { return {

View File

@ -5,7 +5,7 @@ import { getLegendStyles } from './Legend';
import { getFieldValidationMessageStyles } from './FieldValidationMessage'; import { getFieldValidationMessageStyles } from './FieldValidationMessage';
import { getButtonStyles, ButtonVariant } from '../Button'; import { getButtonStyles, ButtonVariant } from '../Button';
import { ComponentSize } from '../../types/size'; import { ComponentSize } from '../../types/size';
import { getInputStyles } from './Input/Input'; import { getInputStyles } from '../Input/Input';
import { getSwitchStyles } from './Switch'; import { getSwitchStyles } from './Switch';
import { getCheckboxStyles } from './Checkbox'; import { getCheckboxStyles } from './Checkbox';

View File

@ -1,8 +1,6 @@
import { Controller as InputControl } from 'react-hook-form'; import { Controller as InputControl } from 'react-hook-form';
import { getFormStyles } from './getFormStyles'; import { getFormStyles } from './getFormStyles';
import { Label } from './Label'; import { Label } from './Label';
// To be removed
import { Input } from './Input/Input';
import { RadioButtonGroup } from './RadioButtonGroup/RadioButtonGroup'; import { RadioButtonGroup } from './RadioButtonGroup/RadioButtonGroup';
import { Form } from './Form'; import { Form } from './Form';
import { Field } from './Field'; import { Field } from './Field';
@ -16,8 +14,6 @@ const Forms = {
Switch, Switch,
getFormStyles, getFormStyles,
Label, Label,
// To be removed
Input,
Form, Form,
Field, Field,
InputControl, InputControl,

View File

@ -0,0 +1,12 @@
import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks';
import { Icon } from './Icon';
Icon
Grafana's wrapper component over Unicons and Font Awesome icons.
Changing icon size
Use size props to controll the size. Pass className to control icon's styling:
import { css } from 'emotion';
<Icon name="check" />

View File

@ -0,0 +1,98 @@
import React, { ChangeEvent, useState } from 'react';
import { css } from 'emotion';
import { Input } from '../Input/Input';
import { Field } from '../Forms/Field';
import { Icon } from './Icon';
import { getAvailableIcons, IconName } from '../../types';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { useTheme, selectThemeVariant } from '../../themes';
import mdx from './Icon.mdx';
export default {
title: 'General/Icons',
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
const IconWrapper: React.FC<{ name: IconName }> = ({ name }) => {
const theme = useTheme();
const borderColor = selectThemeVariant(
{
light: theme.colors.gray5,
dark: theme.colors.dark6,
},
theme.type
);
return (
<div
className={css`
width: 150px;
padding: 12px;
border: 1px solid ${borderColor};
text-align: center;
&:hover {
background: ${borderColor};
}
`}
>
<Icon name={name} />
<div
className={css`
padding-top: 16px;
word-break: break-all;
font-family: ${theme.typography.fontFamily.monospace};
font-size: ${theme.typography.size.xs};
`}
>
{name}
</div>
</div>
);
};
const icons = getAvailableIcons().sort((a, b) => a.localeCompare(b));
export const simple = () => {
const [filter, setFilter] = useState('');
const searchIcon = (event: ChangeEvent<HTMLInputElement>) => {
setFilter(event.target.value);
};
return (
<div
className={css`
display: flex;
flex-direction: column;
width: 100%;
`}
>
<Field
className={css`
width: 300px;
`}
>
<Input onChange={searchIcon} placeholder="Search icons by name" />
</Field>
<div
className={css`
display: flex;
flex-wrap: wrap;
`}
>
{icons
.filter(val => val.includes(filter))
.map(i => {
return <IconWrapper name={i} key={i} />;
})}
</div>
</div>
);
};

View File

@ -0,0 +1,40 @@
import { Props, Preview } from "@storybook/addon-docs/blocks";
import { Input } from "./Input";
import { Field } from "../Forms/Field";
import { Icon } from "../Icon/Icon";
# Input
Used for regular text input. For an array of data or tree-structured data, consider using `Select` or `Cascader` respectively.
## Prefix and suffix
To add more context to the input you can add either text or an icon before or after the input. You can use the `prefix` and `suffix` props for this. Try some examples in the canvas!
```jsx
<Input prefix={<Icon name="search" />} size="sm" />
```
<Preview>
<Input prefix={<Icon name="search" />} size="sm" />
</Preview>
## Usage in forms with Field
`Input` should be used with the `Field` component to get labels and descriptions. It should also be used for validation. See the `Field` component for more information.
```jsx
<Field label="Important information" description="This information is very important, so you really need to fill it in">
<Input name="importantInput" required />
</Field>
```
<Preview>
<Field
label="Important information"
description="This information is very important, so you really need to fill it in"
>
<Input name="importantInput" required />
</Field>
</Preview>
<Props of={Input} />

View File

@ -1,40 +1,110 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { zip, fromPairs } from 'lodash'; import { boolean, text, select, number } from '@storybook/addon-knobs';
import { storiesOf } from '@storybook/react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { Input } from './Input'; import { Input } from './Input';
import { text, select } from '@storybook/addon-knobs'; import { Button } from '../Button';
import { EventsWithValidation } from '../../utils'; import mdx from './Input.mdx';
import { getAvailableIcons, IconName } from '../../types';
import { KeyValue } from '@grafana/data';
import { Icon } from '../Icon/Icon';
import { Field } from '../Forms/Field';
const getKnobs = () => { export default {
return { title: 'Forms/Input',
validation: text('Validation regex (will do a partial match if you do not anchor it)', ''), component: Input,
validationErrorMessage: text('Validation error message', 'Input not valid'), decorators: [withCenteredStory],
validationEvent: select( parameters: {
'Validation event', docs: {
fromPairs(zip(Object.keys(EventsWithValidation), Object.values(EventsWithValidation))), page: mdx,
EventsWithValidation.onBlur },
), },
};
}; };
const Wrapper = () => { export const simple = () => {
const { validation, validationErrorMessage, validationEvent } = getKnobs(); const prefixSuffixOpts = {
None: null,
Text: '$',
...getAvailableIcons().reduce<KeyValue<string>>((prev, c) => {
return {
...prev,
[`Icon: ${c}`]: `icon-${c}`,
};
}, {}),
};
const BEHAVIOUR_GROUP = 'Behaviour props';
// ---
const type = select(
'Type',
{
text: 'text',
password: 'password',
number: 'number',
},
'text',
BEHAVIOUR_GROUP
);
const disabled = boolean('Disabled', false, BEHAVIOUR_GROUP);
const invalid = boolean('Invalid', false, BEHAVIOUR_GROUP);
const loading = boolean('Loading', false, BEHAVIOUR_GROUP);
const VISUAL_GROUP = 'Visual options';
// ---
const placeholder = text('Placeholder', 'Enter your name here...', VISUAL_GROUP);
const before = boolean('Addon before', false, VISUAL_GROUP);
const after = boolean('Addon after', false, VISUAL_GROUP);
const addonAfter = <Button variant="secondary">Load</Button>;
const addonBefore = <div style={{ display: 'flex', alignItems: 'center', padding: '5px' }}>Input</div>;
const prefix = select('Prefix', prefixSuffixOpts, null, VISUAL_GROUP);
const suffix = select('Suffix', prefixSuffixOpts, null, VISUAL_GROUP);
let prefixEl: any = prefix;
if (prefix && prefix.match(/icon-/g)) {
prefixEl = <Icon name={prefix.replace(/icon-/g, '') as IconName} />;
}
let suffixEl: any = suffix;
if (suffix && suffix.match(/icon-/g)) {
suffixEl = <Icon name={suffix.replace(/icon-/g, '') as IconName} />;
}
const CONTAINER_GROUP = 'Container options';
// ---
const containerWidth = number(
'Container width',
300,
{
range: true,
min: 100,
max: 500,
step: 10,
},
CONTAINER_GROUP
);
return (
<div style={{ width: containerWidth }}>
<Input
disabled={disabled}
prefix={prefixEl}
invalid={invalid}
suffix={suffixEl}
loading={loading}
addonBefore={before && addonBefore}
addonAfter={after && addonAfter}
type={type}
placeholder={placeholder}
/>
</div>
);
};
export const withFieldValidation = () => {
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const validations = {
[validationEvent]: [
{
rule: (value: string) => {
return !!value.match(validation);
},
errorMessage: validationErrorMessage,
},
],
};
return <Input value={value} onChange={e => setValue(e.currentTarget.value)} validationEvents={validations} />;
};
const story = storiesOf('General/Input', module); return (
story.addDecorator(withCenteredStory); <div>
story.add('input', () => <Wrapper />); <Field invalid={value === ''} error={value === '' ? 'This input is required' : ''}>
<Input value={value} onChange={e => setValue(e.currentTarget.value)} />
</Field>
</div>
);
};

View File

@ -1,86 +1,260 @@
import React, { PureComponent, ChangeEvent } from 'react'; import React, { HTMLProps, ReactNode } from 'react';
import classNames from 'classnames'; import { GrafanaTheme } from '@grafana/data';
import { validate, EventsWithValidation, hasValidationEvent } from '../../utils'; import { css, cx } from 'emotion';
import { ValidationEvents, ValidationRule } from '../../types'; import { getFocusStyle, inputSizes, sharedInputStyle } from '../Forms/commonStyles';
import { stylesFactory, useTheme } from '../../themes';
import { Icon } from '../Icon/Icon';
import { useClientRect } from '../../utils/useClientRect';
import { FormInputSize } from '../Forms/types';
export enum LegacyInputStatus { export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'prefix' | 'size'> {
Invalid = 'invalid', /** Show an invalid state around the input */
Valid = 'valid', invalid?: boolean;
/** Show an icon as a prefix in the input */
prefix?: JSX.Element | string | null;
/** Show an icon as a suffix in the input */
suffix?: JSX.Element | string | null;
/** Show a loading indicator as a suffix in the input */
loading?: boolean;
/** Add a component as an addon before the input */
addonBefore?: ReactNode;
/** Add a component as an addon after the input */
addonAfter?: ReactNode;
size?: FormInputSize;
} }
interface Props extends React.HTMLProps<HTMLInputElement> { interface StyleDeps {
validationEvents?: ValidationEvents; theme: GrafanaTheme;
hideErrorMessage?: boolean; invalid: boolean;
inputRef?: React.LegacyRef<HTMLInputElement>;
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => void;
} }
interface State { export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDeps) => {
error: string | null; const colors = theme.colors;
} const borderRadius = theme.border.radius.sm;
const height = theme.spacing.formInputHeight;
export class Input extends PureComponent<Props, State> { const prefixSuffixStaticWidth = '28px';
static defaultProps = { const prefixSuffix = css`
className: '', position: absolute;
}; top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
flex-grow: 0;
flex-shrink: 0;
font-size: ${theme.typography.size.md};
height: 100%;
/* Min width specified for prefix/suffix classes used outside React component*/
min-width: ${prefixSuffixStaticWidth};
`;
state: State = { return {
error: null, // Wraps inputWrapper and addons
}; wrapper: cx(
css`
get status() { label: input-wrapper;
return this.state.error ? LegacyInputStatus.Invalid : LegacyInputStatus.Valid; display: flex;
} width: 100%;
height: ${height};
get isInvalid() { border-radius: ${borderRadius};
return this.status === LegacyInputStatus.Invalid; &:hover {
} > .prefix,
.suffix,
validatorAsync = (validationRules: ValidationRule[]) => { .input {
return (evt: ChangeEvent<HTMLInputElement>) => { border-color: ${invalid ? colors.redBase : colors.formInputBorder};
const errors = validate(evt.target.value, validationRules);
this.setState(prevState => {
return { ...prevState, error: errors ? errors[0] : null };
});
};
};
populateEventPropsWithStatus = (restProps: any, validationEvents: ValidationEvents | undefined) => {
const inputElementProps = { ...restProps };
if (!validationEvents) {
return inputElementProps;
}
Object.keys(EventsWithValidation).forEach(eventName => {
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents) || restProps[eventName]) {
inputElementProps[eventName] = async (evt: ChangeEvent<HTMLInputElement>) => {
evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
if (hasValidationEvent(eventName as EventsWithValidation, validationEvents)) {
await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
} }
if (restProps[eventName]) {
restProps[eventName].apply(null, [evt, this.status]); // only show number buttons on hover
input[type='number'] {
-moz-appearance: number-input;
-webkit-appearance: number-input;
appearance: textfield;
} }
};
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: inner-spin-button !important;
opacity: 1;
}
}
`
),
// Wraps input and prefix/suffix
inputWrapper: css`
label: input-inputWrapper;
position: relative;
flex-grow: 1;
/* we want input to be above addons, especially for focused state */
z-index: 1;
/* when input rendered with addon before only*/
&:not(:first-child):last-child {
> input {
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
} }
});
return inputElementProps; /* when input rendered with addon after only*/
&:first-child:not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
/* when rendered with addon before and after */
&:not(:first-child):not(:last-child) {
> input {
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
input {
/* paddings specified for classes used outside React component */
&:not(:first-child) {
padding-left: ${prefixSuffixStaticWidth};
}
&:not(:last-child) {
padding-right: ${prefixSuffixStaticWidth};
}
&[readonly] {
cursor: default;
}
}
`,
input: cx(
getFocusStyle(theme),
sharedInputStyle(theme, invalid),
css`
label: input-input;
position: relative;
z-index: 0;
flex-grow: 1;
border-radius: ${borderRadius};
height: 100%;
width: 100%;
`
),
inputDisabled: css`
background-color: ${colors.formInputBgDisabled};
color: ${colors.formInputDisabledText};
`,
addon: css`
label: input-addon;
display: flex;
justify-content: center;
align-items: center;
flex-grow: 0;
flex-shrink: 0;
position: relative;
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
> :last-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
> :first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
> *:focus {
/* we want anything that has focus and is an addon to be above input */
z-index: 2;
}
`,
prefix: cx(
prefixSuffix,
css`
label: input-prefix;
padding-left: ${theme.spacing.sm};
padding-right: ${theme.spacing.xs};
border-right: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
`
),
suffix: cx(
prefixSuffix,
css`
label: input-suffix;
padding-right: ${theme.spacing.sm};
padding-left: ${theme.spacing.xs};
border-left: none;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
right: 0;
`
),
loadingIndicator: css`
& + * {
margin-left: ${theme.spacing.xs};
}
`,
}; };
});
render() { export const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const { validationEvents, className, hideErrorMessage, inputRef, ...restProps } = this.props; const { className, addonAfter, addonBefore, prefix, suffix, invalid, loading, size = 'auto', ...restProps } = props;
const { error } = this.state; /**
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className); * Prefix & suffix are positioned absolutely within inputWrapper. We use client rects below to apply correct padding to the input
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents); * when prefix/suffix is larger than default (28px = 16px(icon) + 12px(left/right paddings)).
* Thanks to that prefix/suffix do not overflow the input element itself.
*/
const [prefixRect, prefixRef] = useClientRect<HTMLDivElement>();
const [suffixRect, suffixRef] = useClientRect<HTMLDivElement>();
return ( const theme = useTheme();
<div style={{ flexGrow: 1 }}> const styles = getInputStyles({ theme, invalid: !!invalid });
<input {...inputElementProps} ref={inputRef} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>} return (
<div className={cx(styles.wrapper, inputSizes()[size], className)}>
{!!addonBefore && <div className={styles.addon}>{addonBefore}</div>}
<div className={styles.inputWrapper}>
{prefix && (
<div className={styles.prefix} ref={prefixRef}>
{prefix}
</div>
)}
<input
ref={ref}
className={styles.input}
{...restProps}
style={{
paddingLeft: prefixRect ? prefixRect.width : undefined,
paddingRight: suffixRect ? suffixRect.width : undefined,
}}
/>
{(suffix || loading) && (
<div className={styles.suffix} ref={suffixRef}>
{loading && <Icon name="fa fa-spinner" className={cx('fa-spin', styles.loadingIndicator)} />}
{suffix}
</div>
)}
</div> </div>
);
} {!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
} </div>
);
});
Input.displayName = 'Input';

View File

@ -12,7 +12,7 @@ type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center';
type Align = 'normal' | 'flex-start' | 'flex-end' | 'center'; type Align = 'normal' | 'flex-start' | 'flex-end' | 'center';
export interface LayoutProps { export interface LayoutProps {
children: React.ReactNode[]; children: React.ReactNode[] | React.ReactNode;
orientation?: Orientation; orientation?: Orientation;
spacing?: Spacing; spacing?: Spacing;
justify?: Justify; justify?: Justify;

View File

@ -5,7 +5,7 @@ import {
toFloatOrUndefined, toFloatOrUndefined,
NumberFieldConfigSettings, NumberFieldConfigSettings,
} from '@grafana/data'; } from '@grafana/data';
import { Input } from '../Forms/Input/Input'; import { Input } from '../Input/Input';
export const NumberValueEditor: React.FC<FieldConfigEditorProps<number, NumberFieldConfigSettings>> = ({ export const NumberValueEditor: React.FC<FieldConfigEditorProps<number, NumberFieldConfigSettings>> = ({
value, value,

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { FieldConfigEditorProps, StringFieldConfigSettings } from '@grafana/data'; import { FieldConfigEditorProps, StringFieldConfigSettings } from '@grafana/data';
import { Input } from '../Forms/Input/Input'; import { Input } from '../Input/Input';
export const StringValueEditor: React.FC<FieldConfigEditorProps<string, StringFieldConfigSettings>> = ({ export const StringValueEditor: React.FC<FieldConfigEditorProps<string, StringFieldConfigSettings>> = ({
value, value,

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useTheme } from '../../themes/ThemeContext'; import { useTheme } from '../../themes/ThemeContext';
import { getInputStyles } from '../Forms/Input/Input'; import { getInputStyles } from '../Input/Input';
import { cx, css } from 'emotion'; import { cx, css } from 'emotion';
export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsWithChildren<any>>((props, ref) => { export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsWithChildren<any>>((props, ref) => {

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useTheme } from '../../themes/ThemeContext'; import { useTheme } from '../../themes/ThemeContext';
import { getFocusCss, sharedInputStyle } from '../Forms/commonStyles'; import { getFocusCss, sharedInputStyle } from '../Forms/commonStyles';
import { getInputStyles } from '../Forms/Input/Input'; import { getInputStyles } from '../Input/Input';
import { cx, css } from 'emotion'; import { cx, css } from 'emotion';
import { stylesFactory } from '../../themes'; import { stylesFactory } from '../../themes';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';

View File

@ -20,11 +20,14 @@ export const BackgroundColoredCell: FC<TableCellProps> = props => {
const styles: CSSProperties = { const styles: CSSProperties = {
background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`, background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`,
borderRadius: '0px',
color: 'white', color: 'white',
height: tableStyles.cellHeight, height: tableStyles.cellHeight,
padding: tableStyles.cellPadding, padding: tableStyles.cellPadding,
}; };
return <div style={styles}>{formattedValueToString(displayValue)}</div>; return (
<div className={tableStyles.tableCell} style={styles}>
{formattedValueToString(displayValue)}
</div>
);
}; };

View File

@ -77,7 +77,7 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
return applyFieldOverrides({ return applyFieldOverrides({
data: [data], data: [data],
fieldOptions: { fieldConfig: {
overrides, overrides,
defaults: {}, defaults: {},
}, },
@ -105,10 +105,10 @@ export const BarGaugeCell = () => {
{ {
matcher: { id: FieldMatcherID.byName, options: 'Progress' }, matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [ properties: [
{ prop: 'width', value: '200', custom: true }, { id: 'width', value: '200' },
{ prop: 'displayMode', value: 'gradient-gauge', custom: true }, { id: 'displayMode', value: 'gradient-gauge' },
{ prop: 'min', value: '0' }, { id: 'min', value: '0' },
{ prop: 'max', value: '100' }, { id: 'max', value: '100' },
], ],
}, },
]); ]);
@ -141,11 +141,11 @@ export const ColoredCells = () => {
{ {
matcher: { id: FieldMatcherID.byName, options: 'Progress' }, matcher: { id: FieldMatcherID.byName, options: 'Progress' },
properties: [ properties: [
{ prop: 'width', value: '80', custom: true }, { id: 'width', value: '80' },
{ prop: 'displayMode', value: 'color-background', custom: true }, { id: 'displayMode', value: 'color-background' },
{ prop: 'min', value: '0' }, { id: 'min', value: '0' },
{ prop: 'max', value: '100' }, { id: 'max', value: '100' },
{ prop: 'thresholds', value: defaultThresholds }, { id: 'thresholds', value: defaultThresholds },
], ],
}, },
]); ]);

View File

@ -1,16 +1,18 @@
import React, { FC, memo, useMemo } from 'react'; import React, { FC, memo, useMemo } from 'react';
import { DataFrame, Field } from '@grafana/data'; import { DataFrame, Field } from '@grafana/data';
import { Cell, Column, HeaderGroup, useBlockLayout, useSortBy, useTable } from 'react-table'; import { Cell, Column, HeaderGroup, useBlockLayout, useResizeColumns, useSortBy, useTable } from 'react-table';
import { FixedSizeList } from 'react-window'; import { FixedSizeList } from 'react-window';
import useMeasure from 'react-use/lib/useMeasure'; import useMeasure from 'react-use/lib/useMeasure';
import { getColumns, getTableRows, getTextAlign } from './utils'; import { getColumns, getTableRows, getTextAlign } from './utils';
import { useTheme } from '../../themes'; import { useTheme } from '../../themes';
import { TableFilterActionCallback } from './types'; import { ColumnResizeActionCallback, TableFilterActionCallback } from './types';
import { getTableStyles } from './styles'; import { getTableStyles, TableStyles } from './styles';
import { TableCell } from './TableCell'; import { TableCell } from './TableCell';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
const COLUMN_MIN_WIDTH = 150;
export interface Props { export interface Props {
data: DataFrame; data: DataFrame;
width: number; width: number;
@ -18,94 +20,123 @@ export interface Props {
/** Minimal column width specified in pixels */ /** Minimal column width specified in pixels */
columnMinWidth?: number; columnMinWidth?: number;
noHeader?: boolean; noHeader?: boolean;
resizable?: boolean;
onCellClick?: TableFilterActionCallback; onCellClick?: TableFilterActionCallback;
onColumnResize?: ColumnResizeActionCallback;
} }
export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth, noHeader }) => { export const Table: FC<Props> = memo(
const theme = useTheme(); ({ data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = false }) => {
const [ref, headerRowMeasurements] = useMeasure(); const theme = useTheme();
const tableStyles = getTableStyles(theme); const [ref, headerRowMeasurements] = useMeasure();
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth ?? 150), [data, width, columnMinWidth]); const tableStyles = getTableStyles(theme);
const memoizedData = useMemo(() => getTableRows(data), [data]); const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
const memoizedData = useMemo(() => getTableRows(data), [data]);
const { getTableProps, headerGroups, rows, prepareRow } = useTable( const defaultColumn = React.useMemo(
{ () => ({
columns: memoizedColumns, minWidth: memoizedColumns.reduce((minWidth, column) => {
data: memoizedData, if (column.width) {
}, const width = typeof column.width === 'string' ? parseInt(column.width, 10) : column.width;
useSortBy, return Math.min(minWidth, width);
useBlockLayout }
); return minWidth;
}, columnMinWidth),
}),
[columnMinWidth, memoizedColumns]
);
const RenderRow = React.useCallback( const options: any = useMemo(
({ index, style }) => { () => ({
const row = rows[index]; columns: memoizedColumns,
prepareRow(row); data: memoizedData,
return ( disableResizing: !resizable,
<div {...row.getRowProps({ style })} className={tableStyles.row}> defaultColumn,
{row.cells.map((cell: Cell, index: number) => ( }),
<TableCell [memoizedColumns, memoizedData, resizable, defaultColumn]
key={index} );
field={data.fields[index]}
tableStyles={tableStyles}
cell={cell}
onCellClick={onCellClick}
/>
))}
</div>
);
},
[prepareRow, rows]
);
let totalWidth = 0; const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
options,
useBlockLayout,
useResizeColumns,
useSortBy
);
for (const headerGroup of headerGroups) { const RenderRow = React.useCallback(
for (const header of headerGroup.headers) { ({ index, style }) => {
totalWidth += header.width as number; const row = rows[index];
} prepareRow(row);
} return (
<div {...row.getRowProps({ style })} className={tableStyles.row}>
return ( {row.cells.map((cell: Cell, index: number) => (
<div {...getTableProps()} className={tableStyles.table}> <TableCell
<CustomScrollbar hideVerticalTrack={true}> key={index}
{!noHeader && ( field={data.fields[index]}
<div> tableStyles={tableStyles}
{headerGroups.map((headerGroup: HeaderGroup) => ( cell={cell}
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}> onCellClick={onCellClick}
{headerGroup.headers.map((column: Column, index: number) => />
renderHeaderCell(column, tableStyles.headerCell, data.fields[index])
)}
</div>
))} ))}
</div> </div>
)} );
<FixedSizeList },
height={height - headerRowMeasurements.height} [prepareRow, rows]
itemCount={rows.length} );
itemSize={tableStyles.rowHeight}
width={totalWidth ?? width}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</CustomScrollbar>
</div>
);
});
function renderHeaderCell(column: any, className: string, field?: Field) { return (
const headerProps = column.getHeaderProps(column.getSortByToggleProps()); <div {...getTableProps()} className={tableStyles.table}>
const fieldTextAlign = getTextAlign(field); <CustomScrollbar hideVerticalTrack={true}>
<div style={{ width: `${totalColumnsWidth}px` }}>
{!noHeader && (
<div>
{headerGroups.map((headerGroup: HeaderGroup) => {
return (
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
{headerGroup.headers.map((column: Column, index: number) =>
renderHeaderCell(column, tableStyles, data.fields[index])
)}
</div>
);
})}
</div>
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={'100%'}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</div>
</CustomScrollbar>
</div>
);
}
);
if (fieldTextAlign) { Table.displayName = 'Table';
headerProps.style.textAlign = fieldTextAlign;
function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) {
const headerProps = column.getHeaderProps();
if (column.canResize) {
headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
} }
headerProps.style.textAlign = getTextAlign(field);
return ( return (
<div className={className} {...headerProps}> <div className={tableStyles.headerCell} {...headerProps}>
{column.render('Header')} {column.canSort && (
{column.isSorted && (column.isSortedDesc ? <Icon name="angle-down" /> : <Icon name="angle-up" />)} <div {...column.getSortByToggleProps()}>
{column.render('Header')}
{column.isSorted && (column.isSortedDesc ? <Icon name="angle-down" /> : <Icon name="angle-up" />)}
</div>
)}
{!column.canSort && <div>{column.render('Header')}</div>}
{column.canResize && <div {...column.getResizerProps()} className={tableStyles.resizeHandle} />}
</div> </div>
); );
} }

View File

@ -32,7 +32,7 @@ export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick })
} }
return ( return (
<div {...cellProps} onClick={onClick}> <div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
{cell.render('Cell', { field, tableStyles })} {cell.render('Cell', { field, tableStyles })}
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import { css } from 'emotion'; import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { stylesFactory, selectThemeVariant as stv } from '../../themes'; import { stylesFactory } from '../../themes';
export interface TableStyles { export interface TableStyles {
cellHeight: number; cellHeight: number;
@ -11,14 +11,18 @@ export interface TableStyles {
thead: string; thead: string;
headerCell: string; headerCell: string;
tableCell: string; tableCell: string;
tableCellWrapper: string;
row: string; row: string;
theme: GrafanaTheme; theme: GrafanaTheme;
resizeHandle: string;
} }
export const getTableStyles = stylesFactory( export const getTableStyles = stylesFactory(
(theme: GrafanaTheme): TableStyles => { (theme: GrafanaTheme): TableStyles => {
const colors = theme.colors; const colors = theme.colors;
const headerBg = stv({ light: colors.gray6, dark: colors.dark7 }, theme.type); const headerBg = colors.panelBorder;
const headerBorderColor = theme.isLight ? colors.gray70 : colors.gray05;
const resizerColor = theme.isLight ? colors.blue77 : colors.blue95;
const padding = 6; const padding = 6;
const lineHeight = theme.typography.lineHeight.md; const lineHeight = theme.typography.lineHeight.md;
const bodyFontSize = 14; const bodyFontSize = 14;
@ -41,16 +45,29 @@ export const getTableStyles = stylesFactory(
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
background: ${headerBg}; background: ${headerBg};
position: relative;
`, `,
headerCell: css` headerCell: css`
padding: ${padding}px 10px; padding: ${padding}px 10px;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
color: ${colors.blue}; color: ${colors.blue};
border-right: 1px solid ${headerBorderColor};
&:last-child {
border-right: none;
}
`, `,
row: css` row: css`
label: row; label: row;
border-bottom: 2px solid ${colors.bodyBg}; border-bottom: 1px solid ${headerBg};
`,
tableCellWrapper: css`
border-right: 1px solid ${headerBg};
&:last-child {
border-right: none;
}
`, `,
tableCell: css` tableCell: css`
padding: ${padding}px 10px; padding: ${padding}px 10px;
@ -58,6 +75,25 @@ export const getTableStyles = stylesFactory(
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
`, `,
resizeHandle: css`
label: resizeHandle;
cursor: col-resize !important;
display: inline-block;
border-right: 2px solid ${resizerColor};
opacity: 0;
transition: opacity 0.2s ease-in-out;
width: 10px;
height: 100%;
position: absolute;
right: 0;
top: 0;
z-index: ${theme.zIndex.dropdown};
touch-action: none;
&:hover {
opacity: 1;
}
`,
}; };
} }
); );

View File

@ -23,6 +23,7 @@ export interface TableRow {
} }
export type TableFilterActionCallback = (key: string, value: string) => void; export type TableFilterActionCallback = (key: string, value: string) => void;
export type ColumnResizeActionCallback = (field: Field, width: number) => void;
export interface TableCellProps extends CellProps<any> { export interface TableCellProps extends CellProps<any> {
tableStyles: TableStyles; tableStyles: TableStyles;

View File

@ -2,7 +2,7 @@ import React, { ChangeEvent, KeyboardEvent, PureComponent } from 'react';
import { css, cx } from 'emotion'; import { css, cx } from 'emotion';
import { stylesFactory } from '../../themes/stylesFactory'; import { stylesFactory } from '../../themes/stylesFactory';
import { Button } from '../Button'; import { Button } from '../Button';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
import { TagItem } from './TagItem'; import { TagItem } from './TagItem';
interface Props { interface Props {

View File

@ -3,7 +3,7 @@ import { Threshold, sortThresholds, ThresholdsConfig, ThresholdsMode, Selectable
import { colors } from '../../utils'; import { colors } from '../../utils';
import { getColorFromHexRgbOrName } from '@grafana/data'; import { getColorFromHexRgbOrName } from '@grafana/data';
import { ThemeContext } from '../../themes/ThemeContext'; import { ThemeContext } from '../../themes/ThemeContext';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
import { ColorPicker } from '../ColorPicker/ColorPicker'; import { ColorPicker } from '../ColorPicker/ColorPicker';
import { css } from 'emotion'; import { css } from 'emotion';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup'; import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';

View File

@ -10,7 +10,7 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { colors } from '../../utils'; import { colors } from '../../utils';
import { ThemeContext } from '../../themes/ThemeContext'; import { ThemeContext } from '../../themes/ThemeContext';
import { Input } from '../Forms/Input/Input'; import { Input } from '../Input/Input';
import { ColorPicker } from '../ColorPicker/ColorPicker'; import { ColorPicker } from '../ColorPicker/ColorPicker';
import { stylesFactory } from '../../themes'; import { stylesFactory } from '../../themes';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';

View File

@ -4,7 +4,7 @@ import { stringToDateTimeType, isValidTimeString } from '../time';
import { mapStringsToTimeRange } from './mapper'; import { mapStringsToTimeRange } from './mapper';
import { TimePickerCalendar } from './TimePickerCalendar'; import { TimePickerCalendar } from './TimePickerCalendar';
import Forms from '../../Forms'; import Forms from '../../Forms';
import { Input } from '../../Forms/Input/Input'; import { Input } from '../../Input/Input';
import { Button } from '../../Button'; import { Button } from '../../Button';
interface Props { interface Props {

View File

@ -169,7 +169,7 @@ exports[`TimePicker renders buttons correctly 1`] = `
"red88": "#e02f44", "red88": "#e02f44",
"redBase": "#e02f44", "redBase": "#e02f44",
"redShade": "#c4162a", "redShade": "#c4162a",
"text": "#d8d9da", "text": "#c7d0d9",
"textEmphasis": "#ececec", "textEmphasis": "#ececec",
"textFaint": "#222426", "textFaint": "#222426",
"textStrong": "#ffffff", "textStrong": "#ffffff",
@ -480,7 +480,7 @@ exports[`TimePicker renders content correctly after beeing open 1`] = `
"red88": "#e02f44", "red88": "#e02f44",
"redBase": "#e02f44", "redBase": "#e02f44",
"redShade": "#c4162a", "redShade": "#c4162a",
"text": "#d8d9da", "text": "#c7d0d9",
"textEmphasis": "#ececec", "textEmphasis": "#ececec",
"textFaint": "#222426", "textFaint": "#222426",
"textStrong": "#ffffff", "textStrong": "#ffffff",

View File

@ -0,0 +1,221 @@
import React, { useMemo, useCallback } from 'react';
import { css, cx } from 'emotion';
import { OrganizeFieldsTransformerOptions } from '@grafana/data/src/transformations/transformers/organize';
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
import { TransformerUIRegistyItem, TransformerUIProps } from './types';
import { DataTransformerID, transformersRegistry, DataFrame, GrafanaTheme } from '@grafana/data';
import { stylesFactory, useTheme } from '../../themes';
import { Button } from '../Button';
import { createFieldsComparer } from '@grafana/data/src/transformations/transformers/order';
import { VerticalGroup } from '../Layout/Layout';
import { Input } from '../Input/Input';
interface OrganizeFieldsTransformerEditorProps extends TransformerUIProps<OrganizeFieldsTransformerOptions> {}
const OrganizeFieldsTransformerEditor: React.FC<OrganizeFieldsTransformerEditorProps> = props => {
const { options, input, onChange } = props;
const { indexByName, excludeByName, renameByName } = options;
const fieldNames = useMemo(() => fieldNamesFromInput(input), [input]);
const orderedFieldNames = useMemo(() => orderFieldNamesByIndex(fieldNames, indexByName), [fieldNames, indexByName]);
const onToggleVisibility = useCallback(
(field: string, shouldExclude: boolean) => {
onChange({
...options,
excludeByName: {
...excludeByName,
[field]: shouldExclude,
},
});
},
[onChange, excludeByName, indexByName]
);
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result || !result.destination) {
return;
}
const startIndex = result.source.index;
const endIndex = result.destination.index;
if (startIndex === endIndex) {
return;
}
onChange({
...options,
indexByName: reorderToIndex(fieldNames, startIndex, endIndex),
});
},
[onChange, indexByName, excludeByName, fieldNames]
);
const onRenameField = useCallback(
(from: string, to: string) => {
onChange({
...options,
renameByName: {
...options.renameByName,
[from]: to,
},
});
},
[onChange, fieldNames, renameByName]
);
return (
<VerticalGroup>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sortable-fields-transformer" direction="vertical">
{provided => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{orderedFieldNames.map((fieldName, index) => {
return (
<DraggableFieldName
fieldName={fieldName}
index={index}
onToggleVisibility={onToggleVisibility}
onRenameField={onRenameField}
visible={!excludeByName[fieldName]}
key={fieldName}
/>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</VerticalGroup>
);
};
interface DraggableFieldProps {
fieldName: string;
index: number;
visible: boolean;
onToggleVisibility: (fieldName: string, isVisible: boolean) => void;
onRenameField: (from: string, to: string) => void;
}
const DraggableFieldName: React.FC<DraggableFieldProps> = ({
fieldName,
index,
visible,
onToggleVisibility,
onRenameField,
}) => {
const theme = useTheme();
const styles = getFieldNameStyles(theme);
return (
<Draggable draggableId={fieldName} index={index}>
{provided => (
<div
className={styles.container}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles.left}>
<i className={cx('fa fa-ellipsis-v', styles.draggable)} />
<Button
className={styles.toggle}
variant="link"
size="md"
icon={visible ? 'eye' : 'eye-slash'}
onClick={() => onToggleVisibility(fieldName, visible)}
/>
<span className={styles.name}>{fieldName}</span>
</div>
<div className={styles.right}>
<Input
placeholder={`Rename ${fieldName}`}
onChange={event => onRenameField(fieldName, event.currentTarget.value)}
/>
</div>
</div>
)}
</Draggable>
);
};
const getFieldNameStyles = stylesFactory((theme: GrafanaTheme) => ({
container: css`
display: flex;
align-items: center;
margin-top: 8px;
`,
left: css`
width: 35%;
padding: 0 8px;
border-radius: 3px;
background-color: ${theme.isDark ? theme.colors.grayBlue : theme.colors.gray6};
border: 1px solid ${theme.isDark ? theme.colors.dark6 : theme.colors.gray5};
`,
right: css`
width: 65%;
margin-left: 8px;
`,
toggle: css`
padding: 5px;
margin: 0 5px;
`,
draggable: css`
font-size: ${theme.typography.size.md};
opacity: 0.4;
`,
name: css`
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
`,
}));
const reorderToIndex = (fieldNames: string[], startIndex: number, endIndex: number) => {
const result = Array.from(fieldNames);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result.reduce((nameByIndex, fieldName, index) => {
nameByIndex[fieldName] = index;
return nameByIndex;
}, {} as Record<string, number>);
};
const orderFieldNamesByIndex = (fieldNames: string[], indexByName: Record<string, number> = {}): string[] => {
if (!indexByName || Object.keys(indexByName).length === 0) {
return fieldNames;
}
const comparer = createFieldsComparer(indexByName);
return fieldNames.sort(comparer);
};
const fieldNamesFromInput = (input: DataFrame[]): string[] => {
if (!Array.isArray(input)) {
return [] as string[];
}
return Object.keys(
input.reduce((names, frame) => {
if (!frame || !Array.isArray(frame.fields)) {
return names;
}
return frame.fields.reduce((names, field) => {
names[field.name] = null;
return names;
}, names);
}, {} as Record<string, null>)
);
};
export const organizeFieldsTransformRegistryItem: TransformerUIRegistyItem<OrganizeFieldsTransformerOptions> = {
id: DataTransformerID.organize,
component: OrganizeFieldsTransformerEditor,
transformer: transformersRegistry.get(DataTransformerID.organize),
name: 'Organize fields',
description: 'UI for organizing fields',
};

View File

@ -3,11 +3,13 @@ import { reduceTransformRegistryItem } from './ReduceTransformerEditor';
import { filterFieldsByNameTransformRegistryItem } from './FilterByNameTransformerEditor'; import { filterFieldsByNameTransformRegistryItem } from './FilterByNameTransformerEditor';
import { filterFramesByRefIdTransformRegistryItem } from './FilterByRefIdTransformerEditor'; import { filterFramesByRefIdTransformRegistryItem } from './FilterByRefIdTransformerEditor';
import { TransformerUIRegistyItem } from './types'; import { TransformerUIRegistyItem } from './types';
import { organizeFieldsTransformRegistryItem } from './OrganizeFieldsTransformerEditor';
export const transformersUIRegistry = new Registry<TransformerUIRegistyItem<any>>(() => { export const transformersUIRegistry = new Registry<TransformerUIRegistyItem<any>>(() => {
return [ return [
reduceTransformRegistryItem, reduceTransformRegistryItem,
filterFieldsByNameTransformRegistryItem, filterFieldsByNameTransformRegistryItem,
filterFramesByRefIdTransformRegistryItem, filterFramesByRefIdTransformRegistryItem,
organizeFieldsTransformRegistryItem,
]; ];
}); });

View File

@ -2,7 +2,7 @@ import React, { ChangeEvent, PureComponent } from 'react';
import { FormField } from '../FormField/FormField'; import { FormField } from '../FormField/FormField';
import { FormLabel } from '../FormLabel/FormLabel'; import { FormLabel } from '../FormLabel/FormLabel';
import { Input } from '../Input/Input'; import { Input } from '../Forms/Legacy/Input/Input';
import { Select } from '../Forms/Legacy/Select/Select'; import { Select } from '../Forms/Legacy/Select/Select';
import { MappingType, ValueMapping } from '@grafana/data'; import { MappingType, ValueMapping } from '@grafana/data';

View File

@ -2,7 +2,7 @@ import React, { ChangeEvent } from 'react';
import { HorizontalGroup } from '../Layout/Layout'; import { HorizontalGroup } from '../Layout/Layout';
import { Select } from '../index'; import { Select } from '../index';
import Forms from '../Forms'; import Forms from '../Forms';
import { Input } from '../Forms/Input/Input'; import { Input } from '../Input/Input';
import { MappingType, RangeMap, ValueMap, ValueMapping } from '@grafana/data'; import { MappingType, RangeMap, ValueMap, ValueMapping } from '@grafana/data';
import * as styleMixins from '../../themes/mixins'; import * as styleMixins from '../../themes/mixins';
import { useTheme } from '../../themes'; import { useTheme } from '../../themes';

View File

@ -21,7 +21,6 @@ export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './C
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup'; export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid'; export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
export { LegacyValueMappingsEditor } from './ValueMappingsEditor/LegacyValueMappingsEditor'; export { LegacyValueMappingsEditor } from './ValueMappingsEditor/LegacyValueMappingsEditor';
export { Switch } from './Switch/Switch';
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult'; export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
export { PieChart, PieChartType } from './PieChart/PieChart'; export { PieChart, PieChartType } from './PieChart/PieChart';
export { UnitPicker } from './UnitPicker/UnitPicker'; export { UnitPicker } from './UnitPicker/UnitPicker';
@ -103,8 +102,6 @@ export { DataLinkInput } from './DataLinks/DataLinkInput';
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu'; export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
export { SeriesIcon } from './Legend/SeriesIcon'; export { SeriesIcon } from './Legend/SeriesIcon';
export { transformersUIRegistry } from './TransformersUI/transformers'; export { transformersUIRegistry } from './TransformersUI/transformers';
export { TransformationRow } from './TransformersUI/TransformationRow';
export { TransformationsEditor } from './TransformersUI/TransformationsEditor';
export { JSONFormatter } from './JSONFormatter/JSONFormatter'; export { JSONFormatter } from './JSONFormatter/JSONFormatter';
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer'; export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary'; export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
@ -139,7 +136,7 @@ export { ButtonSelect } from './Select/ButtonSelect';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout'; export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup'; export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
export { Input } from './Forms/Input/Input'; export { Input } from './Input/Input';
// Legacy forms // Legacy forms
@ -150,9 +147,9 @@ import { NoOptionsMessage } from './Forms/Legacy/Select/NoOptionsMessage';
import { ButtonSelect } from './Forms/Legacy/Select/ButtonSelect'; import { ButtonSelect } from './Forms/Legacy/Select/ButtonSelect';
//Input //Input
import { Input, LegacyInputStatus } from './Input/Input'; import { Input, LegacyInputStatus } from './Forms/Legacy/Input/Input';
// Export these until Enterprise migrations have been merged
// export { Input, InputStatus} import { Switch } from './Switch/Switch';
const LegacyForms = { const LegacyForms = {
Select, Select,
@ -161,6 +158,7 @@ const LegacyForms = {
NoOptionsMessage, NoOptionsMessage,
ButtonSelect, ButtonSelect,
Input, Input,
Switch,
}; };
export { Switch };
export { LegacyForms, LegacyInputStatus }; export { LegacyForms, LegacyInputStatus };

View File

@ -204,13 +204,13 @@ $input-bg: $input-black;
$input-bg-disabled: $dark-6; $input-bg-disabled: $dark-6;
$input-color: ${theme.colors.formInputText}; $input-color: ${theme.colors.formInputText};
$input-border-color: $dark-6; $input-border-color: ${theme.colors.gray15};
$input-box-shadow: inset 1px 0px 4px 0px rgba(150, 150, 150, 0.1); $input-box-shadow: inset 1px 0px 4px 0px rgba(150, 150, 150, 0.1);
$input-border-focus: $dark-6 !default; $input-border-focus: ${theme.colors.blue95};
$input-box-shadow-focus: $blue-light !default; $input-box-shadow-focus: $blue-light !default;
$input-color-placeholder: ${theme.colors.formInputPlaceholderText}; $input-color-placeholder: ${theme.colors.formInputPlaceholderText};
$input-label-bg: $gray-blue; $input-label-bg: ${theme.colors.gray15};
$input-label-border-color: $dark-6; $input-label-border-color: ${theme.colors.gray15};
$input-color-select-arrow: $white; $input-color-select-arrow: $white;
// Search // Search

View File

@ -195,15 +195,15 @@ $btn-active-box-shadow: 0px 0px 4px rgba(234, 161, 51, 0.6);
$input-bg: $white; $input-bg: $white;
$input-bg-disabled: $gray-5; $input-bg-disabled: $gray-5;
$input-color: $dark-2; $input-color: ${theme.colors.formInputText};
$input-border-color: $gray-5; $input-border-color: ${theme.colors.gray95};
$input-box-shadow: none; $input-box-shadow: none;
$input-border-focus: $gray-5 !default; $input-border-focus: ${theme.colors.blue95};
$input-box-shadow-focus: $blue-light !default; $input-box-shadow-focus: ${theme.colors.blue95};
$input-color-placeholder: ${theme.colors.formInputPlaceholderText}; $input-color-placeholder: ${theme.colors.formInputPlaceholderText};
$input-label-bg: $gray-5; $input-label-bg: ${theme.colors.gray95};
$input-label-border-color: $gray-5; $input-label-border-color: ${theme.colors.gray95};
$input-color-select-arrow: $gray-1; $input-color-select-arrow: ${theme.colors.gray60};
// search // search
$search-shadow: 0 1px 5px 0 $gray-5; $search-shadow: 0 1px 5px 0 $gray-5;

View File

@ -149,8 +149,8 @@ $input-border-radius-sm: 0 $border-radius-sm $border-radius-sm 0 !default;
$label-border-radius: $border-radius 0 0 $border-radius !default; $label-border-radius: $border-radius 0 0 $border-radius !default;
$label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default; $label-border-radius-sm: $border-radius-sm 0 0 $border-radius-sm !default;
$input-padding: ${theme.spacing.sm}; $input-padding: 0 ${theme.spacing.sm};
$input-height: 35px !default; $input-height: 32px !default;
$cursor-disabled: not-allowed !default; $cursor-disabled: not-allowed !default;

Some files were not shown because too many files have changed in this diff Show More