Table of Contents

Dialogs

Working with dialogs is different when decoupling your code as you do with the MVVM paradigm. Even though the view models are not responsible for rendering and should be blissfully unaware of how things gets rendered, you do need at times to interact with the user.

Cratis Application Model supports an approach to working with dialogs and still maintain the clear separation of concerns. It promotes the idea of letting the view and React as a rendering library do just that and then bridges everything through a service called IDialogs and the use of specific hooks to glue it together, making it feel natural for you as a React developer whilst having a clear separation and making your view model logic clear and concise.

The beauty of this is that you can quite easily also write automated unit tests that test for the scenarios, involving dialogs.

Confirmation Dialogs

The most common use of modal dialogs are the standard confirmation dialogs. These are dialogs where you ask the user to confirm a specific action. The Application Model supports these out of the box and you have options for what type of confirmation you're looking for in the form of passing it which buttons to show.

There is an enum called DialogButtons that has the following options:

Value Description
Ok Only show a single Ok button, typically used to inform the user and the user to acknowledge
OkCancel Show both an Ok and a Cancel button
YesNo Show a Yes and No button
YesNoCancel Show Yes, No and a Cancel button

For standard confirmation dialogs, there is a specific expected result called DialogResult that the dialog needs to communicate back. The values are:

  • Yes
  • No
  • Ok
  • Cancel

To use a confirmation dialog from a ViewModel, you need to take a dependency to the IDialogs, assuming you have hooked up TSyringe and bindings. Then in a method, you can call the showConfirmation() on the IDialogs to show the confirmation.

Below is a full sample of how this works.

import { injectable } from 'tsyringe';
import { DialogResult } from '@cratis/applications.react/dialogs';
import { DialogButtons, IDialogs } from '@cratis/applications.react.mvvm/dialogs';

@injectable()
export class YourViewModel {
    constructor(
        private readonly _dialogs: IDialogs) {
    }

    // Method called from typically your view
    async deleteTheThing() {
        const result = await this._dialogs.showConfirmation('Delete?', 'Are you sure you want to delete?', DialogButtons.YesNo);
        if( result == DialogResult.Yes ) {
            // Do something - typically call your server
        }
    }
}

Since you haven't defined how a confirmation dialog looks like, you will not see anything, nor will the showConfirmation() ever return.

Defining the Confirmation Dialog

You define a confirmation dialog on the application level. It is then the dialog that will be used across your entire application.

The anatomy of any dialog is that it uses the useDialogContext() in the dialog itself to get what context it is in. With the context you get access to the actual request payload and the method to call when the dialog should close, or the dialog resolver as it is called.

Below is an example using Prime React to create a confirmation dialog supporting the different button types.

import { Dialog } from 'primereact/dialog';
import { DialogButtons, ConfirmationDialogRequest, useDialogContext } from '@cratis/applications.react.mvvm/dialogs';
import { DialogResult } from '@cratis/applications.react/dialogs';
import { Button } from 'primereact/button';

