1
0
Fork 0
mirror of https://github.com/jellyfin/jellyfin-web synced 2025-03-30 19:56:21 +00:00

Merge pull request #5773 from thornbill/add-plugin-redesign

This commit is contained in:
Bill Thornton 2024-07-26 19:34:59 -04:00 committed by GitHub
commit 03f4251afb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1092 additions and 241 deletions

View file

@ -17,18 +17,14 @@ import { useLocation } from 'react-router-dom';
import ListItemLink from 'components/ListItemLink';
import globalize from 'scripts/globalize';
const PLUGIN_PATHS = [
'/dashboard/plugins',
'/dashboard/plugins/catalog',
'/dashboard/plugins/repositories',
'/dashboard/plugins/add',
'/configurationpage'
];
const isPluginPath = (path: string) => (
path.startsWith('/dashboard/plugins')
|| path === '/configurationpage'
);
const AdvancedDrawerSection = () => {
const location = useLocation();
const isPluginSectionOpen = PLUGIN_PATHS.includes(location.pathname);
const isPluginSectionOpen = isPluginPath(location.pathname);
return (
<List

View file

@ -0,0 +1,21 @@
import type { ConfigurationPageInfo } from '@jellyfin/sdk/lib/generated-client/models/configuration-page-info';
export const findBestConfigurationPage = (
configurationPages: ConfigurationPageInfo[],
pluginId: string
) => {
// Find candidates matching the plugin id
const candidates = configurationPages.filter(c => c.PluginId === pluginId);
// If none are found, return undefined
if (candidates.length === 0) return;
// If only one is found, return it
if (candidates.length === 1) return candidates[0];
// Prefer the first candidate with the EnableInMainMenu flag for consistency
const menuCandidate = candidates.find(c => !!c.EnableInMainMenu);
if (menuCandidate) return menuCandidate;
// Fallback to the first match
return candidates[0];
};

View file

@ -0,0 +1,25 @@
import type { PluginInfo } from '@jellyfin/sdk/lib/generated-client/models/plugin-info';
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
/**
* HACK: The Plugins API is returning garbage data in some cases,
* so we need to try to find the "best" match if multiple exist.
*/
export const findBestPluginInfo = (
pluginId: string,
plugins?: PluginInfo[]
) => {
if (!plugins) return;
// Find all plugin entries with a matching ID
const matches = plugins.filter(p => p.Id === pluginId);
// Get the first match (or undefined if none)
const firstMatch = matches?.[0];
if (matches.length > 1) {
return matches.find(p => p.Status === PluginStatus.Disabled) // Disabled entries take priority
|| matches.find(p => p.Status === PluginStatus.Restart) // Then entries specifying restart is needed
|| firstMatch; // Fallback to the first match
}
return firstMatch;
};

View file

@ -0,0 +1,5 @@
export enum QueryKey {
ConfigurationPages = 'ConfigurationPages',
PackageInfo = 'PackageInfo',
Plugins = 'Plugins'
}

View file

@ -0,0 +1,40 @@
import type { Api } from '@jellyfin/sdk';
import type { DashboardApiGetConfigurationPagesRequest } from '@jellyfin/sdk/lib/generated-client/api/dashboard-api';
import { getDashboardApi } from '@jellyfin/sdk/lib/utils/api/dashboard-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchConfigurationPages = async (
api?: Api,
params?: DashboardApiGetConfigurationPagesRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchConfigurationPages] No API instance available');
return [];
}
const response = await getDashboardApi(api)
.getConfigurationPages(params, options);
return response.data;
};
const getConfigurationPagesQuery = (
api?: Api,
params?: DashboardApiGetConfigurationPagesRequest
) => queryOptions({
queryKey: [ QueryKey.ConfigurationPages, params?.enableInMainMenu ],
queryFn: ({ signal }) => fetchConfigurationPages(api, params, { signal }),
enabled: !!api
});
export const useConfigurationPages = (
params?: DashboardApiGetConfigurationPagesRequest
) => {
const { api } = useApi();
return useQuery(getConfigurationPagesQuery(api, params));
};

View file

