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/package.json b/openbis_ng_ui/package.json
index 41eb6a858624e65777bc677bf9dbeae70d4140e0..91916e3b1acb7a67703a0fa7cd6a9f04b04bbe06 100644
--- a/openbis_ng_ui/package.json
+++ b/openbis_ng_ui/package.json
@@ -12,12 +12,15 @@
     "install": "^0.13.0",
     "npm": "^6.14.8",
     "path-to-regexp": "^6.1.0",
+    "prism-themes": "^1.5.0",
+    "prismjs": "^1.22.0",
     "prop-types": "^15.7.2",
     "re-resizable": "^6.5.4",
     "react": "^16.13.1",
     "react-beautiful-dnd": "^13.0.0",
     "react-dom": "^16.13.1",
     "react-redux": "^7.2.1",
+    "react-simple-code-editor": "^0.11.0",
     "redux": "^4.0.5",
     "redux-saga": "^1.1.3",
     "reselect": "^4.0.0",
diff --git a/openbis_ng_ui/src/js/common/consts/objectType.js b/openbis_ng_ui/src/js/common/consts/objectType.js
index 130269b01f323e3233800e38c7cbca47aa0b44f4..08e9d6b2fb863171f4e37efdd59a04be241f24cd 100644
--- a/openbis_ng_ui/src/js/common/consts/objectType.js
+++ b/openbis_ng_ui/src/js/common/consts/objectType.js
@@ -5,6 +5,8 @@ const NEW_MATERIAL_TYPE = 'newMaterialType'
 const NEW_VOCABULARY_TYPE = 'newVocabularyType'
 const NEW_USER = 'newUser'
 const NEW_USER_GROUP = 'newUserGroup'
+const NEW_DYNAMIC_PROPERTY_PLUGIN = 'newDynamicPropertyPlugin'
+const NEW_ENTITY_VALIDATION_PLUGIN = 'newEntityValidationPlugin'
 
 const OBJECT_TYPE = 'objectType'
 const COLLECTION_TYPE = 'collectionType'
@@ -13,6 +15,8 @@ const MATERIAL_TYPE = 'materialType'
 const VOCABULARY_TYPE = 'vocabularyType'
 const USER = 'user'
 const USER_GROUP = 'userGroup'
+const DYNAMIC_PROPERTY_PLUGIN = 'dynamicPropertyPlugin'
+const ENTITY_VALIDATION_PLUGIN = 'entityValidationPlugin'
 
 const SEARCH = 'search'
 
