Broadleaf Microservices
  • v1.0.0-latest-prod

Displaying a New Entity with Metadata

In this tutorial, we’ll look at what it takes to add an entity view to the Admin, using Menu as the example.

Tip
For more on Broadleaf metadata, Check out the Unified Admin docs.
Note
Custom metadata should go in MicroservicesDemo/services/metadata. You should make a different package for each service’s custom metadata such as com.broadleafdemo.metadata.menu.

Setting up the supporting utilities

Similar to the base metadata provided out-of-box, we’ll want to define the support classes to make referencing our different components cleaner and easier.

  1. Create a MenuIds class with values for BROWSE, CREATE, and UPDATE. Usually these take the form of service:entity:view-type, e.g., menu:menus:browse.

    /**
     * Utility class that holds the constant IDs of the default menu views.
     */
    public final class MenuIds {
        public static final String BROWSE = "menu:menus:browse";
        public static final String CREATE = "menu:menus:create";
        public static final String UPDATE = "menu:menus:update";
    }
  2. Create a MenuScopes class with the various request scopes needed for API requests made on your form. This should usually at least include the scope necessary to read your entity such as MENU.

    /**
     * Helper class containing security scopes required to access resources.
     */
    public final class MenuScopes {
        public static final String MENU = "MENU";
        public static final String PRODUCT = "PRODUCT";
        public static final String CATEGORY = "CATEGORY";
    }
  3. Create a MenuPaths class that defines the URI paths for the various API requests you expect to make from your form. This should at least include the endpoint URIs for basic CRUD operations. If you are making calls to other endpoints to look up external entities, include those here as well.

    /**
     * Utility class that includes constants for the default menu endpoint paths.
     */
    public final class MenuPaths {
        /** read all, create */
        public static final String MENUS = "/menu/menus";
        /** read single, update, replace, delete */
        public static final String MENU = MENUS + "/${id}";
        /** add translations for locale */
        public static final String MENU_TRANSLATIONS = MENU + "/translations/${locale}";
        /** read all menu items, create a menu item */
        public static final String MENU_ITEMS = MENUS + "/${parent.id}/menu-items";
        /** manage a single menu item */
        public static final String MENU_ITEM = MENU_ITEMS + "/${id}";
        /** get a menu item's ancestor items */
        public static final String MENU_ITEM_ANCESTORS = MENU_ITEM + "/ancestors";
        /** get a menu item's child items */
        public static final String MENU_ITEM_CHILDREN = MENU_ITEM + "/children";
        /** add translations for a menu item */
        public static final String MENU_ITEM_TRANSLATIONS = MENU_ITEM + "/translations/${locale}";
    
        /** Used by product lookup */
        public static final class ProductPaths {
            public static final String PRODUCTS = "/catalog/products";
            public static final String PRODUCT = PRODUCTS + "/${id}";
        }
    
        /** Used by category lookup */
        public static final class CategoryPaths {
            public static final String CATEGORIES = "/catalog/categories";
            public static final String CATEGORY = CATEGORIES + "/${id}";
        }
    
    }
  4. Create a MenuForms class where you define the name of any custom forms to add to the Create or Update Views.

    /**
     * Utility class that includes constants for the default menu form names.
     */
    public final class MenuForms {
        public static final String UPDATE = "updateForm";
    }
  5. Create a MenuProps class where you define the name of the fields for the entity such as "name".

    /**
     * Utility class that includes constants for the default menu property paths.
     */
    public final class MenuProps {
        public static final String ID = "id";
        public static final String LABEL = "label";
        public static final String NAME = "name";
    
        public static final class MenuItemProps {
            public static final String CUSTOM_HTML = "customHtml";
            public static final String DISPLAY_ORDER = "displayOrder";
            public static final String ID = "id";
            public static final String IMAGE_ALT_TEXT = "imageAltText";
            public static final String IMAGE_URL = "imageUrl";
            public static final String LABEL = "label";
            public static final String PARENT_MENU_ITEM_ID = "parentMenuItemId";
            public static final String TYPE = "type";
            public static final String URL = "url";
        }
    
        public static final class ProductProps {
            public static final String NAME = "name";
            public static final String URI = "uri";
            public static final String PRIMARY_ASSET_URL = "primaryAsset.contentUrl";
        }
    
        public static final class CategoryProps {
            public static final String NAME = "name";
            public static final String URL = "url";
        }
    
    }
  6. Create a MenuChangeContainers class where you define the change containers for sandboxable entities.

    /**
     * Helper class holding change containers for menu and menu items.
     */
    public final class MenuChangeContainers {
        public static final String MENU = "MENU";
        public static final String MENU_ITEM = "MENU_ITEM";
    }

