4

I have a page with multiple forms on it. Each form has a number of buttons on it. I would like to implement a loading spinner on buttons after they are pressed. When I use a normal click event I can pass in the button:

HTML

<button #cancelButton class="button-with-icon" type="button" *ngIf="hasCancel" mat-raised-button color="warn" [disabled]="isLoading" (click)="clickEvent(cancelButton)">
    <mat-spinner *ngIf="isLoading" style="display: inline-block;" diameter="24"></mat-spinner>
    Cancel
</button>

TS

clickEvent(button: MatButton) {
    console.log(button);
}

In this case the button element gets passed through and you can access it in order to add a loading class to the button.

However, if you try the same thing using a submit button it comes through as undefined:

HTML

<form (ngSubmit)="save(saveButton)">
    <button #saveButton class="button-with-icon" type="submit" *ngIf="hasSave" mat-raised-button color="primary" [disabled]="isLoading">
        <mat-spinner *ngIf="isLoading" style="display: inline-block;" diameter="24"></mat-spinner>
        Save
    </button>
</form>

TS

save(button: MatButton) {
    console.log(button);
}

In this case button is undefined because the *ngIf on the button blocks it from the scope of the form. I could remove the *ngIf and just hide the button, but this leaves the button on the DOM and I do not want to do that.

Here is a stack blitz: https://stackblitz.com/edit/angular-zh7jcw-mrqaok?file=app%2Fform-field-overview-example.html

I have tried adding an additional click event to the save button to set the button to loading but the click event fires first and set the button to disabled before blocking the submit event from being called.

Ive looked through the submit event but cant see anything that links back to the button that was clicked.

Any suggestions?

Curtis
  • 3,170
  • 7
  • 28
  • 44
  • First of all... why would you need to manipulate the dom like that (adding loading), any special reason? – AT82 Apr 29 '19 at 18:18
  • The save and cancel functions will call asynchronously to a web server. I want to disable the buttons and show a spinning icon on the button while the async call is being handled. I am making a dynamic form component. That can have multiple forms and multiple buttons on each form. So keeping track of which button is loading through a fixed variable seemed less efficient than just adding a `loading` class to the dom of the button. – Curtis Apr 29 '19 at 18:23
  • Ok, I get it. Some people just do this when they simply a boolean flag would suffice, but I see your reason in this case well :) – AT82 Apr 29 '19 at 18:24
  • @Curtis I'm not sure what your efficiency concern is here. The point is that Angular does this for by storing the state in the component instead of the DOM. It's less code and it is not less performant. – Ruan Mendes Apr 30 '19 at 12:59

5 Answers5

3

Update I understand your particular issue now. The problem is the *ngIf on the button. It's breaking the reference. Change it to [hidden] and it should work. Here's an updated StackBlitz: https://stackblitz.com/edit/angular-zh7jcw-pcuuyv

As for why it happen, I think this describes the problem pretty well: https://stackoverflow.com/a/36651625/64279

Original answer:

I wouldn't even pass it in. Just add this in your class:

 @ViewChild('saveButton') button;

Then you can reference it in your save method:

save() {
    console.log(this.saveButton);
}

But I will also add that calling function from interpolation is not a good idea as it can cause performance issues. Rather, you should subscribe to an event which sets a property, then reference that property in your view. That way, the function is only called when the event fires instead of every rendering of the page.

