mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-10 02:47:49 +00:00
[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
This commit is contained in:
1 parent
ad723c15e4
commit
461186bee9
9 files changed
+231
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -57,7 +57,22 @@ export function renderPartPanel(target, data) {
|
||||
<h4>Part Detail Panel</h4>
|
||||
<hr>
|
||||
<p>This is a custom panel for a Part detail page</p>
|
||||
<button type="button" id="sample-plugin-edit-part">Edit Part</button>
|
||||
`;
|
||||
|
||||
// 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',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 <GlobalCreateForm key={entry.id} entry={entry} />;
|
||||
case 'edit':
|
||||
return <GlobalEditForm key={entry.id} entry={entry} />;
|
||||
case 'delete':
|
||||
return <GlobalDeleteForm key={entry.id} entry={entry} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
)}
|
||||
</Flex>
|
||||
<GlobalImporterDrawer />
|
||||
<GlobalFormModals />
|
||||
<GlobalPreviewDrawer />
|
||||
</>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GlobalFormStateProps>()(
|
||||
(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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user