Defining Enums for SelectOption Fields

The last things we can set up before we start adding the actual metadata components are any enums we want to reference in select form fields. In this case, we’ll add a MenuItemTypeOptionEnum. We’ll be implementing SelectOption.SelectOptionEnum to help us set the metadata up correctly. This will have a method to convert each Java enum value into a SelectOption representing an option used by a SelectField component in the Admin.

MenuItemTypeOptionEnum.java
import com.broadleafcommerce.metadata.dsl.core.extension.fields.SelectOption;

import java.util.List;

/**
 * Enumerated options for the menu item {@literal "type"} field.
 */
public enum MenuItemTypeOptionEnum implements SelectOption.SelectOptionEnum {

    // we're supplying a message property key here since this is localizable text
    // we'll define that properties file later on once we know all the messages we need
    CATEGORY("menu-item.fields.type.category"),
    IMAGE("menu-item.fields.type.image"),
    LINK("menu-item.fields.type.link"),
    PAGE("menu-item.fields.type.page"),
    PRODUCT("menu-item.fields.type.product"),
    TEXT("menu-item.fields.type.text");

    private String label;

    MenuItemTypeOptionEnum(String label) {
        this.label = label;
    }

    @Override
    public String label() {
        return label;
    }

    /**
     * Factory method to generate a set of {@link SelectOption} from this enumeration.
     *
     * @return the set of options
     */
    public static List<SelectOption> toOptions() {
        return SelectOption.fromEnums(values());
    }
}

Configuring the new metadata

Next we want to set up our configuration class with the new metadata.

First we’ll register the routes where our entity’s views can be found using a browser. We’ll map the path to the view ID along with the main entity scope.

Tip
There are also a few other configuration options for a route such as setting it as visible only at the tenant level (.tenantOnly) or application level (.applicationOnly) or whether an exact path match is required (.exact()). For more details on routes See the Routes doc
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.broadleafcommerce.menu.metadata.support.MenuIds;
import com.broadleafcommerce.menu.metadata.support.MenuScopes;
import com.broadleafcommerce.metadata.route.ComponentRouteLocator;
import com.broadleafcommerce.metadata.route.builder.RoutesBuilder;

/**
 * Configuration for the contribution of Menu service metadata.
 */
@Configuration
public class MenuServicesMetadataAutoConfiguration {

    @Bean
    public ComponentRouteLocator menuMetadataRoutes(RoutesBuilder routesBuilder) {
        return routesBuilder.routes()
                .route("/menus", r -> r.componentId(MenuIds.BROWSE)
                        .scope(MenuScopes.MENU))
                .route("/menus/create", r -> r.componentId(MenuIds.CREATE)
                        .scope(MenuScopes.MENU))
                .route("/menus/:id", r -> r.componentId(MenuIds.UPDATE)
                        .scope(MenuScopes.MENU))
                .build();
    }

}

Next, we’ll register the ComponentSource with all the forms and their components. We’ll break this down into different pieces.

View Components

First let’s set up our view components. Views are the top-level component rendered for a route and contain other views, forms, fields, and actions. There are 3 default top-level views:

@Bean
public ComponentSource menuMetadataComponents() {
    return registry -> registry
            .add(MenuIds.BROWSE, getMenuBrowse())
            .add(MenuIds.CREATE, getMenuCreate())
            .add(MenuIds.UPDATE, getMenuUpdate());
}
Tip
For me details on Views See the Views doc.

Browse View

The Browse View represents the main grid in the Admin that lists all the visible instances of an entity. It can actually have multiple grids, represented as tabs across the top. In this case, it will show a single grid with all our menus—Product and Price List are examples of using multiple grids to separate the different types of each entity.

Menu Browse in the Admin
Figure 1. Menu Browse
Price Lists in the Admin
Figure 2. Price Lists Browse (note multiple grids across the top of the default)
Note
You’ll see below that the metadata DSL is quite powerful with many configuration options for components from simple ones like labels and display order to more complex options like endpoints, actions, and validation. Explore the Metadata Javadocs to see all the DSL can do.
private EntityBrowseView<?> getMenuBrowse() {
    return Views.entityBrowseView()
            .label("menu.browse")
            .defaultGrid(this::getMenuGrid);
}