@@ -24,6 +28,8 @@ export default {
   NEW_VOCABULARY_TYPE,
   NEW_USER,
   NEW_USER_GROUP,
+  NEW_DYNAMIC_PROPERTY_PLUGIN,
+  NEW_ENTITY_VALIDATION_PLUGIN,
   OBJECT_TYPE,
   COLLECTION_TYPE,
   DATA_SET_TYPE,
@@ -31,5 +37,7 @@ export default {
   VOCABULARY_TYPE,
   USER,
   USER_GROUP,
+  DYNAMIC_PROPERTY_PLUGIN,
+  ENTITY_VALIDATION_PLUGIN,
   SEARCH
 }
diff --git a/openbis_ng_ui/src/js/common/consts/pages.js b/openbis_ng_ui/src/js/common/consts/pages.js
index 11578ee6d87219f62aed5e32c7a38305536ed0fc..07c09cbe73356c6d7c8cc0886c70f9b492331f84 100644
--- a/openbis_ng_ui/src/js/common/consts/pages.js
+++ b/openbis_ng_ui/src/js/common/consts/pages.js
@@ -1,9 +1,11 @@
 const LOGIN = 'login'
 const TYPES = 'types'
 const USERS = 'users'
+const TOOLS = 'tools'
 
 export default {
   LOGIN,
   TYPES,
-  USERS
+  USERS,
+  TOOLS
 }
diff --git a/openbis_ng_ui/src/js/common/consts/routes.js b/openbis_ng_ui/src/js/common/consts/routes.js
index 749aad7db45613efbe6f119329c5709bf5c7728f..cf8d30307b3202dca8c6643de4721e1fdb81b0fc 100644
--- a/openbis_ng_ui/src/js/common/consts/routes.js
+++ b/openbis_ng_ui/src/js/common/consts/routes.js
@@ -3,306 +3,160 @@ import { compile, match } from 'path-to-regexp'
 import pages from '@src/js/common/consts/pages.js'
 import objectTypes from '@src/js/common/consts/objectType.js'
 
-function doFormat(actualParams, pattern, requiredParams) {
-  if (_.isMatch(actualParams, requiredParams)) {
-    let toPath = compile(pattern, { encode: encodeURIComponent })
-    return {
-      path: toPath(actualParams),
-      match: Object.keys(requiredParams).length
+class Route {
+  constructor(pattern, params) {
+    this.pattern = pattern
+    this.params = params
+  }
+
+  format(params) {
+    if (_.isMatch(params, this.params)) {
+      let toPath = compile(this.pattern, { encode: encodeURIComponent })
+      return {
+        path: toPath(params),
+        specificity: Object.keys(this.params).length
+      }
+    } else {
+      return null
     }
-  } else {
-    return null
   }
-}
 
-function doParse(path, pattern, extraParams) {
-  let toPathObject = match(pattern, { decode: decodeURIComponent })
-  let pathObject = toPathObject(path)
-  if (pathObject) {
-    return {
-      path: pathObject.path,
-      ...pathObject.params,
-      ...extraParams
+  parse(path) {
+    let toPathObject = match(this.pattern, { decode: decodeURIComponent })
+    let pathObject = toPathObject(path)
+    if (pathObject) {
+      return {
+        path: pathObject.path,
+        ...pathObject.params,
+        ...this.params
+      }
+    } else {
+      return null
     }
-  } else {
-    return null
   }
 }
 
-const routes = {
-  TYPES: {
-    format: params => {
-      return doFormat(params, '/types', {
-        page: pages.TYPES
-      })
-    },
-    parse: path => {
-      return doParse(path, '/types', {
-        page: pages.TYPES
-      })
-    }
-  },
-  TYPES_SEARCH: {
-    format: params => {
-      return doFormat(params, '/types-search/:id', {
-        page: pages.TYPES,
-        type: objectTypes.SEARCH
-      })
-    },
-    parse: path => {
-      return doParse(path, '/types-search/:id', {
-        page: pages.TYPES,
-        type: objectTypes.SEARCH
-      })
-    }
-  },
-  NEW_OBJECT_TYPE: {
-    format: params => {
-      return doFormat(params, '/new-object-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_OBJECT_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-object-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_OBJECT_TYPE
-      })
-    }
-  },
-  OBJECT_TYPE: {
-    format: params => {
-      return doFormat(params, '/object-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.OBJECT_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/object-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.OBJECT_TYPE
-      })
-    }
-  },
-  NEW_COLLECTION_TYPE: {
-    format: params => {
-      return doFormat(params, '/new-collection-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_COLLECTION_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-collection-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_COLLECTION_TYPE
-      })
-    }
-  },
-  COLLECTION_TYPE: {
-    format: params => {
-      return doFormat(params, '/collection-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.COLLECTION_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/collection-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.COLLECTION_TYPE
-      })
-    }
-  },
-  NEW_DATA_SET_TYPE: {
-    format: params => {
-      return doFormat(params, '/new-dataset-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_DATA_SET_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-dataset-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_DATA_SET_TYPE
-      })
-    }
-  },
-  DATA_SET_TYPE: {
-    format: params => {
-      return doFormat(params, '/dataset-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.DATA_SET_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/dataset-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.DATA_SET_TYPE
-      })
-    }
-  },
-  NEW_MATERIAL_TYPE: {
-    format: params => {
-      return doFormat(params, '/new-material-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_MATERIAL_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-material-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_MATERIAL_TYPE
-      })
-    }
-  },
-  MATERIAL_TYPE: {
-    format: params => {
-      return doFormat(params, '/material-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.MATERIAL_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/material-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.MATERIAL_TYPE
-      })
-    }
-  },
-  NEW_VOCABULARY_TYPE: {
-    format: params => {
-      return doFormat(params, '/new-vocabulary-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_VOCABULARY_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-vocabulary-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.NEW_VOCABULARY_TYPE
-      })
-    }
-  },
-  VOCABULARY_TYPE: {
-    format: params => {
-      return doFormat(params, '/vocabulary-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.VOCABULARY_TYPE
-      })
-    },
-    parse: path => {
-      return doParse(path, '/vocabulary-type/:id', {
-        page: pages.TYPES,
-        type: objectTypes.VOCABULARY_TYPE
-      })
-    }
-  },
-  USERS: {
-    format: params => {
-      return doFormat(params, '/users', {
-        page: pages.USERS
-      })
-    },
-    parse: path => {
-      return doParse(path, '/users', {
-        page: pages.USERS
-      })
-    }
-  },
-  USERS_SEARCH: {
-    format: params => {
-      return doFormat(params, '/users-search/:id', {
-        page: pages.USERS,
-        type: objectTypes.SEARCH
-      })
-    },
-    parse: path => {
-      return doParse(path, '/users-search/:id', {
-        page: pages.USERS,
-        type: objectTypes.SEARCH
-      })
-    }
-  },
-  NEW_USER: {
-    format: params => {
-      return doFormat(params, '/new-user/:id', {
-        page: pages.USERS,
-        type: objectTypes.NEW_USER
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-user/:id', {
-        page: pages.USERS,
-        type: objectTypes.NEW_USER
-      })
-    }
-  },
-  USER: {
-    format: params => {
-      return doFormat(params, '/user/:id', {
-        page: pages.USERS,
-        type: objectTypes.USER
-      })
-    },
-    parse: path => {
-      return doParse(path, '/user/:id', {
-        page: pages.USERS,
-        type: objectTypes.USER
-      })
-    }
-  },
-  NEW_USER_GROUP: {
-    format: params => {
-      return doFormat(params, '/new-user-group/:id', {
-        page: pages.USERS,
-        type: objectTypes.NEW_USER_GROUP
-      })
-    },
-    parse: path => {
-      return doParse(path, '/new-user-group/:id', {
-        page: pages.USERS,
-        type: objectTypes.NEW_USER_GROUP
-      })
-    }
-  },
-  USER_GROUP: {
-    format: params => {
-      return doFormat(params, '/user-group/:id', {
-        page: pages.USERS,
-        type: objectTypes.USER_GROUP
-      })
-    },
-    parse: path => {
-      return doParse(path, '/user-group/:id', {
-        page: pages.USERS,
-        type: objectTypes.USER_GROUP
-      })
+class DefaultRoute {
+  format() {
+    return {
+      path: '/',
+      specificity: 0
     }
-  },
-  DEFAULT: {
-    format: () => {
-      return {
-        path: '/',
-        match: 0
-      }
-    },
-    parse: () => {
-      return {
-        path: '/',
-        page: pages.TYPES
-      }
+  }
+  parse() {
+    return {
+      path: '/',
+      page: pages.TYPES
     }
   }
 }
 
+const routes = {
+  TYPES: new Route('/types', {
+    page: pages.TYPES
+  }),
+  TYPES_SEARCH: new Route('/types-search/:id', {
+    page: pages.TYPES,
+    type: objectTypes.SEARCH
+  }),
+  NEW_OBJECT_TYPE: new Route('/new-object-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.NEW_OBJECT_TYPE
+  }),
+  OBJECT_TYPE: new Route('/object-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.OBJECT_TYPE
+  }),
+  NEW_COLLECTION_TYPE: new Route('/new-collection-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.NEW_COLLECTION_TYPE
+  }),
+  COLLECTION_TYPE: new Route('/collection-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.COLLECTION_TYPE
+  }),
+  NEW_DATA_SET_TYPE: new Route('/new-dataset-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.NEW_DATA_SET_TYPE
+  }),
+  DATA_SET_TYPE: new Route('/dataset-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.DATA_SET_TYPE
+  }),
+  NEW_MATERIAL_TYPE: new Route('/new-material-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.NEW_MATERIAL_TYPE
+  }),
+  MATERIAL_TYPE: new Route('/material-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.MATERIAL_TYPE
+  }),
+  NEW_VOCABULARY_TYPE: new Route('/new-vocabulary-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.NEW_VOCABULARY_TYPE
+  }),
+  VOCABULARY_TYPE: new Route('/vocabulary-type/:id', {
+    page: pages.TYPES,
+    type: objectTypes.VOCABULARY_TYPE
+  }),
+  USERS: new Route('/users', {
+    page: pages.USERS
+  }),
+  USERS_SEARCH: new Route('/users-search/:id', {
+    page: pages.USERS,
+    type: objectTypes.SEARCH
+  }),
+  NEW_USER: new Route('/new-user/:id', {
+    page: pages.USERS,
+    type: objectTypes.NEW_USER
+  }),
+  USER: new Route('/user/:id', {
+    page: pages.USERS,
+    type: objectTypes.USER
+  }),
+  NEW_USER_GROUP: new Route('/new-user-group/:id', {
+    page: pages.USERS,
+    type: objectTypes.NEW_USER_GROUP
+  }),
+  USER_GROUP: new Route('/user-group/:id', {
+    page: pages.USERS,
+    type: objectTypes.USER_GROUP
+  }),
+  TOOLS: new Route('/tools', {
+    page: pages.TOOLS
+  }),
+  TOOLS_SEARCH: new Route('/tools-search/:id', {
+    page: pages.TOOLS,
+    type: objectTypes.SEARCH
+  }),
+  NEW_DYNAMIC_PROPERTY_PLUGIN: new Route('/new-dynamic-property-plugin/:id', {
+    page: pages.TOOLS,
+    type: objectTypes.NEW_DYNAMIC_PROPERTY_PLUGIN
+  }),
+  DYNAMIC_PROPERTY_PLUGIN: new Route('/dynamic-property-plugin/:id', {
+    page: pages.TOOLS,
+    type: objectTypes.DYNAMIC_PROPERTY_PLUGIN
+  }),
+  NEW_ENTITY_VALIDATION_PLUGIN: new Route('/new-entity-validation-plugin/:id', {
+    page: pages.TOOLS,
+    type: objectTypes.NEW_ENTITY_VALIDATION_PLUGIN
+  }),
+  ENTITY_VALIDATION_PLUGIN: new Route('/entity-validation-plugin/:id', {
+    page: pages.TOOLS,
+    type: objectTypes.ENTITY_VALIDATION_PLUGIN
+  }),
+  DEFAULT: new DefaultRoute()
+}
+
 function format(params) {
   let keys = Object.keys(routes)
-  let best = { match: 0, path: null }
+  let best = { specificity: 0, path: null }
 
   for (let i = 0; i < keys.length; i++) {
     let route = routes[keys[i]]
     try {
       let result = route.format(params)
-      if (result && result.match > best.match) {
+      if (result && result.specificity > best.specificity) {
         best = result
       }
     } catch (err) {
diff --git a/openbis_ng_ui/src/js/components/App.jsx b/openbis_ng_ui/src/js/components/App.jsx
index 84495be31ee09d5ec512294e6536a0c378cd43d9..ae970d996d40ee02e4e505e31dc5f331dab32700 100644
--- a/openbis_ng_ui/src/js/components/App.jsx
+++ b/openbis_ng_ui/src/js/components/App.jsx
@@ -15,6 +15,7 @@ import Menu from '@src/js/components/common/menu/Menu.jsx'
 import Login from '@src/js/components/login/Login.jsx'
 import Users from '@src/js/components/users/Users.jsx'
 import Types from '@src/js/components/types/Types.jsx'
+import Tools from '@src/js/components/tools/Tools.jsx'
 
 const styles = {
   container: {
@@ -37,7 +38,8 @@ const styles = {
 
 const pageToComponent = {
   [pages.TYPES]: Types,
-  [pages.USERS]: Users
+  [pages.USERS]: Users,
+  [pages.TOOLS]: Tools
 }
 
 function mapStateToProps(state) {
diff --git a/openbis_ng_ui/src/js/components/common/form/FormFieldView.jsx b/openbis_ng_ui/src/js/components/common/form/FormFieldView.jsx
index 8632fc6f8b49fd303844d35f060df72416036467..b8cd02874c79e827f84584ea9a0742664706acfb 100644
--- a/openbis_ng_ui/src/js/components/common/form/FormFieldView.jsx
+++ b/openbis_ng_ui/src/js/components/common/form/FormFieldView.jsx
@@ -11,10 +11,7 @@ const styles = theme => ({
     paddingBottom: theme.spacing(1) / 2,
     borderBottomWidth: '1px',
     borderBottomStyle: 'solid',
-    borderBottomColor: theme.palette.border.secondary,
-    '&:after': {
-      content: '"\\00a0"'
-    }
+    borderBottomColor: theme.palette.border.secondary
   }
 })
 
@@ -23,11 +20,11 @@ class FormFieldView extends React.PureComponent {
     const { label, value, classes } = this.props
     return (
       <div>
-        <Typography variant='body2' className={classes.label}>
+        <Typography variant='body2' component='div' className={classes.label}>
           {label}
         </Typography>
-        <Typography variant='body2' className={classes.value}>
-          {value ? value : ''}
+        <Typography variant='body2' component='div' className={classes.value}>
+          {value ? value : <span>&nbsp;</span>}
         </Typography>
       </div>
     )
diff --git a/openbis_ng_ui/src/js/components/common/form/SourceCodeField.jsx b/openbis_ng_ui/src/js/components/common/form/SourceCodeField.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..28e510b82787fd7ed7ff71747f49ea2aea78209e
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/common/form/SourceCodeField.jsx
@@ -0,0 +1,202 @@
+import React from 'react'
+import autoBind from 'auto-bind'
+import { withStyles } from '@material-ui/core/styles'
+import { highlight, languages } from 'prismjs/components/prism-core.js'
+import 'prismjs/components/prism-clike.js'
+import 'prismjs/components/prism-python.js'
+import 'prismjs/themes/prism.css'
+import Editor from 'react-simple-code-editor'
+import InputLabel from '@material-ui/core/InputLabel'
+import FormFieldLabel from '@src/js/components/common/form/FormFieldLabel.jsx'
+import FormFieldContainer from '@src/js/components/common/form/FormFieldContainer.jsx'
+import FormFieldView from '@src/js/components/common/form/FormFieldView.jsx'
+import logger from '@src/js/common/logger.js'
+
+const styles = theme => ({
+  view: {
+    fontFamily: theme.typography.sourceCode.fontFamily,
+    fontSize: theme.typography.body2.fontSize,
+    whiteSpace: 'pre-wrap',
+    tabSize: 4
+  },
+
+  edit: {
+    fontFamily: theme.typography.sourceCode.fontFamily,
+    fontSize: theme.typography.body2.fontSize,
+    lineHeight: theme.typography.body2.lineHeight,
+    backgroundColor: theme.palette.background.field,
+    tabSize: 4,
+    '& *': {
+      background: 'none !important'
+    },
+    '& textarea': {
+      padding: '23px 12px 6px 12px !important',
+      border: `1px solid ${theme.palette.border.primary} !important`,
+      borderBottom: `1px solid ${theme.palette.border.field} !important`,
+      outline: 'none !important'
+    },
+    '& textarea:focus': {
+      borderBottom: `2px solid ${theme.palette.primary.main} !important`
+    },
+    '& pre': {
+      padding: '23px 12px 6px 12px !important'
+    }
+  },
+  error: {
+    '&$edit textarea': {
+      borderBottom: `2px solid ${theme.palette.error.main} !important`
+    }
+  },
+  disabled: {
+    '&$edit pre': {
+      opacity: 0.5
+    }
+  }
+})
+
+class SourceCodeField extends React.PureComponent {
+  static defaultProps = {
+    mode: 'edit',
+    variant: 'filled'
+  }
+
+  constructor(props) {
+    super(props)
+    autoBind(this)
+    this.state = { focused: false }
+    this.containerRef = React.createRef()
+  }
+
+  handleValueChange(value) {
+    const { name, onChange } = this.props
+    if (onChange) {
+      onChange({
+        target: {
+          name,
+          value
+        }
+      })
+    }
+  }
+
+  handleFocus() {
+    this.setState({
+      focused: true
+    })
+
+    const { onFocus } = this.props
+
+    if (onFocus) {
+      onFocus(event)
+    }
+  }
+
+  handleBlur(event) {
+    this.setState({
+      focused: false
+    })
+
+    const { onBlur } = this.props
+
+    if (onBlur) {
+      onBlur(event)
+    }
+  }
+
+  componentDidUpdate() {
+    const { reference } = this.props
+    if (reference) {
+      const containerElement = this.containerRef.current
+      if (containerElement) {
+        reference.current = containerElement.querySelector('textarea')
+      }
+    }
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'SourceCodeField.render')
+
+    const { mode } = this.props
+
+    if (mode === 'view') {
+      return this.renderView()
+    } else if (mode === 'edit') {
+      return this.renderEdit()
+    } else {
+      throw 'Unsupported mode: ' + mode
+    }
+  }
+
+  renderView() {
+    const { label, value, classes } = this.props
+    const html = { __html: highlight(value || '', languages.python) }
+
+    return (
+      <FormFieldView
+        label={label}
+        value={<div className={classes.view} dangerouslySetInnerHTML={html} />}
+      />
+    )
+  }
+
+  renderEdit() {
+    const {
+      name,
+      label,
+      value,
+      description,
+      mandatory,
+      disabled,
+      error,
+      variant,
+      onClick,
+      styles,
+      classes
+    } = this.props
+
+    const { focused } = this.state
+
+    return (
+      <FormFieldContainer
+        description={description}
+        error={error}
+        styles={styles}
+        onClick={onClick}
+      >
+        <div
+          ref={this.containerRef}
+          className={`
+            ${classes.edit} 
+            ${error ? classes.error : ''} 
+            ${disabled ? classes.disabled : ''}
+          `}
+        >
+          <InputLabel
+            shrink={!!value || focused}
+            error={!!error}
+            variant={variant}
+            margin='dense'
+          >
+            <FormFieldLabel
+              label={label}
+              mandatory={mandatory}
+              styles={styles}
+              onClick={onClick}
+            />
+          </InputLabel>
+          <Editor
+            name={name}
+            value={value || ''}
+            highlight={code => highlight(code, languages.python)}
+            disabled={disabled}
+            onValueChange={this.handleValueChange}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </div>
+      </FormFieldContainer>
+    )
+  }
+}
+
+export default withStyles(styles)(SourceCodeField)
diff --git a/openbis_ng_ui/src/js/components/common/menu/Menu.jsx b/openbis_ng_ui/src/js/components/common/menu/Menu.jsx
index 1da0c2b3724a8254e065847a015b599f5cc8b850..7902417a643d9b055ee68a3a8557bf600112ea01 100644
--- a/openbis_ng_ui/src/js/components/common/menu/Menu.jsx
+++ b/openbis_ng_ui/src/js/components/common/menu/Menu.jsx
@@ -113,6 +113,7 @@ class Menu extends React.Component {
           >
             <Tab value={pages.TYPES} label='Types' />
             <Tab value={pages.USERS} label='Users' />
+            <Tab value={pages.TOOLS} label='Tools' />
           </Tabs>
           <TextField
             placeholder='Search...'
diff --git a/openbis_ng_ui/src/js/components/common/theme/ThemeProvider.jsx b/openbis_ng_ui/src/js/components/common/theme/ThemeProvider.jsx
index f67e2f45d72f35a08890e3486b9b5b7c3bc1643f..2088b09777b35f5d27fab0d066a8e3eb5af4e489 100644
--- a/openbis_ng_ui/src/js/components/common/theme/ThemeProvider.jsx
+++ b/openbis_ng_ui/src/js/components/common/theme/ThemeProvider.jsx
@@ -9,6 +9,9 @@ const config = {
     label: {
       fontSize: '0.7rem',
       color: '#0000008a'
+    },
+    sourceCode: {
+      fontFamily: '"Fira code", "Fira Mono", monospace'
     }
   },
   palette: {
@@ -29,11 +32,13 @@ const config = {
     },
     background: {
       primary: '#ebebeb',
-      secondary: '#f5f5f5'
+      secondary: '#f5f5f5',
+      field: '#e8e8e8'
     },
     border: {
       primary: '#dbdbdb',
-      secondary: '#ebebeb'
+      secondary: '#ebebeb',
+      field: '#878787'
     }
   }
 }
diff --git a/openbis_ng_ui/src/js/components/tools/Tools.jsx b/openbis_ng_ui/src/js/components/tools/Tools.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ea4477897b883a58b349a0a9cbbd5bcda5c31d48
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/Tools.jsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import { withStyles } from '@material-ui/core/styles'
+import logger from '@src/js/common/logger.js'
+import pages from '@src/js/common/consts/pages.js'
+import objectType from '@src/js/common/consts/objectType.js'
+import Content from '@src/js/components/common/content/Content.jsx'
+import ContentTab from '@src/js/components/common/content/ContentTab.jsx'
+import ToolBrowser from '@src/js/components/tools/browser/ToolBrowser.jsx'
+import ToolSearch from '@src/js/components/tools/search/ToolSearch.jsx'
+import PluginForm from '@src/js/components/tools/form/plugin/PluginForm.jsx'
+
+const styles = () => ({
+  container: {
+    display: 'flex',
+    width: '100%'
+  }
+})
+
+class Tools extends React.Component {
+  render() {
+    logger.log(logger.DEBUG, 'Tools.render')
+
+    const classes = this.props.classes
+
+    return (
+      <div className={classes.container}>
+        <ToolBrowser />
+        <Content
+          page={pages.TOOLS}
+          renderComponent={this.renderComponent}
+          renderTab={this.renderTab}
+        />
+      </div>
+    )
+  }
+
+  renderComponent(tab) {
+    const { object } = tab
+    if (
+      object.type === objectType.NEW_DYNAMIC_PROPERTY_PLUGIN ||
+      object.type === objectType.NEW_ENTITY_VALIDATION_PLUGIN ||
+      object.type === objectType.DYNAMIC_PROPERTY_PLUGIN ||
+      object.type === objectType.ENTITY_VALIDATION_PLUGIN
+    ) {
+      return <PluginForm object={object} />
+    } else if (object.type === objectType.SEARCH) {
+      return <ToolSearch objectId={object.id} />
+    }
+  }
+
+  renderTab(tab) {
+    const { object } = tab
+
+    const prefixes = {
+      [objectType.DYNAMIC_PROPERTY_PLUGIN]: 'Dynamic Property Plugin: ',
+      [objectType.NEW_DYNAMIC_PROPERTY_PLUGIN]: 'New Dynamic Property Plugin ',
+      [objectType.ENTITY_VALIDATION_PLUGIN]: 'Entity Validation Plugin: ',
+      [objectType.NEW_ENTITY_VALIDATION_PLUGIN]:
+        'New Entity Validation Plugin ',
+      [objectType.SEARCH]: 'Search: '
+    }
+
+    return <ContentTab prefix={prefixes[object.type]} tab={tab} />
+  }
+}
+
+export default withStyles(styles)(Tools)
diff --git a/openbis_ng_ui/src/js/components/tools/browser/ToolBrowser.jsx b/openbis_ng_ui/src/js/components/tools/browser/ToolBrowser.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f306d4f7cad2df71004085214d5ff9d7a3241419
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/browser/ToolBrowser.jsx
@@ -0,0 +1,18 @@
+import React from 'react'
+import Browser from '@src/js/components/common/browser/Browser.jsx'
+import ToolBrowserController from '@src/js/components/tools/browser/ToolBrowserController.js'
+import logger from '@src/js/common/logger.js'
+
+class ToolBrowser extends React.Component {
+  constructor(props) {
+    super(props)
+    this.controller = this.props.controller || new ToolBrowserController()
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'ToolBrowser.render')
+    return <Browser controller={this.controller} />
+  }
+}
+
+export default ToolBrowser
diff --git a/openbis_ng_ui/src/js/components/tools/browser/ToolBrowserController.js b/openbis_ng_ui/src/js/components/tools/browser/ToolBrowserController.js
new file mode 100644
index 0000000000000000000000000000000000000000..9c9f18b2e7be7eaf7ac75dbe05849550aa824014
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/browser/ToolBrowserController.js
@@ -0,0 +1,141 @@
+import openbis from '@src/js/services/openbis.js'
+import actions from '@src/js/store/actions/actions.js'
+import pages from '@src/js/common/consts/pages.js'
+import objectType from '@src/js/common/consts/objectType.js'
+import objectOperation from '@src/js/common/consts/objectOperation.js'
+import BrowserController from '@src/js/components/common/browser/BrowserController.js'
+
+export default class ToolBrowserController extends BrowserController {
+  doGetPage() {
+    return pages.TOOLS
+  }
+
+  async doLoadNodes() {
+    return Promise.all([
+      openbis.searchPlugins(
+        new openbis.PluginSearchCriteria(),
+        new openbis.PluginFetchOptions()
+      )
+    ]).then(([plugins]) => {
+      const dynamicPropertyPluginNodes = plugins
+        .getObjects()
+        .filter(
+          plugin => plugin.pluginType === openbis.PluginType.DYNAMIC_PROPERTY
+        )
+        .map(plugin => {
+          return {
+            id: `dynamicPropertyPlugin/${plugin.name}`,
+            text: plugin.name,
+            object: {
+              type: objectType.DYNAMIC_PROPERTY_PLUGIN,
+              id: plugin.name
+            },
+            canMatchFilter: true,
+            canRemove: true
+          }
+        })
+
+      const entityValidationPluginNodes = plugins
+        .getObjects()
+        .filter(
+          plugin => plugin.pluginType === openbis.PluginType.ENTITY_VALIDATION
+        )
+        .map(plugin => {
+          return {
+            id: `entityValidationPlugin/${plugin.name}`,
+            text: plugin.name,
+            object: {
+              type: objectType.ENTITY_VALIDATION_PLUGIN,
+              id: plugin.name
+            },
+            canMatchFilter: true,
+            canRemove: true
+          }
+        })
+
+      let nodes = [
+        {
+          id: 'dynamicPropertyPlugins',
+          text: 'Dynamic Property Plugins',
+          children: dynamicPropertyPluginNodes,
+          childrenType: objectType.NEW_DYNAMIC_PROPERTY_PLUGIN,
+          canAdd: true
+        },
+        {
+          id: 'entityValidationPlugins',
+          text: 'Entity Validation Plugins',
+          children: entityValidationPluginNodes,
+          childrenType: objectType.NEW_ENTITY_VALIDATION_PLUGIN,
+          canAdd: true
+        }
+      ]
+
+      return nodes
+    })
+  }
+
+  doNodeAdd(node) {
+    if (node && node.childrenType) {
+      this.context.dispatch(
+        actions.objectNew(this.getPage(), node.childrenType)
+      )
+    }
+  }
+
+  doNodeRemove(node) {
+    if (!node.object) {
+      return Promise.resolve()
+    }
+
+    const { type, id } = node.object
+    const reason = 'deleted via ng_ui'
+
+    return this._prepareRemoveOperations(type, id, reason)
+      .then(operations => {
+        const options = new openbis.SynchronousOperationExecutionOptions()
+        options.setExecuteInOrder(true)
+        return openbis.executeOperations(operations, options)
+      })
+      .then(() => {
+        this.context.dispatch(actions.objectDelete(this.getPage(), type, id))
+      })
+      .catch(error => {
+        this.context.dispatch(actions.errorChange(error))
+      })
+  }
+
+  _prepareRemoveOperations(type, id, reason) {
+    if (
+      type === objectType.DYNAMIC_PROPERTY_PLUGIN ||
+      type === objectType.ENTITY_VALIDATION_PLUGIN
+    ) {
+      return this._prepareRemovePluginOperations(id, reason)
+    } else {
+      throw new Error('Unsupported type: ' + type)
+    }
+  }
+
+  _prepareRemovePluginOperations(id, reason) {
+    const options = new openbis.PluginDeletionOptions()
+    options.setReason(reason)
+    return Promise.resolve([
+      new openbis.DeletePluginsOperation(
+        [new openbis.PluginPermId(id)],
+        options
+      )
+    ])
+  }
+
+  doGetObservedModifications() {
+    return {
+      [objectType.DYNAMIC_PROPERTY_PLUGIN]: [
+        objectOperation.CREATE,
+        objectOperation.DELETE
+      ],
+      [objectType.ENTITY_VALIDATION_PLUGIN]: [
+        objectOperation.CREATE,
+        objectOperation.DELETE
+      ]
+    }
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginForm.jsx b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginForm.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..9ecbede7d4ee1795196bcfd24d64282f68e9e6cd
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginForm.jsx
@@ -0,0 +1,108 @@
+import _ from 'lodash'
+import React from 'react'
+import autoBind from 'auto-bind'
+import { connect } from 'react-redux'
+import { withStyles } from '@material-ui/core/styles'
+import ComponentContext from '@src/js/components/common/ComponentContext.js'
+import PageWithTwoPanels from '@src/js/components/common/page/PageWithTwoPanels.jsx'
+import PluginFormController from '@src/js/components/tools/form/plugin/PluginFormController.js'
+import PluginFormFacade from '@src/js/components/tools/form/plugin/PluginFormFacade.js'
+import PluginFormScript from '@src/js/components/tools/form/plugin/PluginFormScript.jsx'
+import PluginFormParameters from '@src/js/components/tools/form/plugin/PluginFormParameters.jsx'
+import PluginFormButtons from '@src/js/components/tools/form/plugin/PluginFormButtons.jsx'
+import openbis from '@src/js/services/openbis.js'
+import logger from '@src/js/common/logger.js'
+
+const styles = () => ({})
+
+class PluginForm extends React.PureComponent {
+  constructor(props) {
+    super(props)
+    autoBind(this)
+
+    this.state = {}
+
+    if (this.props.controller) {
+      this.controller = this.props.controller
+    } else {
+      this.controller = new PluginFormController(new PluginFormFacade())
+    }
+
+    this.controller.init(new ComponentContext(this))
+  }
+
+  componentDidMount() {
+    this.controller.load()
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'PluginForm.render')
+
+    const { loading, loaded, plugin } = this.state
+
+    return (
+      <PageWithTwoPanels
+        loading={loading}
+        loaded={loaded}
+        object={plugin}
+        renderMainPanel={() => this.renderMainPanel()}
+        renderAdditionalPanel={() => this.renderAdditionalPanel()}
+        renderButtons={() => this.renderButtons()}
+      />
+    )
+  }
+
+  renderMainPanel() {
+    const { controller } = this
+    const { plugin, selection, mode } = this.state
+
+    if (plugin.pluginKind === openbis.PluginKind.JYTHON) {
+      return (
+        <PluginFormScript
+          plugin={plugin}
+          selection={selection}
+          mode={mode}
+          onChange={controller.handleChange}
+          onSelectionChange={controller.handleSelectionChange}
+          onBlur={controller.handleBlur}
+        />
+      )
+    } else {
+      return <div></div>
+    }
+  }
+
+  renderAdditionalPanel() {
+    const { controller } = this
+    const { plugin, selection, mode } = this.state
+
+    return (
+      <PluginFormParameters
+        plugin={plugin}
+        selection={selection}
+        mode={mode}
+        onChange={controller.handleChange}
+        onSelectionChange={controller.handleSelectionChange}
+        onBlur={controller.handleBlur}
+      />
+    )
+  }
+
+  renderButtons() {
+    const { controller } = this
+    const { plugin, changed, mode } = this.state
+
+    return (
+      <PluginFormButtons
+        onEdit={controller.handleEdit}
+        onSave={controller.handleSave}
+        onCancel={controller.handleCancel}
+        plugin={plugin}
+        changed={changed}
+        mode={mode}
+      />
+    )
+  }
+}
+
+export default _.flow(connect(), withStyles(styles))(PluginForm)
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormButtons.jsx b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormButtons.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..0552368188b77e590ea4c45fd2d3bdd90a02002d
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormButtons.jsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import PageButtons from '@src/js/components/common/page/PageButtons.jsx'
+import openbis from '@src/js/services/openbis.js'
+import logger from '@src/js/common/logger.js'
+
+class PluginFormButtons extends React.PureComponent {
+  render() {
+    logger.log(logger.DEBUG, 'PluginFormButtons.render')
+
+    const { mode, onEdit, onSave, onCancel, changed, plugin } = this.props
+
+    return (
+      <PageButtons
+        mode={mode}
+        changed={changed}
+        onEdit={plugin.pluginKind === openbis.PluginKind.JYTHON ? onEdit : null}
+        onSave={onSave}
+        onCancel={plugin.id ? onCancel : null}
+      />
+    )
+  }
+}
+
+export default PluginFormButtons
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormController.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormController.js
new file mode 100644
index 0000000000000000000000000000000000000000..0d7ab8d8ba4880b43495b4557e927bf0ca1aab6c
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormController.js
@@ -0,0 +1,67 @@
+import PageController from '@src/js/components/common/page/PageController.js'
+import PluginFormControllerLoad from '@src/js/components/tools/form/plugin/PluginFormControllerLoad.js'
+import PluginFormControllerValidate from '@src/js/components/tools/form/plugin/PluginFormControllerValidate.js'
+import PluginFormControllerChange from '@src/js/components/tools/form/plugin/PluginFormControllerChange.js'
+import PluginFormControllerSave from '@src/js/components/tools/form/plugin/PluginFormControllerSave.js'
+import pages from '@src/js/common/consts/pages.js'
+import objectTypes from '@src/js/common/consts/objectType.js'
+
+export default class PluginFormController extends PageController {
+  constructor(facade) {
+    super(facade)
+  }
+
+  getPage() {
+    return pages.TOOLS
+  }
+
+  isDynamicPropertyType() {
+    return (
+      this.object.type === objectTypes.DYNAMIC_PROPERTY_PLUGIN ||
+      this.object.type === objectTypes.NEW_DYNAMIC_PROPERTY_PLUGIN
+    )
+  }
+
+  isEntityValidationType() {
+    return (
+      this.object.type === objectTypes.ENTITY_VALIDATION_PLUGIN ||
+      this.object.type === objectTypes.NEW_ENTITY_VALIDATION_PLUGIN
+    )
+  }
+
+  getNewObjectType() {
+    if (this.isDynamicPropertyType()) {
+      return objectTypes.NEW_DYNAMIC_PROPERTY_PLUGIN
+    } else if (this.isEntityValidationType()) {
+      return objectTypes.NEW_ENTITY_VALIDATION_PLUGIN
+    } else {
+      throw new Error('Unsupported object type: ' + this.object.type)
+    }
+  }
+
+  getExistingObjectType() {
+    if (this.isDynamicPropertyType()) {
+      return objectTypes.DYNAMIC_PROPERTY_PLUGIN
+    } else if (this.isEntityValidationType()) {
+      return objectTypes.ENTITY_VALIDATION_PLUGIN
+    } else {
+      throw new Error('Unsupported object type: ' + this.object.type)
+    }
+  }
+
+  load() {
+    return new PluginFormControllerLoad(this).execute()
+  }
+
+  validate(autofocus) {
+    return new PluginFormControllerValidate(this).execute(autofocus)
+  }
+
+  handleChange(type, params) {
+    return new PluginFormControllerChange(this).execute(type, params)
+  }
+
+  handleSave() {
+    return new PluginFormControllerSave(this).execute()
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerChange.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerChange.js
new file mode 100644
index 0000000000000000000000000000000000000000..70d8722b18b9206b19c947030d0dcf35be069f03
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerChange.js
@@ -0,0 +1,25 @@
+import PageControllerChange from '@src/js/components/common/page/PageControllerChange.js'
+import PluginFormSelectionType from '@src/js/components/tools/form/plugin/PluginFormSelectionType.js'
+import FormUtil from '@src/js/components/common/form/FormUtil.js'
+
+export default class PluginFormControllerChange extends PageControllerChange {
+  async execute(type, params) {
+    if (type === PluginFormSelectionType.PLUGIN) {
+      await this._handleChangePlugin(params)
+    }
+  }
+
+  async _handleChangePlugin(params) {
+    await this.context.setState(state => {
+      const { newObject } = FormUtil.changeObjectField(
+        state.plugin,
+        params.field,
+        params.value
+      )
+      return {
+        plugin: newObject
+      }
+    })
+    await this.controller.changed(true)
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js
new file mode 100644
index 0000000000000000000000000000000000000000..c6609a04d51df1035165095f528754d794a9f662
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerLoad.js
@@ -0,0 +1,72 @@
+import _ from 'lodash'
+import PageControllerLoad from '@src/js/components/common/page/PageControllerLoad.js'
+import FormUtil from '@src/js/components/common/form/FormUtil.js'
+import openbis from '@src/js/services/openbis.js'
+
+export default class PluginFormControllerLoad extends PageControllerLoad {
+  async load(object, isNew) {
+    let loadedPlugin = null
+
+    if (!isNew) {
+      loadedPlugin = await this.facade.loadPlugin(object.id)
+      if (!loadedPlugin) {
+        return
+      }
+    }
+
+    const plugin = this._createPlugin(object, loadedPlugin)
+
+    return this.context.setState({
+      plugin
+    })
+  }
+
+  _createPlugin(object, loadedPlugin) {
+    let pluginKind = null
+    let pluginType = null
+
+    if (loadedPlugin) {
+      pluginKind = _.get(loadedPlugin, 'pluginKind')
+      pluginType = _.get(loadedPlugin, 'pluginType')
+    } else {
+      pluginKind = openbis.PluginKind.JYTHON
+
+      if (this.controller.isDynamicPropertyType()) {
+        pluginType = openbis.PluginType.DYNAMIC_PROPERTY
+      } else if (this.controller.isEntityValidationType()) {
+        pluginType = openbis.PluginType.ENTITY_VALIDATION
+      } else {
+        throw new Error('Unsupported object type: ' + object.type)
+      }
+    }
+
+    const entityKinds = _.get(loadedPlugin, 'entityKinds', [])
+
+    const plugin = {
+      id: _.get(loadedPlugin, 'name', null),
+      pluginKind,
+      pluginType,
+      name: FormUtil.createField({
+        value: _.get(loadedPlugin, 'name', null),
+        enabled: loadedPlugin === null
+      }),
+      entityKind: FormUtil.createField({
+        value: entityKinds.length === 1 ? entityKinds[0] : null,
+        enabled: loadedPlugin === null
+      }),
+      description: FormUtil.createField({
+        value: _.get(loadedPlugin, 'description', null)
+      }),
+      script: FormUtil.createField({
+        value: _.get(loadedPlugin, 'script', null)
+      }),
+      available: FormUtil.createField({
+        value: _.get(loadedPlugin, 'available', true)
+      })
+    }
+    if (loadedPlugin) {
+      plugin.original = _.cloneDeep(plugin)
+    }
+    return plugin
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerSave.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerSave.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b97f1ed27f2c6edee57d583c34f2599b62adb14
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerSave.js
@@ -0,0 +1,51 @@
+import PageControllerSave from '@src/js/components/common/page/PageControllerSave.js'
+import FormUtil from '@src/js/components/common/form/FormUtil.js'
+import openbis from '@src/js/services/openbis.js'
+
+export default class PluginFormControllerSave extends PageControllerSave {
+  async save() {
+    const state = this.context.getState()
+
+    const plugin = FormUtil.trimFields({ ...state.plugin })
+    const operations = []
+
+    if (plugin.original) {
+      if (this._isPluginUpdateNeeded(plugin)) {
+        operations.push(this._updatePluginOperation(plugin))
+      }
+    } else {
+      operations.push(this._createPluginOperation(plugin))
+    }
+
+    const options = new openbis.SynchronousOperationExecutionOptions()
+    options.setExecuteInOrder(true)
+    await this.facade.executeOperations(operations, options)
+
+    return plugin.name.value
+  }
+
+  _isPluginUpdateNeeded(plugin) {
+    return FormUtil.haveFieldsChanged(plugin, plugin.original, [
+      'description',
+      'script'
+    ])
+  }
+
+  _createPluginOperation(plugin) {
+    const creation = new openbis.PluginCreation()
+    creation.setPluginType(plugin.pluginType)
+    creation.setEntityKind(plugin.entityKind.value)
+    creation.setName(plugin.name.value)
+    creation.setDescription(plugin.description.value)
+    creation.setScript(plugin.script.value)
+    return new openbis.CreatePluginsOperation([creation])
+  }
+
+  _updatePluginOperation(plugin) {
+    const update = new openbis.PluginUpdate()
+    update.setPluginId(new openbis.PluginPermId(plugin.name.value))
+    update.setDescription(plugin.description.value)
+    update.setScript(plugin.script.value)
+    return new openbis.UpdatePluginsOperation([update])
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerValidate.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerValidate.js
new file mode 100644
index 0000000000000000000000000000000000000000..3a2d00be9622b911d6a18e0c74d15a354542e66b
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormControllerValidate.js
@@ -0,0 +1,33 @@
+import PageControllerValidate from '@src/js/components/common/page/PageConrollerValidate.js'
+import PluginFormSelectionType from '@src/js/components/tools/form/plugin/PluginFormSelectionType.js'
+
+export default class PluginFormControllerValidate extends PageControllerValidate {
+  validate(validator) {
+    const { plugin } = this.context.getState()
+
+    const newPlugin = this._validatePlugin(validator, plugin)
+
+    return {
+      plugin: newPlugin
+    }
+  }
+
+  async select(firstError) {
+    const { plugin } = this.context.getState()
+
+    if (firstError.object === plugin) {
+      await this.setSelection({
+        type: PluginFormSelectionType.PLUGIN,
+        params: {
+          part: firstError.name
+        }
+      })
+    }
+  }
+
+  _validatePlugin(validator, plugin) {
+    validator.validateNotEmpty(plugin, 'name', 'Name')
+    validator.validateNotEmpty(plugin, 'script', 'Script')
+    return validator.withErrors(plugin)
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormFacade.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormFacade.js
new file mode 100644
index 0000000000000000000000000000000000000000..5bf01ba940ff32fd544f47dc1c0b6423dd0e055d
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormFacade.js
@@ -0,0 +1,16 @@
+import openbis from '@src/js/services/openbis.js'
+
+export default class PluginFormFacade {
+  async loadPlugin(pluginName) {
+    const id = new openbis.PluginPermId(pluginName)
+    const fo = new openbis.PluginFetchOptions()
+    fo.withScript()
+    return openbis.getPlugins([id], fo).then(map => {
+      return map[pluginName]
+    })
+  }
+
+  async executeOperations(operations, options) {
+    return openbis.executeOperations(operations, options)
+  }
+}
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormParameters.jsx b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormParameters.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f8da7b61b8355cf08598976d67a99ebee1f6a3c8
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormParameters.jsx
@@ -0,0 +1,216 @@
+import React from 'react'
+import { withStyles } from '@material-ui/core/styles'
+import Container from '@src/js/components/common/form/Container.jsx'
+import Header from '@src/js/components/common/form/Header.jsx'
+import Message from '@src/js/components/common/form/Message.jsx'
+import TextField from '@src/js/components/common/form/TextField.jsx'
+import SelectField from '@src/js/components/common/form/SelectField.jsx'
+import PluginFormSelectionType from '@src/js/components/tools/form/plugin/PluginFormSelectionType.js'
+import openbis from '@src/js/services/openbis.js'
+import logger from '@src/js/common/logger.js'
+
+const styles = theme => ({
+  field: {
+    paddingBottom: theme.spacing(1)
+  }
+})
+
+class PluginFormParameters extends React.PureComponent {
+  constructor(props) {
+    super(props)
+    this.state = {}
+    this.references = {
+      name: React.createRef(),
+      entityKind: React.createRef(),
+      description: React.createRef()
+    }
+    this.handleChange = this.handleChange.bind(this)
+    this.handleFocus = this.handleFocus.bind(this)
+    this.handleBlur = this.handleBlur.bind(this)
+  }
+
+  componentDidMount() {
+    this.focus()
+  }
+
+  componentDidUpdate(prevProps) {
+    const prevSelection = prevProps.selection
+    const selection = this.props.selection
+
+    if (prevSelection !== selection) {
+      this.focus()
+    }
+  }
+
+  focus() {
+    if (this.props.selection) {
+      const { part } = this.props.selection.params
+      if (part) {
+        const reference = this.references[part]
+        if (reference && reference.current) {
+          reference.current.focus()
+        }
+      }
+    }
+  }
+
+  handleChange(event) {
+    this.props.onChange(PluginFormSelectionType.PLUGIN, {
+      field: event.target.name,
+      value: event.target.value
+    })
+  }
+
+  handleFocus(event) {
+    this.props.onSelectionChange(PluginFormSelectionType.PLUGIN, {
+      part: event.target.name
+    })
+  }
+
+  handleBlur() {
+    this.props.onBlur()
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'PluginFormParameters.render')
+
+    const { plugin } = this.props
+
+    return (
+      <Container>
+        <Header>Plugin</Header>
+        {this.renderMessageDisabled(plugin)}
+        {this.renderMessagePredeployed(plugin)}
+        {this.renderName(plugin)}
+        {this.renderEntityKind(plugin)}
+        {this.renderDescription(plugin)}
+      </Container>
+    )
+  }
+
+  renderMessageDisabled(plugin) {
+    const { classes } = this.props
+
+    if (!plugin.available.value) {
+      return (
+        <div className={classes.field}>
+          <Message type='warning'>The plugin is disabled.</Message>
+        </div>
+      )
+    } else {
+      return null
+    }
+  }
+
+  renderMessagePredeployed(plugin) {
+    const { classes } = this.props
+
+    if (plugin.pluginKind === openbis.PluginKind.PREDEPLOYED) {
+      return (
+        <div className={classes.field}>
+          <Message type='info'>
+            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.
+          </Message>
+        </div>
+      )
+    } else {
+      return null
+    }
+  }
+
+  renderName(plugin) {
+    const { visible, enabled, error, value } = { ...plugin.name }
+
+    if (!visible) {
+      return null
+    }
+
+    const { mode, classes } = this.props
+    return (
+      <div className={classes.field}>
+        <TextField
+          reference={this.references.name}
+          label='Name'
+          name='name'
+          mandatory={true}
+          error={error}
+          disabled={!enabled}
+          value={value}
+          mode={mode}
+          onChange={this.handleChange}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+      </div>
+    )
+  }
+
+  renderEntityKind(plugin) {
+    const { visible, enabled, error, value } = { ...plugin.entityKind }
+
+    if (!visible) {
+      return null
+    }
+
+    const { mode, classes } = this.props
+
+    const options = openbis.EntityKind.values.map(entityKind => {
+      return {
+        label: entityKind,
+        value: entityKind
+      }
+    })
+
+    return (
+      <div className={classes.field}>
+        <SelectField
+          reference={this.references.entityKind}
+          label='Entity Kind'
+          name='entityKind'
+          error={error}
+          disabled={!enabled}
+          value={value}
+          options={options}
+          emptyOption={{
+            label: '(all)',
+            selectable: true
+          }}
+          mode={mode}
+          onChange={this.handleChange}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+      </div>
+    )
+  }
+
+  renderDescription(plugin) {
+    const { visible, enabled, error, value } = { ...plugin.description }
+
+    if (!visible) {
+      return null
+    }
+
+    const { mode, classes } = this.props
+    return (
+      <div className={classes.field}>
+        <TextField
+          reference={this.references.description}
+          label='Description'
+          name='description'
+          error={error}
+          disabled={!enabled}
+          value={value}
+          mode={mode}
+          onChange={this.handleChange}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+      </div>
+    )
+  }
+}
+
+export default withStyles(styles)(PluginFormParameters)
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormScript.jsx b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormScript.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b586abcfd2d9e14d24d4fb7f164d4f3e9dd1cb3c
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormScript.jsx
@@ -0,0 +1,106 @@
+import React from 'react'
+import { withStyles } from '@material-ui/core/styles'
+import Container from '@src/js/components/common/form/Container.jsx'
+import Header from '@src/js/components/common/form/Header.jsx'
+import SourceCodeField from '@src/js/components/common/form/SourceCodeField.jsx'
+import PluginFormSelectionType from '@src/js/components/tools/form/plugin/PluginFormSelectionType.js'
+import logger from '@src/js/common/logger.js'
+
+const styles = () => ({})
+
+class PluginFormScript extends React.PureComponent {
+  constructor(props) {
+    super(props)
+    this.state = {}
+    this.references = {
+      script: React.createRef()
+    }
+    this.handleChange = this.handleChange.bind(this)
+    this.handleFocus = this.handleFocus.bind(this)
+    this.handleBlur = this.handleBlur.bind(this)
+  }
+
+  componentDidMount() {
+    this.focus()
+  }
+
+  componentDidUpdate(prevProps) {
+    const prevSelection = prevProps.selection
+    const selection = this.props.selection
+
+    if (prevSelection !== selection) {
+      this.focus()
+    }
+  }
+
+  focus() {
+    if (this.props.selection) {
+      const { part } = this.props.selection.params
+      if (part) {
+        const reference = this.references[part]
+        if (reference && reference.current) {
+          reference.current.focus()
+        }
+      }
+    }
+  }
+
+  handleChange(event) {
+    this.props.onChange(PluginFormSelectionType.PLUGIN, {
+      field: event.target.name,
+      value: event.target.value
+    })
+  }
+
+  handleFocus(event) {
+    this.props.onSelectionChange(PluginFormSelectionType.PLUGIN, {
+      part: event.target.name
+    })
+  }
+
+  handleBlur() {
+    this.props.onBlur()
+  }
+
+  render() {
+    logger.log(logger.DEBUG, 'PluginFormScript.render')
+
+    const { plugin } = this.props
+
+    return (
+      <Container>
+        <Header>Script</Header>
+        {this.renderScript(plugin)}
+      </Container>
+    )
+  }
+
+  renderScript(plugin) {
+    const { visible, enabled, error, value } = { ...plugin.script }
+
+    if (!visible) {
+      return null
+    }
+
+    const { mode, classes } = this.props
+    return (
+      <div className={classes.field}>
+        <SourceCodeField
+          reference={this.references.script}
+          label='Script'
+          name='script'
+          mandatory={true}
+          error={error}
+          disabled={!enabled}
+          value={value}
+          mode={mode}
+          onChange={this.handleChange}
+          onFocus={this.handleFocus}
+          onBlur={this.handleBlur}
+        />
+      </div>
+    )
+  }
+}
+
+export default withStyles(styles)(PluginFormScript)
diff --git a/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormSelectionType.js b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormSelectionType.js
new file mode 100644
index 0000000000000000000000000000000000000000..56bde9532baa625abe32e4689db6f6726549cf44
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/form/plugin/PluginFormSelectionType.js
@@ -0,0 +1,5 @@
+const PLUGIN = 'plugins'
+
+export default {
+  PLUGIN
+}
diff --git a/openbis_ng_ui/src/js/components/tools/search/ToolSearch.jsx b/openbis_ng_ui/src/js/components/tools/search/ToolSearch.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b3bb7f3e0e02b5bbfa4a068c784833f45da4915f
--- /dev/null
+++ b/openbis_ng_ui/src/js/components/tools/search/ToolSearch.jsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import logger from '@src/js/common/logger.js'
+
+export default class ToolSearch extends React.Component {
+  render() {
+    logger.log(logger.DEBUG, 'ToolSearch.render')
+    return 'ToolSearch'
+  }
+}
diff --git a/openbis_ng_ui/src/js/services/openbis/api.js b/openbis_ng_ui/src/js/services/openbis/api.js
index ad8b711018b0067b9a56453b6b81bd1837f60933..baa362e798f146a99388dd8cf7b887a21068e7ba 100644
--- a/openbis_ng_ui/src/js/services/openbis/api.js
+++ b/openbis_ng_ui/src/js/services/openbis/api.js
@@ -31,6 +31,10 @@ class Facade {
     return this.promise(this.v3.logout())
   }
 
+  getPlugins(ids, fo) {
+    return this.promise(this.v3.getPlugins(ids, fo))
+  }
+
   getPropertyTypes(ids, fo) {
     return this.promise(this.v3.getPropertyTypes(ids, fo))
   }
diff --git a/openbis_ng_ui/src/js/services/openbis/dto.js b/openbis_ng_ui/src/js/services/openbis/dto.js
index 7389cf14e4dd5f2baec95812a44975395dc704af..95c047f4e5a5e3b689b6b04d8b55d0ff7222d9dd 100644
--- a/openbis_ng_ui/src/js/services/openbis/dto.js
+++ b/openbis_ng_ui/src/js/services/openbis/dto.js
@@ -52,7 +52,14 @@ const CLASS_FULL_NAMES = [
   'as/dto/person/search/PersonSearchCriteria',
   'as/dto/person/update/PersonUpdate',
   'as/dto/person/update/UpdatePersonsOperation',
+  'as/dto/plugin/PluginKind',
   'as/dto/plugin/PluginType',
+  'as/dto/plugin/create/CreatePluginsOperation',
+  'as/dto/plugin/create/PluginCreation',
+  'as/dto/plugin/update/PluginUpdate',
+  'as/dto/plugin/update/UpdatePluginsOperation',
+  'as/dto/plugin/delete/PluginDeletionOptions',
+  'as/dto/plugin/delete/DeletePluginsOperation',
   'as/dto/plugin/fetchoptions/PluginFetchOptions',
   'as/dto/plugin/id/PluginPermId',
   'as/dto/plugin/search/PluginSearchCriteria',
diff --git a/openbis_ng_ui/src/js/store/reducers/ui/pages/pages.js b/openbis_ng_ui/src/js/store/reducers/ui/pages/pages.js
index 5192825f1ce556a49b36321d2b47aa4367cd3630..97fdf85fb2109f5fed3da305c23f2cc03dc42f50 100644
--- a/openbis_ng_ui/src/js/store/reducers/ui/pages/pages.js
+++ b/openbis_ng_ui/src/js/store/reducers/ui/pages/pages.js
@@ -2,9 +2,11 @@ import { combineReducers } from 'redux'
 import login from '@src/js/store/reducers/ui/pages/login/login.js'
 import types from '@src/js/store/reducers/ui/pages/types/types.js'
 import users from '@src/js/store/reducers/ui/pages/users/users.js'
+import tools from '@src/js/store/reducers/ui/pages/tools/tools.js'
 
 export default combineReducers({
   login,
   types,
-  users
+  users,
+  tools
 })
diff --git a/openbis_ng_ui/src/js/store/reducers/ui/pages/tools/tools.js b/openbis_ng_ui/src/js/store/reducers/ui/pages/tools/tools.js
new file mode 100644
index 0000000000000000000000000000000000000000..ffb16deaa11e3a0804373bcf052c157580f07ac3
--- /dev/null
+++ b/openbis_ng_ui/src/js/store/reducers/ui/pages/tools/tools.js
@@ -0,0 +1,14 @@
+import { combineReducers } from 'redux'
+import pages from '@src/js/common/consts/pages.js'
+import page from '@src/js/store/reducers/ui/pages/common/page.js'
+
+export default function types(state = {}, action) {
+  if (page.isPageAction(pages.TOOLS, action)) {
+    return combineReducers({
+      currentRoute: page.currentRoute,
+      openTabs: page.openTabs
+    })(state, action)
+  } else {
+    return state
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/AppComponentLogin.test.js b/openbis_ng_ui/srcTest/js/components/AppComponentLogin.test.js
index 380b74f6c32f03216d95703e3ff595465ab7c8b9..b480cda823b001765a8f0d42845207f303400a19 100644
--- a/openbis_ng_ui/srcTest/js/components/AppComponentLogin.test.js
+++ b/openbis_ng_ui/srcTest/js/components/AppComponentLogin.test.js
@@ -46,6 +46,10 @@ async function testLogin() {
         {
           label: 'Users',
           selected: false
+        },
+        {
+          label: 'Tools',
+          selected: false
         }
       ]
     },
diff --git a/openbis_ng_ui/srcTest/js/components/AppComponentTest.js b/openbis_ng_ui/srcTest/js/components/AppComponentTest.js
index ea692cf30a149542938cfa9e57ceeb82d2b99536..852dc0e60abc0b72e49cadbcb013349ca6fec7fd 100644
--- a/openbis_ng_ui/srcTest/js/components/AppComponentTest.js
+++ b/openbis_ng_ui/srcTest/js/components/AppComponentTest.js
@@ -25,6 +25,7 @@ export default class AppComponentTest extends ComponentTest {
     openbis.mockSearchVocabularies([])
     openbis.mockSearchPersons([])
     openbis.mockSearchGroups([])
+    openbis.mockSearchPlugins([])
   }
 
   async login(app) {
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/browser/ToolBrowserComponentFilter.test.js b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentFilter.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..5c413609eb122ed9188c5e5682d85a93feedb47a
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentFilter.test.js
@@ -0,0 +1,63 @@
+import ToolBrowserComponentTest from '@srcTest/js/components/tools/browser/ToolBrowserComponentTest.js'
+import ToolBrowserTestData from '@srcTest/js/components/tools/browser/ToolBrowserTestData.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new ToolBrowserComponentTest()
+  common.beforeEach()
+})
+
+describe(ToolBrowserComponentTest.SUITE, () => {
+  test('filter', testFilter)
+})
+
+async function testFilter() {
+  const {
+    testDynamicPropertyJythonPlugin,
+    testDynamicPropertyPredeployedPlugin,
+    testManagedPropertyJythonPlugin,
+    testEntityValidationJythonPlugin
+  } = ToolBrowserTestData
+
+  openbis.mockSearchPlugins([
+    testDynamicPropertyJythonPlugin,
+    testDynamicPropertyPredeployedPlugin,
+    testManagedPropertyJythonPlugin,
+    testEntityValidationJythonPlugin
+  ])
+
+  const browser = await common.mount()
+
+  browser
+    .getFilter()
+    .change(testEntityValidationJythonPlugin.name.toUpperCase())
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: testEntityValidationJythonPlugin.name.toUpperCase()
+    },
+    nodes: [
+      { level: 0, text: 'Entity Validation Plugins' },
+      { level: 1, text: testEntityValidationJythonPlugin.name }
+    ]
+  })
+
+  browser.getFilter().getClearIcon().click()
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 1, text: testDynamicPropertyJythonPlugin.name },
+      { level: 1, text: testDynamicPropertyPredeployedPlugin.name },
+      { level: 0, text: 'Entity Validation Plugins' },
+      { level: 1, text: testEntityValidationJythonPlugin.name }
+    ]
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentLoad.test.js b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentLoad.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..ada84839f76c58c060f5d2ec73f81d220ba896a6
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentLoad.test.js
@@ -0,0 +1,29 @@
+import ToolBrowserComponentTest from '@srcTest/js/components/tools/browser/ToolBrowserComponentTest.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new ToolBrowserComponentTest()
+  common.beforeEach()
+})
+
+describe(ToolBrowserComponentTest.SUITE, () => {
+  test('load', testLoad)
+})
+
+async function testLoad() {
+  openbis.mockSearchPlugins([])
+
+  const browser = await common.mount()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 0, text: 'Entity Validation Plugins' }
+    ]
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentOpenClose.test.js b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentOpenClose.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..ead4b607a7f74cfc00f856881de029b2d266151c
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentOpenClose.test.js
@@ -0,0 +1,90 @@
+import ToolBrowserComponentTest from '@srcTest/js/components/tools/browser/ToolBrowserComponentTest.js'
+import ToolBrowserTestData from '@srcTest/js/components/tools/browser/ToolBrowserTestData.js'
+import openbis from '@srcTest/js/services/openbis.js'
+
+let common = null
+
+beforeEach(() => {
+  common = new ToolBrowserComponentTest()
+  common.beforeEach()
+})
+
+describe(ToolBrowserComponentTest.SUITE, () => {
+  test('open/close', testOpenClose)
+})
+
+async function testOpenClose() {
+  const {
+    testDynamicPropertyJythonPlugin,
+    testDynamicPropertyPredeployedPlugin,
+    testManagedPropertyJythonPlugin,
+    testEntityValidationJythonPlugin
+  } = ToolBrowserTestData
+
+  openbis.mockSearchPlugins([
+    testDynamicPropertyJythonPlugin,
+    testDynamicPropertyPredeployedPlugin,
+    testManagedPropertyJythonPlugin,
+    testEntityValidationJythonPlugin
+  ])
+
+  const browser = await common.mount()
+
+  browser.getNodes()[0].getIcon().click()
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 1, text: testDynamicPropertyJythonPlugin.name },
+      { level: 1, text: testDynamicPropertyPredeployedPlugin.name },
+      { level: 0, text: 'Entity Validation Plugins' }
+    ]
+  })
+
+  browser.getNodes()[3].getIcon().click()
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 1, text: testDynamicPropertyJythonPlugin.name },
+      { level: 1, text: testDynamicPropertyPredeployedPlugin.name },
+      { level: 0, text: 'Entity Validation Plugins' },
+      { level: 1, text: testEntityValidationJythonPlugin.name }
+    ]
+  })
+
+  browser.getNodes()[0].getIcon().click()
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 0, text: 'Entity Validation Plugins' },
+      { level: 1, text: testEntityValidationJythonPlugin.name }
+    ]
+  })
+
+  browser.getNodes()[1].getIcon().click()
+  await browser.update()
+
+  browser.expectJSON({
+    filter: {
+      value: null
+    },
+    nodes: [
+      { level: 0, text: 'Dynamic Property Plugins' },
+      { level: 0, text: 'Entity Validation Plugins' }
+    ]
+  })
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentTest.js b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentTest.js
new file mode 100644
index 0000000000000000000000000000000000000000..9ed62735c5f2578ef31fb8032a126ce6a0d50be7
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserComponentTest.js
@@ -0,0 +1,15 @@
+import React from 'react'
+import ComponentTest from '@srcTest/js/components/common/ComponentTest.js'
+import BrowserWrapper from '@srcTest/js/components/common/browser/wrapper/BrowserWrapper.js'
+import ToolBrowser from '@src/js/components/tools/browser/ToolBrowser.jsx'
+
+export default class ToolBrowserComponentTest extends ComponentTest {
+  static SUITE = 'ToolBrowserComponent'
+
+  constructor() {
+    super(
+      () => <ToolBrowser />,
+      wrapper => new BrowserWrapper(wrapper)
+    )
+  }
+}
diff --git a/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserTestData.js b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserTestData.js
new file mode 100644
index 0000000000000000000000000000000000000000..9d477ad6d80582aba63b1c5edf75f082757db597
--- /dev/null
+++ b/openbis_ng_ui/srcTest/js/components/tools/browser/ToolBrowserTestData.js
@@ -0,0 +1,40 @@
+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
+)
+
+const testDynamicPropertyPredeployedPlugin = new openbis.Plugin()
+testDynamicPropertyPredeployedPlugin.setName(
+  'TEST_DYNAMIC_PROPERTY_PREDEPLOYED'
+)
+testDynamicPropertyPredeployedPlugin.setPluginKind(
+  openbis.PluginKind.PREDEPLOYED
+)
+testDynamicPropertyPredeployedPlugin.setPluginType(
+  openbis.PluginType.DYNAMIC_PROPERTY
+)
+
+const testManagedPropertyJythonPlugin = new openbis.Plugin()
+testManagedPropertyJythonPlugin.setName('TEST_MANAGED_PROPERTY_JYTHON')
+testManagedPropertyJythonPlugin.setPluginKind(openbis.PluginKind.JYTHON)
+testManagedPropertyJythonPlugin.setPluginType(
+  openbis.PluginType.MANAGED_PROPERTY
+)
+
+const testEntityValidationJythonPlugin = new openbis.Plugin()
+testEntityValidationJythonPlugin.setName('TEST_ENTITY_VALIDATION_JYTHON')
+testEntityValidationJythonPlugin.setPluginKind(openbis.PluginKind.JYTHON)
+testEntityValidationJythonPlugin.setPluginType(
+  openbis.PluginType.ENTITY_VALIDATION
+)
+
+export default {
+  testDynamicPropertyJythonPlugin,
+  testDynamicPropertyPredeployedPlugin,
+  testManagedPropertyJythonPlugin,
+  testEntityValidationJythonPlugin
+}
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
diff --git a/openbis_ng_ui/srcTest/js/services/openbis/api.js b/openbis_ng_ui/srcTest/js/services/openbis/api.js
index f33f891bb32d77e7a5203dad3a3da7d51e1ceeea..55c2ad6922933a42c6413effc941bab5bb68b193 100644
--- a/openbis_ng_ui/srcTest/js/services/openbis/api.js
+++ b/openbis_ng_ui/srcTest/js/services/openbis/api.js
@@ -15,6 +15,7 @@ const getPersons = jest.fn()
 const getPropertyTypes = jest.fn()
 const getSampleTypes = jest.fn()
 const getVocabularies = jest.fn()
