Our powerful JS Calendar component


Post by mackopperfield »

I am trying to implement a confirmation dialog for when a user goes to close an event that they have written some values into.

Both the dialog I'm trying to use (https://www.npmjs.com/package/material-ui-confirm) as well as the Bryntum MessageDialog (https://bryntum.com/products/calendar/docs/api/Core/widget/MessageDialog#function-confirm) require marking the method as async to await the users decision.

Unfortunately the Popup beforeClose listener does not seem to be awaiting the result of async functions.

Reproducible minimal example using https://bryntum.com/products/calendar/examples/eventedit/ ...

This works and prevents the event editor from closing.

        eventEdit : {
            editorConfig : {
                listeners: {
                    beforeClose: ({ source }) => {
                        return false;
                    }
                }
            },

This doesn't work. The editor closes.

        eventEdit : {
            editorConfig : {
                listeners: {
                    beforeClose: async ({ source }) => {
                        return false;
                    }
                }
            },

This pops the dialog as expected but the event editor gets closed

 eventEdit : {
            editorConfig : {
                listeners: {
                    beforeClose: async ({ source }) => {
                        const result = await MessageDialog.confirm({
                          title   : 'Please confirm',
                        });

                    return result === MessageDialog.yesButton;
                }
            }
        },

Any advice or workarounds would be greatly appreciated. This also seems like a good candidate for an integrated feature, optional confirmation dialogs before dismissing event editors (see Google Calendar for example).


Post by Animal »

The editor hides when focus leaves it.

That is configurable vis the autoClose config.

The trouble with that is that then means that a user can interact with other parts of the UI during an edit which would be bad.

So probably best configure it:

    editorConfig : {
        autoClose : false,
        modal     : true
    }

Post by Animal »

Are you sure a confirm dialog is a great UX?

Personally, when I click "Update", I don't want the system second guessing me and assuming I have made a mistake. Adding extra steps for experienced users is a bad idea.


Post by Animal »

And preventing the close of the editor does not inhibit the operation of the update button. It just stops the editor from hiding.


Post by Animal »

You should be able to use the beforeEventSave event. That is a preventable event fired through the eventEdit Feature's owning Calendar/Scheduler

It's documented here because the event is defined in the Scheduler base EventEdit feature and is flaged as on-owner, so gets added to Scheduler, but not Calendar docs.

https://bryntum.com/products/calendar/docs/api/Scheduler/view/SchedulerBase#event-beforeEventSave

I'll see if we can fix this.


Post by mackopperfield »

Thank you for the detailed responses! We actually don't want to inhibit the operation of the update/save button, as you mentioned that would not be a great user experience. We only want to inhibit actions that would close the event editor after the user has entered content into it.

In our case, those actions are the X in the top right and the cancel button.


Post by Animal »

I see, so something like a warning that you are about to quit with outstanding changes?

That's a valid requirement, but I don't think there is an out the box way. I'll investigate, and if not, raise a ticket.


Post by Animal »

Try adding this after you import the Bryntum modules. You need to ensure that the Popup and Container classes are imported.

This overrides the close method to make the beforeClose event asynchronous:

Popup.prototype.close = function() {
        const
            me              = this,
            { closeAction } = me;

        /**
         * Fired when the {@link #function-close} method is called and the popup is not hidden.
         * May be vetoed by returning `false` from a handler.
         * @event beforeClose
         * @preventable
         * @param {Core.widget.Popup} source - This Popup
         */
        if (!me._hidden && (await me.trigger('beforeClose')) === false) {
            // Allow beforeClose to veto if called when we are visible.
            // we should destroy it even if it's hidden just omit beforeclose event
            return;
        }
        if (!me._hidden || closeAction === 'destroy'){
            // Revert focus early when closing a modal popup will lead to destruction, to give listeners a shot at doing
            // their thing. Without this, focus will be reverted as part of the destruction process, and listeners won't
            // be called.
            me.modal && closeAction === 'destroy' && me.revertFocus();

            me.unmask();

            // Focus moves unrelated to where the user's attention is upon this gesture.
            // Go into the keyboard mode where the focused widget gets a rendition so that
            // it is obvious where focus now is.
            // Must jump over EventHelper's global mousedown listener which will remove this class.
            if (me.containsFocus && me.highlightReturnedFocus) {
                me.setTimeout(() => DomHelper.setFocusRendition(me.element, true), 0);
            }

            // Get any errorTip associated with any item in the popup and hide them
            const errorTip = me.items.length && me.items.find(item => item.errorTip)?.errorTip;

            if (errorTip) {
                errorTip.pointerOverOutDetacher?.();
                errorTip.hide();
            }

            return me[closeAction]();
        }
    }

This adds a hasChanges property to the Container class and tracks changes made to any contained input fields:

Container.prototype.onFieldChange function({ source, userAction }) {
        if (userAction) {
            const { initialValues } = this;

            // When configured with `autoUpdateRecord`, changes from descendant fields/widgets are applied to the loaded
            // record using the fields `name`. Only changes from valid fields will be applied
            if (this.autoUpdateRecord) {
                const
                    { record, strictRecordMapping } = this,
                    { name, ref, isValid = true, defaultBindProperty } = source,
                    value                           = source[defaultBindProperty || 'value'],
                    key                             = strictRecordMapping ? name : name || ref;

                if (record && key && isValid) {
                    if (record.isModel) {
                        record.setValue(key, value);
                    }
                    else {
                        record[key] = value;
                    }
                }
            }
            if (initialValues) {
                (this.changeSet || (this.changeSet = {}))[source.name] = !ObjectHelper.isEqual(source.value, initialValues[source.getValueName(initialValues.__options)]);
            }
        }
    }

Container.prototype.setValues = function(values, options = this.assignValueDefaults) {
        // Flag checked by Field to determine if it should highlight change or not
        this.assigningValues = options;

        // inital value set for calculating the hasChanges property
        this.initialValues = {
            __options : options
        };

        this.eachWidget(widget => {
            const key = widget.getValueName(options);

            if (key && (!values || key in values)) {
                this.initialValues[key] = values?.[key] || null;
            }
            widget.assignValue(values, options);
        }, false);

        this.assigningValues = false;
    }

Object.defineProperty(Container.prototype, 'hasChanges', {
    get() {
        return Boolean(this.changeSet && Object.values(this.changeSet).some(v => v));
    }
});

Let us know how this works out for you.


Post by Animal »


Post by mackopperfield »

Nice thanks for diving in here!

Unfortunately the async Popup code isn't working:

Uncaught TypeError: _bryntum_calendar__WEBPACK_IMPORTED_MODULE_0__.DomHelper.setFocusRendition is not a function

I imagine this is some private function?

Also tried accessing the container code but wasn't sure how I could access it. Still figuring my way around the Bryntum ecosystem.

I think unfortunately a combo of these features is still not going to be quite enough to accomplish the true capability I'm looking for:
The Google calendar behavior where if you've started creating an event and click on a different time, the event just moves but keeps your editor state. If you try to clear the event, you get asked if you'd like to discard changes.

Screen Recording 2024-08-07 at 3.13.31 PM.mov
Google Calendar example
(8.64 MiB) Downloaded 12 times

The shortfall Bryntum has (besides the async issue which I think you have a fix for) is that with autoClose: true, clicking a new spot on the Calendar effectively involves closing your current editor and popping up a whole new one. This would trigger the confirm discard dialog every time. Setting autoClose: false seems to kind of get past this issue, but then I can't figure out how to autofill the fields with the previous data and the UX feels a bit odd anyway.

I've ended up building the following experience which I think is good enough even though it doesn't include any confirm dialogs due to the reasons listed above. State is saved and applied across event creator instances, with the X, Cancel button, and Save button clearing it.

Screen Recording 2024-08-07 at 3.16.36 PM.mov
Bryntum example
(8.66 MiB) Downloaded 11 times

In the short term love to maybe leverage the hasChanges function to simplify our logic (although being able to pick which fields we care about would be big) but will probably not implement the close dialog until we can figure a way to integrate it better with the creation experience we want.


Post Reply