private EntityGridView<?> getMenuGrid(EntityGridView<?> menuGrid) {
    return menuGrid
            .sandboxTrackable(MenuChangeContainers.MENU)
            .label("menu.browse")
            // this endpoint is used to hydrate the grid
            .readEndpoint(readEndpoint -> readEndpoint
                    .narrowedPaging()
                    .uri(MenuPaths.MENUS)
                    .scope(MenuScopes.MENU))
            // indicates the gird is sortable, also needed on each column that can be sorted on
            .sortable()
            // indicates the grid should have a search input to search the grid
            .filterByTextQuery()
            // indicates the grid should allow filters to be applied based on the columns
            .filterByQueryBuilder()
            // defines the "create new" button which will link to the Create View
            .createAction(createAction -> createAction
                    .label("menu.browse.actions.create")
                    .linkById(MenuIds.CREATE)
                    .scope(MenuScopes.MENU))
            // now we define the grid columns, the name is usually also a link to the Update View
            .addColumn(MenuProps.NAME, Columns.linkById(MenuIds.UPDATE)
                    .label("menu.columns.name")
                    .sortable()
                    .order(1000))
            .addColumn(MenuProps.LABEL, Columns.string()
                    .label("menu.columns.label")
                    .sortable()
                    .order(2000));
}

Create View

The Create View holds any forms, fields, and actions needed for creating a new instance of an entity. For Menus, this is fairly basic.

Menu Create View
Figure 3. Menu Create View
Important
One consideration for the Create View is that the entity you’re creating won’t have an ID until just before it’s persisted on the backend. So, any dependent (non-embedded) entities that require the parent’s ID like MenuItems should not be included.
private CreateEntityView<?> getMenuCreate() {
    return Views.entityViewCreate()
            .sandboxTrackable(MenuChangeContainers.MENU)
            .label("menu.create")
            // allows the user to navigate back to the Browse View
            .backLinkById(MenuIds.BROWSE)
            // this is the endpoint and scope needed to create a new menu
            .submitUrl(MenuPaths.MENUS, MenuScopes.MENU)
            .submitLabel("menu.create.actions.submit")
            // add the default form fields
            .addGeneralForm(getCreateGeneralForm());
}

private EntityFormView<?> getCreateGeneralForm() {
    return Views.entityForm()
            .addField(MenuProps.NAME, Fields.string()
                    .label("menu.fields.name")
                    .required()
                    .order(1000))
            .addField(MenuProps.LABEL, Fields.string()
                    .label("menu.fields.label")
                    .order(2000));
}

Update View

The last view, and often the most complex, is the Update View. This is where a user makes edits to an existing entity. Unlike the Create View, you can add fields for dependent entities like MenuItems here since the parent will now have an ID. We’ll break out the MenuItems component separately and discuss it below, but here’s what the Update View for Menu should look like otherwise.

private UpdateEntityView<?> getMenuUpdate() {
    return Views.entityViewUpdate()
            // enables adding translations for translatable fields
            .translatable()
            .sandboxTrackable(MenuChangeContainers.MENU)
            .label("menu.update")
            .backLinkById(MenuIds.BROWSE)
            .readUrl(MenuScopes.MENU, MenuScopes.MENU)
            .submitUrl(MenuPaths.MENU, MenuScopes.MENU)
            .submitLabel("menu.update.actions.submit")
            .submitTranslationsUrl(MenuPaths.MENU_TRANSLATIONS, MenuScopes.MENU)
            // enables the Delete secondary view action
            .deleteUrl(MenuPaths.MENU, MenuScopes.MENU)
            .deleteLabel("menu.update.actions.delete")
            .addGeneralForm(getUpdateGeneralForm());
}

private EntityFormView<?> getUpdateGeneralForm() {
    return Views.entityForm()
            .addField(MenuProps.NAME, Fields.string()
                    .label("menu.fields.name")
                    .required()
                    .order(1000))
            .addField(MenuProps.LABEL, Fields.string()
                    .label("menu.fields.label")
                    .translatable()
                    .order(2000))
            .addExternal(MenuProps.MENU_ITEMS, getMenuItemTree());
}

