Tim
Tim

Reputation: 1685

Angular 5 child emit event causes loss of focus on child form

I have a child component with a form group and multiple form controls. I'm using the valueChanges function to control what happens when a control is changed:

this.proposalItemForm.get('qtyControl').valueChanges.forEach(
    () => this.changeItem(this.proposalItemForm.get('qtyControl'))
);

Here is what happens when the item is changed:

changeItem(control) {
        // make sure the control is valid
        if (control.status=="VALID") {
            // first, make sure the data model matches the form model
            this.proposalItem = this.prepareSaveItem();
            // then submit the updated data model to the server
            this.proposalItemService.update(this.proposalItem).subscribe(
                data => {
                    this.proposalItem = data;
                },
                error => {
                    if (error.exception=="EntityNotFoundException") {
                        this.messagesService.error(error.message);
                    }
                    else {
                      this.messagesService.error("There was a problem updating the proposal item.");
                      console.error("Error: ", error);
                    }
                },
                () => { 
                    this.itemTotal = this.proposalItem.quantity*this.proposalItem.priceEach;
                    this.updateTotal.emit(this.proposalItem);
                }
            );
        }
    }

The problem happens when the service update is completely finished. If I remove the line this.updateTotal.emit(this.proposalItem); then the tab order of the form is respected and behavior is as expected. The event that is triggered causes some numbers to be re-evaluated in the parent component.

(This is setting up a proposal. The proposal may have multiple items. When the quantity or price of one item is changed, it should change the total for that item and then the grand total of all items. It is this grand total that gets updated when the event is emitted.)

However, when I leave in the line to emit the event, then the focus is moved completely out of the form, so a user moving through the form would have to click back onto the form to continue. In fact, I can see that for a brief moment, the focus goes to the next item in the tab index, but then it is lost when the update completes.

How can emit the event, but still keep focus where it belongs on the form?

EDIT/UPDATE: I now understand why this is happening, but I don't yet understand how to resolve it. The problem happens because the proposalItem object is passed up to the parent. This is what happens in the parent:

updateItem(item) {
    console.log(item);
    let itemIndex = this.proposalRevision.items.findIndex(it => it.proposalRevisionItemID == item.proposalRevisionItemID);
    this.proposalRevision.items[itemIndex] = item;
    this.updateItemTotal();
}

updateItemTotal() {
    this.grandTotal = this.proposalRevision.items.reduce((sum, item) => sum + (item.quantity*item.priceEach), 0);
}

In the updateItem function, in the array of items, the parent is updated with the new item. The item is tied to the template in the parent by:

<proposal-item *ngFor="let item of proposalRevision.items" ...></proposal-item>

So when proposalRevision.items is updated, the proposal-item (child) is refreshed, therefore losing the focus.

Is the best way to handle this to simply not update the parent with the revised child? If I do that, then if a control in the parent is updated, it sends the old version of the child with the parent object to the server. The server still gets the new version of the child, so it works out okay, but it seems a little strange to have different versions of the child hanging around. Further, the updateItemTotal function would no longer work correctly because it would use the old child information. Am I fundamentally missing something? Do I have to submit the entire child form at once, and then the focus doesn't matter?

Upvotes: 0

Views: 1737

Answers (1)

Tim
Tim

Reputation: 1685

One way to handle this correctly is to have one overall FormGroup, then use FormArray and more FormGroup(s) under that. Then just have a single Submit button for the entire form, so nothing happens after each control is filled. The beauty of this approach is that you no longer need to emit an output event because everything is all part of the same form. For my case, I have a Proposal form. A Proposal can have one or more Revisions. Each Revision can have one or more Items. So here is how I set up the Proposal form in the Proposal component:

constructor(
    private fb: FormBuilder, 
    private proposalService: ProposalService, 
    private proposalRevisionService: ProposalRevisionService,
    private messagesService: MessagesService, 
    public dialog: MatDialog) { 

    this.createForm();
}

// this just creates an empty form, and it is populated in ngOnChanges
createForm() {
    this.proposalForm = this.fb.group({
        IDControl: this.proposal.proposalID,
        propTimestampControl: this.proposal.proposalTimeStamp,
        notesControl: this.proposal.proposalNotes,
        estimatedOrderControl: this.proposal.estimatedOrderDate,
        nextContactControl: this.proposal.nextContactDate,
        statusControl: this.proposal.proposalStatus,
        revisionsControl: this.fb.array([])
    });
}

ngOnChanges() {
    this.proposalForm.reset({
        IDControl: this.proposal.proposalID,
        propTimestampControl: this.proposal.proposalTimeStamp,
        notesControl: this.proposal.proposalNotes,
        estimatedOrderControl: this.proposal.estimatedOrderDate,
        nextContactControl: this.proposal.nextContactDate,
        statusControl: this.proposal.proposalStatus
    });
    this.setProposalRevisions(this.proposal.revisions);
}

