From b5f440df3ff066043c60b2d5ce0f718b535ba4ae Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Fri, 30 Aug 2024 05:45:51 +0000 Subject: [PATCH] More work needed... --- .../src/components/plugins/PluginPanel.tsx | 201 ++++++++++++------ src/frontend/src/hooks/UsePluginPanels.tsx | 55 ++--- 2 files changed, 162 insertions(+), 94 deletions(-) diff --git a/src/frontend/src/components/plugins/PluginPanel.tsx b/src/frontend/src/components/plugins/PluginPanel.tsx index 4d4a4644da..09f1eeb8e1 100644 --- a/src/frontend/src/components/plugins/PluginPanel.tsx +++ b/src/frontend/src/components/plugins/PluginPanel.tsx @@ -1,7 +1,7 @@ import { t } from '@lingui/macro'; -import { Alert, Text } from '@mantine/core'; +import { Alert, Loader, LoadingOverlay, Text } from '@mantine/core'; import { IconExclamationCircle } from '@tabler/icons-react'; -import { ReactNode, useEffect, useRef, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; @@ -13,10 +13,13 @@ import { PluginElementProps } from './PluginElement'; interface PluginPanelProps extends PanelType { source?: string; + renderFunction?: string; + hiddenFunction?: string; params?: any; instance?: any; model?: ModelType | string; id?: number | null; + pluginKey: string; } // Placeholder content for a panel with no content @@ -28,6 +31,19 @@ function PanelNoContent() { ); } +// Placeholder content for a panel which has failed to load +function PanelErrorContent({ error }: { error: ReactNode | undefined }) { + return ( + } + > + {error} + + ); +} + /** * TODO: Provide more context information to the plugin renderer: * @@ -49,52 +65,34 @@ function PanelNoContent() { * - `element` is the HTML element to render the content into * - `params` is the set of run-time parameters to pass to the content rendering function */ -export default function PluginPanel({ props }: { props: PluginPanelProps }) { - const ref = useRef(); +export default function PluginPanel(props: PluginPanelProps): PanelType { + return { + ...props, + content: `Hello world, from ${props.pluginKey}: ${props.name}` + }; - const host = useLocalState((s) => s.host); + // TODO: Get this working! + // Note: Having these hooks below the "return" above causes errors, + // Warning: React has detected a change in the order of Hooks called by BasePanelGroup. This will lead to bugs and errors if not fixed + + /* + const ref = useRef(); const user = useUserState(); const navigate = useNavigate(); + const [ isLoading, setIsLoading ] = useState(false); const [errorDetail, setErrorDetail] = useState( undefined ); - const loadExternalSource = async () => { - let source: string = props.source ?? ''; + // External module which defines the plugin content + const [ pluginModule, setPluginModule ] = useState(null); - if (!source) { - return; - } + const host = useLocalState.getState().host; - if (source.startsWith('/')) { - // Prefix the source with the host URL - source = `${host}${source}`; - } - - // TODO: Gate where this content may be loaded from (e.g. only allow certain domains) - - let loaded: boolean = false; - - // Load content from external source - const module = await import(/* @vite-ignore */ source ?? '') - .catch((error) => { - setErrorDetail(error.toString()); - }) - .then((module) => { - loaded = true; - return module; - }); - - // We expect the external source to define a function which will render the content - if ( - loaded && - module && - module.render_panel && - typeof module.render_panel === 'function' - ) { - // Set of attributes to pass through to the plugin for rendering - let attributes: PluginElementProps = { + // Attributes to pass through to the plugin module + const attributes : PluginElementProps= useMemo(() => { + return { target: ref.current, model: props.model, id: props.id, @@ -104,38 +102,121 @@ export default function PluginPanel({ props }: { props: PluginPanelProps }) { host: host, navigate: navigate }; + }, [props, ref.current, api, user, navigate]); - module.render_panel(attributes); - } - }; + + // Reload external source if the source URL changes useEffect(() => { - setErrorDetail(undefined); + loadExternalSource(props.source || ''); + }, [props.source]); - if (props.source) { - // Load content from external source - loadExternalSource(); + // Memoize the panel rendering function + const renderPanel = useMemo(() => { + const renderFunction = props.renderFunction || 'renderPanel'; + + console.log("renderPanel memo:", renderFunction, pluginModule); + + if (pluginModule && pluginModule[renderFunction]) { + return pluginModule[renderFunction]; + } + return null; + }, [pluginModule, props.renderFunction]); + + // Memoize if the panel is hidden + const isPanelHidden : boolean = useMemo(() => { + const hiddenFunction = props.hiddenFunction || 'isPanelHidden'; + + console.log("hiddenFunction memo:", hiddenFunction, pluginModule); + console.log("- attributes:", attributes); + + if (pluginModule && pluginModule[hiddenFunction]) { + return !!pluginModule[hiddenFunction](attributes); + } else { + return false; + } + }, [attributes, pluginModule, props.hiddenFunction]); + + // Construct the panel content + const panelContent = useMemo(() => { + if (isLoading) { + return ; + } else if (errorDetail) { + return ; + } else if (!props.content && !props.source) { + return ; + } else { + return
; + } + }, [props, errorDetail, isLoading]); + + // Regenerate the panel content as required + useEffect(() => { + // If a panel rendering function is provided, use that + + console.log("Regenerating panel content..."); + + if (renderPanel) { + console.log("- using external rendering function"); + renderPanel(attributes); } else if (props.content) { // If content is provided directly, render it into the panel + console.log("- using direct content"); if (ref) { ref.current?.setHTMLUnsafe(props.content.toString()); } } - }, [props]); + }, [ref, renderPanel, props.content, attributes]); - if (errorDetail) { - return ( - } - > - {errorDetail} - - ); - } else if (!props.content && !props.source) { - return ; - } else { - return
; + // Load external source content + const loadExternalSource = async (source: string) => { + + const host = useLocalState.getState().host; + + if (!source) { + setErrorDetail(undefined); + setPluginModule(null); + return; + } + + if (source.startsWith('/')) { + // Prefix the source with the host URL + source = `${host}${source}`; + } + + // TODO: Gate where this content may be loaded from (e.g. only allow certain domains) + // TODO: Add a timeout for loading external content + // TODO: Restrict to certain file types (e.g. .js) + + let errorMessage : ReactNode = undefined; + + console.log("Loading plugin module from:", source); + + setIsLoading(true); + + // Load content from external source + const module = await import(/* @vite-ignore * / source ?? '') + .catch((error) => { + errorMessage = error.toString(); + return null; + }) + .then((module) => { + return module; + }); + + setIsLoading(false); + + console.log("Loaded plugin module:", module); + + setPluginModule(module); + setErrorDetail(errorMessage); } + + // Return the panel state + return { + ...props, + content: panelContent, + hidden: isPanelHidden, + } + */ } diff --git a/src/frontend/src/hooks/UsePluginPanels.tsx b/src/frontend/src/hooks/UsePluginPanels.tsx index e19e435039..41a7e86f9b 100644 --- a/src/frontend/src/hooks/UsePluginPanels.tsx +++ b/src/frontend/src/hooks/UsePluginPanels.tsx @@ -1,5 +1,4 @@ import { t } from '@lingui/macro'; -import { Alert, Text } from '@mantine/core'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; @@ -9,14 +8,10 @@ import PluginPanel from '../components/plugins/PluginPanel'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; import { identifierString } from '../functions/conversion'; -import { InvenTreeIcon } from '../functions/icons'; +import { InvenTreeIcon, InvenTreeIconType } from '../functions/icons'; import { apiUrl } from '../states/ApiState'; import { useGlobalSettingsState } from '../states/SettingsState'; -export type PluginPanelState = { - panels: PanelType[]; -}; - export function usePluginPanels({ instance, model, @@ -25,16 +20,16 @@ export function usePluginPanels({ instance?: any; model?: ModelType | string; id?: string | number | null; -}): PluginPanelState { +}): PanelType[] { const globalSettings = useGlobalSettingsState(); - const pluginPanelsEnabled = useMemo( + const pluginPanelsEnabled: boolean = useMemo( () => globalSettings.isSet('ENABLE_PLUGINS_INTERFACE'), [globalSettings] ); // API query to fetch initial information on available plugin panels - const { isFetching, data } = useQuery({ + const { isFetching, data: pluginPanels } = useQuery({ enabled: pluginPanelsEnabled && !!model && id != undefined, queryKey: [model, id], queryFn: async () => { @@ -57,30 +52,22 @@ export function usePluginPanels({ } }); - const panels: PanelType[] = useMemo(() => { - return ( - data?.map((panel: any) => { - const pluginKey = panel.plugin || 'plugin'; - return { - name: identifierString(`pluigin-${pluginKey}-${panel.name}`), - label: panel.label || t`Plugin Panel`, - icon: , - content: ( - - ) - }; - }) ?? [] - ); - }, [data, id, model, instance]); + return ( + pluginPanels?.map((pluginPanelProps: any) => { + const iconName: string = pluginPanelProps.icon || 'plugin'; - return { - panels: panels - }; + return PluginPanel({ + ...pluginPanelProps, + name: identifierString( + `plugin-panel-${pluginPanelProps.plugin}-${pluginPanelProps.name}` + ), + label: pluginPanelProps.label || t`Plugin Panel`, + icon: , + id: id, + model: model, + instance: instance, + pluginKey: pluginPanelProps.plugin || 'plugin' + }); + }) ?? [] + ); }