diff --git a/openbis_ng_ui/jest.config.js b/openbis_ng_ui/jest.config.js
index 8e12627b1a2a51ab0ce042e19ecea29c63ca6804..f493ac2b3bb36fefcd7228018bf9b8673c7ed0ff 100644
--- a/openbis_ng_ui/jest.config.js
+++ b/openbis_ng_ui/jest.config.js
@@ -28,6 +28,7 @@ module.exports = {
     moment: '<rootDir>/srcV3/lib/moment/js/moment.js',
     stjs: '<rootDir>/srcV3/lib/stjs/js/stjs.js',
     underscore: '<rootDir>/srcV3/lib/underscore/js/underscore.js',
+    '\\.css$': '<rootDir>/srcTest/js/mockStyles.js',
     'openbis.js': '<rootDir>/srcTest/js/services/openbis.js',
     '^@src/(.*)$': '<rootDir>/src/$1',
     '^@srcTest/(.*)$': '<rootDir>/srcTest/$1',
diff --git a/openbis_ng_ui/srcTest/js/components/common/form/wrapper/SourceCodeFieldWrapper.js b/openbis_ng_ui/srcTest/js/components/common/form/wrapper/SourceCodeFieldWrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..a1ec5ea269aada10cad4715aceef84908e540e64
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/common/form/wrapper/SourceCodeFieldWrapper.js
@@ -0,0 +1,13 @@
+import FieldWrapper from './FieldWrapper.js'
+
+export default class SourceCodeFieldWrapper extends FieldWrapper {
+  getFocused() {
+    if (this.getMode() === 'edit') {
+      return (
+        document.activeElement === this.wrapper.find('textarea').getDOMNode()
+      )
+    } else {
+      return false
+    }
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentChange.test.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentChange.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..c38765746f639bbcd0921f87cb53cc7b98138f8e
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentChange.test.js
@@ -0,0 +1,88 @@
+import PluginFormComponentTest from '@srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js'
+import PluginFormTestData from '@srcTest/js/components/tools/form/plugin/PluginFormTestData.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new PluginFormComponentTest()
+  common.beforeEach()
+})
+
+describe(PluginFormComponentTest.SUITE, () => {
+  test('change DYNAMIC_PROPERTY', async () => {
+    const { testDynamicPropertyJythonPlugin } = PluginFormTestData
+    await testChange(testDynamicPropertyJythonPlugin)
+  })
+  test('change ENTITY_VALIDATION', async () => {
+    const { testEntityValidationJythonPlugin } = PluginFormTestData
+    await testChange(testEntityValidationJythonPlugin)
+  })
+})
+
+async function testChange(plugin) {
+  const form = await common.mountExisting(plugin)
+
+  form.getButtons().getEdit().click()
+  await form.update()
+
+  form.getScript().getScript().change('updated script')
+  await form.update()
+
+  form.getParameters().getDescription().change('updated description')
+  await form.update()
+
+  form.expectJSON({
+    script: {
+      title: 'Script',
+      script: {
+        label: 'Script',
+        value: 'updated script',
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    parameters: {
+      title: 'Plugin',
+      name: {
+        label: 'Name',
+        value: plugin.getName(),
+        enabled: false,
+        mode: 'edit'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value:
+          plugin.getEntityKinds().length === 1
+            ? plugin.getEntityKinds()[0]
+            : null,
+        options: [
+          { label: 'MATERIAL' },
+          { label: 'EXPERIMENT' },
+          { label: 'SAMPLE' },
+          { label: 'DATA_SET' }
+        ],
+        enabled: false,
+        mode: 'edit'
+      },
+      description: {
+        label: 'Description',
+        value: 'updated description',
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    buttons: {
+      save: {
+        enabled: true
+      },
+      cancel: {
+        enabled: true
+      },
+      edit: null,
+      message: {
+        text: 'You have unsaved changes.',
+        type: 'warning'
+      }
+    }
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentLoad.test.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentLoad.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..91dde99066f1725f515351fd0ea3c5f6218102a3
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentLoad.test.js
@@ -0,0 +1,243 @@
+import PluginFormComponentTest from '@srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js'
+import PluginFormTestData from '@srcTest/js/components/tools/form/plugin/PluginFormTestData.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new PluginFormComponentTest()
+  common.beforeEach()
+})
+
+describe(PluginFormComponentTest.SUITE, () => {
+  test('load new DYNAMIC_PROPERTY', async () => {
+    await testLoadNew(openbis.PluginType.DYNAMIC_PROPERTY)
+  })
+  test('load new ENTITY_VALIDATION', async () => {
+    await testLoadNew(openbis.PluginType.ENTITY_VALIDATION)
+  })
+  test('load existing DYNAMIC_PROPERTY JYTHON', async () => {
+    const { testDynamicPropertyJythonPlugin } = PluginFormTestData
+    await testLoadExistingJython(testDynamicPropertyJythonPlugin)
+  })
+  test('load existing ENTITY_VALIDATION JYTHON', async () => {
+    const { testEntityValidationJythonPlugin } = PluginFormTestData
+    await testLoadExistingJython(testEntityValidationJythonPlugin)
+  })
+  test('load existing DYNAMIC_PROPERTY PREDEPLOYED', async () => {
+    const { testDynamicPropertyPredeployedPlugin } = PluginFormTestData
+    await testLoadExistingPredeployed(testDynamicPropertyPredeployedPlugin)
+  })
+  test('load existing ENTITY_VALIDATION PREDEPLOYED', async () => {
+    const { testEntityValidationPredeployedPlugin } = PluginFormTestData
+    await testLoadExistingPredeployed(testEntityValidationPredeployedPlugin)
+  })
+})
+
+async function testLoadNew(pluginType) {
+  const form = await common.mountNew(pluginType)
+
+  form.expectJSON({
+    script: {
+      title: 'Script',
+      script: {
+        label: 'Script',
+        value: null,
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    parameters: {
+      title: 'Plugin',
+      name: {
+        label: 'Name',
+        value: null,
+        enabled: true,
+        mode: 'edit'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value: null,
+        options: [
+          { label: 'MATERIAL' },
+          { label: 'EXPERIMENT' },
+          { label: 'SAMPLE' },
+          { label: 'DATA_SET' }
+        ],
+        enabled: true,
+        mode: 'edit'
+      },
+      description: {
+        label: 'Description',
+        value: null,
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    buttons: {
+      save: {
+        enabled: true
+      },
+      edit: null,
+      cancel: null,
+      message: null
+    }
+  })
+}
+
+async function testLoadExistingJython(plugin) {
+  const form = await common.mountExisting(plugin)
+
+  form.expectJSON({
+    script: {
+      title: 'Script',
+      script: {
+        label: 'Script',
+        value: plugin.script,
+        mode: 'view'
+      }
+    },
+    parameters: {
+      title: 'Plugin',
+      name: {
+        label: 'Name',
+        value: plugin.getName(),
+        mode: 'view'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value:
+          plugin.getEntityKinds().length === 1
+            ? plugin.getEntityKinds()[0]
+            : null,
+        options: [
+          { label: 'MATERIAL' },
+          { label: 'EXPERIMENT' },
+          { label: 'SAMPLE' },
+          { label: 'DATA_SET' }
+        ],
+        mode: 'view'
+      },
+      description: {
+        label: 'Description',
+        value: plugin.getDescription(),
+        mode: 'view'
+      }
+    },
+    buttons: {
+      edit: {
+        enabled: true
+      },
+      save: null,
+      cancel: null,
+      message: null
+    }
+  })
+
+  form.getButtons().getEdit().click()
+  await form.update()
+
+  form.expectJSON({
+    script: {
+      title: 'Script',
+      script: {
+        label: 'Script',
+        value: plugin.script,
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    parameters: {
+      title: 'Plugin',
+      name: {
+        label: 'Name',
+        value: plugin.getName(),
+        enabled: false,
+        mode: 'edit'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value:
+          plugin.getEntityKinds().length === 1
+            ? plugin.getEntityKinds()[0]
+            : null,
+        options: [
+          { label: 'MATERIAL' },
+          { label: 'EXPERIMENT' },
+          { label: 'SAMPLE' },
+          { label: 'DATA_SET' }
+        ],
+        enabled: false,
+        mode: 'edit'
+      },
+      description: {
+        label: 'Description',
+        value: plugin.getDescription(),
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    buttons: {
+      save: {
+        enabled: true
+      },
+      cancel: {
+        enabled: true
+      },
+      edit: null,
+      message: null
+    }
+  })
+}
+
+async function testLoadExistingPredeployed(plugin) {
+  const form = await common.mountExisting(plugin)
+
+  form.expectJSON({
+    script: null,
+    parameters: {
+      title: 'Plugin',
+      messages: [
+        {
+          text: 'The plugin is disabled.',
+          type: 'warning'
+        },
+        {
+          text:
+            'This is a predeployed Java plugin. Its parameters and logic are defined in the plugin Java class and therefore cannot be changed from the UI.',
+          type: 'info'
+        }
+      ],
+      name: {
+        label: 'Name',
+        value: plugin.getName(),
+        mode: 'view'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value:
+          plugin.getEntityKinds().length === 1
+            ? plugin.getEntityKinds()[0]
+            : null,
+        options: [
+          { label: 'MATERIAL' },
+          { label: 'EXPERIMENT' },
+          { label: 'SAMPLE' },
+          { label: 'DATA_SET' }
+        ],
+        mode: 'view'
+      },
+      description: {
+        label: 'Description',
+        value: plugin.getDescription(),
+        mode: 'view'
+      }
+    },
+    buttons: {
+      edit: null,
+      save: null,
+      cancel: null,
+      message: null
+    }
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentSave.test.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentSave.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..f0f2ff8f4e28c6452cb0fc59148ef599950b48ed
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentSave.test.js
@@ -0,0 +1,113 @@
+import PluginFormComponentTest from '@srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js'
+import PluginFormTestData from '@srcTest/js/components/tools/form/plugin/PluginFormTestData.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new PluginFormComponentTest()
+  common.beforeEach()
+})
+
+describe(PluginFormComponentTest.SUITE, () => {
+  test('save create DYNAMIC_PROPERTY', async () => {
+    await testSaveCreate(openbis.PluginType.DYNAMIC_PROPERTY)
+  })
+  test('save create ENTITY_VALIDATION', async () => {
+    await testSaveCreate(openbis.PluginType.ENTITY_VALIDATION)
+  })
+  test('save update DYNAMIC_PROPERTY', async () => {
+    const { testDynamicPropertyJythonPlugin } = PluginFormTestData
+    await testSaveUpdate(testDynamicPropertyJythonPlugin)
+  })
+  test('save update ENTITY_VALIDATION', async () => {
+    const { testEntityValidationJythonPlugin } = PluginFormTestData
+    await testSaveUpdate(testEntityValidationJythonPlugin)
+  })
+})
+
+async function testSaveCreate(pluginType) {
+  const form = await common.mountNew(pluginType)
+
+  form.getParameters().getName().change('test-plugin')
+  await form.update()
+
+  form.getParameters().getEntityKind().change(openbis.EntityKind.SAMPLE)
+  await form.update()
+
+  form.getParameters().getDescription().change('test description')
+  await form.update()
+
+  form.getScript().getScript().change('test script')
+  await form.update()
+
+  form.getButtons().getSave().click()
+  await form.update()
+
+  expectExecuteOperations([
+    createPluginOperation({
+      pluginType,
+      name: 'test-plugin',
+      entityKind: openbis.EntityKind.SAMPLE,
+      description: 'test description',
+      script: 'test script'
+    })
+  ])
+}
+
+async function testSaveUpdate(plugin) {
+  const form = await common.mountExisting(plugin)
+
+  form.getButtons().getEdit().click()
+  await form.update()
+
+  form.getParameters().getDescription().change('updated description')
+  await form.update()
+
+  form.getScript().getScript().change('updated script')
+  await form.update()
+
+  form.getButtons().getSave().click()
+  await form.update()
+
+  expectExecuteOperations([
+    updatePluginOperation({
+      name: plugin.getName(),
+      description: 'updated description',
+      script: 'updated script'
+    })
+  ])
+}
+
+function createPluginOperation({
+  pluginType,
+  name,
+  entityKind,
+  description,
+  script
+}) {
+  const creation = new openbis.PluginCreation()
+  creation.setPluginType(pluginType)
+  creation.setEntityKind(entityKind)
+  creation.setName(name)
+  creation.setDescription(description)
+  creation.setScript(script)
+  return new openbis.CreatePluginsOperation([creation])
+}
+
+function updatePluginOperation({ name, description, script }) {
+  const update = new openbis.PluginUpdate()
+  update.setPluginId(new openbis.PluginPermId(name))
+  update.setDescription(description)
+  update.setScript(script)
+  return new openbis.UpdatePluginsOperation([update])
+}
+
+function expectExecuteOperations(expectedOperations) {
+  expect(common.facade.executeOperations).toHaveBeenCalledTimes(1)
+  const actualOperations = common.facade.executeOperations.mock.calls[0][0]
+  expect(actualOperations.length).toEqual(expectedOperations.length)
+  actualOperations.forEach((actualOperation, index) => {
+    expect(actualOperation).toMatchObject(expectedOperations[index])
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc4c8756bc4f5e2a6f61091a2ffe0fb795ea4982
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js
@@ -0,0 +1,62 @@
+import React from 'react'
+import ComponentTest from '@srcTest/js/components/common/ComponentTest.js'
+import PluginForm from '@src/js/components/tools/form/plugin/PluginForm.jsx'
+import PluginFormWrapper from '@srcTest/js/components/tools/form/plugin/wrapper/PluginFormWrapper.js'
+import PluginFormController from '@src/js/components/tools/form/plugin/PluginFormController.js'
+import PluginFormFacade from '@src/js/components/tools/form/plugin/PluginFormFacade'
+import objectTypes from '@src/js/common/consts/objectType.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+jest.mock('@src/js/components/tools/form/plugin/PluginFormFacade')
+
+export default class PluginFormComponentTest extends ComponentTest {
+  static SUITE = 'PluginFormComponent'
+
+  constructor() {
+    super(
+      object => <PluginForm object={object} controller={this.controller} />,
+      wrapper => new PluginFormWrapper(wrapper)
+    )
+    this.facade = null
+    this.controller = null
+  }
+
+  async beforeEach() {
+    super.beforeEach()
+
+    this.facade = new PluginFormFacade()
+    this.controller = new PluginFormController(this.facade)
+  }
+
+  async mountNew(pluginType) {
+    if (pluginType === openbis.PluginType.DYNAMIC_PROPERTY) {
+      return await this.mount({
+        type: objectTypes.NEW_DYNAMIC_PROPERTY_PLUGIN
+      })
+    } else if (pluginType === openbis.PluginType.ENTITY_VALIDATION) {
+      return await this.mount({
+        type: objectTypes.NEW_ENTITY_VALIDATION_PLUGIN
+      })
+    } else {
+      throw Error('Unsupported plugin type: ' + pluginType)
+    }
+  }
+
+  async mountExisting(plugin) {
+    this.facade.loadPlugin.mockReturnValue(Promise.resolve(plugin))
+
+    if (plugin.pluginType === openbis.PluginType.DYNAMIC_PROPERTY) {
+      return await this.mount({
+        id: plugin.getName(),
+        type: objectTypes.DYNAMIC_PROPERTY_PLUGIN
+      })
+    } else if (plugin.pluginType === openbis.PluginType.ENTITY_VALIDATION) {
+      return await this.mount({
+        id: plugin.getName(),
+        type: objectTypes.ENTITY_VALIDATION_PLUGIN
+      })
+    } else {
+      throw Error('Unsupported plugin type: ' + plugin.pluginType)
+    }
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentValidate.test.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentValidate.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..825d2c77c60d917cbf8babe476993fa51bdeddb9
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormComponentValidate.test.js
@@ -0,0 +1,68 @@
+import PluginFormComponentTest from '@srcTest/js/components/tools/form/plugin/PluginFormComponentTest.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new PluginFormComponentTest()
+  common.beforeEach()
+})
+
+describe(PluginFormComponentTest.SUITE, () => {
+  test('validate DYNAMIC_PROPERTY', async () => {
+    await testValidate(openbis.PluginType.DYNAMIC_PROPERTY)
+  })
+  test('validate ENTITY_VALIDATION', async () => {
+    await testValidate(openbis.PluginType.ENTITY_VALIDATION)
+  })
+})
+
+async function testValidate(pluginType) {
+  const form = await common.mountNew(pluginType)
+
+  form.getButtons().getSave().click()
+  await form.update()
+
+  form.expectJSON({
+    script: {
+      title: 'Script',
+      script: {
+        label: 'Script',
+        value: null,
+        error: 'Script cannot be empty',
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    parameters: {
+      title: 'Plugin',
+      name: {
+        label: 'Name',
+        value: null,
+        error: 'Name cannot be empty',
+        enabled: true,
+        mode: 'edit'
+      },
+      entityKind: {
+        label: 'Entity Kind',
+        value: null,
+        enabled: true,
+        mode: 'edit'
+      },
+      description: {
+        label: 'Description',
+        value: null,
+        enabled: true,
+        mode: 'edit'
+      }
+    },
+    buttons: {
+      save: {
+        enabled: true
+      },
+      edit: null,
+      cancel: null,
+      message: null
+    }
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormTestData.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormTestData.js
new file mode 100644
index 0000000000000000000000000000000000000000..5b28a31b8fd980999d283ff2b725f032022a6973
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/PluginFormTestData.js
@@ -0,0 +1,69 @@
+import openbis from '@srcTest/js/services/openbis.js'
+
+const testDynamicPropertyJythonPlugin = new openbis.Plugin()
+testDynamicPropertyJythonPlugin.setName('TEST_DYNAMIC_PROPERTY_JYTHON')
+testDynamicPropertyJythonPlugin.setPluginKind(openbis.PluginKind.JYTHON)
+testDynamicPropertyJythonPlugin.setPluginType(
+  openbis.PluginType.DYNAMIC_PROPERTY
+)
+testDynamicPropertyJythonPlugin.setEntityKinds([openbis.EntityKind.SAMPLE])
+testDynamicPropertyJythonPlugin.setDescription(
+  'Description of TEST_DYNAMIC_PROPERTY_JYTHON'
+)
+testDynamicPropertyJythonPlugin.setScript('def calculate():\n  return "abc"')
+
+const testDynamicPropertyPredeployedPlugin = new openbis.Plugin()
+testDynamicPropertyPredeployedPlugin.setName(
+  'TEST_DYNAMIC_PROPERTY_PREDEPLOYED'
+)
+testDynamicPropertyPredeployedPlugin.setPluginKind(
+  openbis.PluginKind.PREDEPLOYED
+)
+testDynamicPropertyPredeployedPlugin.setPluginType(
+  openbis.PluginType.DYNAMIC_PROPERTY
+)
+testDynamicPropertyPredeployedPlugin.setEntityKinds([
+  openbis.EntityKind.EXPERIMENT
+])
+testDynamicPropertyPredeployedPlugin.setDescription(
+  'Description of TEST_DYNAMIC_PROPERTY_PREDEPLOYED'
+)
+
+const testEntityValidationJythonPlugin = new openbis.Plugin()
+testEntityValidationJythonPlugin.setName('TEST_ENTITY_VALIDATION_JYTHON')
+testEntityValidationJythonPlugin.setPluginKind(openbis.PluginKind.JYTHON)
+testEntityValidationJythonPlugin.setPluginType(
+  openbis.PluginType.ENTITY_VALIDATION
+)
+testEntityValidationJythonPlugin.setEntityKinds([openbis.EntityKind.DATA_SET])
+testEntityValidationJythonPlugin.setDescription(
+  'Description of TEST_ENTITY_VALIDATION_JYTHON'
+)
+testEntityValidationJythonPlugin.setScript('def validate():\n  return True')
+
+const testEntityValidationPredeployedPlugin = new openbis.Plugin()
+testEntityValidationPredeployedPlugin.setName(
+  'TEST_ENTITY_VALIDATION_PREDEPLOYED'
+)
+testEntityValidationPredeployedPlugin.setPluginKind(
+  openbis.PluginKind.PREDEPLOYED
+)
+testEntityValidationPredeployedPlugin.setPluginType(
+  openbis.PluginType.ENTITY_VALIDATION
+)
+testEntityValidationPredeployedPlugin.setEntityKinds([
+  openbis.EntityKind.SAMPLE,
+  openbis.EntityKind.EXPERIMENT,
+  openbis.EntityKind.DATA_SET,
+  openbis.EntityKind.MATERIAL
+])
+testEntityValidationPredeployedPlugin.setDescription(
+  'Description of TEST_ENTITY_VALIDATION_PREDEPLOYED'
+)
+
+export default {
+  testDynamicPropertyJythonPlugin,
+  testDynamicPropertyPredeployedPlugin,
+  testEntityValidationJythonPlugin,
+  testEntityValidationPredeployedPlugin
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormButtonsWrapper.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormButtonsWrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..6c87b0778492bdb2f715c5c26525b4ea3f77cfb9
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormButtonsWrapper.js
@@ -0,0 +1,3 @@
+import PageButtonsWrapper from '@srcTest/js/components/common/page/wrapper/PageButtonsWrapper.js'
+
+export default class PluginFormButtonsWrapper extends PageButtonsWrapper {}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormParametersWrapper.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormParametersWrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..fbfeca42bda384bcde31ead5f8e6b9e3a646ec54
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormParametersWrapper.js
@@ -0,0 +1,34 @@
+import TextField from '@src/js/components/common/form/TextField.jsx'
+import TextFieldWrapper from '@srcTest/js/components/common/form/wrapper/TextFieldWrapper.js'
+import SelectField from '@src/js/components/common/form/SelectField.jsx'
+import SelectFieldWrapper from '@srcTest/js/components/common/form/wrapper/SelectFieldWrapper.js'
+import PageParametersPanelWrapper from '@srcTest/js/components/common/page/wrapper/PageParametersPanelWrapper.js'
+
+export default class PluginFormParametersWrapper extends PageParametersPanelWrapper {
+  getName() {
+    return new TextFieldWrapper(
+      this.findComponent(TextField).filter({ name: 'name' })
+    )
+  }
+
+  getEntityKind() {
+    return new SelectFieldWrapper(
+      this.findComponent(SelectField).filter({ name: 'entityKind' })
+    )
+  }
+
+  getDescription() {
+    return new TextFieldWrapper(
+      this.findComponent(TextField).filter({ name: 'description' })
+    )
+  }
+
+  toJSON() {
+    return {
+      ...super.toJSON(),
+      name: this.getName().toJSON(),
+      entityKind: this.getEntityKind().toJSON(),
+      description: this.getDescription().toJSON()
+    }
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormScriptWrapper.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormScriptWrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..e81f186fefce3d5cd5ecb7e410ef84d1f62f253c
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormScriptWrapper.js
@@ -0,0 +1,25 @@
+import BaseWrapper from '@srcTest/js/components/common/wrapper/BaseWrapper.js'
+import Header from '@src/js/components/common/form/Header.jsx'
+import SourceCodeField from '@src/js/components/common/form/SourceCodeField.jsx'
+import SourceCodeFieldWrapper from '@srcTest/js/components/common/form/wrapper/SourceCodeFieldWrapper.js'
+
+export default class PluginFormScriptWrapper extends BaseWrapper {
+  getTitle() {
+    return this.findComponent(Header)
+  }
+
+  getScript() {
+    return new SourceCodeFieldWrapper(this.findComponent(SourceCodeField))
+  }
+
+  toJSON() {
+    if (this.wrapper.exists()) {
+      return {
+        title: this.getTitle().exists() ? this.getTitle().text() : null,
+        script: this.getScript().toJSON()
+      }
+    } else {
+      return null
+    }
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormWrapper.js b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormWrapper.js
new file mode 100644
index 0000000000000000000000000000000000000000..74f2dc9e7eedcbf283cb7a2311dcb701e3ea493f
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/form/plugin/wrapper/PluginFormWrapper.js
@@ -0,0 +1,31 @@
+import BaseWrapper from '@srcTest/js/components/common/wrapper/BaseWrapper.js'
+import PluginFormScript from '@src/js/components/tools/form/plugin/PluginFormScript.jsx'
+import PluginFormScriptWrapper from '@srcTest/js/components/tools/form/plugin/wrapper/PluginFormScriptWrapper.js'
+import PluginFormParameters from '@src/js/components/tools/form/plugin/PluginFormParameters.jsx'
+import PluginFormParametersWrapper from '@srcTest/js/components/tools/form/plugin/wrapper/PluginFormParametersWrapper.js'
+import PluginFormButtons from '@src/js/components/tools/form/plugin/PluginFormButtons.jsx'
+import PluginFormButtonsWrapper from '@srcTest/js/components/tools/form/plugin/wrapper/PluginFormButtonsWrapper.js'
+
+export default class PluginFormWrapper extends BaseWrapper {
+  getScript() {
+    return new PluginFormScriptWrapper(this.findComponent(PluginFormScript))
+  }
+
+  getParameters() {
+    return new PluginFormParametersWrapper(
+      this.findComponent(PluginFormParameters)
+    )
+  }
+
+  getButtons() {
+    return new PluginFormButtonsWrapper(this.findComponent(PluginFormButtons))
+  }
+
+  toJSON() {
+    return {
+      script: this.getScript().toJSON(),
+      parameters: this.getParameters().toJSON(),
+      buttons: this.getButtons().toJSON()
+    }
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/mockStyles.js b/openbis_ng_ui/srcTest/js/mockStyles.js
new file mode 100644
index 0000000000000000000000000000000000000000..2c6a41afe332fcb44ff59d34f1841231a50ff084
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/mockStyles.js
@@ -0,0 +1 @@
+// styles are not needed for the tests