4

I am trying to get started with creating a dynamic form in Angular 2, and I am using the setup from the Angular cookbook here as my starting point. I didn't have any issues with their setup, which just hard codes the data in the service as apposed to an api call. My issue is that when I try to use an api call the values don't seem to get set correctly.

In the Angular cookbook they have the question.service.ts file hard coded as:

getQuestions() {
let questions: QuestionBase<any>[] = [
  new DropdownQuestion({
    key: 'brave',
    label: 'Bravery Rating',
    options: [
      {key: 'solid',  value: 'Solid'},
      {key: 'great',  value: 'Great'},
      {key: 'good',   value: 'Good'},
      {key: 'unproven', value: 'Unproven'}
    ],
    order: 3
  }),
  new TextboxQuestion({
    key: 'firstName',
    label: 'First name',
    value: 'Bombasto',
    required: true,
    order: 1
  }),
  new TextboxQuestion({
    key: 'emailAddress',
    label: 'Email',
    type: 'email',
    order: 2
  })
 ];
   return questions.sort((a, b) => a.order - b.order);
 }
}

And then from the app.component.ts file its simply called from the constructor as:

 constructor(service: QuestionService) {
 this.questions = service.getQuestions();
 }

Which "questions" then binds to this in the app.component.ts template

 <dynamic-form [questions]="questions"></dynamic-form> 