adam0101
  • 29,096
  • 21
  • 96
  • 174
  • The issue with this is that there can be a number of save buttons on the component. Because there can be a number of forms on the page. Even if I used ViewChildren, I would still need to know which form was being submitted to find out which save button was being clicked. – Curtis Apr 29 '19 at 18:35
  • Yet another "try this other approach even though your current approach should work". – Ruan Mendes Apr 29 '19 at 18:43
  • @Curtis, see my update. I tested it on you StackBlitz and it works. – adam0101 Apr 29 '19 at 18:46
  • @JuanMendes, I can't wait to see what you contribute. – adam0101 Apr 29 '19 at 18:48
  • @adam0101 nice investigation in Update – Kamil Kiełczewski Apr 29 '19 at 18:50
  • @adam0101 I'm not sure what the problem is and I prefer not to spout random suggestions to patch up a problem I don't understand. At the very least, your answer should explain that the OP's approach should work, but they could try this workaround... Don't claim this is an answer to a problem, as for your update, the OP's example has the button visible and it doesn't work. – Ruan Mendes Apr 29 '19 at 18:50
  • @JuanMendes maybe you should test it and see. It's not the value of the `*ngIf` that's breaking it, its the existence of the `*ngIf`. – adam0101 Apr 29 '19 at 18:58
  • Good catch, why didn't I see that! But some explanation to why this is would be useful and worth a thumbs up ;) – AT82 Apr 29 '19 at 19:08
  • Of course I tested it :) I didn't realize just the presence of `ngIf` would cause the problem. It fails even with `*ngIf=true`, seems to be angular bug. Now, why doesn't the problem happen on the other button? I still don't feel like the actual problem has been discovered. – Ruan Mendes Apr 29 '19 at 19:29
  • it's not a bug ;) This is something angular does under the hood, still waiting that @adam0101 would explain why this is. – AT82 Apr 29 '19 at 19:32
  • because in cancel button the `*ngIf` and event handling `clickEvent ` is in same node ` – Kamil Kiełczewski Apr 29 '19 at 19:40
  • @AJT_82, I added a link to an explanation. – adam0101 Apr 29 '19 at 19:48
  • Yes, I already figured out the `*ngIf` was breaking the reference. But I didnt want to just hide the button. Cause then the user can edit the code and turn the button back on. So I was looking for another solution. – Curtis Apr 29 '19 at 19:49
2

Instead of using the ngSubmit event handler on the form use a click event handler on the save button. Leave the type as submit so that it still works when the user uses the Enter key on an input control to submit the form.

<form #formElement="ngForm">
    ...
    <button #saveButton (click)="save(saveButton, formElement)" type="submit" ...>
        <mat-spinner ...></mat-spinner>
        Save
    </button>
</form>

You can also avoid needing to add the loading class to the DOM by using an object to keep track of which button is in the loading state. Working example here. This also allows you to have many buttons without having to create a fixed variable for each button.

class FormFieldOverviewExample {
  
  hasCancel = true;
  hasSave = true;
  isLoading = {};

  saveEvent(event, buttonName, form) {
    console.log(buttonName);
    if(form.valid) {
      this.isLoading[buttonName]=true;
      // ...
    } else {
      this.isLoading[buttonName]=false;
      console.warn("Form not submitted because it contains errors");
    }
  }

  clickEvent(event, buttonName) {
    console.log(buttonName);
    this.isLoading[buttonName]=true;
    // ...
  }
}
form { display: none; }
<form #formElement="ngForm">

  ...

  <button (click)="clickEvent($event, 'cancelButton')" [disabled]="isLoading['cancelButton']" *ngIf="hasCancel" type="button" ...>
    <mat-spinner *ngIf="isLoading['cancelButton']" ...></mat-spinner>
    Cancel
  </button> 
  
  <br><br>
  
  <button (click)="saveEvent($event, 'saveButton', formElement)"  [disabled]="isLoading['saveButton']" *ngIf="hasSave" type="submit" ...>
    <mat-spinner *ngIf="isLoading['saveButton']" ...></mat-spinner>
    Save
  </button>
</form>
Curtis
  • 3,170
  • 7
  • 28
  • 44
Kamil Kiełczewski
  • 85,173
  • 29
  • 368
  • 345
  • 1
    What is the problem with the OP's approach? Suggesting a workaround without understanding the problem is problematic. – Ruan Mendes Apr 29 '19 at 18:26
  • Also, this wouldn't work if user submitted the form using enter... I know, not the case here, but you should always handle the submit for good measure – Ruan Mendes Apr 29 '19 at 18:44
  • I tried this before, I seem to recall there being issues with submit. But I just tried it again... and I cant seem to find any issues, so maybe last time I just had a typo or bug or something. https://stackblitz.com/edit/angular-zh7jcw-mwklob?file=app/form-field-overview-example.ts .. works with submitting the form through the Enter button too... Doesnt answer the question, but it looks to solve my problem. Thanks. – Curtis Apr 29 '19 at 19:47
2

This actually has nothing to do with the submit function, but all to do with angular and structural directives. When you apply a structural directive (ngFor, ngIf...) what angular does under the hood is to create a ng-template node. What this means, that template reference variables that are defined in the scope of the structural directive is only available in that node.

So what you have here:

<button #saveButton *ngIf="hasSave">
  Save
</button>

means that saveButton template reference is only available inside that ng-template which is created for the button, and therefore not available in the submit function.

What we have here:

<button #cancelButton *ngIf="hasCancel" (click)="clickEvent(cancelButton)">
  Cancel
</button>

is that the click event is on the button, so cancelButton template reference variable is available for that event.

Using hidden instead of ngIf like suggested in other answer, solves this issue, since we just.. well, hide the element.

AT82
  • 71,416
  • 24
  • 140
  • 167
  • * only available in that node _and child nodes, but not parent nodes_ – adam0101 Apr 29 '19 at 19:51
  • I haven't seen this documented... Do you have a link? Here's an example showing that adding `*ngIf` makes the the element reference not available outside of that node:. https://stackblitz.com/edit/angular-zh7jcw-u3eakq – Ruan Mendes Apr 30 '19 at 12:57
  • Sorry for not making it clear in my original post, but I did know of this work around. I just didn't want to have the element hidden on the DOM, I was looking for a solution where the `*ngIf` is still there. – Curtis Apr 30 '19 at 13:09
  • @Curtis I explained why this is, you need to do some kind another workaround, with ngIf, for example with `hidden`, but as your code sits in your question... structural directives just does this, without you having any control over it. But glad that you found a solution suitable for you :) – AT82 Apr 30 '19 at 16:45
  • @JuanMendes Yes, this is just how angular and structural directives work. Documentation is surprisingly hard to find, when I looked for it... here's one post though that I found: https://weblogs.thinktecture.com/thomas/2017/05/use-angular-template-reference-variables-anywhere-in-the-template-not.html – AT82 Apr 30 '19 at 16:46
2

tl;dr Here is my solution in action: https://stackblitz.com/edit/angular-zh7jcw-ythpdb


Edit: As I just noticed, @AJT_82 explains OP's original problem with the reference element very well. So I now removed my earlier answer.


However I would suggest the following solution that would fit much better in terms of separation of concerns and doing things in angular way, that is by introducing two new members cancelActive and submitActive, you will be able to handle the different states of the form more flexibly.

Here you can see it in action: https://stackblitz.com/edit/angular-zh7jcw-ythpdb

export class FormFieldOverviewExample {
  cancelActive = false;
  submitActive = false;

  // this is for submit event
  saveEvent(event: Event) {
    // additional logic can be added
    // if(!this.cancelActive) return;

    this.submitActive = true;
    this.cancelActive = false;
  }

  // this is for cancel event
  cancelEvent(event: Event) {
    // additional logic can be added
    // if(!this.submitActive) return;

    this.submitActive = true;
    this.cancelActive = false;
  }

}
<form (ngSubmit)="saveEvent($event)">
  <button  class="button-with-icon"  type="button"  *ngIf="hasCancel"  mat-raised-button color="warn"  [disabled]="cancelActive"  (click)="cancelEvent($event)">
    <mat-spinner *ngIf="cancelActive" class="spinner" diameter="15"></mat-spinner>
    Cancel
  </button>
  <br><br>
  <button class="button-with-icon" type="submit" *ngIf="hasSave" mat-raised-button color="primary" [disabled]="submitActive">
    <mat-spinner *ngIf="submitActive" class="spinner" diameter="15"></mat-spinner>
    Save
  </button>
</form>

This way you would also have more playground, such as:

<button type="submit" [disabled]="submitActive || cancelActive">...</button>

You can simply disable the submit button without showing the loading animation for it, if you want to do it for some reason...

Further, suppose you have an <input type="text" /> element in your form. User switches to that element (via click or tab press) and after he is done with editing he presses the Enter key, in this case, the form is also submitted automatically by the browser; so this would make things even more complicated in your existing code structure.

  • 1
    As mentioned by [@AJT_82](https://stackoverflow.com/a/55909882/227299) This scoping of the element reference only occurs within structural directives. You should update the answer to include that information. Here's an example where you can get a sibling's reference if it does not contain an `*ngIf` I haven't seen this documented... Do you have a link? Here's an example showing that adding `*ngIf` makes the the element reference not available outside of that node:. https://stackblitz.com/edit/angular-zh7jcw-u3eakq – Ruan Mendes Apr 30 '19 at 12:53
  • This requires fixed variables for each button. Not what I'm after. While my demo did have fixed variables: `hasCancel` and `hasSave`. In reality I dont have these variables in my code. The component would dynamically make as many buttons and forms as the API tells it to. – Curtis Apr 30 '19 at 14:08
  • @Curtis Thanks for the info. Since this spec you mention is part of your problem and seems important, I'ld have included it in the question description, so that the others would be aware of it. I'm glad you solved your problem though. –  Apr 30 '19 at 21:42
  • Hey @JuanMendes, thanks for the hint! To be honest, I was quite lazy yesterday and didn't read any of those answers. Instead I wrote my own. Anyway, I just updated my answer accordingly. To your question: I have no link, I just knew it based on my experiences. :) –  Apr 30 '19 at 23:56
  • @JuanMendes This might be slightly easier to read, but it has an overhead cost and isn't flexible. – Curtis May 01 '19 at 14:10
  • @Kenan Güler I didn't include that in my post because it wasn't relevant to my question. Yes it would help others suggest different workarounds that might help me in my specific use case but workarounds usually only work in a very limited scope. They wouldn't necessarily work in all use cases the might require the reference to the button. – Curtis May 01 '19 at 14:31
1

FOr your case, you should not pass the element to the function and then manipulate the class on it but rather simply maintain a variable for loading state. In your component, initialize it as

loading: boolean = false;

And in your template, bind this class as

[class.loading]="loading"

In your method, set this property initially to true and then to false once the async call is complete. Similary use the same variable to set disabled attribute as

[disabled]="loading"
Saksham
  • 9,037
  • 7
  • 45
  • 73
  • I am making a dynamic component that can have multiple forms and multiple buttons on each form. So keeping track of which button is loading through a fixed variable seemed less efficient than just adding a loading class to the dom of the button. If I did it this way, I would need to make a matrix of loading variables and have to keep traversing that matrix to check each buttons state. – Curtis Apr 29 '19 at 18:38