From 461186bee95502c88dfeb0b49a824a43e03a31a5 Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 3 Nov 2026 16:41:33 +1100 Subject: [PATCH] [UI] Launch forms directly from plugin (#13174) * [UI] Launch forms directly from plugin Provide ability for plugins to dynamically launch modal forms, without specifying the underlying react components. This allows (for example) a primary action on a detail page to launch a new native API form * Add CHANGELOG entry --- docs/docs/plugins/frontend.md | 24 +++++ .../static/plugins/sampleui/sample_panel.js | 15 +++ src/frontend/CHANGELOG.md | 1 + src/frontend/lib/types/Plugins.tsx | 4 + .../src/components/forms/GlobalFormModals.tsx | 94 +++++++++++++++++++ src/frontend/src/components/nav/Layout.tsx | 2 + .../src/components/plugins/PluginContext.tsx | 8 ++ src/frontend/src/states/GlobalFormState.tsx | 72 ++++++++++++++ src/frontend/tests/pui_plugins.spec.ts | 12 ++- 9 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/components/forms/GlobalFormModals.tsx create mode 100644 src/frontend/src/states/GlobalFormState.tsx diff --git a/docs/docs/plugins/frontend.md b/docs/docs/plugins/frontend.md index 550a604590..3e722d4d14 100644 --- a/docs/docs/plugins/frontend.md +++ b/docs/docs/plugins/frontend.md @@ -106,6 +106,30 @@ This set of components is passed through at render time to the plugin function, For example, the plugin can make use of the authenticated api instance, to make calls to the InvenTree API, or use the `navigate` function to redirect the user to a different page. +### Launching Forms + +The `forms` object provides two ways for a plugin to display an API-driven form in a modal dialog. + +The `create`, `edit` and `delete` functions are React hooks, which must be called from within a React component. The returned `modal` element must be rendered by the plugin component, and the form is displayed by calling the returned `open` function. + +Alternatively, the `openCreate`, `openEdit` and `openDelete` functions can be called from anywhere - including from outside of a React component, such as a primary action callback. The form modal is rendered by the InvenTree user interface, and the plugin does not need to render anything itself. These functions accept the same form properties as the hook functions, and return an identifier which can be passed to `forms.close` to close the form programmatically. + +```javascript +const formId = context.forms.openCreate({ + url: '/plugin/my-plugin/items/', + title: 'Create Item', + fields: { + name: {}, + description: {}, + }, + onFormSuccess: (data) => { + console.log('Item created:', data); + }, +}); +``` + +Note that the form properties are captured when the form is opened, and are not updated afterwards. + ## Externalized Libraries To create a plugin which integrates natively into the InvenTree UI, it is important that the plugin is compiled in a way that allows it to run within the same React context as the InvenTree frontend code. diff --git a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js index eaee2d6a37..ee0e620bbf 100644 --- a/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js +++ b/src/backend/InvenTree/plugin/samples/static/plugins/sampleui/sample_panel.js @@ -57,7 +57,22 @@ export function renderPartPanel(target, data) {

Part Detail Panel


This is a custom panel for a Part detail page

+ `; + + // Launch an API form from the plugin, without rendering the modal within the plugin itself + target.querySelector('#sample-plugin-edit-part').onclick = () => { + data.forms?.openEdit?.({ + url: 'part/', + pk: data.id, + title: 'Edit Part from Plugin', + fields: { + name: {}, + description: {}, + }, + successMessage: 'Part updated from plugin', + }); + }; } diff --git a/src/frontend/CHANGELOG.md b/src/frontend/CHANGELOG.md index f7af13746b..2dd212b919 100644 --- a/src/frontend/CHANGELOG.md +++ b/src/frontend/CHANGELOG.md @@ -27,6 +27,7 @@ Changes to type definitions: #### New Features +- Modal forms can be launched dynamically from anywhere in the interface (including from the spotlight) - [#13174](https://github.com/inventree/InvenTree/pull/13174) - Tables support nested (child) rows via the new `nestedRows` table property ([#12952](https://github.com/inventree/InvenTree/pull/12952)) - Adds the `tableActionsFullWidth` table property, allowing custom table actions to use the available header width ([#12799](https://github.com/inventree/InvenTree/pull/12799)) - Adds the `json` form field type ([#12799](https://github.com/inventree/InvenTree/pull/12799)) diff --git a/src/frontend/lib/types/Plugins.tsx b/src/frontend/lib/types/Plugins.tsx index d142500ae5..9099932631 100644 --- a/src/frontend/lib/types/Plugins.tsx +++ b/src/frontend/lib/types/Plugins.tsx @@ -55,6 +55,10 @@ export type InvenTreeFormsContext = { delete: (props: ApiFormModalProps) => UseModalReturn; edit: (props: ApiFormModalProps) => UseModalReturn; editApiForm: (props: { id?: string; props: ApiFormProps }) => React.ReactNode; + openCreate: (props: ApiFormModalProps) => string; + openEdit: (props: ApiFormModalProps) => string; + openDelete: (props: ApiFormModalProps) => string; + close: (id: string) => void; stockActions: StockAdjustmentFormsContext; }; diff --git a/src/frontend/src/components/forms/GlobalFormModals.tsx b/src/frontend/src/components/forms/GlobalFormModals.tsx new file mode 100644 index 0000000000..e8174dcc74 --- /dev/null +++ b/src/frontend/src/components/forms/GlobalFormModals.tsx @@ -0,0 +1,94 @@ +import type { ApiFormModalProps } from '@lib/types/Forms'; +import type { UseModalReturn } from '@lib/types/Modals'; +import { useEffect, useMemo } from 'react'; + +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { + type GlobalFormEntry, + useGlobalFormState +} from '../../states/GlobalFormState'; + +// Delay before a closed form is removed, allowing the modal close transition to complete +const REMOVE_DELAY_MS = 500; + +/** + * Construct the form props for a global form entry, + * ensuring that the entry is removed from the global state once the form is closed. + */ +function useGlobalFormProps(entry: GlobalFormEntry): ApiFormModalProps { + const removeForm = useGlobalFormState((state) => state.removeForm); + + return useMemo( + () => ({ + ...entry.props, + modalId: entry.id, + onClose: () => { + entry.props.onClose?.(); + setTimeout(() => removeForm(entry.id), REMOVE_DELAY_MS); + } + }), + [entry.id, entry.props] + ); +} + +/** + * Open the modal when first rendered, and close it when requested via the global state. + */ +function useGlobalFormControl(entry: GlobalFormEntry, form: UseModalReturn) { + useEffect(() => { + form.open(); + }, []); + + useEffect(() => { + if (entry.closeRequested) { + form.close(); + } + }, [entry.closeRequested]); +} + +function GlobalCreateForm({ entry }: Readonly<{ entry: GlobalFormEntry }>) { + const form = useCreateApiFormModal(useGlobalFormProps(entry)); + useGlobalFormControl(entry, form); + return form.modal; +} + +function GlobalEditForm({ entry }: Readonly<{ entry: GlobalFormEntry }>) { + const form = useEditApiFormModal(useGlobalFormProps(entry)); + useGlobalFormControl(entry, form); + return form.modal; +} + +function GlobalDeleteForm({ entry }: Readonly<{ entry: GlobalFormEntry }>) { + const form = useDeleteApiFormModal(useGlobalFormProps(entry)); + useGlobalFormControl(entry, form); + return form.modal; +} + +/** + * Render all API forms which have been opened via the global form state. + * This allows forms to be launched from outside of a React component (e.g. by a plugin). + */ +export default function GlobalFormModals() { + const forms = useGlobalFormState((state) => state.forms); + + return ( + <> + {forms.map((entry) => { + switch (entry.formType) { + case 'create': + return ; + case 'edit': + return ; + case 'delete': + return ; + default: + return null; + } + })} + + ); +} diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index bb8d28ebe7..c24d93dd88 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -21,6 +21,7 @@ import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; +import GlobalFormModals from '../forms/GlobalFormModals'; import GlobalImporterDrawer from '../importer/GlobalImporterDrawer'; import { ApiIcon } from '../items/ApiIcon'; import { useInvenTreeContext } from '../plugins/PluginContext'; @@ -151,6 +152,7 @@ export default function LayoutComponent() { )} + diff --git a/src/frontend/src/components/plugins/PluginContext.tsx b/src/frontend/src/components/plugins/PluginContext.tsx index 844707ae59..47fdf2ba2c 100644 --- a/src/frontend/src/components/plugins/PluginContext.tsx +++ b/src/frontend/src/components/plugins/PluginContext.tsx @@ -11,6 +11,7 @@ import { import { useUserState } from '../../states/UserState'; import { ModelInformationDict } from '@lib/enums/ModelInformation'; +import type { ApiFormModalProps } from '@lib/types/Forms'; import { INVENTREE_MANTINE_VERSION, INVENTREE_PLUGIN_VERSION, @@ -39,6 +40,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { closeGlobalForm, openGlobalForm } from '../../states/GlobalFormState'; import { type ImporterOpenOptions, closeGlobalImporter, @@ -135,6 +137,12 @@ export const useInvenTreeContext = () => { delete: useDeleteApiFormModal, edit: useEditApiFormModal, editApiForm: EditApiForm, + openCreate: (props: ApiFormModalProps) => + openGlobalForm('create', props), + openEdit: (props: ApiFormModalProps) => openGlobalForm('edit', props), + openDelete: (props: ApiFormModalProps) => + openGlobalForm('delete', props), + close: closeGlobalForm, stockActions: { addStock: useAddStockItem, assignStock: useAssignStockItem, diff --git a/src/frontend/src/states/GlobalFormState.tsx b/src/frontend/src/states/GlobalFormState.tsx new file mode 100644 index 0000000000..28eb363b40 --- /dev/null +++ b/src/frontend/src/states/GlobalFormState.tsx @@ -0,0 +1,72 @@ +/** + * This file contains a global state manager for API form modals, allowing them to be opened and closed from anywhere in the app without needing to render the modal within the calling component. + * This is primarily intended for use by plugins, which may need to launch a form from outside of a React component (e.g. from a primary action callback). + * Each opened form is tracked as a separate entry, allowing multiple forms to be stacked (e.g. a form launched from within another form). + * The `GlobalFormModals` component (rendered within the main layout) is responsible for rendering the open forms. + */ + +import type { ApiFormModalProps } from '@lib/types/Forms'; +import { create } from 'zustand'; + +export type GlobalFormType = 'create' | 'edit' | 'delete'; + +export interface GlobalFormEntry { + id: string; + formType: GlobalFormType; + props: ApiFormModalProps; + closeRequested: boolean; +} + +interface GlobalFormStateProps { + forms: GlobalFormEntry[]; + openForm: (formType: GlobalFormType, props: ApiFormModalProps) => string; + closeForm: (id: string) => void; + removeForm: (id: string) => void; +} + +let formCounter = 0; + +export const useGlobalFormState = create()( + (set, get) => ({ + forms: [], + + openForm: (formType: GlobalFormType, props: ApiFormModalProps) => { + // Always generate a unique ID, so that a form being removed cannot affect a newly opened form + formCounter += 1; + const id = `global-form-${formCounter}`; + + set({ + forms: [...get().forms, { id, formType, props, closeRequested: false }] + }); + + return id; + }, + + // Request that the form is closed - the modal is closed by the rendering component + closeForm: (id: string) => { + set({ + forms: get().forms.map((form) => + form.id === id ? { ...form, closeRequested: true } : form + ) + }); + }, + + // Remove the form entirely (once the modal has been closed) + removeForm: (id: string) => { + set({ + forms: get().forms.filter((form) => form.id !== id) + }); + } + }) +); + +export function openGlobalForm( + formType: GlobalFormType, + props: ApiFormModalProps +): string { + return useGlobalFormState.getState().openForm(formType, props); +} + +export function closeGlobalForm(id: string) { + useGlobalFormState.getState().closeForm(id); +} diff --git a/src/frontend/tests/pui_plugins.spec.ts b/src/frontend/tests/pui_plugins.spec.ts index 65f4b64763..7616dde59e 100644 --- a/src/frontend/tests/pui_plugins.spec.ts +++ b/src/frontend/tests/pui_plugins.spec.ts @@ -1,4 +1,4 @@ -import { test } from './baseFixtures'; +import { expect, test } from './baseFixtures'; import { adminuser } from './defaults.js'; import { @@ -185,6 +185,16 @@ test('Plugins - Panels', async ({ browser }) => { await loadTab(page, 'Part Panel'); await page.getByText('This content has been rendered by a custom plugin'); + // Launch an API form from the plugin, via the plugin context + await page.getByRole('button', { name: 'Edit Part' }).click(); + await page.getByText('Edit Part from Plugin').waitFor(); + await page.getByRole('textbox', { name: 'text-field-name' }).waitFor(); + await expect( + page.getByRole('textbox', { name: 'text-field-description' }) + ).not.toHaveValue(''); + await page.getByRole('button', { name: 'Cancel' }).click(); + await expect(page.getByText('Edit Part from Plugin')).toBeHidden(); + // Disable the plugin, and ensure it is no longer visible await setPluginState({ plugin: 'sampleui',