@ -0,0 +1,24 @@
import type { PluginsApiDisablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useDisablePlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiDisablePluginRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.disablePlugin(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,24 @@
import type { PluginsApiEnablePluginRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useEnablePlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiEnablePluginRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.enablePlugin(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,27 @@
import type { PackageApiInstallPackageRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useInstallPackage = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PackageApiInstallPackageRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPackageApi(api!)
.installPackage(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.ConfigurationPages ]
});
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
}
});
};

View file

@ -0,0 +1,47 @@
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { Api } from '@jellyfin/sdk';
import type { PackageApiGetPackageInfoRequest } from '@jellyfin/sdk/lib/generated-client/api/package-api';
import { getPackageApi } from '@jellyfin/sdk/lib/utils/api/package-api';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchPackageInfo = async (
api?: Api,
params?: PackageApiGetPackageInfoRequest,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchPackageInfo] No API instance available');
return;
}
if (!params) {
console.warn('[fetchPackageInfo] Missing request params');
return;
}
const response = await getPackageApi(api)
.getPackageInfo(params, options);
return response.data;
};
const getPackageInfoQuery = (
api?: Api,
params?: PackageApiGetPackageInfoRequest
) => queryOptions({
// Don't retry since requests for plugins not available in repos fail
retry: false,
queryKey: [ QueryKey.PackageInfo, params?.name, params?.assemblyGuid ],
queryFn: ({ signal }) => fetchPackageInfo(api, params, { signal }),
enabled: !!api && !!params?.name
});
export const usePackageInfo = (
params?: PackageApiGetPackageInfoRequest
) => {
const { api } = useApi();
return useQuery(getPackageInfoQuery(api, params));
};

View file

@ -0,0 +1,36 @@
import type { Api } from '@jellyfin/sdk';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { queryOptions, useQuery } from '@tanstack/react-query';
import type { AxiosRequestConfig } from 'axios';
import { useApi } from 'hooks/useApi';
import { QueryKey } from './queryKey';
const fetchPlugins = async (
api?: Api,
options?: AxiosRequestConfig
) => {
if (!api) {
console.warn('[fetchPlugins] No API instance available');
return [];
}
const response = await getPluginsApi(api)
.getPlugins(options);
return response.data;
};
const getPluginsQuery = (
api?: Api
) => queryOptions({
queryKey: [ QueryKey.Plugins ],
queryFn: ({ signal }) => fetchPlugins(api, { signal }),
enabled: !!api
});
export const usePlugins = () => {
const { api } = useApi();
return useQuery(getPluginsQuery(api));
};

View file

@ -0,0 +1,27 @@
import type { PluginsApiUninstallPluginByVersionRequest } from '@jellyfin/sdk/lib/generated-client/api/plugins-api';
import { getPluginsApi } from '@jellyfin/sdk/lib/utils/api/plugins-api';
import { useMutation } from '@tanstack/react-query';
import { useApi } from 'hooks/useApi';
import { queryClient } from 'utils/query/queryClient';
import { QueryKey } from './queryKey';
export const useUninstallPlugin = () => {
const { api } = useApi();
return useMutation({
mutationFn: (params: PluginsApiUninstallPluginByVersionRequest) => (
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
getPluginsApi(api!)
.uninstallPluginByVersion(params)
),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [ QueryKey.Plugins ]
});
void queryClient.invalidateQueries({
queryKey: [ QueryKey.ConfigurationPages ]
});
}
});
};

View file

