Designing UIs in the LemonEdge Platform
This article explains how to create the following simple UI elements in the system from code:
- Views
- Single view for viewing/editing a record
- Grid view for viewing/editing a collection of records
- Different types of views
- Layouts
- Menu Commands
Tip
This article assumes you are familiar with the following LemonEdge concepts:
- Creating An AddIn: How to create a .net dll referencing the LemonEdge dlls.
- Designing Entities: How to create entities in the platform that we can then in turn create views for here
- Layouts: How the UI is organised and how views can be manipulated to create a layout
Caution
Most importantly we are going to be designing views/commands for the country/policy entities we designed from scratch during the DesigningEntities walkthrough.
When creating this project you should create a separate .net 5 library that references your previous library that holds your Country and Policy interface definitions.
This new separate library should reference the Utils, API and ClientCore LemonEdge Dlls so you can implement the UI functionality. You should also mark this as a UI library when importing it as an AddIn into LemonEdge.
Note
You can follow along with this article by downloading the API Examples projects which provide the code examples discussed here.
UI Concepts
The most important concept about the UI for LemonEdge is that it is targeted to work across a multi-monitor Windows application, cross platform Windows/Linux/Mac application, cross platform admin command console, and a web browser environment. Although, as with everything in LemonEdge, you have the ability to individually create UIs targeted towards the specific client applications (windows, cross platform, console, web), in general you can instead target our UI API and the system will take care of presenting everything to the user appropriately depending on the context.
Using our UI API you have complete control and integration over all of the following:
- Using your own custom images throughout the application
- Creating Views
- Views for a single entity, or collection of entities
- Creating Layouts, which are a combination of views for interacting with a specific entity or entities
- Creating Tool Windows that can act against the current items
- Creating commands that can integrate with views to perform any custom action you like
- Creating Main Menu Commands that are always visible to the user
See our Layouts documentation for an understanding of how the UI is organised and how views can be configured into layouts to work against any entity in the system.
Core Standard System Views
The LemonEdge platform has standard views that work across the client applications, and allow you to utilise them through implementing their controllers for your purposes. There are a number of standard controllers you can utilise for single views, grid, images, buttons, tree controls, etc. See here for a complete list. In this walkthrough we're going to use the two most common and simple:
- BaseDefaultSingleViewController: A controller you can inherit and customise to specify controls to display/update properties against a single instance of an entity.
- BaseGridController: A controller you can inherit and customise to specify columns to display/update properties against a collection of entities.
By simply creating these controllers for the UI of our Countries and Policies entities, we can fully integrate these into the LemonEdge application.
Grids
The first controllers we need to make are to display a collection of countries, and a collection of policies. We do that simply enough by creating the following:
namespace MyApp.UI
{
public class CountriesGridController : BaseGridController<ICountry>
{
public CountriesGridController(IBaseGrid<ICountry> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ColumnNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(ICountry.ISOCode), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
new ControlDisplayInfoLight(nameof(ICountry.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
};
public override bool AutoOpenNewItemInTab => false;
public override bool AllowOpenCommand => false;
}
public class PoliciesGridController : BaseGridController<IPolicy>
{
public PoliciesGridController(IBaseGrid<IPolicy> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ColumnNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(IPolicy.Name), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
new ControlDisplayInfoLight(nameof(IPolicy.Price), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL, Format = "n2" },
};
public override bool AutoOpenNewItemInTab => true;
public override bool AllowOpenCommand => true;
}
}
Tip
The most important thing to notice when working with the grid controller are the following key points:
- The controller works with the interface of the entity (ICountry and IPolicy in this case) and does not need to work with the entity classes themselves. This ensures you only need create the interfaces and do not need to write the classes themselves as discussed in the Designing Entities article.
- You only need specify the properties you want as columns the system automatically understands how to display them in the grid itself depending on their type, or if they are a property that holds a relationship
- The controller automatically takes care of all the following functionality for you (See here for more information):
- Filtering, Paging, and Sorting
- Multiple Column Headers (Grouped Headers)
- Grouping of data
- Expand/Collapse data groups
- Multi-row selection
- Opening selected items
- Selecting column visibility
- Marking items as public/private (if they support that functionality)
- Searching across all data
- Showing/Hiding system items (if the entity supports that functionality)
- Showing/Hiding cancelled items (if the entity is a transaction entity)
- Exporting to excel
- Creating/Copying/Deleting Items
- Exporting to xml configuration (if the item supports that functionality)
- Moving items up and down if they have a sequence to them
This ensures in the most common scenario all you need to do to create grids in the system for your entities is inherit a grid controller class and specify in order the properties you would like as columns. It's that simple. Or alternatively you can use our Auto Code Designers to design the grid in LemonEdge and if you want to customise it further, the system can provide you the code of the configuration for you to modify yourself.
Breaking this code down step by step, we get the following:
Grid Controller
The first part is the grid controller we are inheriting from for the Country, and Policy entities.
public class CountriesGridController : BaseGridController<ICountry>
You can inherit from a number of different core grid controllers (see here) depending on the type of functionality you desire. The BaseGridController is the core controller for simply displaying a collection of entities.
In this example we are specifying that the grid should load the complete collection of ICountry entities, or IPolicy entities, into the grid. You can override functionality to filter the results in various ways providing complete control over the set of data displayed, and of course the grid automatically handles paging, sorting, filtering and so forth for the user.
Next we need a constructor for the system to use the controller:
public CountriesGridController(IBaseGrid<ICountry> view) : base(view) { }
This enables the platform to create the controller and passes in the actual UI grid implementation from the client application being used. In the case of the windows desktop app this would be a WPF control implementing IBaseGrid, in the case of the web browser it's a Blazor component. In either case the functionality is the same, and you don't have to worry about which application is being used.
The most important part of the grid implementation is specifying what we want as columns, which we do next:
Grid Columns
protected override IEnumerable<ControlDisplayInfoLight> ColumnNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(ICountry.ISOCode), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
new ControlDisplayInfoLight(nameof(ICountry.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
};
Here we return an array representing each column we want in the grid. The Control Display Light Info is a light weight class for simply specifying the common requirements of a column. You can override more specific functionality to specify more advanced functionality for columns such as filtering data if they are a lookup control for a relationship.
Tip
The key properties we specify for each column is:
- The name of the property on the entity that we want to display in the column.
- Wether or not the column should be editable by the user. This just allows editing, the system will still prevent it or throw an error if they have insufficient permissions.
- The width of the control. This is displayed differently depending on the user interface displaying the grid. There are some standard width constants available in ControlDisplayInfo which can be used to provide a consistent look and feel.
- Any extra formatting to apply to the data within the column.
- Whether or not the column is visible. A hidden column may not be visible to the user immediately, but they can select it to be visible, and it is included in exporting the grid data
- If the column values should have an aggregate function applied for all the rows, and each grouping level
In this example we are simply stating we want the ISO Code and Description to be the columns in the country grid, and the Name and price in the policy grid.
Grid Functions
There are a whole host of command functions included by default with the grid controllers. We override the default behaviour for opening items for both these grids:
public override bool AutoOpenNewItemInTab => false;
public override bool AllowOpenCommand => false;
You can see a complete list of the functionality from here. In this example we are treating countries as a simple list of standing data, and thus we do not want to be able to interact or open an individual country. So we set both of those functions to false, whereas with policies we want to be able to open them and work with them.
Views
The next controller we need is to display information against an individual policy, we can do that use the following:
namespace MyApp.UI
{
public class PolicyController : BaseDefaultSingleViewController<IPolicy>
{
public PolicyController(IBaseDefaultSingleView<IPolicy> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ControlNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(IPolicy.Name), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CurrencyID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CountryID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.Price), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL, Format = "n2"},
new ControlDisplayInfoLight(nameof(IPolicy.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_LARGE},
};
}
}
Tip
The most important thing to notice when working with the single view controller are the following key points:
- The controller works with the interface of the entity (ICountry and IPolicy in this case) and does not need to work with the entity classes themselves. This ensures you only need create the interfaces and do not need to write the classes themselves as discussed in the Designing Entities article.
- You only need specify the properties you want as controls the system automatically understands how to display them in the view itself depending on their type, or if they are a property that holds a relationship
- The controller automatically arranges the control in a wrapping horizontal style, to automatically ensure views fit within whatever layout and/or client application they are being viewed in. This default style can be overridden including arranging everything all controls in a grid style.
This ensures in the most common scenario all you need to do to create a view in the system for your entities is inherit a single view controller class and specify in order the properties you would like as controls. It's that simple. Or alternatively you can use our Auto Code Designers to design the view in LemonEdge and if you want to customise it further, the system can provide you the code of the configuration for you to modify yourself.
Breaking this code down step by step, we get the following:
View Controller
The first part is the view controller we are inheriting from for the Policy entity.
public class PolicyController : BaseDefaultSingleViewController<IPolicy>
You can inherit from a few different core single view controllers (see here) depending on the type of functionality you desire. The BaseDefaultSingleViewController is the core controller for simply displaying a single entity.
Next we need a constructor for the system to use the controller:
public PolicyController(IBaseDefaultSingleView<IPolicy> view) : base(view) { }
This enables the platform to create the controller and passes in the actual UI view implementation from the client application being used. In the case of the windows desktop app this would be a WPF control implementing IBaseDefaultSingleView, in the case of the web browser it's a Blazor component. In either case the functionality is the same, and you don't have to worry about which application is being used.
The most important part of the view implementation is specifying what we want as controls, which we do next:
protected override IEnumerable<ControlDisplayInfoLight> ControlNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(IPolicy.Name), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CurrencyID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CountryID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.Price), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL, Format = "n2"},
new ControlDisplayInfoLight(nameof(IPolicy.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_LARGE},
};
Here we return an array representing each control we want in the view. This is exactly the same setup as the grid override to choose the columns. See above.
Similarly the key properties for the ControlDisplayLightInfo are the same as the grid, and the view controller also has the ability to override functionality to provide more specific functionality for each individual control.
In this example we are simply stating we want all the properties of the IPolicy entity as controls in the policy view.
View Functions
Unlike grid views, there are no default set of commands with a standard view. You do however have the capability to add any custom command you like to the view for the user to interact with.
Layouts
Layouts allow you to combine views in different ways to provide an interface to the user for viewing/updating entities. See here for more information.
You can create as many different layouts as you like for the same entity, and assign different ones to different roles depending on the type of functionality, or view, they would like.
In the example above we've created 3 views:
- Countries grid view
- Policies grid view
- Single Policy item view
We haven't created a single view for a country, because as being part of standing data, we assume all of it can be viewed and edited from a single grid, you do not need to see an individual country. If we wanted to allow that we could however.
So given our use case, we would like 3 layouts to cover all the ways of interacting with our entities:
- A layout for viewing/editing all countries
- A layout for viewing/editing all policies
- A layout for viewing/editing a single policy
This way when we create a menu button to open all countries the system will automatically load our Countries Layout to display that collection. Likewise for the policies. When we open a policy from the grid the system will use the single Policy Layout to display that entity.
Although in this example we have a very simple setup, where each layout is a 100% covered by its corresponding simple view, we do have the capability to configure those layouts through the platform (in a drag+drop manner) or through code to completely customise the look and feel. If we had two separate views for a policy we could combine them however we like, or as separate tabs.
Tip
Now in the simple case of the system only having one view that is applicable for a collection of entities, or a single entity, you don't actually need to create a layout. You can if you want, but if one doesn't exist the system automatically creates a layout that is covered 100% by that single view.
Note
So in this current example, we do not need to create a layout for countries (which would just hold our 1 countries grid view), or policies (which would just hold our 1 policies grid view), or policy (which would just hold our 1 policy view), as the system will automatically create them as needed when users view those items, as they are the only possible default layouts.
However assuming we wanted to create it anyway, and we didn't want to use the drag+drop designer and wanted it in code, we can use the following:
[DefaultLayout]
public class DefaultCountriesLayout : LemonEdge.Client.Core.Views.DefaultLayouts.DefaultLayoutGenerator
{
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(ICountry));
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Entity collection layouts match on entity set name
uniqueLayoutName == _descriptor.SetName;
public override Task<LayoutDescriptor> GenerateLayout(string uniqueLayoutName, string layoutParam)
{
//Create tabs for layout here. Each tab is only loaded once when initially made visible by the user
var mainTab = new LayoutTabSetting() { Name = "Main", Loaded = false, IsDisplayed = false };
mainTab.Loader = (group) =>
{
group.AddView(ViewDescriptorInstance.FromView(typeof(IBaseGrid<>), _descriptor.InterfaceType.Name), LayoutDockPosition.Center, 100, 100);
//This is a task so you can load graphs/charts/kpi's/etc as required to add to the layout in this tab
//This ensures the work to load those assets only happen when the user looks at the tab. Put quick
return Task.FromResult(0);
};
var descriptor = new LayoutDescriptor() { Name = "Default " + _descriptor.SetName.Wordify() };
descriptor.AddTab(mainTab);
return Task.FromResult(descriptor);
}
}
This provides a layout the system can use whenever it tries to display a collection of countries. We can break this down into the following steps:
[DefaultLayout]
public class DefaultCountriesLayout : LemonEdge.Client.Core.Views.DefaultLayouts.DefaultLayoutGenerator
This creates our layout generator. All layout generators must inherit from DefautLayoutGenerator. There are a number of predefined default layout templates you can also inherit from the DefaultLayouts namespace, such as TopCollectionWithBottomChildSingleLayoutGenerator. Inheriting from the DefaultLayoutGenerator provides the full control to design the layout however we like.
Note
The DefaultLayout Attribute is used to mark any layout generator you design as one the system can automatically use. If you don't provide this attribute then you can still use the layout in hardcoded scenarios (such as always opening a popup window of an item using a pre-configured layout), but the system will never automatically select it. An optional priority for the attribute allows you to define which layout the system should use if there is more than one available for an item to be displayed. The highest priority is chosen.
Next we just get a local copy of the EntityDescriptor for the entity we are going to be creating a layout for:
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(ICountry));
In this case, we are creating a layout for a collection of countries, so we retrieve that entity descriptor. We would use IPolicy instead for the layout of a collection of policies, or the single policy.
Next we want to indicate what types of items being displayed this layout is valid against:
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Entity collection layouts match on entity set name
uniqueLayoutName == _descriptor.SetName;
Important
Each item that can be displayed by the LemonEdge system has a corresponding unique layout name. For the layout required to display a collection of entities the uniqueLayoutName is the EntityDescriptor.SetName. For the layout required to display an instance of an entity the uniqueLayoutName is the EntityDescriptor.ItemName.
In this example we simply say this generator can create a valid layout if the uniqueLayoutName required is "Countries", or the SetName of the entity.
To create the generator for the single policy layout we would use the following:
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(IPolicy));
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Entity collection layouts match on entity set name
uniqueLayoutName == _descriptor.ItemName;
Finally we have to implement the function responsible for generating the layout for us:
public override async Task<LayoutDescriptor> GenerateLayout(ILayoutCreator displayer, string uniqueLayoutName, string layoutParam)
This function provides an ILayoutCreator which we can use to define our layout and load into the UI of the client application. We are also provided the uniqueLayoutName (in case we generate more than one type of layout), and any parameters.
The first thing we need to do is define a tab and the contents of the tab. In our case it is just the single view of the grid collection of countries:
//Create tabs for layout here. Each tab is only loaded once when initially made visible by the user
var mainTab = new LayoutTabSetting() { Name = "Main", Loaded = false, IsDisplayed = false };
mainTab.Loader = (group) =>
{
group.AddView(ViewDescriptorInstance.FromView(typeof(IBaseGrid<>), _descriptor.InterfaceType.Name), LayoutDockPosition.Center, 100, 100);
//This is a task so you can load graphs/charts/kpi's/etc as required to add to the layout in this tab
//This ensures the work to load those assets only happen when the user looks at the tab. Put quick
return Task.FromResult(0);
};
Important
Each tab is a LayouTabSetting that holds a function that can load the layout for that tab when it is made visible to the user. The important part about this is only the first tab of a layout is initially visible to a user, until they select the others nothing is loaded. This can vastly improve performance and data when navigating the application.
To the default group for our main tab we want to add our view for ICountry to take up 100% of the available real estate. To get the descriptor for our view we use:
ViewDescriptorInstance.FromView(typeof(IBaseGrid<>), _descriptor.InterfaceType.Name)
This tells the system we want the IBaseGrid view, with a parameter of the ICountry interface name. Each view in the system can have optional parameters for the descriptor. The IBaseGrid view takes a parameter for the interface type name. It can then identify the view and controller to use automatically. See ViewDescriptor.FromView for more information along with Layouts, and Views for their parameters (if any) in layout generators.
Lastly we create the actual layout and return it:
var descriptor = new LayoutDescriptor() { Name = "Default " + _descriptor.SetName.Wordify() };
descriptor.AddTab(mainTab);
return Task.FromResult(descriptor);
This simply creates the layout descriptor and adds each tab we've created, in order, to it. Finally it calls SetAndDisplayLayout to tell the client UI to actually load and display the layout.
Menu Commands
Menu commands allow you to create command implementations that are available as main buttons on the menu system for each role. These are the main menu commands that are always accessible to a user wherever they are in the application. See here for more information.
You can create as many different menu level commands as you like, and they will be available for the user to add as buttons against roles that require that functionality. You can also include the menu command as part of any default/standard roles you would like as part of the upgrade process. See Upgrading Menus for more information.
In the example above we've created views (and optionally layouts) for the original two entities (countries, and policies) that we created in the Designing Entities walkthrough.
What we need is a menu button that the user can activate in order to see a list of all countries, or all policies, and to continue from there. Clicking the Countries button would load our Countries layout (if we defined one, or create a default one otherwise) and display our country grid view to show all countries.
Although in this example we have a very simple setup, where each command directly loads a collection of that entity type, we have a huge selection of advanced menu commands, along with the ability to create any command yourself that can hook into our API.
Tip
Now in the simple case of only needing to display a collection of entities, or a single entity instance, you don't actually need to create a menu command for that. You can if you want, but if one doesn't exist a user can still configure a menu command on a role to perform that function anyway using the standard Entities Grid or Entities Item commands.
Note
So in this current example, we do not need to create a menu command button for countries, or policies as the system can be configured to perform the same command anyway from some standard commands against a role.
However assuming we wanted to create it anyway, that we didn't want to use the role configuration designer and instead we wanted it in code, we can use the following:
public static class GlobalCommandID
{
[Commands("Countries", ImageType.Domicile, "Explore all countries.", ImageType.None, "Explore all countries.")]
public const string CountriesCommand = "F7E7D08C-D6DB-4BC9-9A3C-6B45973C9AD2";
public static Guid CountriesCommandID => Guid.Parse(CountriesCommand);
}
[CommandDescriptorOptions(typeof(GlobalCommandID), GlobalCommandID.CountriesCommand)]
public class CountriesCommand : Client.Core.Commands.Core.EntityCollectionCommand<ICountry>
{
public CountriesCommand(LemonEdge.Client.Core.Views.Core.IModelLayoutCommon owner) : base(owner) { }
}
This provides a command button named "Countries" that is immediately accessibly from the role design menu to just be selected and made available for any role you want. We can break this down into the following steps:
public static class GlobalCommandID
{
[Commands("Countries", ImageType.Domicile, "Explore all countries.", ImageType.None, "Explore all countries.")]
public const string CountriesCommand = "F7E7D08C-D6DB-4BC9-9A3C-6B45973C9AD2";
Like the unique global ids required for entity types them selves (see here for more information), we also need a unique global id for each menu command in the system. This allows the commands to be referred to throughout the system easily.
Tip
We also have a Commands Attribute which can mark the command id with all the required description information for a command. See ICommandDescriptor for more information.
This is optional you do not need to use the attribute, but you'll need to specify all the descriptive information about the command wherever it's required such as in menu upgrades, using this attribute makes that process easier and more consistent.
Next we have the actual implementation of the menu button itself which opens a list of countries:
[CommandDescriptorOptions(typeof(GlobalCommandID), GlobalCommandID.CountriesCommand)]
public class CountriesCommand : Client.Core.Commands.Core.EntityCollectionCommand<ICountry>
{
public CountriesCommand(LemonEdge.Client.Core.Views.Core.IModelLayoutCommon owner) : base(owner) { }
}
A command to open a collection of entities is part of our standard commands you can easily utilise by inheriting from Entity Collection Command.
Our Countries command simply inherits that base class and we're done. You can find more details on all the standard commands and how to implement custom behaviour here.
Important
The Command Descriptor Options attribute is a mandatory attribute used to identify the implementation of your command with that reference id. This way the system can automatically create your command as required throughout the UI. It also includes descriptive information about the commands use. You can use overloads on the attribute to specify them, or if you are using the Commands Attribute it will automatically use those for you.
Adding Custom Command Automatically To Standard Roles
If we have created a custom command then it usually follows we'd like that menu command to be part of a standard menu. Whenever a standard role is reset, or upgraded we would want this command to be automatically included.
You don't have to set the command up to be part of a standard menu, you can just add the command manually to the menu for a given role, or add the general entities command (configured for countries/policies) to the menu for a given role. Either can be configured the the roles functionality here. But if you have created the command you can also integrate it into any upgrade using the following:
public class MenuInserter : LemonEdge.Connections.Database.Migrations.Core.IDefaultMenuInserter
{
public async Task UpdateDefaultMenus(IDefaultMenuItemCreator menuCreator)
{
//Standard user role
//The user role is blanked our and upgraded on every role reset, or upgrade.
//So any buttons we added previously will be removed, we just need to add them again
//If we are working with our own custom standard role, then that is not recreated by the system, so you'll need to check for the existing button and then remove, update or create it accordingly.
var userMainMenu = menuCreator.GetCurrentMenu(menuCreator.StandardRoleID);
//Standard ones begin at 0, this ensures our tab is created first. If using a custom standard role that is not reset you may need to find the existing index.
short tabIndex = -1;
//First group
short groupIndex = 0;
short itemIndex = 0;
var countriesButton = LemonEdge.Core.Client.CommandsAttribute.GetDescriptors(typeof(GlobalCommandID), GlobalCommandID.CountriesCommandID);
var policiesButton = LemonEdge.Core.Client.CommandsAttribute.GetDescriptors(typeof(GlobalCommandID), GlobalCommandID.PoliciesCommandID);
//Add a countries command button, and policies command button to a home/home tab/group on the main menu for the standard user role.
await menuCreator.CreateMenuItem(menuCreator.StandardRoleID, tabIndex, "Home", activeOnAllMenus: false, groupIndex, "Home", itemIndex, countriesButton, activateOnLoad: false, addToMenuBar: false, addToUserMenuBar: false, addToQuickAccess: false, visible: true, addToSimplified: false);
itemIndex++;
await menuCreator.CreateMenuItem(menuCreator.StandardRoleID, tabIndex, "Home", activeOnAllMenus: false, groupIndex, "Home", itemIndex, policiesButton, activateOnLoad: false, addToMenuBar: false, addToUserMenuBar: false, addToQuickAccess: false, visible: true, addToSimplified: false);
}
}
There are many ways to integrate with the LemonEdge upgrade process including taking complete custom control issuing sql commands if you want (See Custom Upgrades for more information). Implementing the IDefaultMenuInserter interface is a quick and easy way to integrate menu changes for default roles into the upgrade process and the resetting of system roles.
Note
There is only one method to implement, the UpdateDefaultMenus method which provides a IDefaultMenuItemCreator implementation to help easily upgrade the menu for any role.
We use that interface in this example to simply create a menu item linking to our new countries command, and another for our policies command. We place both on a new tab, in a new group, called "Home".
Tip
Because we used the Commands attribute against our menu command unique id property, we can use that when creating the menu item. It holds the name, description, image, etc to use when creating the command button, but we can also specify those values directly if we want to.
This provides a powerful way to always ensure your standard role menus are upgraded no matter the process.
Complete Design
Putting all of the above together we have the following complete program for our grids, views and other changes:
namespace MyApp.UI
{
public class CountriesGridController : BaseGridController<ICountry>
{
public CountriesGridController(IBaseGrid<ICountry> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ColumnNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(ICountry.ISOCode), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
new ControlDisplayInfoLight(nameof(ICountry.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
};
public override bool AutoOpenNewItemInTab => false;
public override bool AllowOpenCommand => false;
}
public class PoliciesGridController : BaseGridController<IPolicy>
{
public PoliciesGridController(IBaseGrid<IPolicy> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ColumnNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(IPolicy.Name), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL },
new ControlDisplayInfoLight(nameof(IPolicy.Price), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL, Format = "n2" },
};
public override bool AutoOpenNewItemInTab => true;
public override bool AllowOpenCommand => true;
}
public class PolicyController : BaseDefaultSingleViewController<IPolicy>
{
public PolicyController(IBaseDefaultSingleView<IPolicy> view) : base(view) { }
protected override IEnumerable<ControlDisplayInfoLight> ControlNames() =>
new ControlDisplayInfoLight[]
{
new ControlDisplayInfoLight(nameof(IPolicy.Name), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CurrencyID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.CountryID), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL},
new ControlDisplayInfoLight(nameof(IPolicy.Price), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_NORMAL, Format = "n2"},
new ControlDisplayInfoLight(nameof(IPolicy.Description), editable: true) { Width = ControlDisplayInfo.DEFAULT_CONTROL_WIDTH_LARGE},
};
}
}
That's it.
In less than 50 lines of code you've created grid views for countries and policies, and also a single view for displaying an instance of a policy, with all the following functionality:
- Filtering, Paging, and Sorting
- Multi-row selection
- Opening selected items
- Searching across all data
- Exporting to excel
- Creating/Copying/Deleting Items
You can then use our Layouts functionality to drag+drop those views to create custom layouts for viewing collections of policies and countries, and viewing an individual policy. Or seeing as these are the only views, the system will automatically create these dynamically for you anyway.
Likewise you can add buttons to a role using the standard Entities Grid command configured to show all countries or policies.
Alternatively you can use the following optional code to create the layouts, commands, and upgrades:
namespace MyApp.UI.Layouts
{
[DefaultLayout]
public class DefaultCountriesLayout : LemonEdge.Client.Core.Views.DefaultLayouts.DefaultLayoutGenerator
{
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(ICountry));
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Entity collection layouts match on entity set name
uniqueLayoutName == _descriptor.SetName;
public override Task<LayoutDescriptor> GenerateLayout(string uniqueLayoutName, string layoutParam)
{
//Create tabs for layout here. Each tab is only loaded once when initially made visible by the user
var mainTab = new LayoutTabSetting() { Name = "Main", Loaded = false, IsDisplayed = false };
mainTab.Loader = (group) =>
{
group.AddView(ViewDescriptorInstance.FromView(typeof(IBaseGrid<>), _descriptor.InterfaceType.Name), LayoutDockPosition.Center, 100, 100);
//This is a task so you can load graphs/charts/kpi's/etc as required to add to the layout in this tab
//This ensures the work to load those assets only happen when the user looks at the tab. Put quick
return Task.FromResult(0);
};
var descriptor = new LayoutDescriptor() { Name = "Default " + _descriptor.SetName.Wordify() };
descriptor.AddTab(mainTab);
return Task.FromResult(descriptor);
}
}
[DefaultLayout]
public class DefaultPoliciesLayout : LemonEdge.Client.Core.Views.DefaultLayouts.DefaultLayoutGenerator
{
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(IPolicy));
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Entity collection layouts match on entity set name
uniqueLayoutName == _descriptor.SetName;
public override Task<LayoutDescriptor> GenerateLayout(string uniqueLayoutName, string layoutParam)
{
//Create tabs for layout here. Each tab is only loaded once when initially made visible by the user
var mainTab = new LayoutTabSetting() { Name = "Main", Loaded = false, IsDisplayed = false };
mainTab.Loader = (group) =>
{
group.AddView(ViewDescriptorInstance.FromView(typeof(IBaseGrid<>), _descriptor.InterfaceType.Name), LayoutDockPosition.Center, 100, 100);
//This is a task so you can load graphs/charts/kpi's/etc as required to add to the layout in this tab
//This ensures the work to load those assets only happen when the user looks at the tab. Put quick
return Task.FromResult(0);
};
var descriptor = new LayoutDescriptor() { Name = "Default " + _descriptor.SetName.Wordify() };
descriptor.AddTab(mainTab);
return Task.FromResult(descriptor);
}
}
[DefaultLayout]
public class DefaultPolicyLayout : LemonEdge.Client.Core.Views.DefaultLayouts.DefaultLayoutGenerator
{
private readonly LemonEdge.Core.Descriptors.EntityDescriptor _descriptor =
LemonEdge.Core.Descriptors.EntityDescriptorFactory.GetDescriptor(typeof(IPolicy));
public override bool CanGenerateLayout(string uniqueLayoutName, string layoutParam) =>
//Single item layouts match on entity item name
uniqueLayoutName == _descriptor.ItemName;
public override Task<LayoutDescriptor> GenerateLayout(string uniqueLayoutName, string layoutParam)
{
//Create tabs for layout here. Each tab is only loaded once when initially made visible by the user
var mainTab = new LayoutTabSetting() { Name = "Main", Loaded = false, IsDisplayed = false };
mainTab.Loader = (group) =>
{
group.AddView(ViewDescriptorInstance.FromView(typeof(IBaseDefaultSingleView<>), _descriptor.InterfaceType.Name), LayoutDockPosition.Center, 100, 100);
//This is a task so you can load graphs/charts/kpi's/etc as required to add to the layout in this tab
//This ensures the work to load those assets only happen when the user looks at the tab. Put quick
return Task.FromResult(0);
};
var descriptor = new LayoutDescriptor() { Name = "Default " + _descriptor.ItemName.Wordify() };
descriptor.AddTab(mainTab);
return Task.FromResult(descriptor);
}
}
}
namespace MyApp.UI.Commands
{
[CommandDescriptorOptions(typeof(GlobalCommandID), GlobalCommandID.CountriesCommand)]
public class CountriesCommand : Client.Core.Commands.Core.EntityCollectionCommand<ICountry>
{
public CountriesCommand(LemonEdge.Client.Core.Views.Core.IModelLayoutCommon owner) : base(owner) { }
}
[CommandDescriptorOptions(typeof(GlobalCommandID), GlobalCommandID.PoliciesCommand)]
public class PoliciesCommand : Client.Core.Commands.Core.EntityCollectionCommand<IPolicy>
{
public PoliciesCommand(LemonEdge.Client.Core.Views.Core.IModelLayoutCommon owner) : base(owner) { }
}
}
And In the Core Dll:
namespace MyApp.Core.Commands
{
public static class GlobalCommandID
{
[Commands("Countries", ImageType.Domicile, "Explore all countries.", ImageType.None, "Explore all countries.")]
public const string CountriesCommand = "F7E7D08C-D6DB-4BC9-9A3C-6B45973C9AD2";
public static Guid CountriesCommandID => Guid.Parse(CountriesCommand);
[Commands("Policies", ImageType.Contract, "Explore all policies.", ImageType.None, "Explore all policies.")]
public const string PoliciesCommand = "064defd6-a6fa-4e68-8526-9d6215ece629";
public static Guid PoliciesCommandID => Guid.Parse(PoliciesCommand);
}
}
namespace MyApp.Core.Upgrades
{
public class MenuInserter : LemonEdge.Connections.Database.Migrations.Core.IDefaultMenuInserter
{
public async Task UpdateDefaultMenus(IDefaultMenuItemCreator menuCreator)
{
//Standard user role
//The user role is blanked our and upgraded on every role reset, or upgrade.
//So any buttons we added previously will be removed, we just need to add them again
//If we are working with our own custom standard role, then that is not recreated by the system, so you'll need to check for the existing button and then remove, update or create it accordingly.
var userMainMenu = menuCreator.GetCurrentMenu(menuCreator.StandardRoleID);
//Standard ones begin at 0, this ensures our tab is created first. If using a custom standard role that is not reset you may need to find the existing index.
short tabIndex = -1;
//First group
short groupIndex = 0;
short itemIndex = 0;
var countriesButton = LemonEdge.Core.Client.CommandsAttribute.GetDescriptors(typeof(GlobalCommandID), GlobalCommandID.CountriesCommandID);
var policiesButton = LemonEdge.Core.Client.CommandsAttribute.GetDescriptors(typeof(GlobalCommandID), GlobalCommandID.PoliciesCommandID);
//Add a countries command button, and policies command button to a home/home tab/group on the main menu for the standard user role.
await menuCreator.CreateMenuItem(menuCreator.StandardRoleID, tabIndex, "Home", activeOnAllMenus: false, groupIndex, "Home", itemIndex, countriesButton, activateOnLoad: false, addToMenuBar: false, addToUserMenuBar: false, addToQuickAccess: false, visible: true, addToSimplified: false);
itemIndex++;
await menuCreator.CreateMenuItem(menuCreator.StandardRoleID, tabIndex, "Home", activeOnAllMenus: false, groupIndex, "Home", itemIndex, policiesButton, activateOnLoad: false, addToMenuBar: false, addToUserMenuBar: false, addToQuickAccess: false, visible: true, addToSimplified: false);
}
}
}
Although these elements can be configured in the application, the code itself ensures you have granular control over everything you want to do, without being limited by anything the designers constrain you by.
Of course the Auto-Code Designers are a great place to start as they can provide your configuration as the actual code you need above. Nothing is a black-box using our designers, and as such you can take that code and tweak it as much as you like before adding it back into the system as an Add-In.
See our walkthrough for a guide on how to configure the above views using our tools without writing a line of code. You can then of course export those designs to the above code and configure it in code as much as you like.
Next Steps
Now you've created your own custom entities, and created your own custom views for those entities, along with layouts and menu commands, you're 80% of the way there to achieving the most common requirements when developing new functionality.
There are a few other guides for adding some custom configuration as well:
- Custom Images: Using your own custom images throughout the application for your own branding/themes without using the system provided ones.
- Creating Algorithms: Creating simple algorithms that create, update, or delete your data responding to actions the user performs in the application, along with enabling these algorithms to easily run as a task on remote servers.
After that we have UI specific, Intermediate, and Advanced guides detailing all the typical operations you would want to perform using the API and how to easily go about doing it.