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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user