This was my first time using Telerik and although there are some good things you can do with it, it’s hard to find help whenever you attempt something that is just outside what is shown in their demos and there is so many different ways to hook it up. This post docos what I’ve done when implementing a master-detail grid using KendoUI for an IIS site with windows authentication.
As this is for an intranet I’ve used windows authentication with 2 roles, one for read and another for writes.
Prerequisites
New Project
Open VS2017 and create a new project and choose your target (.NET Core or .NET Framework). I’ve chosen the full framework because later I want to use Telerik Reporting (which as yet hasn’t been converted to standard)
Nuget
Update all packages to the latest.
Packages for DB
If you targeted standard instead of full in the previous step you could just get away with including the “Microsoft.AspNetCore.All” package. If targeting full include the following nuget packages (in order).
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Tools
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.SqlServer.Design
Telerik
To setup Telerik Nuget Package Source in VS2017. (ref. https://docs.telerik.com/aspnet-mvc/getting-started/nuget-install#set-up-nuget-package-source)
In the package manager console run:
NuGet Sources Add -Name "telerik.com" -Source "https://nuget.telerik.com/nuget" ^
-UserName "your login email" -Password "your password" ^
-StorePasswordInClearText
Open NuGet.Config from %AppData%\Nuget and it should look similar to this.
Visual Studio should now have another entry in NuGet.
Add the Telerik.UI.for.AspNet.Core package.
Database
The database is simple with only a few tables. The master/parent table (Tag) and the detail/child (Alarm). Alarms have a few look ups and a boolean and date field, to show different data types.
DB Schema
DB Create Script
The script below creates 10000 Tags with 2-5 Alarms each and the lookup tables.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 |
/* DROP TABLE [dbo].[Alarm] DROP TABLE [dbo].[Tag] DROP TABLE [dbo].[AlarmReviewStatus] DROP TABLE [dbo].[AlarmType] DROP TABLE [dbo].[Lorem] DROP PROCEDURE [dbo].[GetRandomWords] */ CREATE TABLE [dbo].[AlarmType]( [Id] [int] IDENTITY(1,1) NOT NULL, [AlarmType] [varchar](10) NULL, [DisplaySequence] [int] NOT NULL DEFAULT ((0)), [TagAlarmFieldSuffix] [varchar](10) NULL, CONSTRAINT [PK_AlarmType] PRIMARY KEY CLUSTERED ([Id] ASC) ) GO CREATE TABLE [dbo].[AlarmReviewStatus]( [Id] [int] IDENTITY(1,1) NOT NULL, [ReviewStatus] [varchar](30) NULL, [DisplaySequence] [int] NOT NULL DEFAULT ((0)), CONSTRAINT [PK_AlarmReviewStatus] PRIMARY KEY CLUSTERED ([Id] ASC) ) GO CREATE TABLE [dbo].[Tag]( [Id] [int] IDENTITY(1,1) NOT NULL, [TagName] [varchar](30) NOT NULL, [TagDescription] [varchar](200) NOT NULL, [AreaUnitEquipment] [varchar](50) NULL, [DrawingReference] [varchar](50) NULL, CONSTRAINT [PK_Tag] PRIMARY KEY CLUSTERED ([Id] ASC) ) GO CREATE TABLE [dbo].[Alarm]( [Id] [int] IDENTITY(1,1) NOT NULL, [TagId] [int] NOT NULL, [AlarmTagName] [varchar](30) NOT NULL, [AlarmDescription] [varchar](max) NULL, [AlarmTypeId] [int] NOT NULL, [IsValid] [bit] NULL DEFAULT (NULL), [ReviewStatusId] [int] NULL, [ReviewedDate] [date] NULL, CONSTRAINT [PK_Alarm] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Alarm_AlarmReviewStatus] FOREIGN KEY([ReviewStatusId]) REFERENCES [dbo].[AlarmReviewStatus] ([Id]), CONSTRAINT [FK_Alarm_AlarmType] FOREIGN KEY([AlarmTypeId]) REFERENCES [dbo].[AlarmType] ([Id]), CONSTRAINT [FK_Alarm_Tag] FOREIGN KEY([TagId]) REFERENCES [dbo].[Tag] ([Id]) ) GO --Random Word generator IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[Lorem]') AND type in (N'U')) BEGIN CREATE TABLE [dbo].[Lorem]( [id] [int] IDENTITY(1,1) NOT NULL, [Word] [varchar](12) NOT NULL, [RndSort] int NULL, PRIMARY KEY CLUSTERED ([id] ASC) ) END GO IF NOT EXISTS (SELECT * FROM [dbo].[Lorem]) BEGIN INSERT INTO [dbo].[Lorem] (Word) VALUES ('a'),('ac'),('accumsan'),('ad'),('adipiscing'),('Aenean'),('Aliquam'),('aliquet'),('amet'),('ante'),('aptent'),('arcu'),('at'),('auctor'),('augue'),('bibendum'),('blandit'),('Class'),('commodo'),('condimentum'),('congue'),('consectetuer'),('consequat'),('conubia'),('Cras'),('Cum'),('Curabitur'),('cursus'),('dapibus'),('diam'), ('dictumst'),('dis'),('dolor'),('Donec'),('dui'),('Duis'),('egestas'),('eget'),('eleifend'),('elit'),('enim'),('erat'),('eros'),('est'),('et'),('Etiam'),('eu'),('euismod'),('fames'),('faucibus'),('felis'),('fermentum'),('feugiat'),('fringilla'),('Fusce'),('gravida'),('habitant'),('habitasse'),('hac'),('hendrerit'),('hymenaeos'), ('iaculis'),('id'),('imperdiet'),('In'),('inceptos'),('Integer'),('interdum'),('ipsum'),('justo'),('lacinia'),('lacus'),('laoreet'),('lectus'),('leo'),('libero'),('ligula'),('litora'),('lobortis'),('lorem'),('Maecenas'),('magna'),('magnis'),('malesuada'),('massa'),('mattis'),('Mauris'),('metus'),('mi'),('molestie'),('mollis'), ('montes'),('morbi'),('mus'),('Nam'),('nascetur'),('natoque'),('nec'),('neque'),('netus'),('nibh'),('nisl'),('non'),('nonummy'),('nostra'),('nulla'),('Nullam'),('Nunc'),('odio'),('orci'),('ornare'),('parturient'),('pede'),('pellentesque'),('penatibus'),('per'),('pharetra'),('placerat'),('platea'),('porta'),('porttitor'), ('posuere'),('potenti'),('Praesent'),('pretium'),('Proin'),('pulvinar'),('purus'),('quam'),('quis'),('Quisque'),('rhoncus'),('ridiculus'),('risus'),('sagittis'),('sapien'),('scelerisque'),('Sed'),('sem'),('semper'),('senectus'),('sit'),('sociis'),('sociosqu'),('sodales'),('sollicitudin'),('Suspendisse'),('taciti'),('tellus'),('tempor'),('tempus'), ('tincidunt'),('torquent'),('tortor'),('tristique'),('turpis'),('ullamcorper'),('ultrices'),('ultricies'),('urna'),('Ut'),('varius'),('vehicula'),('vel'),('velit'),('venenatis'),('vestibulum'),('Vi'),('vitae'),('Vivamus'),('viverra'),('volutpat'),('vulputate'),('wisi'); END GO IF NOT EXISTS (SELECT * FROM sys.objects WHERE object_id = OBJECT_ID(N'[dbo].[GetRandomWords]') AND type in (N'P', N'PC')) BEGIN EXEC dbo.sp_executesql @statement = N'CREATE PROCEDURE [dbo].[GetRandomWords] AS' END GO ALTER PROCEDURE [dbo].[GetRandomWords] @WordCount int = 100, @RandomWords varchar(max) OUTPUT AS BEGIN SET NOCOUNT ON; DECLARE @RetVal varchar(max) DECLARE @WordCounter int = 0 DECLARE @WordTableRowCount int = 0 DECLARE @WordTable TABLE (Word varchar(max), SortOrder int, INDEX IXWordTable_SortOrder NONCLUSTERED (SortOrder) ) WHILE @WordTableRowCount < @WordCount BEGIN INSERT INTO @WordTable (Word) SELECT Word FROM [dbo].[Lorem] SET @WordTableRowCount = @WordTableRowCount + @@ROWCOUNT END DECLARE @Word varchar(max) DECLARE cur CURSOR FOR SELECT Word FROM @WordTable OPEN cur FETCH NEXT FROM cur INTO @Word WHILE @@FETCH_STATUS = 0 BEGIN UPDATE @WordTable SET SortOrder = RAND()*(@WordTableRowCount - 1) + 1 WHERE CURRENT OF cur FETCH NEXT FROM cur INTO @Word END SELECT @RandomWords = STUFF (( SELECT ' ' + Word FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY SortOrder) AS RowNumber FROM @WordTable ) t WHERE t.RowNumber <= @WordCount FOR XML PATH('') ), 1,1,'') END GO --USAGE: --declare @RandWds varchar(max) --exec [dbo].[GetRandomWords] @WordCount = 500, @RandomWords = @RandWds OUTPUT --print @RandWds --populate data SET NOCOUNT ON SET IDENTITY_INSERT [dbo].[AlarmType] ON INSERT INTO [dbo].[AlarmType] ([Id], [AlarmType], [DisplaySequence], [TagAlarmFieldSuffix]) VALUES (1, 'HI', 1, '.HI'), (2, 'LO', 2, '.LO'), (3, 'HIHI', 3, '.HIHI'), (4, 'LOLO', 4, '.LOLO'), (5, 'DISCRETE', 5, '.OUT') SET IDENTITY_INSERT [dbo].[AlarmType] OFF GO SET IDENTITY_INSERT [dbo].[AlarmReviewStatus] ON INSERT INTO [dbo].[AlarmReviewStatus] ([Id], [ReviewStatus], [DisplaySequence]) VALUES (1, 'COMPLETE', 1), (2, 'PENDING', 2), (3, 'MONITORING', 3), (4, 'PROD MGR TO APPROVE', 4), (5, 'READY TO IMPLEMENT', 5) SET IDENTITY_INSERT [dbo].[AlarmReviewStatus] OFF GO DECLARE @TagId int, @AlarmTypeId int, @ReviewStatusId int DECLARE @TagCounter int = 0, @TagCount int = 10000, @AlarmCounter int = 0, @AlarmCount int = 2 DECLARE @RandomWords varchar(max), @RandomWordCount int WHILE @TagCounter < @TagCount BEGIN SET @TagCounter = @TagCounter + 1 PRINT 'CREATING TAG ' + CONVERT(VARCHAR, @TagCounter) + ' OF ' + CONVERT(VARCHAR, @TagCount) INSERT INTO [dbo].[Tag] ([TagName], [TagDescription], [AreaUnitEquipment], [DrawingReference]) VALUES ( 'Tag'+ CONVERT(VARCHAR, @TagCounter), 'Tag Desc ' + CONVERT(VARCHAR, @TagCounter), 'Area ' + CONVERT(VARCHAR, (@TagCounter % 4)), 'DWG-' + CONVERT(VARCHAR, (@TagCounter % 45)) ) SET @TagId = SCOPE_IDENTITY() SET @AlarmCount = RAND()*(5-2)+2 --(RAND BETWEEN 2 AND 5) SET @AlarmTypeId = RAND()*(5-1)+1 --(RAND BETWEEN 1 AND 5) SET @ReviewStatusId = RAND()*(5-0)+0 --(RAND BETWEEN 0 AND 5) SET @AlarmCounter = 0 WHILE @AlarmCounter < @AlarmCount BEGIN SET @AlarmCounter = @AlarmCounter + 1 PRINT ' - CREATING ALARM ' + CONVERT(VARCHAR, @AlarmCounter) + ' OF ' + CONVERT(VARCHAR, @AlarmCount) SET @RandomWordCount = RAND()*(100-5)+5 --5-100 RANDOM WORDS EXEC [dbo].[GetRandomWords] @WordCount = @RandomWordCount, @RandomWords = @RandomWords OUTPUT INSERT INTO [dbo].[Alarm] ([TagId], [AlarmTagName], [AlarmDescription], [AlarmTypeId], [IsValid], [ReviewStatusId], [ReviewedDate]) SELECT @TagId, 'ALARM' + CONVERT(VARCHAR, @TagId) + '-' + CONVERT(VARCHAR, @AlarmCounter), @RandomWords, @AlarmTypeId, CASE @ReviewStatusId WHEN 1 THEN 1 WHEN 3 THEN 1 WHEN 5 THEN 1 WHEN 2 THEN 0 ELSE NULL END, CASE WHEN @ReviewStatusId = 0 THEN NULL ELSE @ReviewStatusId END, CASE WHEN @ReviewStatusId = 1 THEN DATEADD(DAY, (@AlarmCount + @AlarmTypeId + @ReviewStatusId) * -1, GETDATE()) ELSE NULL END END END SET NOCOUNT OFF GO SELECT (select count(*) from [dbo].[Tag]) + (select count(*) from [dbo].[Alarm]) AS TOTAL_ROW_COUNT |
Generate Model Classes
In the Package Manager Console run the following.
Scaffold-DbContext -Connection "Server=(local);Database=MasterDetailSample;Trusted_Connection=True;" -Provider "Microsoft.EntityFrameworkCore.SqlServer" -OutputDir "Models\Data" -Tables ("Alarm","AlarmReviewStatus","AlarmType","Tag") -Force -DataAnnotations
The project should now have these classes.
Delete the OnConfiguring method in the DbContext class.
Add a constructor, which is used for dependency injection.
Connection String
In appsettings.json add the connection string.
Register the DB Context
In Startup.cs register the db context. (note: add the using statement for Microsoft.EntityFrameworkCore)
NOTE: use the new db context pool only if your context class does not store any state within it.
As this example doesn’t store any state the AddDbContextPool method is used, otherwise fall back to services.AddDbContext().
Authorization
Authorization policies are more flexible than just decorating a controller/method with [Authorize(“ADGroup”)] and allow easy changes within appsettings.json. The following will create 2 model classes (AuthPolicy and AuthMember) and a settings class which will get its values from appsettings.json. It will also show how to initialize the settings in Startup.cs and how to inject it into controllers and views. The Settings class has 2 policies, one for READ and one for WRITE.
Auth Classes
Create a new folder at the project root called Auth, and add a new class file Auth.cs, with the following code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
public class AuthPolicySettings { public AuthPolicy ReadAccess { get; set; } public AuthPolicy WriteAccess { get; set; } public const string ReadAccessName = "ReadAccess"; public const string WriteAccessName = "WriteAccess"; internal void InitAuth(IServiceCollection services) { AddAuthorization(services, ReadAccess, ReadAccessName); AddAuthorization(services, WriteAccess, WriteAccessName); } private void AddAuthorization(IServiceCollection services, AuthPolicy authPolicy, String policyName) { services.AddAuthorization(options => { options.AddPolicy(policyName, policy => { policy.RequireAuthenticatedUser(); foreach (Auth.AuthMember authMember in authPolicy.Members) { policy.RequireRole(authMember.Values); } }); }); } } public class AuthPolicy { public List<AuthMember> Members { get; set; } } public class AuthMember { public List<String> Values { get; set; } } |
Note: add the using statement for Microsoft.Extensions.DependencyInjection.
Config
Add the following to appsettings.json.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
"AuthPolicySettings": { "ReadAccess": { "Members": [ { "Values": [ "mydomain\\Domain Users" ] } ] }, "WriteAccess": { "Members": [ { "Values": [ "mydomain\\user1", "mydomain\\WriteAccessGroup" ] } ] } } |
Replace the values with your AD Groups/users. The above settings give read access to all Domain Users, while write access is restricted to user1 OR members of the WriteAccessGroup.
To restrict the policy to allow members which belong to 2 groups, use the members array. i.e.
"WriteAccess": { "Members": [ { "Values": [ "mydomain\\OfficeStaff" ]}, { "Values": [ "mydomain\\EngineeringStaff" ]} ] }
Meaning WriteAccess is given to users who belong to OfficeStaff AND EngineeringStaff.
Initialize the AuthPolicySettings
In ConfigureServices method of Startup.cs add the following code at the start of the method.
1 2 3 4 |
var authSection = Configuration.GetSection("AuthPolicySettings"); Auth.AuthPolicySettings authPolicySettings = new Auth.AuthPolicySettings(); authSection.Bind(authPolicySettings); authPolicySettings.InitAuth(services); |
Line 1 reads the settings out of appsettings.json. Line 2 and 3 instantiates the classes and line 4 sets it up for dependency injection.
Auth Usage
This Auth can be used 3 ways.
Attributes
Decorate controller classes with attribute. This example lets ReadAccess users to view Index but not the About page. Note: this is only an example, you wouldn’t really expect the About page to be restricted to the write group.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
[Authorize(Policy = Auth.AuthPolicySettings.ReadAccessName)] public class HomeController : Controller { public IActionResult Index() { return View(); } [Authorize(Policy = Auth.AuthPolicySettings.WriteAccessName)] public IActionResult About() { ViewData["Message"] = "Your application description page."; return View(); } |
Dependency Injection into Controller
Include IAuthorizationService parameter into a controller.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public class HomeController : Controller { private readonly IAuthorizationService _authSvc; public HomeController(IAuthorizationService authSvc) { _authSvc = authSvc; } public IActionResult About() { if (_authSvc.AuthorizeAsync(User, AuthPolicySettings.WriteAccessName).Result.Succeeded) { ViewData["Message"] = "Your application description page. Viewed by user with WRITE ACCESS"; } else { ViewData["Message"] = "Your application description page."; } return View(); } } |
Inject into View
Add the using and inject statements at the top of the razor page.
Call the AuthorizeAsync method on the service. Similar to above in the controller.
Web API
This next step changes the code generator template for “API Controller with actions, using Entity Framework” so that it matches the Kendo signature. This technique will allow complete control over the code generation just for the project.
Create a new folder structure at the solution root.
Templates\ControllerGenerator
Copy “ApiControllerWithContext.cshtml” from “C:\Program Files\dotnet\sdk\NuGetFallbackFolder\microsoft.visualstudio.web.codegenerators.mvc\2.0.0\Templates\ControllerGenerator” into the newly created folder. Note: the file can also be found at C:\Users\*YOU*\.nuget\packages\microsoft.visualstudio.web.codegenerators.mvc\1.1.1\Templates\ControllerGenerator
Replace the contents with the following. (hint: do it outside of VS so that it bypasses formatting)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
//Updated from C:\Users\*YOU*\.nuget\packages\microsoft.visualstudio.web.codegenerators.mvc\1.1.1\Templates\ControllerGenerator // where *YOU* is your username @inherits Microsoft.VisualStudio.Web.CodeGeneration.Templating.RazorTemplateBase using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Kendo.Mvc.UI; using Kendo.Mvc.Extensions; using Microsoft.AspNetCore.Authorization; @{ foreach (var namespaceName in Model.RequiredNamespaces) { @:using @namespaceName; } } namespace @Model.ControllerNamespace { @{ string routePrefix = "api/" + Model.ControllerRootName; var entitySetName = Model.ModelMetadata.EntitySetName; var primaryKeyName = Model.ModelMetadata.PrimaryKeys[0].PropertyName; var primaryKeyShortTypeName = Model.ModelMetadata.PrimaryKeys[0].ShortTypeName; var primaryKeyType = Model.ModelMetadata.PrimaryKeys[0].TypeName; var primaryKeyIsAutoGenerated = Model.ModelMetadata.PrimaryKeys[0].IsAutoGenerated; } [Produces("application/json")] [Route("@routePrefix")] [Authorize] public class @Model.ControllerName : Controller { private readonly @Model.ContextTypeName _context; public @(Model.ControllerName)(@Model.ContextTypeName context) { _context = context; } // GET: @routePrefix [HttpGet] [Authorize(Policy = Auth.AuthPolicySettings.ReadAccessName)] public DataSourceResult List([DataSourceRequest]DataSourceRequest request) { return _context.@(entitySetName) .ToDataSourceResult(request); } // GET: @routePrefix/5 [HttpGet("{id}")] [Authorize(Policy = Auth.AuthPolicySettings.ReadAccessName)] public async Task<IActionResult> Get([FromRoute] @primaryKeyShortTypeName id) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var @Model.ModelVariable = await _context.@(entitySetName).SingleOrDefaultAsync(m => m.@primaryKeyName == id); if (@Model.ModelVariable == null) { return NotFound(); } return Ok(@(Model.ModelVariable)); } // PUT: @routePrefix/5 [HttpPut("{id}")] [Authorize(Policy = Auth.AuthPolicySettings.WriteAccessName)] public async Task<IActionResult> Update([FromRoute] @primaryKeyShortTypeName id, @(Model.ModelTypeName) @Model.ModelVariable) { if (!ModelState.IsValid) { return BadRequest(ModelState); } if (id != @(Model.ModelVariable).@primaryKeyName) { return BadRequest(); } _context.Entry(@(Model.ModelVariable)).State = EntityState.Modified; try { await _context.SaveChangesAsync(); } catch (DbUpdateConcurrencyException) { if (!@(Model.ModelTypeName)Exists(id)) { return NotFound(); } else { throw; } } return NoContent(); } // POST: @routePrefix [HttpPost] [Authorize(Policy = Auth.AuthPolicySettings.WriteAccessName)] public async Task<IActionResult> Create(@(Model.ModelTypeName) @Model.ModelVariable) { @{ if (primaryKeyIsAutoGenerated) { @:@(Model.ModelVariable).@primaryKeyName = 0; } } if (!ModelState.IsValid) { return BadRequest(ModelState); } _context.@(entitySetName).Add(@(Model.ModelVariable)); @{ if (primaryKeyIsAutoGenerated) { @:await _context.SaveChangesAsync(); } else { @:try @:{ @:await _context.SaveChangesAsync(); @:} @:catch (DbUpdateException) @:{ @:if (@(Model.ModelTypeName)Exists(@(Model.ModelVariable).@primaryKeyName)) @:{ @:return new StatusCodeResult(StatusCodes.Status409Conflict); @:} @:else @:{ @:throw; @:} @:} } } return new OkObjectResult(new DataSourceResult { Data = new[] { @Model.ModelVariable }, Total = 1 }); } // DELETE: @routePrefix/5 [HttpDelete("{id}")] [Authorize(Policy = Auth.AuthPolicySettings.WriteAccessName)] public async Task<IActionResult> Delete([FromRoute] @primaryKeyShortTypeName id) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var @Model.ModelVariable = await _context.@(entitySetName).SingleOrDefaultAsync(m => m.@primaryKeyName == id); if (@Model.ModelVariable == null) { return NotFound(); } _context.@(entitySetName).Remove(@Model.ModelVariable); await _context.SaveChangesAsync(); return Ok(@Model.ModelVariable); } private bool @(Model.ModelTypeName)Exists(@primaryKeyShortTypeName id) { return _context.@(entitySetName).Any(e => e.@primaryKeyName == id); } } } @* /* List Model Properties @{ foreach (System.ComponentModel.PropertyDescriptor prop in System.ComponentModel.TypeDescriptor.GetProperties(Model.GetType())) { @: @(prop.Name) (@(prop.PropertyType.FullName)); } } */ *@ |
Test it out by creating a WebApi for the AlarmReviewStatus model.
Create a folder WebApi under Controllers. Right click it and choose Controller… (Note: if this is the first time then follow the prompts and add MVC dependencies). When the Add Scaffold screen appears select “API Controller with actions, using Entity Framework”.
Select AlarmReviewStatus for the model and your db context. Leave the default for the controller name.
The following file should be created.
Test it out by running the project and browsing to /api/AlarmReviewStatus. Should be pretty similar to the following.
Continue to do the same for the other models.
- AlarmTypes
- Tag
- Alarm
The WebApi folder should now look like this.
Setup Kendo and Json for use in the views
Startup.cs
In Startup.cs register Kendo and set the Json options.
View Imports
In Views\_ViewImports.cshtml add using statement.
Kendo UI Client-side Resources
Create a folder “kendo-ui” under wwwroot\lib, and copy js and styles from Telerik source. (mine at the time of writing was C:\Program Files (x86)\Progress\Telerik UI for ASP.NET Core R3 2017)
Styles and Javascript files
In Views\Shared\_Layout.cshtml remove the scripts at the bottom and replace them at the top. (I found this easier than remembering to use deferred() for the kendo controls)
You could use the <environment> tag to use the full files for Development and the minified ones for Production. I didn’t do that here because it’s only really useful when troubleshooting.
The 3 kendo styles (highlighted) can be replaced with 1 if you use the theme builder from Telerik (http://themebuilder.telerik.com/aspnet-core).
Editor Templates
Copy all the razor templates from the Telerik source to Views\Shared\EditorTemplates. (mine at the time of writing was C:\Program Files (x86)\Progress\Telerik UI for ASP.NET Core R3 2017\wrappers\aspnetcore\EditorTemplates\razor).
Globalization
In Startup.Configure() set it to use Request Localization.
In _Layout.cshtml include the kendo culture javascript file after the other js files.
1 2 3 4 5 |
@{ var culture = System.Globalization.CultureInfo.CurrentCulture.ToString(); } <script src='@Url.Content("~/lib/kendo-ui/js/cultures/kendo.culture." + culture + ".min.js")'></script> <script> kendo.culture("@culture"); </script> |
The View
Create a new view “Tags” in the Views\Home folder and change the HomeController to suit.
Build and run it to check that all is well.
The Tags (Parent) Grid
Now add the Tags grid. In the view, start with the bare minimum to get the Kendo grid doing something. In Tags.cshtml add the following.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@(Html.Kendo().Grid<KendoMasterDetailGrid.Models.Data.Tag>() .Name("TagGrid") .DataSource(ds => ds .WebApi() .Model(model => { model.Id(vm => vm.Id); model.Field(vm => vm.Id).Editable(false); }) .Read(read => read.Action("List", "Tags")) .Events(ev => { ev.Error("function(e){alert('Error occurred with DataSource. See log for more info.'); console.log(e); e.sender.cancelChanges();}"); }) ) .Columns(c => c.AutoGenerate(true)) .Pageable(p => p .Enabled(true) ) ) |
Line 1 creates a kendo grid for the Tag model.
Line 2 gives it a name so that it can be referenced with jQuery.
Lines 3-15 set the data source.
Line 10 sets up the read action to the List method of the TagsController.
Line 13 hooks the error event. Doesn’t do too much but logs the error to console, which is really helpful when trouble shooting.
Line 16 shows all the columns by default. Expect to change this soon as you’ll want to specifically state your columns and widths. But for now it’s a nice quick start to get something on the screen.
Lines 17-19 sets up paging.
Run the project. The result should be similar to this.
Add some of the other functionality like sorting and filtering.
Add Refresh and bigger page sizes.
The view with extra functionality.
Add the edit/create/delete functionality. First in DataSource add the Update action, then add the command column (before or after the data columns, depending on what you feel like).
The grid should now look like this, with updates and saves.
The default option for edit mode is InLine, but there are other options. InLine or PopUp works well with the command buttons on each row. InCell is good for a batch save, but you have to do other stuff to handle that. This example doesn’t go into that. To change the mode to PopUp, add the Editable method on the grid.
Out of the box it looks like this.
This can be made prettier by adding an EditorTemplate for Tag. For now set the edit mode back to InLine and continue.
Updates should work successfully if you added yourself to the WriteAccess group. Change the config so that you are not in the WriteAccess group and try to update again. Have a look at the console log and drill down into the responseText of the xhr object. You’ll see the error.
Seeing the user can’t save, the edit button (or column) can be hidden. Change the command column to the following code (ref above in Auth Usage for the view).
Include yourself back into the WriteAccess group and add the create functionality. First add the create action to the DataSource, then add the create button on the tool bar of the grid.
Adding a new record.
After Save.
Add the delete functionality in the same way. First add it to the DataSource, then add it next to the edit command.
If you try to delete right now it will fail because of the foreign key constraint. At least you get a hint of that from the Error event of the DataSource.
You could turn on cascade deletes or do an extra check on the TagsController to return a BadRequest if the Tag has children. Leave it for now until after the details grid.
The Alarms (Detail) Grid
Make use of the Kendo templates. Start a script tag and include a kendo grid for the Alarm model. Name it “TagGrid_#=Id#”. In order for the template grid to work properly the last method called on the detail grid must be ToClientTemplate(). Include the template in the parent grid with ClientDetailTemplateId().
If you run the above code, you’ll see the beginnings of the master-detail layout. Notice the id of the detail grid matches the Tag Id.
Continue adding the DataSource and columns to the Alarms grid. (Remember to keep the ToClientTemplate() as the last call).
To make it a bit easier to return Alarms filtered by a Tag, I’ve added an extra parameter “ParentTagId” to the List and Create methods on the AlarmsController.
The full details grid as follows.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
<script id="AlarmsTemplate" type="text/x-kendo-template"> @(Html.Kendo().Grid<KendoMasterDetailGrid.Models.Data.Alarm>() .Name("TagGrid_#=Id#") .DataSource(ds => ds .WebApi() .Model(model => { model.Id(vm => vm.Id); model.Field(vm => vm.Id).Editable(false); }) .Read(read => read.Action("List","Alarms", new { ParentTagId = "#=Id#" })) .Create(create => create.Action("Create","Alarms", new { ParentTagId = "#=Id#" })) .Update(update => update.Action("Update","Alarms", new { id = "{0}" })) .Destroy(destroy => destroy.Action("Delete", "Alarms", new { id = "{0}" })) .Events(e => { e.Error("function(e){alert('Error occurred with DataSource. See log for more info.'); console.log(e); e.sender.cancelChanges();}"); }) ) .ToolBar(tools => { if (_authSvc.AuthorizeAsync(User, AuthPolicySettings.WriteAccessName).Result.Succeeded) { tools.Create().Text("Add New Alarm"); } }) .Columns(c => { c.AutoGenerate(true); if (_authSvc.AuthorizeAsync(User, AuthPolicySettings.WriteAccessName).Result.Succeeded) { c.Command(command => { command.Edit().Text(" ").CancelText(" ").UpdateText(" "); command.Destroy().Text(" "); }); } }) .Editable(e => e.Mode(GridEditMode.InLine)) .Navigatable() .Filterable() .Sortable() .Pageable(p => { p.Refresh(true); p.PageSizes(true); p.Enabled(true); }) .ToClientTemplate() ) </script> |
The modifications to the AlarmsController
The results.
About time to explicitly code the columns. Remove the c.AutoGenerate(true); line and replace with the following. Start with the text fields first.
The description column takes up a lot of room on the grid, so we’ll drop that on this screen and change the edit mode to PopUp and show it there.
Remove the AlarmDescription column and add the lookups, AlarmType and ReviewStatus.
Firstly inject the db context to the Home Controller and use the ViewBag to hold the SelectList for AlarmType.
Add the column to the view.
The results for the view and InLine edit mode.
If you add a UIHint and Display attributes to the model class, it will flow through to the PopUp edit as well.
Do the same for ReviewStatusId (except bind it to a ComboBox instead of a DropDown).
Setup the data in the HomeController.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public IActionResult Tags() { //load select lists //Alarm Lookup ViewBag.AlarmTypeId_Data = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(_dbContext .AlarmType .OrderBy(t => t.DisplaySequence) .ToList() , "Id", "AlarmType1"); //ReviewStatus Lookup ViewBag.ReviewStatusId_Data = new Microsoft.AspNetCore.Mvc.Rendering.SelectList(_dbContext .AlarmReviewStatus .OrderBy(t => t.DisplaySequence) .ToList() , "Id", "ReviewStatus"); return View(); } |
Create a new EditorTemplate named ComboBox. The content will be very similar to the Telerik GridForeignKey.cshtml file.
1 2 3 4 5 6 7 |
@model object @( Html.Kendo().ComboBoxFor(m => m) .BindTo((SelectList)ViewData[ViewData.TemplateInfo.GetFullHtmlFieldName("") + "_Data"]) .ValuePrimitive(true) ) |
Decorate the ReviewStatusId property of the Alarm model.
Add the column to the child grid.
The results should be something similar to this, where Review Status can have a null value.
Tidy up those labels using the Display attribute on the Alarm model. At the same time hide the Id fields by setting the ScaffoldColumn attribute to false and show the ReviewedDate as a Date instead of DateTime.
If the checkbox has the extra label, you could get rid of it, seeing as the label is already there on the edit form. Change the Boolean editor template to have a blank label.
Last thing to fix is the long description should be bound to a bigger input box. As KendoUI don’t have a suitable control, create an editor template named TextArea.cshtml with the following content.
1 2 3 |
@model String @(Html.TextAreaFor(m => m, new { Class = "k-textbox", Rows = "8" })) |
This will style a TextArea element to match the rest of the Kendo controls.
Set the UIHint for the AlarmDescription in the Alarm model.
And the results should be similar to this.
One thing that did tick me off about the Kendo grid was that this works absolutely fine for US date formats. When saving a date in other locales it fails because the datasource serializes it as US format. To fix this you need to hook the Create and Update methods of the datasource. In Tags.cshtml add the following javascript to the bottom.
1 2 3 4 5 6 |
<script> function onAlarmDataSourceSending(data) { data.ReviewedDate = kendo.toString(data.ReviewedDate, 'yyyy-MM-dd'); } </script> |
Add a Data method to the Create and Update actions of the Alarm Datasource.
Next Steps
Custom editor templates to replace the out-of-the-box edit popups and stretching the edit screen to full width.
Showing/hiding columns based on screen width.
Validation should be expanded to handle multiple column validation and multiple objects. And displaying those validation fails to the end user.
Telerik Reports project with some reports for Tags and Alarms and linked from the grids.
Summary
This has shown how to display parent/child relationships in a table like fashion with edits and simple validation using web api with authentication over the top of a SQL database.
I hope this example serves as a nice entry point to Kendo controls, web api and .NET Core.