@ -0,0 +1,94 @@
import Link from '@mui/material/Link/Link';
import Paper, { type PaperProps } from '@mui/material/Paper/Paper';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import Table from '@mui/material/Table/Table';
import TableBody from '@mui/material/TableBody/TableBody';
import TableCell from '@mui/material/TableCell/TableCell';
import TableContainer from '@mui/material/TableContainer/TableContainer';
import TableRow from '@mui/material/TableRow/TableRow';
import React, { FC } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import globalize from 'scripts/globalize';
import type { PluginDetails } from '../types/PluginDetails';
interface PluginDetailsTableProps extends PaperProps {
isPluginLoading: boolean
isRepositoryLoading: boolean
pluginDetails?: PluginDetails
}
const PluginDetailsTable: FC<PluginDetailsTableProps> = ({
isPluginLoading,
isRepositoryLoading,
pluginDetails,
...paperProps
}) => (
<TableContainer component={Paper} {...paperProps}>
<Table>
<TableBody>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelStatus')}
</TableCell>
<TableCell>
{
(isPluginLoading && <Skeleton />)
|| pluginDetails?.status
|| globalize.translate('LabelNotInstalled')
}
</TableCell>
</TableRow>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelVersion')}
</TableCell>
<TableCell>
{
(isPluginLoading && <Skeleton />)
|| pluginDetails?.version?.version
}
</TableCell>
</TableRow>
<TableRow>
<TableCell variant='head'>
{globalize.translate('LabelDeveloper')}
</TableCell>
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| pluginDetails?.owner
|| globalize.translate('Unknown')
}
</TableCell>
</TableRow>
<TableRow
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell variant='head'>
{globalize.translate('LabelRepository')}
</TableCell>
<TableCell>
{
(isRepositoryLoading && <Skeleton />)
|| (pluginDetails?.version?.repositoryUrl && (
<Link
component={RouterLink}
to={pluginDetails.version.repositoryUrl}
target='_blank'
rel='noopener noreferrer'
>
{pluginDetails.version.repositoryName}
</Link>
))
|| globalize.translate('Unknown')
}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
);
export default PluginDetailsTable;

View file

@ -0,0 +1,34 @@
import Paper from '@mui/material/Paper/Paper';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import React, { type FC } from 'react';
interface PluginImageProps {
isLoading: boolean
alt?: string
url?: string
}
const PluginImage: FC<PluginImageProps> = ({
isLoading,
alt,
url
}) => (
<Paper sx={{ width: '100%', aspectRatio: 16 / 9, overflow: 'hidden' }}>
{isLoading && (
<Skeleton
variant='rectangular'
width='100%'
height='100%'
/>
)}
{url && (
<img
src={url}
alt={alt}
width='100%'
/>
)}
</Paper>
);
export default PluginImage;

View file

@ -0,0 +1,67 @@
import Download from '@mui/icons-material/Download';
import DownloadDone from '@mui/icons-material/DownloadDone';
import ExpandMore from '@mui/icons-material/ExpandMore';
import Accordion from '@mui/material/Accordion/Accordion';
import AccordionDetails from '@mui/material/AccordionDetails/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary/AccordionSummary';
import Button from '@mui/material/Button/Button';
import Stack from '@mui/material/Stack/Stack';
import React, { type FC } from 'react';
import MarkdownBox from 'components/MarkdownBox';
import { parseISO8601Date, toLocaleString } from 'scripts/datetime';
import globalize from 'scripts/globalize';
import type { PluginDetails } from '../types/PluginDetails';
import { VersionInfo } from '@jellyfin/sdk/lib/generated-client';
interface PluginRevisionsProps {
pluginDetails?: PluginDetails,
onInstall: (version?: VersionInfo) => () => void
}
const PluginRevisions: FC<PluginRevisionsProps> = ({
pluginDetails,
onInstall
}) => (
pluginDetails?.versions?.map(version => (
<Accordion key={version.checksum}>
<AccordionSummary
expandIcon={<ExpandMore />}
>
{version.version}
{version.timestamp && (<>
&nbsp;&mdash;&nbsp;
{toLocaleString(parseISO8601Date(version.timestamp))}
</>)}
</AccordionSummary>
<AccordionDetails>
<Stack spacing={2}>
<MarkdownBox
fallback={globalize.translate('LabelNoChangelog')}
markdown={version.changelog}
/>
{pluginDetails.status && version.version === pluginDetails.version?.version ? (
<Button
disabled
startIcon={<DownloadDone />}
variant='outlined'
>
{globalize.translate('LabelInstalled')}
</Button>
) : (
<Button
startIcon={<Download />}
variant='outlined'
onClick={onInstall(version)}
>
{globalize.translate('HeaderInstall')}
</Button>
)}
</Stack>
</AccordionDetails>
</Accordion>
))
);
export default PluginRevisions;