Now, let’s examine how to set up the MenuItems component. Since MenuItems can have children, we have a special "tree" component to help represent and navigate their hierarchical relationship. The TreeExternal can also be viewed as a flattened grid for sorting and filtering. Therefore, we’ll be configuring the tree component and a grid for these items.

private TreeExternal<?> getMenuItemTree() {
    return Externals.tree()
            .sandboxTrackable(MenuChangeContainers.MENU_ITEM)
            .translatable()
            .label("menu-item.tree-view")
            .itemDisplayNameKey(MenuItemProps.LABEL)
            .itemIdKey(MenuItemProps.ID)
            .itemParentIdKey(MenuItemProps.PARENT_MENU_ITEM_ID)
            .itemTypeLabel("menu-item.tree-view.item-type")
            .createForm(this::getMenuItemCreateForm)
            .updateForm(MenuForms.UPDATE, this::getMenuItemUpdateForm)
            .grid(this::getMenuItemGrid)
            .apply(this::addMenuItemEndpoints);
}

/**
 * This form defines the fields to create a new MenuItem belonging to the current Menu. It's like a
 * miniature Create View. Other types of external collection fields like an `GridExternal` will
 * render this form in a modal, but for the `TreeExternal`, it renders inline with the tree-
 * hierarchy navigator (like with Categories).
 */
private FormView<?> getMenuItemCreateForm(FormView<?> menuItemCreateForm) {
    return menuItemCreateForm
            .label("menu-item.forms.create")
            .addField(MenuItemProps.LABEL, Fields.string()
                    .label("menu-item.fields.label")
                    .translatable()
                    .required()
                    .order(1000))
            .addField(MenuItemProps.TYPE, Fields.select()
                    .label("menu-item.fields.type")
                    .helpText("menu-item.fields.type.help-text")
                    .options(MenuItemTypeOptionEnum.toOptions())
                    .defaultValue(MenuItemTypeOptionEnum.LINK.name())
                    .required()
                    .order(2000))
            .addField(MenuItemProps.URL, getDynamicMenuItemUrlField()
                    .order(3000))
            .addField(MenuItemProps.DISPLAY_ORDER, Fields.integer()
                    .label("menu-item.fields.display-order")
                    .order(4000));
}

/**
 * Similar to the MenuItem Create Form, we'll add an Update for editing existing items.
 */
private FormView<?> getMenuItemUpdateForm(FormView<?> menuItemUpdateForm) {
    return menuItemUpdateForm
            .label("menu-item.forms.update")
            .addField(MenuItemProps.LABEL, Fields.string()
                    .label("menu-item.fields.label")
                    .translatable()
                    .required()
                    .order(1000))
            .addField(MenuItemProps.TYPE, Fields.select()
                    .label("menu-item.fields.type")
                    .helpText("menu-item.fields.type.help-text")
                    .options(MenuItemTypeOptionEnum.toOptions())
                    .defaultValue(MenuItemTypeOptionEnum.LINK.name())
                    .required()
                    .order(2000))
            .addField(MenuItemProps.URL, getDynamicMenuItemUrlField()
                    .order(3000))
            .addField(MenuItemProps.DISPLAY_ORDER, Fields.integer()
                    .label("menu-item.fields.display-order")
                    .order(4000))
            .addField(MenuItemProps.IMAGE_URL, Fields.string()
                    .label("menu-item.fields.image-url")
                    // add more complex validation for this field in the frontend
                    .validationMethod(
                            // choose a method and pass in the error message
                            ValidationMethods.urlPath("menu-item.fields.image-url.validation"))
                    .order(5000))
            .addField(MenuItemProps.IMAGE_ALT_TEXT, Fields.string()
                    .label("menu-item.fields.image-alt-text")
                    .translatable()
                    .order(6000))
            .addField(MenuItemProps.CUSTOM_HTML, Fields.html()
                    .label("menu-item.fields.custom-html")
                    .helpText("menu-item.fields.custom-html.help-text")
                    .order(7000));
}

