Chore: Convert to functional components (#112951)

* refactor ErrorBoundary so it doesn't trigger the lint rule

* refactor ErrorBoundaryAlert to functional component

* convert StatPanel to a functional component

* convert ServiceAccountPicker to a functional component

* convert UserPicker to a functional component

* don't need displayName when not memoized

* convert TimelineChart to a functional component

* convert UserLdapSyncInfo to a functional component

* convert UserOrgs to functional component

* convert OrgRow to a functional component

* convert UserSessions to a functional component

* convert TimePickerSettings to a functional component

* convert DataSourcePluginSettings to a functional component

* convert ExploreTimeControls to a functional component

* convert SearchBarInput to a functional component

* convert LiveConnectionWarning to a functional component

* convert ConcatenateTransformerEditor

* convert ConstantVariableEditor a functional component

* convert VariableInput to a functional component

* convert ConfigEditor to a functional component

* convert CSVWavesEditor to a functional component
This commit is contained in:
Ashley Harrison
2025-10-30 09:23:21 +00:00
committed by GitHub
parent 58098f9339
commit 8b31ec5040
22 changed files with 924 additions and 1104 deletions
+1 -85
View File
@@ -539,11 +539,6 @@
"count": 2
}
},
"packages/grafana-sql/src/components/ErrorBoundary.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-sql/src/components/configuration/ConnectionLimits.tsx": {
"no-restricted-syntax": {
"count": 2
@@ -666,11 +661,6 @@
"count": 3
}
},
"packages/grafana-ui/src/components/ErrorBoundary/ErrorBoundary.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"packages/grafana-ui/src/components/FormField/FormField.tsx": {
"no-restricted-syntax": {
"count": 1
@@ -1278,21 +1268,11 @@
"count": 1
}
},
"public/app/core/components/Select/ServiceAccountPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/Select/TeamPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/Select/UserPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/SharedPreferences/SharedPreferences.tsx": {
"no-restricted-syntax": {
"count": 8
@@ -1342,11 +1322,6 @@
"count": 2
}
},
"public/app/core/components/TimelineChart/TimelineChart.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/config.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
@@ -1483,25 +1458,17 @@
"public/app/features/admin/UserLdapSyncInfo.tsx": {
"no-restricted-syntax": {
"count": 3
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/admin/UserOrgs.tsx": {
"no-restricted-syntax": {
"count": 2
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 3
}
},
"public/app/features/admin/UserProfile.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/admin/UserSessions.tsx": {
"public/app/features/admin/UserProfile.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
@@ -2399,9 +2366,6 @@
"public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.tsx": {
"no-restricted-syntax": {
"count": 5
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx": {
@@ -2752,11 +2716,6 @@
"count": 1
}
},
"public/app/features/datasources/components/DataSourcePluginSettings.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/datasources/components/DataSourcePluginState.tsx": {
"no-restricted-syntax": {
"count": 3
@@ -2872,11 +2831,6 @@
"count": 1
}
},
"public/app/features/explore/ExploreTimeControls.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/explore/Logs/LiveLogs.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
@@ -2977,11 +2931,6 @@
"count": 1
}
},
"public/app/features/explore/TraceView/components/common/SearchBarInput.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/explore/TraceView/components/demo/trace-generators.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -3118,11 +3067,6 @@
"count": 2
}
},
"public/app/features/live/LiveConnectionWarning.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/live/centrifuge/LiveDataStream.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -3578,11 +3522,6 @@
"count": 1
}
},
"public/app/features/transformers/editors/ConcatenateTransformerEditor.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/transformers/editors/ConvertFieldTypeTransformerEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -3700,11 +3639,6 @@
"count": 2
}
},
"public/app/features/variables/constant/ConstantVariableEditor.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/variables/constant/reducer.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
@@ -3787,9 +3721,6 @@
"public/app/features/variables/pickers/shared/VariableInput.tsx": {
"no-restricted-syntax": {
"count": 1
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/variables/pickers/shared/VariableOptions.tsx": {
@@ -4004,11 +3935,6 @@
"count": 1
}
},
"public/app/plugins/datasource/cloud-monitoring/components/ConfigEditor/ConfigEditor.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/plugins/datasource/cloud-monitoring/components/Fields.tsx": {
"no-restricted-syntax": {
"count": 1
@@ -4207,11 +4133,6 @@
"count": 1
}
},
"public/app/plugins/datasource/grafana-testdata-datasource/components/CSVWaveEditor.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/plugins/datasource/grafana-testdata-datasource/components/RandomWalkEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
@@ -4920,11 +4841,6 @@
"count": 1
}
},
"public/app/plugins/panel/stat/StatPanel.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/plugins/panel/state-timeline/migrations.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -1,4 +1,4 @@
import * as React from 'react';
import { Component, ErrorInfo, PropsWithChildren } from 'react';
import { Trans } from '@grafana/i18n';
@@ -6,14 +6,14 @@ type Props = {
fallBackComponent?: React.ReactNode;
};
export class ErrorBoundary extends React.Component<React.PropsWithChildren<Props>, { hasError: boolean }> {
constructor(props: React.PropsWithChildren<Props>) {
export class ErrorBoundary extends Component<React.PropsWithChildren<Props>, { hasError: boolean }> {
constructor(props: PropsWithChildren<Props>) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState({ hasError: true });
}
render() {
@@ -1,6 +1,7 @@
import { PureComponent, ReactNode, ComponentType, ErrorInfo } from 'react';
import { PureComponent, ReactNode, ComponentType, ErrorInfo, memo } from 'react';
import { faro } from '@grafana/faro-web-sdk';
import { t } from '@grafana/i18n';
import { Alert } from '../Alert/Alert';
@@ -116,15 +117,9 @@ export interface ErrorBoundaryAlertProps {
errorLogger?: (error: Error) => void;
}
export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
static defaultProps: Partial<ErrorBoundaryAlertProps> = {
title: 'An unexpected error happened',
style: 'alertbox',
};
render() {
const { title, children, style, dependencies, errorLogger, boundaryName } = this.props;
export const ErrorBoundaryAlert = memo(
({ title, children, style = 'alertbox', dependencies, errorLogger, boundaryName }: ErrorBoundaryAlertProps) => {
const alertTitle = title ?? t('grafana-ui.error-boundary.title', 'An unexpected error happened');
return (
<ErrorBoundary dependencies={dependencies} errorLogger={errorLogger} boundaryName={boundaryName}>
{({ error, errorInfo }) => {
@@ -134,7 +129,7 @@ export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
if (style === 'alertbox') {
return (
<Alert title={title || ''}>
<Alert title={alertTitle}>
<details style={{ whiteSpace: 'pre-wrap' }}>
{error && error.toString()}
<br />
@@ -144,12 +139,14 @@ export class ErrorBoundaryAlert extends PureComponent<ErrorBoundaryAlertProps> {
);
}
return <ErrorWithStack title={title || ''} error={error} errorInfo={errorInfo} />;
return <ErrorWithStack title={alertTitle} error={error} errorInfo={errorInfo} />;
}}
</ErrorBoundary>
);
}
}
);
ErrorBoundaryAlert.displayName = 'ErrorBoundaryAlert';
/**
* HOC for wrapping a component in an error boundary.
@@ -1,6 +1,6 @@
import debounce from 'debounce-promise';
import { isNil } from 'lodash';
import { Component } from 'react';
import { useMemo, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -14,66 +14,58 @@ export interface Props {
inputId?: string;
}
export interface State {
isLoading: boolean;
}
export const ServiceAccountPicker = ({ className, onSelected, inputId }: Props) => {
const [isLoading, setIsLoading] = useState(false);
export class ServiceAccountPicker extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { isLoading: false };
}
const search = useMemo(
() =>
debounce(
async (query?: string) => {
setIsLoading(true);
search = debounce(
async (query?: string) => {
this.setState({ isLoading: true });
if (isNil(query)) {
query = '';
}
if (isNil(query)) {
query = '';
}
return getBackendSrv()
.get(`/api/serviceaccounts/search?query=${query}&perpage=100`)
.then((result: ServiceAccountsState) => {
return result.serviceAccounts.map((sa) => ({
id: sa.id,
uid: sa.uid,
value: sa,
label: sa.login,
imgUrl: sa.avatarUrl,
login: sa.login,
}));
})
.finally(() => {
this.setState({ isLoading: false });
});
},
300,
{ leading: true }
return getBackendSrv()
.get(`/api/serviceaccounts/search?query=${query}&perpage=100`)
.then((result: ServiceAccountsState) => {
return result.serviceAccounts.map((sa) => ({
id: sa.id,
uid: sa.uid,
value: sa,
label: sa.login,
imgUrl: sa.avatarUrl,
login: sa.login,
}));
})
.finally(() => {
setIsLoading(false);
});
},
300,
{ leading: true }
),
[]
);
render() {
const { className, onSelected, inputId } = this.props;
const { isLoading } = this.state;
return (
<div className="service-account-picker" data-testid="serviceAccountPicker">
<AsyncSelect
isClearable
className={className}
inputId={inputId}
isLoading={isLoading}
defaultOptions={true}
loadOptions={this.search}
onChange={onSelected}
placeholder={t('service-account-picker.select-placeholder', 'Start typing to search for service accounts')}
noOptionsMessage={t(
'service-account-picker.noOptionsMessage-no-service-accounts-found',
'No service accounts found'
)}
aria-label={t('service-account-picker.select-aria-label', 'Service account picker')}
/>
</div>
);
}
}
return (
<div className="service-account-picker" data-testid="serviceAccountPicker">
<AsyncSelect
isClearable
className={className}
inputId={inputId}
isLoading={isLoading}
defaultOptions={true}
loadOptions={search}
onChange={onSelected}
placeholder={t('service-account-picker.select-placeholder', 'Start typing to search for service accounts')}
noOptionsMessage={t(
'service-account-picker.noOptionsMessage-no-service-accounts-found',
'No service accounts found'
)}
aria-label={t('service-account-picker.select-aria-label', 'Service account picker')}
/>
</div>
);
};
@@ -1,6 +1,6 @@
import debounce from 'debounce-promise';
import { isNil } from 'lodash';
import { Component } from 'react';
import { useMemo, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -14,63 +14,55 @@ export interface Props {
inputId?: string;
}
export interface State {
isLoading: boolean;
}
export const UserPicker = ({ className, onSelected, inputId }: Props) => {
const [isLoading, setIsLoading] = useState(false);
export class UserPicker extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { isLoading: false };
}
const search = useMemo(
() =>
debounce(
async (query?: string) => {
setIsLoading(true);
search = debounce(
async (query?: string) => {
this.setState({ isLoading: true });
if (isNil(query)) {
query = '';
}
if (isNil(query)) {
query = '';
}
return getBackendSrv()
.get(`/api/org/users/lookup?query=${query}&limit=100`)
.then((result: OrgUser[]) => {
return result.map((user) => ({
id: user.userId,
uid: user.uid,
value: user,
label: user.login,
imgUrl: user.avatarUrl,
login: user.login,
}));
})
.finally(() => {
this.setState({ isLoading: false });
});
},
300,
{ leading: true }
return getBackendSrv()
.get(`/api/org/users/lookup?query=${query}&limit=100`)
.then((result: OrgUser[]) => {
return result.map((user) => ({
id: user.userId,
uid: user.uid,
value: user,
label: user.login,
imgUrl: user.avatarUrl,
login: user.login,
}));
})
.finally(() => {
setIsLoading(false);
});
},
300,
{ leading: true }
),
[]
);
render() {
const { className, onSelected, inputId } = this.props;
const { isLoading } = this.state;
return (
<div className="user-picker" data-testid="userPicker">
<AsyncSelect
isClearable
className={className}
inputId={inputId}
isLoading={isLoading}
defaultOptions={true}
loadOptions={this.search}
onChange={onSelected}
placeholder={t('user-picker.select-placeholder', 'Start typing to search for user')}
noOptionsMessage={t('user-picker.noOptionsMessage-no-users-found', 'No users found')}
aria-label={t('user-picker.select-aria-label', 'User picker')}
/>
</div>
);
}
}
return (
<div className="user-picker" data-testid="userPicker">
<AsyncSelect
isClearable
className={className}
inputId={inputId}
isLoading={isLoading}
defaultOptions={true}
loadOptions={search}
onChange={onSelected}
placeholder={t('user-picker.select-placeholder', 'Start typing to search for user')}
noOptionsMessage={t('user-picker.noOptionsMessage-no-users-found', 'No users found')}
aria-label={t('user-picker.select-aria-label', 'User picker')}
/>
</div>
);
};
@@ -1,4 +1,4 @@
import { Component } from 'react';
import { useCallback } from 'react';
import { DataFrame, FALLBACK_COLOR, FieldType, TimeRange } from '@grafana/data';
import { VisibilityMode, TimelineValueAlignment, TooltipDisplayMode, VizTooltipOptions } from '@grafana/schema';
@@ -25,69 +25,76 @@ export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsT
const propsToDiff = ['rowHeight', 'colWidth', 'showValue', 'mergeValues', 'alignValue', 'tooltip', 'paginationRev'];
export class TimelineChart extends Component<TimelineProps> {
getValueColor = (frameIdx: number, fieldIdx: number, value: unknown) => {
const field = this.props.frames[frameIdx]?.fields[fieldIdx];
export const TimelineChart = (props: TimelineProps) => {
const { frames, timeZone, rowHeight, tooltip, legend, legendItems } = props;
if (field?.display) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
return disp.color;
const getValueColor = useCallback(
(frameIdx: number, fieldIdx: number, value: unknown) => {
const field = frames[frameIdx]?.fields[fieldIdx];
if (field?.display) {
const disp = field.display(value); // will apply color modes
if (disp.color) {
return disp.color;
}
}
}
return FALLBACK_COLOR;
};
return FALLBACK_COLOR;
},
[frames]
);
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
allFrames: this.props.frames,
...this.props,
const prepConfig = useCallback(
(alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
allFrames: frames,
...props,
// Ensure timezones is passed as an array
timeZones: Array.isArray(this.props.timeZone) ? this.props.timeZone : [this.props.timeZone],
// Ensure timezones is passed as an array
timeZones: Array.isArray(timeZone) ? timeZone : [timeZone],
// When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? this.props.rowHeight : 1,
getValueColor: this.getValueColor,
// When there is only one row, use the full space
rowHeight: alignedFrame.fields.length > 2 ? rowHeight : 1,
getValueColor: getValueColor,
hoverMulti: this.props.tooltip?.mode === TooltipDisplayMode.Multi,
});
};
hoverMulti: tooltip?.mode === TooltipDisplayMode.Multi,
});
},
[frames, props, timeZone, rowHeight, getValueColor, tooltip]
);
renderLegend = (config: UPlotConfigBuilder) => {
const { legend, legendItems } = this.props;
const renderLegend = useCallback(
(config: UPlotConfigBuilder) => {
if (!config || !legendItems || !legend || legend.showLegend === false) {
return null;
}
if (!config || !legendItems || !legend || legend.showLegend === false) {
return null;
}
return (
<VizLayout.Legend placement={legend.placement}>
<VizLegend placement={legend.placement} items={legendItems} displayMode={legend.displayMode} readonly />
</VizLayout.Legend>
);
},
[legend, legendItems]
);
return (
<VizLayout.Legend placement={legend.placement}>
<VizLegend placement={legend.placement} items={legendItems} displayMode={legend.displayMode} readonly />
</VizLayout.Legend>
);
};
render() {
return (
<GraphNG
{...this.props}
fields={{
x: (f) => f.type === FieldType.time,
y: (f) =>
f.type === FieldType.number ||
f.type === FieldType.boolean ||
f.type === FieldType.string ||
f.type === FieldType.enum,
}}
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}
renderLegend={this.renderLegend}
omitHideFromViz={true}
/>
);
}
}
return (
<GraphNG
{...props}
fields={{
x: (f) => f.type === FieldType.time,
y: (f) =>
f.type === FieldType.number ||
f.type === FieldType.boolean ||
f.type === FieldType.string ||
f.type === FieldType.enum,
}}
prepConfig={prepConfig}
propsToDiff={propsToDiff}
renderLegend={renderLegend}
omitHideFromViz={true}
/>
);
};
+65 -73
View File
@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo } from 'react';
import { dateTimeFormat } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -16,80 +16,72 @@ interface Props {
onUserSync: () => void;
}
interface State {}
const format = 'dddd YYYY-MM-DD HH:mm zz';
const debugLDAPMappingBaseURL = '/admin/authentication/ldap';
export class UserLdapSyncInfo extends PureComponent<Props, State> {
onUserSync = () => {
this.props.onUserSync();
};
export const UserLdapSyncInfo = memo(({ ldapSyncInfo, user, onUserSync }: Props) => {
const nextSyncSuccessful = ldapSyncInfo && ldapSyncInfo.nextSync;
const nextSyncTime = nextSyncSuccessful ? dateTimeFormat(ldapSyncInfo.nextSync, { format }) : '';
const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?username=${user && user.login}`;
const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead);
const canSyncLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersSync);
render() {
const { ldapSyncInfo, user } = this.props;
const nextSyncSuccessful = ldapSyncInfo && ldapSyncInfo.nextSync;
const nextSyncTime = nextSyncSuccessful ? dateTimeFormat(ldapSyncInfo.nextSync, { format }) : '';
const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?username=${user && user.login}`;
const canReadLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersRead);
const canSyncLDAPUser = contextSrv.hasPermission(AccessControlAction.LDAPUsersSync);
return (
<>
<h3 className="page-heading">
<Trans i18nKey="admin.ldap-sync.title">LDAP Synchronisation</Trans>
</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td>
<Trans i18nKey="admin.ldap-sync.external-sync-label">External sync</Trans>
</td>
<td>
<Trans i18nKey="admin.ldap-sync.external-sync-description">
User synced via LDAP. Some changes must be done in LDAP or mappings.
</Trans>
</td>
<td>
<TagBadge
label={t('admin.user-ldap-sync-info.label-ldap', 'LDAP')}
removeIcon={false}
count={0}
onClick={undefined}
/>
</td>
</tr>
<tr>
<td>
<Trans i18nKey="admin.ldap-sync.next-sync-label">Next scheduled synchronization</Trans>
</td>
<td colSpan={2}>
{ldapSyncInfo.enabled ? (
nextSyncTime
) : (
<Trans i18nKey="admin.ldap-sync.not-enabled">Not enabled</Trans>
)}
</td>
</tr>
</tbody>
</table>
</div>
<div className="gf-form-button-row">
{canSyncLDAPUser && (
<Button variant="secondary" onClick={this.onUserSync}>
<Trans i18nKey="admin.ldap-sync.sync-button">Sync user</Trans>
</Button>
)}
{canReadLDAPUser && (
<LinkButton variant="secondary" href={debugLDAPMappingURL}>
<Trans i18nKey="admin.ldap-sync.debug-button">Debug LDAP Mapping</Trans>
</LinkButton>
)}
</div>
return (
<>
<h3 className="page-heading">
<Trans i18nKey="admin.ldap-sync.title">LDAP Synchronisation</Trans>
</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td>
<Trans i18nKey="admin.ldap-sync.external-sync-label">External sync</Trans>
</td>
<td>
<Trans i18nKey="admin.ldap-sync.external-sync-description">
User synced via LDAP. Some changes must be done in LDAP or mappings.
</Trans>
</td>
<td>
<TagBadge
label={t('admin.user-ldap-sync-info.label-ldap', 'LDAP')}
removeIcon={false}
count={0}
onClick={undefined}
/>
</td>
</tr>
<tr>
<td>
<Trans i18nKey="admin.ldap-sync.next-sync-label">Next scheduled synchronization</Trans>
</td>
<td colSpan={2}>
{ldapSyncInfo.enabled ? (
nextSyncTime
) : (
<Trans i18nKey="admin.ldap-sync.not-enabled">Not enabled</Trans>
)}
</td>
</tr>
</tbody>
</table>
</div>
</>
);
}
}
<div className="gf-form-button-row">
{canSyncLDAPUser && (
<Button variant="secondary" onClick={onUserSync}>
<Trans i18nKey="admin.ldap-sync.sync-button">Sync user</Trans>
</Button>
)}
{canReadLDAPUser && (
<LinkButton variant="secondary" href={debugLDAPMappingURL}>
<Trans i18nKey="admin.ldap-sync.debug-button">Debug LDAP Mapping</Trans>
</LinkButton>
)}
</div>
</div>
</>
);
});
UserLdapSyncInfo.displayName = 'UserLdapSyncInfo';
+140 -160
View File
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { createRef, PureComponent, ReactElement } from 'react';
import { memo, PureComponent, ReactElement, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
@@ -10,10 +10,8 @@ import {
Icon,
Modal,
stylesFactory,
Themeable2,
Tooltip,
useStyles2,
withTheme2,
Stack,
TextLink,
} from '@grafana/ui';
@@ -37,73 +35,63 @@ interface Props {
onOrgAdd: (orgId: number, role: OrgRole) => void;
}
interface State {
showAddOrgModal: boolean;
}
export const UserOrgs = memo(({ user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd }: Props) => {
const [showAddOrgModal, setShowAddOrgModal] = useState(false);
const addToOrgButtonRef = useRef<HTMLButtonElement>(null);
export class UserOrgs extends PureComponent<Props, State> {
addToOrgButtonRef = createRef<HTMLButtonElement>();
state = {
showAddOrgModal: false,
const showOrgAddModal = () => {
setShowAddOrgModal(true);
};
showOrgAddModal = () => {
this.setState({ showAddOrgModal: true });
const dismissOrgAddModal = () => {
setShowAddOrgModal(false);
addToOrgButtonRef.current?.focus();
};
dismissOrgAddModal = () => {
this.setState({ showAddOrgModal: false }, () => {
this.addToOrgButtonRef.current?.focus();
});
};
const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd) && !isExternalUser;
render() {
const { user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props;
const { showAddOrgModal } = this.state;
return (
<div>
<h3 className="page-heading">
<Trans i18nKey="admin.user-orgs.title">Organizations</Trans>
</h3>
<Stack gap={1.5} direction="column">
<table className="filter-table form-inline">
<tbody>
{orgs.map((org, index) => (
<OrgRow
key={`${org.orgId}-${index}`}
isExternalUser={isExternalUser}
user={user}
org={org}
onOrgRoleChange={onOrgRoleChange}
onOrgRemove={onOrgRemove}
/>
))}
</tbody>
</table>
const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd) && !isExternalUser;
return (
<div>
<h3 className="page-heading">
<Trans i18nKey="admin.user-orgs.title">Organizations</Trans>
</h3>
<Stack gap={1.5} direction="column">
<table className="filter-table form-inline">
<tbody>
{orgs.map((org, index) => (
<OrgRow
key={`${org.orgId}-${index}`}
isExternalUser={isExternalUser}
user={user}
org={org}
onOrgRoleChange={onOrgRoleChange}
onOrgRemove={onOrgRemove}
/>
))}
</tbody>
</table>
<div>
{canAddToOrg && (
<Button variant="secondary" onClick={showOrgAddModal} ref={addToOrgButtonRef}>
<Trans i18nKey="admin.user-orgs.add-button">Add user to organization</Trans>
</Button>
)}
</div>
<AddToOrgModal
user={user}
userOrgs={orgs}
isOpen={showAddOrgModal}
onOrgAdd={onOrgAdd}
onDismiss={dismissOrgAddModal}
/>
</Stack>
</div>
);
});
UserOrgs.displayName = 'UserOrgs';
<div>
{canAddToOrg && (
<Button variant="secondary" onClick={this.showOrgAddModal} ref={this.addToOrgButtonRef}>
<Trans i18nKey="admin.user-orgs.add-button">Add user to organization</Trans>
</Button>
)}
</div>
<AddToOrgModal
user={user}
userOrgs={orgs}
isOpen={showAddOrgModal}
onOrgAdd={onOrgAdd}
onDismiss={this.dismissOrgAddModal}
/>
</Stack>
</div>
);
}
}
const getOrgRowStyles = stylesFactory((theme: GrafanaTheme2) => {
const getOrgRowStyles = (theme: GrafanaTheme2) => {
return {
removeButton: css({
marginRight: '0.6rem',
@@ -130,9 +118,9 @@ const getOrgRowStyles = stylesFactory((theme: GrafanaTheme2) => {
marginRight: theme.spacing(1),
}),
};
});
};
interface OrgRowProps extends Themeable2 {
interface OrgRowProps {
user?: UserDTO;
org: UserOrg;
isExternalUser?: boolean;
@@ -140,124 +128,116 @@ interface OrgRowProps extends Themeable2 {
onOrgRoleChange: (orgId: number, newRole: OrgRole) => void;
}
class UnThemedOrgRow extends PureComponent<OrgRowProps> {
state = {
currentRole: this.props.org.role,
isChangingRole: false,
roleOptions: [],
};
const OrgRow = memo(({ user, org, isExternalUser, onOrgRemove, onOrgRoleChange }: OrgRowProps) => {
const [currentRole, setCurrentRole] = useState(org.role);
const [isChangingRole, setIsChangingRole] = useState(false);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const styles = useStyles2(getOrgRowStyles);
componentDidMount() {
useEffect(() => {
if (contextSrv.licensedAccessControlEnabled()) {
if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
fetchRoleOptions(this.props.org.orgId)
.then((roles) => this.setState({ roleOptions: roles }))
fetchRoleOptions(org.orgId)
.then((roles) => setRoleOptions(roles))
.catch((e) => console.error(e));
}
}
}
}, [org.orgId]);
onOrgRemove = async () => {
const { org } = this.props;
this.props.onOrgRemove(org.orgId);
const handleOrgRemove = async () => {
onOrgRemove(org.orgId);
};
onChangeRoleClick = () => {
const { org } = this.props;
this.setState({ isChangingRole: true, currentRole: org.role });
const handleChangeRoleClick = () => {
setIsChangingRole(true);
setCurrentRole(org.role);
};
onOrgRoleChange = (newRole: OrgRole) => {
this.setState({ currentRole: newRole });
const handleOrgRoleChange = (newRole: OrgRole) => {
setCurrentRole(newRole);
};
onOrgRoleSave = () => {
this.props.onOrgRoleChange(this.props.org.orgId, this.state.currentRole);
const handleOrgRoleSave = () => {
onOrgRoleChange(org.orgId, currentRole);
};
onCancelClick = () => {
this.setState({ isChangingRole: false });
const handleCancelClick = () => {
setIsChangingRole(false);
};
onBasicRoleChange = (newRole: OrgRole) => {
this.props.onOrgRoleChange(this.props.org.orgId, newRole);
const handleBasicRoleChange = (newRole: OrgRole) => {
onOrgRoleChange(org.orgId, newRole);
};
render() {
const { user, org, isExternalUser, theme } = this.props;
const authSource = user?.authLabels?.length && user?.authLabels[0];
const lockMessage = authSource ? `Synced via ${authSource}` : '';
const { currentRole, isChangingRole } = this.state;
const styles = getOrgRowStyles(theme);
const labelClass = cx('width-16', styles.label);
const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersWrite);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove) && !isExternalUser;
const rolePickerDisabled = isExternalUser || !canChangeRole;
const authSource = user?.authLabels?.length && user?.authLabels[0];
const lockMessage = authSource ? `Synced via ${authSource}` : '';
const labelClass = cx('width-16', styles.label);
const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersWrite);
const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove) && !isExternalUser;
const rolePickerDisabled = isExternalUser || !canChangeRole;
const inputId = `${org.name}-input`;
return (
<tr>
<td className={labelClass}>
<label htmlFor={inputId}>{org.name}</label>
</td>
{contextSrv.licensedAccessControlEnabled() ? (
<td>
<div className={styles.rolePickerWrapper}>
<div className={styles.rolePicker}>
<UserRolePicker
userId={user?.id || 0}
orgId={org.orgId}
basicRole={org.role}
roleOptions={this.state.roleOptions}
onBasicRoleChange={this.onBasicRoleChange}
basicRoleDisabled={rolePickerDisabled}
basicRoleDisabledMessage="This user's role is not editable because it is synchronized from your auth provider.
Refer to the Grafana authentication docs for details."
/>
</div>
{isExternalUser && <ExternalUserTooltip lockMessage={lockMessage} />}
const inputId = `${org.name}-input`;
return (
<tr>
<td className={labelClass}>
<label htmlFor={inputId}>{org.name}</label>
</td>
{contextSrv.licensedAccessControlEnabled() ? (
<td>
<div className={styles.rolePickerWrapper}>
<div className={styles.rolePicker}>
<UserRolePicker
userId={user?.id || 0}
orgId={org.orgId}
basicRole={org.role}
roleOptions={roleOptions}
onBasicRoleChange={handleBasicRoleChange}
basicRoleDisabled={rolePickerDisabled}
basicRoleDisabledMessage="This user's role is not editable because it is synchronized from your auth provider.
Refer to the Grafana authentication docs for details."
/>
</div>
</td>
) : (
<>
{isChangingRole ? (
<td>
<OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} autoFocus />
</td>
) : (
<td className="width-25">{org.role}</td>
)}
<td colSpan={1}>
{canChangeRole && (
<ChangeOrgButton
lockMessage={lockMessage}
isExternalUser={isExternalUser}
onChangeRoleClick={this.onChangeRoleClick}
onCancelClick={this.onCancelClick}
onOrgRoleSave={this.onOrgRoleSave}
/>
)}
</td>
</>
)}
<td colSpan={1}>
{canRemoveFromOrg && (
<ConfirmButton
confirmText={t('admin.un-themed-org-row.confirmText-confirm-removal', 'Confirm removal')}
confirmVariant="destructive"
onCancel={this.onCancelClick}
onConfirm={this.onOrgRemove}
>
{t('admin.user-orgs.remove-button', 'Remove from organization')}
</ConfirmButton>
)}
{isExternalUser && <ExternalUserTooltip lockMessage={lockMessage} />}
</div>
</td>
</tr>
);
}
}
const OrgRow = withTheme2(UnThemedOrgRow);
) : (
<>
{isChangingRole ? (
<td>
<OrgRolePicker inputId={inputId} value={currentRole} onChange={handleOrgRoleChange} autoFocus />
</td>
) : (
<td className="width-25">{org.role}</td>
)}
<td colSpan={1}>
{canChangeRole && (
<ChangeOrgButton
lockMessage={lockMessage}
isExternalUser={isExternalUser}
onChangeRoleClick={handleChangeRoleClick}
onCancelClick={handleCancelClick}
onOrgRoleSave={handleOrgRoleSave}
/>
)}
</td>
</>
)}
<td colSpan={1}>
{canRemoveFromOrg && (
<ConfirmButton
confirmText={t('admin.un-themed-org-row.confirmText-confirm-removal', 'Confirm removal')}
confirmVariant="destructive"
onCancel={handleCancelClick}
onConfirm={handleOrgRemove}
>
{t('admin.user-orgs.remove-button', 'Remove from organization')}
</ConfirmButton>
)}
</td>
</tr>
);
});
OrgRow.displayName = 'OrgRow';
const getAddToOrgModalStyles = stylesFactory(() => ({
modal: css({
+91 -104
View File
@@ -1,4 +1,4 @@
import { createRef, PureComponent } from 'react';
import { memo, useRef, useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { ConfirmButton, ConfirmModal, Button, Stack } from '@grafana/ui';
@@ -15,120 +15,107 @@ interface Props {
onAllSessionsRevoke: () => void;
}
interface State {
showLogoutModal: boolean;
}
export const UserSessions = memo(({ sessions, onSessionRevoke, onAllSessionsRevoke }: Props) => {
const [showLogoutModal, setShowLogoutModal] = useState(false);
const forceAllLogoutButton = useRef<HTMLButtonElement>(null);
class BaseUserSessions extends PureComponent<Props, State> {
forceAllLogoutButton = createRef<HTMLButtonElement>();
state: State = {
showLogoutModal: false,
const showLogoutConfirmationModal = () => {
setShowLogoutModal(true);
};
showLogoutConfirmationModal = () => {
this.setState({ showLogoutModal: true });
const dismissLogoutConfirmationModal = () => {
setShowLogoutModal(false);
forceAllLogoutButton.current?.focus();
};
dismissLogoutConfirmationModal = () => {
this.setState({ showLogoutModal: false }, () => {
this.forceAllLogoutButton.current?.focus();
});
};
onSessionRevoke = (id: number) => {
const handleSessionRevoke = (id: number) => {
return () => {
this.props.onSessionRevoke(id);
onSessionRevoke(id);
};
};
onAllSessionsRevoke = () => {
this.setState({ showLogoutModal: false });
this.props.onAllSessionsRevoke();
const handleAllSessionsRevoke = () => {
setShowLogoutModal(false);
onAllSessionsRevoke();
};
render() {
const { sessions } = this.props;
const { showLogoutModal } = this.state;
const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout);
const canLogout = contextSrv.hasPermission(AccessControlAction.UsersLogout);
return (
<div>
<h3 className="page-heading">
<Trans i18nKey="admin.user-sessions.title">Sessions</Trans>
</h3>
<Stack direction="column" gap={1.5}>
<div>
<table className="filter-table form-inline">
<thead>
<tr>
<th>
<Trans i18nKey="admin.user-sessions.last-seen-column">Last seen</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.logged-on-column">Logged on</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.ip-column">IP address</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.browser-column">Browser and OS</Trans>
</th>
<th colSpan={2}>
<Trans i18nKey="user-session.auth-module-column">Identity Provider</Trans>
</th>
</tr>
</thead>
<tbody>
{sessions &&
sessions.map((session, index) => (
<tr key={`${session.id}-${index}`}>
<td>{session.isActive ? t('admin.user-sessions.now', 'Now') : session.seenAt}</td>
<td>{formatDate(session.createdAt, { dateStyle: 'long' })}</td>
<td>{session.clientIp}</td>
<td>{`${session.browser} on ${session.os} ${session.osVersion}`}</td>
<td>
{session.authModule && <TagBadge label={session.authModule} removeIcon={false} count={0} />}
</td>
<td>
{canLogout && (
<ConfirmButton
confirmText={t('admin.base-user-sessions.confirmText-confirm-logout', 'Confirm logout')}
confirmVariant="destructive"
onConfirm={handleSessionRevoke(session.id)}
>
{t('admin.user-sessions.force-logout-button', 'Force logout')}
</ConfirmButton>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
return (
<div>
<h3 className="page-heading">
<Trans i18nKey="admin.user-sessions.title">Sessions</Trans>
</h3>
<Stack direction="column" gap={1.5}>
<div>
<table className="filter-table form-inline">
<thead>
<tr>
<th>
<Trans i18nKey="admin.user-sessions.last-seen-column">Last seen</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.logged-on-column">Logged on</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.ip-column">IP address</Trans>
</th>
<th>
<Trans i18nKey="admin.user-sessions.browser-column">Browser and OS</Trans>
</th>
<th colSpan={2}>
<Trans i18nKey="user-session.auth-module-column">Identity Provider</Trans>
</th>
</tr>
</thead>
<tbody>
{sessions &&
sessions.map((session, index) => (
<tr key={`${session.id}-${index}`}>
<td>{session.isActive ? t('admin.user-sessions.now', 'Now') : session.seenAt}</td>
<td>{formatDate(session.createdAt, { dateStyle: 'long' })}</td>
<td>{session.clientIp}</td>
<td>{`${session.browser} on ${session.os} ${session.osVersion}`}</td>
<td>
{session.authModule && <TagBadge label={session.authModule} removeIcon={false} count={0} />}
</td>
<td>
{canLogout && (
<ConfirmButton
confirmText={t('admin.base-user-sessions.confirmText-confirm-logout', 'Confirm logout')}
confirmVariant="destructive"
onConfirm={this.onSessionRevoke(session.id)}
>
{t('admin.user-sessions.force-logout-button', 'Force logout')}
</ConfirmButton>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
{canLogout && sessions.length > 0 && (
<Button variant="secondary" onClick={this.showLogoutConfirmationModal} ref={this.forceAllLogoutButton}>
<Trans i18nKey="admin.user-sessions.force-logout-all-button">Force logout from all devices</Trans>
</Button>
<div>
{canLogout && sessions.length > 0 && (
<Button variant="secondary" onClick={showLogoutConfirmationModal} ref={forceAllLogoutButton}>
<Trans i18nKey="admin.user-sessions.force-logout-all-button">Force logout from all devices</Trans>
</Button>
)}
<ConfirmModal
isOpen={showLogoutModal}
title={t('admin.base-user-sessions.title-force-logout-from-all-devices', 'Force logout from all devices')}
body={t(
'admin.base-user-sessions.body-force-logout-from-all-devices',
'Are you sure you want to force logout from all devices?'
)}
<ConfirmModal
isOpen={showLogoutModal}
title={t('admin.base-user-sessions.title-force-logout-from-all-devices', 'Force logout from all devices')}
body={t(
'admin.base-user-sessions.body-force-logout-from-all-devices',
'Are you sure you want to force logout from all devices?'
)}
confirmText={t('admin.base-user-sessions.confirmText-force-logout', 'Force logout')}
onConfirm={this.onAllSessionsRevoke}
onDismiss={this.dismissLogoutConfirmationModal}
/>
</div>
</Stack>
</div>
);
}
}
export const UserSessions = BaseUserSessions;
confirmText={t('admin.base-user-sessions.confirmText-force-logout', 'Force logout')}
onConfirm={handleAllSessionsRevoke}
onDismiss={dismissLogoutConfirmationModal}
/>
</div>
</Stack>
</div>
);
});
UserSessions.displayName = 'UserSessions';
@@ -1,6 +1,5 @@
import { isEmpty } from 'lodash';
import { PureComponent } from 'react';
import * as React from 'react';
import { FormEvent, memo, useState } from 'react';
import { rangeUtil, TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -24,49 +23,58 @@ interface Props {
liveNow?: boolean;
}
interface State {
isNowDelayValid: boolean;
}
export const TimePickerSettings = memo(
({
onWeekStartChange,
onTimeZoneChange,
onRefreshIntervalChange,
onNowDelayChange,
onHideTimePickerChange,
onLiveNowChange,
refreshIntervals,
timePickerHidden,
nowDelay,
timezone,
weekStart,
liveNow,
}: Props) => {
const [isNowDelayValid, setIsNowDelayValid] = useState(true);
export class TimePickerSettings extends PureComponent<Props, State> {
state: State = { isNowDelayValid: true };
const handleNowDelayChange = (event: FormEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
onNowDelayChange = (event: React.FormEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
if (isEmpty(value)) {
setIsNowDelayValid(true);
return onNowDelayChange(value);
}
if (isEmpty(value)) {
this.setState({ isNowDelayValid: true });
return this.props.onNowDelayChange(value);
}
if (rangeUtil.isValidTimeSpan(value)) {
setIsNowDelayValid(true);
return onNowDelayChange(value);
}
if (rangeUtil.isValidTimeSpan(value)) {
this.setState({ isNowDelayValid: true });
return this.props.onNowDelayChange(value);
}
setIsNowDelayValid(false);
};
this.setState({ isNowDelayValid: false });
};
const handleHideTimePickerChange = () => {
onHideTimePickerChange(!timePickerHidden);
};
onHideTimePickerChange = () => {
this.props.onHideTimePickerChange(!this.props.timePickerHidden);
};
const handleLiveNowChange = () => {
onLiveNowChange(!liveNow);
};
onLiveNowChange = () => {
this.props.onLiveNowChange(!this.props.liveNow);
};
const handleTimeZoneChange = (timeZone?: string) => {
if (typeof timeZone !== 'string') {
return;
}
onTimeZoneChange(timeZone);
};
onTimeZoneChange = (timeZone?: string) => {
if (typeof timeZone !== 'string') {
return;
}
this.props.onTimeZoneChange(timeZone);
};
const handleWeekStartChange = (weekStart?: WeekStart) => {
onWeekStartChange(weekStart);
};
onWeekStartChange = (weekStart?: WeekStart) => {
this.props.onWeekStartChange(weekStart);
};
render() {
return (
<CollapsableSection label={t('dashboard-settings.time-picker.time-options-label', 'Time options')} isOpen={true}>
<Field
@@ -76,8 +84,8 @@ export class TimePickerSettings extends PureComponent<Props, State> {
<TimeZonePicker
inputId="time-options-input"
includeInternal={true}
value={this.props.timezone}
onChange={this.onTimeZoneChange}
value={timezone}
onChange={handleTimeZoneChange}
width={40}
/>
</Field>
@@ -85,17 +93,9 @@ export class TimePickerSettings extends PureComponent<Props, State> {
label={t('dashboard-settings.time-picker.week-start-label', 'Week start')}
data-testid={selectors.components.WeekStartPicker.containerV2}
>
<WeekStartPicker
inputId="week-start-input"
width={40}
value={this.props.weekStart}
onChange={this.onWeekStartChange}
/>
<WeekStartPicker inputId="week-start-input" width={40} value={weekStart} onChange={handleWeekStartChange} />
</Field>
<AutoRefreshIntervals
refreshIntervals={this.props.refreshIntervals}
onRefreshIntervalChange={this.props.onRefreshIntervalChange}
/>
<AutoRefreshIntervals refreshIntervals={refreshIntervals} onRefreshIntervalChange={onRefreshIntervalChange} />
<Field
label={t('dashboard-settings.time-picker.now-delay-label', 'Now delay')}
description={t(
@@ -105,19 +105,15 @@ export class TimePickerSettings extends PureComponent<Props, State> {
>
<Input
id="now-delay-input"
invalid={!this.state.isNowDelayValid}
invalid={!isNowDelayValid}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="0m"
onChange={this.onNowDelayChange}
defaultValue={this.props.nowDelay}
onChange={handleNowDelayChange}
defaultValue={nowDelay}
/>
</Field>
<Field label={t('dashboard-settings.time-picker.hide-time-picker', 'Hide time picker')}>
<Switch
id="hide-time-picker-toggle"
value={!!this.props.timePickerHidden}
onChange={this.onHideTimePickerChange}
/>
<Switch id="hide-time-picker-toggle" value={!!timePickerHidden} onChange={handleHideTimePickerChange} />
</Field>
<Field
label={t('dashboard-settings.time-picker.refresh-live-dashboards-label', 'Refresh live dashboards')}
@@ -126,9 +122,10 @@ export class TimePickerSettings extends PureComponent<Props, State> {
'Continuously update panels when the time range includes the current time'
)}
>
<Switch id="refresh-live-dashboards-toggle" value={!!this.props.liveNow} onChange={this.onLiveNowChange} />
<Switch id="refresh-live-dashboards-toggle" value={!!liveNow} onChange={handleLiveNowChange} />
</Field>
</CollapsableSection>
);
}
}
);
TimePickerSettings.displayName = 'TimePickerSettings';
@@ -1,4 +1,4 @@
import { createElement, PureComponent } from 'react';
import { createElement, memo } from 'react';
import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data';
import { writableProxy } from 'app/features/plugins/extensions/utils';
@@ -12,36 +12,23 @@ export interface Props {
onModelChange: (dataSource: DataSourceSettings) => void;
}
export class DataSourcePluginSettings extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.onModelChanged = this.onModelChanged.bind(this);
export const DataSourcePluginSettings = memo(({ plugin, dataSource, onModelChange }: Props) => {
if (!plugin) {
return null;
}
onModelChanged = (dataSource: DataSourceSettings) => {
this.props.onModelChange(dataSource);
};
render() {
const { plugin, dataSource } = this.props;
if (!plugin) {
return null;
}
return (
<div>
{plugin.components.ConfigEditor &&
createElement(plugin.components.ConfigEditor, {
options: writableProxy(dataSource, {
source: 'datasource',
pluginId: plugin.meta?.id,
pluginVersion: plugin.meta?.info?.version,
}),
onOptionsChange: this.onModelChanged,
})}
</div>
);
}
}
return (
<div>
{plugin.components.ConfigEditor &&
createElement(plugin.components.ConfigEditor, {
options: writableProxy(dataSource, {
source: 'datasource',
pluginId: plugin.meta?.id,
pluginVersion: plugin.meta?.info?.version,
}),
onOptionsChange: onModelChange,
})}
</div>
);
});
DataSourcePluginSettings.displayName = 'DataSourcePluginSettings';
@@ -1,5 +1,3 @@
import { Component } from 'react';
import { TimeRange, RawTimeRange, dateTimeForTimeZone, dateMath } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { TimeZone } from '@grafana/schema';
@@ -22,9 +20,19 @@ export interface Props {
onChangeFiscalYearStartMonth: (fiscalYearStartMonth: number) => void;
}
export class ExploreTimeControls extends Component<Props> {
onMoveTimePicker = (direction: number) => {
const { range, onChangeTime, timeZone } = this.props;
export const ExploreTimeControls = ({
range,
timeZone,
fiscalYearStartMonth,
splitted,
syncedTimes,
onChangeTimeSync,
hideText,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
onChangeTime,
}: Props) => {
const onMoveTimePicker = (direction: number) => {
const { from, to } = getShiftedTimeRange(direction, range);
const nextTimeRange = {
from: dateTimeForTimeZone(timeZone, from),
@@ -34,14 +42,14 @@ export class ExploreTimeControls extends Component<Props> {
onChangeTime(nextTimeRange);
};
onMoveForward = () => this.onMoveTimePicker(1);
onMoveBack = () => this.onMoveTimePicker(-1);
const onMoveForward = () => onMoveTimePicker(1);
const onMoveBack = () => onMoveTimePicker(-1);
onChangeTimePicker = (timeRange: TimeRange) => {
const onChangeTimePicker = (timeRange: TimeRange) => {
const adjustedFrom = dateMath.isMathString(timeRange.raw.from) ? timeRange.raw.from : timeRange.from;
const adjustedTo = dateMath.isMathString(timeRange.raw.to) ? timeRange.raw.to : timeRange.to;
this.props.onChangeTime({
onChangeTime({
from: adjustedFrom,
to: adjustedTo,
});
@@ -52,8 +60,7 @@ export class ExploreTimeControls extends Component<Props> {
});
};
onZoom = () => {
const { range, onChangeTime, timeZone } = this.props;
const onZoom = () => {
const { from, to } = getZoomedTimeRange(range, 2);
const nextTimeRange = {
from: dateTimeForTimeZone(timeZone, from),
@@ -63,40 +70,27 @@ export class ExploreTimeControls extends Component<Props> {
onChangeTime(nextTimeRange);
};
render() {
const {
range,
timeZone,
fiscalYearStartMonth,
splitted,
syncedTimes,
onChangeTimeSync,
hideText,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
} = this.props;
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined;
const timePickerCommonProps = {
value: range,
timeZone,
fiscalYearStartMonth,
onMoveBackward: this.onMoveBack,
onMoveForward: this.onMoveForward,
onZoom: this.onZoom,
hideText,
};
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined;
const timePickerCommonProps = {
value: range,
timeZone,
fiscalYearStartMonth,
onMoveBackward: onMoveBack,
onMoveForward: onMoveForward,
onZoom: onZoom,
hideText,
};
return (
<TimePickerWithHistory
isOnCanvas
{...timePickerCommonProps}
timeSyncButton={timeSyncButton}
isSynced={syncedTimes}
widthOverride={splitted ? window.innerWidth / 2 : undefined}
onChange={this.onChangeTimePicker}
onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
/>
);
}
}
return (
<TimePickerWithHistory
isOnCanvas
{...timePickerCommonProps}
timeSyncButton={timeSyncButton}
isSynced={syncedTimes}
widthOverride={splitted ? window.innerWidth / 2 : undefined}
onChange={onChangeTimePicker}
onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
/>
);
};
@@ -18,15 +18,16 @@ import SearchBarInput from './SearchBarInput';
describe('SearchBarInput', () => {
describe('rendering', () => {
const onChange = jest.fn();
it('renders as expected with no value', () => {
render(<SearchBarInput />);
render(<SearchBarInput onChange={onChange} />);
const searchBarInput = screen.queryByPlaceholderText('Find...');
expect(searchBarInput).toBeInTheDocument();
expect(searchBarInput?.getAttribute('value')).toEqual('');
});
it('renders as expected with value', () => {
render(<SearchBarInput value="value" />);
render(<SearchBarInput onChange={onChange} value="value" />);
const searchBarInput = screen.queryByPlaceholderText('Find...');
expect(searchBarInput).toBeInTheDocument();
expect(searchBarInput?.getAttribute('value')).toEqual('value');
@@ -12,49 +12,44 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import * as React from 'react';
import { memo } from 'react';
import { t } from '@grafana/i18n';
import { IconButton, Input } from '@grafana/ui';
type Props = {
value: string | undefined;
value?: string;
onChange: (value: string) => void;
};
export default class SearchBarInput extends React.PureComponent<Props> {
static defaultProps: Partial<Props> = {
value: undefined,
const SearchBarInput = memo(({ value, onChange }: Props) => {
const clearUiFind = () => {
onChange('');
};
clearUiFind = () => {
this.props.onChange('');
};
render() {
const { value } = this.props;
const suffix = (
<>
{value && value.length && (
<IconButton
name="times"
onClick={this.clearUiFind}
tooltip={t('explore.search-bar-input.suffix.tooltip-clear-input', 'Clear input')}
/>
)}
</>
);
return (
<div style={{ width: '200px' }}>
<Input
placeholder={t('explore.search-bar-input.placeholder-find', 'Find...')}
onChange={(e) => this.props.onChange(e.currentTarget.value)}
suffix={suffix}
value={value}
const suffix = (
<>
{value && value.length && (
<IconButton
name="times"
onClick={clearUiFind}
tooltip={t('explore.search-bar-input.suffix.tooltip-clear-input', 'Clear input')}
/>
</div>
);
}
}
)}
</>
);
return (
<div style={{ width: '200px' }}>
<Input
placeholder={t('explore.search-bar-input.placeholder-find', 'Find...')}
onChange={(e) => onChange(e.currentTarget.value)}
suffix={suffix}
value={value}
/>
</div>
);
});
SearchBarInput.displayName = 'SearchBarInput';
export default SearchBarInput;
@@ -1,72 +1,63 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import { memo, useEffect, useRef, useState } from 'react';
import { Unsubscribable } from 'rxjs';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config, getGrafanaLiveSrv } from '@grafana/runtime';
import { Alert, stylesFactory } from '@grafana/ui';
import { Alert, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
export interface Props {}
export interface State {
show?: boolean;
}
export const LiveConnectionWarning = memo(function LiveConnectionWarning() {
const [show, setShow] = useState<boolean | undefined>(undefined);
const subscriptionRef = useRef<Unsubscribable>();
const styles = useStyles2(getStyle);
export class LiveConnectionWarning extends PureComponent<Props, State> {
subscription?: Unsubscribable;
styles = getStyle(config.theme2);
state: State = {};
componentDidMount() {
useEffect(() => {
// Only show the error in development mode
if (process.env.NODE_ENV === 'development') {
// Wait a second to listen for server errors
setTimeout(this.initListener, 1500);
const timer = setTimeout(() => {
const live = getGrafanaLiveSrv();
if (live) {
subscriptionRef.current = live.getConnectionState().subscribe({
next: (v) => {
setShow(!v);
},
});
}
}, 1500);
return () => {
clearTimeout(timer);
if (subscriptionRef.current) {
subscriptionRef.current.unsubscribe();
}
};
}
return undefined;
}, []);
if (show) {
if (!contextSrv.isSignedIn || !config.liveEnabled || contextSrv.user.orgRole === OrgRole.None) {
return null; // do not show the warning for anonymous users or ones with no org (and /login page etc)
}
return (
<Alert
severity={'warning'}
className={styles.warn}
title={t('live.live-connection-warning.title-connection-to-server-is-lost', 'Connection to server is lost...')}
/>
);
}
return null;
});
initListener = () => {
const live = getGrafanaLiveSrv();
if (live) {
this.subscription = live.getConnectionState().subscribe({
next: (v) => {
this.setState({ show: !v });
},
});
}
};
componentWillUnmount() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
render() {
const { show } = this.state;
if (show) {
if (!contextSrv.isSignedIn || !config.liveEnabled || contextSrv.user.orgRole === OrgRole.None) {
return null; // do not show the warning for anonymous users or ones with no org (and /login page etc)
}
return (
<Alert
severity={'warning'}
className={this.styles.warn}
title={t(
'live.live-connection-warning.title-connection-to-server-is-lost',
'Connection to server is lost...'
)}
/>
);
}
return null;
}
}
const getStyle = stylesFactory((theme: GrafanaTheme2) => ({
const getStyle = (theme: GrafanaTheme2) => ({
warn: css({
position: 'fixed',
bottom: 0,
@@ -76,4 +67,4 @@ const getStyle = stylesFactory((theme: GrafanaTheme2) => ({
zIndex: theme.zIndex.portal,
cursor: 'wait',
}),
}));
});
@@ -1,4 +1,4 @@
import { PureComponent, ChangeEvent } from 'react';
import { memo, ChangeEvent } from 'react';
import {
DataTransformerID,
@@ -18,13 +18,8 @@ import lightImage from '../images/light/concatenate.svg';
interface ConcatenateTransformerEditorProps extends TransformerUIProps<ConcatenateTransformerOptions> {}
export class ConcatenateTransformerEditor extends PureComponent<ConcatenateTransformerEditorProps> {
constructor(props: ConcatenateTransformerEditorProps) {
super(props);
}
onModeChanged = (value: SelectableValue<ConcatenateFrameNameMode>) => {
const { options, onChange } = this.props;
export const ConcatenateTransformerEditor = memo(({ options, onChange }: ConcatenateTransformerEditorProps) => {
const onModeChanged = (value: SelectableValue<ConcatenateFrameNameMode>) => {
const frameNameMode = value.value ?? ConcatenateFrameNameMode.FieldName;
onChange({
...options,
@@ -32,74 +27,63 @@ export class ConcatenateTransformerEditor extends PureComponent<ConcatenateTrans
});
};
onLabelChanged = (evt: ChangeEvent<HTMLInputElement>) => {
const { options } = this.props;
this.props.onChange({
const onLabelChanged = (evt: ChangeEvent<HTMLInputElement>) => {
onChange({
...options,
frameNameLabel: evt.target.value,
});
};
//---------------------------------------------------------
// Render
//---------------------------------------------------------
const nameModes: Array<SelectableValue<ConcatenateFrameNameMode>> = [
{
value: ConcatenateFrameNameMode.FieldName,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.copy-frame-name-to-field',
'Copy frame name to field name'
),
},
{
value: ConcatenateFrameNameMode.Label,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.label-frame',
'Add a label with the frame name'
),
},
{
value: ConcatenateFrameNameMode.Drop,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.ignore-the-frame-name',
'Ignore the frame name'
),
},
];
render() {
const { options } = this.props;
const nameModes: Array<SelectableValue<ConcatenateFrameNameMode>> = [
{
value: ConcatenateFrameNameMode.FieldName,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.copy-frame-name-to-field',
'Copy frame name to field name'
),
},
{
value: ConcatenateFrameNameMode.Label,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.label-frame',
'Add a label with the frame name'
),
},
{
value: ConcatenateFrameNameMode.Drop,
label: t(
'transformers.concatenate-transformer-editor.name-modes.label.ignore-the-frame-name',
'Ignore the frame name'
),
},
];
const frameNameMode = options.frameNameMode ?? ConcatenateFrameNameMode.FieldName;
const frameNameMode = options.frameNameMode ?? ConcatenateFrameNameMode.FieldName;
return (
<div>
<InlineField label={t('transformers.concatenate-transformer-editor.label-name', 'Name')} labelWidth={16} grow>
<Select
return (
<div>
<InlineField label={t('transformers.concatenate-transformer-editor.label-name', 'Name')} labelWidth={16} grow>
<Select
width={36}
options={nameModes}
value={nameModes.find((v) => v.value === frameNameMode)}
onChange={onModeChanged}
/>
</InlineField>
{frameNameMode === ConcatenateFrameNameMode.Label && (
<InlineField label={t('transformers.concatenate-transformer-editor.label-label', 'Label')} labelWidth={16} grow>
<Input
width={36}
options={nameModes}
value={nameModes.find((v) => v.value === frameNameMode)}
onChange={this.onModeChanged}
value={options.frameNameLabel ?? ''}
placeholder={t('transformers.concatenate-transformer-editor.placeholder-frame', 'Frame')}
onChange={onLabelChanged}
/>
</InlineField>
{frameNameMode === ConcatenateFrameNameMode.Label && (
<InlineField
label={t('transformers.concatenate-transformer-editor.label-label', 'Label')}
labelWidth={16}
grow
>
<Input
width={36}
value={options.frameNameLabel ?? ''}
placeholder={t('transformers.concatenate-transformer-editor.placeholder-frame', 'Frame')}
onChange={this.onLabelChanged}
/>
</InlineField>
)}
</div>
);
}
}
)}
</div>
);
});
ConcatenateTransformerEditor.displayName = 'ConcatenateTransformerEditor';
export const getConcatenateTransformRegistryItem: () => TransformerRegistryItem<ConcatenateTransformerOptions> =
() => ({
@@ -1,4 +1,4 @@
import { FormEvent, PureComponent } from 'react';
import { FormEvent, memo } from 'react';
import { ConstantVariableModel } from '@grafana/data';
import { ConstantVariableForm } from 'app/features/dashboard-scene/settings/variables/components/ConstantVariableForm';
@@ -7,16 +7,15 @@ import { VariableEditorProps } from '../editor/types';
export interface Props extends VariableEditorProps<ConstantVariableModel> {}
export class ConstantVariableEditor extends PureComponent<Props> {
onChange = (event: FormEvent<HTMLInputElement>) => {
this.props.onPropChange({
export const ConstantVariableEditor = memo(({ variable, onPropChange }: Props) => {
const onChange = (event: FormEvent<HTMLInputElement>) => {
onPropChange({
propName: 'query',
propValue: event.currentTarget.value,
updateOptions: true,
});
};
render() {
return <ConstantVariableForm constantValue={this.props.variable.query} onChange={this.onChange} />;
}
}
return <ConstantVariableForm constantValue={variable.query} onChange={onChange} />;
});
ConstantVariableEditor.displayName = 'ConstantVariableEditor';
@@ -1,48 +1,45 @@
import { PureComponent } from 'react';
import * as React from 'react';
import { memo, type KeyboardEvent, type HTMLProps } from 'react';
import { t } from '@grafana/i18n';
import { NavigationKey } from '../types';
export interface Props extends Omit<React.HTMLProps<HTMLInputElement>, 'onChange' | 'value'> {
export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange' | 'value'> {
onChange: (value: string) => void;
onNavigate: (key: NavigationKey, clearOthers: boolean) => void;
value: string | null;
}
export class VariableInput extends PureComponent<Props> {
onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
export const VariableInput = memo(({ value, id, onNavigate, onChange, ...restProps }: Props) => {
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (NavigationKey[event.keyCode] && event.keyCode !== NavigationKey.select) {
const clearOthers = event.ctrlKey || event.metaKey || event.shiftKey;
this.props.onNavigate(event.keyCode, clearOthers);
onNavigate(event.keyCode, clearOthers);
event.preventDefault();
}
};
onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
this.props.onChange(event.target.value);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
render() {
const { value, id, onNavigate, ...restProps } = this.props;
return (
<input
{...restProps}
ref={(instance) => {
if (instance) {
instance.focus();
instance.setAttribute('style', `width:${Math.max(instance.width, 150)}px`);
}
}}
id={id}
type="text"
className="gf-form-input"
value={value ?? ''}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={t('variable.dropdown.placeholder', 'Enter variable value')}
/>
);
}
}
return (
<input
{...restProps}
ref={(instance) => {
if (instance) {
instance.focus();
instance.setAttribute('style', `width:${Math.max(instance.width, 150)}px`);
}
}}
id={id}
type="text"
className="gf-form-input"
value={value ?? ''}
onChange={handleChange}
onKeyDown={onKeyDown}
placeholder={t('variable.dropdown.placeholder', 'Enter variable value')}
/>
);
});
VariableInput.displayName = 'VariableInput';
@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo } from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ConnectionConfig } from '@grafana/google-sdk';
@@ -10,8 +10,8 @@ import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../typ
export type Props = DataSourcePluginOptionsEditorProps<CloudMonitoringOptions, CloudMonitoringSecureJsonData>;
export class ConfigEditor extends PureComponent<Props> {
handleOnOptionsChange = (options: Props['options']) => {
export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => {
const handleOnOptionsChange = (options: Props['options']) => {
if (options.jsonData.privateKeyPath || options.secureJsonFields['privateKey']) {
reportInteraction('grafana_cloud_monitoring_config_changed', {
authenticationType: 'JWT',
@@ -19,34 +19,32 @@ export class ConfigEditor extends PureComponent<Props> {
privateKeyPath: !!options.jsonData.privateKeyPath,
});
}
this.props.onOptionsChange(options);
onOptionsChange(options);
};
render() {
const { options, onOptionsChange } = this.props;
return (
<>
<DataSourceDescription
dataSourceName="Google Cloud Monitoring"
docsLink="https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/"
hasRequiredFields
/>
<Divider />
<ConnectionConfig {...this.props} onOptionsChange={this.handleOnOptionsChange}></ConnectionConfig>
{config.secureSocksDSProxyEnabled && (
<>
<Divider />
<ConfigSection
title="Additional settings"
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy."
isCollapsible={true}
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
</>
)}
</>
);
}
}
return (
<>
<DataSourceDescription
dataSourceName="Google Cloud Monitoring"
docsLink="https://grafana.com/docs/grafana/latest/datasources/google-cloud-monitoring/"
hasRequiredFields
/>
<Divider />
<ConnectionConfig options={options} onOptionsChange={handleOnOptionsChange}></ConnectionConfig>
{config.secureSocksDSProxyEnabled && (
<>
<Divider />
<ConfigSection
title="Additional settings"
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy."
isCollapsible
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
</>
)}
</>
);
});
ConfigEditor.displayName = 'ConfigEditor';
@@ -1,4 +1,4 @@
import { PureComponent, useState } from 'react';
import { memo, useState } from 'react';
import * as React from 'react';
import { Button, InlineField, InlineFieldRow, Input } from '@grafana/ui';
@@ -93,43 +93,42 @@ const CSVWaveEditor = (props: WaveProps) => {
);
};
export class CSVWavesEditor extends PureComponent<WavesProps> {
onChange = (index: number, wave?: CSVWave) => {
let waves = [...(this.props.waves ?? defaultCSVWaveQuery)];
export const CSVWavesEditor = memo(({ waves, onChange }: WavesProps) => {
const handleChange = (index: number, wave?: CSVWave) => {
let wavesArray = [...(waves ?? defaultCSVWaveQuery)];
if (wave) {
waves[index] = { ...wave };
wavesArray[index] = { ...wave };
} else {
// remove the element
waves.splice(index, 1);
wavesArray.splice(index, 1);
}
this.props.onChange(waves);
onChange(wavesArray);
};
onAdd = () => {
const waves = [...(this.props.waves ?? defaultCSVWaveQuery)];
waves.push({ ...defaultCSVWaveQuery[0] });
this.props.onChange(waves);
const onAdd = () => {
const wavesArray = [...(waves ?? defaultCSVWaveQuery)];
wavesArray.push({ ...defaultCSVWaveQuery[0] });
onChange(wavesArray);
};
render() {
let waves = this.props.waves ?? defaultCSVWaveQuery;
if (!waves.length) {
waves = defaultCSVWaveQuery;
}
return (
<>
{waves.map((wave, index) => (
<CSVWaveEditor
key={`${index}/${wave.valuesCSV}`}
wave={wave}
index={index}
onAdd={this.onAdd}
onChange={this.onChange}
last={index === waves.length - 1}
/>
))}
</>
);
let wavesArray = waves ?? defaultCSVWaveQuery;
if (!wavesArray.length) {
wavesArray = defaultCSVWaveQuery;
}
}
return (
<>
{wavesArray.map((wave, index) => (
<CSVWaveEditor
key={`${index}/${wave.valuesCSV}`}
wave={wave}
index={index}
onAdd={onAdd}
onChange={handleChange}
last={index === wavesArray.length - 1}
/>
))}
</>
);
});
CSVWavesEditor.displayName = 'CSVWavesEditor';
+109 -97
View File
@@ -1,5 +1,5 @@
import { isNumber } from 'lodash';
import { PureComponent } from 'react';
import { memo, useCallback } from 'react';
import {
DisplayValueAlignmentFactors,
@@ -12,120 +12,131 @@ import {
} from '@grafana/data';
import { findNumericFieldMinMax } from '@grafana/data/internal';
import { BigValueTextMode, BigValueGraphMode } from '@grafana/schema';
import { BigValue, DataLinksContextMenu, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { BigValue, DataLinksContextMenu, useTheme2, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { Options } from './panelcfg.gen';
export class StatPanel extends PureComponent<PanelProps<Options>> {
renderComponent = (
valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>,
menuProps: DataLinksContextMenuApi
): JSX.Element => {
const { timeRange, options } = this.props;
const { value, alignmentFactors, width, height, count } = valueProps;
const { openMenu, targetClassName } = menuProps;
let sparkline = value.sparkline;
if (sparkline) {
sparkline.timeRange = timeRange;
}
export const StatPanel = memo(
({
timeRange,
options,
fieldConfig,
title,
data,
replaceVariables,
timeZone,
height,
width,
renderCounter,
}: PanelProps<Options>) => {
const theme = useTheme2();
return (
<BigValue
value={value.display}
count={count}
sparkline={sparkline}
colorMode={options.colorMode}
graphMode={options.graphMode}
justifyMode={options.justifyMode}
textMode={this.getTextMode()}
alignmentFactors={alignmentFactors}
text={options.text}
width={width}
height={height}
theme={config.theme2}
onClick={openMenu}
className={targetClassName}
disableWideLayout={!options.wideLayout}
percentChangeColorMode={options.percentChangeColorMode}
/>
const getTextMode = useCallback(() => {
// If we have manually set displayName or panel title switch text mode to value and name
if (options.textMode === BigValueTextMode.Auto && (fieldConfig.defaults.displayName || !title)) {
return BigValueTextMode.ValueAndName;
}
return options.textMode;
}, [options.textMode, fieldConfig.defaults.displayName, title]);
const renderComponent = useCallback(
(
valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>,
menuProps: DataLinksContextMenuApi
): JSX.Element => {
const { value, alignmentFactors, width, height, count } = valueProps;
const { openMenu, targetClassName } = menuProps;
let sparkline = value.sparkline;
if (sparkline) {
sparkline.timeRange = timeRange;
}
return (
<BigValue
value={value.display}
count={count}
sparkline={sparkline}
colorMode={options.colorMode}
graphMode={options.graphMode}
justifyMode={options.justifyMode}
textMode={getTextMode()}
alignmentFactors={alignmentFactors}
text={options.text}
width={width}
height={height}
theme={theme}
onClick={openMenu}
className={targetClassName}
disableWideLayout={!options.wideLayout}
percentChangeColorMode={options.percentChangeColorMode}
/>
);
},
[theme, timeRange, options, getTextMode]
);
};
getTextMode() {
const { options, fieldConfig, title } = this.props;
const renderValue = useCallback(
(valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>): JSX.Element => {
const { value } = valueProps;
const { getLinks, hasLinks } = value;
// If we have manually set displayName or panel title switch text mode to value and name
if (options.textMode === BigValueTextMode.Auto && (fieldConfig.defaults.displayName || !title)) {
return BigValueTextMode.ValueAndName;
}
if (hasLinks && getLinks) {
return (
<DataLinksContextMenu links={getLinks}>
{(api) => {
return renderComponent(valueProps, api);
}}
</DataLinksContextMenu>
);
}
return options.textMode;
}
return renderComponent(valueProps, {});
},
[renderComponent]
);
renderValue = (valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>): JSX.Element => {
const { value } = valueProps;
const { getLinks, hasLinks } = value;
const getValues = useCallback((): FieldDisplay[] => {
let globalRange: NumericRange | undefined = undefined;
if (hasLinks && getLinks) {
return (
<DataLinksContextMenu links={getLinks}>
{(api) => {
return this.renderComponent(valueProps, api);
}}
</DataLinksContextMenu>
);
}
return this.renderComponent(valueProps, {});
};
getValues = (): FieldDisplay[] => {
const { data, options, replaceVariables, fieldConfig, timeZone } = this.props;
let globalRange: NumericRange | undefined = undefined;
for (let frame of data.series) {
for (let field of frame.fields) {
let { config } = field;
// mostly copied from fieldOverrides, since they are skipped during streaming
// Set the Min/Max value automatically
if (field.type === FieldType.number) {
if (field.state?.range) {
continue;
for (let frame of data.series) {
for (let field of frame.fields) {
let { config } = field;
// mostly copied from fieldOverrides, since they are skipped during streaming
// Set the Min/Max value automatically
if (field.type === FieldType.number) {
if (field.state?.range) {
continue;
}
if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) {
globalRange = findNumericFieldMinMax(data.series);
}
const min = config.min ?? globalRange!.min;
const max = config.max ?? globalRange!.max;
field.state = field.state ?? {};
field.state.range = { min, max, delta: max! - min! };
}
if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) {
globalRange = findNumericFieldMinMax(data.series);
}
const min = config.min ?? globalRange!.min;
const max = config.max ?? globalRange!.max;
field.state = field.state ?? {};
field.state.range = { min, max, delta: max! - min! };
}
}
}
return getFieldDisplayValues({
fieldConfig,
reduceOptions: options.reduceOptions,
replaceVariables,
theme: config.theme2,
data: data.series,
sparkline: options.graphMode !== BigValueGraphMode.None,
percentChange: options.showPercentChange,
timeZone,
});
};
render() {
const { height, options, width, data, renderCounter } = this.props;
return getFieldDisplayValues({
fieldConfig,
reduceOptions: options.reduceOptions,
replaceVariables,
theme,
data: data.series,
sparkline: options.graphMode !== BigValueGraphMode.None,
percentChange: options.showPercentChange,
timeZone,
});
}, [data, fieldConfig, theme, options, replaceVariables, timeZone]);
return (
<VizRepeater
getValues={this.getValues}
getValues={getValues}
getAlignmentFactors={getDisplayValueAlignmentFactors}
renderValue={this.renderValue}
renderValue={renderValue}
width={width}
height={height}
source={data}
@@ -136,4 +147,5 @@ export class StatPanel extends PureComponent<PanelProps<Options>> {
/>
);
}
}
);
StatPanel.displayName = 'StatPanel';
+3
View File
@@ -8782,6 +8782,9 @@
"drawer": {
"close": "Close"
},
"error-boundary": {
"title": "An unexpected error happened"
},
"feature-badge": {
"experimental": "Experimental",
"new": "New!",