I made changes to the question.service.ts to make an api call (now currently from a json file cause I don't have access to the api at home)

 getFirstQuestion() {
    return this._http.get(this.__questionJSONApiBaseUrl)
        .map(data => data.json())
        .do(data => console.log('All: ' + JSON.stringify(data)))
        .catch(this.handleError);
}

Which is called from the app.component.ts as

ngOnInit() {
    this.service.getFirstQuestion()
        .subscribe(data => {
            this.data = data;

            if (data.Type == 'TextBox') {
                let questions: QuestionBase<any>[] = [
                    new TextboxQuestion({
                        key: data.Title,
                        label: data.Text,
                        value: '',
                        required: true,                          
                        order: data.Id
                    })];
            }              
        }
        );
 }

As you see I set the properties inside of the .subscribe(), but it doesn't seem to be working correctly because when it binds to [questions] in the template I get a "Cannot read property 'forEach' of undefined" error which comes from the question-control.service file which transforms the question to a FormGroup.

I know that the data is coming in because I can set an alert inside the if statement and see the data from the api call successfully. I believe my issue is that [questions] is binding before the data is ready. Can someone tell me a better way to do this or please provide any suggestions to what I'm doing wrong please? Is there a way I could set the properties in the api first?

ppovoski
  • 4,553
  • 5
  • 22
  • 28
Bobby
  • 85
  • 2
  • 10
  • Likely due to your call being async, you'll need to guard against questions being null/undefined by assigning it to an empty list or using some other null guard technique. – silentsod Dec 16 '16 at 17:54

3 Answers3

1

The issue here, as @silentsod pointed out, is that you're doing an async operation and trying to store that as your questions. You need to handle the async.

You can go about this two ways...either, from the component class do:

service.getQuestions((questions) => this.questions = questions);

Or, you could, from the template, use the async pipe:

<dynamic-form [form]="questions | async"></dynamic-form>

The async pipe subscribes to the observable, in this case questions, and returns the value.

gonzofish
  • 1,417
  • 12
  • 19
  • I agree with the async issue since it works when its hard coded. I tried the async pipe in the template, but its not making a difference with my error. I don't see how your first suggestion of calling getQuestion() works with what I'm already doing or how it helps with async though? – Bobby Dec 16 '16 at 18:36
  • i'm sorry, i must've skipped over the last 1/3 of your question, not sure why. but in your sample in `app.component.ts`...you're setting `this.data` but the template uses `questions`...could that be it? – gonzofish Dec 16 '16 at 18:47
  • 1
    I also did not pay attention it seems. Zing. – silentsod Dec 16 '16 at 18:56
  • No worries! Yes "data" is just the object variable used in the subscribe method to initially store the data to before mapping it to the question variables. My data coming in is a bit different to what I'm setting to, not in type, but by name is all. – Bobby Dec 16 '16 at 19:08
  • but in your code sample, where are you setting the component class's `questions` variable? – gonzofish Dec 16 '16 at 19:27
  • I'm setting it inside of the ngOnInit where its 'let questions: QuestionBase[] = ' but playing around with a sample project it doesn't seem like this works the way I think it would. If I set a string equal to a value inside of ngOnInit and bind to it in my template in a test project it doesn't set, but if I set outside of the ngOnInit it binds with no issues? – Bobby Dec 16 '16 at 20:36
  • you need to set `this.questions` not just `questions`...plain old `questions` is just a local variable, not a class variable that is visible from templates. – gonzofish Dec 17 '16 at 05:21
  • Yes thank you. The way I posted it was incorrect. I was changing things around a lot, and ended up with it updating only locally which of course definitely won't work. However it still doesn't work updating with "this" either. – Bobby Dec 17 '16 at 20:47
  • i can't let this go unsolved...you're sure it's getting into that `if`? have you tried doing `
    {{ questions | json }}
    ` in your template to see if `questions` has anything in it? like, could it be that `dynamic-questions` isn't responding to the change in data?
    – gonzofish Dec 17 '16 at 22:09
  • Yes I know it hits the IF because I can set alerts inside of there and get the data passed in. The way I'm doing this doesn't work. Returning an Observable and then trying to map it inside ngOnInit to a custom class seems to be the wrong approach here. It sort of works if I Initialize the questions variable in the constructor, but then update with my data in ngOnInit, but the issue then is that question-control.service fails as in it doesn't receive any data (not that I'd want to do like that anyway). At this point I'm thinking that I need to modify the data inside of .map() in my service. – Bobby Dec 18 '16 at 16:46
  • So how could I return an Observable of type QuestionBase[]? Is that possible? I know that if my service returns the type my app.component is expecting I think things will work just fine. One thing that I have tried was putting the .subscribe() that I used (shown above) with my actual service function getFirstQuestion() but when I call it from my app.component like 'this.questions = this.service.getFirstQuestion();' it tells me that "type subscription is not assignable to QuestionBase[] property length is missing". There must be a way I can set things from the json data. – Bobby Dec 18 '16 at 17:01
1

I was able to get things working using ideas from this example with modifications to suit my needs.

The way I have it setup now is that the page loads and a user will have to click a start button to get their first question. So I am no longer calling for my first question inside of ngOnInt, but inside of my buttons click event method like so:

 getFirstQuestion() {
    //hide the begin survey button once clicked
    this.clicked = true;
    this.service.getQuestion()
        .subscribe(
        q => {
            this.questions = q
        });
}

Which getQuestion() in my service looks like this:

  getQuestion() {
    return this.http.get(this._questionApiBaseUrl + 'getFirstQuestion' + '?'              'questionId=' + this.questionId)
        .map(res => [res.json()])
        .map(data => {
            this.data = data;            
            return this.buildQuestions(data);
        })            
}

Which returns this buildQuestions() method:

 buildQuestions(data: any[]) {
    let questions: any[] = [];
    data.forEach((item: QuestionsGroup) => {
        console.log("my item control in buildQuestion: " + item.controlType);
        if (item.controlType == 'group') {
            let group = new QUESTION_MODELS[item.controlType](item);
            group.questions = this.buildQuestions(item.questions);
            questions.push(group);
        }
        else if (item.controlType == 'RadioButtons') {
            item.controlType = 'radio';
            questions.push(new QUESTION_MODELS[item.controlType](item));
        }
        else if (item.controlType == 'TextBox'){
            item.controlType = 'textbox'; 
            item.type = 'text'               
            questions.push(new QUESTION_MODELS[item.controlType](item));
        }
        else if (item.controlType == 'Datepicker') {
            item.controlType = 'textbox';
            item.type = 'date'
            questions.push(new QUESTION_MODELS[item.controlType](item));
        }
        //TODO add any remaining question types
        else {
            questions.push(new QUESTION_MODELS[item.controlType](item));
        }
    });
    return questions;
}

The buildQuestions() method above will get refactored later as there is currently a mismatch of property values coming from the api to the client side.

The way I was trying to do things before was to manipulate the data once it was returned from my call, but the binding to "questons" had already happened. Now when "questions" is returned from the http get call its in the correct format already when I subscribe to it. From here a user will submit an answer and get another question dynamically based on their answer.

Below is my service method to post the answer and get the next question (had to leave a few details out but you get the idea):

 submitAnswer(answer: any) {
    //Interface to formulate my answer 
    var questionAnswer: IAnswer = <any>{};     

   //set answer properties interface here

    let headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8' });
    let options = new RequestOptions({ headers: headers });
    let myAnswerObj = JSON.stringify(questionAnswer);

    return this.http.post(this._questionApiBaseUrl + 'submitanswer', myAnswerObj, options)
        .map(res => [res.json()])
        .map(data => {
            this.data = data;
            //return the next question to the user
            return this.buildQuestions(data);
        });          
}
Bobby
  • 85
  • 2
  • 10
0

The ngOnInit() function of dynamic-form.component.ts runs before we receive response from api.

below is the file

dynamic-form.component.ts

import { Component, Input, OnInit }  from '@angular/core';
import { FormGroup }                 from '@angular/forms';

import { QuestionBase }              from './question-base';
import { QuestionControlService }    from './question-control.service';

@Component({
  selector: 'dynamic-form',
  templateUrl: './dynamic-form.component.html',
  providers: [ QuestionControlService ]
})
export class DynamicFormComponent implements OnInit {

  @Input() questions: QuestionBase<any>[] = [];
  form: FormGroup;
  payLoad = '';

  constructor(private qcs: QuestionControlService) {  }

  ngOnInit() {
    this.form = this.qcs.toFormGroup(this.questions);
  }

  onSubmit() {
    this.payLoad = JSON.stringify(this.form.value);
  }
}
hvohra
  • 9
  • 3