private TreeGridView<?> getMenuItemGrid(TreeGridView<?> menuItemGrid) {
    return menuItemGrid
            // read endpoint defined by parent tree: #addMenuItemEndpoints
            .sortable()
            .filterByTextQuery(filter -> filter.label("menu-item.tree-view.grid.search"))
            .openDetails("menu-item.tree-view.grid.open", MenuScopes.MENU)
            .addColumn(MenuItemProps.LABEL, Columns.string()
                    .label("menu-item.columns.label")
                    .order(1000))
            .addColumn(MenuItemProps.URL, Columns.string()
                    .label("menu-item.columns.url")
                    .order(2000));
}

private void addMenuItemEndpoints(TreeExternal<?> menuItemTree) {
    menuItemTree
            .readRootItemsEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEMS)
                    .scope(MenuScopes.MENU)
                    .param("rootsOnly", "true"))
            .readChildrenItemsEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM_CHILDREN)
                    .scope(MenuScopes.MENU))
            .readAncestorsEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM_ANCESTORS)
                    .scope(MenuScopes.MENU))
            .readGridItemsEndpoint(endpoint -> endpoint
                    .narrowedPaging()
                    .uri(MENU_ITEMS)
                    .scope(MenuScopes.MENU)
                    .param("rootsOnly", "false")
                    .mapParam(Mappings.mapValue("sort.sort", "sort")))
            .readItemEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM)
                    .scope(MenuScopes.MENU))
            .createItemEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEMS)
                    .scope(MenuScopes.MENU))
            .updateItemEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM)
                    .scope(MenuScopes.MENU))
            .deleteItemEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM)
                    .scope(MenuScopes.MENU))
            .updateTranslationsEndpoint(endpoint -> endpoint
                    .uri(MENU_ITEM_TRANSLATIONS)
                    .scope(MenuScopes.MENU));
}

Finally, let’s look at the various fields related to MenuItems#url. This field can be represented in a number of ways depending on what kind of URL is represents. It could be plain link, a link to a Product Display Page (PDP), or to a Category Display Page (CDP).

In the case of the latter two, it would aid Admin users if they could look up existing products and categories and automatically pull in their URLs rather then entering them manually. Thus, below, we have actually registered 3 versions of the same url field component that will render conditionally based on the MenuItem#type field. For this, we use the Fields.dynamic() DSL to handle registering fields with the same name.

Tip
For more see the Conditionals doc.
private DynamicField<?> getDynamicMenuItemUrlField() {
    return Fields.dynamic()
            .clearValueOnMatch()
            .configureFields(fields -> {
                // conditionals will control when a field is rendered based on the condition
                // these reference the values of other fields
                fields.add(getCategoryLookupField()
                        .conditional(whenMenuItemTypeIsCategory()));
                fields.add(getProductLookupField()
                        .conditional(whenMenuItemTypeIsProduct()));
                fields.add(getDerivedUrlField()
                        .conditional(whenMenuItemTypeIsNeitherCategoryNorProduct()));

                return fields;
            });
}

private DefaultLookupField getCategoryLookupField() {
    return Fields.lookupValue()
            // this is how we map the Category's URL to the MenuItem automatically after a user
            // selects a particular category
            .valueKey(CategoryProps.URL)
            .valueSelection()
            .label("menu-item.lookups.category.label")
            .placeholder("menu-item.lookups.category.placeholder")
            .modalToggleLabel("menu-item.lookups.category.modal-toggle-label")
            // configures filter params on the lookup grid
            .searchableDefaults()
            .readEndpoint(readCategories -> readCategories
                    .narrowedPaging()
                    .uri(CategoryPaths.CATEGORIES)
                    .scope(MenuScopes.CATEGORY)
                    .param("rootsOnly", "false"))
            // hydrate the values for a single category since the read-all might have a reduced version
            .hydrateEndpoint(hydrateCategory -> hydrateCategory
                    .uri(CategoryPaths.CATEGORY)
                    .scope(MenuScopes.CATEGORY))
            // configure a search grid to allow more granular info to be displayed in the lookup
            .modalSearch(categoryModal -> categoryModal
                    .label("menu-item.lookups.category.modal")
                    .filterByTextQuery()
                    .filterByQueryBuilder()
                    .addColumn(CategoryProps.NAME, Columns.string()
                            .label("menu-item.columns.category-name")
                            .order(1000))
                    .addColumn(CategoryProps.URL, Columns.string()
                            .label("menu-item.columns.category-url")
                            .order(2000)));
}

