Conditional Tasks in Dynamic Forms

matheo

Mateo Tibaquirá

Posted on May 6, 2021

Conditional Tasks in Dynamic Forms

TL;DR
We have support for conditional behaviors in @myndpm/dyn-forms documented at mynd.dev and we are able to provide them easily like the Controls, Validators, AsyncValidators, etc.

In complex forms use-cases, some controls directly depend on the value or status of some other form control. Then we implement custom behaviors, like hiding a field when another control has some value, or disabling it depending on a complex condition, etc.

To support this, we've added Matchers and Conditions, which can be provided just like the Validators and AsyncValidators as we saw in the previous chapter of this series. If you want to get an initial idea from the code, you can check this source file and this real use-case demo.

DynTreeNode

Each dynamic control has a composed Node service instance which holds the data of this point in the form hierarchy. It provides the API and the data to manipulate the form in a customized way when needed.

The node has the control Form Instance, the params object, some utility methods to query or select parent and child controls, manipulate the visibility, etc. We will use this node inside the Conditions, Matchers and any other custom Handlers.

Conditions

To match a special requirement, we need to define one or more conditions, so when all (AND) or one (OR) of them are fulfilled we perform a particular action. The Condition Function type consists of:

interface DynControlConditionFn {
  (node: DynTreeNode): Observable<any>;
}
Enter fullscreen mode Exit fullscreen mode

it streams a truthy value whenever the condition is fulfilled or not, for example, we could check if a specific control has the expected value:

(node: DynTreeNode) => {
  return node.query('specific.control').valueChanges.pipe(
    map(controlValue => controlValue === 'xValue'),
  );
}
Enter fullscreen mode Exit fullscreen mode

we can join these conditions with the required operator (AND | OR) for our use-case, and then evaluate the action to execute in the specific Matcher.

Matchers

We define our requirement with the Matchers that we want to run when all or a single condition is satisfied:

match: [{
  matchers: ['DISABLE'], // one or more matchers
  when: [{
    // the library provides a DEFAULT condition handler
    // to process path, value and negation
    path: 'other.field',
    value: 'expectedValue'
  }]
}]
Enter fullscreen mode Exit fullscreen mode

the DISABLE matcher is included in the library with ENABLE, SHOW, HIDE (display: none) and INVISIBLE (visibility: hidden).

One matcher consists of a function which performs a task in the form hierarchy; to do so, it receives the DynTreeNode instance:

interface DynControlMatcherFn {
  (args: {
    node: DynTreeNode;
    hasMatch: boolean;
    firstTime: boolean;
    results: any[];
  }): void;
}
Enter fullscreen mode Exit fullscreen mode

So, for example the DISABLE matcher operates into the form control when the specified conditions are fulfilled (has match):

{
  id: 'DISABLE',
  fn: (): DynControlMatcherFn => {
    return ({ node , hasMatch }) => {
      hasMatch ? node.control.disable() : node.control.enable();
    }
  }
},
Enter fullscreen mode Exit fullscreen mode

Advanced Stuff

This conditional processing enables us to do some additional logical operations, like negate the result of one or all the conditions, so we are able to play with conditions upside down and have the simplest specification of our requirements.

Matcher Example

For example, if we want to run a Matcher for all the options of a SELECT except a few of them, OR without another condition, we can define that requirement with the few known values instead listing all the other values (which can be a long list), and negate the matcher input:

match: {
  matchers: ['MyMatcherID'],
  operator: 'OR', // the operator is AND by default
  when: [
    {
      path: 'selectorName',
      value: ['A', 'B', 'C'] // this will check if selectorName.value is IN this array
    },
    {
      path: 'other.control',
      value: 'anotherValue'
    },
  ],
  negate: true
}
Enter fullscreen mode Exit fullscreen mode

the Matcher will receive hasMatch: true when the selector has a value NOT in the provided list.

Also note that you can provide your Matcher factories with a custom id like 'MyMatcherID' just like we will do with conditions in the following section.

Condition Factory

We can register Factories with an id and a fn as we do with Validators, and parametrize them in the Config Object:

export interface DynControlCondition {
  id: string;
  fn: (...args: any[]) => DynControlConditionFn;
}
Enter fullscreen mode Exit fullscreen mode

Remember that DynControlConditionFn returns an Observable<any> so you can implement and provide your custom conditions like:

const conditions = [{
  id: 'MyConditionId',
  fn: (...args: any[]) => { // Factory
    return (node: DynTreeNode) => { // Condition
      return node.control.valueChanges.pipe(map(...));
    }
  }
}];

@NgModule({
  imports: [
    DynFormsModule.forFeature({ conditions });
Enter fullscreen mode Exit fullscreen mode

Conditions Config

You can use your custom conditions in these ways:

// inline function
when: [
  (node: DynTreeNode) => {
    // manipulate the form via DynTreeNode
  }
]

// factory ID without arguments
when: [
  'MyConditionId',
]

// parametrized factory
when: [
  ['MyConditionId', args],
]

// or declarative inline config
when: [
  {
    condition: 'MyConditionId',
    path: 'other.control', // path is the only mandatory field in this format,
    param1: 'anyValue', // the whole object will be passed to your DynControlConditionFn
  },
]
Enter fullscreen mode Exit fullscreen mode

in the last notation the whole config object is passed to the Factory, that's how the DEFAULT condition handler receives the path, value and negate config values.

Note: If no value is configured, the DEFAULT handler emits true everytime the configured path control value changes:

id: 'DEFAULT',
fn: ({ path, value, negate }): DynControlConditionFn => {
  return (node: DynTreeNode): Observable<boolean> => {
    if (value === undefined) {
      return node.query(path).valueChanges.pipe(mapTo(true));
    }
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

We have covered most of the details of Matchers and Conditions, and how one or many conditions can be configured so when one or all of them are fulfilled, they trigger a matcher which can modify the state of the form hierarchy via the DynTreeNode API.

If you have an idea after this reading, or after using this library in an Angular App, please share it with us! :)

You can request features and join our discussions.

// PS. We are hiring!

💖 💪 🙅 🚩
matheo
Mateo Tibaquirá

Posted on May 6, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related