mirror of https://github.com/grafana/grafana
parent
bc468e4b92
commit
3f25d50a39
@ -0,0 +1,4 @@ |
||||
import { Registry } from '../utils'; |
||||
import { FieldConfigPropertyItem } from '../types'; |
||||
|
||||
export class FieldConfigOptionsRegistry extends Registry<FieldConfigPropertyItem> {} |
@ -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, |
||||
}; |
||||
}); |
||||
}; |
@ -0,0 +1,7 @@ |
||||
export type VariableType = 'query' | 'adhoc' | 'constant' | 'datasource' | 'interval' | 'textbox' | 'custom'; |
||||
|
||||
export interface VariableModel { |
||||
type: VariableType; |
||||
name: string; |
||||
label: string | null; |
||||
} |
@ -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]; |
||||
}; |
@ -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; |
@ -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); |
||||
}); |
||||
}); |
@ -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,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 />); |
@ -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> |
||||
); |
||||
} |
||||
} |
@ -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'; |
||||
|
@ -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', |
||||
}; |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue