Chore: Convert some more react class components to functional (#113951)

* convert LoginCtrl to a functional component

* convert NumberInput to a functional component

* convert StringArrayEditor to a functional component

* convert TeamPicker to a functional component

* convert AddToOrgModal to a functional component

* don't set noMargin yet

* convert UserProfile to a functional component

* convert AnnotationFieldMapper to a functional component

* convert PanelHeaderCorner to a functional component

* convert ShareExport to a functional component

* convert ShareLink to a functional component

* convert RawPrometheusContainer to a functional component
This commit is contained in:
Ashley Harrison
2025-11-19 09:58:16 +00:00
committed by GitHub
parent 6dcb921333
commit 99e0654fc9
13 changed files with 805 additions and 1071 deletions
-47
View File
@@ -1149,11 +1149,6 @@
"count": 1
}
},
"public/app/core/components/Login/LoginCtrl.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/Login/LoginForm.tsx": {
"no-restricted-syntax": {
"count": 2
@@ -1172,9 +1167,6 @@
"public/app/core/components/OptionsUI/NumberInput.tsx": {
"no-restricted-syntax": {
"count": 1
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/OptionsUI/fieldColor.tsx": {
@@ -1192,11 +1184,6 @@
"count": 1
}
},
"public/app/core/components/OptionsUI/strings.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/Page/EditableTitle.tsx": {
"no-restricted-syntax": {
"count": 1
@@ -1217,11 +1204,6 @@
"count": 1
}
},
"public/app/core/components/Select/TeamPicker.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/core/components/SharedPreferences/SharedPreferences.tsx": {
"no-restricted-syntax": {
"count": 8
@@ -1407,14 +1389,6 @@
"public/app/features/admin/UserOrgs.tsx": {
"no-restricted-syntax": {
"count": 2
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/admin/UserProfile.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/admin/ldap/LdapDrawer.tsx": {
@@ -1836,11 +1810,6 @@
"count": 1
}
},
"public/app/features/annotations/components/AnnotationResultMapper.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/annotations/components/StandardAnnotationQueryEditor.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
@@ -2331,11 +2300,6 @@
"count": 1
}
},
"public/app/features/dashboard/components/PanelEditor/PanelHeaderCorner.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/dashboard/components/PanelEditor/VisualizationSelectPane.tsx": {
"@grafana/no-aria-label-selectors": {
"count": 1
@@ -2393,17 +2357,11 @@
},
"no-restricted-syntax": {
"count": 1
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/dashboard/components/ShareModal/ShareLink.tsx": {
"no-restricted-syntax": {
"count": 3
},
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/dashboard/components/ShareModal/ShareModal.tsx": {
@@ -2752,11 +2710,6 @@
"count": 1
}
},
"public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx": {
"react-prefer-function-component/react-prefer-function-component": {
"count": 1
}
},
"public/app/features/explore/RichHistory/RichHistorySettingsTab.tsx": {
"no-restricted-syntax": {
"count": 1
+128 -147
View File
@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo, useState, useCallback } from 'react';
import { t } from '@grafana/i18n';
import { FetchError, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
@@ -54,87 +54,98 @@ interface Props {
}) => JSX.Element;
}
interface State {
isLoggingIn: boolean;
isChangingPassword: boolean;
showDefaultPasswordWarning: boolean;
loginErrorMessage?: string;
}
export const LoginCtrl = memo(({ resetCode, children }: Props) => {
const [result, setResult] = useState<LoginDTO | undefined>();
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [isChangingPassword, setIsChangingPassword] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
// oAuth unauthorized sets the redirect error message in the bootdata, hence we need to check the key here
const [loginErrorMessage, setLoginErrorMessage] = useState<string | undefined>(
getBootDataErrMessage(config.loginError)
);
export class LoginCtrl extends PureComponent<Props, State> {
result: LoginDTO | undefined;
const toGrafana = useCallback(() => {
if (config.featureToggles.useSessionStorageForRedirection) {
window.location.assign(config.appSubUrl + '/');
return;
}
constructor(props: Props) {
super(props);
this.state = {
isLoggingIn: false,
isChangingPassword: false,
showDefaultPasswordWarning: false,
// oAuth unauthorized sets the redirect error message in the bootdata, hence we need to check the key here
loginErrorMessage: getBootDataErrMessage(config.loginError),
};
}
if (result?.redirectUrl) {
if (config.appSubUrl !== '' && !result.redirectUrl.startsWith(config.appSubUrl)) {
window.location.assign(config.appSubUrl + result.redirectUrl);
} else {
window.location.assign(result.redirectUrl);
}
} else {
window.location.assign(config.appSubUrl + '/');
}
}, [result]);
changePassword = (password: string) => {
const pw = {
newPassword: password,
confirmNew: password,
oldPassword: 'admin',
};
if (this.props.resetCode) {
const resetModel = {
code: this.props.resetCode,
const changePassword = useCallback(
(password: string) => {
const pw = {
newPassword: password,
confirmPassword: password,
confirmNew: password,
oldPassword: 'admin',
};
if (resetCode) {
const resetModel = {
code: resetCode,
newPassword: password,
confirmPassword: password,
};
getBackendSrv()
.post('/api/user/password/reset', resetModel)
.then(() => {
toGrafana();
});
} else {
getBackendSrv()
.put('/api/user/password', pw)
.then(() => {
toGrafana();
})
.catch((err) => console.error(err));
}
},
[resetCode, toGrafana]
);
const changeView = useCallback((showDefaultPasswordWarning: boolean) => {
setIsChangingPassword(true);
setShowDefaultPasswordWarning(showDefaultPasswordWarning);
}, []);
const login = useCallback(
(formModel: FormModel) => {
setLoginErrorMessage(undefined);
setIsLoggingIn(true);
getBackendSrv()
.post('/api/user/password/reset', resetModel)
.then(() => {
this.toGrafana();
});
} else {
getBackendSrv()
.put('/api/user/password', pw)
.then(() => {
this.toGrafana();
.post<LoginDTO>('/login', formModel, { showErrorAlert: false })
.then((result) => {
setResult(result);
if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) {
toGrafana();
return;
} else {
changeView(formModel.password === 'admin');
}
})
.catch((err) => console.error(err));
}
};
login = (formModel: FormModel) => {
this.setState({
loginErrorMessage: undefined,
isLoggingIn: true,
});
getBackendSrv()
.post<LoginDTO>('/login', formModel, { showErrorAlert: false })
.then((result) => {
this.result = result;
if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) {
this.toGrafana();
return;
} else {
this.changeView(formModel.password === 'admin');
}
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({
isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
setIsLoggingIn(false);
setLoginErrorMessage(fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'));
});
});
};
},
[toGrafana, changeView]
);
passwordlessStart = (formModel: PasswordlessFormModel) => {
this.setState({
loginErrorMessage: undefined,
isLoggingIn: true,
});
const passwordlessStart = useCallback((formModel: PasswordlessFormModel) => {
setLoginErrorMessage(undefined);
setIsLoggingIn(true);
getBackendSrv()
.post<AuthNRedirectDTO>('/api/login/passwordless/start', formModel, { showErrorAlert: false })
@@ -144,88 +155,58 @@ export class LoginCtrl extends PureComponent<Props, State> {
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({
isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
});
setIsLoggingIn(false);
setLoginErrorMessage(fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'));
});
};
}, []);
passwordlessConfirm = (formModel: PasswordlessConfirmationFormModel) => {
this.setState({
loginErrorMessage: undefined,
isLoggingIn: true,
});
const passwordlessConfirm = useCallback(
(formModel: PasswordlessConfirmationFormModel) => {
setLoginErrorMessage(undefined);
setIsLoggingIn(true);
getBackendSrv()
.post<LoginDTO>('/api/login/passwordless/authenticate', formModel, { showErrorAlert: false })
.then((result) => {
this.result = result;
this.toGrafana();
return;
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
this.setState({
isLoggingIn: false,
loginErrorMessage: fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'),
getBackendSrv()
.post<LoginDTO>('/api/login/passwordless/authenticate', formModel, { showErrorAlert: false })
.then((result) => {
setResult(result);
toGrafana();
return;
})
.catch((err) => {
const fetchErrorMessage = isFetchError(err) ? getErrorMessage(err) : undefined;
setIsLoggingIn(false);
setLoginErrorMessage(fetchErrorMessage || t('login.error.unknown', 'Unknown error occurred'));
});
});
};
},
[toGrafana]
);
changeView = (showDefaultPasswordWarning: boolean) => {
this.setState({
isChangingPassword: true,
showDefaultPasswordWarning,
});
};
const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config;
toGrafana = () => {
if (config.featureToggles.useSessionStorageForRedirection) {
window.location.assign(config.appSubUrl + '/');
return;
}
return (
<>
{children({
isOauthEnabled: isOauthEnabled(),
loginHint,
passwordHint,
disableLoginForm,
disableUserSignUp,
login,
passwordlessStart,
passwordlessConfirm,
showPasswordlessConfirmation: showPasswordlessConfirmation(),
isLoggingIn,
changePassword,
skipPasswordChange: toGrafana,
isChangingPassword,
showDefaultPasswordWarning,
loginErrorMessage,
})}
</>
);
});
if (this.result?.redirectUrl) {
if (config.appSubUrl !== '' && !this.result.redirectUrl.startsWith(config.appSubUrl)) {
window.location.assign(config.appSubUrl + this.result.redirectUrl);
} else {
window.location.assign(this.result.redirectUrl);
}
} else {
window.location.assign(config.appSubUrl + '/');
}
};
render() {
const { children } = this.props;
const { isLoggingIn, isChangingPassword, showDefaultPasswordWarning, loginErrorMessage } = this.state;
const { login, toGrafana, changePassword, passwordlessStart, passwordlessConfirm } = this;
const { loginHint, passwordHint, disableLoginForm, disableUserSignUp } = config;
return (
<>
{children({
isOauthEnabled: isOauthEnabled(),
loginHint,
passwordHint,
disableLoginForm,
disableUserSignUp,
login,
passwordlessStart,
passwordlessConfirm,
showPasswordlessConfirmation: showPasswordlessConfirmation(),
isLoggingIn,
changePassword,
skipPasswordChange: toGrafana,
isChangingPassword,
showDefaultPasswordWarning,
loginErrorMessage,
})}
</>
);
}
}
LoginCtrl.displayName = 'LoginCtrl';
export default LoginCtrl;
@@ -1,5 +1,5 @@
import { debounce } from 'lodash';
import { PureComponent } from 'react';
import { memo, useState, useRef, useEffect, useCallback, useMemo } from 'react';
import * as React from 'react';
import { Field, Input } from '@grafana/ui';
@@ -18,11 +18,6 @@ interface Props {
suffix?: React.ReactNode;
}
interface State {
text: string;
inputCorrected: boolean;
}
/**
* This is an Input field that will call `onChange` for blur and enter
*
@@ -30,101 +25,85 @@ interface State {
* by options editor (number and slider), and direclty with in grafana core
*/
export class NumberInput extends PureComponent<Props, State> {
state: State = { text: '', inputCorrected: false };
inputRef = React.createRef<HTMLInputElement>();
export const NumberInput = memo(
({ id, value, placeholder, autoFocus, onChange, min, max, step, width, fieldDisabled, suffix }: Props) => {
const [text, setText] = useState('');
const [inputCorrected, setInputCorrected] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
componentDidMount() {
this.setState({
text: isNaN(this.props.value!) ? '' : `${this.props.value}`,
});
}
useEffect(() => {
setText(isNaN(value!) ? '' : `${value}`);
}, [value]);
componentDidUpdate(oldProps: Props) {
if (this.props.value !== oldProps.value) {
const text = isNaN(this.props.value!) ? '' : `${this.props.value}`;
if (text !== this.state.text) {
this.setState({ text });
}
}
}
const updateValue = useCallback(() => {
const txt = inputRef.current?.value;
let corrected = false;
let newValue = '';
let currentValue = txt !== '' ? Number(txt) : undefined;
updateValue = () => {
const txt = this.inputRef.current?.value;
let corrected = false;
let newValue = '';
const min = this.props.min;
const max = this.props.max;
let currentValue = txt !== '' ? Number(txt) : undefined;
if (currentValue && !Number.isNaN(currentValue)) {
if (min != null && currentValue < min) {
newValue = min.toString();
corrected = true;
} else if (max != null && currentValue > max) {
newValue = max.toString();
corrected = true;
} else {
newValue = txt ?? '';
}
if (currentValue && !Number.isNaN(currentValue)) {
if (min != null && currentValue < min) {
newValue = min.toString();
corrected = true;
} else if (max != null && currentValue > max) {
newValue = max.toString();
corrected = true;
} else {
newValue = txt ?? '';
setText(newValue);
setInputCorrected(corrected);
}
this.setState({
text: newValue,
inputCorrected: corrected,
});
}
if (!Number.isNaN(currentValue) && currentValue !== value) {
onChange(currentValue);
}
}, [min, max, value, onChange]);
if (corrected) {
this.updateValueDebounced();
}
const updateValueDebounced = useMemo(() => debounce(updateValue, 500), [updateValue]);
if (!Number.isNaN(currentValue) && currentValue !== this.props.value) {
this.props.onChange(currentValue);
}
};
updateValueDebounced = debounce(this.updateValue, 500); // 1/2 second delay
onChange = (e: React.FocusEvent<HTMLInputElement>) => {
this.setState({
text: e.currentTarget.value,
});
this.updateValueDebounced();
};
onKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
this.updateValue();
}
};
renderInput() {
return (
<Input
type="number"
id={this.props.id}
ref={this.inputRef}
min={this.props.min}
max={this.props.max}
step={this.props.step}
autoFocus={this.props.autoFocus}
value={this.state.text}
onChange={this.onChange}
onBlur={this.updateValue}
onKeyPress={this.onKeyPress}
placeholder={this.props.placeholder}
disabled={this.props.fieldDisabled}
width={this.props.width}
suffix={this.props.suffix}
/>
const handleChange = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setText(e.currentTarget.value);
updateValueDebounced();
},
[updateValueDebounced]
);
}
render() {
const { inputCorrected } = this.state;
const handleKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
updateValue();
}
},
[updateValue]
);
const renderInput = () => {
return (
<Input
type="number"
id={id}
ref={inputRef}
min={min}
max={max}
step={step}
autoFocus={autoFocus}
value={text}
onChange={handleChange}
onBlur={updateValue}
onKeyPress={handleKeyPress}
placeholder={placeholder}
disabled={fieldDisabled}
width={width}
suffix={suffix}
/>
);
};
if (inputCorrected) {
let range = '';
let { min, max } = this.props;
if (max == null) {
if (min != null) {
range = `< ${min}`;
@@ -141,11 +120,13 @@ export class NumberInput extends PureComponent<Props, State> {
validationMessageHorizontalOverflow={true}
style={{ direction: 'rtl' }}
>
{this.renderInput()}
{renderInput()}
</Field>
);
}
return this.renderInput();
return renderInput();
}
}
);
NumberInput.displayName = 'NumberInput';
@@ -1,96 +1,94 @@
import { css } from '@emotion/css';
import { memo, useState, useCallback } from 'react';
import * as React from 'react';
import { StandardEditorProps, StringFieldConfigSettings, GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { stylesFactory, Button, Icon, Input } from '@grafana/ui';
import { Button, Icon, Input, useStyles2 } from '@grafana/ui';
type Props = StandardEditorProps<string[], StringFieldConfigSettings>;
interface State {
showAdd: boolean;
}
export class StringArrayEditor extends React.PureComponent<Props, State> {
state = {
showAdd: false,
};
export const StringArrayEditor = memo(({ value, onChange, item }: Props) => {
const [showAdd, setShowAdd] = useState(false);
const styles = useStyles2(getStyles);
onRemoveString = (index: number) => {
const { value, onChange } = this.props;
const copy = [...value];
copy.splice(index, 1);
onChange(copy);
};
const onRemoveString = useCallback(
(index: number) => {
const copy = [...value];
copy.splice(index, 1);
onChange(copy);
},
[value, onChange]
);
onValueChange = (e: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>, idx: number) => {
if ('key' in e) {
if (e.key !== 'Enter') {
const onValueChange = useCallback(
(e: React.KeyboardEvent<HTMLInputElement> | React.FocusEvent<HTMLInputElement>, idx: number) => {
if ('key' in e) {
if (e.key !== 'Enter') {
return;
}
}
// Form event, or Enter
const v = e.currentTarget.value.trim();
if (idx < 0) {
if (v) {
e.currentTarget.value = ''; // reset last value
onChange([...value, v]);
}
setShowAdd(false);
return;
}
}
const { value, onChange } = this.props;
// Form event, or Enter
const v = e.currentTarget.value.trim();
if (idx < 0) {
if (v) {
e.currentTarget.value = ''; // reset last value
onChange([...value, v]);
if (!v) {
return onRemoveString(idx);
}
this.setState({ showAdd: false });
return;
}
if (!v) {
return this.onRemoveString(idx);
}
const copy = [...value];
copy[idx] = v;
onChange(copy);
},
[value, onChange, onRemoveString]
);
const copy = [...value];
copy[idx] = v;
onChange(copy);
};
const placeholder = item.settings?.placeholder || 'Add text';
render() {
const { value, item } = this.props;
const { showAdd } = this.state;
const styles = getStyles(config.theme2);
const placeholder = item.settings?.placeholder || 'Add text';
return (
<div>
{value.map((v, index) => {
return (
<Input
className={styles.textInput}
key={`${index}/${v}`}
defaultValue={v || ''}
onBlur={(e) => this.onValueChange(e, index)}
onKeyDown={(e) => this.onValueChange(e, index)}
suffix={<Icon className={styles.trashIcon} name="trash-alt" onClick={() => this.onRemoveString(index)} />}
/>
);
})}
{showAdd ? (
return (
<div>
{value.map((v, index) => {
return (
<Input
autoFocus
className={styles.textInput}
placeholder={placeholder}
defaultValue={''}
onBlur={(e) => this.onValueChange(e, -1)}
onKeyDown={(e) => this.onValueChange(e, -1)}
suffix={<Icon name="plus-circle" />}
key={`${index}/${v}`}
defaultValue={v || ''}
onBlur={(e) => onValueChange(e, index)}
onKeyDown={(e) => onValueChange(e, index)}
suffix={<Icon className={styles.trashIcon} name="trash-alt" onClick={() => onRemoveString(index)} />}
/>
) : (
<Button icon="plus" size="sm" variant="secondary" onClick={() => this.setState({ showAdd: true })}>
{placeholder}
</Button>
)}
</div>
);
}
}
);
})}
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
{showAdd ? (
<Input
autoFocus
className={styles.textInput}
placeholder={placeholder}
defaultValue={''}
onBlur={(e) => onValueChange(e, -1)}
onKeyDown={(e) => onValueChange(e, -1)}
suffix={<Icon name="plus-circle" />}
/>
) : (
<Button icon="plus" size="sm" variant="secondary" onClick={() => setShowAdd(true)}>
{placeholder}
</Button>
)}
</div>
);
});
StringArrayEditor.displayName = 'StringArrayEditor';
const getStyles = (theme: GrafanaTheme2) => {
return {
textInput: css({
marginBottom: '5px',
@@ -107,4 +105,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => {
},
}),
};
});
};
@@ -1,6 +1,6 @@
import debounce from 'debounce-promise';
import { isNil } from 'lodash';
import { Component } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -14,19 +14,11 @@ export interface Props {
teamId?: number;
}
export interface State {
isLoading: boolean;
value?: SelectableValue<Team>;
}
export const TeamPicker = ({ onSelected, className, teamId }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [value, setValue] = useState<SelectableValue<Team> | undefined>();
export class TeamPicker extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { isLoading: false };
}
componentDidMount(): void {
const { teamId } = this.props;
useEffect(() => {
if (!teamId) {
return;
}
@@ -34,60 +26,58 @@ export class TeamPicker extends Component<Props, State> {
getBackendSrv()
.get(`/api/teams/${teamId}`)
.then((team: Team) => {
this.setState({
value: {
value: team,
label: team.name,
imgUrl: team.avatarUrl,
},
setValue({
value: team,
label: team.name,
imgUrl: team.avatarUrl,
});
});
}
}, [teamId]);
search = debounce(
async (query?: string) => {
this.setState({ isLoading: true });
const search = useMemo(
() =>
debounce(
async (query?: string) => {
setIsLoading(true);
if (isNil(query)) {
query = '';
}
if (isNil(query)) {
query = '';
}
return getBackendSrv()
.get(`/api/teams/search?perpage=100&page=1&query=${query}`)
.then((result: { teams: Team[] }) => {
const teams: Array<SelectableValue<Team>> = result.teams.map((team) => {
return {
value: team,
label: team.name,
imgUrl: team.avatarUrl,
};
});
return getBackendSrv()
.get(`/api/teams/search?perpage=100&page=1&query=${query}`)
.then((result: { teams: Team[] }) => {
const teams: Array<SelectableValue<Team>> = result.teams.map((team) => {
return {
value: team,
label: team.name,
imgUrl: team.avatarUrl,
};
});
this.setState({ isLoading: false });
return teams;
});
},
300,
{ leading: true }
setIsLoading(false);
return teams;
});
},
300,
{ leading: true }
),
[]
);
render() {
const { onSelected, className } = this.props;
const { isLoading, value } = this.state;
return (
<div className="user-picker" data-testid="teamPicker">
<AsyncSelect
isLoading={isLoading}
defaultOptions={true}
loadOptions={this.search}
value={value}
onChange={onSelected}
className={className}
placeholder={t('team-picker.select-placeholder', 'Select a team')}
noOptionsMessage={t('team-picker.noOptionsMessage-no-teams-found', 'No teams found')}
aria-label={t('team-picker.select-aria-label', 'Team picker')}
/>
</div>
);
}
}
return (
<div className="user-picker" data-testid="teamPicker">
<AsyncSelect
isLoading={isLoading}
defaultOptions={true}
loadOptions={search}
value={value}
onChange={onSelected}
className={className}
placeholder={t('team-picker.select-placeholder', 'Select a team')}
noOptionsMessage={t('team-picker.noOptionsMessage-no-teams-found', 'No teams found')}
aria-label={t('team-picker.select-aria-label', 'Team picker')}
/>
</div>
);
};
+75 -108
View File
@@ -1,20 +1,9 @@
import { css, cx } from '@emotion/css';
import { memo, PureComponent, ReactElement, useEffect, useRef, useState } from 'react';
import { memo, ReactElement, useEffect, useRef, useState } from 'react';
import { GrafanaTheme2, OrgRole } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import {
Button,
ConfirmButton,
Field,
Icon,
Modal,
stylesFactory,
Tooltip,
useStyles2,
Stack,
TextLink,
} from '@grafana/ui';
import { Button, ConfirmButton, Field, Icon, Modal, Tooltip, useStyles2, Stack, TextLink } from '@grafana/ui';
import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
@@ -239,7 +228,7 @@ const OrgRow = memo(({ user, org, isExternalUser, onOrgRemove, onOrgRoleChange }
});
OrgRow.displayName = 'OrgRow';
const getAddToOrgModalStyles = stylesFactory(() => ({
const getAddToOrgModalStyles = () => ({
modal: css({
width: '500px',
}),
@@ -249,7 +238,7 @@ const getAddToOrgModalStyles = stylesFactory(() => ({
modalContent: css({
overflow: 'visible',
}),
}));
});
interface AddToOrgModalProps {
isOpen: boolean;
@@ -260,126 +249,104 @@ interface AddToOrgModalProps {
onDismiss?(): void;
}
interface AddToOrgModalState {
selectedOrg: Organization | null;
role: OrgRole;
roleOptions: Role[];
pendingOrgId: number | null;
pendingUserId: number | null;
pendingRoles: Role[];
}
export const AddToOrgModal = memo(({ isOpen, user, userOrgs, onOrgAdd, onDismiss }: AddToOrgModalProps) => {
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [role, setRole] = useState<OrgRole>(OrgRole.Viewer);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [pendingOrgId, setPendingOrgId] = useState<number | null>(null);
const [pendingUserId, setPendingUserId] = useState<number | null>(null);
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const styles = useStyles2(getAddToOrgModalStyles);
export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgModalState> {
state: AddToOrgModalState = {
selectedOrg: null,
role: OrgRole.Viewer,
roleOptions: [],
pendingOrgId: null,
pendingUserId: null,
pendingRoles: [],
};
onOrgSelect = (org: OrgSelectItem) => {
const userOrg = this.props.userOrgs.find((userOrg) => userOrg.orgId === org.value?.id);
this.setState({ selectedOrg: org.value!, role: userOrg?.role || OrgRole.Viewer });
const onOrgSelect = (org: OrgSelectItem) => {
const userOrg = userOrgs.find((userOrg) => userOrg.orgId === org.value?.id);
setSelectedOrg(org.value!);
setRole(userOrg?.role || OrgRole.Viewer);
if (contextSrv.licensedAccessControlEnabled()) {
if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
fetchRoleOptions(org.value?.id)
.then((roles) => this.setState({ roleOptions: roles }))
.then((roles) => setRoleOptions(roles))
.catch((e) => console.error(e));
}
}
};
onOrgRoleChange = (newRole: OrgRole) => {
this.setState({
role: newRole,
});
const onOrgRoleChange = (newRole: OrgRole) => {
setRole(newRole);
};
onAddUserToOrg = async () => {
const { selectedOrg, role } = this.state;
this.props.onOrgAdd(selectedOrg!.id, role);
const onAddUserToOrg = async () => {
onOrgAdd(selectedOrg!.id, role);
// add the stored userRoles also
if (contextSrv.licensedAccessControlEnabled()) {
if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd)) {
if (this.state.pendingUserId) {
await updateUserRoles(this.state.pendingRoles, this.state.pendingUserId!, this.state.pendingOrgId!);
if (pendingUserId) {
await updateUserRoles(pendingRoles, pendingUserId, pendingOrgId!);
// clear pending state
this.setState({
pendingOrgId: null,
pendingRoles: [],
pendingUserId: null,
});
setPendingOrgId(null);
setPendingRoles([]);
setPendingUserId(null);
}
}
}
};
onCancel = () => {
const onCancel = () => {
// clear selectedOrg when modal is canceled
this.setState({
selectedOrg: null,
pendingRoles: [],
pendingOrgId: null,
pendingUserId: null,
});
if (this.props.onDismiss) {
this.props.onDismiss();
setSelectedOrg(null);
setPendingRoles([]);
setPendingOrgId(null);
setPendingUserId(null);
if (onDismiss) {
onDismiss();
}
};
onRoleUpdate = async (roles: Role[], userId: number, orgId: number | undefined) => {
const onRoleUpdate = async (roles: Role[], userId: number, orgId: number | undefined) => {
// keep the new role assignments for user
this.setState({
pendingRoles: roles,
pendingOrgId: orgId!,
pendingUserId: userId,
});
setPendingRoles(roles);
setPendingOrgId(orgId!);
setPendingUserId(userId);
};
render() {
const { isOpen, user, userOrgs } = this.props;
const { role, roleOptions, selectedOrg } = this.state;
const styles = getAddToOrgModalStyles();
return (
<Modal
className={styles.modal}
contentClassName={styles.modalContent}
title={t('admin.add-to-org-modal.title-add-to-an-organization', 'Add to an organization')}
isOpen={isOpen}
onDismiss={this.onCancel}
>
<Field label={t('admin.add-to-org-modal.label-organization', 'Organization')}>
<OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} excludeOrgs={userOrgs} autoFocus />
</Field>
<Field label={t('admin.add-to-org-modal.label-role', 'Role')} disabled={selectedOrg === null}>
<UserRolePicker
userId={user?.id || 0}
orgId={selectedOrg?.id}
basicRole={role}
onBasicRoleChange={this.onOrgRoleChange}
basicRoleDisabled={false}
roleOptions={roleOptions}
apply={true}
onApplyRoles={this.onRoleUpdate}
pendingRoles={this.state.pendingRoles}
/>
</Field>
<Modal.ButtonRow>
<Stack gap={2} justifyContent="center">
<Button variant="secondary" fill="outline" onClick={this.onCancel}>
<Trans i18nKey="admin.user-orgs-modal.cancel-button">Cancel</Trans>
</Button>
<Button variant="primary" disabled={selectedOrg === null} onClick={this.onAddUserToOrg}>
<Trans i18nKey="admin.user-orgs-modal.add-button">Add to organization</Trans>
</Button>
</Stack>
</Modal.ButtonRow>
</Modal>
);
}
}
return (
<Modal
className={styles.modal}
contentClassName={styles.modalContent}
title={t('admin.add-to-org-modal.title-add-to-an-organization', 'Add to an organization')}
isOpen={isOpen}
onDismiss={onCancel}
>
<Field label={t('admin.add-to-org-modal.label-organization', 'Organization')}>
<OrgPicker inputId="new-org-input" onSelected={onOrgSelect} excludeOrgs={userOrgs} autoFocus />
</Field>
<Field label={t('admin.add-to-org-modal.label-role', 'Role')} disabled={selectedOrg === null}>
<UserRolePicker
userId={user?.id || 0}
orgId={selectedOrg?.id}
basicRole={role}
onBasicRoleChange={onOrgRoleChange}
basicRoleDisabled={false}
roleOptions={roleOptions}
apply={true}
onApplyRoles={onRoleUpdate}
pendingRoles={pendingRoles}
/>
</Field>
<Modal.ButtonRow>
<Stack gap={2} justifyContent="center">
<Button variant="secondary" fill="outline" onClick={onCancel}>
<Trans i18nKey="admin.user-orgs-modal.cancel-button">Cancel</Trans>
</Button>
<Button variant="primary" disabled={selectedOrg === null} onClick={onAddUserToOrg}>
<Trans i18nKey="admin.user-orgs-modal.add-button">Add to organization</Trans>
</Button>
</Stack>
</Modal.ButtonRow>
</Modal>
);
});
AddToOrgModal.displayName = 'AddToOrgModal';
interface ChangeOrgButtonProps {
lockMessage?: string;
+64 -72
View File
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { PureComponent, useRef, useState } from 'react';
import { memo, useRef, useState, useCallback, useEffect } from 'react';
import * as React from 'react';
import { Trans, t } from '@grafana/i18n';
@@ -185,78 +185,68 @@ interface UserProfileRowProps {
onChange?: (value: string) => void;
}
interface UserProfileRowState {
value: string;
editing: boolean;
}
export const UserProfileRow = memo(
({
label,
value: valueProp = '',
locked = false,
lockMessage = '',
inputType = 'text',
onChange,
}: UserProfileRowProps) => {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(valueProp);
const inputElemRef = useRef<HTMLInputElement>(null);
export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfileRowState> {
inputElem?: HTMLInputElement;
useEffect(() => {
setValue(valueProp);
}, [valueProp]);
static defaultProps: Partial<UserProfileRowProps> = {
value: '',
locked: false,
lockMessage: '',
inputType: 'text',
};
const focusInput = useCallback(() => {
if (inputElemRef.current) {
inputElemRef.current.focus();
}
}, []);
state = {
editing: false,
value: this.props.value || '',
};
const onEditClick = useCallback(() => {
if (inputType === 'password') {
// Reset value for password field
setValue('');
setEditing(true);
setTimeout(focusInput, 0);
} else {
setEditing(true);
setTimeout(focusInput, 0);
}
}, [inputType, focusInput]);
setInputElem = (elem: HTMLInputElement) => {
this.inputElem = elem;
};
const onCancelClick = useCallback(() => {
setEditing(false);
setValue(valueProp);
}, [valueProp]);
onEditClick = () => {
if (this.props.inputType === 'password') {
// Reset value for password field
this.setState({ editing: true, value: '' }, this.focusInput);
} else {
this.setState({ editing: true }, this.focusInput);
}
};
const onInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
onCancelClick = () => {
this.setState({ editing: false, value: this.props.value || '' });
};
setValue(event.target.value);
}, []);
onInputChange = (event: React.ChangeEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
const onInputBlur = useCallback((event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
this.setState({
value: event.target.value,
});
};
setValue(event.target.value);
}, []);
onInputBlur = (event: React.FocusEvent<HTMLInputElement>, status?: LegacyInputStatus) => {
if (status === LegacyInputStatus.Invalid) {
return;
}
const onSave = useCallback(() => {
if (onChange) {
onChange(value);
}
}, [onChange, value]);
this.setState({
value: event.target.value,
});
};
focusInput = () => {
if (this.inputElem && this.inputElem.focus) {
this.inputElem.focus();
}
};
onSave = () => {
if (this.props.onChange) {
this.props.onChange(this.state.value);
}
};
render() {
const { label, locked, lockMessage, inputType } = this.props;
const { value } = this.state;
const labelClass = cx(
'width-16',
css({
@@ -275,26 +265,26 @@ export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfi
<label htmlFor={inputId}>{label}</label>
</td>
<td className="width-25" colSpan={2}>
{this.state.editing ? (
{editing ? (
<Input
id={inputId}
type={inputType}
defaultValue={value}
onBlur={this.onInputBlur}
onChange={this.onInputChange}
ref={this.setInputElem}
onBlur={onInputBlur}
onChange={onInputChange}
ref={inputElemRef}
width={30}
/>
) : (
<span>{this.props.value}</span>
<span>{valueProp}</span>
)}
</td>
<td>
<ConfirmButton
confirmText={t('admin.user-profile-row.confirmText-save', 'Save')}
onClick={this.onEditClick}
onConfirm={this.onSave}
onCancel={this.onCancelClick}
onClick={onEditClick}
onConfirm={onSave}
onCancel={onCancelClick}
>
{t('admin.user-profile.edit-button', 'Edit')}
</ConfirmButton>
@@ -302,7 +292,9 @@ export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfi
</tr>
);
}
}
);
UserProfileRow.displayName = 'UserProfileRow';
interface LockedRowProps {
label: string;
@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo, useState, useEffect, useCallback } from 'react';
import {
SelectableValue,
@@ -16,37 +16,20 @@ import { Select, Tooltip, Icon } from '@grafana/ui';
import { getAnnotationEventNames, AnnotationFieldInfo } from '../standardAnnotationSupport';
import { AnnotationQueryResponse } from '../types';
// const valueOptions: Array<SelectableValue<AnnotationEventFieldSource>> = [
// { value: AnnotationEventFieldSource.Field, label: 'Field', description: 'Set the field value from a response field' },
// { value: AnnotationEventFieldSource.Text, label: 'Text', description: 'Enter direct text for the value' },
// { value: AnnotationEventFieldSource.Skip, label: 'Skip', description: 'Hide this field' },
// ];
interface Props {
response?: AnnotationQueryResponse;
mappings?: AnnotationEventMappings;
change: (mappings?: AnnotationEventMappings) => void;
}
interface State {
fieldNames: Array<SelectableValue<string>>;
}
export const AnnotationFieldMapper = memo(({ response, mappings, change }: Props) => {
const [fieldNames, setFieldNames] = useState<Array<SelectableValue<string>>>([]);
export class AnnotationFieldMapper extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
fieldNames: [],
};
}
updateFields = () => {
const panelData = this.props.response?.panelData;
useEffect(() => {
const panelData = response?.panelData;
const frame = panelData?.series?.[0] ?? panelData?.annotations?.[0];
if (frame && frame.fields) {
const fieldNames = frame.fields.map((f) => {
const newFieldNames = frame.fields.map((f) => {
const name = getFieldDisplayName(f, frame);
let description = '';
@@ -71,144 +54,115 @@ export class AnnotationFieldMapper extends PureComponent<Props, State> {
description,
};
});
this.setState({ fieldNames });
setFieldNames(newFieldNames);
}
};
}, [response]);
componentDidMount() {
this.updateFields();
}
const onFieldNameChange = useCallback(
(k: keyof AnnotationEvent, v: SelectableValue<string>) => {
const currentMappings = mappings || {};
componentDidUpdate(oldProps: Props) {
if (oldProps.response !== this.props.response) {
this.updateFields();
}
}
// in case of clearing the value
if (!v) {
const newMappings = { ...mappings };
delete newMappings[k];
change(newMappings);
return;
}
onFieldSourceChange = (k: keyof AnnotationEvent, v: SelectableValue<AnnotationEventFieldSource>) => {
const mappings = this.props.mappings || {};
const mapping = mappings[k] || {};
const mapping = currentMappings[k] || {};
this.props.change({
...mappings,
[k]: {
...mapping,
source: v.value || AnnotationEventFieldSource.Field,
},
});
};
onFieldNameChange = (k: keyof AnnotationEvent, v: SelectableValue<string>) => {
const mappings = this.props.mappings || {};
// in case of clearing the value
if (!v) {
const newMappings = { ...this.props.mappings };
delete newMappings[k];
this.props.change(newMappings);
return;
}
const mapping = mappings[k] || {};
this.props.change({
...mappings,
[k]: {
...mapping,
value: v.value,
source: AnnotationEventFieldSource.Field,
},
});
};
renderRow(row: AnnotationFieldInfo, mapping: AnnotationEventFieldMapping, first?: AnnotationEvent) {
const { fieldNames } = this.state;
let picker = [...fieldNames];
const current = mapping.value;
let currentValue = fieldNames.find((f) => current === f.value);
if (current && !currentValue) {
picker.push({
label: current,
value: current,
change({
...currentMappings,
[k]: {
...mapping,
value: v.value,
source: AnnotationEventFieldSource.Field,
},
});
}
},
[mappings, change]
);
let value = first ? first[row.key] : '';
if (value && row.key.startsWith('time')) {
const fmt = getValueFormat('dateTimeAsIso');
value = formattedValueToString(fmt(value));
}
if (value === null || value === undefined) {
value = ''; // empty string
}
const renderRow = useCallback(
(row: AnnotationFieldInfo, mapping: AnnotationEventFieldMapping, first?: AnnotationEvent) => {
let picker = [...fieldNames];
const current = mapping.value;
let currentValue = fieldNames.find((f) => current === f.value);
if (current && !currentValue) {
picker.push({
label: current,
value: current,
});
}
return (
<tr key={row.key}>
<td>
{row.label || row.key}{' '}
{row.help && (
<Tooltip content={row.help}>
<Icon name="info-circle" />
</Tooltip>
)}
</td>
{/* <td>
<Select
let value = first ? first[row.key] : '';
if (value && row.key.startsWith('time')) {
const fmt = getValueFormat('dateTimeAsIso');
value = formattedValueToString(fmt(value));
}
if (value === null || value === undefined) {
value = ''; // empty string
}
value={valueOptions.find(v => v.value === mapping.source) || valueOptions[0]}
options={valueOptions}
onChange={(v: SelectableValue<AnnotationEventFieldSource>) => {
this.onFieldSourceChange(row.key, v);
}}
/>
</td> */}
<td>
<Select
value={currentValue}
options={picker}
placeholder={row.placeholder || row.key}
onChange={(v: SelectableValue<string>) => {
this.onFieldNameChange(row.key, v);
}}
noOptionsMessage={t(
'annotations.annotation-field-mapper.noOptionsMessage-unknown-field-names',
'Unknown field names'
return (
<tr key={row.key}>
<td>
{row.label || row.key}{' '}
{row.help && (
<Tooltip content={row.help}>
<Icon name="info-circle" />
</Tooltip>
)}
allowCustomValue={true}
isClearable
/>
</td>
<td>{`${value}`}</td>
</tr>
);
}
</td>
<td>
<Select
value={currentValue}
options={picker}
placeholder={row.placeholder || row.key}
onChange={(v: SelectableValue<string>) => {
onFieldNameChange(row.key, v);
}}
noOptionsMessage={t(
'annotations.annotation-field-mapper.noOptionsMessage-unknown-field-names',
'Unknown field names'
)}
allowCustomValue={true}
isClearable
/>
</td>
<td>{`${value}`}</td>
</tr>
);
},
[fieldNames, onFieldNameChange]
);
render() {
const first = this.props.response?.events?.[0];
const mappings = this.props.mappings || {};
const first = response?.events?.[0];
const currentMappings = mappings || {};
return (
<table className="filter-table">
<thead>
<tr>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.annotation">Annotation</Trans>
</th>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.from">From</Trans>
</th>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.first-value">First value</Trans>
</th>
</tr>
</thead>
<tbody>
{getAnnotationEventNames().map((row) => {
return this.renderRow(row, mappings[row.key] || {}, first);
})}
</tbody>
</table>
);
}
}
return (
<table className="filter-table">
<thead>
<tr>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.annotation">Annotation</Trans>
</th>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.from">From</Trans>
</th>
<th>
<Trans i18nKey="annotations.annotation-field-mapper.first-value">First value</Trans>
</th>
</tr>
</thead>
<tbody>
{getAnnotationEventNames().map((row) => {
return renderRow(row, currentMappings[row.key] || {}, first);
})}
</tbody>
</table>
);
});
AnnotationFieldMapper.displayName = 'AnnotationFieldMapper';
@@ -1,6 +1,5 @@
import { render, screen } from '@testing-library/react';
import { createTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { PanelModel } from '../../state/PanelModel';
@@ -11,7 +10,6 @@ const setup = () => {
const testPanel = new PanelModel({ title: 'test', description: 'test panel' });
const props: Props = {
panel: testPanel,
theme: createTheme(),
};
return render(<PanelHeaderCorner {...props} />);
};
@@ -1,11 +1,10 @@
import { css, cx } from '@emotion/css';
import { Component } from 'react';
import { useCallback } from 'react';
import { GrafanaTheme2, renderMarkdown, LinkModelSupplier, ScopedVars, IconName } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, getTemplateSrv } from '@grafana/runtime';
import { Tooltip, PopoverContent, Icon, Themeable2, withTheme2, useStyles2 } from '@grafana/ui';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { Tooltip, PopoverContent, Icon, useStyles2 } from '@grafana/ui';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import { InspectTab } from 'app/features/inspector/types';
@@ -15,7 +14,7 @@ enum InfoMode {
Links = 'Links',
}
export interface Props extends Themeable2 {
export interface Props {
panel: PanelModel;
title?: string;
description?: string;
@@ -24,11 +23,10 @@ export interface Props extends Themeable2 {
error?: string;
}
export class PanelHeaderCorner extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
export function PanelHeaderCorner({ panel, links, error }: Props) {
const styles = useStyles2(getContentStyles);
getInfoMode = () => {
const { panel, error } = this.props;
const getInfoMode = useCallback(() => {
if (error) {
return InfoMode.Error;
}
@@ -40,23 +38,21 @@ export class PanelHeaderCorner extends Component<Props> {
}
return undefined;
};
}, [panel, error]);
getInfoContent = (): JSX.Element => {
const { panel, theme } = this.props;
const getInfoContent = useCallback((): JSX.Element => {
const markdown = panel.description || '';
const interpolatedMarkdown = getTemplateSrv().replace(markdown, panel.scopedVars);
const markedInterpolatedMarkdown = renderMarkdown(interpolatedMarkdown);
const links = this.props.links && this.props.links.getLinks(panel.replaceVariables);
const styles = getContentStyles(theme);
const linksList = links && links.getLinks(panel.replaceVariables);
return (
<div className={styles.content}>
<div dangerouslySetInnerHTML={{ __html: markedInterpolatedMarkdown }} />
{links && links.length > 0 && (
{linksList && linksList.length > 0 && (
<ul className={styles.cornerLinks}>
{links.map((link, idx) => {
{linksList.map((link, idx) => {
return (
<li key={idx}>
<a href={link.href} target={link.target}>
@@ -69,39 +65,36 @@ export class PanelHeaderCorner extends Component<Props> {
)}
</div>
);
};
}, [panel, links, styles]);
/**
* Open the Panel Inspector when we click on an error
*/
onClickError = () => {
const onClickError = useCallback(() => {
locationService.partial({
inspect: this.props.panel.id,
inspect: panel.id,
inspectTab: InspectTab.Error,
});
};
}, [panel.id]);
render() {
const { error } = this.props;
const infoMode: InfoMode | undefined = this.getInfoMode();
if (!infoMode) {
return null;
}
if (infoMode === InfoMode.Error && error) {
return <PanelInfoCorner infoMode={infoMode} content={error} onClick={this.onClickError} />;
}
if (infoMode === InfoMode.Info || infoMode === InfoMode.Links) {
return <PanelInfoCorner infoMode={infoMode} content={this.getInfoContent} />;
}
const infoMode: InfoMode | undefined = getInfoMode();
if (!infoMode) {
return null;
}
if (infoMode === InfoMode.Error && error) {
return <PanelInfoCorner infoMode={infoMode} content={error} onClick={onClickError} />;
}
if (infoMode === InfoMode.Info || infoMode === InfoMode.Links) {
return <PanelInfoCorner infoMode={infoMode} content={getInfoContent} />;
}
return null;
}
export default withTheme2(PanelHeaderCorner);
export default PanelHeaderCorner;
interface PanelInfoCornerProps {
infoMode: InfoMode;
@@ -1,5 +1,5 @@
import { saveAs } from 'file-saver';
import { PureComponent } from 'react';
import { memo, useState, useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, Modal, Switch } from '@grafana/ui';
@@ -15,64 +15,43 @@ import { getTrackingSource } from './utils';
interface Props extends ShareModalTabProps {}
interface State {
shareExternally: boolean;
}
export const ShareExport = memo(({ dashboard, panel, onDismiss }: Props) => {
const [shareExternally, setShareExternally] = useState(false);
const exporter = useMemo(() => new DashboardExporter(), []);
export class ShareExport extends PureComponent<Props, State> {
private exporter: DashboardExporter;
constructor(props: Props) {
super(props);
this.state = {
shareExternally: false,
};
this.exporter = new DashboardExporter();
}
onShareExternallyChange = () => {
this.setState({
shareExternally: !this.state.shareExternally,
});
};
onSaveAsFile = () => {
const { dashboard } = this.props;
const { shareExternally } = this.state;
const onShareExternallyChange = () => setShareExternally((prev) => !prev);
const onSaveAsFile = () => {
DashboardInteractions.exportSaveJsonClicked({
externally: shareExternally,
shareResource: getTrackingSource(this.props.panel),
shareResource: getTrackingSource(panel),
});
if (shareExternally) {
makeExportableV1(dashboard).then((dashboardJson) => {
this.openSaveAsDialog(dashboardJson);
openSaveAsDialog(dashboardJson);
});
} else {
this.openSaveAsDialog(dashboard.getSaveModelClone());
openSaveAsDialog(dashboard.getSaveModelClone());
}
};
onViewJson = () => {
const { dashboard } = this.props;
const { shareExternally } = this.state;
const onViewJson = () => {
DashboardInteractions.exportViewJsonClicked({
externally: shareExternally,
shareResource: getTrackingSource(this.props.panel),
shareResource: getTrackingSource(panel),
});
if (shareExternally) {
this.exporter.makeExportable(dashboard).then((dashboardJson) => {
this.openJsonModal(dashboardJson);
exporter.makeExportable(dashboard).then((dashboardJson) => {
openJsonModal(dashboardJson);
});
} else {
this.openJsonModal(dashboard.getSaveModelClone());
openJsonModal(dashboard.getSaveModelClone());
}
};
openSaveAsDialog = (dash: any) => {
const openSaveAsDialog = (dash: any) => {
const dashboardJsonPretty = JSON.stringify(dash, null, 2);
const blob = new Blob([dashboardJsonPretty], {
type: 'application/json;charset=utf-8',
@@ -81,7 +60,7 @@ export class ShareExport extends PureComponent<Props, State> {
saveAs(blob, `${dash.title}-${time}.json`);
};
openJsonModal = (clone: object) => {
const openJsonModal = (clone: object) => {
appEvents.publish(
new ShowModalReactEvent({
props: {
@@ -91,35 +70,32 @@ export class ShareExport extends PureComponent<Props, State> {
})
);
this.props.onDismiss?.();
onDismiss?.();
};
render() {
const { onDismiss } = this.props;
const { shareExternally } = this.state;
const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`);
const exportExternallyTranslation = t('share-modal.export.share-externally-label', `Export for sharing externally`);
return (
<>
<p>
<Trans i18nKey="share-modal.export.info-text">Export this dashboard.</Trans>
</p>
<Field label={exportExternallyTranslation}>
<Switch id="share-externally-toggle" value={shareExternally} onChange={onShareExternallyChange} />
</Field>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="share-modal.export.cancel-button">Cancel</Trans>
</Button>
<Button variant="secondary" onClick={onViewJson}>
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
</Button>
<Button variant="primary" onClick={onSaveAsFile}>
<Trans i18nKey="share-modal.export.save-button">Save to file</Trans>
</Button>
</Modal.ButtonRow>
</>
);
});
return (
<>
<p>
<Trans i18nKey="share-modal.export.info-text">Export this dashboard.</Trans>
</p>
<Field label={exportExternallyTranslation}>
<Switch id="share-externally-toggle" value={shareExternally} onChange={this.onShareExternallyChange} />
</Field>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onDismiss} fill="outline">
<Trans i18nKey="share-modal.export.cancel-button">Cancel</Trans>
</Button>
<Button variant="secondary" onClick={this.onViewJson}>
<Trans i18nKey="share-modal.export.view-button">View JSON</Trans>
</Button>
<Button variant="primary" onClick={this.onSaveAsFile}>
<Trans i18nKey="share-modal.export.save-button">Save to file</Trans>
</Button>
</Modal.ButtonRow>
</>
);
}
}
ShareExport.displayName = 'ShareExport';
@@ -1,4 +1,4 @@
import { PureComponent } from 'react';
import { memo, useEffect, useState } from 'react';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
@@ -12,166 +12,121 @@ import { buildImageUrl, buildShareUrl, getTrackingSource } from './utils';
export interface Props extends ShareModalTabProps {}
export interface State {
useCurrentTimeRange: boolean;
useShortUrl: boolean;
selectedTheme: string;
shareUrl: string;
imageUrl: string;
}
export const ShareLink = memo(({ panel, dashboard }: Props) => {
const [useCurrentTimeRange, setUseCurrentTimeRange] = useState(true);
const [useShortUrl, setUseShortUrl] = useState(false);
const [selectedTheme, setSelectedTheme] = useState('current');
const [shareUrl, setShareUrl] = useState('');
const [imageUrl, setImageUrl] = useState('');
export class ShareLink extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
useCurrentTimeRange: true,
useShortUrl: false,
selectedTheme: 'current',
shareUrl: '',
imageUrl: '',
};
}
useEffect(() => {
async function buildUrl() {
const newShareUrl = await buildShareUrl(useCurrentTimeRange, selectedTheme, panel, useShortUrl);
const newImageUrl = buildImageUrl(useCurrentTimeRange, dashboard.uid, selectedTheme, panel);
componentDidMount() {
this.buildUrl();
}
componentDidUpdate(prevProps: Props, prevState: State) {
const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state;
if (
prevState.useCurrentTimeRange !== useCurrentTimeRange ||
prevState.selectedTheme !== selectedTheme ||
prevState.useShortUrl !== useShortUrl
) {
this.buildUrl();
setShareUrl(newShareUrl);
setImageUrl(newImageUrl);
}
}
buildUrl();
}, [useCurrentTimeRange, selectedTheme, useShortUrl, panel, dashboard]);
buildUrl = async () => {
const { panel, dashboard } = this.props;
const { useCurrentTimeRange, useShortUrl, selectedTheme } = this.state;
const shareUrl = await buildShareUrl(useCurrentTimeRange, selectedTheme, panel, useShortUrl);
const imageUrl = buildImageUrl(useCurrentTimeRange, dashboard.uid, selectedTheme, panel);
this.setState({ shareUrl, imageUrl });
const onUseCurrentTimeRangeChange = () => {
setUseCurrentTimeRange((prev) => !prev);
};
onUseCurrentTimeRangeChange = () => {
this.setState({ useCurrentTimeRange: !this.state.useCurrentTimeRange });
};
const onUrlShorten = () => setUseShortUrl((prev) => !prev);
onUrlShorten = () => {
this.setState({ useShortUrl: !this.state.useShortUrl });
};
const onThemeChange = (value: string) => setSelectedTheme(value);
onThemeChange = (value: string) => {
this.setState({ selectedTheme: value });
};
getShareUrl = () => {
return this.state.shareUrl;
};
onCopy = () => {
const onCopy = () => {
DashboardInteractions.shareLinkCopied({
currentTimeRange: this.state.useCurrentTimeRange,
theme: this.state.selectedTheme,
shortenURL: this.state.useShortUrl,
shareResource: getTrackingSource(this.props.panel),
currentTimeRange: useCurrentTimeRange,
theme: selectedTheme,
shortenURL: useShortUrl,
shareResource: getTrackingSource(panel),
});
};
render() {
const { panel, dashboard } = this.props;
const isRelativeTime = dashboard ? dashboard.time.to === 'now' : false;
const { useCurrentTimeRange, useShortUrl, selectedTheme, shareUrl, imageUrl } = this.state;
const selectors = e2eSelectors.pages.SharePanelModal;
const isDashboardSaved = Boolean(dashboard.id);
const isRelativeTime = dashboard ? dashboard.time.to === 'now' : false;
const selectors = e2eSelectors.pages.SharePanelModal;
const isDashboardSaved = Boolean(dashboard.id);
const timeRangeLabelTranslation = t('share-modal.link.time-range-label', `Lock time range`);
const timeRangeLabelTranslation = t('share-modal.link.time-range-label', `Lock time range`);
const timeRangeDescriptionTranslation = t(
'share-modal.link.time-range-description',
`Transforms the current relative time range to an absolute time range`
);
const timeRangeDescriptionTranslation = t(
'share-modal.link.time-range-description',
`Transforms the current relative time range to an absolute time range`
);
const shortenURLTranslation = t('share-modal.link.shorten-url', `Shorten URL`);
const shortenURLTranslation = t('share-modal.link.shorten-url', `Shorten URL`);
const linkURLTranslation = t('share-modal.link.link-url', `Link URL`);
const linkURLTranslation = t('share-modal.link.link-url', `Link URL`);
return (
<>
<p>
<Trans i18nKey="share-modal.link.info-text">
Create a direct link to this dashboard or panel, customized with the options below.
return (
<>
<p>
<Trans i18nKey="share-modal.link.info-text">
Create a direct link to this dashboard or panel, customized with the options below.
</Trans>
</p>
<FieldSet>
<Field label={timeRangeLabelTranslation} description={isRelativeTime ? timeRangeDescriptionTranslation : ''}>
<Switch id="share-current-time-range" value={useCurrentTimeRange} onChange={onUseCurrentTimeRangeChange} />
</Field>
<ThemePicker selectedTheme={selectedTheme} onChange={onThemeChange} />
<Field label={shortenURLTranslation}>
<Switch id="share-shorten-url" value={useShortUrl} onChange={onUrlShorten} />
</Field>
<Field label={linkURLTranslation}>
<Input
id="link-url-input"
value={shareUrl}
readOnly
addonAfter={
<ClipboardButton icon="copy" variant="primary" getText={() => shareUrl} onClipboardCopy={onCopy}>
<Trans i18nKey="share-modal.link.copy-link-button">Copy</Trans>
</ClipboardButton>
}
/>
</Field>
</FieldSet>
{panel && config.rendererAvailable && (
<>
{isDashboardSaved && (
<TextLink href={imageUrl} external icon={'camera'} aria-label={selectors.linkToRenderedImage}>
{t('share-modal.link.rendered-image', 'Direct link rendered image')}
</TextLink>
)}
{!isDashboardSaved && (
<Alert severity="info" title={t('share-modal.link.save-alert', 'Dashboard is not saved')} bottomSpacing={0}>
<Trans i18nKey="share-modal.link.save-dashboard">
To render a panel image, you must save the dashboard first.
</Trans>
</Alert>
)}
</>
)}
{panel && !config.rendererAvailable && (
<Alert
severity="info"
title={t('share-modal.link.render-alert', 'Image renderer plugin not installed')}
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.render-instructions">
To render an image, you must install the{' '}
<TextLink href="https://grafana.com/grafana/plugins/grafana-image-renderer" external>
Grafana image renderer plugin
</TextLink>
. Please contact your Grafana administrator to install the plugin.
</Trans>
</p>
<FieldSet>
<Field label={timeRangeLabelTranslation} description={isRelativeTime ? timeRangeDescriptionTranslation : ''}>
<Switch
id="share-current-time-range"
value={useCurrentTimeRange}
onChange={this.onUseCurrentTimeRangeChange}
/>
</Field>
<ThemePicker selectedTheme={selectedTheme} onChange={this.onThemeChange} />
<Field label={shortenURLTranslation}>
<Switch id="share-shorten-url" value={useShortUrl} onChange={this.onUrlShorten} />
</Field>
</Alert>
)}
</>
);
});
<Field label={linkURLTranslation}>
<Input
id="link-url-input"
value={shareUrl}
readOnly
addonAfter={
<ClipboardButton icon="copy" variant="primary" getText={this.getShareUrl} onClipboardCopy={this.onCopy}>
<Trans i18nKey="share-modal.link.copy-link-button">Copy</Trans>
</ClipboardButton>
}
/>
</Field>
</FieldSet>
{panel && config.rendererAvailable && (
<>
{isDashboardSaved && (
<TextLink href={imageUrl} external icon={'camera'} aria-label={selectors.linkToRenderedImage}>
{t('share-modal.link.rendered-image', 'Direct link rendered image')}
</TextLink>
)}
{!isDashboardSaved && (
<Alert
severity="info"
title={t('share-modal.link.save-alert', 'Dashboard is not saved')}
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.save-dashboard">
To render a panel image, you must save the dashboard first.
</Trans>
</Alert>
)}
</>
)}
{panel && !config.rendererAvailable && (
<Alert
severity="info"
title={t('share-modal.link.render-alert', 'Image renderer plugin not installed')}
bottomSpacing={0}
>
<Trans i18nKey="share-modal.link.render-instructions">
To render an image, you must install the{' '}
<TextLink href="https://grafana.com/grafana/plugins/grafana-image-renderer" external>
Grafana image renderer plugin
</TextLink>
. Please contact your Grafana administrator to install the plugin.
</Trans>
</Alert>
)}
</>
);
}
}
ShareLink.displayName = 'ShareLink';
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { PureComponent } from 'react';
import { memo, useState } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { applyFieldOverrides, DataFrame, SelectableValue, SplitOpen } from '@grafana/data';
@@ -25,10 +25,6 @@ interface RawPrometheusContainerProps {
splitOpenFn: SplitOpen;
}
interface PrometheusContainerState {
resultsStyle: TableResultsStyle;
}
function mapStateToProps(state: StoreState, { exploreId }: RawPrometheusContainerProps) {
const explore = state.explore;
const item: ExploreItemState = explore.panes[exploreId]!;
@@ -43,69 +39,67 @@ const connector = connect(mapStateToProps, {});
type Props = RawPrometheusContainerProps & ConnectedProps<typeof connector>;
export class RawPrometheusContainer extends PureComponent<Props, PrometheusContainerState> {
constructor(props: Props) {
super(props);
export const RawPrometheusContainer = memo(
({
loading,
onCellFilterAdded,
tableResult,
width,
splitOpenFn,
range,
ariaLabel,
timeZone,
showRawPrometheus,
}: Props) => {
// If resultsStyle is undefined we won't render the toggle, and the default table will be rendered
if (props.showRawPrometheus) {
this.state = {
resultsStyle: TABLE_RESULTS_STYLE.raw,
};
}
}
onChangeResultsStyle = (resultsStyle: TableResultsStyle) => {
this.setState({ resultsStyle });
};
getTableHeight() {
const { tableResult } = this.props;
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
}
renderLabel = () => {
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state:
this.state.resultsStyle === TABLE_RESULTS_STYLE.table
? TABLE_RESULTS_STYLE.raw
: TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={this.state?.resultsStyle}
onChange={this.onChangeResultsStyle}
/>
</div>
const [resultsStyle, setResultsStyle] = useState<TableResultsStyle | undefined>(
showRawPrometheus ? TABLE_RESULTS_STYLE.raw : undefined
);
};
render() {
const { loading, onCellFilterAdded, tableResult, width, splitOpenFn, range, ariaLabel, timeZone } = this.props;
const height = this.getTableHeight();
const onChangeResultsStyle = (newResultsStyle: TableResultsStyle) => {
setResultsStyle(newResultsStyle);
};
const getTableHeight = () => {
if (!tableResult || tableResult.length === 0) {
return 200;
}
// tries to estimate table height
return Math.max(Math.min(600, tableResult[0].length * 35) + 35);
};
const renderLabel = () => {
const spacing = css({
display: 'flex',
justifyContent: 'space-between',
flex: '1',
});
const ALL_GRAPH_STYLE_OPTIONS: Array<SelectableValue<TableResultsStyle>> = TABLE_RESULTS_STYLES.map((style) => ({
value: style,
// capital-case it and switch `_` to ` `
label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '),
}));
return (
<div className={spacing}>
<RadioButtonGroup
onClick={() => {
const props = {
state: resultsStyle === TABLE_RESULTS_STYLE.table ? TABLE_RESULTS_STYLE.raw : TABLE_RESULTS_STYLE.table,
};
reportInteraction('grafana_explore_prometheus_instant_query_ui_toggle_clicked', props);
}}
size="sm"
options={ALL_GRAPH_STYLE_OPTIONS}
value={resultsStyle}
onChange={onChangeResultsStyle}
/>
</div>
);
};
const height = getTableHeight();
const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER;
let dataFrames = tableResult;
@@ -130,11 +124,11 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
(frame: DataFrame | undefined): frame is DataFrame => !!frame && frame.length !== 0
);
const title = this.state.resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = this.state?.resultsStyle !== undefined ? this.renderLabel() : 'Table';
const title = resultsStyle === TABLE_RESULTS_STYLE.raw ? 'Raw' : 'Table';
const label = resultsStyle !== undefined ? renderLabel() : 'Table';
// Render table as default if resultsStyle is not set.
const renderTable = !this.state?.resultsStyle || this.state?.resultsStyle === TABLE_RESULTS_STYLE.table;
const renderTable = !resultsStyle || resultsStyle === TABLE_RESULTS_STYLE.table;
return (
<PanelChrome title={title} actions={label} loadingState={loading}>
@@ -149,13 +143,15 @@ export class RawPrometheusContainer extends PureComponent<Props, PrometheusConta
onCellFilterAdded={onCellFilterAdded}
/>
)}
{this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
{resultsStyle === TABLE_RESULTS_STYLE.raw && <RawListContainer tableResult={frames[0]} />}
</>
)}
{!frames?.length && <MetaInfoText metaItems={[{ value: '0 series returned' }]} />}
</PanelChrome>
);
}
}
);
RawPrometheusContainer.displayName = 'RawPrometheusContainer';
export default connector(RawPrometheusContainer);