export const ConfirmationDialog = () => {
    const { request, resolver } = useDialogContext<ConfirmationDialogRequest, DialogResult>();

    const headerElement = (
        <div className="inline-flex align-items-center justify-content-center gap-2">
            <span className="font-bold white-space-nowrap">{request.title}</span>
        </div>
    );

    const okFooter = (
        <>
            {/* Hook up buttons with resolvers resolving to expected DialogResult */}
            <Button label="Ok" icon="pi pi-check" onClick={() => resolver(DialogResult.Ok)} autoFocus />
        </>
    );

    const okCancelFooter = (
        <>
            {/* Hook up buttons with resolvers resolving to expected DialogResult */}
            <Button label="Ok" icon="pi pi-check" onClick={() => resolver(DialogResult.Ok)} autoFocus />
            <Button label="Cancel" icon="pi pi-times" severity='secondary' onClick={() => resolver(DialogResult.Cancelled)} />
        </>
    );

    const yesNoFooter = (
        <>
            {/* Hook up buttons with resolvers resolving to expected DialogResult */}
            <Button label="Yes" icon="pi pi-check" onClick={() => resolver(DialogResult.Yes)} autoFocus />
            <Button label="No" icon="pi pi-times" severity='secondary' onClick={() => resolver(DialogResult.No)} />
        </>
    );

    const yesNoCancelFooter = (
        <>
            {/* Hook up buttons with resolvers resolving to expected DialogResult */}
            <Button label="Yes" icon="pi pi-check" onClick={() => resolver(DialogResult.Yes)} autoFocus />
            <Button label="No" icon="pi pi-times" severity='secondary' onClick={() => resolver(DialogResult.No)} />
        </>
    );

    const getFooterInterior = () => {
        switch (request.buttons) {
            case DialogButtons.Ok:
                return okFooter;
            case DialogButtons.OkCancel:
                return okCancelFooter;
            case DialogButtons.YesNo:
                return yesNoFooter;
            case DialogButtons.YesNoCancel:
                return yesNoCancelFooter;
        }

        return (<></>)
    }

    const footer = (
        <div className="card flex flex-wrap justify-content-center gap-3">
            {getFooterInterior()}
        </div>
    );

    return (
        <>
            {/* On hide we call the resolver with cancelled */}
            <Dialog header={headerElement} modal footer={footer} onHide={() => resolver(DialogResult.Cancelled)} visible={true}>
                <p className="m-0">
                    {request.message}
                </p>
            </Dialog>
        </>
    );
};

The code above uses the useDialogContext() with the ConfirmationDialogRequest and DialogResult as the types expected from the request and resolver type. Within the rendering of the component you'll notice that buttons are hooked up to resolve the dialog with the expected DialogResult. Once a button is called, it resolves the request and the information will be passed onto the Promise created within the IDialogs service.

To enable the new ConfirmationDialog all you need to do is hook it up in your application like below.

export const App = () => {
    return (
        <DialogComponents confirmation={ConfirmationDialog}>
            {/* Your application */}
        </DialogComponents>
    );
};

Busy indicator dialogs

Another common type of modal dialog is the indeterminate busy indicator dialog. You typically use these dialogs for giving a visual clue to the user that the system is working. These type of dialogs are not meant to be something the user can close, but rather something the system closes when it is ready with the work the system is doing.

To use a busy indicator dialog from a ViewModel, you need to take a dependency to the IDialogs, assuming you have hooked up TSyringe and bindings. Then in a method, you can call the showBusyIndicator() on the IDialogs to show the confirmation.

Below is a full sample of how this works.

import { injectable } from 'tsyringe';
import { DialogResult } from '@cratis/applications.react/dialogs';
import { DialogButtons, IDialogs } from '@cratis/applications.react.mvvm/dialogs';

@injectable()
export class YourViewModel {
    constructor(
        private readonly _dialogs: IDialogs) {
    }

    // Method called from typically your view
    async performLongRunningOperation() {
        const busyIndicator = this._dialogs.showBusyIndicator('Performing something that will take a while', 'Please wait');
        setTimeout(() => {
            busyIndicator.close();
        }, 1000);
    }
}

The showBusyIndicator() returns an object that has a method called close(). This method is then something you use to close the dialog.

Since you haven't defined how a busy indicator dialog looks like, you will not see anything.

Defining the Busy Indicator Dialog

As with the confirmation dialog, you define a busy indicator dialog on the application level. It is then the dialog that will be used across your entire application.

The anatomy of any dialog is that it uses the useDialogContext() in the dialog itself to get what context it is in. With the context you get access to the actual request payload and the method to call when the dialog should close, or the dialog resolver as it is called.

Below is an example using Prime React to create a confirmation dialog supporting the different button types.

import { Dialog } from 'primereact/dialog';
import { useDialogContext, BusyIndicatorDialogRequest } from '@cratis/applications.react.mvvm/dialogs';
import { DialogResult } from '@cratis/applications.react/dialogs';
import { ProgressSpinner } from 'primereact/progressspinner';

export const BusyIndicatorDialog = () => {
    const { request } = useDialogContext<BusyIndicatorDialogRequest, DialogResult>();

    const headerElement = (
        <div className="inline-flex align-items-center justify-content-center gap-2">
            <span className="font-bold white-space-nowrap">{request.title}</span>
        </div>
    );

    return (
        <>
            <Dialog header={headerElement} modal visible={true} onHide={() => { }}>
                <ProgressSpinner />
                <p className="m-0">
                    {request.message}
                </p>
            </Dialog>
        </>
    );
};

The code above uses the useDialogContext() with the BusyIndicatorDialogRequest and DialogResult as the types expected from the request and resolver type. For this implementation, it shows a spinner.

To enable the new BusyIndicatorDialog all you need to do is hook it up in your application like below.

export const App = () => {
    return (
        <DialogComponents busyIndicator={BusyIndicatorDialog}>
            {/* Your application */}
        </DialogComponents>
    );
};

Custom dialogs

The anatomy of dialogs in general is based on a request and response pattern. You request a dialog through the IDialogs service by giving it an instance of a type of a message that the view knows how to resolve into a dialog. This mechanism is in use on the confirmation dialogs and is the same for a custom dialog.

For the dialog to know the context in which it is rendering, there is a hook called useDialogContext(). In the view where the dialog is used, you define the context implicitly by using the useDialogRequest(). This establishes the subscriber that responds to a request from your view model of showing a dialog.

Subscriptions are based on type and it must be a well known type at runtime, so typically in TypeScript you'd define the request as a class as interface and type is optimized away by the TypeScript transpiler and are not present at runtime.

The following code creates a custom dialog component.

import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { useDialogContext } from '@cratis/applications.react.mvvm/dialogs';

export class CustomDialogRequest { 
    constructor(readonly content: string) {
    }
}

export const CustomDialog = () => {
    const { request, resolver } = useDialogContext<CustomDialogRequest, string>();

    return (
        <Dialog header="My custom dialog" visible={true} onHide={() => resolver('Did not do it..')}>
            <h2>Dialog</h2>
            {request.content}
            <br />
            <Button onClick={() => resolver('Done done done...')}>We're done</Button>
        </Dialog>
    );
};

Notice that the code creates a CustomDialogRequest class, it is defined as an immutable class with a constructor that holds the properties as readonly. The purpose of the the request object is to provide information that can be passed along from a view model to the dialog. This could for instance be data is needed to be displayed in the dialog or similar. You don't need to have any properties on it, the type as a class is however required.

Within the dialog component, you use the useDialogContext() and pass it the request type and the expected response type. The hook returns an object called IDialogContext, this holds the request and a delegate type that can be called to "resolve" the dialog. Both properties are type-safe based on the generic parameters passed to the hook.

With the custom dialog defined, we can start using it.

Below is an example of a view that leverages the dialog and has a view model behind that actually shows it.

import { withViewModel } from '@cratis/applications.react.mvvm';
import { FeatureViewModel } from './FeatureViewModel';
import { useDialogRequest } from '@cratis/applications.react.mvvm/dialogs';
import { CustomDialog, CustomDialogRequest } from './CustomDialog';

export const Feature = withViewModel<FeatureViewModel>(FeatureViewModel, ({ viewModel }) => {

    // Use the dialog request to get a wrapper for rendering our dialog
    const [CustomDialogWrapper] = useDialogRequest<CustomDialogRequest, string>(CustomDialogRequest);

    return (
        <div>
            {/* Use the dialog wrapper here. It will automatically show or hide its children - your dialog */}
            <CustomDialogWrapper>
                <CustomDialog />
            </CustomDialogWrapper>
        </div>
    );
});

The code leverages the useDialogRequest() with the generic parameters corresponding to the request and response types, as you saw when defining the CustomDialog component. It returns a tuple that holds a wrapper as a React functional component, then the context which holds the request when a request is made and then a resolver. This allows for inlining dialogs or passing the information on to things that needs it. But for this scenario, we don't need them and we therefor only capture the wrapper.

Note: See the sample later on how to create dialogs with a view model for an example of context and resolver use.

With the wrapper, the code wraps the actual CustomDialog component as part of the rendering of the component. This ensures that is will only be displayed when it is supposed to.

The last piece of the puzzle is now to use it from the view model. Following is a sample that shows the usage.

import { injectable } from 'tsyringe';
import { DialogButtons, IDialogs } from '@cratis/applications.react.mvvm/dialogs';
import { CustomDialogRequest } from './CustomDialogRequest;

@injectable()
export class FeatureViewModel {
    constructor(
        private readonly _dialogs: IDialogs) {
    }

    async doThings() {
        // Show the custom dialog
        const result = await this._dialogs.show<CustomDialogRequest, string>(new CustomDialogRequest('This is the content to show));
        if( result == 'Done done done...') {
            // Do something
        }
    }
}

The view model takes a dependency to IDialogs which is resolved by the IoC, assuming you have hooked up TSyringe and bindings.

In the doThings() method we show the dialog by calling .show() on the IDialogs service, giving it an instance of the CustomDialogRequest. With the generic arguments; CustomDialogRequest and string we are sure to get type-safety for the response. If you don't provide any of the generic arguments, the return type will become unknown.

The .show() method is an async, Promise based method that will return when the dialog is resolved. The return from the .show() method will then be the response type, in this case; a string.

Dialog with a view model

You might want to use a view model for the dialog itself. That is fully possible and recommended for scenarios where there will be logic and / or you want to be able to test the dialog logic code.

Your dialog view would then look like below:

import { Button } from 'primereact/button';
import { Dialog } from 'primereact/dialog';
import { useDialogContext } from '@cratis/applications.react.mvvm/dialogs';
import { DialogResolver } from '@cratis/applications.react.mvvm/dialogs';
import { withViewModel } from '@cratis/applications.react.mvvm';
import { CustomDialogViewModel } from './CustomDialogViewModel';

// The dialog now needs props with the request and resolver.
export interface CustomDialogProps {
    request: CustomDialogRequest;
    resolver: DialogResolver<string>;
}

export class CustomDialogRequest { 
    constructor(readonly content: string) {
    }
}

// Use the withViewModel() to pull in and specify props
export const CustomDialog = withViewModel<CustomDialogViewModel, CustomDialogProps>({ viewModel, props }) => {
    return (
        <Dialog header="My custom dialog" visible={true} onHide={() => viewModel.cancel() }>
            <h2>Dialog</h2>
            {request.content}
            <br />
            <Button onClick={() => viewModel.done() }>We're done</Button>
        </Dialog>
    );
};

The above code introduces a CustomDialogProps type that holds the request and resolver. This is now something you need to pass down to the component, mostly for the view model to be able to get these as that is not available directly to the view model.

Your view model would then need to be something like below:

import { inject, injectable } from 'tsyringe';
import { type CustomDialogProps } from './CustomDialog';

@injectable()
export class CustomDialogViewModel {

    constructor(@inject('props') private readonly _props: CustomDialogProps) {
    }

    name: string = '';

    done() {
        this._props.resolver('Done done done...');
    }

    cancel() {
        this._props.resolver('Did not do it..');
    }
}

The view model now has a named dependency called props, this is automatically hooked up for the component. It contains the resolver and our view model can now handle the logic.

Using the dialog is almost exactly the same, but now we need to provide the context and resolver:

import { withViewModel } from '@cratis/applications.react.mvvm';
import { FeatureViewModel } from './FeatureViewModel';
import { useDialogRequest } from '@cratis/applications.react.mvvm/dialogs';
import { CustomDialog, CustomDialogRequest } from './CustomDialog';

export const Feature = withViewModel<FeatureViewModel>(FeatureViewModel, ({ viewModel }) => {

    // Use the dialog request to get a wrapper for rendering our dialog and the context and resolver
    const [CustomDialogWrapper, context, resolver] = useDialogRequest<CustomDialogRequest, string>(CustomDialogRequest);

    return (
        <div>
            {/* Use the dialog wrapper here. It will automatically show or hide its children - your dialog */}
            <CustomDialogWrapper>
                <CustomDialog request={context.request} resolver={resolver}/>
            </CustomDialogWrapper>
        </div>
    );
});

The code takes all the values of the tuple returned by useDialogRequest() and passes the request from the context and resolver into the CustomDialog component, as it will use this in its view model.