Merge remote branch origin master to icons-unicons

pull/23402/head
Ivana 5 years ago
parent bc468e4b92
commit 3f25d50a39
  1. 5
      contribute/style-guides/storybook.md
  2. 10
      docs/sources/_index.md
  3. 6
      docs/sources/auth/azuread.md
  4. 58
      docs/sources/auth/generic-oauth.md
  5. 6
      docs/sources/installation/behind_proxy.md
  6. 2
      go.mod
  7. 5
      go.sum
  8. 3
      package.json
  9. 4
      packages/grafana-data/src/field/FieldConfigOptionsRegistry.tsx
  10. 265
      packages/grafana-data/src/field/fieldOverrides.test.ts
  11. 76
      packages/grafana-data/src/field/fieldOverrides.ts
  12. 1
      packages/grafana-data/src/field/index.ts
  13. 4
      packages/grafana-data/src/field/standardFieldConfigEditorRegistry.ts
  14. 158
      packages/grafana-data/src/panel/PanelPlugin.test.tsx
  15. 222
      packages/grafana-data/src/panel/PanelPlugin.ts
  16. 6
      packages/grafana-data/src/transformations/transformers.ts
  17. 3
      packages/grafana-data/src/transformations/transformers/ids.ts
  18. 148
      packages/grafana-data/src/transformations/transformers/order.test.ts
  19. 58
      packages/grafana-data/src/transformations/transformers/order.ts
  20. 105
      packages/grafana-data/src/transformations/transformers/organize.test.ts
  21. 53
      packages/grafana-data/src/transformations/transformers/organize.ts
  22. 148
      packages/grafana-data/src/transformations/transformers/rename.test.ts
  23. 54
      packages/grafana-data/src/transformations/transformers/rename.ts
  24. 2
      packages/grafana-data/src/types/OptionsUIRegistryBuilder.ts
  25. 21
      packages/grafana-data/src/types/fieldOverrides.ts
  26. 1
      packages/grafana-data/src/types/index.ts
  27. 2
      packages/grafana-data/src/types/panel.ts
  28. 7
      packages/grafana-data/src/types/templateVars.ts
  29. 18
      packages/grafana-data/src/utils/OptionsUIBuilders.ts
  30. 2
      packages/grafana-data/src/utils/Registry.ts
  31. 170
      packages/grafana-data/src/utils/tests/mockStandardProperties.ts
  32. 1
      packages/grafana-runtime/src/services/index.ts
  33. 13
      packages/grafana-runtime/src/services/templateSrv.ts
  34. 14
      packages/grafana-toolkit/bin/grafana-toolkit.dist.js
  35. 1
      packages/grafana-toolkit/package.json
  36. 8
      packages/grafana-toolkit/src/cli/index.ts
  37. 1
      packages/grafana-toolkit/src/cli/tasks/manifest.test.ts
  38. 49
      packages/grafana-toolkit/src/cli/tasks/plugin.utils.ts
  39. 38
      packages/grafana-toolkit/src/cli/tasks/plugin/bundle.managed.ts
  40. 10
      packages/grafana-toolkit/src/cli/utils/githubRelease.test.ts
  41. 41
      packages/grafana-toolkit/src/cli/utils/githubRelease.ts
  42. 11
      packages/grafana-toolkit/src/config/webpack.plugin.config.ts
  43. 1
      packages/grafana-ui/.storybook/preview.ts
  44. 2
      packages/grafana-ui/package.json
  45. 1
      packages/grafana-ui/rollup.config.ts
  46. 2
      packages/grafana-ui/src/components/Cascader/Cascader.tsx
  47. 2
      packages/grafana-ui/src/components/ClipboardButton/ClipboardButton.story.tsx
  48. 2
      packages/grafana-ui/src/components/ColorPicker/ColorInput.tsx
  49. 3
      packages/grafana-ui/src/components/DataLinks/DataLinkEditor.tsx
  50. 2
      packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx
  51. 2
      packages/grafana-ui/src/components/DataSourceSettings/TLSAuthSettings.tsx
  52. 126
      packages/grafana-ui/src/components/FieldConfigs/fieldOverrides.test.ts
  53. 1
      packages/grafana-ui/src/components/FormField/_FormField.scss
  54. 2
      packages/grafana-ui/src/components/FormLabel/FormLabel.tsx
  55. 2
      packages/grafana-ui/src/components/Forms/Field.story.tsx
  56. 2
      packages/grafana-ui/src/components/Forms/Form.story.tsx
  57. 6
      packages/grafana-ui/src/components/Forms/Input/Input.mdx
  58. 110
      packages/grafana-ui/src/components/Forms/Input/Input.story.tsx
  59. 259
      packages/grafana-ui/src/components/Forms/Input/Input.tsx
  60. 40
      packages/grafana-ui/src/components/Forms/Legacy/Input/Input.story.internal.tsx
  61. 4
      packages/grafana-ui/src/components/Forms/Legacy/Input/Input.test.tsx
  62. 86
      packages/grafana-ui/src/components/Forms/Legacy/Input/Input.tsx
  63. 0
      packages/grafana-ui/src/components/Forms/Legacy/Input/__snapshots__/Input.test.tsx.snap
  64. 0
      packages/grafana-ui/src/components/Forms/Legacy/Select/ButtonSelect.story.internal.tsx
  65. 0
      packages/grafana-ui/src/components/Forms/Legacy/Select/Select.story.internal.tsx
  66. 4
      packages/grafana-ui/src/components/Forms/Legacy/Select/_Select.scss
  67. 20
      packages/grafana-ui/src/components/Forms/RadioButtonGroup/RadioButton.tsx
  68. 20
      packages/grafana-ui/src/components/Forms/TextArea/TextArea.tsx
  69. 2
      packages/grafana-ui/src/components/Forms/getFormStyles.ts
  70. 4
      packages/grafana-ui/src/components/Forms/index.ts
  71. 12
      packages/grafana-ui/src/components/Icon/Icon.mdx
  72. 98
      packages/grafana-ui/src/components/Icon/Icon.story.tsx
  73. 40
      packages/grafana-ui/src/components/Input/Input.mdx
  74. 134
      packages/grafana-ui/src/components/Input/Input.story.tsx
  75. 316
      packages/grafana-ui/src/components/Input/Input.tsx
  76. 2
      packages/grafana-ui/src/components/Layout/Layout.tsx
  77. 2
      packages/grafana-ui/src/components/OptionsUI/number.tsx
  78. 2
      packages/grafana-ui/src/components/OptionsUI/string.tsx
  79. 2
      packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx
  80. 2
      packages/grafana-ui/src/components/Select/InputControl.tsx
  81. 0
      packages/grafana-ui/src/components/Switch/Switch.story.internal.tsx
  82. 7
      packages/grafana-ui/src/components/Table/BackgroundColorCell.tsx
  83. 20
      packages/grafana-ui/src/components/Table/Table.story.tsx
  84. 183
      packages/grafana-ui/src/components/Table/Table.tsx
  85. 2
      packages/grafana-ui/src/components/Table/TableCell.tsx
  86. 42
      packages/grafana-ui/src/components/Table/styles.ts
  87. 1
      packages/grafana-ui/src/components/Table/types.ts
  88. 2
      packages/grafana-ui/src/components/TagsInput/TagsInput.tsx
  89. 2
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  90. 2
      packages/grafana-ui/src/components/ThresholdsEditorNew/ThresholdsEditor.tsx
  91. 2
      packages/grafana-ui/src/components/TimePicker/TimePickerContent/TimeRangeForm.tsx
  92. 4
      packages/grafana-ui/src/components/TimePicker/__snapshots__/TimePicker.test.tsx.snap
  93. 221
      packages/grafana-ui/src/components/TransformersUI/OrganizeFieldsTransformerEditor.tsx
  94. 2
      packages/grafana-ui/src/components/TransformersUI/transformers.ts
  95. 2
      packages/grafana-ui/src/components/ValueMappingsEditor/LegacyMappingRow.tsx
  96. 2
      packages/grafana-ui/src/components/ValueMappingsEditor/MappingRow.tsx
  97. 14
      packages/grafana-ui/src/components/index.ts
  98. 8
      packages/grafana-ui/src/themes/_variables.dark.scss.tmpl.ts
  99. 14
      packages/grafana-ui/src/themes/_variables.light.scss.tmpl.ts
  100. 4
      packages/grafana-ui/src/themes/_variables.scss.tmpl.ts
  101. Some files were not shown because too many files have changed in this diff Show More

