From 368fc0f1202897587e6d3c6e5b745588af87e9b7 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Thu, 26 Sep 2024 10:19:26 +0200 Subject: [PATCH] Plugins: Improve update all modal UX (#93448) --- .../admin/components/UpdateAllModal.tsx | 200 ++++------------- .../admin/components/UpdateAllModalBody.tsx | 208 ++++++++++++++++++ public/locales/en-US/grafana.json | 5 +- public/locales/pseudo-LOCALE/grafana.json | 5 +- 4 files changed, 259 insertions(+), 159 deletions(-) create mode 100644 public/app/features/plugins/admin/components/UpdateAllModalBody.tsx diff --git a/public/app/features/plugins/admin/components/UpdateAllModal.tsx b/public/app/features/plugins/admin/components/UpdateAllModal.tsx index 6dfa1758a32..d0c07295f59 100644 --- a/public/app/features/plugins/admin/components/UpdateAllModal.tsx +++ b/public/app/features/plugins/admin/components/UpdateAllModal.tsx @@ -1,14 +1,13 @@ -import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { config, reportInteraction } from '@grafana/runtime'; -import { Checkbox, ConfirmModal, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; -import { t, Trans } from 'app/core/internationalization'; +import { ConfirmModal } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { useInstall, useInstallStatus } from '../state/hooks'; import { CatalogPlugin } from '../types'; +import { UpdateModalBody } from './UpdateAllModalBody'; const PLUGINS_UPDATE_ALL_INTERACTION_EVENT_NAME = 'plugins_update_all_clicked'; type UpdateError = { @@ -16,150 +15,6 @@ type UpdateError = { message: string; }; -function getIcon({ - id, - inProgress, - errorMap, - selectedPlugins, -}: { - id: string; - inProgress: boolean; - errorMap: Map; - selectedPlugins?: Set; -}) { - if (errorMap && errorMap.has(id)) { - return ( - - - - ); - } - if (inProgress && selectedPlugins?.has(id)) { - return ; - } - return ''; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css({ - marginTop: theme.spacing(2), - width: '100%', - borderCollapse: 'collapse', - }), - tableRow: css({ - borderBottom: `1px solid ${theme.colors.border.weak}`, - td: { - paddingRight: theme.spacing(1), - }, - }), - icon: css({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - }), - header: css({ - textAlign: 'left', - padding: theme.spacing(1), - borderBottom: `2px solid ${theme.colors.border.strong}`, - th: { - paddingRight: theme.spacing(1), - }, - }), - data: css({ - padding: '10px', - }), - footer: css({ - fontSize: theme.typography.bodySmall.fontSize, - marginTop: theme.spacing(3), - }), - noPluginsMessage: css({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - height: '100%', - }), - tableContainer: css({ - overflowY: 'auto', - overflowX: 'hidden', - height: theme.spacing(32), - }), - modalContainer: css({ - height: theme.spacing(41), - }), -}); - -type ModalBodyProps = { - plugins: CatalogPlugin[]; - inProgress: boolean; - selectedPlugins?: Set; - onCheckboxChange: (id: string) => void; - errorMap: Map; -}; - -const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => { - const styles = useStyles2(getStyles); - - return ( -
- {plugins.length === 0 ? ( - - ) : ( - <> -
- The following plugins have update available -
-
- - - - - - - - - - - - {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( - - - - - - - - ))} - -
- Update - - Name - - Installed - - Available -
- onCheckboxChange(id)} value={selectedPlugins?.has(id)} /> - {name}{installedVersion}{latestVersion}{getIcon({ id, inProgress, errorMap, selectedPlugins })}
-
- {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( -
- - * It may take a few minutes for the plugins to be available for usage. - -
- )} - - )} -
- ); -}; - type Props = { isOpen: boolean; isLoading: boolean; @@ -173,10 +28,19 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) const [errorMap, setErrorMap] = useState(new Map()); const [inProgress, setInProgress] = useState(false); const [selectedPlugins, setSelectedPlugins] = useState>(); + const initialPluginsRef = useRef(plugins); const pluginsSet = useMemo(() => new Set(plugins.map((plugin) => plugin.id)), [plugins]); const installsRemaining = plugins.length; + // Since the plugins comes from the store and changes every time we update a plugin, + // we need to keep track of the initial plugins. + useEffect(() => { + if (initialPluginsRef.current.length === 0) { + initialPluginsRef.current = [...plugins]; + } + }, [plugins]); + // Updates the component state on every plugins change, since the installation will change the store content useEffect(() => { if (inProgress) { @@ -245,6 +109,7 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) }; const onDismissClick = () => { + initialPluginsRef.current = []; setErrorMap(new Map()); setInProgress(false); setSelectedPlugins(undefined); @@ -277,8 +142,9 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props) isOpen={isOpen} title={t('plugins.catalog.update-all.modal-title', 'Update Plugins')} body={ - 0 ? onConfirm : onDismissClick} onDismiss={onDismissClick} - disabled={pluginsSelected === 0 || inProgress} - confirmText={ - installsRemaining > 0 - ? `${t('plugins.catalog.update-all.modal-confirmation', 'Update')} (${pluginsSelected})` - : t('plugins.catalog.update-all.modal-dismiss', 'Close') - } + disabled={shouldDisableConfirm(inProgress, installsRemaining, pluginsSelected)} + confirmText={getConfirmationText(installsRemaining, inProgress, pluginsSelected)} + confirmButtonVariant="primary" /> ); }; +function getConfirmationText(installsRemaining: number, inProgress: boolean, pluginsSelected: number) { + if (inProgress) { + return t('plugins.catalog.update-all.modal-in-progress', 'Updating...'); + } + + if (installsRemaining > 0) { + return t('plugins.catalog.update-all.modal-confirmation', 'Update') + ` (${pluginsSelected})`; + } + return t('plugins.catalog.update-all.modal-dismiss', 'Close'); +} + +function shouldDisableConfirm(inProgress: boolean, installsRemaining: number, pluginsSelected: number) { + if (inProgress) { + return true; + } + + if (installsRemaining > 0 && pluginsSelected === 0) { + return true; + } + + return false; +} + export default UpdateAllModal; diff --git a/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx new file mode 100644 index 00000000000..20214900d3a --- /dev/null +++ b/public/app/features/plugins/admin/components/UpdateAllModalBody.tsx @@ -0,0 +1,208 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Checkbox, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; + +import { CatalogPlugin } from '../types'; + +type UpdateError = { + id: string; + message: string; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css({ + marginTop: theme.spacing(2), + width: '100%', + borderCollapse: 'collapse', + }), + tableRow: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + td: { + paddingRight: theme.spacing(1), + }, + }), + icon: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }), + header: css({ + textAlign: 'left', + padding: theme.spacing(1), + borderBottom: `2px solid ${theme.colors.border.strong}`, + th: { + paddingRight: theme.spacing(1), + }, + }), + data: css({ + padding: '10px', + }), + footer: css({ + fontSize: theme.typography.bodySmall.fontSize, + marginTop: theme.spacing(3), + }), + noPluginsMessage: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }), + tableContainer: css({ + overflowY: 'auto', + overflowX: 'hidden', + maxHeight: theme.spacing(41), + marginBottom: theme.spacing(2), + }), + errorIcon: css({ + color: theme.colors.error.main, + }), + successIcon: css({ + color: theme.colors.success.main, + }), + pluginsInstalled: css({ + svg: { + marginRight: theme.spacing(1), + }, + }), +}); + +const StatusIcon = ({ + id, + inProgress, + isSelected, + isInstalled, + errorMap, +}: { + id: string; + inProgress: boolean; + isSelected: boolean; + isInstalled: boolean; + errorMap: Map; +}) => { + const styles = useStyles2(getStyles); + + if (errorMap && errorMap.has(id)) { + return ( + + + + ); + } + if (isInstalled) { + return ; + } + if (inProgress && isSelected) { + return ; + } + return ''; +}; + +type Props = { + plugins: CatalogPlugin[]; + pluginsNotInstalled: Set; + inProgress: boolean; + selectedPlugins?: Set; + onCheckboxChange: (id: string) => void; + errorMap: Map; +}; + +export const UpdateModalBody = ({ + plugins, + pluginsNotInstalled, + inProgress, + selectedPlugins, + onCheckboxChange, + errorMap, +}: Props) => { + const styles = useStyles2(getStyles); + + const numberInstalled = plugins.length - pluginsNotInstalled.size; + const installationFinished = plugins.length !== pluginsNotInstalled.size && !inProgress; + + return ( +
+ {plugins.length === 0 ? ( + + ) : ( + <> +
+ The following plugins have update available +
+
+ + + + + + + + + + + + {plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => ( + + + + + + + + ))} + +
+ Update + + Name + + Installed + + Available +
+ onCheckboxChange(id)} + value={selectedPlugins?.has(id)} + disabled={!pluginsNotInstalled.has(id)} + /> + {name}{installedVersion}{latestVersion} + +
+
+ {numberInstalled > 0 && installationFinished && ( +
+ + {`${numberInstalled} ${t('plugins.catalog.update-all.update-status-text', 'plugins updated')}`} +
+ )} + {errorMap.size > 0 && installationFinished && ( +
+ + {`${errorMap.size} ${t('plugins.catalog.update-all.error-status-text', 'failed - see error messages')}`} +
+ )} + {config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && ( + + )} + + )} +
+ ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 51e5a1e67d2..a6eaee746f6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1926,13 +1926,16 @@ "button": "Update all", "cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.", "error": "Error updating plugin:", + "error-status-text": "failed - see error messages", "header": "The following plugins have update available", "installed-header": "Installed", "modal-confirmation": "Update", "modal-dismiss": "Close", + "modal-in-progress": "Updating...", "modal-title": "Update Plugins", "name-header": "Name", - "update-header": "Update" + "update-header": "Update", + "update-status-text": "plugins updated" } }, "details": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 23911fd61f2..618d140017d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1926,13 +1926,16 @@ "button": "Ůpđäŧę äľľ", "cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.", "error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:", + "error-status-text": "ƒäįľęđ - şęę ęřřőř męşşäģęş", "header": "Ŧĥę ƒőľľőŵįʼnģ pľūģįʼnş ĥävę ūpđäŧę äväįľäþľę", "installed-header": "Ĩʼnşŧäľľęđ", "modal-confirmation": "Ůpđäŧę", "modal-dismiss": "Cľőşę", + "modal-in-progress": "Ůpđäŧįʼnģ...", "modal-title": "Ůpđäŧę Pľūģįʼnş", "name-header": "Ńämę", - "update-header": "Ůpđäŧę" + "update-header": "Ůpđäŧę", + "update-status-text": "pľūģįʼnş ūpđäŧęđ" } }, "details": {