Chore: Convert more class components to functional (#114311)

* refactor ColorPicker to functional components

* don't need memo for these components

* convert CustomHeadersSettings to a functional component

* ignore Legacy form components

* ignore legacy forms in some lint rules

* convert JSONFormatter to a functional component

* convert WrapperWithState to a functional component

* convert StatsPicker to a functional component

* convert PopoverController to a functional component

* convert UnitPicker to a functional component

* fix linting

* fix flaky dashboardcontrolsmenu test
This commit is contained in:
Ashley Harrison 2025-11-28 12:00:31 +00:00 committed by GitHub
parent 026a000304
commit 11a27ab870
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 390 additions and 532 deletions

View file

@ -567,14 +567,6 @@
"packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Combobox/Combobox.story.tsx": {
@ -605,9 +597,6 @@
"packages/grafana-ui/src/components/DataSourceSettings/CustomHeadersSettings.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/DataSourceSettings/types.ts": {
@ -644,14 +633,8 @@
"@typescript-eslint/consistent-type-assertions": {
"count": 2
},
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"no-restricted-syntax": {
"count": 1
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Forms/Legacy/Select/NoOptionsMessage.tsx": {
@ -660,38 +643,18 @@
}
},
"packages/grafana-ui/src/components/Forms/Legacy/Select/Select.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"no-restricted-syntax": {
"count": 6
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 3
}
},
"packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOption.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"no-restricted-syntax": {
"count": 4
}
},
"packages/grafana-ui/src/components/Forms/Legacy/Select/SelectOptionGroup.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 3
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx": {
"no-restricted-syntax": {
"count": 3
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Gauge/Gauge.tsx": {
@ -709,11 +672,6 @@
"count": 3
}
},
"packages/grafana-ui/src/components/JSONFormatter/JSONFormatter.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/JSONFormatter/json_explorer/json_explorer.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@ -838,16 +796,6 @@
"count": 1
}
},
"packages/grafana-ui/src/components/StatsPicker/StatsPicker.story.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/StatsPicker/StatsPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Table/Cells/TableCell.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 3
@ -933,21 +881,11 @@
"count": 1
}
},
"packages/grafana-ui/src/components/Tooltip/PopoverController.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/Typeahead/Typeahead.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 2
}
},
"packages/grafana-ui/src/components/UnitPicker/UnitPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/ValuePicker/ValuePicker.tsx": {
"@grafana/no-aria-label-selectors": {
"count": 1

View file

@ -132,6 +132,7 @@ module.exports = [
reportUnusedDisableDirectives: false,
},
files: ['**/*.{ts,tsx,js}'],
ignores: ['packages/grafana-ui/src/components/Forms/Legacy/**'],
plugins: {
'@emotion': emotionPlugin,
lodash: lodashPlugin,
@ -254,6 +255,9 @@ module.exports = [
name: 'grafana/jsx-a11y-overrides',
files: ['**/*.tsx'],
ignores: ['**/*.{spec,test}.tsx'],
plugins: {
'jsx-a11y': jsxA11yPlugin,
},
rules: {
...jsxA11yPlugin.configs.recommended.rules,
'jsx-a11y/no-autofocus': [
@ -278,6 +282,9 @@ module.exports = [
name: 'grafana/packages',
files: ['packages/**/*.{ts,tsx}'],
ignores: [],
plugins: {
import: importPlugin,
},
rules: {
'import/no-extraneous-dependencies': ['error', { includeInternal: true }],
'no-restricted-imports': [
@ -378,6 +385,7 @@ module.exports = [
plugins: {
'testing-library': testingLibraryPlugin,
'jest-dom': jestDomPlugin,
jest: jestPlugin,
},
files: [
'public/app/features/alerting/**/__tests__/**/*.[jt]s?(x)',
@ -508,10 +516,12 @@ module.exports = [
// Old betterer rules config:
{
files: ['**/*.{js,jsx,ts,tsx}'],
ignores:
ignores: [
// FIXME: Remove once all enterprise issues are fixed -
// we don't have a suppressions file/approach for enterprise code yet
enterpriseIgnores,
...enterpriseIgnores,
'packages/grafana-ui/src/components/Forms/Legacy/**',
],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@grafana/no-aria-label-selectors': 'error',

View file

@ -1,11 +1,16 @@
import { css } from '@emotion/css';
import { Component, createRef } from 'react';
import * as React from 'react';
import {
type ComponentType,
createElement,
type PropsWithChildren,
type ReactNode,
type RefObject,
useRef,
} from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { withTheme2 } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes/stylesFactory';
import { useTheme2 } from '../../themes/ThemeContext';
import { closePopover } from '../../utils/closePopover';
import { Popover } from '../Tooltip/Popover';
import { PopoverController } from '../Tooltip/PopoverController';
@ -21,76 +26,81 @@ import { SeriesColorPickerPopover } from './SeriesColorPickerPopover';
* component as a custom trigger you will need to forward the reference to first HTMLElement child.
*/
type ColorPickerTriggerRenderer = (props: {
// This should be a React.RefObject<HTMLElement> but due to how object refs are defined you cannot downcast from that
// to a specific type like React.RefObject<HTMLDivElement> even though it would be fine in runtime.
ref: React.RefObject<any>;
// This should be a RefObject<HTMLElement> but due to how object refs are defined you cannot downcast from that
// to a specific type like RefObject<HTMLDivElement> even though it would be fine in runtime.
ref: RefObject<any>;
showColorPicker: () => void;
hideColorPicker: () => void;
}) => React.ReactNode;
}) => ReactNode;
export const colorPickerFactory = <T extends ColorPickerProps>(
popover: React.ComponentType<React.PropsWithChildren<T>>,
popover: ComponentType<PropsWithChildren<T>>,
displayName = 'ColorPicker'
) => {
return class ColorPicker extends Component<T & { children?: ColorPickerTriggerRenderer }> {
static displayName = displayName;
pickerTriggerRef = createRef<any>();
const ColorPickerComponent = (props: T & { children?: ColorPickerTriggerRenderer }) => {
const { children, onChange, color, id } = props;
const theme = useTheme2();
const pickerTriggerRef = useRef<any>(null);
const styles = getStyles(theme);
render() {
const { theme, children, onChange, color, id } = this.props;
const styles = getStyles(theme);
const popoverElement = React.createElement(popover, {
...{ ...this.props, children: null },
const popoverElement = createElement(
popover,
{
...props,
onChange,
});
return (
<PopoverController content={popoverElement} hideAfter={300}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{this.pickerTriggerRef.current && (
<Popover
{...popperProps}
referenceElement={this.pickerTriggerRef.current}
wrapperClassName={styles.colorPicker}
onMouseLeave={hidePopper}
onMouseEnter={showPopper}
onKeyDown={(event) => closePopover(event, hidePopper)}
/>
)}
},
null
);
{children ? (
children({
ref: this.pickerTriggerRef,
showColorPicker: showPopper,
hideColorPicker: hidePopper,
})
) : (
<ColorSwatch
id={id}
ref={this.pickerTriggerRef}
onClick={showPopper}
onMouseLeave={hidePopper}
color={theme.visualization.getColorByName(color || '#000000')}
aria-label={color}
/>
)}
</>
);
}}
</PopoverController>
);
}
return (
<PopoverController content={popoverElement} hideAfter={300}>
{(showPopper, hidePopper, popperProps) => {
return (
<>
{pickerTriggerRef.current && (
<Popover
{...popperProps}
referenceElement={pickerTriggerRef.current}
wrapperClassName={styles.colorPicker}
onMouseLeave={hidePopper}
onMouseEnter={showPopper}
onKeyDown={(event) => closePopover(event, hidePopper)}
/>
)}
{children ? (
children({
ref: pickerTriggerRef,
showColorPicker: showPopper,
hideColorPicker: hidePopper,
})
) : (
<ColorSwatch
id={id}
ref={pickerTriggerRef}
onClick={showPopper}
onMouseLeave={hidePopper}
color={theme.visualization.getColorByName(color || '#000000')}
aria-label={color}
/>
)}
</>
);
}}
</PopoverController>
);
};
return ColorPickerComponent;
};
/**
* https://developers.grafana.com/ui/latest/index.html?path=/docs/pickers-colorpicker--docs
*/
export const ColorPicker = withTheme2(colorPickerFactory(ColorPickerPopover, 'ColorPicker'));
export const SeriesColorPicker = withTheme2(colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker'));
export const ColorPicker = colorPickerFactory(ColorPickerPopover, 'ColorPicker');
export const SeriesColorPicker = colorPickerFactory(SeriesColorPickerPopover, 'SeriesColorPicker');
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
colorPicker: css({
position: 'absolute',
@ -102,4 +112,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
overflow: 'auto',
}),
};
});
};

View file

@ -72,7 +72,6 @@ export const ColorPickerInput = forwardRef<HTMLInputElement, ColorPickerInputPro
)}
<ColorInput
{...inputProps}
theme={theme}
color={currentColor}
onChange={setColor}
buttonAriaLabel="Open color picker"

View file

@ -1,14 +1,11 @@
import { css } from '@emotion/css';
import { FocusScope } from '@react-aria/focus';
import { Component } from 'react';
import * as React from 'react';
import { type ComponentType, createElement, useState } from 'react';
import { GrafanaTheme2, colorManipulator } from '@grafana/data';
import { t } from '@grafana/i18n';
import { withTheme2 } from '../../themes/ThemeContext';
import { stylesFactory } from '../../themes/stylesFactory';
import { Themeable2 } from '../../types/theme';
import { useTheme2 } from '../../themes/ThemeContext';
import { Tab } from '../Tabs/Tab';
import { TabsBar } from '../Tabs/TabsBar';
import { PopoverContentProps } from '../Tooltip/types';
@ -18,7 +15,7 @@ import SpectrumPalette from './SpectrumPalette';
export type ColorPickerChangeHandler = (color: string) => void;
export interface ColorPickerProps extends Themeable2 {
export interface ColorPickerProps {
color: string;
onChange: ColorPickerChangeHandler;
enableNamedColors?: boolean;
@ -29,69 +26,56 @@ export interface Props<T> extends ColorPickerProps, PopoverContentProps {
customPickers?: T;
}
type PickerType = 'palette' | 'spectrum';
export interface CustomPickersDescriptor {
[key: string]: {
tabComponent: React.ComponentType<ColorPickerProps>;
tabComponent: ComponentType<ColorPickerProps>;
name: string;
};
}
interface State<T> {
activePicker: PickerType | keyof T;
}
type PickerType = 'palette' | 'spectrum';
class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Component<Props<T>, State<T>> {
constructor(props: Props<T>) {
super(props);
this.state = {
activePicker: 'palette',
};
}
export const ColorPickerPopover = <T extends CustomPickersDescriptor>(props: Props<T>) => {
const { color, onChange, enableNamedColors, customPickers } = props;
const theme = useTheme2();
const [activePicker, setActivePicker] = useState<PickerType | keyof T>('palette');
handleChange = (color: string) => {
const { onChange, enableNamedColors, theme } = this.props;
const styles = getStyles(theme);
const handleChange = (color: string) => {
if (enableNamedColors) {
return onChange(color);
}
onChange(colorManipulator.asHexString(theme.visualization.getColorByName(color)));
};
onTabChange = (tab: PickerType | keyof T) => {
return () => this.setState({ activePicker: tab });
const onTabChange = (tab: PickerType | keyof T) => {
return () => setActivePicker(tab);
};
renderPicker = () => {
const { activePicker } = this.state;
const { color } = this.props;
switch (activePicker) {
case 'spectrum':
return <SpectrumPalette color={color} onChange={this.handleChange} />;
case 'palette':
return <NamedColorsPalette color={color} onChange={this.handleChange} />;
default:
return this.renderCustomPicker(activePicker);
}
};
renderCustomPicker = (tabKey: keyof T) => {
const { customPickers, color, theme } = this.props;
const renderCustomPicker = (tabKey: keyof T) => {
if (!customPickers) {
return null;
}
return React.createElement(customPickers[tabKey].tabComponent, {
return createElement(customPickers[tabKey].tabComponent, {
color,
theme,
onChange: this.handleChange,
onChange: handleChange,
});
};
renderCustomPickerTabs = () => {
const { customPickers } = this.props;
const renderPicker = () => {
switch (activePicker) {
case 'spectrum':
return <SpectrumPalette color={color} onChange={handleChange} />;
case 'palette':
return <NamedColorsPalette color={color} onChange={handleChange} />;
default:
return renderCustomPicker(activePicker);
}
};
const renderCustomPickerTabs = () => {
if (!customPickers) {
return null;
}
@ -99,49 +83,39 @@ class UnThemedColorPickerPopover<T extends CustomPickersDescriptor> extends Comp
return (
<>
{Object.keys(customPickers).map((key) => {
return <Tab label={customPickers[key].name} onChangeTab={this.onTabChange(key)} key={key} />;
return <Tab label={customPickers[key].name} onChangeTab={onTabChange(key)} key={key} />;
})}
</>
);
};
render() {
const { theme } = this.props;
const { activePicker } = this.state;
return (
<FocusScope contain restoreFocus autoFocus>
{/*
tabIndex=-1 is needed here to support highlighting text within the picker when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<div tabIndex={-1} className={styles.colorPickerPopover}>
<TabsBar>
<Tab
label={t('grafana-ui.color-picker-popover.palette-tab', 'Colors')}
onChangeTab={onTabChange('palette')}
active={activePicker === 'palette'}
/>
<Tab
label={t('grafana-ui.color-picker-popover.spectrum-tab', 'Custom')}
onChangeTab={onTabChange('spectrum')}
active={activePicker === 'spectrum'}
/>
{renderCustomPickerTabs()}
</TabsBar>
<div className={styles.colorPickerPopoverContent}>{renderPicker()}</div>
</div>
</FocusScope>
);
};
const styles = getStyles(theme);
return (
<FocusScope contain restoreFocus autoFocus>
{/*
tabIndex=-1 is needed here to support highlighting text within the picker when using FocusScope
see https://github.com/adobe/react-spectrum/issues/1604#issuecomment-781574668
*/}
<div tabIndex={-1} className={styles.colorPickerPopover}>
<TabsBar>
<Tab
label={t('grafana-ui.color-picker-popover.palette-tab', 'Colors')}
onChangeTab={this.onTabChange('palette')}
active={activePicker === 'palette'}
/>
<Tab
label={t('grafana-ui.color-picker-popover.spectrum-tab', 'Custom')}
onChangeTab={this.onTabChange('spectrum')}
active={activePicker === 'spectrum'}
/>
{this.renderCustomPickerTabs()}
</TabsBar>
<div className={styles.colorPickerPopoverContent}>{this.renderPicker()}</div>
</div>
</FocusScope>
);
}
}
export const ColorPickerPopover = withTheme2(UnThemedColorPickerPopover);
ColorPickerPopover.displayName = 'ColorPickerPopover';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
const getStyles = (theme: GrafanaTheme2) => {
return {
colorPickerPopover: css({
borderRadius: theme.shape.radius.default,
@ -165,4 +139,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
borderRadius: `${theme.shape.radius.default} ${theme.shape.radius.default} 0 0`,
}),
};
});
};

View file

@ -1,6 +1,5 @@
import { t } from '@grafana/i18n';
import { withTheme2 } from '../../themes/ThemeContext';
import { InlineField } from '../Forms/InlineField';
import { InlineSwitch } from '../Switch/Switch';
import { PopoverContentProps } from '../Tooltip/types';
@ -36,4 +35,4 @@ export const SeriesColorPickerPopover = (props: SeriesColorPickerPopoverProps) =
};
// This component is to enable SeriesColorPickerPopover usage via series-color-picker-popover directive
export const SeriesColorPickerPopoverWithTheme = withTheme2(SeriesColorPickerPopover);
export const SeriesColorPickerPopoverWithTheme = SeriesColorPickerPopover;

View file

@ -38,7 +38,7 @@ const SpectrumPalette = ({ color, onChange }: SpectrumPaletteProps) => {
return (
<div className={styles.wrapper}>
<RgbaStringColorPicker className={styles.root} color={rgbaString} onChange={setColor} />
<ColorInput theme={theme} color={rgbaString} onChange={setColor} className={styles.colorInput} />
<ColorInput color={rgbaString} onChange={setColor} className={styles.colorInput} />
</div>
);
};

View file

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { PureComponent } from 'react';
import { memo, useState } from 'react';
import { DataSourceSettings } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
@ -27,10 +27,6 @@ export interface Props {
onChange: (config: DataSourceSettings) => void;
}
export interface State {
headers: CustomHeaders;
}
interface CustomHeaderRowProps {
header: CustomHeader;
onReset: (id: string) => void;
@ -98,150 +94,129 @@ const CustomHeaderRow = ({ header, onBlur, onChange, onRemove, onReset }: Custom
CustomHeaderRow.displayName = 'CustomHeaderRow';
export class CustomHeadersSettings extends PureComponent<Props, State> {
state: State = {
headers: [],
};
constructor(props: Props) {
super(props);
const { jsonData, secureJsonData, secureJsonFields } = this.props.dataSourceConfig;
this.state = {
headers: Object.keys(jsonData)
.sort()
.filter((key) => key.startsWith('httpHeaderName'))
.map((key, index) => {
return {
id: uniqueId(),
name: jsonData[key],
value: secureJsonData !== undefined ? secureJsonData[key] : '',
configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false,
};
}),
};
}
updateSettings = () => {
const { headers } = this.state;
export const CustomHeadersSettings = memo<Props>(({ dataSourceConfig, onChange }) => {
const [headers, setHeaders] = useState<CustomHeaders>(() => {
const { jsonData, secureJsonData, secureJsonFields } = dataSourceConfig;
return Object.keys(jsonData)
.sort()
.filter((key) => key.startsWith('httpHeaderName'))
.map((key, index) => {
return {
id: uniqueId(),
name: jsonData[key],
value: secureJsonData !== undefined ? secureJsonData[key] : '',
configured: (secureJsonFields && secureJsonFields[`httpHeaderValue${index + 1}`]) || false,
};
});
});
const updateSettings = (newHeaders: CustomHeaders) => {
// we remove every httpHeaderName* field
const newJsonData = Object.fromEntries(
Object.entries(this.props.dataSourceConfig.jsonData).filter(([key, val]) => !key.startsWith('httpHeaderName'))
Object.entries(dataSourceConfig.jsonData).filter(([key, val]) => !key.startsWith('httpHeaderName'))
);
// we remove every httpHeaderValue* field
const newSecureJsonData = Object.fromEntries(
Object.entries(this.props.dataSourceConfig.secureJsonData || {}).filter(
([key, val]) => !key.startsWith('httpHeaderValue')
)
Object.entries(dataSourceConfig.secureJsonData || {}).filter(([key, val]) => !key.startsWith('httpHeaderValue'))
);
// then we add the current httpHeader-fields
for (const [index, header] of headers.entries()) {
for (const [index, header] of newHeaders.entries()) {
newJsonData[`httpHeaderName${index + 1}`] = header.name;
if (!header.configured) {
newSecureJsonData[`httpHeaderValue${index + 1}`] = header.value;
}
}
this.props.onChange({
...this.props.dataSourceConfig,
onChange({
...dataSourceConfig,
jsonData: newJsonData,
secureJsonData: newSecureJsonData,
});
};
onHeaderAdd = () => {
this.setState((prevState) => {
return { headers: [...prevState.headers, { id: uniqueId(), name: '', value: '', configured: false }] };
});
const onHeaderAdd = () => {
setHeaders((prevHeaders) => [...prevHeaders, { id: uniqueId(), name: '', value: '', configured: false }]);
};
onHeaderChange = (headerIndex: number, value: CustomHeader) => {
this.setState(({ headers }) => {
return {
headers: headers.map((item, index) => {
if (headerIndex !== index) {
return item;
}
return { ...value };
}),
};
});
};
onHeaderReset = (headerId: string) => {
this.setState(({ headers }) => {
return {
headers: headers.map((h, i) => {
if (h.id !== headerId) {
return h;
}
return {
...h,
value: '',
configured: false,
};
}),
};
});
};
onHeaderRemove = (headerId: string) => {
this.setState(
({ headers }) => ({
headers: headers.filter((h) => h.id !== headerId),
}),
this.updateSettings
const onHeaderChange = (headerIndex: number, value: CustomHeader) => {
setHeaders((prevHeaders) =>
prevHeaders.map((item, index) => {
if (headerIndex !== index) {
return item;
}
return { ...value };
})
);
};
render() {
const { headers } = this.state;
const { dataSourceConfig } = this.props;
const onHeaderReset = (headerId: string) => {
setHeaders((prevHeaders) =>
prevHeaders.map((h) => {
if (h.id !== headerId) {
return h;
}
return {
...h,
value: '',
configured: false,
};
})
);
};
return (
<Box marginBottom={5}>
const onHeaderRemove = (headerId: string) => {
setHeaders((prevHeaders) => {
const newHeaders = prevHeaders.filter((h) => h.id !== headerId);
updateSettings(newHeaders);
return newHeaders;
});
};
return (
<Box marginBottom={5}>
<Box marginBottom={0.5} position="relative">
<Stack direction="row" alignItems="baseline">
<h6>
<Trans i18nKey="grafana-ui.data-source-settings.custom-headers-title">Custom HTTP Headers</Trans>
</h6>
</Stack>
</Box>
<div>
{headers.map((header, i) => (
<CustomHeaderRow
key={header.id}
header={header}
onChange={(h) => {
onHeaderChange(i, h);
}}
onBlur={() => updateSettings(headers)}
onRemove={onHeaderRemove}
onReset={onHeaderReset}
/>
))}
</div>
{!dataSourceConfig.readOnly && (
<Box marginBottom={0.5} position="relative">
<Stack direction="row" alignItems="baseline">
<h6>
<Trans i18nKey="grafana-ui.data-source-settings.custom-headers-title">Custom HTTP Headers</Trans>
</h6>
<Button
variant="secondary"
icon="plus"
type="button"
onClick={(e) => {
onHeaderAdd();
}}
>
<Trans i18nKey="grafana-ui.data-source-settings.custom-headers-add">Add header</Trans>
</Button>
</Stack>
</Box>
<div>
{headers.map((header, i) => (
<CustomHeaderRow
key={header.id}
header={header}
onChange={(h) => {
this.onHeaderChange(i, h);
}}
onBlur={this.updateSettings}
onRemove={this.onHeaderRemove}
onReset={this.onHeaderReset}
/>
))}
</div>
{!dataSourceConfig.readOnly && (
<Box marginBottom={0.5} position="relative">
<Stack direction="row" alignItems="baseline">
<Button
variant="secondary"
icon="plus"
type="button"
onClick={(e) => {
this.onHeaderAdd();
}}
>
<Trans i18nKey="grafana-ui.data-source-settings.custom-headers-add">Add header</Trans>
</Button>
</Stack>
</Box>
)}
</Box>
);
}
}
)}
</Box>
);
});
CustomHeadersSettings.displayName = 'CustomHeadersSettings';
export default CustomHeadersSettings;

View file

@ -1,4 +1,4 @@
import { PureComponent, createRef } from 'react';
import { memo, useRef, useEffect } from 'react';
import { JsonExplorer, JsonExplorerConfig } from './json_explorer/json_explorer'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
@ -10,45 +10,32 @@ interface Props {
onDidRender?: (formattedJson: {}) => void;
}
export class JSONFormatter extends PureComponent<Props> {
private wrapperRef = createRef<HTMLDivElement>();
export const JSONFormatter = memo<Props>(
({ className, json, config = { animateOpen: true }, open = 3, onDidRender }) => {
const wrapperRef = useRef<HTMLDivElement>(null);
static defaultProps = {
open: 3,
config: {
animateOpen: true,
},
};
useEffect(() => {
const wrapperEl = wrapperRef.current;
if (!wrapperEl) {
return;
}
componentDidMount() {
this.renderJson();
const formatter = new JsonExplorer(json, open, config);
const hasChildren = wrapperEl.hasChildNodes();
if (hasChildren && wrapperEl.lastChild) {
wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
} else {
wrapperEl.appendChild(formatter.render());
}
if (onDidRender) {
onDidRender(formatter.json);
}
}, [json, config, open, onDidRender]);
return <div className={className} ref={wrapperRef} />;
}
);
componentDidUpdate() {
this.renderJson();
}
renderJson = () => {
const { json, config, open, onDidRender } = this.props;
const wrapperEl = this.wrapperRef.current;
const formatter = new JsonExplorer(json, open, config);
// @ts-ignore
const hasChildren: boolean = wrapperEl.hasChildNodes();
if (hasChildren) {
// @ts-ignore
wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
} else {
// @ts-ignore
wrapperEl.appendChild(formatter.render());
}
if (onDidRender) {
onDidRender(formatter.json);
}
};
render() {
const { className } = this.props;
return <div className={className} ref={this.wrapperRef} />;
}
}
JSONFormatter.displayName = 'JSONFormatter';

View file

@ -1,45 +1,33 @@
import { action } from '@storybook/addon-actions';
import { Meta, StoryFn } from '@storybook/react';
import { PureComponent } from 'react';
import { memo, useState } from 'react';
import { Field } from '../Forms/Field';
import { Props, StatsPicker } from './StatsPicker';
interface State {
stats: string[];
}
const WrapperWithState = memo<Props>(({ placeholder, allowMultiple, menuPlacement, width }) => {
const [stats, setStats] = useState<string[]>([]);
class WrapperWithState extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
stats: [],
};
}
return (
<Field label="Pick stats">
<StatsPicker
inputId="stats-picker"
placeholder={placeholder}
allowMultiple={allowMultiple}
stats={stats}
onChange={(newStats: string[]) => {
action('Picked:')(newStats);
setStats(newStats);
}}
menuPlacement={menuPlacement}
width={width}
/>
</Field>
);
});
render() {
const { placeholder, allowMultiple, menuPlacement, width } = this.props;
const { stats } = this.state;
return (
<Field label="Pick stats">
<StatsPicker
inputId="stats-picker"
placeholder={placeholder}
allowMultiple={allowMultiple}
stats={stats}
onChange={(stats: string[]) => {
action('Picked:')(stats);
this.setState({ stats });
}}
menuPlacement={menuPlacement}
width={width}
/>
</Field>
);
}
}
WrapperWithState.displayName = 'WrapperWithState';
const meta: Meta<typeof StatsPicker> = {
title: 'Pickers/StatsPicker',

View file

@ -1,5 +1,5 @@
import { difference } from 'lodash';
import { PureComponent } from 'react';
import { memo, useEffect } from 'react';
import { fieldReducers, SelectableValue, FieldReducerInfo } from '@grafana/data';
@ -18,54 +18,47 @@ export interface Props {
filterOptions?: (ext: FieldReducerInfo) => boolean;
}
export class StatsPicker extends PureComponent<Props> {
static defaultProps: Partial<Props> = {
allowMultiple: false,
};
export const StatsPicker = memo<Props>(
({
placeholder,
onChange,
stats,
allowMultiple = false,
defaultStat,
className,
width,
menuPlacement,
inputId,
filterOptions,
}) => {
useEffect(() => {
const current = fieldReducers.list(stats);
if (current.length !== stats.length) {
const found = current.map((v) => v.id);
const notFound = difference(stats, found);
console.warn('Unknown stats', notFound, stats);
onChange(current.map((stat) => stat.id));
}
componentDidMount() {
this.checkInput();
}
// Make sure there is only one
if (!allowMultiple && stats.length > 1) {
console.warn('Removing extra stat', stats);
onChange([stats[0]]);
}
componentDidUpdate(prevProps: Props) {
this.checkInput();
}
// Set the reducer from callback
if (defaultStat && stats.length < 1) {
onChange([defaultStat]);
}
}, [stats, allowMultiple, defaultStat, onChange]);
checkInput = () => {
const { stats, allowMultiple, defaultStat, onChange } = this.props;
const current = fieldReducers.list(stats);
if (current.length !== stats.length) {
const found = current.map((v) => v.id);
const notFound = difference(stats, found);
console.warn('Unknown stats', notFound, stats);
onChange(current.map((stat) => stat.id));
}
// Make sure there is only one
if (!allowMultiple && stats.length > 1) {
console.warn('Removing extra stat', stats);
onChange([stats[0]]);
}
// Set the reducer from callback
if (defaultStat && stats.length < 1) {
onChange([defaultStat]);
}
};
onSelectionChange = (item: SelectableValue<string>) => {
const { onChange } = this.props;
if (Array.isArray(item)) {
onChange(item.map((v) => v.value));
} else {
onChange(item && item.value ? [item.value] : []);
}
};
render() {
const { stats, allowMultiple, defaultStat, placeholder, className, menuPlacement, width, inputId, filterOptions } =
this.props;
const onSelectionChange = (item: SelectableValue<string>) => {
if (Array.isArray(item)) {
onChange(item.map((v) => v.value));
} else {
onChange(item && item.value ? [item.value] : []);
}
};
const select = fieldReducers.selectOptions(stats, filterOptions);
return (
@ -78,10 +71,12 @@ export class StatsPicker extends PureComponent<Props> {
isSearchable={true}
options={select.options}
placeholder={placeholder}
onChange={this.onSelectionChange}
onChange={onSelectionChange}
menuPlacement={menuPlacement}
inputId={inputId}
/>
);
}
}
);
StatsPicker.displayName = 'StatsPicker';

View file

@ -1,5 +1,5 @@
import { Placement } from '@popperjs/core';
import { Component, type JSX } from 'react';
import { useState, useRef, useCallback, type JSX } from 'react';
import { PopoverContent } from './types';
@ -21,37 +21,28 @@ interface Props {
hideAfter?: number;
}
interface State {
show: boolean;
}
const PopoverController = ({ placement = 'auto', content, children, hideAfter }: Props) => {
const [show, setShow] = useState(false);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
class PopoverController extends Component<Props, State> {
private hideTimeout: ReturnType<typeof setTimeout> | null = null;
state = { show: false };
showPopper = () => {
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
const showPopper = useCallback(() => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
this.setState({ show: true });
};
setShow(true);
}, []);
hidePopper = () => {
this.hideTimeout = setTimeout(() => {
this.setState({ show: false });
}, this.props.hideAfter);
};
const hidePopper = useCallback(() => {
hideTimeoutRef.current = setTimeout(() => {
setShow(false);
}, hideAfter);
}, [hideAfter]);
render() {
const { children, content, placement = 'auto' } = this.props;
const { show } = this.state;
return children(this.showPopper, this.hidePopper, {
show,
placement,
content,
});
}
}
return children(showPopper, hidePopper, {
show,
placement,
content,
});
};
export { PopoverController };

View file

@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo } from 'react';
import { getValueFormats, SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@ -19,58 +19,52 @@ function formatCreateLabel(input: string) {
/**
* https://developers.grafana.com/ui/latest/index.html?path=/docs/pickers-unitpicker--docs
*/
export class UnitPicker extends PureComponent<UnitPickerProps> {
onChange = (value: SelectableValue<string>) => {
this.props.onChange(value.value);
};
export const UnitPicker = memo<UnitPickerProps>(({ onChange, value, width, id }) => {
// Set the current selection
let current: SelectableValue<string> | undefined = undefined;
render() {
const { value, width, id } = this.props;
// All units
const unitGroups = getValueFormats();
// Set the current selection
let current: SelectableValue<string> | undefined = undefined;
// All units
const unitGroups = getValueFormats();
// Need to transform the data structure to work well with Select
const groupOptions: CascaderOption[] = unitGroups.map((group) => {
const options = group.submenu.map((unit) => {
const sel = {
label: unit.text,
value: unit.value,
};
if (unit.value === value) {
current = sel;
}
return sel;
});
return {
label: group.text,
value: group.text,
items: options,
// Need to transform the data structure to work well with Select
const groupOptions: CascaderOption[] = unitGroups.map((group) => {
const options = group.submenu.map((unit) => {
const sel = {
label: unit.text,
value: unit.value,
};
if (unit.value === value) {
current = sel;
}
return sel;
});
// Show the custom unit
if (value && !current) {
current = { value, label: value };
}
return {
label: group.text,
value: group.text,
items: options,
};
});
return (
<Cascader
id={id}
width={width}
initialValue={current && current.label}
allowCustomValue
changeOnSelect={false}
formatCreateLabel={formatCreateLabel}
options={groupOptions}
placeholder={t('grafana-ui.unit-picker.placeholder', 'Choose')}
isClearable
onSelect={this.props.onChange}
/>
);
// Show the custom unit
if (value && !current) {
current = { value, label: value };
}
}
return (
<Cascader
id={id}
width={width}
initialValue={current && current.label}
allowCustomValue
changeOnSelect={false}
formatCreateLabel={formatCreateLabel}
options={groupOptions}
placeholder={t('grafana-ui.unit-picker.placeholder', 'Choose')}
isClearable
onSelect={onChange}
/>
);
});
UnitPicker.displayName = 'UnitPicker';

View file

@ -1,4 +1,4 @@
import { act, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VariableHide } from '@grafana/data';
@ -65,15 +65,13 @@ describe('DashboardControlsMenu', () => {
}),
];
act(() => {
render(<DashboardControlsButton dashboard={getDashboard(variables)} />);
});
render(<DashboardControlsButton dashboard={getDashboard(variables)} />);
// Should have rendered a dropdown
expect(screen.getByRole('button')).toBeInTheDocument();
// Open the dropdown
userEvent.click(screen.getByRole('button'));
await userEvent.click(screen.getByRole('button'));
expect(await screen.findByText('textVar1')).toBeInTheDocument();
expect(await screen.findByText('textVar2')).toBeInTheDocument();
expect(await screen.findByText('queryVar')).toBeInTheDocument();
@ -104,7 +102,7 @@ describe('DashboardControlsMenu', () => {
expect(screen.getByRole('button')).toBeInTheDocument();
// Open the dropdown
userEvent.click(screen.getByRole('button'));
await userEvent.click(screen.getByRole('button'));
expect(await screen.findByText('textVar1')).toBeInTheDocument();
expect(await screen.findByText('customVar')).toBeInTheDocument();
expect(screen.queryByText('textVar2')).not.toBeInTheDocument();