oo

Writing a Custom Form Field Type

The Forms application contains many highly configurable field types out-of-the-box. Most use cases are met with one of the existing field types. If your use case can’t be met with the default field types, you can create your own.

There are many useful form elements.

note
  • Form field types in other applications: Forms created with Documents and Media (Metadata Sets), Web Content (Structures), and the Forms application can all consume the same form fields. By default a custom form field is only used in the Forms application. To specify explicitly which applications should enable the form field type, add the component property:

    "ddm.form.field.type.scope=document-library,forms,journal"
    
  • Project compatibility: The example project runs on Liferay 7.4. If you’re running Liferay 7.3, the source code is compatible but the Workspace project must be reconfigured for Liferay 7.3. The steps to do this are included in the instructions below.

    If you’re running Liferay 7.2, this source code does not run due to a difference in supported frontend frameworks. Please see Developing a Custom Form Field for Liferay 7.2 to learn how to adapt the C2P9 Slider code sample for 7.2.

Examine the Custom Form Field in Liferay

To see how custom form fields work, deploy an example and then add some form data using the new field.

Deploy the Example

Start a new Liferay instance by running

docker run -it -m 8g -p 8080:8080 liferay/portal:7.4.3.112-ga112

Sign in to Liferay at http://localhost:8080. Use the email address test@liferay.com and the password test. When prompted, change the password to learn.

Then, follow these steps:

  1. Download and unzip the Custom Form Field Type project.

    curl https://resources.learn.liferay.com/dxp/latest/en/process-automation/forms/developer-guide/liferay-c2p9.zip -O
    
    unzip liferay-c2p9.zip
    
  2. From the module root, build and deploy.

    ./gradlew deploy -Ddeploy.docker.container.id=$(docker ps -lq)
    
    tip

    This command is the same as copying the deployed jars to /opt/liferay/osgi/modules on the Docker container.

    note

    For Liferay 7.3, make these adjustments to the project before deploying it:

    • In c2p9-impl/package.json, change the devDependencies reference from @liferay/portal-7.4 to @liferay/portal-7.3.
    • In gradle.properties, change the liferay.workspace.product value to portal-7.3-ga8 (if a Liferay 7.3 version newer than GA8 is available, try to reference it here instead).
  3. Confirm the deployment in the Liferay Docker container console.

    STARTED com.acme.c2p9.impl_1.0.0 [1009]
    

Use the Deployed Slider Field

  1. Open your browser to http://localhost:8080.

  2. Go to the Forms application in Site MenuContent & DataForms.

  3. Click the Add button (Add) to open the Form Builder.

  4. Add the C2P9 Slider field to the form.

  5. You can fill out the Label, Predefined Value, and Help Text, as well as make the field Required. These settings match what many out-of-the-box fields provide as basic settings.

  6. Publish the form and go submit a record using the slider field.

Use the slider to set a value between 0 and 100.

Understand the Form Field’s Code

A basic form field contains a Java class and a JavaScript file. In the C2P9 Slider field, C2P9DDMFormFieldType.java provides a DDMFormFieldType implementation by extending the abstract class BaseDDMFormFieldType and defining its metadata in the OSGi Component:

