mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Merge remote branch origin master to icons-unicons
This commit is contained in:
parent
bc468e4b92
commit
3f25d50a39
@ -101,7 +101,10 @@ import { MyComponent } from "./MyComponent";
|
||||
|
||||
### 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
|
||||
<Meta title="Docs Overview/Color Palettes"/>
|
||||
|
@ -8,7 +8,8 @@ aliases = ["/docs/grafana/v1.1", "/docs/grafana/latest/guides/reference/admin",
|
||||
|
||||
# Grafana documentation
|
||||
|
||||
<h2>Installing Grafana</h2>
|
||||
## Installing Grafana
|
||||
|
||||
<div class="nav-cards">
|
||||
<a href="{{< relref "installation/debian.md" >}}" class="nav-cards__item nav-cards__item--install">
|
||||
<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>
|
||||
</a>
|
||||
<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>
|
||||
</a>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h2>Guides</h2>
|
||||
## Guides
|
||||
|
||||
<div class="nav-cards">
|
||||
<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>
|
||||
|
||||
<h2>Data source guides</h2>
|
||||
## Data source guides
|
||||
|
||||
<div class="nav-cards">
|
||||
<a href="{{< relref "features/datasources/graphite.md" >}}" class="nav-cards__item nav-cards__item--ds">
|
||||
<img src="/img/docs/logos/icon_graphite.svg" >
|
||||
|
@ -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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```ini
|
||||
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
@ -150,46 +132,6 @@ allowed_organizations =
|
||||
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
|
||||
|
||||
1. Create a new Custom OpenID Connect application configuration in the Centrify dashboard.
|
||||
|
@ -38,7 +38,7 @@ domain = foo.bar
|
||||
Nginx is a high performance load balancer, web server and reverse proxy: https://www.nginx.com/
|
||||
|
||||
#### Nginx configuration with HTTP and Reverse Proxy enabled
|
||||
```bash
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
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
|
||||
|
||||
```bash
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name foo.bar;
|
||||
@ -98,7 +98,7 @@ root_url = %(protocol)s://%(domain)s/grafana/
|
||||
```
|
||||
|
||||
#### Nginx configuration with sub path
|
||||
```bash
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/www;
|
||||
|
2
go.mod
2
go.mod
@ -30,7 +30,7 @@ require (
|
||||
github.com/gorilla/websocket v1.4.1
|
||||
github.com/gosimple/slug v1.4.2
|
||||
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-plugin v1.0.1
|
||||
github.com/hashicorp/go-version v1.1.0
|
||||
|
5
go.sum
5
go.sum
@ -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/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-sdk-go v0.33.0 h1:+eFcOV/KioHTTRNimENZeajlkw31B+m92RNRShooPEQ=
|
||||
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 h1:IxNaNq8hN3ShQ804FURFOd1ehbKOmFROztY+8vohhW8=
|
||||
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/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
|
||||
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/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/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/go.mod h1:X6nyAIge2JGVmuJt2MFCqmHrb/5IHiphfHtot0s5cnI=
|
||||
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
|
||||
|
@ -282,7 +282,8 @@
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"plugins-bundled/internal/*"
|
||||
],
|
||||
"nohoist": [
|
||||
"**/@types/*",
|
||||
|
@ -0,0 +1,4 @@
|
||||
import { Registry } from '../utils';
|
||||
import { FieldConfigPropertyItem } from '../types';
|
||||
|
||||
export class FieldConfigOptionsRegistry extends Registry<FieldConfigPropertyItem> {}
|
@ -2,46 +2,50 @@ import {
|
||||
FieldOverrideEnv,
|
||||
findNumericFieldMinMax,
|
||||
setFieldConfigDefaults,
|
||||
setDynamicConfigValue,
|
||||
applyFieldOverrides,
|
||||
} from './fieldOverrides';
|
||||
import { MutableDataFrame } from '../dataframe';
|
||||
import { MutableDataFrame, toDataFrame } from '../dataframe';
|
||||
import {
|
||||
FieldConfig,
|
||||
FieldConfigEditorRegistry,
|
||||
FieldOverrideContext,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
GrafanaTheme,
|
||||
FieldType,
|
||||
DataFrame,
|
||||
FieldConfigSource,
|
||||
InterpolateFunction,
|
||||
} from '../types';
|
||||
import { Registry } from '../utils';
|
||||
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
|
||||
import { mockStandardProperties } from '../utils/tests/mockStandardProperties';
|
||||
import { FieldMatcherID } from '../transformations';
|
||||
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
|
||||
const property1 = {
|
||||
id: 'property1', // Match field properties
|
||||
id: 'custom.property1', // Match field properties
|
||||
path: 'property1', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
|
||||
const property2 = {
|
||||
id: 'property2', // Match field properties
|
||||
id: 'custom.property2', // Match field properties
|
||||
path: 'property2', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
|
||||
const unit = {
|
||||
id: 'unit', // Match field properties
|
||||
const property3 = {
|
||||
id: 'custom.property3.nested', // Match field properties
|
||||
path: 'property3.nested', // Match field properties
|
||||
isCustom: true,
|
||||
process: (value: any) => value,
|
||||
shouldApply: () => true,
|
||||
} as any;
|
||||
|
||||
export const customFieldRegistry: FieldConfigEditorRegistry = new Registry<FieldPropertyEditorItem>(() => {
|
||||
return [property1, property2];
|
||||
});
|
||||
|
||||
// For the need of this test we need to mock the standard registry
|
||||
// as we cannot imporrt from grafana/ui
|
||||
standardFieldConfigEditorRegistry.setInit(() => {
|
||||
return [unit];
|
||||
export const customFieldRegistry: FieldConfigOptionsRegistry = new Registry<FieldConfigPropertyItem>(() => {
|
||||
return [property1, property2, property3, ...mockStandardProperties()];
|
||||
});
|
||||
|
||||
describe('Global MinMax', () => {
|
||||
@ -59,6 +63,32 @@ describe('Global MinMax', () => {
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const f0 = new MutableDataFrame({
|
||||
name: 'A',
|
||||
@ -72,12 +102,13 @@ describe('applyFieldOverrides', () => {
|
||||
it('should add scopedVars to fields', () => {
|
||||
const withOverrides = applyFieldOverrides({
|
||||
data: [f0, f1],
|
||||
fieldOptions: {
|
||||
fieldConfig: {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
},
|
||||
replaceVariables: (value: any) => value,
|
||||
theme: {} as GrafanaTheme,
|
||||
fieldConfigRegistry: new FieldConfigOptionsRegistry(),
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@ -132,10 +240,11 @@ describe('setFieldConfigDefaults', () => {
|
||||
unit: 'km',
|
||||
};
|
||||
|
||||
const context: FieldOverrideContext = {
|
||||
const context: FieldOverrideEnv = {
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
};
|
||||
|
||||
// we mutate dsFieldConfig
|
||||
@ -143,6 +252,7 @@ describe('setFieldConfigDefaults', () => {
|
||||
|
||||
expect(dsFieldConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"custom": Object {},
|
||||
"decimals": 2,
|
||||
"max": 100,
|
||||
"min": 0,
|
||||
@ -169,7 +279,7 @@ describe('setFieldConfigDefaults', () => {
|
||||
data: [] as any,
|
||||
field: { type: FieldType.number } as any,
|
||||
dataFrameIndex: 0,
|
||||
custom: customFieldRegistry,
|
||||
fieldConfigRegistry: customFieldRegistry,
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
@ -7,17 +7,21 @@ import {
|
||||
ThresholdsMode,
|
||||
FieldColorMode,
|
||||
ColorScheme,
|
||||
FieldConfigEditorRegistry,
|
||||
FieldOverrideContext,
|
||||
ScopedVars,
|
||||
ApplyFieldOverrideOptions,
|
||||
FieldConfigPropertyItem,
|
||||
} from '../types';
|
||||
import { fieldMatchers, ReducerID, reduceField } from '../transformations';
|
||||
import { FieldMatcher } from '../types/transformations';
|
||||
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 { guessFieldTypeForField } from '../dataframe';
|
||||
import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry';
|
||||
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
|
||||
interface OverrideProps {
|
||||
match: FieldMatcher;
|
||||
@ -59,11 +63,13 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
return [];
|
||||
}
|
||||
|
||||
const source = options.fieldOptions;
|
||||
const source = options.fieldConfig;
|
||||
if (!source) {
|
||||
return options.data;
|
||||
}
|
||||
|
||||
const fieldConfigRegistry = options.fieldConfigRegistry ?? standardFieldConfigEditorRegistry;
|
||||
|
||||
let range: GlobalMinMax | undefined = undefined;
|
||||
|
||||
// Prepare the Matchers
|
||||
@ -105,7 +111,7 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
||||
data: options.data!,
|
||||
dataFrameIndex: index,
|
||||
replaceVariables: options.replaceVariables,
|
||||
custom: options.custom,
|
||||
fieldConfigRegistry: fieldConfigRegistry,
|
||||
};
|
||||
|
||||
// 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 {
|
||||
custom?: FieldConfigEditorRegistry;
|
||||
fieldConfigRegistry: FieldConfigOptionsRegistry;
|
||||
}
|
||||
|
||||
function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) {
|
||||
const reg = value.custom ? context.custom : standardFieldConfigEditorRegistry;
|
||||
|
||||
const item = reg?.getIfExists(value.prop);
|
||||
export function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, context: FieldOverrideEnv) {
|
||||
const reg = context.fieldConfigRegistry;
|
||||
const item = reg.getIfExists(value.id);
|
||||
if (!item || !item.shouldApply(context.field!)) {
|
||||
return;
|
||||
}
|
||||
@ -204,19 +209,19 @@ function setDynamicConfigValue(config: FieldConfig, value: DynamicConfigValue, c
|
||||
const remove = val === undefined || val === null;
|
||||
|
||||
if (remove) {
|
||||
if (value.custom && config.custom) {
|
||||
delete config.custom[value.prop];
|
||||
if (item.isCustom && config.custom) {
|
||||
unset(config.custom, item.path);
|
||||
} else {
|
||||
delete (config as any)[value.prop];
|
||||
unset(config, item.path);
|
||||
}
|
||||
} else {
|
||||
if (value.custom) {
|
||||
if (item.isCustom) {
|
||||
if (!config.custom) {
|
||||
config.custom = {};
|
||||
}
|
||||
config.custom[value.prop] = val;
|
||||
set(config.custom, item.path, val);
|
||||
} 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
|
||||
// defaults -> from Panel config
|
||||
export function setFieldConfigDefaults(config: FieldConfig, defaults: FieldConfig, context: FieldOverrideEnv) {
|
||||
if (defaults) {
|
||||
const keys = Object.keys(defaults);
|
||||
for (const key of keys) {
|
||||
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);
|
||||
}
|
||||
for (const fieldConfigProperty of context.fieldConfigRegistry.list()) {
|
||||
if (fieldConfigProperty.isCustom && !config.custom) {
|
||||
config.custom = {};
|
||||
}
|
||||
processFieldConfigValue(
|
||||
fieldConfigProperty.isCustom ? config.custom : config,
|
||||
fieldConfigProperty.isCustom ? defaults.custom : defaults,
|
||||
fieldConfigProperty,
|
||||
context
|
||||
);
|
||||
}
|
||||
|
||||
validateFieldConfig(config);
|
||||
}
|
||||
|
||||
const processFieldConfigValue = (
|
||||
destination: Record<string, any>, // it's mutable
|
||||
source: Record<string, any>,
|
||||
key: string,
|
||||
registry: FieldConfigEditorRegistry,
|
||||
context: FieldOverrideContext
|
||||
fieldConfigProperty: FieldConfigPropertyItem,
|
||||
context: FieldOverrideEnv
|
||||
) => {
|
||||
const currentConfig = destination[key];
|
||||
const currentConfig = get(destination, fieldConfigProperty.path);
|
||||
|
||||
if (currentConfig === null || currentConfig === undefined) {
|
||||
const item = registry.getIfExists(key);
|
||||
const item = context.fieldConfigRegistry.getIfExists(fieldConfigProperty.id);
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
destination[key] = val;
|
||||
set(destination, item.path, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,5 +3,6 @@ export * from './displayProcessor';
|
||||
export * from './scale';
|
||||
export * from './standardFieldConfigEditorRegistry';
|
||||
export * from './overrides/processors';
|
||||
export { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
|
||||
export { applyFieldOverrides, validateFieldConfig } from './fieldOverrides';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { FieldConfigEditorRegistry, FieldPropertyEditorItem } from '../types/fieldOverrides';
|
||||
import { Registry, RegistryItem } from '../utils/Registry';
|
||||
import { ComponentType } from 'react';
|
||||
import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry';
|
||||
|
||||
export interface StandardEditorProps<TValue = any, TSettings = any> {
|
||||
value: TValue;
|
||||
@ -11,6 +11,6 @@ export interface StandardEditorsRegistryItem<TValue = any, TSettings = any> exte
|
||||
editor: ComponentType<StandardEditorProps<TValue, TSettings>>;
|
||||
settings?: TSettings;
|
||||
}
|
||||
export const standardFieldConfigEditorRegistry: FieldConfigEditorRegistry = new Registry<FieldPropertyEditorItem>();
|
||||
export const standardFieldConfigEditorRegistry = new FieldConfigOptionsRegistry();
|
||||
|
||||
export const standardEditorsRegistry = new Registry<StandardEditorsRegistryItem<any>>();
|
||||
|
@ -1,11 +1,23 @@
|
||||
import React from 'react';
|
||||
import { identityOverrideProcessor, standardEditorsRegistry } from '../field';
|
||||
import { PanelPlugin, standardFieldConfigProperties } from './PanelPlugin';
|
||||
import { identityOverrideProcessor, standardEditorsRegistry, standardFieldConfigEditorRegistry } from '../field';
|
||||
import { PanelPlugin } from './PanelPlugin';
|
||||
import { FieldConfigProperty } from '../types';
|
||||
|
||||
describe('PanelPlugin', () => {
|
||||
describe('declarative options', () => {
|
||||
beforeAll(() => {
|
||||
standardFieldConfigEditorRegistry.setInit(() => {
|
||||
return [
|
||||
{
|
||||
id: 'min',
|
||||
path: 'min',
|
||||
},
|
||||
{
|
||||
id: 'max',
|
||||
path: 'max',
|
||||
},
|
||||
] as any;
|
||||
});
|
||||
standardEditorsRegistry.setInit(() => {
|
||||
return [
|
||||
{
|
||||
@ -14,26 +26,29 @@ describe('PanelPlugin', () => {
|
||||
] as any;
|
||||
});
|
||||
});
|
||||
|
||||
test('field config UI API', () => {
|
||||
const panel = new PanelPlugin(() => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.setCustomFieldOptions(builder => {
|
||||
builder.addCustomEditor({
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
description: 'Custom field config property description',
|
||||
editor: () => <div>Editor</div>,
|
||||
override: () => <div>Editor</div>,
|
||||
process: identityOverrideProcessor,
|
||||
settings: {},
|
||||
shouldApply: () => true,
|
||||
});
|
||||
panel.useFieldConfig({
|
||||
useCustomConfig: builder => {
|
||||
builder.addCustomEditor({
|
||||
id: 'custom',
|
||||
path: 'custom',
|
||||
name: 'Custom',
|
||||
description: 'Custom field config property description',
|
||||
editor: () => <div>Editor</div>,
|
||||
override: () => <div>Editor</div>,
|
||||
process: identityOverrideProcessor,
|
||||
settings: {},
|
||||
shouldApply: () => true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
expect(panel.customFieldConfigs).toBeDefined();
|
||||
expect(panel.customFieldConfigs!.list()).toHaveLength(1);
|
||||
expect(panel.fieldConfigRegistry.list()).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('options UI API', () => {
|
||||
@ -44,6 +59,7 @@ describe('PanelPlugin', () => {
|
||||
panel.setPanelOptions(builder => {
|
||||
builder.addCustomEditor({
|
||||
id: 'option',
|
||||
path: 'option',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
editor: () => <div>Editor</div>,
|
||||
@ -66,18 +82,19 @@ describe('PanelPlugin', () => {
|
||||
panel.setPanelOptions(builder => {
|
||||
builder
|
||||
.addNumberInput({
|
||||
id: 'numericOption',
|
||||
path: 'numericOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
})
|
||||
.addNumberInput({
|
||||
id: 'numericOptionNoDefault',
|
||||
path: 'numericOptionNoDefault',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'customOption',
|
||||
path: 'customOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
editor: () => <div>Editor</div>,
|
||||
@ -101,7 +118,7 @@ describe('PanelPlugin', () => {
|
||||
|
||||
panel.setPanelOptions(builder => {
|
||||
builder.addNumberInput({
|
||||
id: 'numericOption.nested',
|
||||
path: 'numericOption.nested',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
@ -122,30 +139,33 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.setCustomFieldOptions(builder => {
|
||||
builder
|
||||
.addNumberInput({
|
||||
id: 'numericOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
})
|
||||
.addNumberInput({
|
||||
id: 'numericOptionNoDefault',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'customOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
editor: () => <div>Editor</div>,
|
||||
override: () => <div>Override editor</div>,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
settings: {},
|
||||
defaultValue: { value: 'Custom default value' },
|
||||
});
|
||||
panel.useFieldConfig({
|
||||
useCustomConfig: builder => {
|
||||
builder
|
||||
.addNumberInput({
|
||||
path: 'numericOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
})
|
||||
.addNumberInput({
|
||||
path: 'numericOptionNoDefault',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
})
|
||||
.addCustomEditor({
|
||||
id: 'customOption',
|
||||
path: 'customOption',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
editor: () => <div>Editor</div>,
|
||||
override: () => <div>Override editor</div>,
|
||||
process: identityOverrideProcessor,
|
||||
shouldApply: () => true,
|
||||
settings: {},
|
||||
defaultValue: { value: 'Custom default value' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const expectedDefaults = {
|
||||
@ -161,13 +181,15 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.setCustomFieldOptions(builder => {
|
||||
builder.addNumberInput({
|
||||
id: 'numericOption.nested',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
});
|
||||
panel.useFieldConfig({
|
||||
useCustomConfig: builder => {
|
||||
builder.addNumberInput({
|
||||
path: 'numericOption.nested',
|
||||
name: 'Option editor',
|
||||
description: 'Option editor description',
|
||||
defaultValue: 10,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const expectedDefaults = {
|
||||
@ -184,8 +206,8 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.useStandardFieldConfig();
|
||||
expect(panel.standardFieldConfigProperties).toEqual(Array.from(standardFieldConfigProperties.keys()));
|
||||
panel.useFieldConfig();
|
||||
expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('selected standard config', () => {
|
||||
@ -193,8 +215,10 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.useStandardFieldConfig([FieldConfigProperty.Min, FieldConfigProperty.Thresholds]);
|
||||
expect(panel.standardFieldConfigProperties).toEqual(['min', 'thresholds']);
|
||||
panel.useFieldConfig({
|
||||
standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max],
|
||||
});
|
||||
expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('default values', () => {
|
||||
@ -203,17 +227,21 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.useStandardFieldConfig([FieldConfigProperty.Color, FieldConfigProperty.Min], {
|
||||
[FieldConfigProperty.Color]: '#ff00ff',
|
||||
[FieldConfigProperty.Min]: 10,
|
||||
panel.useFieldConfig({
|
||||
standardOptions: [FieldConfigProperty.Max, FieldConfigProperty.Min],
|
||||
standardOptionsDefaults: {
|
||||
[FieldConfigProperty.Max]: 20,
|
||||
[FieldConfigProperty.Min]: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(panel.standardFieldConfigProperties).toEqual(['color', 'min']);
|
||||
expect(panel.fieldConfigRegistry.list()).toHaveLength(2);
|
||||
|
||||
expect(panel.fieldConfigDefaults).toEqual({
|
||||
defaults: {
|
||||
min: 10,
|
||||
color: '#ff00ff',
|
||||
max: 20,
|
||||
custom: {},
|
||||
},
|
||||
overrides: [],
|
||||
});
|
||||
@ -224,16 +252,20 @@ describe('PanelPlugin', () => {
|
||||
return <div>Panel</div>;
|
||||
});
|
||||
|
||||
panel.useStandardFieldConfig([FieldConfigProperty.Color], {
|
||||
[FieldConfigProperty.Color]: '#ff00ff',
|
||||
[FieldConfigProperty.Min]: 10,
|
||||
panel.useFieldConfig({
|
||||
standardOptions: [FieldConfigProperty.Max],
|
||||
standardOptionsDefaults: {
|
||||
[FieldConfigProperty.Max]: 20,
|
||||
[FieldConfigProperty.Min]: 10,
|
||||
},
|
||||
});
|
||||
|
||||
expect(panel.standardFieldConfigProperties).toEqual(['color']);
|
||||
expect(panel.fieldConfigRegistry.list()).toHaveLength(1);
|
||||
|
||||
expect(panel.fieldConfigDefaults).toEqual({
|
||||
defaults: {
|
||||
color: '#ff00ff',
|
||||
max: 20,
|
||||
custom: {},
|
||||
},
|
||||
overrides: [],
|
||||
});
|
||||
|
@ -1,5 +1,4 @@
|
||||
import {
|
||||
FieldConfigEditorRegistry,
|
||||
FieldConfigSource,
|
||||
GrafanaPlugin,
|
||||
PanelEditorProps,
|
||||
@ -9,55 +8,34 @@ import {
|
||||
PanelProps,
|
||||
PanelTypeChangedHandler,
|
||||
FieldConfigProperty,
|
||||
ThresholdsMode,
|
||||
} from '../types';
|
||||
import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders';
|
||||
import { ComponentClass, ComponentType } from 'react';
|
||||
import set from 'lodash/set';
|
||||
import { deprecationWarning } from '../utils';
|
||||
import { FieldConfigOptionsRegistry, standardFieldConfigEditorRegistry } from '../field';
|
||||
|
||||
export const allStandardFieldConfigProperties: FieldConfigProperty[] = [
|
||||
FieldConfigProperty.Min,
|
||||
FieldConfigProperty.Max,
|
||||
FieldConfigProperty.Title,
|
||||
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 interface SetFieldConfigOptionsArgs<TFieldConfigOptions = any> {
|
||||
standardOptions?: FieldConfigProperty[];
|
||||
standardOptionsDefaults?: Partial<Record<FieldConfigProperty, any>>;
|
||||
useCustomConfig?: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void;
|
||||
}
|
||||
|
||||
export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = any> extends GrafanaPlugin<
|
||||
PanelPluginMeta
|
||||
> {
|
||||
private _defaults?: TOptions;
|
||||
private _standardFieldConfigProperties?: Map<FieldConfigProperty, any>;
|
||||
|
||||
private _fieldConfigDefaults: FieldConfigSource<TFieldConfigOptions> = {
|
||||
defaults: {},
|
||||
overrides: [],
|
||||
};
|
||||
private _customFieldConfigs?: FieldConfigEditorRegistry;
|
||||
private customFieldConfigsUIBuilder = new FieldConfigEditorBuilder<TFieldConfigOptions>();
|
||||
private registerCustomFieldConfigs?: (builder: FieldConfigEditorBuilder<TFieldConfigOptions>) => void;
|
||||
|
||||
private _fieldConfigRegistry?: FieldConfigOptionsRegistry;
|
||||
private _initConfigRegistry = () => {
|
||||
return new FieldConfigOptionsRegistry();
|
||||
};
|
||||
|
||||
private _optionEditors?: PanelOptionEditorsRegistry;
|
||||
private optionsUIBuilder = new PanelOptionsEditorBuilder<TOptions>();
|
||||
private registerOptionEditors?: (builder: PanelOptionsEditorBuilder<TOptions>) => void;
|
||||
|
||||
panel: ComponentType<PanelProps<TOptions>>;
|
||||
@ -94,39 +72,21 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
|
||||
}
|
||||
|
||||
get fieldConfigDefaults(): FieldConfigSource<TFieldConfigOptions> {
|
||||
let customPropertiesDefaults = this._fieldConfigDefaults.defaults.custom;
|
||||
const configDefaults = this._fieldConfigDefaults.defaults;
|
||||
configDefaults.custom = {} as TFieldConfigOptions;
|
||||
|
||||
if (!customPropertiesDefaults) {
|
||||
customPropertiesDefaults = {} as TFieldConfigOptions;
|
||||
}
|
||||
const editors = this.customFieldConfigs;
|
||||
|
||||
if (editors && editors.list().length !== 0) {
|
||||
for (const editor of editors.list()) {
|
||||
set(customPropertiesDefaults, editor.id, editor.defaultValue);
|
||||
}
|
||||
for (const option of this.fieldConfigRegistry.list()) {
|
||||
set(configDefaults, option.id, option.defaultValue);
|
||||
}
|
||||
|
||||
return {
|
||||
defaults: {
|
||||
...(this._standardFieldConfigProperties ? Object.fromEntries(this._standardFieldConfigProperties) : {}),
|
||||
custom:
|
||||
Object.keys(customPropertiesDefaults).length > 0
|
||||
? {
|
||||
...customPropertiesDefaults,
|
||||
}
|
||||
: undefined,
|
||||
...this._fieldConfigDefaults.defaults,
|
||||
...configDefaults,
|
||||
},
|
||||
// TODO: not sure yet what about overrides, if anything
|
||||
overrides: this._fieldConfigDefaults.overrides,
|
||||
};
|
||||
}
|
||||
|
||||
get standardFieldConfigProperties() {
|
||||
return this._standardFieldConfigProperties ? Array.from(this._standardFieldConfigProperties.keys()) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated setDefaults is deprecated in favor of setPanelOptions
|
||||
*/
|
||||
@ -136,19 +96,19 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
|
||||
return this;
|
||||
}
|
||||
|
||||
get customFieldConfigs() {
|
||||
if (!this._customFieldConfigs && this.registerCustomFieldConfigs) {
|
||||
this.registerCustomFieldConfigs(this.customFieldConfigsUIBuilder);
|
||||
this._customFieldConfigs = this.customFieldConfigsUIBuilder.getRegistry();
|
||||
get fieldConfigRegistry() {
|
||||
if (!this._fieldConfigRegistry) {
|
||||
this._fieldConfigRegistry = this._initConfigRegistry();
|
||||
}
|
||||
|
||||
return this._customFieldConfigs;
|
||||
return this._fieldConfigRegistry;
|
||||
}
|
||||
|
||||
get optionEditors() {
|
||||
if (!this._optionEditors && this.registerOptionEditors) {
|
||||
this.registerOptionEditors(this.optionsUIBuilder);
|
||||
this._optionEditors = this.optionsUIBuilder.getRegistry();
|
||||
const builder = new PanelOptionsEditorBuilder<TOptions>();
|
||||
this.registerOptionEditors(builder);
|
||||
this._optionEditors = builder.getRegistry();
|
||||
}
|
||||
|
||||
return this._optionEditors;
|
||||
@ -188,47 +148,6 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
|
||||
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
|
||||
*
|
||||
@ -277,44 +196,93 @@ export class PanelPlugin<TOptions = any, TFieldConfigOptions extends object = an
|
||||
*
|
||||
* // when plugin should use all standard options
|
||||
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
|
||||
* .useStandardFieldConfig();
|
||||
* .useFieldConfig();
|
||||
*
|
||||
* // when plugin should only display specific standard options
|
||||
* // note, that options will be displayed in the order they are provided
|
||||
* 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
|
||||
* export const plugin = new PanelPlugin<ShapePanelOptions>(ShapePanel)
|
||||
* .useStandardFieldConfig([FieldConfigProperty.Min, FieldConfigProperty.Max], {
|
||||
* [FieldConfigProperty.Min]: 20,
|
||||
* [FieldConfigProperty.Max]: 100
|
||||
* .useFieldConfig({
|
||||
* standardOptions: [FieldConfigProperty.Min, FieldConfigProperty.Max],
|
||||
* 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
|
||||
*/
|
||||
useStandardFieldConfig(
|
||||
properties?: FieldConfigProperty[] | null,
|
||||
customDefaults?: Partial<Record<FieldConfigProperty, any>>
|
||||
) {
|
||||
if (!properties) {
|
||||
this._standardFieldConfigProperties = standardFieldConfigProperties;
|
||||
return this;
|
||||
} else {
|
||||
this._standardFieldConfigProperties = new Map(properties.map(p => [p, standardFieldConfigProperties.get(p)]));
|
||||
}
|
||||
useFieldConfig(config?: SetFieldConfigOptionsArgs<TFieldConfigOptions>) {
|
||||
// builder is applied lazily when custom field configs are accessed
|
||||
this._initConfigRegistry = () => {
|
||||
const registry = new FieldConfigOptionsRegistry();
|
||||
|
||||
const defaults = customDefaults ?? standardFieldConfigDefaults;
|
||||
// Add custom options
|
||||
if (config && config.useCustomConfig) {
|
||||
const builder = new FieldConfigEditorBuilder<TFieldConfigOptions>();
|
||||
config.useCustomConfig(builder);
|
||||
|
||||
if (defaults) {
|
||||
Object.keys(defaults).map(k => {
|
||||
if (properties.indexOf(k as FieldConfigProperty) > -1) {
|
||||
this._standardFieldConfigProperties!.set(k as FieldConfigProperty, defaults[k as FieldConfigProperty]);
|
||||
for (const customProp of builder.getRegistry().list()) {
|
||||
customProp.isCustom = true;
|
||||
// need to do something to make the custom items not conflict with standard ones
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,10 @@ import { filterFieldsByNameTransformer, FilterFieldsByNameTransformerOptions } f
|
||||
import { noopTransformer } from './transformers/noop';
|
||||
import { DataTransformerConfig, DataTransformerInfo } from '../types/transformations';
|
||||
import { filterFramesByRefIdTransformer } from './transformers/filterByRefId';
|
||||
import { orderFieldsTransformer } from './transformers/order';
|
||||
import { organizeFieldsTransformer } from './transformers/organize';
|
||||
import { seriesToColumnsTransformer } from './transformers/seriesToColumns';
|
||||
import { renameFieldsTransformer } from './transformers/rename';
|
||||
|
||||
// Initalize the Registry
|
||||
|
||||
@ -67,9 +70,12 @@ export const transformersRegistry = new TransformerRegistry(() => [
|
||||
filterFieldsByNameTransformer,
|
||||
filterFramesTransformer,
|
||||
filterFramesByRefIdTransformer,
|
||||
orderFieldsTransformer,
|
||||
organizeFieldsTransformer,
|
||||
appendTransformer,
|
||||
reduceTransformer,
|
||||
seriesToColumnsTransformer,
|
||||
renameFieldsTransformer,
|
||||
]);
|
||||
|
||||
export { ReduceTransformerOptions, FilterFieldsByNameTransformerOptions };
|
||||
|
@ -3,6 +3,9 @@ export enum DataTransformerID {
|
||||
append = 'append', // Merge all series together
|
||||
// rotate = 'rotate', // Columns to rows
|
||||
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
|
||||
filterFields = 'filterFields', // Pick some fields (keep all frames)
|
||||
|
@ -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]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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})$`;
|
||||
};
|
@ -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]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
});
|
||||
};
|
@ -6,7 +6,7 @@ import { NumberFieldConfigSettings, SelectFieldConfigSettings, StringFieldConfig
|
||||
* Option editor registry item
|
||||
*/
|
||||
export interface OptionsEditorItem<TOptions, TSettings, TEditorProps, TValue> extends RegistryItem {
|
||||
id: (keyof TOptions & string) | string;
|
||||
path: (keyof TOptions & string) | string;
|
||||
editor: ComponentType<TEditorProps>;
|
||||
settings?: TSettings;
|
||||
defaultValue?: TValue;
|
||||
|
@ -15,9 +15,8 @@ import { StandardEditorProps } from '../field';
|
||||
import { OptionsEditorItem } from './OptionsUIRegistryBuilder';
|
||||
|
||||
export interface DynamicConfigValue {
|
||||
prop: string;
|
||||
id: string;
|
||||
value?: any;
|
||||
custom?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigOverrideRule {
|
||||
@ -43,19 +42,19 @@ export interface FieldOverrideContext {
|
||||
|
||||
export interface FieldConfigEditorProps<TValue, TSettings>
|
||||
extends Omit<StandardEditorProps<TValue, TSettings>, 'item'> {
|
||||
item: FieldPropertyEditorItem<TValue, TSettings>; // The property info
|
||||
item: FieldConfigPropertyItem<TValue, TSettings>; // The property info
|
||||
value: TValue;
|
||||
context: FieldOverrideContext;
|
||||
onChange: (value?: TValue) => void;
|
||||
}
|
||||
|
||||
export interface FieldOverrideEditorProps<TValue, TSettings> extends Omit<StandardEditorProps<TValue>, 'item'> {
|
||||
item: FieldPropertyEditorItem<TValue, TSettings>;
|
||||
item: FieldConfigPropertyItem<TValue, TSettings>;
|
||||
context: FieldOverrideContext;
|
||||
}
|
||||
|
||||
export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any> {
|
||||
id: (keyof TOptions & string) | string;
|
||||
path: (keyof TOptions & string) | string;
|
||||
name: string;
|
||||
description: string;
|
||||
settings?: TSettings;
|
||||
@ -63,11 +62,14 @@ export interface FieldConfigEditorConfig<TOptions, TSettings = any, TValue = any
|
||||
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> {
|
||||
// An editor that can be filled in with context info (template variables etc)
|
||||
override: ComponentType<FieldOverrideEditorProps<TValue, TSettings>>;
|
||||
|
||||
/** true for plugin field config properties */
|
||||
isCustom?: boolean;
|
||||
|
||||
// Convert the override value to a well typed value
|
||||
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;
|
||||
}
|
||||
|
||||
export type FieldConfigEditorRegistry = Registry<FieldPropertyEditorItem>;
|
||||
|
||||
export interface ApplyFieldOverrideOptions {
|
||||
data?: DataFrame[];
|
||||
fieldOptions: FieldConfigSource;
|
||||
fieldConfig: FieldConfigSource;
|
||||
replaceVariables: InterpolateFunction;
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
autoMinMax?: boolean;
|
||||
standard?: FieldConfigEditorRegistry;
|
||||
custom?: FieldConfigEditorRegistry;
|
||||
fieldConfigRegistry?: Registry<FieldConfigPropertyItem>;
|
||||
}
|
||||
|
||||
export enum FieldConfigProperty {
|
||||
|
@ -19,6 +19,7 @@ export * from './datasource';
|
||||
export * from './panel';
|
||||
export * from './plugin';
|
||||
export * from './thresholds';
|
||||
export * from './templateVars';
|
||||
export * from './fieldColor';
|
||||
export * from './theme';
|
||||
export * from './orgs';
|
||||
|
@ -119,7 +119,7 @@ export interface PanelOptionsEditorItem<TOptions = any, TValue = any, TSettings
|
||||
extends OptionsEditorItem<TOptions, TSettings, PanelOptionsEditorProps<TValue>, TValue> {}
|
||||
|
||||
export interface PanelOptionsEditorConfig<TOptions, TSettings = any, TValue = any> {
|
||||
id: (keyof TOptions & string) | string;
|
||||
path: (keyof TOptions & string) | string;
|
||||
name: string;
|
||||
description: string;
|
||||
settings?: TSettings;
|
||||
|
7
packages/grafana-data/src/types/templateVars.ts
Normal file
7
packages/grafana-data/src/types/templateVars.ts
Normal 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;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
FieldType,
|
||||
FieldConfigEditorProps,
|
||||
FieldPropertyEditorItem,
|
||||
FieldConfigPropertyItem,
|
||||
PanelOptionsEditorConfig,
|
||||
PanelOptionsEditorItem,
|
||||
FieldConfigEditorConfig,
|
||||
@ -29,11 +29,12 @@ import {
|
||||
export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder<
|
||||
TOptions,
|
||||
FieldConfigEditorProps<any, any>,
|
||||
FieldPropertyEditorItem<TOptions>
|
||||
FieldConfigPropertyItem<TOptions>
|
||||
> {
|
||||
addNumberInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
override: standardEditorsRegistry.get('number').editor as any,
|
||||
editor: standardEditorsRegistry.get('number').editor as any,
|
||||
process: numberOverrideProcessor,
|
||||
@ -45,6 +46,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
addTextInput<TSettings>(config: FieldConfigEditorConfig<TOptions, TSettings & StringFieldConfigSettings, string>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
override: standardEditorsRegistry.get('text').editor as any,
|
||||
editor: standardEditorsRegistry.get('text').editor as any,
|
||||
process: stringOverrideProcessor,
|
||||
@ -58,6 +60,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
override: standardEditorsRegistry.get('select').editor as any,
|
||||
editor: standardEditorsRegistry.get('select').editor as any,
|
||||
process: selectOverrideProcessor,
|
||||
@ -70,6 +73,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
addRadio<TOption, TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, TOption>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
override: standardEditorsRegistry.get('radio').editor as any,
|
||||
editor: standardEditorsRegistry.get('radio').editor as any,
|
||||
process: selectOverrideProcessor,
|
||||
@ -82,6 +86,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
addBooleanSwitch<TSettings = any>(config: FieldConfigEditorConfig<TOptions, TSettings, boolean>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('boolean').editor as any,
|
||||
override: standardEditorsRegistry.get('boolean').editor as any,
|
||||
process: booleanOverrideProcessor,
|
||||
@ -95,6 +100,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('color').editor as any,
|
||||
override: standardEditorsRegistry.get('color').editor as any,
|
||||
process: identityOverrideProcessor,
|
||||
@ -108,6 +114,7 @@ export class FieldConfigEditorBuilder<TOptions> extends OptionsUIRegistryBuilder
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('unit').editor as any,
|
||||
override: standardEditorsRegistry.get('unit').editor as any,
|
||||
process: unitOverrideProcessor,
|
||||
@ -128,6 +135,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
|
||||
addNumberInput<TSettings>(config: PanelOptionsEditorConfig<TOptions, TSettings & NumberFieldConfigSettings, number>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
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>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('text').editor as any,
|
||||
});
|
||||
}
|
||||
@ -144,6 +153,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('select').editor as any,
|
||||
});
|
||||
}
|
||||
@ -153,6 +163,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
|
||||
) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
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>) {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('boolean').editor as any,
|
||||
});
|
||||
}
|
||||
@ -169,6 +181,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
|
||||
): this {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('color').editor as any,
|
||||
settings: config.settings || {},
|
||||
});
|
||||
@ -179,6 +192,7 @@ export class PanelOptionsEditorBuilder<TOptions> extends OptionsUIRegistryBuilde
|
||||
): this {
|
||||
return this.addCustomEditor({
|
||||
...config,
|
||||
id: config.path,
|
||||
editor: standardEditorsRegistry.get('unit').editor as any,
|
||||
});
|
||||
}
|
||||
|
@ -124,7 +124,7 @@ export class Registry<T extends RegistryItem> {
|
||||
if (!this.initialized) {
|
||||
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) {
|
||||
|
170
packages/grafana-data/src/utils/tests/mockStandardProperties.ts
Normal file
170
packages/grafana-data/src/utils/tests/mockStandardProperties.ts
Normal 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];
|
||||
};
|
@ -3,3 +3,4 @@ export * from './AngularLoader';
|
||||
export * from './dataSourceSrv';
|
||||
export * from './LocationSrv';
|
||||
export * from './EchoSrv';
|
||||
export * from './templateSrv';
|
||||
|
13
packages/grafana-runtime/src/services/templateSrv.ts
Normal file
13
packages/grafana-runtime/src/services/templateSrv.ts
Normal 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;
|
@ -4,10 +4,16 @@ const fs = require('fs');
|
||||
|
||||
entrypoint = () => {
|
||||
const defaultEntryPoint = '../src/cli/index.js';
|
||||
// We are running in dev mode. Don't use compiled binaries, rather use the dev entrypoint.
|
||||
if (fs.existsSync(`${process.env['HOME']}/.config/yarn/link/@grafana/toolkit`)) {
|
||||
console.log('Running in linked mode');
|
||||
return `${__dirname}/grafana-toolkit.js`;
|
||||
const toolkitDirectory = `${process.env['PWD']}/node_modules/@grafana/toolkit`;
|
||||
|
||||
// IF we have a toolkit directory AND linked grafana toolkit AND the toolkit dir is a symbolic lik
|
||||
// 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
|
||||
|
@ -67,6 +67,7 @@
|
||||
"execa": "^1.0.0",
|
||||
"expect-puppeteer": "4.1.1",
|
||||
"file-loader": "^4.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "1.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"globby": "^10.0.1",
|
||||
"html-loader": "0.5.5",
|
||||
|
@ -18,6 +18,7 @@ import { githubPublishTask } from './tasks/plugin.utils';
|
||||
import { ciBuildPluginTask, ciBuildPluginDocsTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci';
|
||||
import { buildPackageTask } from './tasks/package.build';
|
||||
import { pluginCreateTask } from './tasks/plugin.create';
|
||||
import { bundleManagedTask } from './tasks/plugin/bundle.managed';
|
||||
|
||||
export const run = (includeInternalScripts = false) => {
|
||||
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
|
||||
.command('plugin:github-publish')
|
||||
.option('--dryrun', 'Do a dry run only', false)
|
||||
|
@ -13,6 +13,7 @@ describe('Manifest', () => {
|
||||
"manifest.ts",
|
||||
"nodeVersionChecker.ts",
|
||||
"package.build.ts",
|
||||
"plugin/bundle.managed.ts",
|
||||
"plugin/bundle.ts",
|
||||
"plugin/create.ts",
|
||||
"plugin/tests.ts",
|
||||
|
@ -10,9 +10,11 @@ import path = require('path');
|
||||
import execa = require('execa');
|
||||
|
||||
interface Command extends Array<any> {}
|
||||
const DEFAULT_EMAIL_ADDRESS = 'eng@grafana.com';
|
||||
const DEFAULT_USERNAME = 'CircleCI Automation';
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@ -62,12 +64,10 @@ const prepareRelease = useSpinner<any>('Preparing release', async ({ dryrun, ver
|
||||
const distContentDir = path.resolve(distDir, getPluginId());
|
||||
const pluginJsonFile = path.resolve(distContentDir, 'plugin.json');
|
||||
const pluginJson = getPluginJson(pluginJsonFile);
|
||||
const GIT_EMAIL = 'eng@grafana.com';
|
||||
const GIT_USERNAME = 'CircleCI Automation';
|
||||
|
||||
const githubPublishScript: Command = [
|
||||
['git', ['config', 'user.email', GIT_EMAIL]],
|
||||
['git', ['config', 'user.name', GIT_USERNAME]],
|
||||
['git', ['config', 'user.email', DEFAULT_EMAIL_ADDRESS]],
|
||||
['git', ['config', 'user.name', DEFAULT_USERNAME]],
|
||||
await checkoutBranch(`release-${pluginJson.info.version}`),
|
||||
['cp', ['-rf', distContentDir, 'dist']],
|
||||
['git', ['add', '--force', distDir], { dryrun }],
|
||||
@ -138,14 +138,14 @@ const prepareRelease = useSpinner<any>('Preparing release', async ({ dryrun, ver
|
||||
interface GithubPublishReleaseOptions {
|
||||
commitHash?: string;
|
||||
githubToken: string;
|
||||
gitRepoOwner: string;
|
||||
githubUser: string;
|
||||
gitRepoName: string;
|
||||
}
|
||||
|
||||
const createRelease = useSpinner<GithubPublishReleaseOptions>(
|
||||
'Creating release',
|
||||
async ({ commitHash, githubToken, gitRepoName, gitRepoOwner }) => {
|
||||
const gitRelease = new GitHubRelease(githubToken, gitRepoOwner, gitRepoName, await releaseNotes(), commitHash);
|
||||
async ({ commitHash, githubUser, githubToken, gitRepoName }) => {
|
||||
const gitRelease = new GitHubRelease(githubToken, githubUser, gitRepoName, await releaseNotes(), commitHash);
|
||||
return gitRelease.release();
|
||||
}
|
||||
);
|
||||
@ -159,16 +159,37 @@ export interface GithubPublishOptions {
|
||||
|
||||
const githubPublishRunner: TaskRunner<GithubPublishOptions> = async ({ dryrun, verbose, commitHash }) => {
|
||||
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']) {
|
||||
throw `Github publish requires that you set the environment variable GITHUB_TOKEN to a valid github api token.
|
||||
See: https://github.com/settings/tokens for more details.`;
|
||||
if (!process.env['GITHUB_ACCESS_TOKEN']) {
|
||||
// Try to use GITHUB_TOKEN, which may be set.
|
||||
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 githubToken = process.env['GITHUB_TOKEN'];
|
||||
const githubToken = process.env['GITHUB_ACCESS_TOKEN'];
|
||||
const githubUser = parsedUrl.owner;
|
||||
|
||||
await prepareRelease({
|
||||
dryrun,
|
||||
@ -177,8 +198,8 @@ const githubPublishRunner: TaskRunner<GithubPublishOptions> = async ({ dryrun, v
|
||||
|
||||
await createRelease({
|
||||
commitHash,
|
||||
githubUser,
|
||||
githubToken,
|
||||
gitRepoOwner: parsedUrl.owner,
|
||||
gitRepoName: parsedUrl.name,
|
||||
});
|
||||
};
|
||||
|
@ -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);
|
10
packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts
Normal file
10
packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts
Normal 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);
|
||||
});
|
||||
});
|
@ -9,6 +9,9 @@ import GithubClient from './githubClient';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
const resolveContentType = (extension: string): string => {
|
||||
if (extension.startsWith('.')) {
|
||||
extension = extension.substr(1);
|
||||
}
|
||||
switch (extension) {
|
||||
case 'zip':
|
||||
return 'application/zip';
|
||||
@ -37,30 +40,23 @@ class GitHubRelease {
|
||||
this.commitHash = commitHash;
|
||||
|
||||
this.git = new GithubClient({
|
||||
required: true,
|
||||
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.
|
||||
fs.readdir(srcLocation, (err: NodeJS.ErrnoException | null, files: string[]) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
const files = fs.readdirSync(srcLocation);
|
||||
|
||||
files.forEach(async (file: string) => {
|
||||
const fileStat = fs.statSync(`${srcLocation}/${file}`);
|
||||
const fileData = fs.readFileSync(`${srcLocation}/${file}`);
|
||||
try {
|
||||
await this.git.client.post(`${destUrl}?name=${file}`, fileData, {
|
||||
headers: {
|
||||
'Content-Type': resolveContentType(path.extname(file)),
|
||||
'Content-Length': fileStat.size,
|
||||
},
|
||||
});
|
||||
} catch (reason) {
|
||||
console.log('Could not post', reason);
|
||||
}
|
||||
return files.map(async (file: string) => {
|
||||
const fileStat = fs.statSync(`${srcLocation}/${file}`);
|
||||
const fileData = fs.readFileSync(`${srcLocation}/${file}`);
|
||||
return this.git.client.post(`${destUrl}?name=${file}`, fileData, {
|
||||
headers: {
|
||||
'Content-Type': resolveContentType(path.extname(file)),
|
||||
'Content-Length': fileStat.size,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -75,7 +71,7 @@ class GitHubRelease {
|
||||
const commitHash = this.commitHash || pluginInfo.build?.hash;
|
||||
|
||||
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
|
||||
if (latestRelease.data.tag_name === `v${pluginInfo.version}`) {
|
||||
@ -92,12 +88,15 @@ class GitHubRelease {
|
||||
prerelease: false,
|
||||
});
|
||||
|
||||
this.publishAssets(
|
||||
const publishPromises = this.publishAssets(
|
||||
PUBLISH_DIR,
|
||||
`https://uploads.github.com/repos/${this.username}/${this.repository}/releases/${newReleaseResponse.data.id}/assets`
|
||||
);
|
||||
await Promise.all(publishPromises);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ const TerserPlugin = require('terser-webpack-plugin');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');
|
||||
|
||||
const readdirPromise = util.promisify(fs.readdir);
|
||||
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',
|
||||
options: { onlyCompileBundledFiles: true },
|
||||
options: {
|
||||
onlyCompileBundledFiles: true,
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
exclude: /(node_modules)/,
|
||||
|
@ -38,6 +38,7 @@ addParameters({
|
||||
light: GrafanaLight,
|
||||
},
|
||||
options: {
|
||||
theme: GrafanaLight,
|
||||
showPanel: true,
|
||||
showRoots: true,
|
||||
panelPosition: 'bottom',
|
||||
|
@ -33,6 +33,7 @@
|
||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||
"@iconscout/react-unicons": "^1.0.0",
|
||||
"@torkelo/react-select": "3.0.8",
|
||||
"@types/react-beautiful-dnd": "12.1.2",
|
||||
"@types/react-color": "3.0.1",
|
||||
"@types/react-select": "3.0.8",
|
||||
"@types/react-table": "7.0.12",
|
||||
@ -52,6 +53,7 @@
|
||||
"rc-slider": "9.2.3",
|
||||
"rc-time-picker": "^3.7.3",
|
||||
"react": "16.12.0",
|
||||
"react-beautiful-dnd": "13.0.0",
|
||||
"react-calendar": "2.19.2",
|
||||
"react-color": "2.18.0",
|
||||
"react-custom-scrollbars": "4.2.1",
|
||||
|
@ -59,6 +59,7 @@ const buildCjsPackage = ({ env }) => {
|
||||
'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'],
|
||||
'../../node_modules/esrever/esrever.js': ['reverse'],
|
||||
'../../node_modules/react-table/index.js': ['useTable', 'useSortBy', 'useBlockLayout', 'Cell'],
|
||||
'../../node_modules/react-is/index.js': ['isValidElementType', 'isContextConsumer'],
|
||||
},
|
||||
}),
|
||||
resolve(),
|
||||
|
@ -4,7 +4,7 @@ import RCCascader from 'rc-cascader';
|
||||
|
||||
import { Select } from '../Select/Select';
|
||||
import { FormInputSize } from '../Forms/types';
|
||||
import { Input } from '../Forms/Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { onChangeCascader } from './optionMappings';
|
||||
|
@ -3,7 +3,7 @@ import React, { useState } from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { ClipboardButton } from './ClipboardButton';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Input } from '../Forms/Legacy/Input/Input';
|
||||
import { text } from '@storybook/addon-knobs';
|
||||
|
||||
const getKnobs = () => {
|
||||
|
@ -3,7 +3,7 @@ import tinycolor from 'tinycolor2';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import { ColorPickerProps } from './ColorPickerPopover';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Input } from '../Forms/Legacy/Input/Input';
|
||||
|
||||
interface ColorInputState {
|
||||
previousColor: string;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { ChangeEvent, useContext } from 'react';
|
||||
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 { ThemeContext, stylesFactory } from '../../themes/index';
|
||||
import { DataLinkInput } from './DataLinkInput';
|
||||
|
@ -9,7 +9,7 @@ import { DataSourceSettings } from '@grafana/data';
|
||||
import { HttpSettingsProps } from './types';
|
||||
import { CustomHeadersSettings } from './CustomHeadersSettings';
|
||||
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 { FormLabel } from '../FormLabel/FormLabel';
|
||||
import { Switch } from '../Switch/Switch';
|
||||
|
@ -48,7 +48,7 @@ export const TLSAuthSettings: React.FC<HttpSettingsBaseProps> = ({ dataSourceCon
|
||||
theme="info"
|
||||
>
|
||||
<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>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
@ -1,5 +1,4 @@
|
||||
.form-field {
|
||||
margin-bottom: $space-xxs;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
@ -34,7 +34,7 @@ export const FormLabel: FunctionComponent<Props> = ({
|
||||
{tooltip && (
|
||||
<Tooltip placement="top" content={tooltip} theme={'info'}>
|
||||
<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>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { boolean, number, text } from '@storybook/addon-knobs';
|
||||
import { Field } from './Field';
|
||||
import { Input } from './Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Switch } from './Switch';
|
||||
import mdx from './Field.mdx';
|
||||
|
||||
|
@ -4,7 +4,7 @@ import { Legend } from './Legend';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { withStoryContainer } from '../../utils/storybook/withStoryContainer';
|
||||
import { Field } from './Field';
|
||||
import { Input } from './Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Button } from '../Button';
|
||||
import { Form } from './Form';
|
||||
import { Switch } from './Switch';
|
||||
|
@ -1,6 +0,0 @@
|
||||
import { Props } from '@storybook/addon-docs/blocks';
|
||||
import { Input } from './Input';
|
||||
|
||||
# Input
|
||||
|
||||
<Props of={Input} />
|
@ -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>
|
||||
);
|
||||
};
|
@ -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';
|
@ -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 />);
|
@ -2,8 +2,8 @@ import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import { shallow } from 'enzyme';
|
||||
import { Input } from './Input';
|
||||
import { EventsWithValidation } from '../../utils';
|
||||
import { ValidationEvents } from '../../types';
|
||||
import { EventsWithValidation } from '../../../../utils';
|
||||
import { ValidationEvents } from '../../../../types';
|
||||
|
||||
const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
|
||||
const testBlurValidation: ValidationEvents = {
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
$select-input-height: 35px;
|
||||
$select-input-height: 32px;
|
||||
$select-input-bg-disabled: $input-bg-disabled;
|
||||
|
||||
@mixin select-control() {
|
||||
@ -122,7 +122,7 @@ $select-input-bg-disabled: $input-bg-disabled;
|
||||
|
||||
.gf-form-select-box__value-container {
|
||||
display: inline-block;
|
||||
padding: 8px 16px 8px 10px;
|
||||
padding: 6px 16px 6px 10px;
|
||||
vertical-align: middle;
|
||||
|
||||
> div {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTheme, stylesFactory, selectThemeVariant as stv } from '../../../themes';
|
||||
import { useTheme, stylesFactory } from '../../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
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 c = theme.colors;
|
||||
|
||||
const textColor = stv({ light: c.gray33, dark: c.gray70 }, theme.type);
|
||||
const textColorHover = stv({ light: c.blueShade, dark: c.blueLight }, theme.type);
|
||||
const textColorActive = stv({ light: c.blueShade, dark: c.blueLight }, theme.type);
|
||||
const borderColor = stv({ light: c.gray4, dark: c.gray25 }, theme.type);
|
||||
const borderColorHover = stv({ light: c.gray70, dark: c.gray33 }, theme.type);
|
||||
const borderColorActive = stv({ light: c.blueShade, dark: c.blueLight }, theme.type);
|
||||
const bg = stv({ light: c.gray98, dark: c.gray10 }, theme.type);
|
||||
const bgDisabled = stv({ light: c.gray95, dark: c.gray15 }, theme.type);
|
||||
const bgActive = stv({ light: c.white, dark: c.gray05 }, theme.type);
|
||||
const textColor = theme.isLight ? c.gray33 : c.gray70;
|
||||
const textColorHover = theme.isLight ? c.blueShade : c.blueLight;
|
||||
const textColorActive = theme.isLight ? c.blueShade : c.blueLight;
|
||||
const borderColor = theme.isLight ? c.gray4 : c.gray25;
|
||||
const borderColorHover = theme.isLight ? c.gray70 : c.gray33;
|
||||
const borderColorActive = theme.isLight ? c.blueShade : c.blueLight;
|
||||
const bg = c.pageBg;
|
||||
const bgDisabled = theme.isLight ? c.gray95 : c.gray15;
|
||||
const bgActive = theme.isLight ? c.white : c.gray05;
|
||||
|
||||
const border = `1px solid ${borderColor}`;
|
||||
const borderActive = `1px solid ${borderColorActive}`;
|
||||
|
@ -12,16 +12,18 @@ export interface Props extends Omit<HTMLProps<HTMLTextAreaElement>, 'size'> {
|
||||
size?: FormInputSize;
|
||||
}
|
||||
|
||||
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(({ invalid, size = 'auto', ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getTextAreaStyle(theme, invalid);
|
||||
export const TextArea = React.forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ invalid, size = 'auto', className, ...props }, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getTextAreaStyle(theme, invalid);
|
||||
|
||||
return (
|
||||
<div className={inputSizes()[size]}>
|
||||
<textarea className={styles.textarea} {...props} ref={ref} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div className={inputSizes()[size]}>
|
||||
<textarea {...props} className={cx(styles.textarea, className)} ref={ref} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const getTextAreaStyle = stylesFactory((theme: GrafanaTheme, invalid = false) => {
|
||||
return {
|
||||
|
@ -5,7 +5,7 @@ import { getLegendStyles } from './Legend';
|
||||
import { getFieldValidationMessageStyles } from './FieldValidationMessage';
|
||||
import { getButtonStyles, ButtonVariant } from '../Button';
|
||||
import { ComponentSize } from '../../types/size';
|
||||
import { getInputStyles } from './Input/Input';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { getSwitchStyles } from './Switch';
|
||||
import { getCheckboxStyles } from './Checkbox';
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { Controller as InputControl } from 'react-hook-form';
|
||||
import { getFormStyles } from './getFormStyles';
|
||||
import { Label } from './Label';
|
||||
// To be removed
|
||||
import { Input } from './Input/Input';
|
||||
import { RadioButtonGroup } from './RadioButtonGroup/RadioButtonGroup';
|
||||
import { Form } from './Form';
|
||||
import { Field } from './Field';
|
||||
@ -16,8 +14,6 @@ const Forms = {
|
||||
Switch,
|
||||
getFormStyles,
|
||||
Label,
|
||||
// To be removed
|
||||
Input,
|
||||
Form,
|
||||
Field,
|
||||
InputControl,
|
||||
|
12
packages/grafana-ui/src/components/Icon/Icon.mdx
Normal file
12
packages/grafana-ui/src/components/Icon/Icon.mdx
Normal 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" />
|
98
packages/grafana-ui/src/components/Icon/Icon.story.tsx
Normal file
98
packages/grafana-ui/src/components/Icon/Icon.story.tsx
Normal 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>
|
||||
);
|
||||
};
|
40
packages/grafana-ui/src/components/Input/Input.mdx
Normal file
40
packages/grafana-ui/src/components/Input/Input.mdx
Normal 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} />
|
@ -1,40 +1,110 @@
|
||||
import React, { useState } from 'react';
|
||||
import { zip, fromPairs } from 'lodash';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, text, select, number } from '@storybook/addon-knobs';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { Input } from './Input';
|
||||
import { text, select } from '@storybook/addon-knobs';
|
||||
import { EventsWithValidation } from '../../utils';
|
||||
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 '../Forms/Field';
|
||||
|
||||
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
|
||||
),
|
||||
};
|
||||
export default {
|
||||
title: 'Forms/Input',
|
||||
component: Input,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Wrapper = () => {
|
||||
const { validation, validationErrorMessage, validationEvent } = getKnobs();
|
||||
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}
|
||||
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 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 />);
|
||||
return (
|
||||
<div>
|
||||
<Field invalid={value === ''} error={value === '' ? 'This input is required' : ''}>
|
||||
<Input value={value} onChange={e => setValue(e.currentTarget.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,86 +1,260 @@
|
||||
import React, { PureComponent, ChangeEvent } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { validate, EventsWithValidation, hasValidationEvent } from '../../utils';
|
||||
import { ValidationEvents, ValidationRule } from '../../types';
|
||||
import React, { HTMLProps, ReactNode } from 'react';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { css, cx } from 'emotion';
|
||||
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 {
|
||||
Invalid = 'invalid',
|
||||
Valid = 'valid',
|
||||
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 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 StyleDeps {
|
||||
theme: GrafanaTheme;
|
||||
invalid: boolean;
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: string | null;
|
||||
}
|
||||
export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDeps) => {
|
||||
const colors = theme.colors;
|
||||
const borderRadius = theme.border.radius.sm;
|
||||
const height = theme.spacing.formInputHeight;
|
||||
|
||||
export class Input extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
className: '',
|
||||
};
|
||||
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};
|
||||
`;
|
||||
|
||||
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]);
|
||||
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};
|
||||
}
|
||||
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() {
|
||||
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);
|
||||
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>();
|
||||
|
||||
return (
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<input {...inputElementProps} ref={inputRef} className={inputClassName} />
|
||||
{error && !hideErrorMessage && <span>{error}</span>}
|
||||
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 && <Icon name="fa fa-spinner" className={cx('fa-spin', styles.loadingIndicator)} />}
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
{!!addonAfter && <div className={styles.addon}>{addonAfter}</div>}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
@ -12,7 +12,7 @@ type Justify = 'flex-start' | 'flex-end' | 'space-between' | 'center';
|
||||
type Align = 'normal' | 'flex-start' | 'flex-end' | 'center';
|
||||
|
||||
export interface LayoutProps {
|
||||
children: React.ReactNode[];
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
orientation?: Orientation;
|
||||
spacing?: Spacing;
|
||||
justify?: Justify;
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
toFloatOrUndefined,
|
||||
NumberFieldConfigSettings,
|
||||
} from '@grafana/data';
|
||||
import { Input } from '../Forms/Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
|
||||
export const NumberValueEditor: React.FC<FieldConfigEditorProps<number, NumberFieldConfigSettings>> = ({
|
||||
value,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
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>> = ({
|
||||
value,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../themes/ThemeContext';
|
||||
import { getInputStyles } from '../Forms/Input/Input';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { cx, css } from 'emotion';
|
||||
|
||||
export const IndicatorsContainer = React.forwardRef<HTMLDivElement, React.PropsWithChildren<any>>((props, ref) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from '../../themes/ThemeContext';
|
||||
import { getFocusCss, sharedInputStyle } from '../Forms/commonStyles';
|
||||
import { getInputStyles } from '../Forms/Input/Input';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { cx, css } from 'emotion';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
@ -20,11 +20,14 @@ export const BackgroundColoredCell: FC<TableCellProps> = props => {
|
||||
|
||||
const styles: CSSProperties = {
|
||||
background: `linear-gradient(120deg, ${bgColor2}, ${displayValue.color})`,
|
||||
borderRadius: '0px',
|
||||
color: 'white',
|
||||
height: tableStyles.cellHeight,
|
||||
padding: tableStyles.cellPadding,
|
||||
};
|
||||
|
||||
return <div style={styles}>{formattedValueToString(displayValue)}</div>;
|
||||
return (
|
||||
<div className={tableStyles.tableCell} style={styles}>
|
||||
{formattedValueToString(displayValue)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -77,7 +77,7 @@ function buildData(theme: GrafanaTheme, overrides: ConfigOverrideRule[]): DataFr
|
||||
|
||||
return applyFieldOverrides({
|
||||
data: [data],
|
||||
fieldOptions: {
|
||||
fieldConfig: {
|
||||
overrides,
|
||||
defaults: {},
|
||||
},
|
||||
@ -105,10 +105,10 @@ export const BarGaugeCell = () => {
|
||||
{
|
||||
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
|
||||
properties: [
|
||||
{ prop: 'width', value: '200', custom: true },
|
||||
{ prop: 'displayMode', value: 'gradient-gauge', custom: true },
|
||||
{ prop: 'min', value: '0' },
|
||||
{ prop: 'max', value: '100' },
|
||||
{ id: 'width', value: '200' },
|
||||
{ id: 'displayMode', value: 'gradient-gauge' },
|
||||
{ id: 'min', value: '0' },
|
||||
{ id: 'max', value: '100' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@ -141,11 +141,11 @@ export const ColoredCells = () => {
|
||||
{
|
||||
matcher: { id: FieldMatcherID.byName, options: 'Progress' },
|
||||
properties: [
|
||||
{ prop: 'width', value: '80', custom: true },
|
||||
{ prop: 'displayMode', value: 'color-background', custom: true },
|
||||
{ prop: 'min', value: '0' },
|
||||
{ prop: 'max', value: '100' },
|
||||
{ prop: 'thresholds', value: defaultThresholds },
|
||||
{ id: 'width', value: '80' },
|
||||
{ id: 'displayMode', value: 'color-background' },
|
||||
{ id: 'min', value: '0' },
|
||||
{ id: 'max', value: '100' },
|
||||
{ id: 'thresholds', value: defaultThresholds },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
@ -1,16 +1,18 @@
|
||||
import React, { FC, memo, useMemo } from 'react';
|
||||
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 useMeasure from 'react-use/lib/useMeasure';
|
||||
import { getColumns, getTableRows, getTextAlign } from './utils';
|
||||
import { useTheme } from '../../themes';
|
||||
import { TableFilterActionCallback } from './types';
|
||||
import { getTableStyles } from './styles';
|
||||
import { ColumnResizeActionCallback, TableFilterActionCallback } from './types';
|
||||
import { getTableStyles, TableStyles } from './styles';
|
||||
import { TableCell } from './TableCell';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
|
||||
const COLUMN_MIN_WIDTH = 150;
|
||||
|
||||
export interface Props {
|
||||
data: DataFrame;
|
||||
width: number;
|
||||
@ -18,94 +20,123 @@ export interface Props {
|
||||
/** Minimal column width specified in pixels */
|
||||
columnMinWidth?: number;
|
||||
noHeader?: boolean;
|
||||
resizable?: boolean;
|
||||
onCellClick?: TableFilterActionCallback;
|
||||
onColumnResize?: ColumnResizeActionCallback;
|
||||
}
|
||||
|
||||
export const Table: FC<Props> = memo(({ data, height, onCellClick, width, columnMinWidth, noHeader }) => {
|
||||
const theme = useTheme();
|
||||
const [ref, headerRowMeasurements] = useMeasure();
|
||||
const tableStyles = getTableStyles(theme);
|
||||
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth ?? 150), [data, width, columnMinWidth]);
|
||||
const memoizedData = useMemo(() => getTableRows(data), [data]);
|
||||
export const Table: FC<Props> = memo(
|
||||
({ data, height, onCellClick, width, columnMinWidth = COLUMN_MIN_WIDTH, noHeader, resizable = false }) => {
|
||||
const theme = useTheme();
|
||||
const [ref, headerRowMeasurements] = useMeasure();
|
||||
const tableStyles = getTableStyles(theme);
|
||||
const memoizedColumns = useMemo(() => getColumns(data, width, columnMinWidth), [data, width, columnMinWidth]);
|
||||
const memoizedData = useMemo(() => getTableRows(data), [data]);
|
||||
|
||||
const { getTableProps, headerGroups, rows, prepareRow } = useTable(
|
||||
{
|
||||
columns: memoizedColumns,
|
||||
data: memoizedData,
|
||||
},
|
||||
useSortBy,
|
||||
useBlockLayout
|
||||
);
|
||||
const defaultColumn = React.useMemo(
|
||||
() => ({
|
||||
minWidth: memoizedColumns.reduce((minWidth, column) => {
|
||||
if (column.width) {
|
||||
const width = typeof column.width === 'string' ? parseInt(column.width, 10) : column.width;
|
||||
return Math.min(minWidth, width);
|
||||
}
|
||||
return minWidth;
|
||||
}, columnMinWidth),
|
||||
}),
|
||||
[columnMinWidth, memoizedColumns]
|
||||
);
|
||||
|
||||
const RenderRow = React.useCallback(
|
||||
({ index, style }) => {
|
||||
const row = rows[index];
|
||||
prepareRow(row);
|
||||
return (
|
||||
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
||||
{row.cells.map((cell: Cell, index: number) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
field={data.fields[index]}
|
||||
tableStyles={tableStyles}
|
||||
cell={cell}
|
||||
onCellClick={onCellClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[prepareRow, rows]
|
||||
);
|
||||
const options: any = useMemo(
|
||||
() => ({
|
||||
columns: memoizedColumns,
|
||||
data: memoizedData,
|
||||
disableResizing: !resizable,
|
||||
defaultColumn,
|
||||
}),
|
||||
[memoizedColumns, memoizedData, resizable, defaultColumn]
|
||||
);
|
||||
|
||||
let totalWidth = 0;
|
||||
const { getTableProps, headerGroups, rows, prepareRow, totalColumnsWidth } = useTable(
|
||||
options,
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useSortBy
|
||||
);
|
||||
|
||||
for (const headerGroup of headerGroups) {
|
||||
for (const header of headerGroup.headers) {
|
||||
totalWidth += header.width as number;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...getTableProps()} className={tableStyles.table}>
|
||||
<CustomScrollbar hideVerticalTrack={true}>
|
||||
{!noHeader && (
|
||||
<div>
|
||||
{headerGroups.map((headerGroup: HeaderGroup) => (
|
||||
<div className={tableStyles.thead} {...headerGroup.getHeaderGroupProps()} ref={ref}>
|
||||
{headerGroup.headers.map((column: Column, index: number) =>
|
||||
renderHeaderCell(column, tableStyles.headerCell, data.fields[index])
|
||||
)}
|
||||
</div>
|
||||
const RenderRow = React.useCallback(
|
||||
({ index, style }) => {
|
||||
const row = rows[index];
|
||||
prepareRow(row);
|
||||
return (
|
||||
<div {...row.getRowProps({ style })} className={tableStyles.row}>
|
||||
{row.cells.map((cell: Cell, index: number) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
field={data.fields[index]}
|
||||
tableStyles={tableStyles}
|
||||
cell={cell}
|
||||
onCellClick={onCellClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FixedSizeList
|
||||
height={height - headerRowMeasurements.height}
|
||||
itemCount={rows.length}
|
||||
itemSize={tableStyles.rowHeight}
|
||||
width={totalWidth ?? width}
|
||||
style={{ overflow: 'hidden auto' }}
|
||||
>
|
||||
{RenderRow}
|
||||
</FixedSizeList>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
[prepareRow, rows]
|
||||
);
|
||||
|
||||
function renderHeaderCell(column: any, className: string, field?: Field) {
|
||||
const headerProps = column.getHeaderProps(column.getSortByToggleProps());
|
||||
const fieldTextAlign = getTextAlign(field);
|
||||
return (
|
||||
<div {...getTableProps()} className={tableStyles.table}>
|
||||
<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) {
|
||||
headerProps.style.textAlign = fieldTextAlign;
|
||||
Table.displayName = 'Table';
|
||||
|
||||
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 (
|
||||
<div className={className} {...headerProps}>
|
||||
{column.render('Header')}
|
||||
{column.isSorted && (column.isSortedDesc ? <Icon name="angle-down" /> : <Icon name="angle-up" />)}
|
||||
<div className={tableStyles.headerCell} {...headerProps}>
|
||||
{column.canSort && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ export const TableCell: FC<Props> = ({ cell, field, tableStyles, onCellClick })
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...cellProps} onClick={onClick}>
|
||||
<div {...cellProps} onClick={onClick} className={tableStyles.tableCellWrapper}>
|
||||
{cell.render('Cell', { field, tableStyles })}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { css } from 'emotion';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { stylesFactory, selectThemeVariant as stv } from '../../themes';
|
||||
import { stylesFactory } from '../../themes';
|
||||
|
||||
export interface TableStyles {
|
||||
cellHeight: number;
|
||||
@ -11,14 +11,18 @@ export interface TableStyles {
|
||||
thead: string;
|
||||
headerCell: string;
|
||||
tableCell: string;
|
||||
tableCellWrapper: string;
|
||||
row: string;
|
||||
theme: GrafanaTheme;
|
||||
resizeHandle: string;
|
||||
}
|
||||
|
||||
export const getTableStyles = stylesFactory(
|
||||
(theme: GrafanaTheme): TableStyles => {
|
||||
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 lineHeight = theme.typography.lineHeight.md;
|
||||
const bodyFontSize = 14;
|
||||
@ -41,16 +45,29 @@ export const getTableStyles = stylesFactory(
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: ${headerBg};
|
||||
position: relative;
|
||||
`,
|
||||
headerCell: css`
|
||||
padding: ${padding}px 10px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: ${colors.blue};
|
||||
border-right: 1px solid ${headerBorderColor};
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
`,
|
||||
row: css`
|
||||
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`
|
||||
padding: ${padding}px 10px;
|
||||
@ -58,6 +75,25 @@ export const getTableStyles = stylesFactory(
|
||||
white-space: nowrap;
|
||||
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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -23,6 +23,7 @@ export interface TableRow {
|
||||
}
|
||||
|
||||
export type TableFilterActionCallback = (key: string, value: string) => void;
|
||||
export type ColumnResizeActionCallback = (field: Field, width: number) => void;
|
||||
|
||||
export interface TableCellProps extends CellProps<any> {
|
||||
tableStyles: TableStyles;
|
||||
|
@ -2,7 +2,7 @@ import React, { ChangeEvent, KeyboardEvent, PureComponent } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { stylesFactory } from '../../themes/stylesFactory';
|
||||
import { Button } from '../Button';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Input } from '../Forms/Legacy/Input/Input';
|
||||
import { TagItem } from './TagItem';
|
||||
|
||||
interface Props {
|
||||
|
@ -3,7 +3,7 @@ import { Threshold, sortThresholds, ThresholdsConfig, ThresholdsMode, Selectable
|
||||
import { colors } from '../../utils';
|
||||
import { getColorFromHexRgbOrName } from '@grafana/data';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { Input } from '../Input/Input';
|
||||
import { Input } from '../Forms/Legacy/Input/Input';
|
||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { css } from 'emotion';
|
||||
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from '@grafana/data';
|
||||
import { colors } from '../../utils';
|
||||
import { ThemeContext } from '../../themes/ThemeContext';
|
||||
import { Input } from '../Forms/Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
import { ColorPicker } from '../ColorPicker/ColorPicker';
|
||||
import { stylesFactory } from '../../themes';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
@ -4,7 +4,7 @@ import { stringToDateTimeType, isValidTimeString } from '../time';
|
||||
import { mapStringsToTimeRange } from './mapper';
|
||||
import { TimePickerCalendar } from './TimePickerCalendar';
|
||||
import Forms from '../../Forms';
|
||||
import { Input } from '../../Forms/Input/Input';
|
||||
import { Input } from '../../Input/Input';
|
||||
import { Button } from '../../Button';
|
||||
|
||||
interface Props {
|
||||
|
@ -169,7 +169,7 @@ exports[`TimePicker renders buttons correctly 1`] = `
|
||||
"red88": "#e02f44",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"text": "#c7d0d9",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
@ -480,7 +480,7 @@ exports[`TimePicker renders content correctly after beeing open 1`] = `
|
||||
"red88": "#e02f44",
|
||||
"redBase": "#e02f44",
|
||||
"redShade": "#c4162a",
|
||||
"text": "#d8d9da",
|
||||
"text": "#c7d0d9",
|
||||
"textEmphasis": "#ececec",
|
||||
"textFaint": "#222426",
|
||||
"textStrong": "#ffffff",
|
||||
|
@ -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',
|
||||
};
|
@ -3,11 +3,13 @@ import { reduceTransformRegistryItem } from './ReduceTransformerEditor';
|
||||
import { filterFieldsByNameTransformRegistryItem } from './FilterByNameTransformerEditor';
|
||||
import { filterFramesByRefIdTransformRegistryItem } from './FilterByRefIdTransformerEditor';
|
||||
import { TransformerUIRegistyItem } from './types';
|
||||
import { organizeFieldsTransformRegistryItem } from './OrganizeFieldsTransformerEditor';
|
||||
|
||||
export const transformersUIRegistry = new Registry<TransformerUIRegistyItem<any>>(() => {
|
||||
return [
|
||||
reduceTransformRegistryItem,
|
||||
filterFieldsByNameTransformRegistryItem,
|
||||
filterFramesByRefIdTransformRegistryItem,
|
||||
organizeFieldsTransformRegistryItem,
|
||||
];
|
||||
});
|
||||
|
@ -2,7 +2,7 @@ import React, { ChangeEvent, PureComponent } from 'react';
|
||||
|
||||
import { FormField } from '../FormField/FormField';
|
||||
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 { MappingType, ValueMapping } from '@grafana/data';
|
||||
|
@ -2,7 +2,7 @@ import React, { ChangeEvent } from 'react';
|
||||
import { HorizontalGroup } from '../Layout/Layout';
|
||||
import { Select } from '../index';
|
||||
import Forms from '../Forms';
|
||||
import { Input } from '../Forms/Input/Input';
|
||||
import { Input } from '../Input/Input';
|
||||
import { MappingType, RangeMap, ValueMap, ValueMapping } from '@grafana/data';
|
||||
import * as styleMixins from '../../themes/mixins';
|
||||
import { useTheme } from '../../themes';
|
||||
|
@ -21,7 +21,6 @@ export { SeriesColorPickerPopover, SeriesColorPickerPopoverWithTheme } from './C
|
||||
export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
|
||||
export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';
|
||||
export { LegacyValueMappingsEditor } from './ValueMappingsEditor/LegacyValueMappingsEditor';
|
||||
export { Switch } from './Switch/Switch';
|
||||
export { EmptySearchResult } from './EmptySearchResult/EmptySearchResult';
|
||||
export { PieChart, PieChartType } from './PieChart/PieChart';
|
||||
export { UnitPicker } from './UnitPicker/UnitPicker';
|
||||
@ -103,8 +102,6 @@ export { DataLinkInput } from './DataLinks/DataLinkInput';
|
||||
export { DataLinksContextMenu } from './DataLinks/DataLinksContextMenu';
|
||||
export { SeriesIcon } from './Legend/SeriesIcon';
|
||||
export { transformersUIRegistry } from './TransformersUI/transformers';
|
||||
export { TransformationRow } from './TransformersUI/TransformationRow';
|
||||
export { TransformationsEditor } from './TransformersUI/TransformationsEditor';
|
||||
export { JSONFormatter } from './JSONFormatter/JSONFormatter';
|
||||
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
|
||||
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
|
||||
@ -139,7 +136,7 @@ export { ButtonSelect } from './Select/ButtonSelect';
|
||||
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
|
||||
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
|
||||
|
||||
export { Input } from './Forms/Input/Input';
|
||||
export { Input } from './Input/Input';
|
||||
|
||||
// Legacy forms
|
||||
|
||||
@ -150,9 +147,9 @@ import { NoOptionsMessage } from './Forms/Legacy/Select/NoOptionsMessage';
|
||||
import { ButtonSelect } from './Forms/Legacy/Select/ButtonSelect';
|
||||
|
||||
//Input
|
||||
import { Input, LegacyInputStatus } from './Input/Input';
|
||||
// Export these until Enterprise migrations have been merged
|
||||
// export { Input, InputStatus}
|
||||
import { Input, LegacyInputStatus } from './Forms/Legacy/Input/Input';
|
||||
|
||||
import { Switch } from './Switch/Switch';
|
||||
|
||||
const LegacyForms = {
|
||||
Select,
|
||||
@ -161,6 +158,7 @@ const LegacyForms = {
|
||||
NoOptionsMessage,
|
||||
ButtonSelect,
|
||||
Input,
|
||||
Switch,
|
||||
};
|
||||
|
||||
export { Switch };
|
||||
export { LegacyForms, LegacyInputStatus };
|
||||
|
@ -204,13 +204,13 @@ $input-bg: $input-black;
|
||||
$input-bg-disabled: $dark-6;
|
||||
|
||||
$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-border-focus: $dark-6 !default;
|
||||
$input-border-focus: ${theme.colors.blue95};
|
||||
$input-box-shadow-focus: $blue-light !default;
|
||||
$input-color-placeholder: ${theme.colors.formInputPlaceholderText};
|
||||
$input-label-bg: $gray-blue;
|
||||
$input-label-border-color: $dark-6;
|
||||
$input-label-bg: ${theme.colors.gray15};
|
||||
$input-label-border-color: ${theme.colors.gray15};
|
||||
$input-color-select-arrow: $white;
|
||||
|
||||
// Search
|
||||
|
@ -195,15 +195,15 @@ $btn-active-box-shadow: 0px 0px 4px rgba(234, 161, 51, 0.6);
|
||||
$input-bg: $white;
|
||||
$input-bg-disabled: $gray-5;
|
||||
|
||||
$input-color: $dark-2;
|
||||
$input-border-color: $gray-5;
|
||||
$input-color: ${theme.colors.formInputText};
|
||||
$input-border-color: ${theme.colors.gray95};
|
||||
$input-box-shadow: none;
|
||||
$input-border-focus: $gray-5 !default;
|
||||
$input-box-shadow-focus: $blue-light !default;
|
||||
$input-border-focus: ${theme.colors.blue95};
|
||||
$input-box-shadow-focus: ${theme.colors.blue95};
|
||||
$input-color-placeholder: ${theme.colors.formInputPlaceholderText};
|
||||
$input-label-bg: $gray-5;
|
||||
$input-label-border-color: $gray-5;
|
||||
$input-color-select-arrow: $gray-1;
|
||||
$input-label-bg: ${theme.colors.gray95};
|
||||
$input-label-border-color: ${theme.colors.gray95};
|
||||
$input-color-select-arrow: ${theme.colors.gray60};
|
||||
|
||||
// search
|
||||
$search-shadow: 0 1px 5px 0 $gray-5;
|
||||
|
@ -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-sm: $border-radius-sm 0 0 $border-radius-sm !default;
|
||||
|
||||
$input-padding: ${theme.spacing.sm};
|
||||
$input-height: 35px !default;
|
||||
$input-padding: 0 ${theme.spacing.sm};
|
||||
$input-height: 32px !default;
|
||||
|
||||
$cursor-disabled: not-allowed !default;
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user