View file

@ -0,0 +1,15 @@
import type { ConfigurationPageInfo, PluginStatus, VersionInfo } from '@jellyfin/sdk/lib/generated-client';
export interface PluginDetails {
canUninstall: boolean
description?: string
id: string
imageUrl?: string
isEnabled: boolean
name?: string
owner?: string
configurationPage?: ConfigurationPageInfo
status?: PluginStatus
version?: VersionInfo
versions: VersionInfo[]
}

View file

@ -2,11 +2,12 @@ import { AsyncRouteType, type AsyncRoute } from 'components/router/AsyncRoute';
export const ASYNC_ADMIN_ROUTES: AsyncRoute[] = [
{ path: 'activity', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard },
{ path: 'plugins/:pluginId', page: 'plugins/plugin', type: AsyncRouteType.Dashboard },
{ path: 'users', type: AsyncRouteType.Dashboard },
{ path: 'users/access', type: AsyncRouteType.Dashboard },
{ path: 'users/add', type: AsyncRouteType.Dashboard },
{ path: 'users/parentalcontrol', type: AsyncRouteType.Dashboard },
{ path: 'users/password', type: AsyncRouteType.Dashboard },
{ path: 'users/profile', type: AsyncRouteType.Dashboard },
{ path: 'playback/trickplay', type: AsyncRouteType.Dashboard }
{ path: 'users/profile', type: AsyncRouteType.Dashboard }
];

View file

