diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 5db3b71773e..479f297e1a1 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 diff --git a/public/app/core/components/Login/LoginCtrl.tsx b/public/app/core/components/Login/LoginCtrl.tsx index f0820da9485..9ea5cba50cf 100644 --- a/public/app/core/components/Login/LoginCtrl.tsx +++ b/public/app/core/components/Login/LoginCtrl.tsx @@ -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(); + 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( + getBootDataErrMessage(config.loginError) + ); -export class LoginCtrl extends PureComponent { - 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('/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('/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('/api/login/passwordless/start', formModel, { showErrorAlert: false }) @@ -144,88 +155,58 @@ export class LoginCtrl extends PureComponent { }) .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('/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('/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; diff --git a/public/app/core/components/OptionsUI/NumberInput.tsx b/public/app/core/components/OptionsUI/NumberInput.tsx index b96893096d8..456bf5ced49 100644 --- a/public/app/core/components/OptionsUI/NumberInput.tsx +++ b/public/app/core/components/OptionsUI/NumberInput.tsx @@ -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 { - state: State = { text: '', inputCorrected: false }; - inputRef = React.createRef(); +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(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) => { - this.setState({ - text: e.currentTarget.value, - }); - this.updateValueDebounced(); - }; - - onKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - this.updateValue(); - } - }; - - renderInput() { - return ( - + const handleChange = useCallback( + (e: React.FocusEvent) => { + setText(e.currentTarget.value); + updateValueDebounced(); + }, + [updateValueDebounced] ); - } - render() { - const { inputCorrected } = this.state; + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + updateValue(); + } + }, + [updateValue] + ); + + const renderInput = () => { + return ( + + ); + }; + 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 { validationMessageHorizontalOverflow={true} style={{ direction: 'rtl' }} > - {this.renderInput()} + {renderInput()} ); } - return this.renderInput(); + return renderInput(); } -} +); + +NumberInput.displayName = 'NumberInput'; diff --git a/public/app/core/components/OptionsUI/strings.tsx b/public/app/core/components/OptionsUI/strings.tsx index 77294b2314c..e6acc2448aa 100644 --- a/public/app/core/components/OptionsUI/strings.tsx +++ b/public/app/core/components/OptionsUI/strings.tsx @@ -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; -interface State { - showAdd: boolean; -} -export class StringArrayEditor extends React.PureComponent { - 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 | React.FocusEvent, idx: number) => { - if ('key' in e) { - if (e.key !== 'Enter') { + const onValueChange = useCallback( + (e: React.KeyboardEvent | React.FocusEvent, 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 ( -
- {value.map((v, index) => { - return ( - this.onValueChange(e, index)} - onKeyDown={(e) => this.onValueChange(e, index)} - suffix={ this.onRemoveString(index)} />} - /> - ); - })} - - {showAdd ? ( + return ( +
+ {value.map((v, index) => { + return ( this.onValueChange(e, -1)} - onKeyDown={(e) => this.onValueChange(e, -1)} - suffix={} + key={`${index}/${v}`} + defaultValue={v || ''} + onBlur={(e) => onValueChange(e, index)} + onKeyDown={(e) => onValueChange(e, index)} + suffix={ onRemoveString(index)} />} /> - ) : ( - - )} -
- ); - } -} + ); + })} -const getStyles = stylesFactory((theme: GrafanaTheme2) => { + {showAdd ? ( + onValueChange(e, -1)} + onKeyDown={(e) => onValueChange(e, -1)} + suffix={} + /> + ) : ( + + )} +
+ ); +}); + +StringArrayEditor.displayName = 'StringArrayEditor'; + +const getStyles = (theme: GrafanaTheme2) => { return { textInput: css({ marginBottom: '5px', @@ -107,4 +105,4 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => { }, }), }; -}); +}; diff --git a/public/app/core/components/Select/TeamPicker.tsx b/public/app/core/components/Select/TeamPicker.tsx index 3f374fc8930..e7b109bae49 100644 --- a/public/app/core/components/Select/TeamPicker.tsx +++ b/public/app/core/components/Select/TeamPicker.tsx @@ -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; -} +export const TeamPicker = ({ onSelected, className, teamId }: Props) => { + const [isLoading, setIsLoading] = useState(false); + const [value, setValue] = useState | undefined>(); -export class TeamPicker extends Component { - 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 { 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> = 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> = 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 ( -
- -
- ); - } -} + return ( +
+ +
+ ); +}; diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index 221c7138f99..56149dbcf55 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -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(null); + const [role, setRole] = useState(OrgRole.Viewer); + const [roleOptions, setRoleOptions] = useState([]); + const [pendingOrgId, setPendingOrgId] = useState(null); + const [pendingUserId, setPendingUserId] = useState(null); + const [pendingRoles, setPendingRoles] = useState([]); + const styles = useStyles2(getAddToOrgModalStyles); -export class AddToOrgModal extends PureComponent { - 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 ( - - - - - - - - - - - - - - - ); - } -} + return ( + + + + + + + + + + + + + + + ); +}); +AddToOrgModal.displayName = 'AddToOrgModal'; interface ChangeOrgButtonProps { lockMessage?: string; diff --git a/public/app/features/admin/UserProfile.tsx b/public/app/features/admin/UserProfile.tsx index c2c4d98b0b9..414b8e56000 100644 --- a/public/app/features/admin/UserProfile.tsx +++ b/public/app/features/admin/UserProfile.tsx @@ -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(null); -export class UserProfileRow extends PureComponent { - inputElem?: HTMLInputElement; + useEffect(() => { + setValue(valueProp); + }, [valueProp]); - static defaultProps: Partial = { - 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, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } - onCancelClick = () => { - this.setState({ editing: false, value: this.props.value || '' }); - }; + setValue(event.target.value); + }, []); - onInputChange = (event: React.ChangeEvent, status?: LegacyInputStatus) => { - if (status === LegacyInputStatus.Invalid) { - return; - } + const onInputBlur = useCallback((event: React.FocusEvent, status?: LegacyInputStatus) => { + if (status === LegacyInputStatus.Invalid) { + return; + } - this.setState({ - value: event.target.value, - }); - }; + setValue(event.target.value); + }, []); - onInputBlur = (event: React.FocusEvent, 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{label} - {this.state.editing ? ( + {editing ? ( ) : ( - {this.props.value} + {valueProp} )} {t('admin.user-profile.edit-button', 'Edit')} @@ -302,7 +292,9 @@ export class UserProfileRow extends PureComponent ); } -} +); + +UserProfileRow.displayName = 'UserProfileRow'; interface LockedRowProps { label: string; diff --git a/public/app/features/annotations/components/AnnotationResultMapper.tsx b/public/app/features/annotations/components/AnnotationResultMapper.tsx index b572ab13e40..d466c3153db 100644 --- a/public/app/features/annotations/components/AnnotationResultMapper.tsx +++ b/public/app/features/annotations/components/AnnotationResultMapper.tsx @@ -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> = [ -// { 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>; -} +export const AnnotationFieldMapper = memo(({ response, mappings, change }: Props) => { + const [fieldNames, setFieldNames] = useState>>([]); -export class AnnotationFieldMapper extends PureComponent { - 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 { description, }; }); - this.setState({ fieldNames }); + setFieldNames(newFieldNames); } - }; + }, [response]); - componentDidMount() { - this.updateFields(); - } + const onFieldNameChange = useCallback( + (k: keyof AnnotationEvent, v: SelectableValue) => { + 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) => { - 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) => { - 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 ( - - - {row.label || row.key}{' '} - {row.help && ( - - - - )} - - {/* - ) => { - this.onFieldNameChange(row.key, v); - }} - noOptionsMessage={t( - 'annotations.annotation-field-mapper.noOptionsMessage-unknown-field-names', - 'Unknown field names' + return ( + + + {row.label || row.key}{' '} + {row.help && ( + + + )} - allowCustomValue={true} - isClearable - /> - - {`${value}`} - - ); - } + + + shareUrl} onClipboardCopy={onCopy}> + Copy + + } + /> + + + + {panel && config.rendererAvailable && ( + <> + {isDashboardSaved && ( + + {t('share-modal.link.rendered-image', 'Direct link rendered image')} + + )} + + {!isDashboardSaved && ( + + + To render a panel image, you must save the dashboard first. + + + )} + + )} + + {panel && !config.rendererAvailable && ( + + + To render an image, you must install the{' '} + + Grafana image renderer plugin + + . Please contact your Grafana administrator to install the plugin. -

-
- - - - - - - + + )} + + ); +}); - - - Copy - - } - /> - -
- - {panel && config.rendererAvailable && ( - <> - {isDashboardSaved && ( - - {t('share-modal.link.rendered-image', 'Direct link rendered image')} - - )} - - {!isDashboardSaved && ( - - - To render a panel image, you must save the dashboard first. - - - )} - - )} - - {panel && !config.rendererAvailable && ( - - - To render an image, you must install the{' '} - - Grafana image renderer plugin - - . Please contact your Grafana administrator to install the plugin. - - - )} - - ); - } -} +ShareLink.displayName = 'ShareLink'; diff --git a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx index 3d0af16edc4..ebd005f595b 100644 --- a/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx +++ b/public/app/features/explore/RawPrometheus/RawPrometheusContainer.tsx @@ -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; -export class RawPrometheusContainer extends PureComponent { - 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> = TABLE_RESULTS_STYLES.map((style) => ({ - value: style, - // capital-case it and switch `_` to ` ` - label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '), - })); - - return ( -
- { - 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} - /> -
+ const [resultsStyle, setResultsStyle] = useState( + 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> = TABLE_RESULTS_STYLES.map((style) => ({ + value: style, + // capital-case it and switch `_` to ` ` + label: style[0].toUpperCase() + style.slice(1).replace(/_/, ' '), + })); + + return ( +
+ { + 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} + /> +
+ ); + }; + + const height = getTableHeight(); const tableWidth = width - config.theme.panelPadding * 2 - PANEL_BORDER; let dataFrames = tableResult; @@ -130,11 +124,11 @@ export class RawPrometheusContainer extends PureComponent !!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 ( @@ -149,13 +143,15 @@ export class RawPrometheusContainer extends PureComponent )} - {this.state?.resultsStyle === TABLE_RESULTS_STYLE.raw && } + {resultsStyle === TABLE_RESULTS_STYLE.raw && } )} {!frames?.length && } ); } -} +); + +RawPrometheusContainer.displayName = 'RawPrometheusContainer'; export default connector(RawPrometheusContainer);