2

EDIT regarding question title change

The original question title was this:

Angular 2/4 Observable - how to modify the object that emits values inside the subscribe without firing more events?

After extensive investigation that was not the cause of the issue, BUT there are suggestions in the comments here about how one might go about addressing that particular problem. The ACTUAL problem was related to having multiple ng2-dragula subscribers alive at the same time, hence why I have updated the question title to reflect that.


I am using ng2-dragula plugin and subscribe to the dropModel event.

The problem I am facing is that inside the subscribe code I need to modify the model by re-arranging the other items in the model. This causes dropModel to fire the dropModel event again - it obviously thinks that because I changed the model positions in the list that the user did a drag and drop, and not my code.

I tried take(1) but that did not solve the problem - it just keeps taking one event, so when I change the model inside subscribe, obviously it takes the next (1) again.

This is the code:

this._dragulaService.dropModel.take(1).subscribe(() => {
  // For ease, we just copy all the values into the model and then destroy
  // the itemsControl, then patch all model values back to the form
  // This is easier than working out which item was dragged and dropped where
  // (and on each drop we want to save the model, so we would need to update it anyway)
  this.itemsControl['controls'].forEach((formGroup, index) => {
    this.template.template_items[index].sort = index; // ensures the API will always return items in the index order
    console.log('copying ID', formGroup['controls'].id.value);
    this.template.template_items[index].id = formGroup['controls'].id.value;
    this.template.template_items[index].item_type = formGroup['controls'].item_type.value;
    this.template.template_items[index].content = formGroup['controls'].content.value;
    this.template.template_items[index].is_completed = formGroup['controls'].is_completed.value;
  });
}

Ideally I would want to 'grab' the first drop event (user initiated), then inside the subscribe code, unsubscribe or stop receiving more events, then process the model and finally resubscribe afterwards.

I know this is kind of odd - inside the subscribe async code I need to somehow 'pause' the subscription. Although 'pause' is not quite right - I actually want to somehow prevent firing new events until I've finished processing the current event. Pause would just result in me processing my own events (if that makes sense). Is this possible?

Note

The model here that dragula is bound to here is a dynamic array of itemsControls and NOT a pure data model in the normal sense. Hence why I am extracting the data out of the form controls and inserting into the actual data model.

UPDATE 1

I decided to log what dragula was doing with my bound itemsControl (an array of AbstractControls).

Before the drag, I log what is actually inside the array:

itemsControl is now this: (4) [FormGroup, FormGroup, FormGroup, FormGroup]

In the dropModel subscribe handler, I log a "dropped" event and the length of the array. Here is the output when I drag and drop any item, always the same output:

dropped
length is 3
dropped
length is 3
dropped
length is 4
dropped
length is 5
dropped
length is 5
dropped
length is 4
dropped
length is 4

However, if I remove the code I posted above (ie. so I do not touch the underlying data model), this is the output:

dropped
length is 4
dropped
length is 4

So at least that proves that by re-sorting the data I am not only causing more events to fire (as I suspected) but also the strange side effect that the length of the controls array is increasing and decreasing (not sure why that is).

Given this output, I need a way to only act upon the very last event emitted.

Is there a way to only get the last event from an Observable?

UPDATE 2

According to this, the real problem here is that ng2-dragula does not support binding dropModel to a FormArray. But there appears to be a workaround...still searching!

Glorfindel
  • 21,988
  • 13
  • 81
  • 109
rmcsharry
  • 5,363
  • 6
  • 65
  • 108
  • Throttling the observable with throttleTime(0) or debounceTime(0) won't fix it, will it? – Estus Flask Oct 21 '17 at 18:28
  • @estus I have no idea, but I will try. – rmcsharry Oct 21 '17 at 18:35
  • @estus It appears that debounceTime(0) does fix it...are you able to create an answer that might explain why that works? – rmcsharry Oct 21 '17 at 18:38
  • @estus Ok, my bad, after I reloaded the app and tested further, it dit not fix it – rmcsharry Oct 21 '17 at 18:41
  • 1
    I was unable to embrace the example, but filtering and throttling are usual way to avoid recursive events. Since this is complex issue I'd suggest to provide http://stackoverflow.com/help/mcve in order to replicate the problem. You may also try a bigger delay in case if this will work as a quick fix, throttleTime(10) or debounceTime(10). – Estus Flask Oct 21 '17 at 18:45
  • Thanks for your help. It would probably take me a day to replicate this, as it's a really complicated form (dynamically built from api data). If I can't find a solution today I'll try to build a replica tomorrow if I am able. – rmcsharry Oct 21 '17 at 18:51
  • @estus I also tried larger values (up to 1000) with both throttle and debounce and neither helped unfortunately. – rmcsharry Oct 21 '17 at 18:52

3 Answers3

3

EDIT 2022

This answer is now out of date

Dragula used to use EventEmitters, now it uses Observables. Please see the latest docs.


So, after all this, it appears there is a RIGHT way and a WRONG way to subscribe to ng2-dragula:

The wrong way, which is what I had:

dragulaService.dropModel.subscribe((result) => { ... }

The right way:

private destroy$ = new Subject();

constructor (private _dragulaService: DragulaService) {
  this._dragulaService.dropModel.asObservable()
    .takeUntil(this.destroy$).subscribe((result) => { ... }
}

ngOnDestroy() {
  this.destroy$.next();
}

Taken from here.

Now I only get one event on each drop, and sorting the AbstractControls does not trigger further drop events.

UPDATE

Thanks to comments from @RichardMatsen I investigated further why the above code fixed the problem. It is not using asObservable() that does it, so I'm not really sure why that is recommended in the link to issue 714 I provided above. Possibly asObservable() is needed so that we can properly unsubscribe (given that dragulaService is an EventEmitter)?

Previously I used what I read in the docs:

dragulaService.destroy(name)

Destroys a drake instance named name.

to destroy the 'drake':

  ngOnDestroy(){
    this._dragulaService.destroy('bag-container');
  }  

This did not unsubscribe from the dragulaService. So when I navigated back to the component, it was still emitting events and that is the reason I was getting multiple drops.

It also turns out that subscribing and destroying is not the recommended way to use the dragulaService, so I've submitted this PR to update the readme for ng2-dragula to make this clear for future users.

rmcsharry
  • 5,363
  • 6
  • 65
  • 108
  • 1
    It's likely to be the `.asObservable()` that solves the problem, as the subscription safeguard does not activate until the component is destroyed. – Richard Matsen Oct 23 '17 at 19:02
  • Yes it would seem so. None of this would have been necessary in the first place if dropModel provided what I needed - interesting issue logged here on that: https://github.com/valor-software/ng2-dragula/issues/306 – rmcsharry Oct 24 '17 at 00:29
  • Looks similar to our earlier discussion. On another note, I wonder if there's a principle to be had from your `asObservable` fix, since `dropModel` emits an Angular `EventEmitter` - maybe it's always good practice to append asObservable to these. – Richard Matsen Oct 24 '17 at 01:04
  • Hmm, you've got me wondering now whether I missed something here. asObservable should not make a difference to an EventEmitter - its purpose is for use on Subjects to prevent leaky abstractions: https://stackoverflow.com/questions/36986548/when-to-use-asobservable-in-rxjs Also of interest is that it seems ng2-dragula should not be using EventEmitter: https://stackoverflow.com/questions/36076700/what-is-the-proper-use-of-an-eventemitter – rmcsharry Oct 24 '17 at 10:08
  • Yes, I read that too - but just because it's used for masking underlying observables doesn't mean it's not your fix as well :). Take it off again and see! – Richard Matsen Oct 24 '17 at 10:12
  • To me, that second reference (maybe) reinforces why your fix is fixing it. – Richard Matsen Oct 24 '17 at 10:16
  • Take a look at the source - you can see ng-dragula is wrapping dragula events like `drop` as well as adding new events like `dropModel`. It make sense for all these events to have the same API to us developers. – Richard Matsen Oct 24 '17 at 10:19
  • 1
    From the Angular source, `export class EventEmitter extends Subject`. The comments about EventEmitter not being an observable were made back at Angular 2 Beta 6, in comments section of a blog post and then re-posted in SO answers in bold type. – Richard Matsen Oct 24 '17 at 10:33
  • 1
    LOL - I just realized, EventEmitter has a subscribe method! When they take that away, we'll know we can't subscribe to it any more. – Richard Matsen Oct 24 '17 at 10:40
  • @RichardMatsen Thanks for your continual feedback on this issue. I took out the asObservable() and could see no change. So I went back to the original code I had - and have updated the answer to reflect that. Finally got to the bottom of this. Notice that I should not even be using subscribe in the first place to subscribe to the dragula Service! So when they do finally take it away, that is the correct solution (see the PR). LOL indeed. – rmcsharry Oct 24 '17 at 10:51
  • Cheers, but I have a feeling there's more going on here. It's a pity you were not able to set up a test Plunker. – Richard Matsen Oct 24 '17 at 10:54
  • I will do that when I have the time. Hopefully next weekend. – rmcsharry Oct 24 '17 at 10:56
2

If you always get two emits, a cheap answer might be to use a flag to differentiate first and second

const haveModified = false;
this._dragulaService.dropModel.subscribe(() => {
  if (!haveModified) {
    this.itemsControl['controls'].forEach((formGroup, index) => {
      ...
    });
    haveModified = true;
  } else {
    haveModified = false;
  }
  });
}

A more Rx approach - check the subscripted value, if the same for both emits (or a property is the same) use distinctUntilChanged(compare: function).

Update

Have just been playing with a Plunker, doing simple sorts on the data arrays in the class, and I'm not getting a second emit from dragula.

  constructor(dragulaService: DragulaService) {
    dragulaService.dropModel.subscribe(value => {
      console.log(value)
      console.log('target before sort', this.target)
      this.target.sort();
      console.log('target after sort', this.target)
    })
  }

I can't quite get my head around the operation you're doing, but I see you're using references to the HTML controls. Can you do it by manipulating the base data instead?

Update #2

Have been working the comparer for distinctUntilChanged(comparer), which involved comparing the index of the item, which should make that solution viable.

However, the index is not obtainable because of a bit of a hack in the source (dragula.provider.ts, line 97)

target.removeChild(dropElm); // element must be removed for ngFor to apply correctly

The dropModel event is

this.dropModel.emit([name, dropElm, target, source]);

but dropElm is in neither target nor source, so we don't know what the index was. Would be better if dropIndex was emitted as well like so

this.dropModel.emit([name, dropElm, target, source, dropIndex]);

then we could trap duplicate events.

Richard Matsen
  • 20,671
  • 3
  • 43
  • 77
  • The form is built dynamically from an array of data sent from the API. To get an idea see my answer to this question: https://stackoverflow.com/questions/42968619/angular-2-how-to-use-array-of-objects-for-controls-in-reactive-forms Notice the patch and patchValues methods - and look at the HTML template. You will see that the binding is not to a model of data, but to an array of controls: *ngFor="let child of form.controls.emailsArray.controls;" – rmcsharry Oct 21 '17 at 22:55
  • This means that dragula dropModel is dragging a control, and so when the dropModel event fires, the controls are re-arranged and the value I get sent is the entire div of the bag-container (with 3 divs inside, each of which has multiple children etc). So it's not possible to easily compare the emitted value, since it is this entire DOM structure (and not what you would normally get, ie. simple model values). – rmcsharry Oct 21 '17 at 22:58
  • Cheers, I'll digest that. Could you please give me the essence of what's happening inside the subscribe (sorry if it's already explained somewhere). – Richard Matsen Oct 21 '17 at 23:04
  • There is only one bag and the dropModel is an array of AbstractControls. So when the user drags a control and drops it, dragula has reordered the AbstractControls. So in the subscribe I need to loop through this new order and then update the real model of the data to reflect this new order. The problem then is that updating the data seems to make dragula think the underlying data has changed and it fires another drop event (sometimes more) – rmcsharry Oct 21 '17 at 23:11
  • I am rapidly concluding that building a reactive form with an array of AbstractControls and using dragula on them is just never going to work. – rmcsharry Oct 21 '17 at 23:14
  • FYI the reason your plunker works (thanks for adding that) is because dragula is bound to a simple data model - so when you sort by dragging and dropping, it also causes the bound model to sort. In my scenario I have to manually sort the data model to reflect the order of the AbstractControls. And doing that confuses dragula :( – rmcsharry Oct 21 '17 at 23:16
  • I'm beginning to get the picture - the 'cheap option' using a flag won't work because sometimes you get more than two emits per drop. The `distinctUntilChanged` won't work because the subscription value emitted contains the dragged item, the target and the source, but the source and target are the same so if the user drags the same item twice in a row the second drag won't fire. – Richard Matsen Oct 21 '17 at 23:21
  • The only path I see is to try and dissociate the data from the controls, on the basis that dragula watches elements only. I need to think that through a bit. – Richard Matsen Oct 21 '17 at 23:23
  • I was thinking the exact same thing. Problem there is that the controls are inputs, so the users data needs to be captured from the controls. If I empty the bound controls array (this.itemsControl.controls = [];) and then repopulate it after I sort the data, everything works fine. But obviously it means the list in the UI disappears and then reappears. – rmcsharry Oct 21 '17 at 23:37
  • Another thing - the dragula subscribe, after sorting the data, sends it to the API. When it comes back at that point I have to reassign it to the data model (so the id's are stored). If I do not call the API until the user leaves the page, everything works fine. So the issue seems to be more complicated - drop event, sort data, send to api (meanwhile another drop event fires, causing another sort and send to api). I am actually getting deadlocks in the database if I drag and drop too quickly lol ! OMG. – rmcsharry Oct 21 '17 at 23:40
  • Ok if I don't call the save to the API, everything works as expected. So calling the API and re-populating the data is the issue - that is what causes dragula to fire more drop events. So upon saving I need a way to dissociate the data from the controls. – rmcsharry Oct 21 '17 at 23:47
  • I'm having trouble getting the plunker modified to approximate the scenario, so perhaps you can try this - I noticed the `drop` event occurs after the `dropModel` event and wondered if it might be preferable to use `drop`. – Richard Matsen Oct 22 '17 at 00:23
  • I think I already tried that, but I'll try again. GIve me a minute. – rmcsharry Oct 22 '17 at 00:25
  • Nope, just got db deadlock :( – rmcsharry Oct 22 '17 at 00:30
  • I have a solution - a hack really - using your flag idea. I set isSaving=true just before subscribing to the API. On the div where dragula is bound I use *ngIf="!isSaving" and have a visually identical div with *ngIf="isSaving". So when save starts, dragula is removed from the DOM, so no events can fire. The new div is template-bound to the data so it looks identical. Since the user should not drag and drop while saving anyway, this works. – rmcsharry Oct 22 '17 at 00:43
  • Nope - it works when there are only a few items. Once I add about 10 items into the list, it breaks. Grrr! 3am going to sleep. – rmcsharry Oct 22 '17 at 00:45
  • I saw your update. One thing I tried was to give each control in the array and id based on the index: "item-{{i}}". Then in the drop event I could get the index of the item that was dropped by using jQuery to grab the id from the dropElm emitted. In the DOM, using inspect, I can see that id is updated when you drop it into the new position. Sadly it seems dragula passes the dropElm BEFORE that happens, so when I examine item-x of the dropElm in the drop code, it has the old value (so I know the index it came from but not its new index). – rmcsharry Oct 22 '17 at 14:20
  • That sounds promising, needs a test. I'd suggest looking at dropElm index in first event and in second 'faux' event, should be the same. Then try dragging same list item - if you get a different index (which I suppose you will), then the index is a good basis for the `distinctUntilChanged' comparer. – Richard Matsen Oct 22 '17 at 18:22
0

2021 Solution for Angular

Wrongway:this.dragulaService.dropModel.subscribe((result) => { ... }

Rightway:this.dragulaService.dropModel('GroupName').pipe(takeUntil(this.destroyDragulaEventHandler$)).subscribe((result) => { ... }

Create an Instance variable with rxjs subject: private destroyDragulaEventHandler$ = new Subject();

Make sure to do some clean up in ngOnDestory: ngOnDestroy(){ this.destroyDragulaEventHandler$.next(); this.dragulaService.destroy("groupName"); }

Saransh Dhyani
  • 397
  • 3
  • 9