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) {
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