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

A 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
        }
    }
}

In order for the dialogs to show, you need to configure the component that represent it. You can read more about that here

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.

In order for the dialogs to show, you need to configure the component that represent it. You can read more about that here

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/dialogs';

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

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

    return (
        <Dialog header="My custom dialog" visible={true} onHide={() => closeDialog(DialogResult.Cancelled, 'Did not do it..')}>
            <h2>Dialog</h2>
            {request.content}
            <br />
            <Button onClick={() => closeDialog(DialogResult.Ok, '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 close 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, CustomDialog);

    return (
        <div>
            {/* Use the dialog wrapper here. It will automatically include the actual dialog and show your dialog.
            If using a component that represents the dialog and it has a property for visibility, just set it to true.
             */}
            <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 a function to close the dialog. 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 closeDialog 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, CloseDialog } from '@cratis/applications.react/dialogs';
import { withViewModel } from '@cratis/applications.react.mvvm';
import { CustomDialogViewModel } from './CustomDialogViewModel';

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

// Use the withViewModel() to pull in and specify props
export const CustomDialog = withViewModel<CustomDialogViewModel>({ viewModel }) => {
    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 is not different for the component except that it uses the withViewModel() which points it to the view model type.

Your view model can then be something like the following:

import { inject, injectable } from 'tsyringe';
import { DialogContextContent, DialogResult } from '@cratis/applications.react/dialogs';

@injectable()
export class CustomDialogViewModel {

    constructor(private readonly _dialogContext: DialogContextContent) {
    }

    name: string = '';

    done() {
        this._dialogContext.closeDialog(DialogResult.Ok, 'Done done done...');
    }

    cancel() {
        this._dialogContext.closeDialog(DialogResult.Cancelled, 'Did not do it..');
    }
}

The view model now takes the DialogContextContent as a dependency. This will be the correct context for the current dialog and contains the closeDialog function that can be called for closing the dialog in addition to the request instance.

Using the dialog is exactly the same as before.