Plugins: Improve update all modal UX (#93448)
This commit is contained in:
@@ -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<string, UpdateError>;
|
||||
selectedPlugins?: Set<string>;
|
||||
}) {
|
||||
if (errorMap && errorMap.has(id)) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={`${t('plugins.catalog.update-all.error', 'Error updating plugin:')} ${errorMap.get(id)?.message}`}
|
||||
>
|
||||
<Icon size="xl" name="exclamation-circle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (inProgress && selectedPlugins?.has(id)) {
|
||||
return <Spinner />;
|
||||
}
|
||||
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<string>;
|
||||
onCheckboxChange: (id: string) => void;
|
||||
errorMap: Map<string, UpdateError>;
|
||||
};
|
||||
|
||||
const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.modalContainer}>
|
||||
{plugins.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="completed"
|
||||
message={t('plugins.catalog.update-all.all-plugins-updated', 'All plugins updated!')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Trans i18nKey="plugins.catalog.update-all.header">The following plugins have update available</Trans>
|
||||
</div>
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.header}>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.update-header">Update</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.name-header">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.installed-header">Installed</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.available-header">Available</Trans>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => (
|
||||
<tr key={id} className={styles.tableRow}>
|
||||
<td>
|
||||
<Checkbox onChange={() => onCheckboxChange(id)} value={selectedPlugins?.has(id)} />
|
||||
</td>
|
||||
<td>{name}</td>
|
||||
<td>{installedVersion}</td>
|
||||
<td>{latestVersion}</td>
|
||||
<td className={styles.icon}>{getIcon({ id, inProgress, errorMap, selectedPlugins })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && (
|
||||
<footer className={styles.footer}>
|
||||
<Trans i18nKey="plugins.catalog.update-all.cloud-update-message">
|
||||
* It may take a few minutes for the plugins to be available for usage.
|
||||
</Trans>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -173,10 +28,19 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props)
|
||||
const [errorMap, setErrorMap] = useState(new Map<string, UpdateError>());
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<Set<string>>();
|
||||
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={
|
||||
<ModalBody
|
||||
plugins={plugins}
|
||||
<UpdateModalBody
|
||||
plugins={initialPluginsRef.current}
|
||||
pluginsNotInstalled={pluginsSet}
|
||||
inProgress={inProgress}
|
||||
errorMap={errorMap}
|
||||
onCheckboxChange={onCheckboxChange}
|
||||
@@ -287,14 +153,34 @@ export const UpdateAllModal = ({ isOpen, onDismiss, isLoading, plugins }: Props)
|
||||
}
|
||||
onConfirm={installsRemaining > 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;
|
||||
|
||||
@@ -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<string, UpdateError>;
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (errorMap && errorMap.has(id)) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={`${t('plugins.catalog.update-all.error', 'Error updating plugin:')} ${errorMap.get(id)?.message}`}
|
||||
>
|
||||
<Icon className={styles.errorIcon} size="xl" name="exclamation-triangle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (isInstalled) {
|
||||
return <Icon className={styles.successIcon} size="xl" name="check" />;
|
||||
}
|
||||
if (inProgress && isSelected) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
type Props = {
|
||||
plugins: CatalogPlugin[];
|
||||
pluginsNotInstalled: Set<string>;
|
||||
inProgress: boolean;
|
||||
selectedPlugins?: Set<string>;
|
||||
onCheckboxChange: (id: string) => void;
|
||||
errorMap: Map<string, UpdateError>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div>
|
||||
{plugins.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="completed"
|
||||
message={t('plugins.catalog.update-all.all-plugins-updated', 'All plugins updated!')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Trans i18nKey="plugins.catalog.update-all.header">The following plugins have update available</Trans>
|
||||
</div>
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.header}>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.update-header">Update</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.name-header">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.installed-header">Installed</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.available-header">Available</Trans>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => (
|
||||
<tr key={id} className={styles.tableRow}>
|
||||
<td>
|
||||
<Checkbox
|
||||
onChange={() => onCheckboxChange(id)}
|
||||
value={selectedPlugins?.has(id)}
|
||||
disabled={!pluginsNotInstalled.has(id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{name}</td>
|
||||
<td>{installedVersion}</td>
|
||||
<td>{latestVersion}</td>
|
||||
<td className={styles.icon}>
|
||||
<StatusIcon
|
||||
id={id}
|
||||
inProgress={inProgress}
|
||||
isSelected={selectedPlugins?.has(id) ?? false}
|
||||
isInstalled={!pluginsNotInstalled.has(id)}
|
||||
errorMap={errorMap}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{numberInstalled > 0 && installationFinished && (
|
||||
<div className={styles.pluginsInstalled}>
|
||||
<Icon className={styles.successIcon} size="lg" name="check" />
|
||||
{`${numberInstalled} ${t('plugins.catalog.update-all.update-status-text', 'plugins updated')}`}
|
||||
</div>
|
||||
)}
|
||||
{errorMap.size > 0 && installationFinished && (
|
||||
<div className={styles.pluginsInstalled}>
|
||||
<Icon className={styles.errorIcon} size="lg" name="exclamation-triangle" />
|
||||
{`${errorMap.size} ${t('plugins.catalog.update-all.error-status-text', 'failed - see error messages')}`}
|
||||
</div>
|
||||
)}
|
||||
{config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && (
|
||||
<footer className={styles.footer}>
|
||||
<Trans i18nKey="plugins.catalog.update-all.cloud-update-message">
|
||||
* It may take a few minutes for the plugins to be available for usage.
|
||||
</Trans>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user