@ -31,12 +31,6 @@ export const LEGACY_ADMIN_ROUTES: LegacyRoute[] = [
controller: 'dashboard/devices/device',
view: 'dashboard/devices/device.html'
}
}, {
path: 'plugins/add',
pageProps: {
controller: 'dashboard/plugins/add/index',
view: 'dashboard/plugins/add/index.html'
}
}, {
path: 'libraries',
pageProps: {

View file

@ -1,7 +1,6 @@
import type { Redirect } from 'components/router/Redirect';
export const REDIRECTS: Redirect[] = [
{ from: 'addplugin.html', to: '/dashboard/plugins/add' },
{ from: 'apikeys.html', to: '/dashboard/keys' },
{ from: 'availableplugins.html', to: '/dashboard/plugins/catalog' },
{ from: 'dashboard.html', to: '/dashboard' },

View file

@ -0,0 +1,443 @@
import { PluginStatus } from '@jellyfin/sdk/lib/generated-client/models/plugin-status';
import type { VersionInfo } from '@jellyfin/sdk/lib/generated-client/models/version-info';
import Alert from '@mui/material/Alert/Alert';
import Button from '@mui/material/Button/Button';
import Container from '@mui/material/Container/Container';
import FormControlLabel from '@mui/material/FormControlLabel/FormControlLabel';
import FormGroup from '@mui/material/FormGroup/FormGroup';
import Grid from '@mui/material/Grid/Grid';
import Skeleton from '@mui/material/Skeleton/Skeleton';
import Stack from '@mui/material/Stack/Stack';
import Switch from '@mui/material/Switch/Switch';
import Typography from '@mui/material/Typography/Typography';
import Delete from '@mui/icons-material/Delete';
import Download from '@mui/icons-material/Download';
import Settings from '@mui/icons-material/Settings';
import React, { type FC, useState, useCallback, useMemo } from 'react';
import { useSearchParams, Link as RouterLink, useParams } from 'react-router-dom';
import { findBestConfigurationPage } from 'apps/dashboard/features/plugins/api/configurationPage';
import { findBestPluginInfo } from 'apps/dashboard/features/plugins/api/pluginInfo';
import { useConfigurationPages } from 'apps/dashboard/features/plugins/api/useConfigurationPages';
import { useDisablePlugin } from 'apps/dashboard/features/plugins/api/useDisablePlugin';
import { useEnablePlugin } from 'apps/dashboard/features/plugins/api/useEnablePlugin';
import { useInstallPackage } from 'apps/dashboard/features/plugins/api/useInstallPackage';
import { usePackageInfo } from 'apps/dashboard/features/plugins/api/usePackageInfo';
import { usePlugins } from 'apps/dashboard/features/plugins/api/usePlugins';
import { useUninstallPlugin } from 'apps/dashboard/features/plugins/api/useUninstallPlugin';
import PluginImage from 'apps/dashboard/features/plugins/components/PluginImage';
import PluginDetailsTable from 'apps/dashboard/features/plugins/components/PluginDetailsTable';
import PluginRevisions from 'apps/dashboard/features/plugins/components/PluginRevisions';
import type { PluginDetails } from 'apps/dashboard/features/plugins/types/PluginDetails';
import ConfirmDialog from 'components/ConfirmDialog';
import Page from 'components/Page';
import { useApi } from 'hooks/useApi';
import globalize from 'scripts/globalize';
import { getPluginUrl } from 'utils/dashboard';
import { getUri } from 'utils/api';
interface AlertMessage {
severity?: 'success' | 'info' | 'warning' | 'error'
messageKey: string
}
// Plugins from this url will be trusted and not prompt for confirmation when installing
const TRUSTED_REPO_URL = 'https://repo.jellyfin.org/';
const PluginPage: FC = () => {
const { api } = useApi();
const { pluginId } = useParams();
const [ searchParams ] = useSearchParams();
const disablePlugin = useDisablePlugin();
const enablePlugin = useEnablePlugin();
const installPlugin = useInstallPackage();
const uninstallPlugin = useUninstallPlugin();
const [ isEnabledOverride, setIsEnabledOverride ] = useState<boolean>();
const [ isInstallConfirmOpen, setIsInstallConfirmOpen ] = useState(false);
const [ isUninstallConfirmOpen, setIsUninstallConfirmOpen ] = useState(false);
const [ pendingInstallVersion, setPendingInstallVersion ] = useState<VersionInfo>();
const pluginName = searchParams.get('name') ?? undefined;
const {
data: configurationPages,
isError: isConfigurationPagesError,
isLoading: isConfigurationPagesLoading
} = useConfigurationPages();
const {
data: packageInfo,
isError: isPackageInfoError,
isLoading: isPackageInfoLoading
} = usePackageInfo(pluginName ? {
name: pluginName,
assemblyGuid: pluginId
} : undefined);
const {
data: plugins,
isLoading: isPluginsLoading,
isError: isPluginsError
} = usePlugins();
const isLoading =
isConfigurationPagesLoading || isPackageInfoLoading || isPluginsLoading;
const pluginDetails = useMemo<PluginDetails | undefined>(() => {
if (pluginId && !isPluginsLoading) {
const pluginInfo = findBestPluginInfo(pluginId, plugins);
let version;
if (pluginInfo) {
// Find the installed version
const repoVersion = packageInfo?.versions?.find(v => v.version === pluginInfo.Version);
version = repoVersion || {
version: pluginInfo.Version,
VersionNumber: pluginInfo.Version
};
} else {
// Use the latest version
version = packageInfo?.versions?.[0];
}
let imageUrl;
if (pluginInfo?.HasImage) {
imageUrl = getUri(`/Plugins/${pluginInfo.Id}/${pluginInfo.Version}/Image`, api);
}
return {
canUninstall: !!pluginInfo?.CanUninstall,
description: pluginInfo?.Description || packageInfo?.description || packageInfo?.overview,
id: pluginId,
imageUrl: imageUrl || packageInfo?.imageUrl || undefined,
isEnabled: (isEnabledOverride && pluginInfo?.Status === PluginStatus.Restart)
?? pluginInfo?.Status !== PluginStatus.Disabled,
name: pluginName || pluginInfo?.Name || packageInfo?.name,
owner: packageInfo?.owner,
status: pluginInfo?.Status,
configurationPage: findBestConfigurationPage(configurationPages || [], pluginId),
version,
versions: packageInfo?.versions || []
};
}
}, [
api,
configurationPages,
isEnabledOverride,
isPluginsLoading,
packageInfo?.description,
packageInfo?.imageUrl,
packageInfo?.name,
packageInfo?.overview,
packageInfo?.owner,
packageInfo?.versions,
pluginId,
pluginName,
plugins
]);
const alertMessages = useMemo(() => {
const alerts: AlertMessage[] = [];
if (disablePlugin.isError) {
alerts.push({ messageKey: 'PluginDisableError' });
}
if (enablePlugin.isError) {
alerts.push({ messageKey: 'PluginEnableError' });
}
if (installPlugin.isSuccess) {
alerts.push({
severity: 'success',
messageKey: 'MessagePluginInstalled'
});
}
if (installPlugin.isError) {
alerts.push({ messageKey: 'MessagePluginInstallError' });
}
if (uninstallPlugin.isError) {
alerts.push({ messageKey: 'PluginUninstallError' });
}
if (isConfigurationPagesError) {
alerts.push({ messageKey: 'PluginLoadConfigError' });
}
if (isPackageInfoError) {
alerts.push({
severity: 'warning',
messageKey: 'PluginLoadRepoError'
});
}
if (isPluginsError) {
alerts.push({ messageKey: 'MessageGetInstalledPluginsError' });
}
return alerts;
}, [
disablePlugin.isError,
enablePlugin.isError,
installPlugin.isError,
installPlugin.isSuccess,
isConfigurationPagesError,
isPackageInfoError,
isPluginsError,
uninstallPlugin.isError
]);
/** Enable/disable the plugin */
const toggleEnabled = useCallback(() => {
if (!pluginDetails?.version?.version) return;
console.debug('[PluginPage] %s plugin', pluginDetails.isEnabled ? 'disabling' : 'enabling', pluginDetails);
if (pluginDetails.isEnabled) {
disablePlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
}, {
onSuccess: () => {
setIsEnabledOverride(false);
},
onSettled: () => {
installPlugin.reset();
enablePlugin.reset();
uninstallPlugin.reset();
}
});
} else {
enablePlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
}, {
onSuccess: () => {
setIsEnabledOverride(true);
},
onSettled: () => {
installPlugin.reset();
disablePlugin.reset();
uninstallPlugin.reset();
}
});
}
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
/** Install the plugin or prompt for confirmation if untrusted */
const onInstall = useCallback((version?: VersionInfo, isConfirmed = false) => () => {
if (!pluginDetails?.name) return;
const installVersion = version || pluginDetails.version;
if (!installVersion) return;
if (!isConfirmed && !installVersion.repositoryUrl?.startsWith(TRUSTED_REPO_URL)) {
console.debug('[PluginPage] plugin install needs confirmed', installVersion);
setPendingInstallVersion(installVersion);
setIsInstallConfirmOpen(true);
return;
}
console.debug('[PluginPage] installing plugin', installVersion);
installPlugin.mutate({
name: pluginDetails.name,
assemblyGuid: pluginDetails.id,
version: installVersion.version,
repositoryUrl: installVersion.repositoryUrl
}, {
onSettled: () => {
setPendingInstallVersion(undefined);
disablePlugin.reset();
enablePlugin.reset();
uninstallPlugin.reset();
}
});
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
/** Confirm and install the plugin */
const onConfirmInstall = useCallback(() => {
console.debug('[PluginPage] confirmed installing plugin', pendingInstallVersion);
setIsInstallConfirmOpen(false);
onInstall(pendingInstallVersion, true)();
}, [ onInstall, pendingInstallVersion ]);
/** Close the install confirmation dialog */
const onCloseInstallConfirmDialog = useCallback(() => {
setPendingInstallVersion(undefined);
setIsInstallConfirmOpen(false);
}, []);
/** Show the uninstall confirmation dialog */
const onConfirmUninstall = useCallback(() => {
setIsUninstallConfirmOpen(true);
}, []);
/** Uninstall the plugin */
const onUninstall = useCallback(() => {
if (!pluginDetails?.version?.version) return;
console.debug('[PluginPage] uninstalling plugin', pluginDetails);
setIsUninstallConfirmOpen(false);
uninstallPlugin.mutate({
pluginId: pluginDetails.id,
version: pluginDetails.version.version
}, {
onSettled: () => {
disablePlugin.reset();
enablePlugin.reset();
installPlugin.reset();
}
});
}, [ disablePlugin, enablePlugin, installPlugin, pluginDetails, uninstallPlugin ]);
/** Close the uninstall confirmation dialog */
const onCloseUninstallConfirmDialog = useCallback(() => {
setIsUninstallConfirmOpen(false);
}, []);
return (
<Page
id='addPluginPage'
className='mainAnimatedPage type-interior'
>
<Container className='content-primary'>
{alertMessages.map(({ severity = 'error', messageKey }) => (
<Alert key={messageKey} severity={severity}>
{globalize.translate(messageKey)}
</Alert>
))}
<Grid container spacing={2} sx={{ marginTop: 0 }}>
<Grid item xs={12} lg={8}>
<Stack spacing={2}>
<Typography variant='h1'>
{pluginDetails?.name || pluginName}
</Typography>
<Typography sx={{ maxWidth: '80ch' }}>
{isLoading && !pluginDetails?.description ? (
<Skeleton />
) : (
pluginDetails?.description
)}
</Typography>
</Stack>
</Grid>
<Grid item lg={4} sx={{ display: { xs: 'none', lg: 'initial' } }}>
<PluginImage
isLoading={isLoading}
alt={pluginDetails?.name}
url={pluginDetails?.imageUrl}
/>
</Grid>
<Grid item xs={12} lg={8} sx={{ order: { xs: 1, lg: 'initial' } }}>
{!!pluginDetails?.versions.length && (
<>
<Typography variant='h3' sx={{ marginBottom: 2 }}>
{globalize.translate('HeaderRevisionHistory')}
</Typography>
<PluginRevisions
pluginDetails={pluginDetails}
onInstall={onInstall}
/>
</>
)}
</Grid>
<Grid item xs={12} lg={4}>
<Stack spacing={2} direction={{ xs: 'column', sm: 'row-reverse', lg: 'column' }}>
<Stack spacing={1} sx={{ flexBasis: '50%' }}>
{!isLoading && !pluginDetails?.status && (
<>
<Alert severity='info'>
{globalize.translate('ServerRestartNeededAfterPluginInstall')}
</Alert>
<Button
startIcon={<Download />}
onClick={onInstall()}
>
{globalize.translate('HeaderInstall')}
</Button>
</>
)}
{!isLoading && pluginDetails?.canUninstall && (
<FormGroup>
<FormControlLabel
control={
<Switch
checked={pluginDetails.isEnabled}
onChange={toggleEnabled}
disabled={pluginDetails.status === PluginStatus.Restart}
/>
}
label={globalize.translate('LabelEnablePlugin')}
/>
</FormGroup>
)}
{!isLoading && pluginDetails?.configurationPage?.Name && (
<Button
component={RouterLink}
to={`/${getPluginUrl(pluginDetails.configurationPage.Name)}`}
startIcon={<Settings />}
>
{globalize.translate('Settings')}
</Button>
)}
{!isLoading && pluginDetails?.canUninstall && (
<Button
color='error'
startIcon={<Delete />}
onClick={onConfirmUninstall}
>
{globalize.translate('ButtonUninstall')}
</Button>
)}
</Stack>
<PluginDetailsTable
isPluginLoading={isPluginsLoading}
isRepositoryLoading={isPackageInfoLoading}
pluginDetails={pluginDetails}
sx={{ flexBasis: '50%' }}
/>
</Stack>
</Grid>
</Grid>
</Container>
<ConfirmDialog
open={isInstallConfirmOpen}
title={globalize.translate('HeaderConfirmPluginInstallation')}
text={globalize.translate('MessagePluginInstallDisclaimer')}
onCancel={onCloseInstallConfirmDialog}
onConfirm={onConfirmInstall}
confirmButtonText={globalize.translate('HeaderInstall')}
/>
<ConfirmDialog
open={isUninstallConfirmOpen}
title={globalize.translate('HeaderUninstallPlugin')}
text={globalize.translate('UninstallPluginConfirmation', pluginName || '')}
onCancel={onCloseUninstallConfirmDialog}
onConfirm={onUninstall}
confirmButtonColor='error'
confirmButtonText={globalize.translate('ButtonUninstall')}
/>
</Page>
);
};
export default PluginPage;