#Dynamic forms using signal API

68 messages · Page 1 of 1 (latest)

empty forge
#

Hello, I've being playing lately with the new signal API and here is my use case:
given an input model (data), fields (a structure describing each field in my model such as key, label, validators, children in case it is list or group) the schema becomes dynamic.
the problem is form signal can't be used with a computed signal that will depend on the combination of fields, model, it basically throws this: https://angular.dev/errors/NG0602.
I also saw that there is a restriction which is that the schema is a pure function that is not reactive.
I also tried to look up for helper functions that allows using validate, validateAsync outside the scope of creation but there are none, since the FieldPath is restricted only to that phase.

Are there currently any ways to achieve this ? will you in the future give the ability to create dynamic schema ? the old APIs supported this btw

The web development framework for building modern apps.

strong osprey
empty forge
#

thanks for the video, I have watched it, and yes it confirms what I said, the schema part is deterministic at the creation phase of the form. you still can change the items of your model since that part is reactive, but the validation part is statically defined at the beginning which is not the case in my use case.

In my case to simplify, imagine the user add a property at the root of the model, like age, then also wants to add a validation logic that was sent from JSON coming from the backend. in this case I need an api that allows me to update the validation schema

strong osprey
split pecan
# empty forge thanks for the video, I have watched it, and yes it confirms what I said, the sc...

Would really need to see an example in order to understand your particular use case, but there is this article on Angular Architects you might find helpful: https://www.angulararchitects.io/blog/dynamic-forms-building-a-form-generator-with-signal-forms/

Dies ist Beitrag 2 von 2 der Serie “Signal Forms” All About Angular’s New Signal Forms Dynamic Forms: Building a Form Generator with Signal Forms Dynamic Forms built by a form generator have quite a history in Angular. Such form generators enable us to build a form at runtime using metadata, such as field names […]

#

There are also the applyWhen and applyWhenValue properties which allow you to load schema conditionally.

empty forge
split pecan
#

Hard to say since many of these APIs are still experimental, but I wouldn't imagine the overhead would be much unless your dynamic validation is extensive.

empty forge
#

https://stackblitz.com/~/github.com/kouji-dev/ngk-forms the logic mainly resides in FormCompiler and ValidationEngine

run the demo and you will find the example at demo-form component.

the obstacle of creating validation is that there is a function called: assertPathIsCurrent, that is run inside validate for example to ensure its running only when the form in compilation phase aka creationg phase.

What im really looking for to simplify is the equivalent of:
https://angular.dev/api/forms/AbstractControl#setValidators
https://angular.dev/api/forms/AbstractControl#setAsyncValidators
https://angular.dev/api/forms/AbstractControl#addValidators
https://angular.dev/api/forms/AbstractControl#addAsyncValidators

The web development framework for building modern apps.

split pecan
#

So you're applying validation when the form is currently being created? Why? Nothing in the example seems like something applyWhen couldn't cover. Also unsure what benefit the ngx-forms library is providing you in this example.

empty forge
# split pecan So you're applying validation when the form is currently being created? Why? Not...
this.compiledForm = form(this.modelSignal, (p) => {
   applyWhen(p.root, () => true, (ctx) => {this.applyFieldValidators(this.fields(), p.root);});
});

the problem with a code like this, is that it isn't reactive to this.fields.

so if I initialize a form instance with form() signal, and I have to populate this.fields() from the server, the validations won't apply since the form() signal will already be created with empty schema, because initialy this.fields() is empty, and the model aswell

therefore I think the current API lacks a way to add/remove validations/schema in a post-creation phase.

btw i'm aware that i'm using an experimental API so it's fine having theses issues, currently we have already a system using the old api form controls that is working, and looking forward to use the new signals API

split pecan
#

Got it. I would prevent initialization of the form until the form data has returned from the server. That would gel with Signal Form API’s expectation.