setProposalRevisions(revisions: ProposalRevision[]) {
    const revisionFormGroups = revisions.map(revision => this.fb.group({
        proposalRevisionIDControl: revision.proposalRevisionID,
        revisionIDControl: revision.revisionID,
        revDateControl: {value: moment(revision.revTimeStamp).format("D-MMM-Y"), disabled: true},
        subjectControl: [revision.subject, { updateOn: "blur", validators: [Validators.required] }],
        notesControl: [revision.notes, { updateOn: "blur" }],
        employeeControl: {value: revision.employee.initials, disabled: true},
        printTimeStampControl: [revision.printTimeStamp, { updateOn: "blur" }],
        deliveryContract: this.fb.group({
            deliveryTime: [revision.deliveryContract.deliveryTime, { updateOn: "blur", validators: [Validators.required, Validators.pattern("^[0-9]*")] }],
            deliveryUnit: [revision.deliveryContract.deliveryUnit, { validators: [Validators.required] }],
            deliveryClause: [revision.deliveryContract.deliveryClause, { validators: [Validators.required] }]
        }),
        shippingContract: this.fb.group({
            shippingTerms: revision.shippingContract.shippingTerms,
            shippingTermsText: [revision.shippingContract.shippingTermsText, { updateOn: "blur" }]
        }),
        paymentContract: this.fb.group({
            paymentTerms: [revision.paymentContract.paymentTerms, { updateOn: "blur" }],
            paymentValue: [revision.paymentContract.paymentValue, { updateOn: "blur" }],
            paymentUnit: revision.paymentContract.paymentUnit
        }),
        durationContract: this.fb.group({
            durationValue: [revision.durationContract.durationValue, { updateOn: "blur" }],
            durationUnit: revision.durationContract.durationUnit
        }),
        itemsControl: this.setProposalItems(revision.items)
    }));
    const revisionFormArray = this.fb.array(revisionFormGroups);
    this.proposalForm.setControl('revisionsControl', revisionFormArray);
}

setProposalItems(items: ProposalItem[]): FormArray {
    const itemFormGroups = items.map(item => this.fb.group({
        proposalRevisionItemIDControl: item.proposalRevisionItemID,
        itemIDControl: item.itemID,
        descriptionControl: [item.itemText, { validators: [Validators.required] }],
        qtyControl: [item.quantity, { validators: [Validators.required, Validators.pattern("^[0-9]*")] }],
        // The price can have any number of digits to the left of a decimal point, but a decimal point does not have to be present.
        // If a decimal point is present, then there must be 2-4 numbers after the decimal point.
        priceEachControl: [item.priceEach, { validators: [Validators.required, Validators.pattern("^[0-9]*(?:[.][0-9]{2,4})?$")] }],
        deliveryControl: this.fb.group({
            deliveryTypeControl: item.delivery.deliveryType,
            deliveryContractGroup: this.fb.group({
                deliveryTime: (item.delivery.deliveryContract==null ? 0 : item.delivery.deliveryContract.deliveryTime),
                deliveryUnit: (item.delivery.deliveryContract==null ? null : item.delivery.deliveryContract.deliveryUnit),
                deliveryClause: (item.delivery.deliveryContract==null ? null : item.delivery.deliveryContract.deliveryClause)
            })
        }),
        likelihoodControl: [item.likelihoodOfSale, { validators: [Validators.min(0), Validators.max(100)] }],
        mnfgTimeControl: item.mnfgTime
    }));
    return this.fb.array(itemFormGroups);
}

Then in the Proposal component template:

<form [formGroup]="proposalForm" autocomplete="off" novalidate>
    ...
    <div fxLayout="row" fxLayoutAlign="start center">
        <p class="h7">Revisions</p>
        <button mat-icon-button matTooltip="Add New Revision (The currently selected tab will be copied)" (click)="addRevision()"><i class="fa fa-2x fa-plus-circle"></i></button>
    </div>
    <mat-tab-group #revTabGroup class="tab-group" [(selectedIndex)]="selectedTab" formArrayName="revisionsControl">
        <mat-tab *ngFor="let rev of revisionsControl.controls; let last=last; let first=first; let i=index" [formGroupName]="i" [label]="rev.get('revisionIDControl').value">
            <ng-template mat-tab-label>
                <button mat-icon-button>{{rev.get('revisionIDControl').value}}</button>
                <button *ngIf="last && !first" mat-icon-button matTooltip="Delete Revision {{rev.get('revisionIDControl').value}}" (click)="deleteRevision(rev)"><i class="fa fa-trash"></i></button>
            </ng-template>
            <proposal-revision [proposalRevisionForm]="rev" [proposalMetadata]="proposalMetadata"></proposal-revision>
        </mat-tab>
    </mat-tab-group>
</form>

In the ProposalRevisionComponent, the FormGroup is now an Input rather than the data model:

grandTotal: number = 0;

@Input()
proposalMetadata: ProposalMetadata;

@Input()
proposalRevisionForm: FormGroup;

constructor(private fb: FormBuilder, private proposalRevisionService: ProposalRevisionService, private proposalItemService: ProposalItemService,
            private messagesService: MessagesService, public dialog: MatDialog) { }

ngOnInit() {
    this.dataChangeListeners();
    //console.log(this.proposalRevisionForm);
}

dataChangeListeners() {
    this.proposalRevisionForm.get('itemsControl').valueChanges.forEach(() => 
        this.grandTotal = this.proposalRevisionForm.controls.itemsControl.controls.reduce((sum, control) => sum + (control.controls.qtyControl.value*control.controls.priceEachControl.value), 0)
    );
}

It then becomes very simple to listen for changes in the Items for and update the grand total for the revision. As far as updating each individual item total, in the ProposalItemComponent template, I just used:

<span>Total: {{(proposalItemForm.controls.qtyControl.value*proposalItemForm.controls.priceEachControl.value) | currency:'USD':'symbol':'1.2-2'}}</span>

This was possible because again the FormGroup was passed in as an input to the ProposalItemComponent. Here is a portion of the the ProposalRevisionComponent template:

<div [sortablejs]="proposalRevisionForm.controls.itemsControl" [sortablejsOptions]="sortEventOptions" formArrayName="itemsControl">
    <proposal-item *ngFor="let item of proposalRevisionForm.controls.itemsControl.controls; let i=index" [formGroupName]="i"
        [proposalItemForm]="item" 
        [proposalMetadata]="proposalMetadata">
    </proposal-item>
</div>

Hopefully that is enough detail to get someone who has the same issue going in the right direction.

Upvotes: 0

Related Questions