private DefaultLookupField getProductLookupField() {
    return Fields.lookupValue()
            .valueKey(ProductProps.URI)
            .valueSelection()
            .label("menu-item.lookups.product.label")
            .placeholder("menu-item.lookups.product.placeholder")
            .modalToggleLabel("menu-item.lookups.product.modal-toggle-label")
            // we'll use a specialized ProductSelect component instead of the default
            .selectComponent("ProductSelect")
            .searchableDefaults()
            .readEndpoint(readProducts -> readProducts
                    .narrowedPaging()
                    .uri(ProductPaths.PRODUCTS)
                    .scope(MenuScopes.PRODUCT))
            .hydrateUrl(ProductPaths.PRODUCT, MenuScopes.PRODUCT)
            .modalSearch(productModal -> productModal
                    .label("menu-item.lookups.product.modal")
                    .filterByTextQuery()
                    .filterByQueryBuilder()
                    .addColumn(ProductProps.PRIMARY_ASSET_URL, Columns.thumbnail()
                            .label("menu-item.columns.product-primary-asset-url")
                            .width("5rem")
                            .thumbnailWidth("4rem")
                            .thumbnailHeight("4rem")
                            .notSortable()
                            .order(1000))
                    .addColumn(ProductProps.NAME, Columns.string()
                            .label("menu-item.columns.product-name")
                            .colSpan(2)
                            .order(2000))
                    .addColumn(ProductProps.URI, Columns.string()
                            .label("menu-item.columns.product-url")
                            .order(3000)));
}

private DerivedUrlField<?> getDerivedUrlField() {
    return Fields.derivedUrl()
            .label("menu-item.fields.url")
            .source(MenuItemProps.LABEL)
            .validationMethod(ValidationMethods
                    .urlAbsoluteOrRelative("menu-item.fields.url.validation"));
}

private LogicalConditional<?> whenMenuItemTypeIsNeitherCategoryNorProduct() {
    return Conditionals.and(Conditionals.not(whenMenuItemTypeIsCategory()),
            Conditionals.not(whenMenuItemTypeIsProduct()));
}

private PropertyConditional<?> whenMenuItemTypeIsCategory() {
    return Conditionals.when(MenuItemProps.TYPE).equalTo(MenuItemTypeOptionEnum.CATEGORY.name());
}

private PropertyConditional<?> whenMenuItemTypeIsProduct() {
    return Conditionals.when(MenuItemProps.TYPE).equalTo(MenuItemTypeOptionEnum.PRODUCT.name());
}

Adding Localized Text

Now that we know all the localizable text we need, we’ll create the messages file(s) for our translatable labels. The default file will be the English text—for translations, create a version of the file with _<locale_code>, e.g., menu_es.properties for Spanish. Let’s add it in resources/messages.

menu.properties
# General Page Labels
menu.browse=Menus
menu.create=Create Menu
menu.update=Update Menu

# Form Labels
menu-item.forms.create=Create New Menu Item
menu-item.forms.update=Update Menu Item

# Action Labels
menu.browse.actions.create=Create Menu
menu.create.actions.submit=Create Menu
menu.update.actions.submit=Update Menu
menu.update.actions.delete=Delete

# Column Labels
menu.columns.name=Name
menu.columns.label=Label
menu-item.columns.label=Label
menu-item.columns.url=URL

menu-item.columns.category-name=Name
menu-item.columns.category-url=URL

menu-item.columns.product-name=Name
menu-item.columns.product-url=URL
menu-item.columns.product-primary-asset-url=Primary Asset

# Field Labels
menu.fields.name=Name
menu.fields.label=Label
menu-item.fields.label=Label
menu-item.fields.url=URL
menu-item.fields.url.validation=Input must be a valid url
menu-item.fields.display-order=Display Order
menu-item.fields.image-url=Image URL
menu-item.fields.image-url.validation=Input must be a valid url
menu-item.fields.image-alt-text=Image Alt Text
menu-item.fields.custom-html=Custom HTML
menu-item.fields.custom-html.help-text=This field is intended to enable customization of the display of the menu item.
menu-item.fields.type=Type
menu-item.fields.type.help-text=This can be useful to the commerce frontend when determining how to render the content related to the item.
menu-item.fields.type.category=Category
menu-item.fields.type.image=Image
menu-item.fields.type.link=Link
menu-item.fields.type.page=Page
menu-item.fields.type.product=Product
menu-item.fields.type.text=Text

