Opening a Modal from Workspace Context when content node loads - closes itself straight away 🙃 ?!

Hello gang :waving_hand:
I am working on finishing up porting a package that notifies another user has locked the page.

I achieve this by using a workspace context that makes an API call to my controller to check if the node is locked or not.

If it is locked then it should open/display a Modal to prompt the user someone has this page locked.

I had this previously working in V14 back in Sept/Nov time by using the ModalManager to open a modal I created and this worked, but now this no longer seems to work for me as something has changed between now and then.

Possible cause

From my investigation of the Umbraco source code is that the ModalManagerContext file is opening and closing the modal immediately, is that when the content node is finished loading it is hitting the onNavigationSuccess method.

So I am not sure I can workaround this issue for my usecase of showing a modal immediately when the node loads, unless anyone can give me some ideas or pointers to try.

My hack/workaround

I have tried to work around this problem, by using a native HTML dialog element which probably what the ModalManager is doing under the hood. But I have more control to insert this element into the DOM and open/close and destroy/remove it as needed.

contentlock.workspace.context.ts

import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
import { type UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UMB_DOCUMENT_WORKSPACE_CONTEXT, UmbDocumentWorkspaceContext } from '@umbraco-cms/backoffice/document';
import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
import { ContentLockService, ContentLockStatus } from '../api';
import { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';

import '../components/dialog/locked-content-dialog';
import { LockedContentDialogElement } from '../components/dialog/locked-content-dialog';

export class ContentLockWorkspaceContext extends UmbControllerBase {

    private _docWorkspaceCtx?: UmbDocumentWorkspaceContext;
    private _unique: UmbEntityUnique | undefined;
    private _dialogElement: LockedContentDialogElement | null = null;
  
    #isLocked = new UmbBooleanState(false);
    isLocked = this.#isLocked.asObservable();

    #isLockedBySelf = new UmbBooleanState(false);
    isLockedBySelf = this.#isLockedBySelf.asObservable();

    #lockedByName = new UmbStringState('');
    lockedByName = this.#lockedByName.asObservable();

	constructor(host: UmbControllerHost) {
		super(host, CONTENTLOCK_WORKSPACE_CONTEXT.toString());
		this.provideContext(CONTENTLOCK_WORKSPACE_CONTEXT, this);

        this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, (docWorkspaceCtx) => {
            this._docWorkspaceCtx = docWorkspaceCtx;
            this._docWorkspaceCtx?.unique.subscribe((unique) => {
                this._unique = unique;

                if(!this._unique){
                    return;
                }

                // Call API now we have assigned the unique
                this.checkContentLockState();
            });
        });

        // Create and append the dialog element to the body
        // This feels a bit hacky to use a native HTML5 dialog and not modalManager context
        // As the modal manager closes the modal immediately after opening it when we navigate to the page
        // This is due to closeNoneRoutableModals AFAIK from Umbraco modalManagerCtx
        console.log('Add dialog to body for us to open');

        // Create and append the dialog element to the body
        this._dialogElement = document.createElement('locked-content-dialog') as LockedContentDialogElement;
        document.body.appendChild(this._dialogElement);
	}

    override destroy() {
        super.destroy();

        console.log('DESTROY THE DIALOG');

        if (this._dialogElement) {
            document.body.removeChild(this._dialogElement);
            this._dialogElement = null;
        }
    }

    private async _getStatus(key: string) : Promise<ContentLockStatus | undefined> {

        const { data, error } = await ContentLockService.status({path:{key:key}});
        if (error){
            console.error(error);
            return undefined;
        }
        
        return data;
    }

    async checkContentLockState() {
        // Check if the current document is locked and its not locked by self
        await this._getStatus(this._unique!).then(async (status) => {

            // Set the observable bool that we consume as part of condition
            this.setIsLocked(status?.isLocked ?? false);
            this.setIsLockedBySelf(status?.lockedBySelf ?? false);
            this.setLockedByName(status?.lockedByName ?? '');

            if(status?.isLocked && status.lockedBySelf === false){
                
                try {
                    // Display the dialog if the document is locked by someone else
                    const dialog = this._dialogElement;
                    if(dialog){
                        dialog.lockedBy = status.lockedByName!;
                        dialog.openDialog();
                    }
                } catch (error) {
                    console.error('Error opening dialog:', error);
                }
            }
        });
    }
    
	getIsLocked() {
		return this.#isLocked.getValue();
	}
	
	setIsLocked(isLocked: boolean) {
		this.#isLocked.setValue(isLocked);
	}

    getIsLockedBySelf() {
		return this.#isLockedBySelf.getValue();
	}
	
	setIsLockedBySelf(isLockedBySelf: boolean) {
		this.#isLockedBySelf.setValue(isLockedBySelf);
	}

    getLockedByName() {
		return this.#lockedByName.getValue();
	}
	
	setLockedByName(lockedBy: string) {
		this.#lockedByName.setValue(lockedBy);
	}
}