@Component(
	property = {
		"ddm.form.field.type.description=c2p9-description",
		"ddm.form.field.type.display.order:Integer=10",
		"ddm.form.field.type.group=customized", "ddm.form.field.type.icon=text",
		"ddm.form.field.type.label=c2p9-label",
		"ddm.form.field.type.name=c2p9-slider"
	},
	service = DDMFormFieldType.class
)
public class C2P9DDMFormFieldType extends BaseDDMFormFieldType {

ddm.form.field.type.description: provide the language key for the description text. Make sure the translated value is defined in the Language.properties file.

ddm.form.field.type.display.order: set an integer or floating point value to determine where the field is displayed in the Form Builder sidebar. Fields with the same value are ordered randomly.

ddm.form.field.type.icon: decide which icon type to use for your field. Choose any Clay Icon.

ddm.form.field.type.label: provide the language key for the label text. Make sure the translated value is defined in the Language.properties file.

ddm.form.field.type.name: provide the field type identifier. This is used to identify the field internally and in other components.

The getModuleName method passes the Slider.es.js file path to the NPMResolver service.

@Override
public String getModuleName() {
	return _npmResolver.resolveModuleName(
		"dynamic-data-mapping-form-field-type-c2p9-slider/C2P9/Slider.es");
}
@Reference
private NPMResolver _npmResolver;

Some of the path definition is accomplished in the package.json file (see the name declaration and the source-maps defined in the scripts section).

The getName method returns the form field identifier. It must match the value in the component property ddm.form.field.type.name.

@Override
public String getName() {
	return "c2p9-slider";
}

The isCustomDDMFormFieldType is used internally. Return true if you’re returning the result of NPMResolver.resolveModuleName() in the getModuleName method.

@Override
public boolean isCustomDDMFormFieldType() {
	return true;
}

Slider.es.js provides the JavaScript logic for the field. Two components are defined in the file; Main and Slider.

The import statements bring in functionality from Liferay’s base form field, dynamic-data-mapping-form-field-type. These are called later using the declared variables FieldBase and useSyncValue.

import {FieldBase} from 'dynamic-data-mapping-form-field-type/FieldBase/ReactFieldBase.es';
import {useSyncValue} from 'dynamic-data-mapping-form-field-type/hooks/useSyncValue.es';

The const Slider = block defines the field: it’s instantiated with the parameters name, onChange, predefinedValue, readOnly, and value.

const Slider = ({name, onChange, predefinedValue, readOnly, value}) => (
	<input
		className="ddm-field-slider form-control slider"
		disabled={readOnly}
		id="myRange"
		max={100}
		min={1}
		name={name}
		onInput={onChange}
		type="range"
		value={value ? value : predefinedValue}
	/>
);

The values for these parameters, along with some others, define the HTML <input> tag for the form field. Importantly, the max and min values that the user can select are hard coded right now. You’ll change this later. The field’s value is defined using a ternary operator: if a value is entered, use it. Otherwise use the predefined value.

The Main component is exported at the end of the file; it includes the Slider as a child element of the imported FieldBase. The onChange function gets the slider’s position/value each time the event is detected (each time the slider is dragged to a new value).

const Main = ({
	label,
	name,
	onChange,
	predefinedValue,
	readOnly,
	value,
	...otherProps
}) => {
	const [currentValue, setCurrentValue] = useSyncValue(
		value ? value : predefinedValue
	);

	return (
		<FieldBase
			label={label}
			name={name}
			predefinedValue={predefinedValue}
			{...otherProps}
		>
			<Slider
				name={name}
				onChange={(event) => {
					setCurrentValue(event.target.value);
					onChange(event);
				}}
				predefinedValue={predefinedValue}
				readOnly={readOnly}
				value={currentValue}
			/>
		</FieldBase>
	);
};

Main.displayName = 'Slider';

export default Main;

Add Custom Settings to the Form Field

Right now the Max and Min settings for the Slider field are hard coded, but it’s better if they’re configurable. To add custom settings to a form field,

  • Adjust the backend by adding a DDMFormFieldTypeSettings class and adding a method to the DDMFormFieldType.
  • Adapt the frontend for rendering the new settings by adding a DDMFormFieldTemplateContextContributor and updating the way the settings are defined in Slider.es.js.

Supporting Custom Settings in the Backend

The form field’s settings are defined in the DDMTypeSettings class, which also defines the form that appears in the field’s sidebar using the @DDMForm annotation. Then the DDMFormFieldType itself must know about the new settings definition so it doesn’t display the default field settings form. A DDMFormFieldContextContributor class sends the new settings to the React component to show it to the end user.

  1. Add a C2P9DDMFormFieldTypeSettings Java class to the com.acme.c2p9.internal.dynamic.data.mapping.form.field.type package.

    package com.acme.c2p9.internal.dynamic.data.mapping.form.field.type;
    
    import com.liferay.dynamic.data.mapping.annotations.DDMForm;
    import com.liferay.dynamic.data.mapping.annotations.DDMFormField;
    import com.liferay.dynamic.data.mapping.annotations.DDMFormLayout;
    import com.liferay.dynamic.data.mapping.annotations.DDMFormLayoutColumn;
    import com.liferay.dynamic.data.mapping.annotations.DDMFormLayoutPage;
    import com.liferay.dynamic.data.mapping.annotations.DDMFormLayoutRow;
    import com.liferay.dynamic.data.mapping.form.field.type.DefaultDDMFormFieldTypeSettings;
    
    @DDMForm
    @DDMFormLayout(
       paginationMode = com.liferay.dynamic.data.mapping.model.DDMFormLayout.TABBED_MODE,
       value = {
          @DDMFormLayoutPage(
             title = "%basic",
             value = {
                @DDMFormLayoutRow(
                   {
                      @DDMFormLayoutColumn(
                         size = 12,
                         value = {
                            "label", "predefinedValue", "required", "tip"
                         }
                      )
                   }
                )
             }
          ),
          @DDMFormLayoutPage(
             title = "%advanced",
             value = {
                @DDMFormLayoutRow(
                   {
                      @DDMFormLayoutColumn(
                         size = 12,
                         value = {
                            "dataType", "min", "max", "name", "showLabel",
                            "repeatable", "type", "validation",
                            "visibilityExpression"
                         }
                      )
                   }
                )
             }
          )
       }
    )
    public interface C2P9DDMFormFieldTypeSettings
       extends DefaultDDMFormFieldTypeSettings {
    
       @DDMFormField(
          label = "%max-value",
          properties = "placeholder=%enter-the-top-limit-of-the-range",
          type = "numeric"
       )
       public String max();
    
       @DDMFormField(
          label = "%min-value",
          properties = "placeholder=%enter-the-bottom-limit-of-the-range",
          type = "numeric"
       )
       public String min();
    
    }
    