Generally though, I cannot see a reason why you wouldn’t wait to have all of your data first — dynamic or not. Having that information prior to initializing your form would make this issue moot. But perhaps I’m just not understanding.

strong osprey
empty forge
#

will your suggestion makes sense to me, specially recreating the form each time, the problem is that I can't put form() inside computed it doesn't work.
it throws this:

NG0203: The `_Injector` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`
#

i also tried to put it inside an effect, or passing the injector, still doesnt work

split pecan
#

Your form component probably shouldn’t be sharing data fetching and form initialization responsibilities. If your parent component / container handles the data fetching and simply passes the value from your http request as an input to your form component, you can force the form to only be initialized once you have data — either by wrapping your form component in a @defer tag or marking the input as required.

#

To Dominic’s point this will recreate the form anytime the value of the input Signal changes.

empty forge
#

but in case the fields input changes the form wont be recreated, so i have to unmount and mount that component to be able to create a new form ?
not sure how your proposal solves my issue, because basically if we have updateSchema, or we can pass a reactive schema, that would solve the issue

split pecan
#

In what situation would you be adding / removing an input and not updating your model also? Your form structure is tied to the model you provide to it, so changing the structure of your form with its controls means updating the model itself.

#

If you want to present fields distinctly from your data model, then we're really discussing distinct form groups in the Signal Forms paradigm.

#

Hope the above makes sense

empty forge
#

can we have a voice chat ? it would be better I think, if you are available.

empty forge
#

just by passing the injector, it gives this:

ERROR RuntimeError: NG0602: effect() cannot be called from within a reactive context. Call `effect` outside of a reactive context. For example, schedule the effect inside the component constructor. Find more at https://v21.angular.dev/errors/NG0602
    at assertNotInReactiveContext (_root_effect_scheduler-chunk.mjs:4446:15)
    at effect (_resource-chunk.mjs:134:9)
    at FormFieldManager.createFieldManagementEffect (signals.mjs:2865:5)
    at form (signals.mjs:2919:16)
    at FormRenderer.compiledForm.ngDevMode.debugName [as computation] (form-renderer.ts:66:33)
    at Object.producerRecomputeValue (_signal-chunk.mjs:468:33)
    at producerUpdateValueVersion (_signal-chunk.mjs:158:10)
    at _FormRenderer.computed2 [as compiledForm] (_signal-chunk.mjs:408:9)
    at FormRenderer.fieldRenderingEffect.ngDevMode.debugName (form-renderer.ts:96:31)
#

so it was intentional to not allow dynamic schema, that's the only conclusion I can make

strong osprey
#

Now I see a computed but no runInInjectionContext

ruby mist
# empty forge ```ts this.compiledForm = form(this.modelSignal, (p) => { applyWhen(p.root, (...

this.compiledForm = form(this.modelSignal, (p) => {
applyWhen(p.root, () => true, (ctx) => {this.applyFieldValidators(this.fields(), p.root);});
});

the problem with a code like this, is that it isn't reactive to this.fields.

so if I initialize a form instance with form() signal, and I have to populate this.fields() from the server, the validations won't apply since the form() signal will already be created with empty schema, because initialy this.fields() is empty, and the model aswell

therefore I think the current API lacks a way to add/remove validations/schema in a post-creation phase.

Maybe it doesn't have to be reactive to this.fields()?
It only needs to react to modelSignal()

this.compiledForm = form(
  this.modelSignal,
  (p) => applyFieldValidators(untracked(this.fields), p.root))
); // untracked as an example
  • When you update your form via user-interaction, the New validations will apply.
  • If you want to "updateValueAndValidity" after you has changes to this.fields()
effect(() => {
  const _dep_ this.fields(); // track potential validatition changes
  this.modelSignal(old => ({ ...old })); // "updateValueAndValidity" 
});
#
this.modelSignal.update(old => ({ ...old }));
#

effect just for quick example, linkedSignal can work as well

empty forge
#
// Input signals
  fields = input<Field[]>([]);
  model = input.required<any>();
  modelSignal = linkedSignal(
    () => {
      const _ignored = this.fields();
      return this.model();
    },
    { equal: (a, b) => false }
  );
  options = input<FormOptions>({});
  
  compiledForm = form(this.modelSignal, (p) => {
    const fields = untracked(() => this.fields());
    this.formCompiler.applyFieldValidators(fields, p);
  });

this compiles the form corrctly but when the modelSignal changes the schema doesnt re execute, unlesss i do this:

compiledForm = form(this.modelSignal, (p) => {
    validateTree(p, (ctx) => {
     ...
    });
  })

when encapsulating the schema logic with validateTree, once the modelSignal changs what's inside the validateTree executes again which makes sense.

now this is not what I exactly need, since the applyFieldValidators under the hood will recursively visit every field and call validate/validateAsync etc which causes an error:

main.ts:5 ERROR Error: A FieldPath can only be used directly within the Schema that owns it, **not** outside of it or within a sub-schema.
    at Object.apply (built-in-validators.ts:11:5)
    at _ValidationEngine.applyBuiltInValidator (validation-engine.ts:100:23)
    at _ValidationEngine.applyValidators (validation-engine.ts:37:14)
    at _FormCompiler.applyFieldValidators (form-compiler.ts:69:35)
    at form-renderer.ts:72:25

this is because validateTree has the root path as input, and appereantly you can't nest validation utility functions: validate/validteAsync/apply/validateTree ... but calling applyFieldValidators without wrapping it doesn't re trigger so the validation schema wont update to the latest version of fields.

ruby mist
#

You can't use validateTree for that

#

You Field is fixed and desgined for the purpose of dynamic forms, there is not really a need for a new schema (?). i.e. only needs to run validations when fields changed. Try

private schema = schema<any>(p => {
  for (const field of this.fields()) {
    if (!field.key) continue;
    const path = (p as any)[field.key];
    if (!path) continue;
    if (field.validators) this.validationEngine.applyValidators(path, field.validators);
    // Keep it minimal, ignore the rest
  }
});

readonly compiledForm = form(this.modelSignal, this.schema);
empty forge
ruby mist
empty forge
#

anytime you can, we can vc if you're fine with that, it would be more efficient

ruby mist
#
private schema = schema<any>(p => {
  console.log('model or fields Changed'); // this should always print
  for (const field of this.fields()) {...}
  // ...
}
#

But if it doesn't always print, then there is some closure and need to use applyWhen / applyWhenValue, maybe they do have a purpose, otherwise we could just use if/else if (isRequired) { required(p) }

empty forge
#

it doesn only one time, when I debug It only triggers with empty fields

empty forge
ruby mist
#

it doesn only one time, when I debug It only triggers with empty fields

Then it is worse case scenario: iterate path instead of fields

empty forge
#

aaaah that might work, let me refactor..

ruby mist
#

Can path knows this:

if (field.fieldArray && field.fieldArray.length > 0) {

#

Can path know itself is an array without fields()

#

If yes, then it can work for sure

#

And this:

// Handle nested field groups (objects)
if (field.fieldGroup && field.fieldGroup.length > 0) {

fields() has too much knowledge (all the metadata)

#

hopefully path (or ctx, available in applyWhen) can know as well. Array.isArray(ctx.valueOf(....)) etc

#

Also note that don't pass the Value this.fields() to your services/functions, pass the Signal this.fields

#
applyFieldValidators(fields: Field[], ...); // ->
applyFieldValidators(fields: Signal(Field[]), ...)
empty forge
empty forge
ruby mist
# empty forge no i cannot use this one, since i cant call these helpers in a nested way

Pseudocode:

// () => Field[] is compatible with Signal<Field[]>
// root will call it with signal, recursion with call it with () => field
applyFieldValidators(fields: () => Field[], p?: FieldPath<any>) {
  const nPath = (<unknown>path) as Array<any>|Record<string, any>;
  if (Array.isArray(nPath)) {
    nPath.forEach((path, i) => {
      this.applyFieldValidators(() => fields()[i], path);
    });
  } else {
    for (const childPathKey of nPath) {
      this.applyFieldValidators(() => fields()[childPathKey], path[childPathKey]);
    }
  }
}
empty forge
ruby mist
#

rootPath[field.key] as FieldPath<any>
You can scan it with square bracket [] property accessor but can't iterate it?

#

Ah, you need some FieldNode that has .children() ?

#

i can't open your stackblitz, browser will "Oh snap. page crash"

#

Previously able to open it once

#

I need to run it to try, i don't have v21.
I think anything that allows [non_Symbol_Key] accessor, will allow iteration (but not the inverse). It is those that requires next() that can't access. Maybe it only doesn't work with Object.keys but for() may work

ruby mist
#

github.com/kouji-dev/ngk-forms

You may need to wholesale refactor to before this can work.

export interface Field {
  key: string;
  fieldGroup?: Field[]; // <-- ???
}

While understandable Database works better with flatten list of fields (That is fine)
App should (easily) transform a list of keyvalue to Record<string, Field>

export interface Field {
  key: string;
  fieldGroup?: Record<string, Field>; // <--
  fieldArray?: Field[];
}

Such that Field is same shape as FieldPath (also starts a root). Our recursive function will be easier to write

{
  key: "root",
  fieldGroup: {
    "product": {
      key: "product",
      required: true,
      fieldGroup: {
        "name": { key: "name", required: true }
        "category": { key: "category", required: true }
      }
    }
  }
}
#
applyFieldValidators(fieldSignal: () => Field|undefined, p: FieldPath<any>) {
  const nPath = (<unknown>p) as Array<any>|Record<string, any>;
  // traverse array
  if (Array.isArray(nPath)) {
    nPath.forEach((path, i) => this.applyFieldValidators(() => fieldSignal()?.fieldArray?.[i], path));
  }
  // traverse (if) nested object
  else if (Object.keys(nPath).length) {
    for (const childPathKey of nPath)
      this.applyFieldValidators(() => fieldSignal()?.fieldGroup?.[childPathKey], path[childPathKey]);
      // return; // don't return? Having formGroup may still have 'required', i.e. product itself is required
  }

  // Finally primitive value, actual application of validators
  applyWhen(p, () => fieldSignal()?.required, required);
}
readonly field = signal<Field>({ key: 'root', fieldGroup: {} });
form(this.modelSignal, schema(p => applyFieldValidators(this.field, p))
empty forge
#

I will have a look and get back to you, btw stackblitz link works fine, it will also install deps and run the demo for you automatically..

#

but so that you know, the

for (const childPathKey of nPath)

throws an error saying its not iterable..

I will make a minimal reproducible stackblitz and share it

ruby mist
# empty forge but so that you know, the ``` for (const childPathKey of nPath) ``` throws an e...

It was Pseudocode...
Maybe it is undefined

for (const x of undefined) {} // error not iterable
for (const x of null/123/false) {} // error not iterable

In the later snippet. path no longer optional undefined and checks if nPath has keys.

Although for other reasons rather than guard against error:

i.e. if it is not nested { [INTERNAL_SYMBOL]: wrappedFieldNode }, the key length will be 0. Object.keys will exclude keys that are symbol.
No need to unwrap to traverse the path, we can't unwrap a key when symbol is not exported.

function applyFieldValidators(... , p: FieldPath<any>) {
  const keys = Object.keys(nPath);
  if (keys.length) { ... }
chrome geyser
#

@empty forge were you able to work around this?

#

I'm also facing the same issue and it's due to the fact that the schema function is not reactive itself and the form model has no helper function to "recompute" the form tree

empty forge
#

exactly! I havent found any solution for that (I tried playing with the new api when it came out, but didnt check again since!), there is also an issue which is to dynamically add validation synchronously from outside the form signal hook! like the addValidator function we have today.