// Declare a api export, so Extension Registry can initialize this class:
export const api = ContentLockWorkspaceContext;

// Declare a Context Token that other elements can use to request the WorkspaceContextCounter:
export const CONTENTLOCK_WORKSPACE_CONTEXT = new UmbContextToken<ContentLockWorkspaceContext>('UmbWorkspaceContext', 'contentlock.workspacecontext');

locked-content-dialog.ts

import { css, customElement, html, LitElement, property, query } from '@umbraco-cms/backoffice/external/lit';

@customElement('locked-content-dialog')
export class LockedContentDialog extends LitElement {

    // Pass in a name of a person who has locked the content
    @property({ type: String })
    lockedBy = '';

    @query('#locked-modal')
    dialogEl!: HTMLDialogElement;

    openDialog() {
        this.dialogEl?.showModal();
    }

    closeDialog() {
        this.dialogEl?.close();
    }

    render() {
        return html`
            <dialog id="locked-modal">
                <uui-dialog-layout headline="Content Lock - This page is locked">
                    <p>This page is currently locked by <strong>${this.lockedBy}</strong></p>
                    <uui-button slot="actions" @click=${this.closeDialog}>Close</uui-button>
                </uui-dialog-layout>
            </dialog>
        `;
    }

    static styles = css`
        dialog {
            border: none;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
        }

        dialog::backdrop {
            background-color: rgba(0, 0, 0, 0.2);
        }
    `;
}

export default LockedContentDialog;

export interface LockedContentDialogElement extends HTMLElement {
    lockedBy: string;
    openDialog: () => void;
    closeDialog: () => void;
}

Thoughts?

Is is the bast way to solve this, I am not 100% sure, but I would love to get some feedback or thoughts please gang :slight_smile:

So should I go with my custom HTML5 dialog that I have added to the DOM to launch mod modal/dialog or should I try to use the ModalManager from Umbraco to achieve the same thing?

Hi Warren,

First of all, I want to concur with your assessment that Umbraco has this behavior:

We needed a way to close modals when navigation happens, because they would otherwise stay open, and we did not want to make people also have to code that into their logic. What could work for you is if we added configuration to the modal context to protect a modal, i.e. mark it as indisposable or similar.

(Please create an issue on the tracker about this)

Meanwhile, your approach is not unwarranted by creating a native dialog. As you correctly assess, this is exactly what the modal manager is doing under the hood for you anyway. I would, in fact, state that thinking of a modal as something to “lock” the UI, as you need to do here, is exactly a case that warrants a custom dialog.

So when you ask:

My answer would be yes. Yes, you should go with a custom dialog. This allows you to pass properties in, as you need, and ensure there is no way to “resolve” your modal, as there would be with Umbraco’s modal manager, and you control the backdrop, etc. Your code snippets display a marvellous mix of Umbraco’s APIs and native browser behavior - I find that quite excellent.

Well done.

1 Like

Its great to hear that I have approached the problem in the ‘right’ way, as I wasn’t 100% sure if it was the way to solve the problem or not, but its good to hear it from someone from the core team working on Bellissima :smile:

Here is an issue that links back to this forum post as context…

I’m trying to do something similar and I’ve got the same problem.

I get the “why”, but it’s a nasty side-effect and rather counter-intuitive when we’re dealing with code that’s running after the content has loaded but before the navigationsuccess event is fired.

Marking modals as indisposable would help, but I feel like this is actually an event/lifecycle problem and if it’s a problem for modals it will be a problem elsewhere. A stateful navigation context(?) where we can choose to do different things on, during, after navigation would help. Maybe a callback on the modal with the reason for closing that lets us prevent it? Maybe the ability to set specific behaviours for on, during, after navigation directly on the modal?

My workaround right now :grimacing:

Edit: Don’t do this. I have a different side effect/event that I’m registering that just happens to be taking longer than it does to render the modal 10x :person_facepalming:

this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => {
  window.addEventListener('navigationsuccess', () => {
    instance.open(this._host, JASONS_MODAL_TOKEN);
  });
});

Yeh I did think about listening to the navigationsuccess event but as it fires off loads of times as there are lots of routers nested about all over the DOM.
I think it could be annoying with my use case.

IE if you navigate into the node for the first time and notified its locked, then you dismiss it and then you are to navigate to a different content tab or context app on the node will fire this again and get this modal again.

Good shout!

I stripped my example down a bit - there’s some extra logic in there that will prevent mine firing multiple times.

Worth sharing here, for anyone else reading this thread in the future Jason?

Yeah, good shout. I honestly think this belongs on the issue tracker, as I said to Warren, as it is kind of a regression - at least in the sense that it now works differently and therefore breaks stuff, even though the original solution was also not nice.

I will additionally bring it up with the frontend team to press the issue a bit.

1 Like

I had a chat with the team. We don’t need to open modals like this in the core, which is why it is not supported. The TLDR is that we want to support modals staying open - how is yet to be determined.

Meanwhile, we had a look at the proposed workaround here, and to be frank, we don’t see it as such a workaround as much as an actual solution — you have found a way to open a modal after the Umbraco logic has run, which is pretty much what you’d need to do in any case:

The other alternative at the moment is to create a native dialog like @warren did, and that is also a perfectly acceptable solution. This allows you to open/close the modal when needed, and to style everything around it as you need to. You can even make use of Umbraco-ish styling inside with the <uui-dialog-layout /> element.

Thank you again for bringing this up, and we now have an issue on the tracker, which we can reference to extend the functionality.

Hi @JasonElkin and @warren

Thanks for highlighting this.

I agree that it would be ideal if Opening a Dialog did not close by any URL activity happening of that same event. (I would still prefer any later navigation to close it, like if the user navigating backwards with the browser history).
So Jason your hack might be the right solution, but we would have to investigate this further to make sure it covers scenarios of deep URLs based on Extensions that has to be loaded and Extension that uses the Condition system.

Now I do something bad, but I hope you can manage, a question for each of you:

On the broader conceptual level I would really love if you, @JasonElkin, could describe your use-case, from a User Experience perspective.

I would love to get an understanding of the case where a dialog appearing not by the users immediate interaction is the right solution from a User Experience perspective.

For Warrens case here above I have to be honest and say that I do not think using a Dialog for this case is ideal. The dialog comes a long aggressive as it enforces the user to respond to it — Something the user did not specifically ask for.
To highlight my case, imagine the user strolling around to find a specific Document, so they click through documents of the tree. Suddenly, they hit a ´locked´ document and now they are prevented from navigating away from the document, but they have to respond to the dialog first.

Instead, I would suggest making something that only fills the visual space of the Workspace. Still enabling the user to navigate the Tree(Sidebar) and the Topbar(Sections, Header-apps)
Something less like a Dialog, but more like a Lock-layer that dims the Workspace. Imagine a sci-fi shield in front of the Workspace.
In this way you could also make your Action of it, more relevant. Instead of Close it could be View in read-only mode or Enforce access or what ever makes sense in terms of this specific feature you are building. :slight_smile:

Let me know what you think @warren

At the moment I am trying to do a 1 to 1 port of the package, but I see you opinion with the UX, however I don’t think its totally horrible either to be warned by a dialog.

Yes its LOUD, but it does mean the user can not forget that this page is locked, but if other extension points are available to me to indicate to the user that the node is locked than I am open to the idea in the next iteration once its out the door perhaps.

Perhaps off topic?

This may be going slightly off topic of the original thread, but I was wondering Niels is it possible to add a secondary icon to indicate in the tree itself the item is locked before they were to even click the tree node.

I asked the question over here, but be curious to hear your thoughts on it

Sure. I’m creating an AutoSave package.

Changes to the document get saved and persisted in the browser. In the event that a user has unsaved changes when they come back to a document I want to ask them if they’d like to recover the autosaved version.

Not ‘immediate interaction’, but close enough. They’ve chosen to navigate to that node and this modal should show before they go to make changes to the document.

Plan is to have the level of interruption in the UI configurable. Big shouty modal, a little :warning: icon somewhere, and something in between (haven’t worked out what yet).

This topic was automatically closed 5 days after the last reply. New replies are no longer allowed.