angular-schema-form icon indicating copy to clipboard operation
angular-schema-form copied to clipboard

[WIP] oneOf/anyOf support

Open gazpachoking opened this issue 10 years ago • 49 comments

This PR adds a new basic form type 'formselect', which can show different forms based on a dropdown. I have it wired up to automatically use the formselect with a schema that has oneOf, anyOf, or types as an array. The extendSchemas helper is added (based on code from json editor) to combine schemas together. It's very rough, as I just got it working and I'm not an html or js guy. Thought I'd get it out here for feedback, or if anyone wants to fork this and run with it, that sounds good to me too.

TODO:

  • [ ] tests
  • [x] make it auto select a form based on data type from model
  • [ ] make it prettier?
  • [x] also use extendSchemas helper to add allOf support
  • [ ] make sure names in selector are unique
  • [ ] make generated forms for each branch overridable by user set ones from form

gazpachoking avatar Aug 13 '15 05:08 gazpachoking

Hey @gazpachoking great that you put it out there :+1: This is on the roadmap for us pretty soon and I've already done some proof of concept on it myself. I won't merge directly, but as soon as the new builder is up and running I'll take a look at it!

davidlgj avatar Aug 13 '15 13:08 davidlgj

@davidlgj Good to hear. How is your proof of concept going about it (that's all this pr is at this point too)? Just trying to figure out if I should continue with this, or if there is some sort of better way to structure a solution that you are already planning. I spent most of my time just reading the code so far, and the method I've implemented seemed like the path of least resistance, though I'm not sure if it's actually a good one.

gazpachoking avatar Aug 13 '15 20:08 gazpachoking

Honestly I can't remember! I did it before going on vacation for the summer :) I'll try to find the branch later today. But I think I must be doing something similar.

davidlgj avatar Aug 14 '15 06:08 davidlgj

Ok, so I found my code I pushed it here: https://github.com/Textalk/angular-schema-form/tree/feature/something-of-other and relevant commit with diff here https://github.com/Textalk/angular-schema-form/commit/9d58aca6fc002931208da21ff6f215efb3122b90

I taken a similar approach but managed without a directive, only did anyOf, with the specific case the you have a no type and just a anyOf. It's a proof of concept I'm lacking the extending of the schemas though.

One of the things I was aiming at was making the form definition similar to what arrays has. I.e. you need to be able to have a form def that specifies that the input in this version of the anyof should have this placeholder, or have this type etc. So for that I use a key notation with {nr} where nr is the index in the anyOf array. Ex. of what I'm aiming at:

var schema = {
  type: 'object',
  properties: {
     subform: {
        title: 'Subform',
        anyOf: [
          {
            type: 'object',
            properties: {
              name: {type: string}
              comment: {type: string}
            }
          },
          {
            type: 'object',
            properties: {
              name: {type: string}
              email: {type: string}
            }
          }
        ]
     } 
  } 
};

var form = [
  {
    key: 'subform',
    children: [ // named "items" in my poc
      [
        'subform{0}.name',
        {
          key: 'subform{0}.comment',
          type: 'textarea'
        }
      ],
      [
        'subform{1}.name',
        {
          key: 'subform{1}.email',
          type: 'text',
          pattern: '^\S+@\S+$'
        }
      ]
    ]
  }
];

As I said before I need to get the new builder up and running (almost there, mostly testing and docs left), but we should be looking at anyOf support soon. Please keep me updated if you make any further efforts on it!

davidlgj avatar Aug 14 '15 13:08 davidlgj

Yeah, we have similar things going on. I'm just using the directive to do stuff like switch the form based on the model data, and switch the model data based on the selected form. I'll take a closer peek at your ideas and see what sort of modifications would be good.

I was also struggling with how to manually specify the form definitions for the different schemas, I'll see what I can come up with.

gazpachoking avatar Aug 14 '15 14:08 gazpachoking

var form = [ { key: 'subform', children: [ // named "items" in my poc [ 'subform{0}.name', { key: 'subform{0}.comment', type: 'textarea' } ], [ 'subform{1}.name', { key: 'subform{1}.email', type: 'text', pattern: '^\S+@\S+$' } ] ] } ];

Do we really need the subform name or number in the key though? That form will only be used when selected, and the key is normally pointing to the position in the model, not the position in the form right? With arrays it's a bit different, as we are creating forms for different model locations, (the array indexes,) whereas here all the forms are all for the same model location.

gazpachoking avatar Aug 14 '15 15:08 gazpachoking

Hmmm.... you might be right! If the index in the form definition sort of specifies which subform it belongs to, like this:

var form = [
  {
    key: 'subform',
    children: [ 
      [  // Since this is index 0 we now it's index zero in the anyOf 
        'subform.name',
        {
          key: 'subform.comment',
          type: 'textarea'
        }
      ],
      [ // Since this is index 1 we now it's index one in the anyOf 
        'subform.name',
        {
          key: 'subform.email',
          type: 'text',
          pattern: '^\S+@\S+$'
        }
      ]
    ]
  }
];```

btw the bootstrap decorator in the development branch will soon be deprecated in favor of the new one here https://github.com/Textalk/angular-schema-form-bootstrap 

Just so you know :) 

davidlgj avatar Aug 15 '15 16:08 davidlgj

Yep, index order is what I was going with, but I currently don't allow the array definition of a form. (using 'items' rather than 'children' as well atm) Am I right in thinking that the array type form definition is just a shortcut for a 'fieldset' type with that array as the 'items' key?

gazpachoking avatar Aug 15 '15 18:08 gazpachoking

any updates or progress on this?

stevezau avatar Sep 01 '15 13:09 stevezau

@davidlgj are you planning on using this brach or writing your own oneOf/anyOf support?

I'd like to start work on this PR as the project i'm working one requires this support but i didn't want to waste effort if you are working on a different branch?

stevezau avatar Sep 08 '15 02:09 stevezau

Alright @gazpachoking and @stevezau. Feels like we need to get this done :) I'm going to try to get time to look at your PR @gazpachoking and modify it to work with the new builder next week. And when I have something moderately broken I'll do a push a branch in this as well as the angular-schema-form-bootstrap repo so we can collaborate.

davidlgj avatar Sep 18 '15 13:09 davidlgj

@davidlgj Awesome, sounds good :smile:

gazpachoking avatar Sep 18 '15 15:09 gazpachoking

@davidlgj great! I'm keen to get this completed!

stevezau avatar Sep 19 '15 23:09 stevezau

@gazpachoking @stevezau @DanielSchaffer Hi all!

I've merged this PR @gazpachoking into https://github.com/Textalk/angular-schema-form/tree/gazpachoking-oneOf and the template and builder part into https://github.com/Textalk/angular-schema-form-bootstrap/tree/feature/anyof-or-bust

I'm thinking any further development should happen in these branches. And I love to get your help on this :smile:

To test it, check out both branches. Build them both (gulp watch is nice during development). Symlink angular-schema-form into angular-schema-form-bootstrap/bower_components/. Start a server (http-server or puer for instance) and load up angular-schema-form-bootstrap/examples/bootstrap-example.html in a browser. I've prepared a anyOf example.

The first form, a anyOf with to different types seems to work somewhat, but the second, an array with anyOf in items does not.

So I also merged in some things from my own stab at anyOf support. Let me explain some things. If you look at the example you will that in the form definition the key property for subforms of anyOf options have strange keys, for ex. `"key": "name.{1}". This signifies that it's the second schema in the anyOf list of schemas.

Let me explain why this is needed. ASF works like this. #1 It starts by creating a default form definition from the schema, let's call it default form. #2 While creating the default form it creates a map object (options.lookup) between form key and it's form definition. I.e. something like {"name": { "key": name ....}, "email": {...}} #3 Next it loops over the form definition and checks each form object for a key and checks the lookup mapping for it's matching default form. This is then used to extend the current form with missing options and especially hooking it up to it's part of the schema.

So one property in the schema that is an anyOf will as a default create 1+n form objects, one for the actual form select and one each for the "items", i.e. the different schemas in the anyOf array. (here is the code https://github.com/Textalk/angular-schema-form/blob/gazpachoking-oneOf/src/services/schema-form.js#L296)

So you see we have a problem with the lookup, since all n+1 form objects would get the same key! ("name" in my example) Therefore I added the syntax .{index}.

I think this is at least necessary internally, but maybe not needed in the user supplied form definition if we're smart in the "merge" function here: https://github.com/Textalk/angular-schema-form/blob/gazpachoking-oneOf/src/services/schema-form.js#L434 We can probably check for "formselect" and then add proper .{index} to the key before lookup. An similarly if the user has specified (if they for instance want to change the order of the subforms) we can strip them out before rendering.

Stripping key of .{...} before rendering is needed anyway to get proper ng-model binding and name attribute etc.

I also changed from ng-show to ng-if since if the subform is in the DOM it will be validated.

Ok. So what's left? A lot. Non exhaustive list:

  • "clean" form key at the end of "merge" function
  • make sure we operate on a copy of the schema since we change it (angular.copy in schema-form directive, form too) This is a breaking change for some that are used to changing the form or schema instance. And ties into several other issues.
  • since we use ng-if we need to take destroy strategy into account.
  • select the first subform that validates (not counting required errors) as the selected form.
  • Should we clear fields that get validation errors when changing subform? In my example the anyOf changes the type of the field. So in that case we like to clear it right? @gazpachoking's pr has some code handling this, but I might have broken it :)
  • Fill in defaults when selecting a subform.
  • Make it look more pretty?

davidlgj avatar Sep 21 '15 10:09 davidlgj

Trying to get my environment set up properly (all these js tools are new to me, npm, bower, gulp.) Then I'll try to grok the changes and see what I'm thinking.

gazpachoking avatar Sep 21 '15 23:09 gazpachoking

I've submitted a PRs to each of the branches to fix a couple things, including the dists not including all the changes. Once that was all settled (and I changed my local angular-schema-form-bootstrap bower config fork to look at my fixed angular-schema-form repo), I was able to load the example correctly.

However, this code only seems to work when switching between different properties, but not when you want to switch between entirely different schemas - for example, a "person" schema:

{
  "type": "object",
  "title": "person",
  "properties": {
    "name": {
      "type": "string"
    },
    "age": {
      "type": "number"
    },
    "primaryTransportation": {
      "type": "object",
      "oneOf": [{
        "title": "pedestrian",
        "properties": {
          "type": {
            "type": "string",
            "enum":["pedestrian"]
          }
        }
      }, {
        "title": "automobile",
        "properties": {
          "type": {
            "type": "string",
            "enum": ["bicycle", "moped", "motorcycle", "car", "truck", "suv", "van"]
          },
          "make": {
            "type": "string"
          },
          "model": {
            "type": "string"
          }
        }
      }, {
        "title": "public transit",
        "properties": {
          "type": {
            "type": "string",
            "enum": ["bus", "subway", "train", "other light rail", "shuttle"]
          },
          "route": {
            "type": "string"
          }
        }
      }]
    }
  }
}

Given the above, I'd expect to see:

  • A <select> populated with the titles/names of each of the schema options
  • The correct schema pre-selected given model data
  • The correct form shown when selecting a schema

I'm still pretty new to JSON schema, so please let me know if this is completely off-base - but it's the pattern that I'm needing to support for my app.

I was actually able to get it to generate the form using an add-on. It doesn't work alongside the changes you guys made, but here's what I've got so far:

// someOf.js
angular.module('schemaForm').config(['schemaFormDecoratorsProvider', 'schemaFormProvider', 'sfBuilderProvider', 'sfPathProvider', function (
    decoratorsProvider, sfProvider, sfBuilderProvider, sfPathProvider) {

    var ofNodeTypes = {
        oneOf: 'oneof',
        anyOf: 'anyof',
        allOf: 'allof'
    };

    function someOfSelector(name, schema, options) {
        if (!schema[schema.type] || !ofNodeTypes[schema.type]) {
            return null;
        }

        var selectorPath = options.path.slice();
        selectorPath.push('selected');

        var f = sfProvider.stdFormObj(name, schema, options);
        f.key = selectorPath;
        f.type = ofNodeTypes[schema.type];
        f.titleMap = _.chain(schema[schema.type])
            .map(function (item) {
                return [item.title || item.name, item.title || item.name];
            })
            .object()
            .value();
        f.selector = f;

        options.lookup[sfPathProvider.stringify(selectorPath)] = f;

        return f;
    }

    function someOf(name, schema, options) {
        var node = _.first(_.intersection(_.keys(ofNodeTypes), _.keys(schema)));
        if (!node) {
            return null;
        }

        var fieldset = sfProvider.defaultFormDefinition(name, _.omit(schema, node), options);
        if (!fieldset) {
            debug.warn('no fieldset!');
            return null;
        }

        var selector = sfProvider.defaultFormDefinition(node, _.extend(_.pick(schema, node, 'title', 'name'), { type: node }), options);
        if (!selector) {
            debug.warn('no selector!');
            return null;
        }
        fieldset.selector = selector;
        fieldset.items.push(selector);

        angular.forEach(schema[node], function (item) {
            var optionForm = sfProvider.defaultFormDefinition(item.title || item.name, angular.extend(item, { type: 'object' }), options);
            if (optionForm) {
                optionForm.selector = selector;
                optionForm.condition = 'form.selector.current === \'' + (item.title || item.name) + '\'';
                fieldset.items.push(optionForm);
            }
        });

        return fieldset;
    }

    var simpleTransclusion  = sfBuilderProvider.builders.simpleTransclusion;
    var ngModelOptions      = sfBuilderProvider.builders.ngModelOptions;
    var ngModel             = sfBuilderProvider.builders.ngModel;
    var sfField             = sfBuilderProvider.builders.sfField;
    var condition           = sfBuilderProvider.builders.condition;
    var array               = sfBuilderProvider.builders.array;


    sfProvider.prependRule('object', someOf);

    _.each(ofNodeTypes, function (type, node) {
        sfProvider.prependRule(node, someOfSelector);
        decoratorsProvider.defineAddOn('bootstrapDecorator', type, 'view/decorators/bootstrap/someof.html', [
            sfField, ngModel, ngModelOptions, condition
        ]);
    });
}]);
<!-- view/decorators/bootstrap/someof.html -->
<div ng-class="{'has-error': form.disableErrorState !== true &amp;&amp; hasError(), 'has-success': form.disableSuccessState !== true &amp;&amp; hasSuccess(), 'has-feedback': form.feedback !== false}" class="form-group {{form.htmlClass}} schema-form-select"><div>form.selector {{form.selector.current}}</div><label ng-show="showTitle()" class="control-label {{form.labelHtmlClass}}">{{form.title}}</label><select name="{{form.key.slice(-1)[0]}}" ng-model="form.selector.current" ng-disabled="form.readonly" ng-options="item.value as item.name group by item.group for item in form.titleMap" class="form-control {{form.fieldHtmlClass}}"></select><div sf-message="form.description" class="help-block"></div></div>

The way it works is that it adds a new rule for the "object" type which creates a new fieldset, schema selector, and subforms when it encounters an object that has one of the "someOf" properties (oneOf/anyOf/allOf). There are also new form type rules added specifically for the "someOf" types which are responsible for generating the aforementioned schema selector (see someOfSelector). The decorator is basically a modified version of the existing "select" bootstrap decorator. What I like about how all this works is that it was still based on all the existing functionality - so regardless of what structure the schemas for the "someOf" property are, it is still able to recurse into the structure and generate all of the required subforms.

~~The problem I was running into was finding a way to store the state of the selector without using the model, since using the model would add an extra field that wasn't actually part of the schema. You can see that I'd started trying to use the form to store that state, but I hadn't been able to get it to work yet (I know I'd need to change ASF to make the form object available for the condition handler, but I couldn't get that to work right either).~~

In order to solve the issue of storing the "selected" state in order to show the correct form, I'm sort of abusing JS object references - I put a reference to the selector form on each of the fieldset, subforms, and the selector itself so they can all look at the same thing, and then the selector's ng-model is bound to form.selector.current. The same expression is used to in the condition applied to the subforms so they are correctly shown or hidden.

I think my solution is also sort of predicated on the "type" property pattern I have in my above example

  • there'd need to be a solution for labeling schema options that don't have a title or name.

What this doesn't cover yet is preselecting the right schema, and I'm also not sure that anyOf/oneOf/allOf validation is working correctly, though the individual fields in the subform to get validated.

I'm sure I'm misusing stuff with this solution though, so if that's the case, feel free to point it out.


Updated: Made some changes in my add-on code and updated the description to reflect it.

DanielSchaffer avatar Sep 22 '15 00:09 DanielSchaffer

I put my code in a new repo: https://github.com/DanielSchaffer/angular-schema-form-someof

It now has a solution for preselecting the correct schema given a model - see the sfSomeOfSelector and sfSomeOf directives.

Again, I feel like I'm doing some odd contortions with object references and the form object in order to accomplish these things, so feel free to point out if anything is particularly bad. But this appears to be working for the usage pattern I described above. Let me know what you think!

DanielSchaffer avatar Sep 23 '15 23:09 DanielSchaffer

@davidlgj I made a couple PRs to your branches which fixes the example at least a bit. I think the reason I wasn't using ng-if, is because when that bit of form gets destroyed, the model value gets deleted as well, which is I believe why the directive isn't working currently. I'll have a look at that next.

@DanielSchaffer It's hard to evaluate your changes, since it isn't a fork of this repo, so I can't make github give me a nice diff.

gazpachoking avatar Sep 28 '15 23:09 gazpachoking

@gazpachoking it wouldn't be a diff since it's an addon - it's all new code. That said, it is dependent on changes to asf that I did do in a fork (https://github.com/DanielSchaffer/angular-schema-form/tree/danielschaffer-someof)

DanielSchaffer avatar Sep 28 '15 23:09 DanielSchaffer

Ahh. Guess I didn't read your stuff carefully enough, I'll have another look through.

gazpachoking avatar Sep 29 '15 00:09 gazpachoking

@gazpachoking I merged your PR's and I like that you thought of the example with a formselect used without an anyOf in the schema!

@DanielSchaffer I just skimmed the code of your add-on, I'll try to take a deeper look later. But I did add the example schema you used in a previous comment: https://github.com/Textalk/angular-schema-form/pull/505#issuecomment-142142573

And after @gazpachoking fixes it seems to render properly! Still a lot left, but nice to see that it's coming along :)

davidlgj avatar Oct 02 '15 09:10 davidlgj

@gazpachoking Just wanted to mention a thing I saw but have no immediate fix for: https://github.com/Textalk/angular-schema-form/blob/gazpachoking-oneOf/src/directives/formselect.js#L28

So here we like to validate the sub form. Sadly the sfValidator does not handle validating a chunk of a form, it just validates a form field. So we get an exception here on fieldsets for instance.

So one way to fix this is to wrap each subform in an ng-form and us the schemaValidate event to validate each field and then check the subforms vaidation status. This can probably work but can lead to validation errors showing etc.

We could also drop down to using tv4 directly on each subform and it's model. This could be very nice if we use tv4.validateMultiple(data, schema); because I think a model update should select a subform that validates, and failing that the subform that has only "required" errors and as a last resort the subform that has the least amount of errors.

But this leaves us in a different pickle since we then don't take into account fields that are in the schema but not in the form definition! So if we use the tv4 method we need to filter out errors not in the subform (doable I guess...).

A third solution might be recursively traverse items and call validate on each field that has some sort of validation. This might be the cleanest solution...

davidlgj avatar Oct 02 '15 10:10 davidlgj

How's the progress going with this??

stevezau avatar Oct 19 '15 11:10 stevezau

@davidlgj I like the idea of using tv4 directly, I do that for my own basic implementation of anyOf, but I was curious if you think we should start wrapping tv4 in a wrapper so that in future we can replace it with something like is_my_json_valid or some other tool?

As an aside, for my own version I use properties value to define the form and anyOf to do the validation against, so that is another potential use case to consider where a user has both as I am fairly sure that is still a valid use.

Anthropic avatar Oct 28 '15 22:10 Anthropic

Hey All,

So how is this coming along?? Any updates ?

stevezau avatar Dec 14 '15 22:12 stevezau

@stevezau this is something we want to have in v1, still trying to organise when that will be given so many people with so many busy schedules.

Anthropic avatar Apr 11 '16 10:04 Anthropic

@gazpachoking do you have any response to @davidlgj 's last post?

Anthropic avatar Apr 11 '16 10:04 Anthropic

So I followed @davidlgj:s instructions above and it worked, to my amazeballsment. And monkeypatched into my system, it worked as well and that made me so happy. :-)

WRT to todo list: 2 . Make sure we operate on a copy of the schema If this is breaking, then that change should be done in 1.0, as we can't break things in the minors, where this functionality appears. Remember the semver rules, people. :-) (also, I am feeling that dynamically changing the schema seems like a slightly non-pretty design choice :-))

3 . Select the first subform that validates (not counting required errors) as the selected form. Agree on that, I don't see that there is any other solution to that beyond being super-advanced about it, and those solutions never hold up. Rather then to have that as default and add some kind of callback so the user can do whatever.

5 . Should we clear fields that get validation errors when changing subform? Not sure. But IMO no. I feel that one probably has started filling in another field because one have forgotten to change to the right form type. If a same-named field exists in the other form, it shouldn't be cleared, it may have a different requirement nonetheless.

7 . Make it look more pretty? It looks like if follows the general look to me? So no. And if that would be a thing, it is a bootstrap issue, and minor at best.

nicklasb avatar Apr 15 '16 22:04 nicklasb

Preface: I haven't touched the code in a while, so my input is based on memory. I also haven't had much motivation/free time alignment for this recently, but I'll at least comment.

2 . Make sure we operate on a copy of the schema If this is breaking, then that change should be done in 1.0, as we can't break things in the minors, where this functionality appears. Remember the semver rules, people. :-) (also, I am feeling that dynamically changing the schema seems like a slightly non-pretty design choice :-))

I don't think this breaks any current functionality, the display components will just get the edited schema, dynamically merged/edited from the input schemas.

As for dynamically merging schemas, I think ultimately, it's what any solution needs to do. The display componentry needs one set of things to know what it should display. This choice just keeps the merged format still expressed in json schema. Perhaps there is a better place to do it though, not sure.

3 . Select the first subform that validates (not counting required errors) as the selected form. Agree on that, I don't see that there is any other solution to that beyond being super-advanced about it, and those solutions never hold up. Rather then to have that as default and add some kind of callback so the user can do whatever.

:+1:

5 . Should we clear fields that get validation errors when changing subform? Not sure. But IMO no. I feel that one probably has started filling in another field because one have forgotten to change to the right form type. If a same-named field exists in the other form, it shouldn't be cleared, it may have a different requirement nonetheless.

I think right now it's storing the data separately for each form choice (or at least that was my goal,) such that when you go back to the same choice, you have the last data you entered for that anyOf/oneOf selection. Whether it can try to intelligently transfer the data as the forms are changed I'm still not sure.

gazpachoking avatar Apr 20 '16 01:04 gazpachoking

I have used it for a couple of days now, and I see just one problem with adding this like it is, and that is automatically selecting the first validating type, which I suppose must be done for it to be editable.

But even that could be left in, actually, in my view this is about being able to start development of projects that use this functionality, if that is in the next minor, that will do it at least for me, for now.

Neither of the other issues with it that have been brought up is a show stopper in my view.

They can all be seen as additional and non-breaking functionality, and as such they can be added in subsequent releases. It might even be a good idea to let them ferment for a while.

WRT to copying the schema, it breaks in a 1.0, and only really ugly hacks, so I see no huge issues with that either.

nicklasb avatar Apr 20 '16 08:04 nicklasb