@ -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;

@ -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

@ -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;
if (!customPropertiesDefaults) {
customPropertiesDefaults = {} as TFieldConfigOptions;
}
const editors = this.customFieldConfigs;
const configDefaults = this._fieldConfigDefaults.defaults;
configDefaults.custom = {} as TFieldConfigOptions;
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)]));
}
const defaults = customDefaults ?? standardFieldConfigDefaults;
useFieldConfig(config?: SetFieldConfigOptionsArgs<TFieldConfigOptions>) {
// builder is applied lazily when custom field configs are accessed
this._initConfigRegistry = () => {
const registry = new FieldConfigOptionsRegistry();
// Add custom options
if (config && config.useCustomConfig) {
const builder = new FieldConfigEditorBuilder<TFieldConfigOptions>();
config.useCustomConfig(builder);
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 (defaults) {
Object.keys(defaults).map(k => {
if (properties.indexOf(k as FieldConfigProperty) > -1) {
this._standardFieldConfigProperties!.set(k as FieldConfigProperty, defaults[k as FieldConfigProperty]);
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;

@ -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) {

@ -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';

@ -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_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_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_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);

@ -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,

@ -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" />

@ -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>
);
};

@ -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';
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
),
};
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';
export default {
title: 'Forms/Input',
component: Input,
decorators: [withCenteredStory],
parameters: {
docs: {
page: mdx,
},
},
};
const Wrapper = () => {
const { validation, validationErrorMessage, validationEvent } = getKnobs();
const [value, setValue] = useState('');
const validations = {
[validationEvent]: [
{
rule: (value: string) => {
return !!value.match(validation);
},
errorMessage: validationErrorMessage,
},
],
export const simple = () => {
const prefixSuffixOpts = {
None: null,
Text: '$',
...getAvailableIcons().reduce<KeyValue<string>>((prev, c) => {
return {
...prev,
[`Icon: ${c}`]: `icon-${c}`,
};
}, {}),
};
return <Input value={value} onChange={e => setValue(e.currentTarget.value)} validationEvents={validations} />;
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>
);
};
const story = storiesOf('General/Input', module);
story.addDecorator(withCenteredStory);
story.add('input', () => <Wrapper />);
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,86 +1,260 @@
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>;
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';
// 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;
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 State {
error: string | null;
interface StyleDeps {
theme: GrafanaTheme;
invalid: boolean;
}
export class Input extends PureComponent<Props, State> {
static defaultProps = {
className: '',
};
export const getInputStyles = stylesFactory(({ theme, invalid = false }: StyleDeps) => {
const colors = theme.colors;
const borderRadius = theme.border.radius.sm;
const height = theme.spacing.formInputHeight;
state: State = {
error: null,
};
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};
`;
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 };
});
};
};
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};
}
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]);
// only show number buttons on hover
input[type='number'] {
-moz-appearance: number-input;
-webkit-appearance: number-input;
appearance: textfield;
}
if (restProps[eventName]) {
restProps[eventName].apply(null, [evt, this.status]);
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;
}
}
});
return inputElementProps;
`,
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;
}
}
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]
);
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])
)}
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>
)}
<FixedSizeList
height={height - headerRowMeasurements.height}
itemCount={rows.length}
itemSize={tableStyles.rowHeight}
width={totalWidth ?? width}
style={{ overflow: 'hidden auto' }}
>
{RenderRow}
</FixedSizeList>
</CustomScrollbar>
</div>
);
});
</CustomScrollbar>
</div>
);
}
);
function renderHeaderCell(column: any, className: string, field?: Field) {
const headerProps = column.getHeaderProps(column.getSortByToggleProps());
const fieldTextAlign = getTextAlign(field);
Table.displayName = 'Table';
if (fieldTextAlign) {
headerProps.style.textAlign = fieldTextAlign;
function renderHeaderCell(column: any, tableStyles: TableStyles, field?: Field) {
const headerProps = column.getHeaderProps();
if (column.canResize) {
headerProps.style.userSelect = column.isResizing ? 'none' : 'auto'; // disables selecting text while resizing
}
headerProps.style.textAlign = getTextAlign(field);
return (
<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…
Cancel
Save