  2. There are two language keys for each setting: the label and the placeholder. Open c2p9-impl/src/main/resources/content/Language.properties and add these lines:

    max-value=Maximum Value
    min-value=Minimum Value
    enter-the-bottom-limit-of-the-range=Enter the bottom limit of the range.
    enter-the-top-limit-of-the-range=Enter the top limit of the range.
    
  3. Update the DDMFormFieldType class by adding/overriding the getDDMFormFieldTypeSettings method:

    @Override
    public Class<? extends DDMFormFieldTypeSettings>
       getDDMFormFieldTypeSettings() {
    
       return C2P9DDMFormFieldTypeSettings.class;
    }
    

Supporting Custom Settings in the Frontend

The frontend requires updates to the Slider.es.js to support user-entered min and max values and a DDMTemplateContextContributor so that the frontend can receive the setting values from the backend.

  1. Create a C2P9DDMFormFieldTemplateContextContributor class in the com.acme.c2p9.internal.dynamic.data.mapping.form.field.type package:

    package com.acme.c2p9.internal.dynamic.data.mapping.form.field.type;
    
    import com.liferay.dynamic.data.mapping.form.field.type.DDMFormFieldTemplateContextContributor;
    import com.liferay.dynamic.data.mapping.model.DDMFormField;
    import com.liferay.dynamic.data.mapping.render.DDMFormFieldRenderingContext;
    
    import java.util.HashMap;
    import java.util.Map;
    
    import org.osgi.service.component.annotations.Component;
    
    @Component(
      property = "ddm.form.field.type.name=c2p9-slider",
      service = DDMFormFieldTemplateContextContributor.class
    )
    public class C2P9DDMFormFieldTemplateContextContributor
      implements DDMFormFieldTemplateContextContributor {
    
      @Override
      public Map<String, Object> getParameters(
         DDMFormField ddmFormField,
         DDMFormFieldRenderingContext ddmFormFieldRenderingContext) {
    
         Map<String, Object> parameters = new HashMap<>();
    
         parameters.put("max", (String)ddmFormField.getProperty("max"));
         parameters.put("min", (String)ddmFormField.getProperty("min"));
    
         return parameters;
      }
    
    }
    
  2. Update the JavaScript component in Slider.es.js, removing the hard coded min and max values and instead allowing for the user to enter their values. The full file contents appear below:

    import {FieldBase} from 'dynamic-data-mapping-form-field-type/FieldBase/ReactFieldBase.es';
    import {useSyncValue} from 'dynamic-data-mapping-form-field-type/hooks/useSyncValue.es';
    import React from 'react';
    
    const Slider = ({max, min, name, onChange, predefinedValue, readOnly, value}) => (
       <input
          className="ddm-field-slider form-control slider"
          disabled={readOnly}
          id="myRange"
          max={max}
          min={min}
          name={name}
          onInput={onChange}
          type="range"
          value={value ? value : predefinedValue}
       />
    );
    
    const Main = ({
       label,
       max,
       min,
       name,
       onChange,
       predefinedValue,
       readOnly,
       value,
       ...otherProps
    }) => {
       const [currentValue, setCurrentValue] = useSyncValue(
          value ? value : predefinedValue
       );
    
       return (
          <FieldBase
             label={label}
             name={name}
             predefinedValue={predefinedValue}
             {...otherProps}
          >
             <Slider
                max={max}
                min={min}
                name={name}
                onChange={(event) => {
                   setCurrentValue(event.target.value);
                   onChange(event);
                }}
                predefinedValue={predefinedValue}
                readOnly={readOnly}
                value={currentValue}
             />
          </FieldBase>
       );
    };
    
    Main.displayName = 'Slider';
    
    export default Main;
    
  3. Redeploy the form field module. Once it’s processed (STOPPED → STARTED in the console), restart Liferay:

    ./gradlew deploy -Ddeploy.docker.container.id=$(docker ps -lq)
    
    docker container restart $(docker ps -lq)
    
  4. Test the Slider field in a form again. This time make sure you go to the Advanced tab in the field’s sidebar settings and try a different min and max setting.

    The Min and Max settings are now configurable.

Capability:
Feature: