Basic (user) main view controller class

When developing many Jmix based applications it might be convenient not to repeat yourself too often.

I did some development on Jmix applications with left and some with top navigation as well with similar basic behaviour. So I created a basic main view with user support extending the StandardMain view saving some duplicate code. This helps reducing templated code and reduce migration efforts.

A suggestion

import java.io.Serial;
import java.util.Objects;

import com.vaadin.flow.component.Component;
import com.vaadin.flow.component.avatar.Avatar;
import com.vaadin.flow.component.avatar.AvatarVariant;
import com.vaadin.flow.component.html.Div;
import com.vaadin.flow.component.html.Span;

import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.security.core.userdetails.UserDetails;

import io.jmix.core.Messages;
import io.jmix.core.usersubstitution.CurrentUserSubstitution;
import io.jmix.flowui.UiComponents;
import io.jmix.flowui.app.main.StandardMainView;

/**
 * Common implementation for different main view variants offering basic user menu support.
 *
 * @param <U>
 *            A user type.
 */
public class BasicMainView<U extends UserDetails> extends StandardMainView
{
    /**
     * Defines a default <i>Vaadin</i> route (path).
     */
    public static final String ROUTE_PATH = "";

    /**
     * Generated serialization identifier.
     */
    @Serial
    private static final long serialVersionUID = -4485091503059586394L;

    /**
     * Holds the constructor injected <i>Jmix</i> core messages component.
     */
    protected final Messages messages;

    /**
     * Holds the constructor injected <i>Jmix</i> core current-user-substitution component.
     */
    protected final CurrentUserSubstitution currentUserSubstitution;

    /**
     * Holds the constructor injected <i>Jmix</i> (flow) UI components component.
     */
    protected final UiComponents uiComponents;

    /**
     * Basic main view constructor injecting dependencies.
     *
     * @param someMessages
     *            A <i>Jmix</i> core messages component.
     * @param aCurrentUserSubstitution
     *            A <i>Jmix</i> core current user substitution component.
     * @param someUIComponents
     *            A <i>Jmix</i> (flow) UI components component.
     * @throws NullPointerException
     *            Will be thrown, if any argument is {@code null}.
     */
    protected BasicMainView(@NonNull final Messages someMessages,
                    @NonNull final CurrentUserSubstitution aCurrentUserSubstitution,
                    @NonNull final UiComponents someUIComponents)
    {
        super();

        Objects.requireNonNull(someMessages);
        Objects.requireNonNull(aCurrentUserSubstitution);
        Objects.requireNonNull(someUIComponents);

        messages = someMessages;
        currentUserSubstitution = aCurrentUserSubstitution;
        uiComponents = someUIComponents;
    }

    /**
     * <p>Renders some user specific details onto the user menu button.</p>
     * <p>This method is indented to be called by the application specific main view controller button renderer, e.g.:
     * </p>
     * <pre>
     *     {@literal @}Install(to = "userMenu", subject = "buttonRenderer")
     *     private Component userMenuButtonRenderer(final UserDetails someUserDetails) {
     *         return someUserDetails instanceof final User user ? createUserMenuButtonRenderer(user) : null;
     *     }
     * </pre>
     *
     * @param aUser
     *            A user.
     * @return button component or {@code null}
     */
    protected Component createUserMenuButtonRenderer(@Nullable final U aUser)
    {
        final Component result;
        if (aUser == null)
        {
            result = null;
        }
        else
        {
            final boolean substituted = isSubstituted(aUser);

            final String name = generateName(aUser);

            final Div content = uiComponents.create(Div.class);
            content.setClassName("user-menu-button-content");

            final Avatar avatar = createAvatar(name);
            if (!substituted)
            {
                final String avatarUrl = getUserAvatarUrl(aUser);
                if (avatarUrl != null)
                {
                    avatar.setImage(avatarUrl);
                }
            }

            final Span text = uiComponents.create(Span.class);
            text.setText(name);
            text.setClassName("user-menu-text");

            content.add(avatar, text);

            if (substituted)
            {
                final Span subtext = uiComponents.create(Span.class);
                subtext.setText(messages.getMessage("userMenu.substituted"));
                subtext.setClassName("user-menu-subtext");

                content.add(subtext);
            }

            result = content;
        }

        return result;
    }

    /**
     * <p>Renders some user specific details onto the user menu header.</p>
     * <p>This method is indented to be called by the application specific main view controller button renderer, e.g.:
     * </p>
     * <pre>
     *     {@literal @}Install(to = "userMenu", subject = "headerRenderer")
     *     private Component userMenuHeaderRenderer(final UserDetails someUserDetails) {
     *         return someUserDetails instanceof final User user ? createUserMenuHeaderRenderer(user) : null;
     *     }
     * </pre>
     *
     * @param aUser
     *            A user.
     * @return header content component or {@code null}
     */
    protected Component createUserMenuHeaderRenderer(@Nullable final U aUser)
    {
        final Component result;
        if (aUser == null)
        {
            result = null;
        }
        else
        {
            final Div content = uiComponents.create(Div.class);
            content.setClassName("user-menu-header-content");

            final String name = generateName(aUser);

            final boolean substituted = isSubstituted(aUser);

            final Avatar avatar = createAvatar(name);
            avatar.addThemeVariants(AvatarVariant.LUMO_LARGE);
            if (!substituted)
            {
                final String avatarUrl = getUserAvatarUrl(aUser);
                if (avatarUrl != null)
                {
                    avatar.setImage(avatarUrl);
                }
            }

            final Span text = uiComponents.create(Span.class);
            text.setText(name);
            text.setClassName("user-menu-text");

            content.add(avatar, text);

            if (Objects.equals(name, aUser.getUsername()))
            {
                text.addClassNames("user-menu-text-subtext");
            }
            else
            {
                final Span subtext = uiComponents.create(Span.class);
                subtext.setText(aUser.getUsername());
                subtext.setClassName("user-menu-subtext");

                content.add(subtext);
            }

            result = content;
        }

        return result;
    }

    /**
     * <p>Default implementation to return a custom user avatar URL for a given user or {@code null}.</p>
     * <p>Overwrite this method to return a user specific avatar URL.</p>
     *
     * @param aUser
     *            A user.
     * @return user avatar URL
     */
    @Nullable
    protected String getUserAvatarUrl(@Nullable final U aUser)
    {
        return null;
    }

    /**
     * Creates an avatar with the given name.
     *
     * @param aName
     *            A name.
     * @return avatar
     */
    @NonNull
    protected Avatar createAvatar(final String aName)
    {
        final Avatar avatar = uiComponents.create(Avatar.class);
        avatar.setName(aName);
        avatar.getElement().setAttribute("tabindex", "-1");
        avatar.setClassName("user-menu-avatar");

        return avatar;
    }

    /**
     * <p>Default implementation to return a user's name.</p>
     * <p>This method is intended to be overwritten by the application specific main view controller, e.g.:</p>
     * <pre>
     * {@literal @}Override
     * protected String generateName(final User aUser) {
     *     String userName = String.format("%s %s",
     *                     Strings.nullToEmpty(aUser.getFirstName()),
     *                     Strings.nullToEmpty(aUser.getLastName()))
     *                .trim();
     *
     *     return userName.isEmpty() ? super.generateName(aUser) : userName;
     * }
     * </pre>
     *
     * @param aUser
     *            A user.
     * @return name
     */
    @NonNull
    protected String generateName(@Nullable final U aUser)
    {
        return aUser == null ? "" : aUser.getUsername();
    }

    /**
     * Returns, whether the given user is substituted by comparing the {@code username} of the currently authenticated
     * on with the given user's {@code username}.
     *
     * @param aUser
     *            A user.
     * @return substituted
     */
    protected boolean isSubstituted(@Nullable final U aUser)
    {
        final UserDetails authenticatedUser = currentUserSubstitution.getAuthenticatedUser();

        return aUser != null && !Objects.equals(authenticatedUser.getUsername(), aUser.getUsername());
    }
}

Hi,

Thank you for your idea.

We intentionally scaffold the main views (and some others, .e.g. master-detail view) without any specific base controller classes so that users have full control over their code and use it as an example.

Speaking of migration, controllers work in combination with descriptors, so simply changing the base controller class will not make the main view get new features.

Regards,
Gleb

@gorelov I understand the migration issue on your side.

Migration on customer side is an ongoing task - at least until ending up in a Jmix LTS version - and this strategy also might change over time since there will be Jmix 2.8.

Since maintenance of a lot of small applications takes some time, I implement custom add-ons doing at least the controller code in common way instead of copying code changes in dozens of templated controllers. My suggested controller class does not have an own descriptor but provides overidable lookup only.

My thought was an optional main view controller variant with some user menu related operations which could extend the standard main view. The application does not nesseccarily extend that extension but can use the standard main view.

So I keep going using it in a organization/customer specific add-ons. It’s fine for me too.