+const getPlugins = jest.fn()
 const searchAuthorizationGroups = jest.fn()
 const searchDataSetTypes = jest.fn()
 const searchExperimentTypes = jest.fn()
@@ -85,6 +86,12 @@ const mockSearchVocabularies = vocabularies => {
   searchVocabularies.mockReturnValue(Promise.resolve(searchResult))
 }
 
+const mockSearchPlugins = plugins => {
+  const searchResult = new dto.SearchResult()
+  searchResult.setObjects(plugins)
+  searchPlugins.mockReturnValue(Promise.resolve(searchResult))
+}
+
 export default {
   login,
   logout,
@@ -101,6 +108,7 @@ export default {
   getPropertyTypes,
   getSampleTypes,
   getVocabularies,
+  getPlugins,
   searchAuthorizationGroups,
   searchDataSetTypes,
   searchExperimentTypes,
@@ -127,5 +135,6 @@ export default {
   mockSearchPersons,
   mockSearchSampleTypes,
   mockSearchPropertyTypes,
-  mockSearchVocabularies
+  mockSearchVocabularies,
+  mockSearchPlugins
 }
diff --git a/openbis_ng_ui/srcTest/js/services/openbis/dto.js b/openbis_ng_ui/srcTest/js/services/openbis/dto.js
index 904b252629c4395b60da553ca24b4758a49c9213..44b258a2019628c0d5c65289f08e7658590ede04 100644
--- a/openbis_ng_ui/srcTest/js/services/openbis/dto.js
+++ b/openbis_ng_ui/srcTest/js/services/openbis/dto.js
@@ -10,6 +10,7 @@ import CreateDataSetTypesOperation from 'as/dto/dataset/create/CreateDataSetType
 import CreateExperimentTypesOperation from 'as/dto/experiment/create/CreateExperimentTypesOperation'
 import CreateMaterialTypesOperation from 'as/dto/material/create/CreateMaterialTypesOperation'
 import CreatePersonsOperation from 'as/dto/person/create/CreatePersonsOperation'
+import CreatePluginsOperation from 'as/dto/plugin/create/CreatePluginsOperation'
 import CreatePropertyTypesOperation from 'as/dto/property/create/CreatePropertyTypesOperation'
 import CreateRoleAssignmentsOperation from 'as/dto/roleassignment/create/CreateRoleAssignmentsOperation'
 import CreateSampleTypesOperation from 'as/dto/sample/create/CreateSampleTypesOperation'
@@ -30,6 +31,7 @@ import DeleteAuthorizationGroupsOperation from 'as/dto/authorizationgroup/delete
 import DeleteDataSetTypesOperation from 'as/dto/dataset/delete/DeleteDataSetTypesOperation'
 import DeleteExperimentTypesOperation from 'as/dto/experiment/delete/DeleteExperimentTypesOperation'
 import DeleteMaterialTypesOperation from 'as/dto/material/delete/DeleteMaterialTypesOperation'
+import DeletePluginsOperation from 'as/dto/plugin/delete/DeletePluginsOperation'
 import DeletePropertyTypesOperation from 'as/dto/property/delete/DeletePropertyTypesOperation'
 import DeleteRoleAssignmentsOperation from 'as/dto/roleassignment/delete/DeleteRoleAssignmentsOperation'
 import DeleteSampleTypesOperation from 'as/dto/sample/delete/DeleteSampleTypesOperation'
@@ -60,7 +62,11 @@ import PersonPermId from 'as/dto/person/id/PersonPermId'
 import PersonSearchCriteria from 'as/dto/person/search/PersonSearchCriteria'
 import PersonUpdate from 'as/dto/person/update/PersonUpdate'
 import Plugin from 'as/dto/plugin/Plugin'
+import PluginCreation from 'as/dto/plugin/create/PluginCreation'
+import PluginUpdate from 'as/dto/plugin/update/PluginUpdate'
+import PluginDeletionOptions from 'as/dto/plugin/delete/PluginDeletionOptions'
 import PluginFetchOptions from 'as/dto/plugin/fetchoptions/PluginFetchOptions'
+import PluginKind from 'as/dto/plugin/PluginKind'
 import PluginPermId from 'as/dto/plugin/id/PluginPermId'
 import PluginSearchCriteria from 'as/dto/plugin/search/PluginSearchCriteria'
 import PluginType from 'as/dto/plugin/PluginType'
@@ -109,6 +115,7 @@ import UpdateDataSetTypesOperation from 'as/dto/dataset/update/UpdateDataSetType
 import UpdateExperimentTypesOperation from 'as/dto/experiment/update/UpdateExperimentTypesOperation'
 import UpdateMaterialTypesOperation from 'as/dto/material/update/UpdateMaterialTypesOperation'
 import UpdatePersonsOperation from 'as/dto/person/update/UpdatePersonsOperation'
+import UpdatePluginsOperation from 'as/dto/plugin/update/UpdatePluginsOperation'
 import UpdatePropertyTypesOperation from 'as/dto/property/update/UpdatePropertyTypesOperation'
 import UpdateSampleTypesOperation from 'as/dto/sample/update/UpdateSampleTypesOperation'
 import UpdateVocabulariesOperation from 'as/dto/vocabulary/update/UpdateVocabulariesOperation'
@@ -142,6 +149,7 @@ const dto = {
   CreateExperimentTypesOperation,
   CreateMaterialTypesOperation,
   CreatePersonsOperation,
+  CreatePluginsOperation,
   CreatePropertyTypesOperation,
   CreateRoleAssignmentsOperation,
   CreateSampleTypesOperation,
@@ -162,6 +170,7 @@ const dto = {
   DeleteDataSetTypesOperation,
   DeleteExperimentTypesOperation,
   DeleteMaterialTypesOperation,
+  DeletePluginsOperation,
   DeletePropertyTypesOperation,
   DeleteRoleAssignmentsOperation,
   DeleteSampleTypesOperation,
@@ -192,7 +201,11 @@ const dto = {
   PersonSearchCriteria,
   PersonUpdate,
   Plugin,
+  PluginCreation,
+  PluginUpdate,
+  PluginDeletionOptions,
   PluginFetchOptions,
+  PluginKind,
   PluginPermId,
   PluginSearchCriteria,
   PluginType,
@@ -241,6 +254,7 @@ const dto = {
   UpdateExperimentTypesOperation,
   UpdateMaterialTypesOperation,
   UpdatePersonsOperation,
+  UpdatePluginsOperation,
   UpdatePropertyTypesOperation,
   UpdateSampleTypesOperation,
   UpdateVocabulariesOperation,