# Menu Item Tree View Labels
menu-item.tree-view=Manage Menu Items
menu-item.tree-view.item-type=Menu Item
menu-item.tree-view.grid.search=Search Menu Items...
menu-item.tree-view.grid.open=Open

# Lookups
menu-item.lookups.category.label=Category
menu-item.lookups.category.placeholder=Select a Category
menu-item.lookups.category.modal-toggle-label=Browse
menu-item.lookups.category.modal=Select a Category

menu-item.lookups.product.label=Product
menu-item.lookups.product.placeholder=Select a Product
menu-item.lookups.product.modal-toggle-label=Browse
menu-item.lookups.product.modal=Select a Product

After creating the file, we need to register it with Spring. To do this we’ll create a class that implements MetadataMessagesBasename and add it to a spring.factories. The getBasename method should return a comma-delimited string with the paths of the message files.

MenuMessagesBasename.java
import com.broadleafcommerce.metadata.i18n.MetadataMessagesBasename;

public class MenuMessagesBasename implements MetadataMessagesBasename {

    @Override
    public String getBasename() {
        return "messages/menu";
    }

}
spring.factories
com.broadleafcommerce.metadata.i18n.MetadataMessagesBasename=\
  com.broadleafdemo.metadata.menu.MenuMessagesBasename

Final Steps

The final steps for displaying the entity in the admin both require changes outside the metadata. We need to add the user permissions necessary to view the entity and an entry in the Admin nav menu.

Section Permissions

We need to add the permissions in Auth and Admin User and map them to appropriate role. In auth, we’ll also need to set up the security and permission scopes for the new entity.

Tip
Custom SQL should go in their respective service’s starter. For this, that would be AuthenticationServicesImage.
Important
Don’t forget to update the master changelog YAML file to include the SQL updates.
auth-menu-perms.sql
INSERT INTO blc_user_permission(id, name, last_updated)
VALUES ('-34', 'READ_MENU', '2021-01-01 12:00:00.000'),
       ('-33', 'ALL_MENU', '2021-01-01 12:00:00.000');

-- -1    Partial access user
-- -2    Full access user

INSERT INTO blc_role_permission_xref (role_id, permission_id)
VALUES ('-1', '-34'),
       ('-2', '-33');

INSERT INTO blc_security_scope (id, "name", "open")
VALUES ('-20', 'MENU', 'N');

INSERT INTO blc_permission_scope(id, permission, is_permission_root, scope_id)
VALUES ('-20', 'MENU', 'Y', '-20');

Admin Nav

Lastly, we will add a new Admin-nav menu item under "Content".

admin-nav-menu.sql
INSERT INTO blc_admin_menu_item (id, context_id, label, icon, url, display_order,
                                 parent_menu_item_context_id, trk_archived,  trk_tenant_id)
VALUES ('402', '402', 'menu-item.content-management.menus', 'menu', '/menus', 2000,
        '400', 'N', '5DF1363059675161A85F576D')
        on conflict do nothing;

For a new entity, we’ll also want to add the label (menu-item.content-management.menus) to a messages file in MicroservicesDemo/services/adminnav and register it with Spring. You can do register it by defining a MessagesDefaultBasenameAddingPostProcessor @Bean.

custom-menu-item.properties
menu-item.content-management.menus=Menus
CustomAdminNavI18nAutoConfiguration
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.broadleafcommerce.common.extension.i18n.MessagesDefaultBasenameAddingPostProcessor;
import com.broadleafcommerce.common.extension.i18n.autoconfigure.CommonExtensionI18nAutoConfiguration;

import java.util.Set;

@AutoConfigureAfter(MessageSourceAutoConfiguration.class)
@AutoConfigureBefore(CommonExtensionI18nAutoConfiguration.class)
@Configuration
public class CustomAdminNavI18nAutoConfiguration {

    @Bean
    public MessagesDefaultBasenameAddingPostProcessor customNavMessageSourcePostProcessor() {
        return new CustomAdminNavMessagesDefaultBasenameAddingPostProcessor();
    }

    protected static class CustomAdminNavMessagesDefaultBasenameAddingPostProcessor extends
            MessagesDefaultBasenameAddingPostProcessor {

        @Override
        protected void addMessagesDefaultBasenames(Set<String> basenames) {
            basenames.add("messages/custom-menu-item");
        }

    }

}