Serenity Developer Guide

User Manual:

Open the PDF directly: View PDF PDF.
Page Count: 475

DownloadSerenity Developer Guide Serenity-guide
Open PDF In BrowserView PDF
Table of Contents
Introduction

1.1

Getting Started

1.2

Installing Serene From Visual Studio Marketplace

1.2.1

Installing Serene Directly From Visual Studio

1.2.2

Instaling Serene Asp.Net Core Version with Serin

1.2.3

Starting Serene

1.2.4

A Tour Of Serene Features

1.3

Theming

1.3.1

Localization

1.3.2

User and Role Management

1.3.3

Listing Pages

1.3.4

Edit Dialogs

1.3.5

Tutorials

1.4

Movie Database

1.4.1

Creating Movie Table

1.4.1.1

Generating Code For Movie Table

1.4.1.2

Customizing Movie Interface

1.4.1.3

Handling Movie Navigation

1.4.1.4

Customizing Quick Search

1.4.1.5

Adding a Movie Kind Field

1.4.1.6

Adding Movie Genres

1.4.1.7

Updating Serenity Packages

1.4.1.8

Allowing Multiple Genre Selection

1.4.1.9

Filtering with Multiple Genre List

1.4.1.10

The Cast and Characters They Played

1.4.1.11

Listing Movies in Person Dialog

1.4.1.12

Adding Primary and Gallery Images

1.4.1.13

Multi Tenancy

1.4.2

Adding Tenants Table and TenantId Field

1.4.2.1

Generating Code for Tenants Table

1.4.2.2

2

Tenant Selection in User Dialog

1.4.2.3

Filtering Users By TenantId

1.4.2.4

Removing Tenant Dropdown From User Form

1.4.2.5

Securing Tenant Selection At Server Side

1.4.2.6

Setting TenantId For New Users

1.4.2.7

Preventing Edits To Users From Other Tenants

1.4.2.8

Hiding the Tenant Administration Permission

1.4.2.9

Making Roles Multi-Tenant

1.4.2.10

Using Serenity Service Behaviors

1.4.2.11

Extending Multi-Tenant Behavior To Northwind

1.4.2.12

Handling Lookup Scripts

1.4.2.13

Meeting Management
Creating Lookup Tables
How To Guides

1.4.3
1.4.3.1
1.5

How To: Remove Northwind & Other Samples From Serene

1.5.1

How To: Update Serenity NuGet Packages

1.5.2

How To: Upgrade to Serenity 2.0 and Enable TypeScript

1.5.3

How To: Authenticate With Active Directory or LDAP

1.5.4

How To: Use a SlickGrid Formatter

1.5.5

How To: Add a Row Selection Column

1.5.6

How To: Setup Cascaded Editors

1.5.7

How To: Use Recaptcha

1.5.8

How To: Register Permissions in Serene

1.5.9

How To: Use a Third Party Plugin With Serenity

1.5.10

How To: Enable Script Bundling

1.5.11

How To: Debugging with Serenity Sources

1.5.12

Frequently Asked Questions

1.6

Troubleshooting

1.7

Service Locator & Initialization

1.8

Dependency Static Class

1.8.1

IDependencyResolver Interface

1.8.2

IDependencyRegistrar Interface

1.8.3

MunqContainer Class

1.8.4

CommonInitialization Static Class

1.8.5
3

Authentication & Authorization

1.9

IAuthenticationService Interface

1.9.1

IAuthorizationService Interface

1.9.2

IPermissionService Interface

1.9.3

IUserDefinition Interface

1.9.4

IUserRetrieveService Interface

1.9.5

Authorization Static Class

1.9.6

Configuration System

1.10

Defining Configuration Settings

1.10.1

IConfigurationRepository Interface

1.10.2

AppSettingsJsonConfigRepository

1.10.3

Config Static Class

1.10.4

Localization

1.11

LocalText Class

1.11.1

Language Identifiers

1.11.2

Language Fallbacks

1.11.3

ILocalTextRegistry Interface

1.11.4

LocalTextRegistry Class

1.11.5

Pending Approval Mode
Registering Translations

1.11.5.1
1.11.6

Manually Registering Translations

1.11.6.1

Nested Local Texts

1.11.6.2

Enumeration Texts

1.11.6.3

JSON Local Texts

1.11.6.4

Caching

1.12

Local Caching

1.12.1

ILocalCache Interface

1.12.1.1

LocalCache Static Class

1.12.1.2

User Profile Caching Sample

1.12.1.3

Distributed Caching

1.12.2

WEB Farms and Caching

1.12.2.1

IDistributedCache Interface

1.12.2.2

Distributed Cache Static Class

1.12.2.3

4

DistributedCacheEmulator Class

1.12.2.4

CouchbaseDistributedCache Class

1.12.2.5

RedisDistributedCache Class

1.12.2.6

Two Level Caching

1.12.3

Using Local Cache and Distributed Cache In Sync

1.12.3.1

TwoLevelCache Class

1.12.3.2

Entities (Row)

1.13

Mapping Attributes

1.13.1

FieldFlags Enumeration

1.13.2

Fluent SQL

1.14

SqlQuery Object

1.14.1

Criteria Objects

1.14.2

Connections and Transactions

1.15

SQL Database Types

1.16

Working with Other Databases

1.16.1

Setting Connection Dialect

1.16.2

Dialect Based Expressions

1.16.3

PostgreSQL

1.16.4

MySQL

1.16.5

Sqlite

1.16.6

Oracle

1.16.7

Services

1.17

Service Endpoints

1.17.1

List Request Handler

1.17.2

Widgets

1.18

ScriptContext Class

1.18.1

Widget Class

1.18.2

Widget With Options

1.18.3

TemplatedWidget Class

1.18.4

TemplatedDialog Class

1.18.5

Attributes

1.19

Grids

1.20

Formatter Types

1.20.1

Persisting Settings

1.20.2
5

Code Generator (Sergen)

1.21

Used Tools & Libraries

1.22

6

Introduction

Introduction
What is Serenity Platform
Serenity is an ASP.NET Core / MVC / TypeScript application platform which has been built
on open source technologies.
It aims to make development easier while reducing maintenance costs by avoiding boilerplate code, reducing the time spent on repetitive tasks and applying the best software design
practices.

Who/What This Platform Is For
Serenity is best suited to business applications with many data entry forms or administrative
interface of public facing web sites. It's features can be useful for other kinds of web
applications as well.

Where To Look For Information
After reading this guide and its tutorials, follow resources below for more information about
Serenity.
Home Page:
http://serenity.is
Blog:
http://serenity.is/blog
Github Repository:
https://github.com/volkanceylan/Serenity
Issues / Questions
https://github.com/volkanceylan/Serenity/issues
Wiki (FAQ, Troubleshooting and Other Community Content)
https://github.com/volkanceylan/Serenity/wiki
Change Log:
https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md

7

Introduction

Serene Application Template:
https://marketplace.visualstudio.com/items?
itemName=VolkanCeylan.SereneSerenityApplicationTemplate

What's In The Name
Serenity has dictionary meanings of peace, comfort and calmness.
This is what we are trying to achieve with Serenity. We hope that after installing and using it
you will feel this way too...

What Features It Provides
A modular, service based web application model
Code generator to produce initial services / user interface code for an SQL table
T4 based code generation on server to reference script widgets with intellisense /
compile time validation
T4 based code generation to provide compile time type safety and intellisense while
calling AJAX services from script side.
An attribute based form definition system (prepare UI in server side with a simple C#
class)
Automatic seamless data-binding through form definitions (form <-> entity <-> service).
Caching Helpers (Local / Distributed)
Automatic cache validation
Configuration System (storage medium independent. store settings in database, file,
whatever...)
Simple Logging
Reporting (reports just provide data, has no dependency on rendering, similar to MVC)
Script bundling, minification (making use of Node / UglifyJS / CleanCSS) and content
versioning (no more F5 / clear browser cache)
Fluent SQL Builder (SELECT/INSERT/UPDATE/DELETE)
Micro ORM (also Dapper is integrated)
Customizable handlers for REST like services that work by reusing information in entity
classes and do automatic validation.
Attribute based navigation menu
UI Localization (store localized texts in json files, embedded resource, database, in
memory class, anywhere)
Data Localization (using an extension table mechanism helps to localize even data
entered by users, like lookup tables)

8

Introduction

Script widget system (inspired by jQueryUI but more suitable for C# code)
Client side and server side validation (based on jQuery validate plugin, but abstracts
dependency)
Audit logging (where CDC is not available)
System for data based integration tests
Dynamic scripts
Script side templates

Background
This part was originally written for a CodeProject article as an introduction to Serenity.
The article was rejected with the reason that it didn't contain code but was an ad for
code. They were right, as i did put a link to Movie tutorial in this guide, instead of copy
pasting code.
You can safely skip to next chapter, if you don't like reading history :)
We, developers, are all solving the same sets of problems everyday. Just like college
students working on their problem books.
Even though we know that they are already solved and have answers somewhere, it doesn't
stop us from working on them. Actually, it helps us improve our skills, and hey you can't
learn without making some mistakes, can you? But we should learn where to draw a line
between training and wasting time.
When you start a new project, you have several decisions to make on platform, architecture
and set of libraries. Today you have so many choices for every single topic. Yes, having
some options is good, as long as they are limited, as our time is not infinite.
Here is a short history about Serenity, which aims to handle common tasks you deal with
business applications, and let you spare your precious time focusing on features specific to
your application domain.
My first real job in web technologies was in a web agency designing country-specific web
sites of some of big names in industry, e.g. automative companies (btw, we are talking about
10+ years past, time flows fast).
As I had a software architect career in desktop applications before I signed there, I was
asked to design a ASP.NET WebForms platform for them. They explained that they have
many shared modules, like news, galleries, navigation at each site, but as requirements are
different, they had to copy/paste then customize code specific to every customer. When they
wanted to add a common feature, they had to repeat it for every site.

9

Introduction

At that time, there weren't so many CMS systems in market, and I designed one for them,
without even knowing it was called a CMS. For me, it wasn't perfect, not even good enough
as I just had a few weeks to design it. But they were very pleased with the result, as it took
development of new sites down to days/weeks from months. Also resulting code was more
manageable than before.
Learning from that experience, and mistakes, that poor-mans CMS became something
better. Later, that platform is evolved to be used by applications in varying domains, like a
help-desk system, a CRM, ERP, personnel management, electronic document management,
university student information system and more.
To be compatible with different kinds of applications, systems and even legacy databases, it
had to be flexible and went through many architectural changes.
Now it takes us to Serenity. Even though it is an open source project for about 2 years, it has
a much older background. But it is also young, energetic, and is not afraid of change. It can
adapt to new technologies as they became popular and stable. This might mean breaking
changes from time to time, but we strive to keep them to a minimum without being paranoid
about backwards compability.

10

Getting Started

Getting Started
The best and fastest way to get your hands dirty on Serenity is SERENE, which is a sample
application template.
You have three options to install SERENE template:
Please check prerequisites below before trying to install Serene.
Installing SERENE from Visual Studio Marketplace (Windows)
Installing SERENE directly from Visual Studio (Windows)
Installing SERENE for Asp.Net Core with SERIN (Linux, OSX, Windows)

Prerequisites
Visual Studio Version
Serene .NET Framework version (ASP.NET MVC 4) requires Visual Studio 2017 or Visual
Studio 2015 with Update 3 installed.
If you have Visual Studio 2015, please make sure that you have Update 3 installed by
looking at Help => About

11

Getting Started

It might be possible to work with Visual Studio 2013 as well but you'll have many
intellisense errors as TypeScript 2.5.2 can't be installed in VS2013.
Serene ASP.NET Core 2.0 version only works in Visual Studio 2017, or using command line.
Microsoft recently obsoleted project.json based projects and replaced them with a
lighter version of MsBuild based CSPROJ projects. This new project system only works
in Visual Studio 2017, so if you want to work with .NET Core version of Serene, either
you need to use Visual Studio 2017 or go lighter with Visual Studio Code / Command
Line.

.NET Core SDK
If you are going to use ASP.NET Core version of Serene, please install .NET Core 2.0 SDK
from:
https://www.microsoft.com/net/download/core

Visual Studio TypeScript Extension
As of writing, the recommended version of TypeScript is 2.5.2.

12

Getting Started

Even though Serene uses NodeJS based TypeScript compiler (tsc) on build, Visual Studio
still uses its own version of TypeScript for intellisense and refactoring etc. If you have an
older version of that extension, you'll be greeted with many errors as soon as you open a
Serene project.
To check what version of TypeScript Visual Studio Extension you have, again see Help =>
About:

Visual Studio 2017 comes with TypeScript 2.1.5 by default, but Visual Studio 2015 might
include older versions.
If you have something lower than 2.3.4 there, you might need to install TypeScript for Visual
Studio extension.
TypeScript version you see in Control Panel / Add Remove Programs doesn't matter at
all. What matters is the one that is enabled in Visual Studio.
TypeScript versions after 1.8.6 requires Visual Studio 2015 Update 3 to be installed first, so
even if you try to install the extension it will raise an error, so please first install Update 3.
You can get TypeScript extension for your Visual Studio version from
http://www.typescriptlang.org/#download-links.

13

Getting Started

Here is the link for Visual Studio 2015:
https://www.microsoft.com/en-us/download/details.aspx?id=48593
But don't click the download button right away. Expand Details section, and select the
exact version you need (e.g. 2.5.2):

Latest version of TypeScript might probably work but keeping in sync with the version we
currently use, can help you avoid compatibility problems that could come with them.

NodeJS / NPM
Serene uses NodeJS / NPM for these:
TypeScript typings (.d.ts) for libraries like jQuery, Bootstrap etc.

14

Getting Started

TypeScript compiler itself (tsc)
Less compilation (lessjs)
T4 Code generation by parsing TypeScript sources
It requires NodeJS v6.9+ and NPM 3.10+
Serene will check their versions on project creation and ask for confirmation to download
and install them. Anyway, please check your versions manually by opening a command
prompt:
> npm -v
3.10.10

> node -v
6.9.4

If you get an error, they might not be installed or not in path. Please install LTS (long term
support) versions from https://nodejs.org/en/
Current version might also work but is not tested.

Visual Studio and External Web Tool Paths
Even if you have correct Node / NPM installed, Visual Studio might still be trying to use its
own integrated, and older version of NodeJS.
Click Tools => Options, and then under Projects and Solutions => External Web Tools add
C:\Program Files\nodejs to the top of the list by clicking plus folder icon, typing C:\Program
Files\nodejs and using Up Arrow to move it to the start:

15

Getting Started

16

Installing Serene From Visual Studio Marketplace

Installing Serene From Visual Studio
Marketplace
Downloading Template
Open URL below in your browser:
https://marketplace.visualstudio.com/items?
itemName=VolkanCeylan.SereneSerenityApplicationTemplate

Click Download to transfer VSIX file to your computer.

Install Template into Visual Studio
After download is finished, double click the downloaded VSIX file to start Visual Studio
extension installation dialog

17

Installing Serene From Visual Studio Marketplace

If you have both Visual Studio 2017 and 2015 installed, sometimes Visual Studio 2015
installer might pick up VSIX file so it only installs in Visual Studio 2015. If you
experience this issue, right click the file, click Open With and choose Visual Studio
Version Selector.

Click Install when prompted.

Note that this application template requires Visual Studio 2012 or higher. Make you sure
you have the latest Visual Studio updates installed. ASP.NET MVC Core version
requires Visual Studio 2017 with Update 3

Creating a New Project in Visual Studio

18

Installing Serene From Visual Studio Marketplace

Start Visual Studio (if it was already open, restart it). Click File => New Project. You should
see Serene template under Templates => Visual C# section.

We have two versions of Serene template. One that uses classic ASP.NET MVC 4
(SERENE) and another one that works on ASP.NET CORE MVC 2.0 / .NET CORE 2.0.
ASP.NET Core is a recent technology and is platform independent (as long as you target
.NET Core it also runs on Linux / OSX).
ASP.NET MVC only runs on Windows and .NET framework but more mature (latest version
is dated 2/9/2015).
We can say both versions of Serene is pretty stable.
Here is a document from Microsoft that might help you choose between two frameworks:
https://docs.microsoft.com/en-us/aspnet/core/choose-aspnet-framework
Name your application something like MyCompany, MyProduct, HelloWorld or leave the
default Serene1.
Please don't name it Serenity. It may conflict with other Serenity assemblies.
Please use Pascal casing, e.g. a name that starts with a Capital Letter. Don't name your
project something like

myProject

.

Click OK.

Feature Selection
19

Installing Serene From Visual Studio Marketplace

Serene will prompt you to choose features you would like to see.

All of these features / samples are optional. Initially we recommend you to leave them all
checked so that you might have a look at how they are implemented.
After having some experience with Serene, you might create a new application and clear all
these checkboxes to have a bare minimum project.
Choose features you like, click OK and take a break while Visual Studio creates the solution.

20

Installing Serene Directly From Visual Studio

Installing Serene Directly From Visual
Studio
Start Visual Studio and Click New => Project.
Note that this application template requires Visual Studio 2012 or higher. Make sure you
have the latest Visual Studio updates installed.
In the New Project dialog box Recent, Installed and Online sections will be shown on left and
Installed is the active one.
Click the Online section and wait a bit while Retrieving information message is on screen.
Please wait while it is loading results.

Serene might be already showing on top of the list. If it is not, type SERENE into input box
with Search Online Templates label and press ENTER.
You will see Serene (Serenity Application Template):

21

Installing Serene Directly From Visual Studio

Creating a New Project in Visual Studio 2015
and Older
Name your application something like MyCompany, MyProduct, HelloWorld or leave the
default Serene1.
Please don't name it Serenity. It may conflict with other Serenity assemblies.
After you create your first project, Serene template is installed into Visual Studio, so you
can use the Installed section in New Project dialog to create another Serenity
application.
Click OK to download Serene and create your new project
Your project will use ASP.NET MVC 4 version of Serene, next time you may choose
ASP.NET Core version from New Project dialog, Installed section.

Creating a New Project in Visual Studio 2017
Unfortunately Visual Studio 2017 changed template installation process. When you select
Serene from Online section and click OK, you'll see this dialog:

22

Installing Serene Directly From Visual Studio

When you click Modify, you'll be asked to terminate Visual Studio and other related
processes.
After installation, Visual Studio won't restart itself. You'll have to manually relaunch Visual
Studio.
Now, if you again go to Online section and click Serene, you'll get this error message:

Unfortunately, this is a VS2017 bug.
Please close Online section, and find Serene under New Project => Installed => Visual C#:

23

Installing Serene Directly From Visual Studio

After you create your first project, Serene template is installed into Visual Studio, so you
can use the Installed section in New Project dialog to create another Serenity
application.
Name your application something like MyCompany, MyProduct, HelloWorld or leave the
default Serene1.
Please don't name it Serenity. It may conflict with other Serenity assemblies.
Please use Pascal casing, e.g. a name that starts with a Capital Letter. Don't name your
project something like

myProject

.

Click OK.

Choosing Between ASP.NET MVC / ASP.NET
Core
We have two versions of Serene template. One that uses classic ASP.NET MVC 4
(SERENE) and another one that works on ASP.NET CORE 2.0 / .NET CORE 2.0.
ASP.NET Core is a recent technology and is platform independent (as long as you target
.NET Core it also runs on Linux / OSX).
ASP.NET MVC only runs on Windows and .NET framework but more mature (latest version
is dated 2/9/2015).
We can say both versions of Serene is pretty stable.

24

Installing Serene Directly From Visual Studio

Here is a document from Microsoft that might help you choose between two frameworks:
https://docs.microsoft.com/en-us/aspnet/core/choose-aspnet-framework
Unfortunately Visual Studio 2015 doesn't let you choose which version to use on first
install. But, you will see ASP.NET Core version after installation on New Project dialog.

Feature Selection
Serene will prompt you to choose features you would like to see.

All of these features / samples are optional. Initially we recommend you to leave them all
checked so that you might have a look at how they are implemented.
After having some experience with Serene, you might create a new application and clear all
these checkboxes to have a bare minimum project.
Choose features you like, click OK and take a break while Visual Studio creates the solution.

25

Instaling Serene Asp.Net Core Version with Serin

Installing Serene Asp.Net Core Version
with SERIN
This section is for users who doesn't or can't use Visual Studio (in Linux / OSX).
Serene Asp.Net Core version supports Linux and OSX in addition to Windows.
We recommend Visual Studio Code for all platforms, but it is also possible to work with
a basic text editor like Notepad / VIM. There are also other nice options e.g. Atom.

Install .NET Core 2.0 SDK
Please go to address below and follow instructions for your specific platform:
https://www.microsoft.com/net/download/core

Install NodeJS
As TypeScript (and our SERene project INitializer - SERIN) runs on NodeJS you need to
install Node/NPM from:
https://nodejs.org/en/download/
or using your favorite package manager:
https://nodejs.org/en/download/package-manager/

Install SERIN as a Global Tool
Install our project initializer, SERIN as a global tool using NPM:
Linux / OSX:
> sudo npm install -g serin

Windows:
> npm install -g serin

26

Instaling Serene Asp.Net Core Version with Serin

Thanks to Victor (@vctor) for Linux screenshots

Create Folder for New Project
Create an empty MySerene (or a name you like) folder.
Linux / OSX:
> cd ~
> mkdir MySerene
> cd MySerene

Windows:
> cd c:\Projects
> mkdir MySerene
> cd MySerene

Serin has to be run from a completely empty directory

Run Serin to Create a New Project
While inside an empty directory, run serin:
> serin

Type an application name, e.g. MySerene and press enter. Take a break while Serin creates
your project, initializes static content and restores packages etc.

27

Instaling Serene Asp.Net Core Version with Serin

After Serin creates your project, you will have a MySerene.Web folder under current
directory. Enter that directory:
> cd MySerene.Web

Running Serene
For OSX / Linux, first restore packages:
> dotnet restore

Make sure you run this command under MySerene.AspNetCore folder.
Then type:
> dotnet run

Now open a browser and navigate to

http://localhost:5000

.

Actual port may vary. You'll see it on console after executing dotnet run.

28

Starting Serene

Starting Serene
After your first project is created in Visual Studio using Serene template, you will see a
solution like this:
Asp.Net Core users don't have to use Visual Studio, but we'll use Visual Studio in this
guide as we think most of our users will.

Your solution contains Serene1.Web project, which is an ASP.NET MVC (or ASP.NET Core)
application.
It includes server side code written in C# (.cs) and client side code that is written in
TypeScript (.ts).
Serene.Web has references to Serenity NuGet packages, so you can update it using
package manager console anytime necessary.
Asp.Net Core version can also be updated by hand editing .CSPROJ file.
Serene automatically creates its database in SQL local db at first run, so just press F5 and
you are ready to go.
When application launches use

admin

user and

serenity

password to login. You can

change password or create more users later, using Administration / User Management page.

29

Starting Serene

The sample application includes old and famous Northwind data along with services and
user interface to edit it, which is mostly produced by Serenity Code Generator.

Troubleshooting Connection Problems
If you are getting a connection error like the following while starting Serene for first time:
> A network-related or instance-specific error occurred
> while establishing a connection to SQL Server.
> The server was not found or was not accessible.
> Verify that the instance name is correct...

This error might mean that you don't have SQL Server Local DB 2012 installed. This server
comes preinstalled with Visual Studio 2012 and 2013.

ASP.NET MVC
In

web.config

file there are Default and Northwind connection entries:





30

Starting Serene

ASP.NET Core
In

appsettings.json

file you'll find Default and Northwind connection entries:

"Data": {
"Default": {
"ConnectionString": "Server=(localdb)\\MsSqlLocalDB;Database=Serene2_Default_v1;
Integrated Security=true",
"ProviderName": "System.Data.SqlClient"
},
"Northwind": {
"ConnectionString": "Server=(localdb)\\MsSqlLocalDB;Database=Serene2_Northwind_v
1;Integrated Security=true",
"ProviderName": "System.Data.SqlClient"
}
}

Fixing Connection Strings
(localdb)\v11.0

corresponds to default SQL Server 2012 LocalDB instance, while

(localdb)\MsSqlLocalDB

is an instance of SQL 2014+ LocalDB.

If you don't have SQL LocalDB 2012, you can install it from:
http://www.microsoft.com/en-us/download/details.aspx?id=29062
Visual Studio 2015 comes with SQL Server 2014 LocalDB. It's default instance name is
renamed to MsSqlLocalDB by default. Thus, if you have VS2015, try changing connection
strings from

(localdb)\v11.0

to

(localdb)\MsSqlLocalDB

.





If you still have an error, open an administrative command prompt and type
> sqllocaldb info

This will list localdb instances like:
MSSqlLocalDB
test

31

Starting Serene

If you don't have MsSqlLocalDB listed, you can create it:
> sqllocaldb create MsSqlLocalDB

If you have another SQL server instance, for example SQL Express, change data source to
.\SqlExpress

:





You can also use another SQL server. Just change the connection string.
Perform these steps for both Default and Northwind databases.

32

A Tour Of Serene Features

A Tour Of Serene Features
After logging to Serene, you are greeted with the dashboard page.

This page is taken as a sample from free AdminLTE
(https://almsaeedstudio.com/themes/AdminLTE/index.html) theme.
The page content, except some numbers calculated from Northwind tables, contains random
data.
There is an accordion navigation menu on left which has a search feature by input above it.
We'll talk about how to customize items there in later sections.
On top navigation, there is site name on left, along with a dropdown containing current user
name on right, and a settings button through which we change change theme or active
language.
Theming
Localization

33

Theming

Theming
Serene initially starts with a dark/blue theme. On top right of the screen, next to username,
click the settings button and change theme to another one.

This feature is implemented by replacing a body CSS class.
If you look at the source, you may spot a skin class like below inside



tag:



When you select the light yellow skin, it actually changes to this:


This is done in memory so no page reload is required.

34

Theming

Also cookie, "ThemePreference"" with the content "yellow-light" is added to your browser. So
next time you launch Serene, it will remember your preference and start with a light yellow
theme.
These skin files are located under "Content/adminlte/skins/" of the Serene.Web project. If
you look there you can see files with names:
_all-skins.less
skin.black-light.less
site.blue.less
site.yellow-light.less
site.yellow.less

We are using LESS for CSS generation so you should try editing LESS files, not CSS. Next
time you build your project, LESS files will be compiled to CSS (using Less.js compiler for
Node).
This operation is configured with a build step in Serene.Web.csproj file:
...




...

Here site.less file is compiled to its corresponding css file in the same directory.
See http://lesscss.org/ for more information on LESS compiler and its syntax.

35

Localization

Localization
Serene allows you to change the active language from top right settings menu .
Try changing active language to Spanish.

I don't speak Spanish and used machine translation, so sorry for errors...
When you changed the language, page is reloaded, unlike the theme selection where no
page reload is required.
Serene, also added a cookie, "LanguagePreference" with content "es" to your browser, so
next time you visit the site, it will remember your last selection and start with Spanish.
When you launched Serene first time, you might have seen the site in English, but it is also
possible that it started in Spanish, Turkish or Russian (these are currently available sample
languages) if you have an operating system or browser of that language.
This is controlled by a web.config setting:


36

Localization

Here we set UI culture to automatic, while en-US is a fallback (if system can't determine your
browser language).
It is also possible to set another language as fallback:


Or set a language as default, whatever visiting users browser language is:


If you don't want to let users to change UI language, you should remove the language
selection dropdown.
You may add more languages to the language selection dropdown by using Languages
page under Administration menu.

Localizing UI Texts
Serene includes ability to translate its text resources live.
Click Administration then Translations link in navigation.

Type navigation into top left search box to see list of texts related to navigation menu.
Choose English as source language and Spanish as target language.
Type Welcome Page into line with Navigation.Dashboard local text key.
Click Save Changes.

37

Localization

When you switch to Spanish language, Dashboard menu item will be changed to Welcome
Page instead of Salpicadero.

When you saved changes, Serene created a
App_Data/texts

user.texts.es.json

file in folder

with content like below:

{
"Navigation.Dashboard": "Welcome Page"
}

In the

~/scripts/site/texts

folder, there are also other similar JSON files with default

translations for Serene interface:
site.texts.es.json
site.texts.invariant.json
site.texts.tr.json
It is recommended to transfer your translations from user.texts.xx.json files to
site.texts.xx.json files before publishing. You can also keep them under version control
this way, if App_Data folder is ignored.

38

User and Role Management

User and Role Management
Serene has user, role and rights management built in.
This feature is not embedded in Serenity itself. It is just a sample, so you can always
implement and use your user management of choice. We'll take a look at how in
following chapters.
Open Administration / Roles to create roles Administrators and Translators.
Click New Role and and type Administrators, then click Save.
Repeat it for Translators.

Then click role Administrators to open edit form, and click Edit Permissons button to modify
its permissions. Check all boxes to grant every permisson to this role, then click OK.

39

User and Role Management

Repeat same steps for the Translations role but this time grant only the Administration:
Languages and Translations permission.
Navigate to Administration / User Management page to add more users.
Click admin user to edit its details.

40

User and Role Management

Here you can change admin details like username, display name, email.
You can also change its password (which is serenity by default) by typing into Password and
Confirm Password inputs and clicking Update.
You can also delete it but this would make your site unusable as you wouldn't be able to
login.
admin is a special user in Serene, as it has all permissions even if none is explicitly granted
to him.
Lets create another one and grant roles / permissions to it.
Close this dialog, click new user and type translator as username. Fill in other fields as you'd
like, then click Update.

41

User and Role Management

You may have noticed there is a Apply Changes button with a black disk icon without
title, next to Save. Unlike Save, when you use it, the form stays open, so you can see
how your record looks like after saving, also you can edit roles and permissions before
closing the form.
Now click Translator role to open its edit form and click Edit Roles. Grant him Translators
role and click OK.

42

User and Role Management

When you grant a role to a user, he gets all permissions granted to the role
automatically. By clicking Edit Permissions and you can also grant extra permissions
explicitly. You can also revoke a role permission from a user explicitly.
Now close all dialogs and logout by clicking admin on top right of site and clicking Logout.
Try logging in with translator and the password you set.
Translator user will only have access to Dashboard, Theme Samples, Languages and
Translations pages.

43

User and Role Management

44

Listing Pages

Listing Pages
Serene has listing pages and editing interface for Northwind database. Let's have a look at
the Products page under Northwind module.

Here we see list of products sorted by product name (initial sort order).
Grid component is SlickGrid with a customized theme.
https://github.com/mleibman/SlickGrid
You can change order by clicking column headers. To sort descending, click the same
column header again.
To sort by multiple columns, you can use Shift+Click.
Here is what it looks like after sorting by Category then Supplier columns:

45

Listing Pages

When you changed sort order, grid loaded data from a service with an AJAX request.
When you open the page first time, initial records were also loaded by an AJAX call.
By default grid loads records by 100 page size. Only records in current page are loaded from
server. In the sample image, i changed page size to 20 (bottom left of grid) to show paging in
effect.
On top left of the grid, you can type something to do a simple search.
Type coffee for example to see products containing it in their names.

It searched in product name field. It is also possible to use another, or multiple fields for
quick search. We'll see how in later chapters.

46

Listing Pages

On top right of the grid, there are quick filtering dropdowns for Supplier and Category fields.
Dropdown component used is Select2
https://github.com/select2/select2
Choose Seafood as Category and it will show only products in that category.

All sorting, paging and filtering is done on server side with dynamic SQL queries
generated by Serenity service handlers.
It is also possible to filter by any column by clicking edit filter link at bottom right of the grid.

Here you can add criteria by any column by clicking add criteria and choosing column name,
choosing comparison operator from next dropdown, and setting a value.

47

Listing Pages

Some value editors are simple textboxes while some others may have dropdowns and other
custom editors depending on column type.
It is also possible to change and to or by clicking on it.
You can also group criteria by clicking parenthesis. Groups will have a bit more space
between them than ordinary lines.

48

Edit Dialogs

Edit Dialogs
When you click a product name in Products page, an editing dialog for that row is displayed.

This dialog is shown on client side, there is no post-back happening. Data for the clicked
entity is loaded from server side using an AJAX request (only data, not markup). Dialog itself
is a customized version of jQuery UI dialog.
In this form we have three categories for fields: General, Pricing and Status. By clicking
category links on top blue bar you can navigate to start of that category.
Each form field occupies a row with label and editor. You may choose to show more than
one field in a row if required (with CSS).
Fields marked with "*" are required (cannot be empty).
Each field has a specific type of editor tailored to its data type like string, image upload,
checkbox, select etc.
We would see such an HTML code if we looked at the source (simplified for brevity):

49

Edit Dialogs

...
... Every field has a separate "div" of its own with class "field". Inside this div, there is a "label" element and another element (input, select, div) that changes with the editor type for that field. We can look at the class names of these elements to identify their editor types (e.g. sStringEditor, s-ImageUploadEditor) In the toolbar we have a button to save current entity and close dialog (Update), next to it a smaller one that keeps dialog open after save and another one to delete current entity (obviously). Most Serenity editing dialogs has this familiar interface, though you can always customize buttons, fields, add tabs, and other interface elements. 50 Tutorials Tutorials Movie Database (similar to IMDB) Multi Tenancy 51 Movie Database Tutorial: Movie Database Let's create editing interface for some site similar to IMDB with Serenity. You can find source code for this tutorial at: https://github.com/volkanceylan/MovieTutorial Create a new project named MovieTutorial In Visual Studio click File -> New Project. Make sure you choose Serene template. Type MovieTutorial as name and click OK. You might also choose Serene (AspNetCore) version. A few things will be different. We'll try to list those differences. In Solution explorer, you should see one project with name MovieTutorial.Web. MovieTutorial.Web project is an ASP.NET MVC (or Core) application that contains server side code plus static resources like css files, images etc. MovieTutorial.Web also has a tsconfig.json file at root, which specifies that it is also a TypeScript project. All .ts files under Modules/ and Scripts/ directories are compiled on save, and their outputs are combined into a script file under scripts/site/ folder with name MovieTutorial.Web.js. Please make sure that you have TypeScript 2.5.2+ installed. Check your version from Visual Studio Extensions dialog. To install TypeScript 2.5.2+ in Visual Studio, you'll need to install Visual Studio 2015 Update 3. Download its latest version from http://www.typescriptlang.org/#download-links for your Visual Studio. Also check prerequisites in Getting Started section. 52 Creating Movie Table Creating Movie Table To store list of movies we need a Movie table. We could create this table using old-school techniques like SQL Management Studio but we prefer creating it as a migration using Fluent Migrator: Fluent Migrator is a migration framework for .NET much like Ruby on Rails Migrations. Migrations are a structured way to alter your database schema and are an alternative to creating lots of sql scripts that have to be run manually by every developer involved. Migrations solve the problem of evolving a database schema for multiple databases (for example, the developer’s local database, the test database and the production database). Database schema changes are described in classes written in C# that can be checked into a version control system. See https://github.com/schambers/fluentmigrator for more information on FluentMigrator. Please Note As we are using FluentMigrator in our samples, some users assume Serenity doesn't work without it. That's not correct. You don't have to use migrations. Serenity has no direct dependency on migrations. If you like, instead of using these migrations you may manually create tables in SQL Management Studio. You could also work with an existing database. Locating Migration Folder Using Solution Explorer navigate to Migrations / DefaultDB. 53 Creating Movie Table Here we already have several migrations. A migration is like a DML script that manipulates database structure. DefaultDB_20141103_140000_Initial.cs for example, contains our initial migration that created Northwind tables and Users table. Create a new migration file in the same folder with name DefaultDB_20160519_115100_MovieTable.cs. You can copy and change one of the existing migration files, rename it and change contents. Migration file name / class name is actually not important but recommended for consistency and correct ordering. 20160519_115100 corresponds to the time we are writing this migration in yyyyMMdd_HHmmss format. It will also act as a unique key for this migration. Our migration file should look like below: 54 Creating Movie Table using FluentMigrator; using System; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160519115100)] public class DefaultDB_20160519_115100_MovieTable : Migration { public override void Up() { Create.Schema("mov"); Create.Table("Movie").InSchema("mov") .WithColumn("MovieId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Title").AsString(200).NotNullable() .WithColumn("Description").AsString(1000).Nullable() .WithColumn("Storyline").AsString(Int32.MaxValue).Nullable() .WithColumn("Year").AsInt32().Nullable() .WithColumn("ReleaseDate").AsDateTime().Nullable() .WithColumn("Runtime").AsInt32().Nullable(); } public override void Down() { } } } Make sure you use the namespace MovieTutorial.Migrations.DefaultDB as Serene template applies migrations only in this namespace to the default database. In Up() method we specify that this migration, when applied, will create a schema named mov. We will use a separate schema for movie tables to avoid clashes with existing tables. It will also create a table named Movie with "MovieId, Title, Description..." fields on it. We could implement Down() method to make it possible to undo this migration (drop movie table and mov schema etc), but for the scope of this sample, lets leave it empty. Inability to undo a migration might not hurt much, but deleting a table by mistake could do more damage. On top of our class we applied a Migration attribute. [Migration(20160519115100)] 55 Creating Movie Table This specifies a unique key for this migration. After a migration is applied to a database, its key is recorded in a special table specific to FluentMigrator ([dbo].[VersionInfo]), so same migration won't be applied again. Migration key should be in sync with class name (for consistency) but without underscore as migration keys are Int64 numbers. Migrations are executed in the key order, so using a sortable datetime pattern like yyyyMMdd for migration keys looks like a good idea. Please make sure you always use same number of characters for migration keys e.g. 14 (20160519115100). Otherwise your migration order will get messed up, and you will have migration errors, due to migrations running in unexpected order. Running Migrations By default, Serene template runs all migrations in MovieTutorial.Migrations.DefaultDB namespace. This happens on application start automatically. The code that runs migrations are in App_Start/SiteInitialization.cs and App_Start/SiteInitialization.Migrations.cs files: In Asp.Net Core version, they are under Initialization/Startup.cs and Initialization/DataMigrations.cs files as there is no App_Start folder in ASP.NET Core. SiteInitialization.Migrations.cs (or DataMigrations.cs): namespace MovieTutorial { //... public static partial class SiteInitialization { private static string[] databaseKeys = new[] { "Default", "Northwind" }; //... private static void EnsureDatabase(string databaseKey) { //... } public static bool SkippedMigrations { get; private set; } private static void RunMigrations(string databaseKey) { // ... // safety check to ensure that we are not modifying an 56 Creating Movie Table // arbitrary database. remove these two lines if you want // MovieTutorial migrations to run on your DB. if (cs.ConnectionString.IndexOf(typeof(SiteInitialization).Namespace + @"_" + databaseKey + "_v1", StringComparison.OrdinalIgnoreCase) < 0) { SkippedMigrations = true; return; } // ... using (var sw = new StringWriter()) { // ... var runner = new RunnerContext(announcer) { Database = databaseType, Connection = cs.ConnectionString, Targets = new string[] { typeof(SiteInitialization).Assembly.Location }, Task = "migrate:up", WorkingDirectory = Path.GetDirectoryName( typeof(SiteInitialization).Assembly.Location), Namespace = "MovieTutorial.Migrations." + databaseKey + "DB" }; new TaskExecutor(runner).Execute(); } } } } There is a safety check on database name to avoid running migrations on some arbitrary database other than the default Serene database (MovieTutorial_Default_v1). You can remove this check if you understand the risks. For example, if you change Northwind connection in web.config to your own production database, migrations will run on it and you will have Northwind etc tables even if you didn't mean to. Now press F5 to run your application and create Movie table in default database. Verifying That the Migration is Run Using Sql Server Management Studio or Visual Studio -> Connection To Database, open a connection to MovieTutorial_Default_v1 database in server (localdb)\MsSqlLocalDB or (localdb)\v11.0 depending on version you use. 57 Creating Movie Table (localdb)\v11.0 is a LocalDB instance created by SQL Server 2012 LocalDB. (localdb)\MsSqlLocalDB is an instance created by SQL 2014+ LocalDB. If you didn't install LocalDB yet, download it from https://www.microsoft.com/enus/download/details.aspx?id=29062. If you have SQL Server 2014 LocalDB, your server name would be (localdb)\MSSqlLocalDB or (localdb)\v12.0, so change connection string in web.config file. You could also use another SQL server instance, just change the connection string to target server and remove the migration safety check. You should see [mov].[Movies] table in SQL object explorer. Also when you view data in [dbo].[VersionInfo] table, Version column in the last row of the table should be 20160519115100. This specifies that the migration with that version number (migration key) is already executed on this database. So, even if you change migration source code, that migration won't ever run again in this database. Try to avoid modifying migrations after they run on your DB. Create a new migration if possible. Usually, you don't have to do these checks after every migration. Here we show these to explain where to look, in case you'll have any trouble in the future. 58 Generating Code For Movie Table Generating Code For Movie Table Serenity Code Generator (ASP.NET MVC) These steps applies only to ASP.NET MVC version, not ASP.NET Core version. Keep reading to see how to run Sergen in ASP.NET Core version. After making sure that our table exists in the database, we will use Serenity Code Generator (sergen.exe) to generate initial editing interface. In Visual Studio, open Package Manager Console by clicking View => Other Windows => Package Manager Console. Type sergen and press Enter. Resolving Sergen is not Recognized Issue Sometimes NuGet package manager can't set PATH correctly and you may get an error like below while trying to execute Sergen. Unfortunately, this is a bug of Visual Studio / NuGet and is not related to Serenity or Sergen itself. Most of the times, restarting Visual Studio might resolve the issue. If it doesn't, you may open Sergen.exe from Windows Explorer. Right click on MovieTutorial solution in Solution Explorer, click Open In File Explorer. Sergen.exe is under packages\Serenity.CodeGenerator.X.Y.Z\tools directory. 59 Generating Code For Movie Table Sergen UI Setting Project Location When you first run Sergen, Web Project field will be prefilled for you to: 60 Generating Code For Movie Table ..\..\..\MovieTutorial\MovieTutorial.Web\MovieTutorial.Web.csproj If you change this value and other options, and generate your first page, you won't have to set them again. All these options will be saved in Serenity.CodeGenerator.config in your solution directory. This value is required, as Sergen will automatically include generated files to your WEB project. Script project field should be empty for v2.1+. This is for users of older Serene, who might still have code that was written with Saltaralle compiler, instead of TypeScript. Root Namespace Option Your root namespace option is set to the Solution name you used, e.g. MovieTutorial. If your project name is MyProject.Web, your root namespace is MyProject by default. This is critical so make sure you don't set it to anything different, as by default, Serene template expects all generated code to be under this root namespace. It is also very important to understand that Root Namespace is case sensitive and must exactly match your project name, e.g. not movietutorial or movieTutorial but MovieTutorial. This option is also saved, so next time you won't have to fill it in. Choosing Connection String Once you set Web project name, Sergen populates connection dropdown with connection strings from your web.config file. We might have Default and Northwind in there. Choose Default one. Selecting Table To Generate Code For Sergen can generate code for multiple tables, but we'll generate for only one now. Once we choose connection string, table grid is populated with table names from that database. Mark checkbox next to Movie table. Identifier This usually corresponds to the table name but sometimes table names might have underscores or other invalid characters, so you decide what to name your entity in generated code (a valid identifier name). 61 Generating Code For Movie Table Our table name is Movie so it is also a valid and fine C# identifier, so let's leave Movie as the entity identifier. Our entity class will be named MovieRow. This name is also used in other class names. For example our page controller will be named MovieController. It also determines the page url, in this sample our editing page will be at URL /MovieDB/Movie. Please Note! Identifier must always be in Pascal case, e.g. something that starts with a CAPITAL letter. myTable , mycoolTable , aTable are invalid module names. MyCoolTable is OK. We'll add a validation to Sergen for this soon. Setting Module Name In Serenity terms, a module is a logical group of pages, sharing a common purpose. For example, in Serene template, all pages related to Northwind sample belongs to Northwind module. Pages that are related to general management of site, like users, roles etc. belongs to Administration module. A module usually corresponds to a database schema, or a single database but there is nothing that prevents you from using multiple modules in a single database / schema, or the opposite, multiple databases in one module. For this tutorial, we will use MovieDB (analogous to IMDB) for all pages. Module name is used in determining namespace and url of generated pages. For example, our new page will be under MovieTutorial.MovieDB namespace and will use /MovieDB relative url. Please Note! Module names must also be in Pascal case, e.g. something that starts with a CAPITAL letter. myModule , mycoolmodule , aModule are invalid module names. MyCoolModule is fine. Permission Key 62 Generating Code For Movie Table In Serenity, access control to resources (pages, services etc.) are controlled by permission keys which are simple strings. Users or roles are granted these permissions. Our Movie page will be only used by administrative users (or maybe later content moderators) so let's leave it as Administration:General for now. By default, in Serene template, only the admin user has this permission. ConnectionKey Parameter Connection key is set to the connection key of selected connection string in web.config file. You usually don't have to change it, just leave default. Generating Code for First Page After setting parameters as shown in the image above (you only have to set Module Name, others were prefilled), click Generate Code for Entity button. Sergen will generate several files and include them in MovieTutorial.Web and MovieTutorial.Script projects. Now you can close Sergen, and return to Visual Studio. Serenity Code Generator (ASP.NET Core) These steps applies only to ASP.NET Core version, not ASP.NET MVC version. As ASP.NET Core has cross-platform support, .NET Core version of Sergen also needs to run in OSX / Linux / Windows. Thus, its UI is currently console based. We first need to open a command prompt at project folder. Right click MovieTutorial.Web project and click Open Folder in File Explorer. Click File menu in file explorer, and click Open Windows Powershell or Open Command Prompt. You may also install this extension (https://marketplace.visualstudio.com/items? itemName=MadsKristensen.OpenCommandLine) to easily open a command line next time. I can't understand why there is still not such an option in Visual Studio itself. Make sure you are at MovieTutorial.Web Type to open Sergen code generation UI (console). dotnet sergen g directory. 63 Generating Code For Movie Table If you receive an error, type dotnet restore before running sergen. Sergen will list connections in appsettings.json file. You can use TAB completion, e.g. type D and press TAB to complete Default. After pressing Enter you'll get a list of tables in that database: Clear dbo. using backspace, and type select mov.Movie mov.Movie or type m and use TAB completion to , then press ENTER. Next, Sergen will ask for a module name, enter MovieDB. When prompted, enter Movie as identifier. Leave permission as Administration:General and press enter again. 64 Generating Code For Movie Table Sergen will ask you which files to generate, leave default RSU option (e.g. Row, Service and User Interface) and press ENTER last time. Now you can quit command prompt, and return back to Visual Studio (or Notepad :) After Generating Code As project is modified, Visual Studio may ask if you want to reload changes, click Reload All. REBUILD the Solution and then press F5 to launch application. Use admin as username, and serenity as password to login. When you are greeted with Dashboard page, you will notice that there is a new section, MovieDB on the bottom of left navigation. Click to expand it and click Movie to open our first generated page. Now try adding a new movie, than try updating and deleting it. Sergen generated code for our table, and it just works without writing a single line of code. 65 Generating Code For Movie Table This doesn't mean i don't like writing code. In contrast, i love it. Actually i'm not a fan of most designers and code generators. The code they produce is usually unmanagable mess. Sergen just helped us here for initial setup which is required for layered architecture and platform standards. We would have to create about 10 files for entity, repository, page, endpoint, grid, form etc. Also we needed to do some setup in a few other places. Even if we did copy paste and replace code from some other page, it would be error prone and take about 5-10 mins. The code files Sergen generates has minimum code with the absolute basics. This is thanks to the base classes in Serenity that handles the most logic. Once we generate code for some table, we'll probably never use Sergen again (for this table), and modify this generated code to our needs. We'll see how. 66 Customizing Movie Interface Customizing Movie Interface Customizing Field Captions In our movie grid and form, we have a field named Runtime. This field expects an integer number in minutes, but in its title there is no sign of this. Let's change its title to Runtime (mins). There are several ways to do this. Our options include server side form definition, server side columns definition, from script grid code etc. But let's make this change in the central location, the entity itself, so its title changes everywhere. When Sergen generated code for Movie table, it created a entity class named MovieRow. You can find it at Modules/MovieDB/Movie/MovieRow.cs. Here is an excerpt from its source with our Runtime property: namespace MovieTutorial.MovieDB.Entities { // ... [ConnectionKey("Default"), DisplayName("Movie"), InstanceName("Movie"), TwoLevelCached] public sealed class MovieRow : Row, IIdRow, INameRow { // ... [DisplayName("Runtime")] public Int32? Runtime { get { return Fields.Runtime[this]; } set { Fields.Runtime[this] = value; } } //... } } We'll talk about entities (or rows) later, let's now focus on our target and change its DisplayName attribute value to *Runtime (mins)": 67 Customizing Movie Interface namespace MovieTutorial.MovieDB.Entities { // ... [ConnectionKey("Default"), DisplayName("Movie"), InstanceName("Movie"), TwoLevelCached] public sealed class MovieRow : Row, IIdRow, INameRow { // ... [DisplayName("Runtime (mins)")] public Int32? Runtime { get { return Fields.Runtime[this]; } set { Fields.Runtime[this] = value; } } //... } } Now build solution and run application. You'll see that field title is changed in both grid and dialog. Column title has "..." in it as column is not wide enough, though its hint shows the full title. We'll see how to handle this soon. Overriding Column Title and Width So far so good, what if we wanted to show another title in grid (columns) or dialog (form). We can override it corresponding definition file. 68 Customizing Movie Interface Let's do it on columns first. Next to MovieRow.cs, you can find a source file named MovieColumns.cs: namespace MovieTutorial.MovieDB.Columns { // ... [ColumnsScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieColumns { [EditLink, DisplayName("Db.Shared.RecordId"), AlignRight] public Int32 MovieId { get; set; } //... public Int32 Runtime { get; set; } } } You may notice that this columns definition is based on the Movie entity (BasedOnRow attribute). Any attribute written here will override attributes defined in the entity class. Let's add a DisplayName attribute to the Runtime property: namespace MovieTutorial.MovieDB.Columns { // ... [ColumnsScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieColumns { [EditLink, DisplayName("Db.Shared.RecordId"), AlignRight] public Int32 MovieId { get; set; } //... [DisplayName("Runtime in Minutes"), Width(150), AlignRight] public Int32 Runtime { get; set; } } } Now we set column caption to "Runtime in Minutes". We actually added two more attributes. One to override column width to 150px. Serenity applies an automatic width to columns based on field type and character length, unless you set the width explicitly. 69 Customizing Movie Interface And another one to align column to right (AlignCenter, AlignLeft is also available). Let's build and run again, than we get: Form field title stayed same, while column title changed. If we wanted to override form field title instead, we would do similar steps in MovieForm.cs Changing Editor Type For Description and Storyline Description and Storyline fields can be a bit longer compared to Title field, so lets change their editor types to a textarea. Open MovieForm.cs in the same folder with MovieColumns.cs and MovieRow.cs. 70 Customizing Movie Interface namespace MovieTutorial.MovieDB.Forms { //... [FormScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieForm { public String Title { get; set; } public String Description { get; set; } public String Storyline { get; set; } public Int32 Year { get; set; } public DateTime ReleaseDate { get; set; } public Int32 Runtime { get; set; } } } and add TextAreaEditor attributes to both: namespace MovieTutorial.MovieDB.Forms { //... [FormScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieForm { public String Title { get; set; } [TextAreaEditor(Rows = 3)] public String Description { get; set; } [TextAreaEditor(Rows = 8)] public String Storyline { get; set; } public Int32 Year { get; set; } public DateTime ReleaseDate { get; set; } public Int32 Runtime { get; set; } } } I left more editing rows for Storyline (8) compared to Description (3) as Storyline should be much longer. After rebuild and run, we have this: 71 Customizing Movie Interface Serene has several editor types to choose from. Some are automatically picked based on field data type, while you need to explicitly set others. You can also develop your own editor types. You can take existing editor types as base classes, or develop your own from stratch. We'll see how in following chapters. As editors became a bit higher, form height exceeded the default Serenity form height (which is about 260px) and now we have a vertical scroll bar. Let's remove it. Setting Initial Dialog Size With CSS (Less) Sergen generated some CSS for our movie dialog in MovieTutorial.Web/Content/site/site.less file. If you open it, and scroll to bottom, you will see this: /* ------------------------------------------------------------------------- */ /* APPENDED BY CODE GENERATOR, MOVE TO CORRECT PLACE AND REMOVE THIS COMMENT */ /* ------------------------------------------------------------------------- */ .s-MovieDB-MovieDialog { > .size { width: 650px; } .caption { width: 150px; } } 72 Customizing Movie Interface You can safely remove the 3 comment lines (appended by code generator...). This is just a reminder for you to move them to a better place like a site.movies.less file specific to this module (recommended). These rules are applied to elements with .s-MovieDB-MovieDialog class. Our Movie dialog has this class by default, which is generated by "s-" + ModuleName + "-" + ClassName. In the second line it is specified that this dialog is 650px wide by default. In third line, we specify that field labels should be 150px (@l: 150px). Let's change our initial dialog height to 500px (in desktop mode), so it won't require a vertical scroll bar: .s-MovieDialog { > .size { width: 650px); height: 500px; } .caption { width: 150px; } } For this change to be applied to your dialog, you need to build solution. As this "site.less" file is compiled to a "site.css" file on build. Now build and refresh the page. What i mean by desktop mode above will become clearer soon. Serenity dialogs are responsive by default. Let's resize our browser window to a width about 350px. I'll use mobile mode of my Chrome browser to switch to iPhone 6: 73 Customizing Movie Interface And now an iPad in landscape mode: 74 Customizing Movie Interface So, the height we set here is only meaningfull for desktop mode. Dialog will turn into a responsive, device size specific mode in mobile, without having to mess with CSS @media queries. Changing Page Title Our page has title of Movie. Let's change it to Movies. Open MovieRow.cs again. namespace MovieTutorial.MovieDB.Entities { // ... [ConnectionKey("Default"), DisplayName("Movie"), InstanceName("Movie"), TwoLevelCached] public sealed class MovieRow : Row, IIdRow, INameRow { [DisplayName("Movie Id"), Identity] public Int32? MovieId Change DisplayName attribute value to Movies. This is the name that is used when this table is referenced, and it is usually a plural name. This attribute is used for determining default page title. It is also possible to override the page title in MoviePage.Index.cshtml file but as before, we prefer to do it from a central location so that this information can be reused in other places. InstanceName corresponds to singular name and is used in New Record (New Movie) button of the grid and also determines the dialog title (e.g. Edit Movie). namespace MovieTutorial.MovieDB.Entities { // ... [ConnectionKey("Default"), DisplayName("Movies"), InstanceName("Movie"), TwoLevelCached] public sealed class MovieRow : Row, IIdRow, INameRow { [DisplayName("Movie Id"), Identity] public Int32? MovieId 75 Handling Movie Navigation Handling Movie Navigation Setting Navigation Item Title and Icon When Sergen generated code for Movie table, it also created a navigation item entry. In Serene, navigation items are created with special assembly attributes. Open MoviePage.cs in the same folder, on top of it you'll find this line: [assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "MovieDB/Movie", typeof(MovieTutorial.MovieDB.Pages.MovieController))] namespace MovieTutorial.MovieDB.Pages { //... First argument to this attribute is display order for this navigation item. As we only have one navigation item in Movie category yet, we don't have to mess with ordering yet. Second parameter is navigation title in "Section Title/Link Title" format. Section and navigation items are seperated with a slash (/). Lets change it to Movie Database/Movies. [assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "Movie Database/Movies", typeof(MovieTutorial.MovieDB.Pages.MovieController), icon: "icon-camrecorder")] namespace MovieTutorial.MovieDB.Pages { //.. 76 Handling Movie Navigation We also changed navigation item icon to icon-camcorder. Serene template has two sets of font icons, Simple Line Icons and Font Awesome. Here we used a glyph from simple line icons set. To see list of simple line icons and their css classes, visit link below: http://thesabbir.github.io/simple-line-icons/ FontAwesome is available here: https://fortawesome.github.io/Font-Awesome/icons/ There is also a page in Serene under Theme Samples / UI Elements / Icons containing a list of these icon sets. Ordering Navigation Sections As our Movie Database section is auto generated last, it is displayed at the bottom of navigation menu. We'll move it before Northwind menu. As we saw recently, Sergen created a navigation item in MoviePage.cs. If navigation items are scattered through pages like this, it would be hard to see the big picture (list of all navigation items) and order them easily. So we move it to our central location which is at MovieTutorial.Web/Modules/Common/Navigation/NavigationItems.cs. 77 Handling Movie Navigation Just cut the below lines from MoviePage.cs: [assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "Movie Database/Movies", typeof(MovieTutorial.MovieDB.Pages.MovieController), icon: "icon-camrecorder")] Move it into NavigationItems.cs and modify it like this: using Serenity.Navigation; using Northwind = MovieTutorial.Northwind.Pages; using Administration = MovieTutorial.Administration.Pages; using MovieDB = MovieTutorial.MovieDB.Pages; [assembly: NavigationLink(1000, "Dashboard", url: "~/", permission: "", icon: "icon-speedometer")] [assembly: NavigationMenu(2000, "Movie Database", icon: "icon-film")] [assembly: NavigationLink(2100, "Movie Database/Movies", typeof(MovieDB.MovieController), icon: "icon-camrecorder")] [assembly: NavigationMenu(8000, "Northwind", icon: "icon-anchor")] [assembly: NavigationLink(8200, "Northwind/Customers", typeof(Northwind.CustomerController), icon: "icon-wallet")] [assembly: NavigationLink(8300, "Northwind/Products", typeof(Northwind.ProductController), icon: "icon-present")] // ... Here we also declared a navigation menu (Movie Database) with film icon. When you don't have an explicitly defined navigation menu, Serenity implicitly creates one, but in this case you can't order menu yourself, or set menu icon. We assigned it a display order of 2000 so this menu will display just after Dashboard link (1000) but before Northwind menu (8000). We assigned our Movies link a display order value of 2100 but it doesn't matter right now, as we have only one navigation item under Movie Database menu yet. First level links and navigation menus are sorted according to their display order first, then second level links among their siblings. Here is how it looks like after these changes: 78 Handling Movie Navigation Troubleshooting Some Issues with Visual Studio In case you didn't notice already, Visual Studio doesn't let you modify code while your site is running. Also your site stops when you stop debugging, so you can't keep browser window open and refresh after rebuilding. To solve this issue, we need to disable Edit And Continue (have no idea why). Right Click MovieTutorial.Web project, click Properties, in the Web tab, uncheck Enable Edit And Continue under Debuggers. Unfortunately, the solution above stops works in Visual Studio 2015 Update 2. Only workaround so far seems like starting without debugging, e.g. Ctrl+F5 instead of F5. Solution above only applies to ASP.NET MVC version, not ASP.NET CORE version. Also, on your site, top blue progress bar (which is a Pace.js animation), keeps running all the time like it is still loading something. It is thanks to the Browser Link feature of Visual Studio. To disable it, locate its button in Visual Studio toolbar that looks like a refresh button (next to play icon with browser name like Chrome), click dropdown and uncheck Enable Browser Link. It's also possible to disable it with a web.config setting 79 Handling Movie Navigation Serene 1.5.4 and later has this in web.config by default, so you might not experience this issue I'm not sure if there is a corresponding setting in appsettings.json file of ASP.NET Core version 80 Customizing Quick Search Customizing Quick Search Adding Several Movie Entries For the following sections, we need some sample data. We can copy and paste some from IMDB. If you don't want to waste your time entering this sample data, it is available as a migration at the link below: https://github.com/volkanceylan/MovieTutorial/blob/master/MovieTutorial/MovieTutorial. Web/Modules/Common/Migrations/DefaultDB/DefaultDB_20160519_135200_SampleM ovies.cs If we typed go into search box, we would see two movies are filtered: The Good, the Bad and the Ugly and The Godfather. If we typed Gandalf we wouldn't be able to find anything. By default, Sergen determines first textual field of a table as the name field. In movies table it is Title. This field has a QuickSearch attribute on it that specifies that text searches should be performed on it. The name field also determines initial sorting order and shown in edit dialog titles. Sometimes, first textual column might not be the name field. If you wanted to change it to another field, you would do it in MovieRow.cs: 81 Customizing Quick Search namespace MovieTutorial.MovieDB.Entities { //... public sealed class MovieRow : Row, IIdRow, INameRow { //... StringField INameRow.NameField { get { return Fields.Title; } } } Code generator determined that first textual (string) field in our table is Title. So it added a INameRow interface to our Movies row and implemented it by returning Title field. If wanted to use Description as name field, we would just replace it. Here, Title is actually the name field, so we leave it as is. But we want Serenity to search also in Description and Storyline fields. To do this, you need to add QuickSearch attribute to these fields too, as shown below: 82 Customizing Quick Search namespace MovieTutorial.MovieDB.Entities { //... public sealed class MovieRow : Row, IIdRow, INameRow { //... [DisplayName("Title"), Size(200), NotNull, QuickSearch] public String Title { get { return Fields.Title[this]; } set { Fields.Title[this] = value; } } [DisplayName("Description"), Size(1000), QuickSearch] public String Description { get { return Fields.Description[this]; } set { Fields.Description[this] = value; } } [DisplayName("Storyline"), QuickSearch] public String Storyline { get { return Fields.Storyline[this]; } set { Fields.Storyline[this] = value; } } //... } } Now, if we search for Gandalf, we'll get a The Lord of the Rings entry: 83 Customizing Quick Search QuickSearch attribute, by default, searches with contains filter. It has some options to make it match by starts with filter or match only exact values. If we wanted it to show only rows that starts with typed text, we would change attribute to: [DisplayName("Title"), Size(200), NotNull, QuickSearch(SearchType.StartsWith)] public String Title { get { return Fields.Title[this]; } set { Fields.Title[this] = value; } } Here this quick search feature is not very useful, but for values like SSN, serial number, identification number, phone number etc, it might be. If we wanted to search also in year column, but only exact integer values (1999 matches but not 19): [DisplayName("Year"), QuickSearch(SearchType.Equals, numericOnly: 1)] public Int32? Year { get { return Fields.Year[this]; } set { Fields.Year[this] = value; } } You might have noticed that we are not writing any C# or SQL code for these basic features to work. We just specify what we want, rather than how to do it. This is what declarative programming is. It is also possible to provide user with ability to determine which field she wants to search on. Open MovieTutorial.Web/Modules/MovieDB/Movie/MovieGrid.ts and modify it like: 84 Customizing Quick Search namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() export class MovieGrid extends Serenity.EntityGrid { //... constructor(container: JQuery) { super(container); } protected getQuickSearchFields(): Serenity.QuickSearchField[] { return [ { name: "", title: "all" }, { name: "Description", title: "description" }, { name: "Storyline", title: "storyline" }, { name: "Year", title: "year" } ]; } } } Once you save that file, we'll have a dropdown in quick search input: Unlike prior samples where we modified Server side code, this time we did changes in client side, and modified Javascript (TypeScript) code. Running T4 Templates (.tt files, ASP.NET MVC Version) 85 Customizing Quick Search In prior sample we harcoded field names like Description, Storyline etc. This may lead to typing errors if we forgot actual property names or their casing at server side (javascript is case sensitive). Serene contains some T4 (.tt) files to transfer such information from server side (rows etc in C#) to client side (TypeScript) for intellisense purposes. Before running these templates, please make sure that your solution builds successfully as templates uses your output DLL file (MovieTutorial.Web.dll) to generate code. After building your solution, click on Build menu, than Transform All Templates. Running T4 Templates (ASP.NET Core Version) You don't have to transform templates in ASP.NET Core version. Serene does it automatically on build. Actually, there isn't any T4 file in ASP.NET Core version. So from now on, when we say transform templates, just build your project (if you use ASP.NET Core version). It is also possible to open a command prompt in project directory and type dotnet sergen t to transform templates manually. Intellisense on TypeScript Code (After transforming templates) We can now use intellisense to replace hardcoded field names with compile time checked versions: 86 Customizing Quick Search namespace MovieTutorial.MovieDB { //... public class MovieGrid extends EntityGrid { constructor(container: JQuery) { super(container); } protected getQuickSearchFields(): Serenity.QuickSearchField[] { let fld = MovieRow.Fields; return [ { name: "", title: "all" }, { name: fld.Description, title: "description" }, { name: fld.Storyline, title: "storyline" }, { name: fld.Year, title: "year" } ]; } } ///... } What about field titles? It is not so critical as field names, but can be useful for localization purposes (if we later decide to translate it): namespace MovieTutorial.MovieDB { //... public class MovieGrid extends EntityGrid { constructor(container: JQuery) { super(container); } protected getQuickSearchFields(): Serenity.QuickSearchField[] { let fld = MovieRow.Fields; let txt = (s) => Q.text("Db." + MovieRow.localTextPrefix + "." + s).toLowerCase(); return [ { name: "", title: "all" }, { name: fld.Description, title: txt(fld.Description) }, { name: fld.Storyline, title: txt(fld.Storyline) }, { name: fld.Year, title: txt(fld.Year) } ]; } } ///... } 87 Customizing Quick Search We made use of the local text dictionary (translations) available at client side. It's something like this: { // ... "Db.MovieDB.Movie.Description": "Description", "Db.MovieDB.Movie.Storyline": "Storyline", "Db.MovieDB.Movie.Year": "Year" // ... } Local text keys for row fields are generated from "Db." + (LocalTextPrefix for Row) + "." + FieldName. Their values are generated from [DisplayName] attributes on your fields by but might be something else in another culture if they are translated. LocalTextPrefix corresponds to ModuleName + "." + RowClassName by default, but can be changed in Row fields constructor. 88 Adding a Movie Kind Field Adding a Movie Kind Field If we wanted to also keep TV series and mini series in our movie table, we would need another field to store it: MovieKind. As we didn't add it while creating the Movie table, now we'll write another migration to add it to our database. Don't modify existing migrations, they won't run again. Create another migration file under Modules/Common/Migrations/DefaultDB/ DefaultDB_20160519_145500_MovieKind.cs: using FluentMigrator; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160519145500)] public class DefaultDB_20160519_145500_MovieKind : Migration { public override void Up() { Alter.Table("Movie").InSchema("mov") .AddColumn("Kind").AsInt32().NotNullable() .WithDefaultValue(1); } public override void Down() { } } } Declaring a MovieKind Enumeration Now as we added Kind column to Movie table, we need a set of movie kind values. Let's define it as an enumeration at MovieTutorial.Web/Modules/MovieDB/Movie/MovieKind.cs: 89 Adding a Movie Kind Field using Serenity.ComponentModel; using System.ComponentModel; namespace MovieTutorial.MovieDB { [EnumKey("MovieDB.MovieKind")] public enum MovieKind { [Description("Film")] Film = 1, [Description("TV Series")] TvSeries = 2, [Description("Mini Series")] MiniSeries = 3 } } Adding Kind Field to MovieRow Entity As we are not using Sergen anymore, we need to add a mapping in our MovieRow.cs for Kind column manually. Add following property declaration in MovieRow.cs after Runtime property: [DisplayName("Runtime (mins)")] public Int32? Runtime { get { return Fields.Runtime[this]; } set { Fields.Runtime[this] = value; } } [DisplayName("Kind"), NotNull] public MovieKind? Kind { get { return (MovieKind?)Fields.Kind[this]; } set { Fields.Kind[this] = (Int32?)value; } } We also need to declare a Int32Field object which is required for Serenity entity system. On the bottom of MovieRow.cs locate RowFields class and modify it to add Kind field after the Runtime field: 90 Adding a Movie Kind Field public class RowFields : RowFieldsBase { // ... public readonly Int32Field Runtime; public readonly Int32Field Kind; public RowFields() : base("[mov].Movie") { LocalTextPrefix = "MovieDB.Movie"; } } Adding Kind Selection To Our Movie Form If we build and run our project now, we'll see that there is no change in the Movie form, even if we added Kind field mapping to the MovieRow. This is because, fields shown/edited in the form are controlled by declerations in MovieForm.cs. Modify MovieForm.cs as below: namespace MovieTutorial.MovieDB.Forms { // ... [FormScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieForm { // ... public MovieKind Kind { get; set; } public Int32 Runtime { get; set; } } } Now, build your solution and run it. When you try to edit a movie or add a new one, nothing will happen. This is an expected situation. If you check developer tools console of your browser (F12, inspect element etc.) you'll see such an error: You might not have this error with ASP.NET Core version as it auto transforms T4 Uncaught Can't find MovieTutorial.MovieDB.MovieKind enum type! Please Note! 91 Adding a Movie Kind Field Whenever such a thing happens, e.g. some button not working, you got an empty page, grid etc, please first check browser console for errors, before reporting it. Why We Had This Error? This error is caused by MoveKind enumeration not available client side. We should run our T4 templates before executing our program. Now in Visual Studio, click Build -> Transform All Templates again. Rebuild your solution and execute it. Now we have a nice dropdown in our form to select movie kind. Just build project for ASP.NET Core version, as there is no T4 template Declaring a Default Value for Movie Kind As Kind is a required field, we need to fill it in Add Movie dialog, otherwise we'll get a validation error. But most movies we'll store are feature films, so its default should be this value. To add a default value for Kind property, add a DefaultValue attribute like this: 92 Adding a Movie Kind Field [DisplayName("Kind"), NotNull, DefaultValue(MovieKind.Film)] public MovieKind? Kind { get { return (MovieKind?)Fields.Kind[this]; } set { Fields.Kind[this] = (Int32?)value; } } Now, in Add Movie dialog, Kind field will come prefilled as Film. 93 Adding Movie Genres Adding Movie Genres Adding Genre Field To hold Movie genres we need a lookup table. For Kind field we used an enumeration but this time genres might not be that static to declare them as an enumeration. As usual, we start with a migration. Modules/Common/Migrations/DefaultDB/ DefaultDB_20160519_154700_GenreTable.cs: using FluentMigrator; using System; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160519154700)] public class DefaultDB_20160519_154700_GenreTable : Migration { public override void Up() { Create.Table("Genre").InSchema("mov") .WithColumn("GenreId").AsInt32().NotNullable() .PrimaryKey().Identity() .WithColumn("Name").AsString(100).NotNullable(); Alter.Table("Movie").InSchema("mov") .AddColumn("GenreId").AsInt32().Nullable() .ForeignKey("FK_Movie_GenreId", "mov", "Genre", "GenreId"); } public override void Down() { } } } We also added a GenreId field to Movie table. Actually a movie can have multiple genres so we should keep it in a separate MovieGenres table. But for now, we think it as single. We'll see how to change it to multiple later. Generating Code For Genre Table 94 Adding Movie Genres Fire sergen.exe using Package Manager Console again and generate code for Genre table with the parameters shown below: Use parameters shown with dotnet sergen g if you are using ASP.NET Core version. This screenshot belongs to an older version of Sergen, just use parameters shown in new version Rebuild solution and run it. We'll get a new page like this: 95 Adding Movie Genres As you see in screenshot, it is generated under a new section MovieDB instead of the one we renamed recently: Movie Database. This is because Sergen has no idea of what customizations we performed on our Movie page. So we need to movie it under Movie Database manually. Open Modules/Movie/GenrePage.cs, cut the navigation link shown below: [assembly:Serenity.Navigation.NavigationLink(int.MaxValue, "MovieDB/Genre", typeof(MovieTutorial.MovieDB.Pages.GenreController))] ` And move it to Modules/Common/Navigation/NavigationItems.cs: //... [assembly: NavigationMenu(2000, "Movie Database", icon: "icon-film")] [assembly: NavigationLink(2100, "Movie Database/Movies", typeof(MovieDB.MovieController), icon: "icon-camcorder")] [assembly: NavigationLink(2200, "Movie Database/Genres", typeof(MovieDB.GenreController), icon: "icon-pin")] //... Adding Several Genre Definitions Now let's add some sample genres. I'll do it through migration, to not to repeat it in another PC, but you might want to add them manually through Genre page. 96 Adding Movie Genres using FluentMigrator; using System; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160519181800)] public class DefaultDB_20160519_181800_SampleGenres : Migration { public override void Up() { Insert.IntoTable("Genre").InSchema("mov") .Row(new { Name = "Action" }) .Row(new { Name = "Drama" }) .Row(new { Name = "Comedy" }) .Row(new { Name = "Sci-fi" }) .Row(new { Name = "Fantasy" }) .Row(new { Name = "Documentary" }); } public override void Down() { } } } Mapping GenreId Field in MovieRow As we did with Kind field before, GenreId field needs to be mapped in MovieRow.cs. 97 Adding Movie Genres namespace MovieTutorial.MovieDB.Entities { // ... public sealed class MovieRow : Row, IIdRow, INameRow { [DisplayName("Kind"), NotNull, DefaultValue(1)] public MovieKind? Kind { get { return (MovieKind?)Fields.Kind[this]; } set { Fields.Kind[this] = (Int32?)value; } } [DisplayName("Genre"), ForeignKey("[mov].Genre", "GenreId"), LeftJoin("g")] public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } [DisplayName("Genre"), Expression("g.Name")] public String GenreName { get { return Fields.GenreName[this]; } set { Fields.GenreName[this] = value; } } // ... public class RowFields : RowFieldsBase { // ... public readonly Int32Field Kind; public readonly Int32Field GenreId; public readonly StringField GenreName; public RowFields() : base("[mov].Movie") { LocalTextPrefix = "MovieDB.Movie"; } } } } Here we mapped GenreId field and also declared that it has a foreign key relation to GenreId field in [mov].Genre table using ForeignKey attribute. If we did generate code for Movie table after we added this Genre table, Sergen would understand this relation by checking foreign key definition at database level, and generate similar code for us. 98 Adding Movie Genres We also added another field, GenreName that is not actually a field in Movie table, but in Genre table. Serenity entities are more like SQL views. You can bring in fields from other tables with joins. By adding LeftJoin("g") attribute to MovieId property, we declared that whenever Genre table needs to be joined to, its alias will be g. So when Serenity needs to select from Movies table, it will produce an SQL query like this: SELECT t0.MovieId, t0.Kind, t0.GenreId, g.Name as GenreName FROM Movies t0 LEFT JOIN Genre g on t0.GenreId = g.GenreId This join will only be performed if a field from Genre table requested to be selected, e.g. its column is visible in a data grid. By adding Expression("g.Name") on top of GenreName property, we specified that this field has an SQL expression of g.Name, thus it is a view field originating from our g join. Adding Genre Selection To Movie Form Let's add GenreId field to our form in MovieForm.cs: namespace MovieTutorial.MovieDB.Forms { //... [FormScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieForm { //... public Int32 GenreId { get; set; } public MovieKind Kind { get; set; } } } Now if we build and run application, we'll see that a Genre field is added to our form. The problem is, it accepts data entry as an integer. We want it to use a dropdown. It's clear that we need to change editor type for GenreId field. Declaring a Lookup Script for Genres To show an editor for Genre field, list of genres in our database should be available at client side. 99 Adding Movie Genres For enumeration values, it was simple, we just run T4 templates, and they copied enum declaration to script side. Here we can't do the same. Genre list is a database based dynamic list. Serenity has notion of dynamic scripts to make dynamic data available to script side in the form of runtime generated scripts. Dynamic scripts are similar to web services, but their outputs are dynamic javascript files that can be cached on client side. The dynamic here corresponds to the data they contain, not their behavior. Unlike web services, dynamic scripts can't accept any parameters. And their data is shared among all users of your site. They are like singletons or static variables. You shouldn't try to write a dynamic script (e.g. lookup) that acts like a web service. To declare a dynamic lookup script for Genre table, open GenreRow.cs and modify it like below: namespace MovieTutorial.MovieDB.Entities { // ... [ConnectionKey("Default"), DisplayName("Genre"), InstanceName("Genre"), TwoLevelCached] [ReadPermission("Administration")] [ModifyPermission("Administration")] [JsonConverter(typeof(JsonRowConverter))] [LookupScript("MovieDB.Genre")] public sealed class GenreRow : Row, IIdRow, INameRow { // ... } We just added line with [LookupScript("MovieDB.Genre")]. Rebuild your project, launch it, after logging in, open developer console by F12. Type Q.getLookup('MovieDB.Genre') and you will get something like this: 100 Adding Movie Genres Here MovieDB.Genre is the lookup key we assigned to this lookup script when declaring it with: [LookupScript("MovieDB.Genre")] This step was just to show how to check if a lookup script is available client side. Lookup key, "MovieDB.Genre" is case sensitive. Make sure you type exact same case everywhere. Using LookupEditor for Genre Field There are two places to set editor type for GenreId field. One is MovieForm.cs, other is MovieRow.cs. I usually prefer the latter, as it is the central place, but you may choose to set it on a form, if that editor type is specific to that form only. Information defined on a form can't be reused. For example, grids use information in XYZColumn.cs / XYZRow.cs while dialogs use information in XYZForm.cs / XYZRow.cs. So it's usually better to define things in XYZRow.cs. Open MovieRow.cs and add LookupEditor attribute to GenreId property as shown below: [DisplayName("Genre"), ForeignKey("[mov].Genre", "GenreId"), LeftJoin("g")] [LookupEditor("MovieDB.Genre")] public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } 101 Adding Movie Genres After we build and launch our project, we'll now have a searchable dropdown (Select2.js) on our Genre field. While defining [LookupEditor] we hardcoded the lookup key. It's also possible to reuse information on GenreRow: [DisplayName("Genre"), ForeignKey("[mov].Genre", "GenreId"), LeftJoin("g")] [LookupEditor(typeof(GenreRow))] public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } This is functionally equivalent. I'd prefer latter. Here, Serenity will locate the [LookupScript] attribute on GenreRow, and get lookup key information from there. If we had no [LookupScript] attribute on GenreRow, you'd get an error on application startup: Server Error in '/' Application. 'MovieTutorial.MovieDB.Entities.GenreRow' type doesn't have a [LookupScript] attribute, so it can't be used with a LookupEditor! Parameter name: lookupType 102 Adding Movie Genres Forms are scanned at application startup, so there is no way to handle this error without fixing the issue. Display Genre in Movie Grid Currently, movie genre can be edited in the form but is not displayed in Movie grid. Edit MovieColumns.cs to show GenreName (not GenreId). namespace MovieTutorial.MovieDB.Columns { // ... public class MovieColumns { //... [Width(100)] public String GenreName { get; set; } [DisplayName("Runtime in Minutes"), Width(150), AlignRight] public Int32 Runtime { get; set; } } } Now GenreName is shown in the grid. Making It Possible To Define A New Genre Inplace While setting genre for our sample movies, we notice that The Good, the Bad and the Ugly is Western but there is no such genre in Genre dropdown yet (so I had to choose Drama). One option is to open Genres page, add it, and come back to movie form again. Not so pretty... 103 Adding Movie Genres Fortunately, Serenity has integrated inplace item definition ability for lookup editors. Open MovieRow.cs and modify LookupEditor attribute like this: [DisplayName("Genre"), ForeignKey("[mov].Genre", "GenreId"), LeftJoin("g")] [LookupEditor(typeof(GenreRow), InplaceAdd = true)] public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } Now we can define a new Genre by clicking star/pen icon next to genre field. Here we also see that we can use a dialog from another page (GenreDialog) in the movies page. In Serenity applications, all client side objects (dialogs, grids, editors, formatters etc.) are self-contained reusable components (widgets) that are not bound to any page. It is also possible to start typing in genre editor, and it will provide you with an option to add a new genre. 104 Adding Movie Genres How Did It Determine Which Dialog Type To Use You probably didn't notice this detail. Our lookup editor for genre selection, automatically opened a new GenreDialog when you wanted to add a new genre inplace. Here, our lookup editor made use of a convention. Because its lookup key is MovieDB.Genre, it searched for a dialog class with full names below: MovieDB.GenreDialog MovieTutorial.MovieDB.GenreDialog ... ... Luckily, we have a GenreDialog, which is defined in Modules/Genre/GenreDialog.ts and its full name is MovieTutorial.MovieDB.GenreDialog. namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() @Serenity.Decorators.responsive() export class GenreDialog extends Serenity.EntityDialog { protected getFormKey() { return GenreForm.formKey; } protected getIdProperty() { return GenreRow.idProperty; } protected getLocalTextPrefix() { return GenreRow.localTextPrefix; } protected getNameProperty() { return GenreRow.nameProperty; } protected getService() { return GenreService.baseUrl; } protected form = new GenreForm(this.idPrefix); } } If, lookup key for GenreRow and its dialog class didn't match, we would get an error in browser console, as soon as we click the inplace add button: Uncaught MovieDB.GenreDialog dialog class is not found! But this is not the case as they match. In such a case, either you'd have to use a compatible lookup key like "ModuleName.RowType", or you'd need to specify dialog type explicitly: 105 Adding Movie Genres [DisplayName("Genre"), ForeignKey("[mov].Genre", "GenreId"), LeftJoin("g")] [LookupEditor(typeof(GenreRow), InplaceAdd = true, DialogType = "MovieDB.Genre")] public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } You shouldn't specify Dialog suffix, nor the full namespace, e.g. MovieTutorial.MovieDB.Genre, as Serenity automatically searches for them. Adding Quick Filter for Genre To Grid As our list of movies becomes larger, we might need to filter movies based on values of some fields, besides the quick search functionality. Serenity has several filtering methods. One of them is Quick Filter, which we'll use on Genre field. Edit Modules/MovieDB/Movie/MovieColumns.cs to add a [QuickFilter] attribute on top of GenreName field: public class MovieColumns { //... public DateTime ReleaseDate { get; set; } [Width(100), QuickFilter] public String GenreName { get; set; } [DisplayName("Runtime in Minutes"), Width(150), AlignRight] public Int32 Runtime { get; set; } } Build and navigate to Movies page. You'll a quick filtering dropdown for genre field is available: 106 Adding Movie Genres The field that is filtered is actually GenreId not GenreName that we attached this attribute to. Serenity is clever enough to understand this relation, and determined editor type to use by looking at attributes of GenreId property in GenreRow.cs. Re-runing T4 Templates As we added a new entity to our application, we should run T4 templates after building solution. 107 Updating Serenity Packages Updating Serenity Packages (ASP.NET MVC Version) When i started writing this tutorial, Serenity (NuGet packages containing Serenity assemblies and standard scripts libraries) and Serene (the application template) was at version 2.1.8. When you read this you are probably using a later version, so you might not have to update serenity yet. But, i want to show how you can update Serenity NuGet packages, in case another version comes out in the future. I prefer to work with NuGet from Package Manager Console instead of using NuGet GUI interface as it performs much faster. So, click View -> Other Windows -> Package Manager Console. Type: Update-Package Serenity.Web This will also update following NuGet packages in MovieTutorial.Web because of dependencies: Serenity.Core Serenity.Data Serenity.Data.Entity Serenity.Services To update Serenity.CodeGenerator (containg sergen.exe), type: Update-Package Serenity.CodeGenerator Serenity.CodeGenerator is also installed in MovieTutorial.Web project. During updates, if NuGet asks to override changes in some script files, you can safely say yes unless you did manual modifications to Serenity script files (which i suggest you avoid). Updating Serenity Packages (ASP.NET 108 Updating Serenity Packages Core Version) Theorically, you should be able to update Serenity just like ASP.NET MVC version using NuGet package manager console, but it might not work, probably due to some conditionals in CSPROJ file confusing NuGet. These conditionals are there to support switching easily to full .NET Framework if you have to. Right click your project file, click Edit MySerene.csproj: Find three lines that include Serenity.Web, Serenity.Web.AspNetCore and Serenity.CodeGenerator like shown above and change their versions to latest Serenity version. Open a command prompt in your project directory and type these two lines: dotnet restore dotnet sergen restore Building Project Now rebuild your solution and it should build successfully. From time to time, breaking changes might happen in Serenity, but they are kept to minimum, and you might have to do a few manual changes in your application code. Such changes are documented with a [BREAKING CHANGE] tag in change log at: https://github.com/volkanceylan/Serenity/blob/master/CHANGELOG.md If you still have a problem after upgrade, feel free to open an issue at: https://github.com/volkanceylan/Serenity/issues What Is Updated Updating Serenity NuGet packages, takes Serenity assemblies up to the latest version. It might also update some other third-party packages like ASP.NET MVC, FluentMigrator, Select2.js, SlickGrid etc. 109 Updating Serenity Packages Please don't update Select2.js to a version after 3.5.1 yet as it has some compability problems with jQuery validation. Serenity.Web package also comes with some static script and css resources like the following: Content/serenity/serenity.css Scripts/saltarelle/mscorlib.js Scripts/saltarelle/linq.js Scripts/serenity/Serenity.CoreLib.js Scripts/serenity/Serenity.Script.UI.js So, these and a few more are also updated in MovieApplication.Web. What Is Not Updated (OR Can't Be Updated Automatically) Updating Serenity packages, updates Serenity assemblies and most static scripts, but not all Serene template content is updated. We are trying to keep updating your application as simple as possible, but Serene is just a project template, not a static package. Your application is a customizable copy of Serene. You might have done modifications to application source, so updating a Serene application created with an older version of Serene template, might not be as easy as it sounds. So sometimes you might have to create a new Serene application with up-to-date Serene template version, and compare it to your application, and merge features you need. This is a manual process. Usually, updating Serenity packages is enough. Updating Serene itself is not required unless you need some recent features from latest Serene version. We have some plans to make parts of Serene template also a NuGet package, but it is still not trivial how to update your application without overriding your changes, e.g. to shared code like Navigation items. And what if you removed Northwind code, but our update reinstalls it? I'm open to suggestions... 110 Allowing Multiple Genre Selection Allowing Multiple Genre Selection It happens. Requirements change. Now we want to allow selecting multiple genres for a movie. For this, we need a M-N mapping table that will let us link any movie to multiple genres. Creating MovieGenres Table As usual, we start with a migration: Modules/Common/Migrations/DefaultDB/ DefaultDB_20160528_115400_MovieGenres.cs: 111 Allowing Multiple Genre Selection using FluentMigrator; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160528115400)] public class DefaultDB_20160528_115400_MovieGenres : Migration { public override void Up() { Create.Table("MovieGenres").InSchema("mov") .WithColumn("MovieGenreId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("MovieId").AsInt32().NotNullable() .ForeignKey("FK_MovieGenres_MovieId", "mov", "Movie", "MovieId") .WithColumn("GenreId").AsInt32().NotNullable() .ForeignKey("FK_MovieGenres_GenreId", "mov", "Genre", "GenreId"); Execute.Sql( @"INSERT INTO mov.MovieGenres (MovieId, GenreId) SELECT m.MovieId, m.GenreId FROM mov.Movie m WHERE m.GenreId IS NOT NULL"); Delete.ForeignKey("FK_Movie_GenreId") .OnTable("Movie").InSchema("mov"); Delete.Column("GenreId") .FromTable("Movie").InSchema("mov"); } public override void Down() { } } } I tried to save existing Genre declarations on Movie table, by copying them to our new MovieGenres table. The line above with Execute.Sql does this. Then we should remove GenreId column, by first deleting the foreign key declaration FK_Movie_GenreId that we defined on it previously. Deleting Mapping for GenreId Column As soon as you build and open the Movies page, you'll get this error: 112 Allowing Multiple Genre Selection This is because we still have mapping for GenreId column in our row. Error above is received from AJAX call to List service handler for Movie table. Repeating of error message originates from SQL server. MovieId column name passes several times within the generated dynamic SQL. Remove GenreId and GenreName properties and their related field objects from MovieRow.cs: // remove this public Int32? GenreId { get { return Fields.GenreId[this]; } set { Fields.GenreId[this] = value; } } // remove this public String GenreName { get { return Fields.GenreName[this]; } set { Fields.GenreName[this] = value; } } public class RowFields : RowFieldsBase { // and remove these public Int32Field GenreId; public StringField GenreName; } Remove GenreName property from MovieColumns.cs: // remove this [Width(100), QuickFilter] public String GenreName { get; set; } Remove GenreId property from MovieForm.cs: 113 Allowing Multiple Genre Selection // remove this public Int32 GenreId { get; set; } After building, we at least have a working Movies page again. Generating Code For MovieGenres Table Fire up sergen and generate code for MovieGenres table as usual: As we're not going to edit movie genres from a separate page, you can safely delete the generated files below: MovieGenresColumns.cs MovieGenresDialog.ts MovieGenresEndpoint.cs MovieGenresForm.cs MovieGenresGrid.cs MovieGenresIndex.cshtml MovieGenresPage.cs You can also remove CSS entries for s-MovieDB-MovieGenresDialog from site.less. Only leave last two files, MovieGenresRow.cs and MovieGenresRepository.cs. After building, run T4 templates to be sure, no T4 generated files related to MovieGenresForm etc. is left behind. 114 Allowing Multiple Genre Selection Adding GenreList Field As one movie might have multiple genres now, instead of a Int32 property, we need a list of Int32 values, e.g. List . Add the GenreList property to MovieRow.cs: You might have to add System.Collections.Generic to usings. //... [DisplayName("Kind"), NotNull, DefaultValue(MovieKind.Film)] public MovieKind? Kind { get { return (MovieKind?)Fields.Kind[this]; } set { Fields.Kind[this] = (Int32?)value; } } [DisplayName("Genres")] [LookupEditor(typeof(GenreRow), Multiple = true), NotMapped] [LinkingSetRelation(typeof(MovieGenresRow), "MovieId", "GenreId")] public List GenreList { get { return Fields.GenreList[this]; } set { Fields.GenreList[this] = value; } } public class RowFields : RowFieldsBase { //... public Int32Field Kind; public ListField GenreList; Our property has [LookupEditor] attribute just like GenreId property had, but with one difference. This one accepts multiple genre selection. We set it with Multiple = true argument. This property also has NotMapped flag, which is something similar to Unmapped fields in Serenity. It specifies that this property has no matching database column in database. We don't have a GenreList column in Movie table, so we should set it as an unmapped field. Otherwise, Serenity will try to SELECT it, and we'll get SQL errors. In the next line, we use another new attribute, LinkingSetRelation: [LinkingSetRelation(typeof(MovieGenresRow), "MovieId", "GenreId")] This is an attribute which is specific to M-N releations that links a row in this table to multiple rows from another table. 115 Allowing Multiple Genre Selection First argument of it is the type of M-N mapping row, which is MovieGenresRow here. Second argument is the property name of field in that row (MovieGenresRow) that matches this row's ID property, e.g. MovieId. Third argument is the property name of field in that row (MovieGenresRow) that links multiple Genres by their IDs, e.g. GenreId. LinkingSetRelation has a related Serenity service behavior, named LinkingSetRelationBehavior that is automatically activated for all fields with a LinkingSetRelation attribute. This behavior, will intercept service handlers for Create, Update, Delete, Retrieve and List and inject code to populate or update our GenreList column and its related MovieGenres table. We'll talk about Serenity service behaviors in following chapters. Adding Genre List To Form Edit MovieForm.cs and add GenreList property: public class MovieForm { //... public List GenreList { get; set; } public MovieKind Kind { get; set; } public Int32 Runtime { get; set; } } Now we can add multiple genres to a Movie: 116 Allowing Multiple Genre Selection Showing Selected Genres in a Column Previously, when we had only one Genre per Movie. We could show the selected genre in a column, by adding a view field to MovieRow.cs. It is not going to be so simple this time. Let's start by adding GenreList property to MovieColumns.cs: public class MovieColumns { //... [Width(200)] public List GenreList { get; set; } [DisplayName("Runtime in Minutes"), Width(150), AlignRight] public Int32 Runtime { get; set; } } This is what we got: 117 Allowing Multiple Genre Selection GenreList column contains a list of Int32 values, which corresponds to an array in Javascript. Luckily, Javascript .toString() method for an array returns items separated by comma, so we got "1,2" for Fight Club movie. We would prefer genre names instead of Genre IDs, so it's clear that we need to format these values, by converting GenreId to their Genre name equivalents. Creating GenreListFormatter Class It's time to write a SlickGrid column formatter. Create file GenreListFormatter.ts next to MovieGrid.ts: 118 Allowing Multiple Genre Selection namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerFormatter() export class GenreListFormatter implements Slick.Formatter { format(ctx: Slick.FormatterContext) { let idList = ctx.value as number[]; if (!idList || !idList.length) return ""; let byId = GenreRow.getLookup().itemById; return idList.map(x => { let g = byId[x]; if (!g) return x.toString(); return Q.htmlEncode(g.Name); }).join(", "); } } } Here we define a new formatter, GenreListFormatter and register it with Serenity type system, using @Serenity.Decorators.registerFormatter decorator. Decorators are similar to .NET attributes. All formatters should implement Slick.Formatter interface, which has a format method that takes a ctx parameter of type Slick.FormatterContext. ctx, which is the formatting context, is an object with several members. One of them is value that contains the column value for current grid row/column being formatted. As we know that we'll use this formatter on column with a List value, we start by casting value to number[]. There is no Int32 type in Javascript. Int32, Double, Single etc. corresponds to number type. Also, generic List<> type in C# corresponds to an Array in Javascript. If the array is empty or null, we can safely return an empty string: let idList = ctx.value as number[]; if (!idList || !idList.length) return ""; Then we get a reference to Genre lookup, which has a dictionary of Genre rows in its itemById property: 119 Allowing Multiple Genre Selection let byId = GenreRow.getLookup().itemById; Next, we start mapping these ID values in our idList to their Genre name equivalents, using Array.map function in Javascript, which is pretty similar to LINQ Select statement: return idList.map(x => { We lookup an ID in our Genre dictionary. It should be in dictionary, but we play safe here, and return its numeric value, if the genre is not found in dictionary. let g = byId[x]; if (!g) return x.toString(); If we could find the genre row, corresponding to this ID, we return its Name value. We should HTML encode the genre name, just in case it contains invalid HTML characters, like < , > or & . return Q.htmlEncode(g.Name); We could also write a generic formatter that works with any type of lookup list, but it's beyond scope of this tutorial. Assigning GenreListFormatter to GenreList Column As we defined a new formatter class, we should build and transform T4 files, so that we can reference GenreListFormatter in server side code. After building and transforming, open MovieColumns.cs and attach this formatter to MovieList property: public class MovieColumns { //... [Width(200), GenreListFormatter] public List GenreList { get; set; } [DisplayName("Runtime in Minutes"), Width(150), AlignRight] public Int32 Runtime { get; set; } } Now we can see Genre names in Genres column: 120 Allowing Multiple Genre Selection 121 Filtering with Multiple Genre List Filtering with Multiple Genre List Remember that when we had only one Genre per Movie, it was easy to quick filter, by adding a [QuickFilter] attribute to GenreId field. Let's try to do similar in MovieColumns.cs: [ColumnsScript("MovieDB.Movie")] [BasedOnRow(typeof(Entities.MovieRow))] public class MovieColumns { //... [Width(200), GenreListFormatter, QuickFilter] public List GenreList { get; set; } } As soon as you type a Genre into Genres you'll have this error: As of Serenity 2.6.3, LinkingSetRelation will automatically handle equality filter for its field, so you won't get this error and it will just work. Anyway, it's still recommended to follow steps below as it is a good sample for defining custom list requests and handling them when required. ListHandler tried to filter by GenreList field, but as there is no such column in database, we got this error. So, now we have to handle it somehow. Declaring MovieListRequest Type 122 Filtering with Multiple Genre List As we are going to do something non-standard, e.g. filtering by values in a linking set table, we need to prevent ListHandler from filtering itself on GenreList property. We could process the request Criteria object (which is similar to an expression tree) using a visitor and handle GenreList ourself, but it would be a bit complex. So i'll take a simpler road for now. Let's take a subclass of standard ListRequest object and add our Genres filter parameter there. Add a MovieListRequest.cs file next to MovieRepository.cs: namespace MovieTutorial.MovieDB { using Serenity.Services; using System.Collections.Generic; public class MovieListRequest : ListRequest { public List Genres { get; set; } } } We added a Genres property to our list request object, which will hold the optional Genres we want movies to be filtered on. Modifying Repository/Endpoint for New Request Type For our list handler and service to use our new list request type, need to do changes in a few places. Start with MovieRepository.cs: public class MovieRepository { //... public ListResponse List(IDbConnection connection, MovieListRequest request ) { return new MyListHandler().Process(connection, request); } //... private class MyListHandler : ListRequestHandler { } } We changed ListRequest to MovieListRequest in List method and added a generic parameter to MyListHandler, to use our new type instead of ListRequest. 123 Filtering with Multiple Genre List And another little change in MovieEndpoint.cs, which is the actual web service: public class MovieController : ServiceEndpoint { //... public ListResponse List(IDbConnection connection, MovieListRequest request ) { return new MyRepository().List(connection, request); } } Now its time to build and transform templates, so our MovieListRequest object and related service methods will be available at client side. Moving Quick Filter to Genres Parameter We still have the same error as quick filter is not aware of the parameter we just added to list request type and still uses the Criteria parameter. Need to intercept quick filter item and move the genre list to Genres property of our MovieListRequest. Edit MovieGrid.ts: export class MovieGrid extends Serenity.EntityGrid { //... protected getQuickFilters() { let items = super.getQuickFilters(); var genreListFilter = Q.first(items, x => x.field == MovieRow.Fields.GenreList); genreListFilter.handler = h => { var request = (h.request as MovieListRequest); var values = (h.widget as Serenity.LookupEditor).values; request.Genres = values.map(x => parseInt(x, 10)); h.handled = true; }; return items; } } getQuickFilters is a method that is called to get a list of quick filter objects for this grid type. 124 Filtering with Multiple Genre List By default grid enumerates properties with [QuickFilter] attributes in MovieColumns.cs and creates suitable quick filter objects for them. We start by getting list of QuickFilter objects from super class. let items = super.getQuickFilters(); Then locate the quick filter object for GenreList property: var genreListFilter = Q.first(items, x => x.field == MovieRow.Fields.GenreList); Actually there is only one quick filter now, but we want to play safe. Next step is to set the handler method. This is where a quick filter object reads the editor value and applies it to request's Criteria (if multiple) or EqualityFilter (if single value) parameters, just before its submitted to list service. genreListFilter.handler = h => { Then we get a reference to current ListRequest being prepared: var request = (h.request as MovieListRequest); And read the current value in lookup editor: var values = (h.widget as Serenity.LookupEditor).values; Set it in request.Genres property: request.Genres = values.map(x => parseInt(x, 10)); As values is a list of string, we needed to convert them to integer. Last step is to set handled to true, to disable default behavior of quick filter object, so it won't set Criteria or EqualityFilter itself: h.handled = true; Now we'll no longer have Invalid Column Name GenreList error but Genres filter is not applied server side yet. 125 Filtering with Multiple Genre List Handling Genre Filtering In Repository Modify MyListHandler in MovieRepository.cs like below: private class MyListHandler : ListRequestHandler { protected override void ApplyFilters(SqlQuery query) { base.ApplyFilters(query); if (!Request.Genres.IsEmptyOrNull()) { var mg = Entities.MovieGenresRow.Fields.As("mg"); query.Where(Criteria.Exists( query.SubQuery() .From(mg) .Select("1") .Where( mg.MovieId == fld.MovieId && mg.GenreId.In(Request.Genres)) .ToString())); } } } ApplyFilters is a method that is called to apply filters specified in list request's Criteria and EqualityFilter parameters. This is a good place to apply our custom filter. We first check if Request.Genres is null or an empty list. If so no filtering needs to be done. Next, we get a reference to MovieGenresRow's fields with alias mg. var mg = Entities.MovieGenresRow.Fields.As("mg"); Here it needs some explanation, as we didn't cover Serenity entity system yet. Let's start by not aliasing MovieGenresRow.Fields: var x = MovieGenresRow.Fields; new SqlQuery() .From(x) .Select(x.MovieId) .Select(x.GenreId); If we wrote a query like above, its SQL output would be something like this: 126 Filtering with Multiple Genre List SELECT t0.MovieId, t0.GenreId FROM MovieGenres t0 Unless told otherwise, Serenity always assigns t0 to a row's primary table. Even if we named MovieGenresRow.Fields as variable x, it's alias will still be t0. Because when compiled, x won't be there and Serenity has no way to know its variable name. Serenity entity system doesn't use an expression tree like in LINQ to SQL or Entity Framework. It makes use of very simple string / query builders. So, if wanted it to use x as an alias, we'd have to write it explicitly: var x = MovieGenresRow.Fields.As("x"); new SqlQuery() .From(x) .Select(x.MovieId) .Select(x.GenreId); ...results at: SELECT x.MovieId, x.GenreId FROM MovieGenres x In MyListHandler, which is for MovieRow entities, t0 is already used for MovieRow fields. So, to prevent clashes with MovieGenresRow fields (which is named fld), i had to assign MovieGenresRow an alias, mg. var mg = Entities.MovieGenresRow.Fields.As("mg"); What i'm trying to achieve, is a query like this (just the way we'd do this in bare SQL): SELECT t0.MovieId, t0.Title, ... FROM Movies t0 WHERE EXISTS ( SELECT 1 FROM MovieGenres mg WHERE mg.MovieId = t0.MovieId AND mg.GenreId IN (1, 3, 5, 7) ) So i'm adding a WHERE filter to main query with Where method, using an EXISTS criteria: query.Where(Criteria.Exists( 127 Filtering with Multiple Genre List Then starting to write the subquery: query.SubQuery() .From(mg) .Select("1") And adding the where statement for subquery: .Where( mg.MovieId == fld.MovieId && mg.GenreId.In(Request.Genres)) Here fld actually contains the alias t0 for MovieRow fields. As Criteria.Exists method expects a simple string, i had to use .ToString() at the end, to convert subquery to a string: Yes, i should add one overload that accepts a subquery... noted. .ToString())); It might look a bit alien at start, but by time you'll understand that Serenity query system matches SQL almost 99%. It can't be the exact SQL as we have to work in a different language, C#. Now our filtering for GenreList property works perfectly... 128 The Cast and Characters They Played The Cast and Characters They Played If we wanted to keep a record of actors and the roles they played like this: Actor/Actress Character Keanu Reeves Neo Laurence Fishburne Morpheus Carrie-Anne Moss Trinity We need a table MovieCast with columns like: MovieCastId MovieId PersonId Character ... ... ... ... 11 2 (Matrix) 77 (Keanu Reeves) Neo 12 2 (Matrix) 99 (Laurence Fisburne) Morpheus 13 2 (Matrix) 30 (Carrie-Anne Moss) Trinitity ... ... ... ... It's clear that we also need a Person table as we'll keep actors/actresses by their ID. It's better to call it Person as actors/actresses might become directors, scenario writers and such later. Creating Person and MovieCast Tables Now its time to create a migration with two tables: MovieTutorial.Web/Modules/Common/Migrations/DefaultDB/ DefaultDB_20160528_141600_PersonAndMovieCast.cs: 129 The Cast and Characters They Played using FluentMigrator; using System; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160528141600)] public class DefaultDB_20160528_141600_PersonAndMovieCast : Migration { public override void Up() { Create.Table("Person").InSchema("mov") .WithColumn("PersonId").AsInt32().Identity() .PrimaryKey().NotNullable() .WithColumn("Firstname").AsString(50).NotNullable() .WithColumn("Lastname").AsString(50).NotNullable() .WithColumn("BirthDate").AsDateTime().Nullable() .WithColumn("BirthPlace").AsString(100).Nullable() .WithColumn("Gender").AsInt32().Nullable() .WithColumn("Height").AsInt32().Nullable(); Create.Table("MovieCast").InSchema("mov") .WithColumn("MovieCastId").AsInt32().Identity() .PrimaryKey().NotNullable() .WithColumn("MovieId").AsInt32().NotNullable() .ForeignKey("FK_MovieCast_MovieId", "mov", "Movie", "MovieId") .WithColumn("PersonId").AsInt32().NotNullable() .ForeignKey("FK_MovieCast_PersonId", "mov", "Person", "PersonId") .WithColumn("Character").AsString(50).Nullable(); } public override void Down() { } } } Generating Code For Person Table First generate code for Person table: 130 The Cast and Characters They Played Changing Gender To Enumeration Gender column in Person table should be an enumeration. Declare a Gender enumeration in Gender.cs next to PersonRow.cs: using Serenity.ComponentModel; using System.ComponentModel; namespace MovieTutorial.MovieDB { [EnumKey("MovieDB.Gender")] public enum Gender { [Description("Male")] Male = 1, [Description("Female")] Female = 2 } } Change Gender property declaration in PersonRow.cs as below: 131 The Cast and Characters They Played //... [DisplayName("Gender")] public Gender? Gender { get { return (Gender?)Fields.Gender[this]; } set { Fields.Gender[this] = (Int32?)value; } } //... For consistency, change type of Gender property in PersonForm.cs and PersonColumns.cs from Int32 to Gender. Rebuilding T4 Templates As we declared a new enumeration and used it, we should rebuild solution, convert T4 templates Now after launching your project, you should be able to enter actors: Declaring FullName Field 132 The Cast and Characters They Played On the title of edit dialog, first name of the person is shown (Carrie-Anne). It would be nice to show full name. And also search with full name in grid. So let's edit our PersonRow.cs: namespace MovieTutorial.MovieDB.Entities { //... public sealed class PersonRow : Row, IIdRow, INameRow { //... remove QuickSearch from FirstName [DisplayName("First Name"), Size(50), NotNull] public String Firstname { get { return Fields.Firstname[this]; } set { Fields.Firstname[this] = value; } } [DisplayName("Last Name"), Size(50), NotNull] public String Lastname { get { return Fields.Lastname[this]; } set { Fields.Lastname[this] = value; } } [DisplayName("Full Name"), Expression("(t0.Firstname + ' ' + t0.Lastname)"), QuickSearch] public String Fullname { get { return Fields.Fullname[this]; } set { Fields.Fullname[this] = value; } } //... change NameField to Fullname StringField INameRow.NameField { get { return Fields.Fullname; } } //... public class RowFields : RowFieldsBase { public readonly Int32Field PersonId; public readonly StringField Firstname; public readonly StringField Lastname; public readonly StringField Fullname; //... } } } 133 The Cast and Characters They Played We specified SQL expression Expression("(t0.Firstname + ' ' + t0.Lastname)") on top of Fullname property. Thus, it is a server side calculated field. By adding QuickSearch attribute to FullName, instead of Firstname, grid will now search by default on Fullname field. But dialog will still show Firstname. We need to build and transform templates to make it show Fullname. Why Had to Transform Templates? This will become more clear after looking at PersonDialog.ts file: namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() @Serenity.Decorators.responsive() export class PersonDialog extends Serenity.EntityDialog { protected getFormKey() { return PersonForm.formKey; } protected getIdProperty() { return PersonRow.idProperty; } protected getLocalTextPrefix() { return PersonRow.localTextPrefix; } protected getNameProperty() { return PersonRow.nameProperty; } protected getService() { return PersonService.baseUrl; } protected form = new PersonForm(this.idPrefix); } } Here we see that getNameProperty() method returns PersonRow.nameProperty. PersonRow typing in TypeScript is in a file (MovieDB.PersonRow.ts) generated by our T4 templates. Thus, unless we transform T4 templates, the name property change we did in PersonRow.cs won't be reflected in corresponding MovieDB.PersonRow.ts file under *Modules/Common/Imports/ServerTypings/ ServerTypings.tt": 134 The Cast and Characters They Played namespace MovieTutorial.MovieDB { export interface PersonRow { PersonId?: number; Firstname?: string; Lastname?: string; Fullname?: string; //... } export namespace PersonRow { export const idProperty = 'PersonId'; export const nameProperty = 'Fullname'; export const localTextPrefix = 'MovieDB.Person'; export namespace Fields { export declare const PersonId: string; //... } //... } } This metadata (name property of PersonRow) is transferred to TypeScript with a code file (MovieDB.PersonRow.ts) that is generated by ServerTypings.tt file. Similarly, idProperty, localTextPrefix, Enum Types etc. are also generated under ServerTypings.tt. Thus, when you make a change that affects a metadata in these generated files, you should transform T4 templates to transfer that information to TypeScript. You should always build before transforming, as T4 files uses output DLL of MovieTutorial.Web project. Otherwise you'll be generating code for an older version of your Web project. Declaring PersonRow Lookup Script While we are still here, let's declare a LookupScript for Person table in PersonRow.cs: namespace MovieTutorial.MovieDB.Entities { //... [LookupScript("MovieDB.Person")] public sealed class PersonRow : Row, IIdRow, INameRow //... We'll use it for editing Movie cast later. 135 The Cast and Characters They Played Build and transform templates again, you'll see that MovieDB.PersonRow.ts now has a getLookup() method alongside with a new lookupKey property: namespace MovieTutorial.MovieDB { export interface PersonRow { //... } export namespace PersonRow { export const idProperty = 'PersonId'; export const nameProperty = 'Fullname'; export const localTextPrefix = 'MovieDB.Person'; export const lookupKey = 'MovieDB.Person'; export function getLookup(): Q.Lookup { return Q.getLookup('MovieDB.Person'); } //... } Generating Code For MovieCast Table Generate code for MovieCast table using sergen: After generating code, as we don't need a separate page to edit movie cast table, you may delete files listed below: 136 The Cast and Characters They Played MovieCastIndex.cshtml MovieCastPage.cs MovieDialog.ts MovieGrid.ts Again, build and transform templates. Master/Detail Editing Logic For MovieCast Table Up until now, we created a page for each table, and list and edit its records in that page. This time we are going to use a different strategy. We'll list the cast for a movie in the Movie dialog and allow them to be edited along with the movie. Also, cast will be saved together with movie entity in one transaction. Thus, cast editing will be in memory, and when user presses save button in Movie dialog, the movie and its cast will be saved to database in one shot (one transaction). It would be possible to edit the cast independently, here we just want to show how it can be done. For some types of master/detail records like order/detail, details shouldn't be allowed to be edited independently for consistency reasons. Serene already has a sample for this kind of editing in Northwind/Order dialog. Creating an Editor For Movie Cast List Next to MovieCastRow.cs (at MovieTutorial.Web/Modules/MovieDB/MovieCast/), create a file named MovieCastEditor.ts with contents below: /// namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerEditor() export class MovieCastEditor extends Common.GridEditorBase { protected getColumnsKey() { return "MovieDB.MovieCast"; } protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; } constructor(container: JQuery) { super(container); } } } 137 The Cast and Characters They Played This editor derives from Common.GridEditorBase class in Serene, which is a special grid type that is designed for in-memory editing. It is also the base class for Order Details editor used in Order dialog. The line at top of the file is important. TypeScript has ordering problems with input files. If we didn't put it there, TypeScript would sometimes output GridEditorBase after our MovieCastEditor, and we'd get runtime errors. As a rule of thumb, if you are deriving some class from another in your project (not Serenity classes), you should put a reference to file containing that base class. This helps TypeScript to convert GridEditorBase to javascript before other classes that might need it. To reference this new editor type from server side, build and transform all templates. This base class might be integrated to Serenity in later versions. In that case, its namespace will become Serenity, instead of Serene or MovieTutorial. Using MovieCastEditor in Movie Form Open MovieForm.cs, between Description and Storyline fields, add a CastList property like: namespace MovieTutorial.MovieDB.Forms { //... public class MovieForm { public String Title { get; set; } [TextAreaEditor(Rows = 3)] public String Description { get; set; } [MovieCastEditor] public List CastList { get; set; } [TextAreaEditor(Rows = 8)] public String Storyline { get; set; } //... } } By putting [MovieCastEditor] attribute on top of CastList property, we specified that this property will be edited by our new MovieCastEditor type which is defined in TypeScript code. We could also write [EditorType("MovieDB.MovieCast")] but who really likes hard-coded strings? Not me... Now build and launch your application. Open a movie dialog and you'll be greeted by our new editor: 138 The Cast and Characters They Played OK, it looked easy, but i'll be honest, we are not even half the way. That New MovieCast button doesn't work, need to define a dialog for it. The grid columns are not what i'd like them to be and the field and button titles are not so user friendly... Also we'll have to handle a bit more plumbing like loading and saving cast list on server side (we'll show the harder - manual way first, then we'll see how easy it can be using a service behavior). Configuring MovieCastEditor to Use MovieCastEditDialog Create a MovieCastEditDialog.ts file next to MovieCastEditor.ts and modify it like below: 139 The Cast and Characters They Played /// namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() export class MovieCastEditDialog extends Common.GridEditorDialog { protected getFormKey() { return MovieCastForm.formKey; } protected getNameProperty() { return MovieCastRow.nameProperty; } protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; } protected form: MovieCastForm; constructor() { super(); this.form = new MovieCastForm(this.idPrefix); } } } We are using another base class from Serene, Common.GridEditorDialog which is also used by OrderDetailEditDialog. Open MovieCastEditor.ts again, add a getDialogType method and override getAddButtonCaption: /// namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerEditor() export class MovieCastEditor extends Common.GridEditorBase { protected getColumnsKey() { return "MovieDB.MovieCast"; } protected getDialogType() { return MovieCastEditDialog; } protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; } constructor(container: JQuery) { super(container); } protected getAddButtonCaption() { return "Add"; } } } We specified that MovieCastEditor uses a MovieCastEditDialog by default which is also used by Add button. 140 The Cast and Characters They Played Now, instead of doing nothing, Add button shows a dialog. This dialog needs some CSS formatting. Movie title and person name fields accepts integer inputs (as they are actually MovieId and PersonId fields). Editing MovieCastForm.cs getFormKey() method of MovieCastEditDialog returns MovieCastForm.formKey, so it currently uses MovieCastForm.cs generated by Sergen. It is possible to have multiple forms for one entity in Serenity. If i wanted to save MovieCastForm for some other standalone dialog, e.g. MovieCastDialog (which we actually deleted), i would prefer to define a new form like MovieCastEditForm, but this is not the case. Open MovieCastForm.cs and modify it: 141 The Cast and Characters They Played namespace MovieTutorial.MovieDB.Forms { using Serenity.ComponentModel; using System; using System.ComponentModel; [FormScript("MovieDB.MovieCast")] [BasedOnRow(typeof(Entities.MovieCastRow))] public class MovieCastForm { public Int32 PersonId { get; set; } public String Character { get; set; } } } I have removed MovieId as this form is going to be used in MovieCastEditDialog, so MovieCast entities will have the MovieId of the movie currently being edited in the MovieDialog automatically. Opening Lord of the Rings movie and adding a cast entry for the Matrix would be non-sense. Next, edit MovieCastRow.cs: [ConnectionKey("Default"), TwoLevelCached] [DisplayName("Movie Casts"), InstanceName("Cast")] [ReadPermission("Administration")] [ModifyPermission("Administration")] public sealed class MovieCastRow : Row, IIdRow, INameRow { //... [DisplayName("Actor/Actress"), NotNull, ForeignKey("[mov].[Person]", "PersonId ")] [LeftJoin("jPerson"), TextualField("PersonFirstname")] [LookupEditor(typeof(PersonRow))] public Int32? PersonId { get { return Fields.PersonId[this]; } set { Fields.PersonId[this] = value; } } I have set editor type for PersonId field to a lookup editor and as i have already added a LookupScript attribute to PersonRow, i can reuse that information for setting the lookup key. We could have also written [LookupEditor("MovieDB.Person")] Changed PersonId display name to Actor/Actress. Also changed DisplayName and InstanceName attributes for row to set dialog title. 142 The Cast and Characters They Played Build solution, launch and now MovieCastEditDialog has a better editing experience. But still too big in width and height. Fixing the Look Of MovieCastEditDialog Let's check site.less to understand why our MovieCastEditDialog is not styled. .s-MovieDB-MovieCastDialog { > .size { width: 650px; } .caption { width: 150px; } } The CSS at the bottom of site.less is for the MovieCastDialog, not MovieCastEditDialog, because we defined this class ourselves, not with code generator. We created a new dialog type MovieCastEditDialog, so now our new dialog has a CSS class of s-MovieDB-MovieCastEditDialog, but code generator only generated CSS rules for sMovieDB-MovieCastDialog. Serenity dialogs automatically assigns CSS classes to dialog elements, by prefixing type name with "s-". You can see this by inspecting the dialog in developer tools. MovieCastEditDialog has CSS classes of s-MovieCastEditDialog and s-MovieDBMovieCastEditDialog, along with some other like ui-dialog. s-ModuleName-TypeName CSS class helps with individual styling when two modules has a type with the same name. As we are not gonna actually use MovieCastDialog (we deleted it), let's rename the one in site.less: .s-MovieDB-MovieCastEditDialog { > .size { width: 450px; } .caption { width: 120px; } .s-PropertyGrid .categories { height: 120px; } } Now MovieCastEditDialog has a better look: 143 The Cast and Characters They Played Fixing MovieCastEditor Columns MovieCastEditor is currently using columns defined in MovieCastColumns.cs (because it returns "MovieDB.MovieCast" in getColumnsKey() method. We have MovieCastId, MovieId, PersonId (shown as Actor/Actress) and Character columns there. It is better to show only Actor/Actress and Character columns. We want to show actors fullname instead of PersonId (integer value), so we'll declare this field in MovieCastRow.cs first: 144 The Cast and Characters They Played namespace MovieTutorial.MovieDB.Entities { //... public sealed class MovieCastRow : Row, IIdRow, INameRow { // ... [DisplayName("Person Firstname"), Expression("jPerson.Firstname")] public String PersonFirstname { get { return Fields.PersonFirstname[this]; } set { Fields.PersonFirstname[this] = value; } } [DisplayName("Person Lastname"), Expression("jPerson.Lastname")] public String PersonLastname { get { return Fields.PersonLastname[this]; } set { Fields.PersonLastname[this] = value; } } [DisplayName("Actor/Actress"), Expression("(jPerson.Firstname + ' ' + jPerson.Lastname)")] public String PersonFullname { get { return Fields.PersonFullname[this]; } set { Fields.PersonFullname[this] = value; } } // ... public class RowFields : RowFieldsBase { // ... public readonly StringField PersonFirstname; public readonly StringField PersonLastname; public readonly StringField PersonFullname; // ... } } } and modify MovieCastColumns.cs: 145 The Cast and Characters They Played namespace MovieTutorial.MovieDB.Columns { using Serenity.ComponentModel; using System; [ColumnsScript("MovieDB.MovieCast")] [BasedOnRow(typeof(Entities.MovieCastRow))] public class MovieCastColumns { [EditLink, Width(220)] public String PersonFullname { get; set; } [EditLink, Width(150)] public String Character { get; set; } } } Rebuild and cast grid has better columns: Now try adding an actor/actress, for example, Keanu Reeves / Neo: 146 The Cast and Characters They Played Why Actor/Actress column is empty?? Resolving Empty Actor/Actress Column Problem Remember that we are editing in-memory. There is no service call involved here. So, grid is displaying whatever entity is sent back to it from the dialog. When you click the save button, dialog builds an entity to save like this: { PersonId: 7, Character: 'Neo' } These fields corresponds to the form fields you previously set in MovieCastForm.cs: public class MovieCastForm { public Int32 PersonId { get; set; } public String Character { get; set; } } 147 The Cast and Characters They Played But in grid, we are showing these columns: public class MovieCastColumns { public String PersonFullname { get; set; } public String Character { get; set; } } There is no PersonFullname field in this entity, so grid can't display its value. We need to set PersonFullname ourself. Let's first transform T4 templates to have access to PersonFullname field that we recently added, then edit MovieCastEditor.ts: /// namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerEditor() export class MovieCastEditor extends Common.GridEditorBase { //... protected validateEntity(row: MovieCastRow, id: number) { if (!super.validateEntity(row, id)) return false; row.PersonFullname = PersonRow.getLookup() .itemById[row.PersonId].Fullname; return true; } } } ValidateEntity is a method from our GridEditorBase class in Serene. This method is called when Save button is clicked to validate the entity, just before it is going to be added to the grid. But we are overriding it here for another purpose (to set PersonFullname field value) rather than validation. As we saw before, our entity has PersonId and Character fields filled in. We can use the value of PersonId field to determine the person fullname. For this, we need a dictionary that maps PersonId to their Fullname values. Fortunately, person lookup has such a dictionary. We can access the lookup for PersonRow through its getLookup method. Another way to access person lookup is by Q.getLookup('MovieDB.Person'). The one in PersonRow is just a shortcut defined by T4 templates. 148 The Cast and Characters They Played All lookups has a itemById dictionary that allows you to access an entity of that type by its ID. Lookups are a simple way to share server side data with client side. But they are only suitable for small sets of data. If a table has hundreds of thousands of records, it wouldn't be reasonable to define a lookup for it. In that case, we would use a service request to query a record by its ID. Defining CastList in MovieRow While having a Movie dialog open, and at least one cast in CastList, click save button, and you'll get such an error: This error is raised from -> Row deserializer (JsonRowConverter for JSON.NET) at server side. We defined CastList property in MovieForm, but have no corresponding field declaration in MovieRow. So deserializer can't find where to write CastList value that is received from client side. If you open developer tools with F12, click Network tab, and watch AJAX request after clicking Save button, you'll see that it has such a request payload: 149 The Cast and Characters They Played { "Entity": { "Title": "The Matrix", "Description": "A computer hacker...", "CastList": [ { "PersonId":"1", "Character":"Neo", "PersonFullname":"Keanu Reeves" } ], "Storyline":"Thomas A. Anderson is a man living two lives...", "Year":1999, "ReleaseDate":"1999-03-31", "Runtime":136, "GenreId":"", "Kind":"1", "MovieId":1 } } Here, CastList property can't be deserialized at server side. So we need to declare it in MovieRow.cs: namespace MovieTutorial.MovieDB.Entities { // ... public sealed class MovieRow : Row, IIdRow, INameRow { [DisplayName("Cast List"), NotMapped] public List CastList { get { return Fields.CastList[this]; } set { Fields.CastList[this] = value; } } public class RowFields : RowFieldsBase { // ... public readonly RowListField CastList; // ... } } } We defined a CastList property that will accept a List of MovieCastRow objects. The type of Field class that is used for such row list properties is RowListField. 150 The Cast and Characters They Played By adding [NotMapped] attribute, we specified that this field is not available directly in database table, thus can't be selected through simple SQL queries. It is analogous to an unmapped field in other ORM systems. Now, when you click the Save button, you will not get an error. But reopen the Matrix entity you just saved. There is no cast entry there. What happened to Neo? As this is an unmapped field, so movie Save service just ignored the CastList property. If you remember that in prior section, our GenreList also was an unmapped field, but somehow it worked there. Thats because we made use of a behavior, LinkedSetRelationBehavior with that property. Here we are sampling what would happen if we had no such service behavior. Handling Save for CastList Open MovieRepository.cs, find the empty MySaveHandler class, and modify it like below: private class MySaveHandler : SaveRequestHandler { protected override void AfterSave() { base.AfterSave(); if (Row.CastList != null) { var mc = Entities.MovieCastRow.Fields; var oldList = IsCreate ? null : Connection.List( mc.MovieId == this.Row.MovieId.Value); new Common.DetailListSaveHandler( oldList, Row.CastList, x => x.MovieId = Row.MovieId.Value).Process(this.UnitOfWork); } } } MySaveHandler, processes both CREATE (insert), and UPDATE service requests for Movie rows. As most of its logic is handled by base SaveRequestHandler class, its class definition was empty before. 151 The Cast and Characters They Played We should first wait for Movie entity to be inserted / updated successfully, before inserting / updating the cast list. Thus, we are including our customized code by overriding the base AfterSave method. If this is CREATE (insert) operation, we need the MovieId field value to reuse in MovieCast records. As MovieId is an IDENTITY field, it is only available after inserting the movie record. As we are editing cast list in memory (client-side), this will be a batch update. We need to compare old list of the cast records for this movie to the new list of cast records, and INSERT/UPDATE/DELETE them. Let's say we had cast records A, B, C, D in database for movie X. User did some modifications in edit dialogs to cast list, and now we have A, B, D, E, F. So we need to update A, B, D (in case character / actor changed), delete C, and insert new records E and F. Fortunately, DetailListSaveHandler class that is defined in Serene, handles all these comparisons and performs insert/update/delete operations automatically (by ID values). Otherwise we would have to write much more code here. To get a list of old records, we need to query database if this is an UPDATE movie operation. If this is a CREATE movie operation there shouldn't be any old cast record. We are using Connection.List< Entities.MovieCastRow > extension method. Connection here is a property of SaveRequestHandler that returns the current connection used. List selects records that matches the specified criteria (mc.MovieId == this.Row.MovieId.Value). this.Row refers to currently inserted / updated record (movie) with its new field values, so it contains the MovieId value (new or existing). To update cast records, we are creating a DetailListHandler object, with old cast list, new cast list, and a delegate to set the MovieId field value in a cast record. This is to link new cast records with the current movie. Then we call DetailListHandler.Process with current unit of work. UnitOfWork is a special object that wraps the current connection/transaction. All Serenity CREATE/UPDATE/DELETE handlers works with implicit transactions (IUnitOfWork). Handling Retrieve for CastList 152 The Cast and Characters They Played We are not done yet. When a Movie entity is clicked in movie grid, movie dialog loads the movie record by calling the movie Retrieve service. As CastList is an unmapped field, even if we saved them properly, they won't be loaded into the dialog. We need to also edit MyRetrieveHandler class in MovieRepository.cs: private class MyRetrieveHandler : RetrieveRequestHandler { protected override void OnReturn() { base.OnReturn(); var mc = Entities.MovieCastRow.Fields; Row.CastList = Connection.List(q => q .SelectTableFields() .Select(mc.PersonFullname) .Where(mc.MovieId == Row.MovieId.Value)); } } Here, we are overriding OnReturn method, to inject CastList into movie row just before returning the it from retrieve service. I used a different overload of Connection.List extension, which allows me to modify the select query. By default, List selects all table fields (not foreign view fields coming from other tables), but to show actor name, i needed to also select PersonFullName field. Now build the solution, and we can finally list / edit the cast. Handling Delete for CastList When you try to delete a Movie entity, you'll get foreign key errors. You could use a "CASCADE DELETE" foreign key while creating MovieCast table. But we'll handle this at repository level again: 153 The Cast and Characters They Played private class MyDeleteHandler : DeleteRequestHandler { protected override void OnBeforeDelete() { base.OnBeforeDelete(); var mc = Entities.MovieCastRow.Fields; foreach (var detailID in Connection.Query( new SqlQuery() .From(mc) .Select(mc.MovieCastId) .Where(mc.MovieId == Row.MovieId.Value))) { new DeleteRequestHandler().Process(this.UnitOfWork, new DeleteRequest { EntityId = detailID }); } } } The way we implemented this master/detail handling is not very intuitive and included several manual steps at repository layer. Keep on reading to see how easily it could be done by using an integrated feature (MasterDetailRelationAttribute). Handling Save / Retrieve / Delete With a Behavior Master/detail relations are an integrated feature (at least on server side), so instead of manually overriding Save / Retrieve and Delete handlers, i'll use an attribute, MasterDetailRelation. Open MovieRow.cs and modify CastList property: [MasterDetailRelation(foreignKey: "MovieId", IncludeColumns = "PersonFullname")] [DisplayName("Cast List"), NotMapped] public List CastList { get { return Fields.CastList[this]; } set { Fields.CastList[this] = value; } } We specified that this field is a detail list of a master/detail relation and master ID field (foreignKey) of the detail table is MovieId. Now undo all changes we made in MovieRepository.cs: 154 The Cast and Characters They Played private class MySaveHandler : SaveRequestHandler { } private class MyDeleteHandler : DeleteRequestHandler { } private class MyRetrieveHandler : RetrieveRequestHandler { } In our MasterDetailRelation attribute, we specified an extra property, IncludeColumns: [MasterDetailRelation(foreignKey: "MovieId", IncludeColumns = "PersonFullname")] This ensures that PersonFullname field on cast list is selected on retrieve. Otherwise, it wouldn't be loaded as only table fields are selected by default. When you open a movie dialog with existing cast list, full name would be empty. Make sure you add any view field you use in grid columns to IncludeColumns. Put comma between names of multiple fields, e.g. IncludeColumns = "FieldA, FieldB, FieldC". Now build your project and you'll see that same functionality works with much less code. MasterDetailRelationAttribute triggers an instrinsic (automatic) behavior, MasterDetailRelationBehavior which intercepts Retrieve/Save/Delete handlers and methods we had overriden before and performs similar operations. So we did the same thing, but this time declaratively, not imperatively (what should be done, instead of how to do it) https://en.wikipedia.org/wiki/Declarative_programming We'll see how to write your own request handler behaviors in following chapters. 155 Listing Movies in Person Dialog Listing Movies in Person Dialog To show list of movies a person acted in, we'll add a tab to PersonDialog. By default all entity dialogs (ones we used so far, which derive from EntityDialog) uses EntityDialog template at MovieTutorial.Web/Views/Templates/EntityDialog.Template.html:
This template contains a toolbar placeholder (~_Toolbar), form (~_Form) and PropertyGrid (*~_PropertyGrid). ~_ is a special prefix that is replaced with a unique dialog ID at runtime. This ensures that objects in two instances of a dialog won't have the same ID values. EntityDialog template is shared by all dialogs, so we are not gonna modify it to add a tab to PersonDialog. Defining a Tabbed Template for PersonDialog Create a new file, MovieDB.PersonDialog.Template.html under Modules/MovieDB/Person/ folder with contents: 156 Listing Movies in Person Dialog The syntax we used here is specific to jQuery UI tabs widget. It needs an UL element with list of tab links pointing to tab pane divs (.tab-pane). When EntityDialog finds a div with ID ~_Tabs in its template, it automatically initializes a tabs widget on it. Naming of the template file is important. It must end with .Template.html extension. All files with this extension are made available at client side through a dynamic script. Folder of the template file is ignored, but templates must be under Modules or Views/Template directories. By default, all templated widgets (EntityDialog also derives from TemplatedWidget class), looks for a template with their own classname. Thus, PersonDialog looks for a template with the name MovieDB.PersonDialog.Template.html, followed by PersonDialog.Template.html. MovieDB comes from PersonDialog namespace with the root namespace (MovieTutorial) removed. You can also think of it as module name dot class name. If a template with class name is not found, search continues to base classes and eventually a fallback template, EntityDialog.Template.html is used. Now, we have a tab in PersonDialog: 157 Listing Movies in Person Dialog Meanwhile, i noticed Person link is still under MovieDB and we forgot to remove MovieCast link. I'm fixing them now... Creating PersonMovieGrid Movie tab is empty for now. We need to define a grid with suitable columns and place it in that tab. First, declare the columns we'll use with the grid, in file PersonMovieColumns.cs next to PersonColumns.cs: 158 Listing Movies in Person Dialog namespace MovieTutorial.MovieDB.Columns { using Serenity.ComponentModel; using System; [ColumnsScript("MovieDB.PersonMovie")] [BasedOnRow(typeof(Entities.MovieCastRow))] public class PersonMovieColumns { [Width(220)] public String MovieTitle { get; set; } [Width(100)] public Int32 MovieYear { get; set; } [Width(200)] public String Character { get; set; } } } Next define a PersonMovieGrid class, in file PersonMovieGrid.ts next to PersonGrid.ts: namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() export class PersonMovieGrid extends Serenity.EntityGrid { protected getColumnsKey() { return "MovieDB.PersonMovie"; } protected getIdProperty() { return MovieCastRow.idProperty; } protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; } protected getService() { return MovieCastService.baseUrl; } constructor(container: JQuery) { super(container); } } } We'll actually use MovieCast service, to list movies a person acted in. Last step is to instantiate this grid in PersonDialog.ts: 159 Listing Movies in Person Dialog @Serenity.Decorators.registerClass() @Serenity.Decorators.responsive() export class PersonDialog extends Serenity.EntityDialog { protected getFormKey() { return PersonForm.formKey; } protected getIdProperty() { return PersonRow.idProperty; } protected getLocalTextPrefix() { return PersonRow.localTextPrefix; } protected getNameProperty() { return PersonRow.nameProperty; } protected getService() { return PersonService.baseUrl; } protected form = new PersonForm(this.idPrefix); private moviesGrid: PersonMovieGrid; constructor() { super(); this.moviesGrid = new PersonMovieGrid(this.byId("MoviesGrid")); this.tabs.on('tabsactivate', (e, i) => { this.arrange(); }); } } Remember that in our template we had a div with id ~_MoviesGrid under movies tab pane. We created PersonMovie grid on that div. this.ById("MoviesGrid") is a special method for templated widgets. $('#MoviesGrid') wouldn't work here, as that div actually has some ID like PersonDialog17_MoviesGrid. ~_ in templates are replaced with a unique container widget ID. We also attached to OnActivate event of jQuery UI tabs, and called Arrange method of the dialog. This is to solve a problem with SlickGrid, when it is initially created in invisible tab. Arrange triggers relayout for SlickGrid to solve this problem. OK, now we can see list of movies in Movies tab, but something is strange: 160 Listing Movies in Person Dialog Filtering Movies for the Person No, Carrie-Anne Moss didn't act in three roles. This grid is showing all movie cast records for now, as we didn't tell what filter it should apply yet. PersonMovieGrid should know the person it shows the movie cast records for. So, we add a PersonID property to this grid. This PersonID should be passed somehow to list service for filtering. 161 Listing Movies in Person Dialog namespace MovieTutorial.MovieDB { @Serenity.Decorators.registerClass() export class PersonMovieGrid extends Serenity.EntityGrid { protected getColumnsKey() { return "MovieDB.PersonMovie"; } protected getIdProperty() { return MovieCastRow.idProperty; } protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; } protected getService() { return MovieCastService.baseUrl; } constructor(container: JQuery) { super(container); } protected getButtons() { return null; } protected getInitialTitle() { return null; } protected usePager() { return false; } protected getGridCanLoad() { return this.personID != null; } private _personID: number; get personID() { return this._personID; } set personID(value: number) { if (this._personID != value) { this._personID = value; this.setEquality(MovieCastRow.Fields.PersonId, value); this.refresh(); } } } } We are using ES5 (EcmaScript 5) property (get/set) features. It's pretty similar to C# properties. 162 Listing Movies in Person Dialog We store the person ID in a private variable. When it changes, we also set a equality filter for PersonId field using SetEquality method (which will be sent to list service), and refresh to see changes. Equality filter is the list request parameter that is also used by quick filter items. Overriding GetGridCanLoad method allows us to control when grid can call list service. If we didn't override it, while creating a new Person, grid would load all movie cast records, as there is not a PersonID yet (it is null). List handler ignores an equality filter parameter if its value is null. Just like when a quick filter dropdown is empty, all records are shown. We also did three cosmetic changes, by overriding three methods, first to remove all buttons from toolbar (getButtons), second to remove title from the grid (getInitialTitle) as tab title is enough), and third to remove paging functionality (usePager), a person can't have a million movies right?). Setting PersonID of PersonMovieGrid in PersonDialog If nobody sets grid's PersonID property, it will always be null, and no records will be loaded. We should set it in afterLoadEntity method of Person dialog: namespace MovieTutorial.MovieDB { // ... export class PersonDialog extends Serenity.EntityDialog { // ... protected afterLoadEntity() { super.afterLoadEntity(); this.moviesGrid.personID = this.entityId; } } } afterLoadEntity is called after an entity or a new entity is loaded into dialog. Please note that entity is loaded in a later phase, so it won't be available in dialog constructor. this.EntityId refers to the identity value of the currently loaded entity. In new record mode, it is null. 163 Listing Movies in Person Dialog AfterLoadEntity and LoadEntity might be called several times during dialog lifetime, so avoid creating some child objects in these events, otherwise you will have multiple instances of created objects. Thats why we created the grid in dialog constructor. Fixing Movies Tab Size You might have noticed that when you switch to Movies tab, dialog gets a bit less in height. This is because dialog is set to auto height and grids are 200px by default. When you switch to movies tab, form gets hidden, so dialog adjusts to movies grid height. Edit s-MovieDB-PersonDialog css in site.less: .s-MovieDB-PersonDialog { > .size { width: 650px; } .caption { width: 150px; } .s-PersonMovieGrid > .grid-container { height: 287px; } } 164 Adding Primary and Gallery Images Adding Primary and Gallery Images To add a primary image and multiple gallery images to both Movie and Person records, need to start with a migration: using FluentMigrator; namespace MovieTutorial.Migrations.DefaultDB { [Migration(20160603205900)] public class DefaultDB_20160603_205900_PersonMovieImages : Migration { public override void Up() { Alter.Table("Person").InSchema("mov") .AddColumn("PrimaryImage").AsString(100).Nullable() .AddColumn("GalleryImages").AsString(int.MaxValue).Nullable(); Alter.Table("Movie").InSchema("mov") .AddColumn("PrimaryImage").AsString(100).Nullable() .AddColumn("GalleryImages").AsString(int.MaxValue).Nullable(); } public override void Down() { } } } Then modify MovieRow.cs and PersonRow.cs: 165 Adding Primary and Gallery Images namespace MovieTutorial.MovieDB.Entities { // ... public sealed class PersonRow : Row, IIdRow, INameRow { [DisplayName("Primary Image"), Size(100), ImageUploadEditor(FilenameFormat = "Person/PrimaryImage/~")] public string PrimaryImage { get { return Fields.PrimaryImage[this]; } set { Fields.PrimaryImage[this] = value; } } [DisplayName("Gallery Images"), MultipleImageUploadEditor(FilenameFormat = "Person/GalleryImages/~")] public string GalleryImages { get { return Fields.GalleryImages[this]; } set { Fields.GalleryImages[this] = value; } } // ... public class RowFields : RowFieldsBase { // ... public readonly StringField PrimaryImage; public readonly StringField GalleryImages; // ... } } } 166 Adding Primary and Gallery Images namespace MovieTutorial.MovieDB.Entities { // ... public sealed class MovieRow : Row, IIdRow, INameRow { [DisplayName("Primary Image"), Size(100), ImageUploadEditor(FilenameFormat = "Movie/PrimaryImage/~")] public string PrimaryImage { get { return Fields.PrimaryImage[this]; } set { Fields.PrimaryImage[this] = value; } } [DisplayName("Gallery Images"), MultipleImageUploadEditor(FilenameFormat = "Movie/GalleryImages/~")] public string GalleryImages { get { return Fields.GalleryImages[this]; } set { Fields.GalleryImages[this] = value; } } // ... public class RowFields : RowFieldsBase { // ... public readonly StringField PrimaryImage; public readonly StringField GalleryImages; // ... } } } Here we specify that these fields will be handled by ImageUploadEditor and MultipleImageUploadEditor types. FilenameFormat specifies the naming of uploaded files. For example, Person primary image will be uploaded to a folder under App_Data/upload/Person/PrimaryImage/. You may change upload root (App_Data/upload) to anything you like by modifying UploadSettings appSettings key in web.config. ~ at the end of FilenameFormat is a shortcut for the automatic naming scheme {1:00000}/{0:00000000}_{2} . Here, parameter {0} is replaced with identity of the record, e.g. PersonID. Parameter {1} is identity / 1000. This is useful to limit number of files that is stored in one directory. 167 Adding Primary and Gallery Images Parameter {2} is a unique string like 6l55nk6v2tiyi, which is used to generate a new file name on every upload. This helps to avoid problems caused by caching on client side. It also provides some security so file names can't be known without having a link. Thus, a file we upload for person primary image will be located at a path like this: > App_Data\upload\Person\PrimaryImage\00000\00000001_6l55nk6v2tiyi.jpg You don't have to follow this naming scheme. You can specify your own format like PersonPrimaryImage_{0}_{2} . Next step is to add these fields to forms (MovieForm.cs and PersonForm.cs): namespace MovieTutorial.MovieDB.Forms { //... public class PersonForm { public String Firstname { get; set; } public String Lastname { get; set; } public String PrimaryImage { get; set; } public String GalleryImages { get; set; } public DateTime BirthDate { get; set; } public String BirthPlace { get; set; } public Gender Gender { get; set; } public Int32 Height { get; set; } } } 168 Adding Primary and Gallery Images namespace MovieTutorial.MovieDB.Forms { //... public class MovieForm { public String Title { get; set; } [TextAreaEditor(Rows = 3)] public String Description { get; set; } [MovieCastEditor] public List CastList { get; set; } public String PrimaryImage { get; set; } public String GalleryImages { get; set; } [TextAreaEditor(Rows = 8)] public String Storyline { get; set; } public Int32 Year { get; set; } public DateTime ReleaseDate { get; set; } public Int32 Runtime { get; set; } public Int32 GenreId { get; set; } public MovieKind Kind { get; set; } } } I also modified Person dialog css a bit to have more space: .s-MovieDB-PersonDialog { > .size { width: 700px; height: 600px; } .caption { width: 150px; } .s-PersonMovieGrid > .grid-container { height: 500px; } } This is what we get now: 169 Adding Primary and Gallery Images ImageUploadEditor stores file name directly in a string field, while MultipleImageUpload editor stores file names in a string field with JSON array format. Removing Northwind and Other Samples As i think our project has reached a good state, i'm now going to remove Northwind and other samples from MovieTutorial project. See following how-to topic: How To: Removing Northwind and Other Samples 170 Multi Tenancy Multi Tenancy In this tutorial we are going to turn Norhwind into a multi-tenant application. Here is a definition of multi-tenant sofware from Wikipedia: Software Multitenancy refers to a software architecture in which a single instance of a software runs on a server and serves multiple tenants. A tenant is a group of users who share a common access with specific privileges to the software instance. With a multitenant architecture, a software application is designed to provide every tenant a dedicated share of the instance including its data, configuration, user management, tenant individual functionality and non-functional properties. Multitenancy contrasts with multi-instance architectures, where separate software instances operate on behalf of different tenants. ---Wikipedia We'll add a TenantId field to every table, including Users, and let user see and modify only records belonging to her tenant. So, tenants will work in isolation, as if they are working with their own database. Multi tenant applications has some advantages like reduced cost of management. But they also have some disadvantages. For example, as all tenant data is in a single database, a tenant can't simply take or backup her data alone. Performance is usually reduced as there are more records to handle. With increasing trend of cloud applications, decreased cost of virtualization, and with features like migration, its now easier to setup multi-instance apps. I'd personally avoid multi-tenant applications. It's better to have one database per customer in my opinion. But some users asked about how to implement this feature. This tutorial will help us explain some advanced Serenity topics as a bonus, along with multi tenancy. You can find source code for this tutorial at: https://github.com/volkanceylan/MultiTenancy Create a new project named MultiTenancy In Visual Studio click File -> New Project. Make sure you choose Serene template. Type MultiTenancy as name and click OK. In Solution explorer, you should see a project with name MultiTenancy.Web. 171 Multi Tenancy 172 Adding Tenants Table and TenantId Field Adding Tenants Table and TenantId Field We need to add a TenantId field to all tables, to isolate tenants from each other. So, we first need a Tenants table. As Northwind tables already have records, we'll define a primary tenant with ID 1, and set all existing records TenantId to it. It's time to write a migration, actually two migrations, one for Northwind and one for Default database. DefaultDB_20170430_134800_MultiTenant.cs: 173 Adding Tenants Table and TenantId Field using FluentMigrator; namespace MultiTenancy.Migrations.DefaultDB { [Migration(20170430134800)] public class DefaultDB_20170430_134800_MultiTenant : AutoReversingMigration { public override void Up() { this.CreateTableWithId32("Tenants", "TenantId", s => s .WithColumn("TenantName").AsString(100) .NotNullable()); Insert.IntoTable("Tenants") .Row(new { TenantName = "Primary Tenant" }); Insert.IntoTable("Tenants") .Row(new { TenantName = "Second Tenant" }); Insert.IntoTable("Tenants") .Row(new { TenantName = "Third Tenant" }); Alter.Table("Users") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Roles") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Languages") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); } } } I have created Tenants table in Default database where user tables are. Here we add 3 predefined tenants. We actually only need first one with ID 1. 174 Adding Tenants Table and TenantId Field We didn't add TenantId column to tables like UserPermissions, UserRoles, RolePermissions etc, as they instrinsicly have TenantId information through their UserId or RoleId (as these tables already have TenantId value) Let's write another migration for Nortwhind database to add TenantId column to required tables: NorthwindDB_20160110_093500_MultiTenant.cs: 175 Adding Tenants Table and TenantId Field using FluentMigrator; namespace MultiTenancy.Migrations.NorthwindDB { [Migration(20170430194100)] public class NorthwindDB_20170430_194100_MultiTenant : AutoReversingMigration { public override void Up() { Alter.Table("Employees") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Categories") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Customers") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Shippers") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Suppliers") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Orders") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Products") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Region") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); Alter.Table("Territories") .AddColumn("TenantId").AsInt32() .NotNullable().WithDefaultValue(1); } } } 176 Adding Tenants Table and TenantId Field 177 Generating Code for Tenants Table Generating Code for Tenants Table Launch Sergen and generate code for Tenants table in Default connection: Next we'll define a lookup script in TenantRow and set DisplayName property to Tenants: namespace MultiTenancy.Administration.Entities { //... [ConnectionKey("Default"), DisplayName("Tenants"), InstanceName("Tenant"), TwoLevelCached] [LookupScript("Administration.Tenant")] public sealed class TenantRow : Row, IIdRow, INameRow { [DisplayName("Tenant Id"), Identity] public Int32? TenantId { get { return Fields.TenantId[this]; } set { Fields.TenantId[this] = value; } } //... Let's define a Administration:Tenants permission that only admin user will have (in AdministrationPermissionKeys.cs): 178 Generating Code for Tenants Table namespace MultiTenancy.Administration { public class PermissionKeys { public const string Security = "Administration:Security"; public const string Translation = "Administration:Translation"; public const string Tenants = "Administration:Tenants"; } } And use it on TenantRow: [ConnectionKey("Default"), DisplayNahme("Tenants"), InstanceName("Tenant"), TwoLevelCached] [ReadPermission(PermissionKeys.Tenants)] [ModifyPermission(PermissionKeys.Tenants)] [LookupScript("Administration.Tenant")] public sealed class TenantRow : Row, IIdRow, INameRow { 179 Tenant Selection in User Dialog Tenant Selection in User Dialog We added a TenantId field to Users table, but it's not defined in UserRow, and not visible in user dialog. This field, should only be seen and edited by admin user. Other users, even if we give them access to users page to manage their tenant users, shouldn't be able to see or change this information. Let's first add it to UserRow.cs: 180 Tenant Selection in User Dialog namespace MultiTenancy.Administration.Entities { //... public sealed class UserRow : LoggingRow, IIdRow, INameRow { //... [DisplayName("Last Directory Update"), Insertable(false), Updatable(false)] public DateTime? LastDirectoryUpdate { get { return Fields.LastDirectoryUpdate[this]; } set { Fields.LastDirectoryUpdate[this] = value; } } [DisplayName("Tenant"), ForeignKey("Tenants", "TenantId"), LeftJoin("tnt")] [LookupEditor(typeof(TenantRow))] public Int32? TenantId { get { return Fields.TenantId[this]; } set { Fields.TenantId[this] = value; } } [DisplayName("Tenant"), Expression("tnt.TenantName")] public String TenantName { get { return Fields.TenantName[this]; } set { Fields.TenantName[this] = value; } } //... public class RowFields : LoggingRowFields { //... public readonly DateTimeField LastDirectoryUpdate; public readonly Int32Field TenantId; public readonly StringField TenantName; //... } } } To edit it, we need to add it to UserForm.cs: 181 Tenant Selection in User Dialog namespace MultiTenancy.Administration.Forms { using Serenity; using Serenity.ComponentModel; using System; using System.ComponentModel; [FormScript("Administration.User")] [BasedOnRow(typeof(Entities.UserRow))] public class UserForm { public String Username { get; set; } public String DisplayName { get; set; } [EmailEditor] public String Email { get; set; } [PasswordEditor] public String Password { get; set; } [PasswordEditor, OneWay] public String PasswordConfirm { get; set; } [OneWay] public string Source { get; set; } public Int32? TenantId { get; set; } } } Need to also increase size of user dialog a bit, in site.administration.less to make space for tenant selection: .s-Administration-UserDialog { > .size { width: 650px; } .caption { width: 150px; } .s-PropertyGrid .categories { height: 470px; } } Now open User Management page and create a user tenant2 that belongs to Second Tenant. 182 Tenant Selection in User Dialog After creating this user, edit its permissions and grant him User, Role Management and Permissions permission as this will be our administrative user for Second Tenant. Logging In With Tenant2 Signout and login with user tenant2. When you open User Management page, there may be two different cases you may experience. In first case, tenant2 might be able to open user dialog and change his and any other users tenant. This happens if your browser cached the tenant lookup. In the second case, you'll see that tenant2 can't open User dialog. When you click a user nothing happens. If you check browser console (whenever such a thing occurs, you should first check browser console for errors), you'll see an error like this: 183 Tenant Selection in User Dialog This is because, our TenantRow has Administration:Tenants read permission which is inherited by lookup script. We could change read permission for tenant lookup script to something else to resolve this error, but in that case Tenant2 would be able to see and change tenant of himself and any other user including admin. This is not what we wanted. Let's first prevent him seeing users of other tenants. 184 Filtering Users By TenantId Filtering Users By TenantId We first need to load and cache user tenant information in UserDefinition. Open UserDefinition.cs under Multitenancy.Web/ Modules/ Administration/ User/ Authentication and add a TenantId property. namespace MultiTenancy.Administration { using Serenity; using System; [Serializable] public class UserDefinition : IUserDefinition { public string Id { get { return UserId.ToInvariant(); } } public string DisplayName { get; set; } public string Email { get; set; } public short IsActive { get; set; } public int UserId { get; set; } public string Username { get; set; } public string PasswordHash { get; set; } public string PasswordSalt { get; set; } public string Source { get; set; } public DateTime? UpdateDate { get; set; } public DateTime? LastDirectoryUpdate { get; set; } public int TenantId { get; set; } } } This is the class that is returned when you ask for current user through Authorization.UserDefinition. We also need to modify the code where this class is loaded. In the same folder, edit UserRetrieveService.cs and change GetFirst method like below: 185 Filtering Users By TenantId private UserDefinition GetFirst(IDbConnection connection, BaseCriteria criteria) { var user = connection.TrySingle(criteria); if (user != null) return new UserDefinition { UserId = user.UserId.Value, Username = user.Username, Email = user.Email, DisplayName = user.DisplayName, IsActive = user.IsActive.Value, Source = user.Source, PasswordHash = user.PasswordHash, PasswordSalt = user.PasswordSalt, UpdateDate = user.UpdateDate, LastDirectoryUpdate = user.LastDirectoryUpdate, TenantId = user.TenantId.Value }; return null; } Now, it's time to filter listed users by TenantId. Open UserRepository.cs, locate MyListHandler class and modify it like this: private class MyListHandler : ListRequestHandler { protected override void ApplyFilters(SqlQuery query) { base.ApplyFilters(query); var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fld.TenantId == user.TenantId); } } Here, we first get a reference to cached user definition of currently logged user. We check if he has tenant administration permission, which only admin will have in the end. If not, we filter listed records by TenantId. 186 Removing Tenant Dropdown From User Form Removing Tenant Dropdown From User Form After you rebuild, and launch, now user page will be like this: Yes, he can't see admin user anymore, but something is wrong. When you click tenant2, nothing will happen and you'll get an error "Can't load script data: Lookup.Administration.Tenant" in browser console: This error is not related to our recent filtering at repository level. It can't load this lookup script, because current user has no permission to Tenants table. But how did he see it last time (in one case)? He could see it, because we first logged in as admin and when we open edit dialog for user, we loaded this lookup script. Browser cached it, so when we logged in with tenant2 and open edit dialog, it loaded tenants from browser cache. But this time, as we rebuild project, browser tried to load it from server, and we got this error, as tenant2 doesn't have this permission. It's ok, we don't want him to have this permission, but how to avoid this error? 187 Removing Tenant Dropdown From User Form We need to remove Tenant field from the user form. But we need that field for admin user, so we can't simply delete it from UserForm.cs. Thus, we need to do it conditionally. Build the project, transform all templates and add method below to UserDialog.ts: protected getPropertyItems() { var items = super.getPropertyItems(); if (!Q.Authorization.hasPermission("Administration:Tenants")) items = items.filter(x => x.name != UserRow.Fields.TenantId); return items; } Dialogs gets list of fields it will show in its form by getPropertyItems method, which in turn loads them from server side form definition. Here we exclude TenantId field, if current user doesn't have the tenants permission. This doesn't modify the original user form, it just changes list for this dialog instance. User tenant2 can now open the user dialog. 188 Securing Tenant Selection At Server Side Securing Tenant Selection At Server Side When you log in with tenant2 user and open its edit form, Tenant selection dropdown is not displayed, so he can't change his tenant right? Wrong! If he is an ordinary user, he can't. But if he has some knowledge of how Serenity and its services work, he could. When you are working with web, you got to take security much more seriously. It's very easy to create security holes in web applications unless you handle validations both at client side and server side. Let's demonstrate it. Open Chrome console, while logged in with user tenant2. Copy and paste this into console: Q.serviceCall({ service: 'Administration/User/Update', request: { EntityId: 2, Entity: { UserId: 2, TenantId: 1 } } }); Now refresh the user management page, you'll see that tenant2 can see admin user now! We called User Update service with javascript, and changed tenant2 user TenaNntId to 1 (Primary Tenant). Let's revert it back to Second Tenant (2) first, then we'll fix this security hole: 189 Securing Tenant Selection At Server Side Q.serviceCall({ service: 'Administration/User/Update', request: { EntityId: 2, Entity: { UserId: 2, TenantId: 2 } } }); Luckily, Serenity provides field level permissions. Edit UserRow.cs to let only users with Administration:Tenants permission to see and edit tenant information. [LookupEditor(typeof(TenantRow))] [ReadPermission(PermissionKeys.Tenants)] public Int32? TenantId { get { return Fields.TenantId[this]; } set { Fields.TenantId[this] = value; } } Now only admin can see and update tenant field for users. We didn't have to also set ModifyPermission as if a user doesn't have the read permission, he doesn't have the write permission by default. Build your project, then try typing this into console again: Q.serviceCall({ service: 'Administration/User/Update', request: { EntityId: 2, Entity: { UserId: 2, TenantId: 1 } } }); You will now get this error: Tenant field is read only! 190 Securing Tenant Selection At Server Side 191 Setting TenantId For New Users Setting TenantId For New Users While logged in with Tenant2, try to create a new user, User2. You won't get any error but by suprise, you won't see the newly created user in list. What happened to User2? As we set default value for TenantId to 1 in migrations, now User2 has 1 as TenantId and is a member of Primary Tenant. We have to set new users TenantId to same value with logged in user. Modify SetInternalFields method of UserRepository like below: protected override void SetInternalFields() { base.SetInternalFields(); if (IsCreate) { Row.Source = "site"; Row.IsActive = Row.IsActive ?? 1; if (!Authorization.HasPermission(Administration.PermissionKeys.Tenants) || Row.TenantId == null) { Row.TenantId = ((UserDefinition)Authorization.UserDefinition) .TenantId; } } if (IsCreate || !Row.Password.IsEmptyOrNull()) { string salt = null; Row.PasswordHash = GenerateHash(password, ref salt); Row.PasswordSalt = salt; } } Here, we set TenantId to the same value with current user, unless he has tenant administration permission. Now try to create a new user User2b and this time you'll see him on the list. 192 Preventing Edits To Users From Other Tenants Preventing Edits To Users From Other Tenants Remember that user tenant2 could update his TenantId with some service call, and we had to secure it server side. Similar to this, even if he can't see users from other tenants by default, he can actually retrieve and update them. Time to hack again. Open Chrome console and type this: new MultiTenancy.Administration.UserDialog().loadByIdAndOpenDialog(1) What? He could open user dialog for admin and update it! MultiTenancy.Administration.UserDialog is the dialog class that is opened when you click a username in user administration page. We created a new instance of it, and asked to load a user entity by its ID. Admin user has an ID of 1. So, to load the entity with ID 1, dialog called Retrieve service of UserRepository. Remember that we did filtering in List method of UserRepository, not Retrieve. So, service has no idea, if it should return this record from another tenant, or not. It's time to secure retrieve service in UserRepository: private class MyRetrieveHandler : RetrieveRequestHandler { protected override void PrepareQuery(SqlQuery query) { base.PrepareQuery(query); var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fld.TenantId == user.TenantId); } } We did same changes in MyListHandler before. 193 Preventing Edits To Users From Other Tenants If you try same Javascript code now, you'll get an error: Record not found. It might be deleted or you don't have required permissions! But, we could still update record calling Update service manually. So, need to secure MySaveHandler too. Change its ValidateRequest method like this: protected override void ValidateRequest() { base.ValidateRequest(); if (IsUpdate) { var user = (UserDefinition)Authorization.UserDefinition; if (Old.TenantId != user.TenantId) Authorization.ValidatePermission(PermissionKeys.Tenants); // ... Here we check if it's an update, and if TenantId of record being updated (Old.TenantId) is different than currently logged user's TenantId. If so, we call Authorization.ValidatePermission method to ensure that user has tenant administration permission. It will raise an error if not. Authorization has been denied for this request! Preventing To Delete Users From Other Tenants There are delete and undelete handlers in UserRepository, and they suffer from similar security holes. Using similar methods, we need to secure them too: 194 Preventing Edits To Users From Other Tenants private class MyDeleteHandler : DeleteRequestHandler { protected override void ValidateRequest() { base.ValidateRequest(); var user = (UserDefinition)Authorization.UserDefinition; if (Row.TenantId != user.TenantId) Authorization.ValidatePermission(PermissionKeys.Tenants); } } private class MyUndeleteHandler : UndeleteRequestHandler { protected override void ValidateRequest() { base.ValidateRequest(); var user = (UserDefinition)Authorization.UserDefinition; if (Row.TenantId != user.TenantId) Authorization.ValidatePermission(PermissionKeys.Tenants); } } 195 Hiding the Tenant Administration Permission Hiding the Tenant Administration Permission We now have one little problem. User tenant2 has permission Administration:Security so he can access user and role permission dialogs. Thus, he can grant himself Administration:Tenants permission using the permission UI. Serenity scans your assemblies for attributes like ReadPermission, WritePermission, PageAuthorize, ServiceAuthorize etc. and lists these permissions in edit permissions dialog. We should first remove it from this pre-populated list. Find method, ListPermissionKeys in UserPermissionRepository.cs: 196 Hiding the Tenant Administration Permission public ListResponse ListPermissionKeys() { return LocalCache.Get("Administration:PermissionKeys", TimeSpan.Zero, () => { //... result.Remove(Administration.PermissionKeys.Tenants); result.Remove("*"); result.Remove("?"); //... Now, this permission won't be listed in Edit User Permissions or Edit Role Permissions dialog. But, still, he can grant this permission to himself, by some little hacking through UserPermissionRepository.Update or RolePermissionRepository.Update methods. We should add some checks to prevent this: public class UserPermissionRepository { public SaveResponse Update(IUnitOfWork uow, UserPermissionUpdateRequest request) { //... var newList = new Dictionary( StringComparer.OrdinalIgnoreCase); foreach (var p in request.Permissions) newList[p.PermissionKey] = p.Grant ?? false; var allowedKeys = ListPermissionKeys() .Entities.ToDictionary(x => x); if (newList.Keys.Any(x => !allowedKeys.ContainsKey(x))) throw new AccessViolationException(); //... 197 Hiding the Tenant Administration Permission public class RolePermissionRepository { public SaveResponse Update(IUnitOfWork uow, RolePermissionUpdateRequest request) { //... var newList = new HashSet( request.Permissions.ToList(), StringComparer.OrdinalIgnoreCase); var allowedKeys = new UserPermissionRepository() .ListPermissionKeys() .Entities.ToDictionary(x => x); if (newList.Any(x => !allowedKeys.ContainsKey(x))) throw new AccessViolationException(); //... Here we check if any of the new permission keys that are tried to be granted, are not listed in permission dialog. If so, this is probably a hack attempt. Actually this check should be the default, even without multi-tenant systems, but usually we trust administrative users. Here, administrators will be only managing their own tenants, so we certainly need this check. 198 Making Roles Multi-Tenant Making Roles Multi-Tenant So far, we have made users page work in multi-tenant style. Seems like we did too many changes to make it work. But remember that we are trying to turn a system that is not designed to be multi-tenant into such one. Let's apply similar principles to the Roles table. Again, a user in one tenant shouldn't see or modify roles in other tenants and work in isolation. We start by adding TenantId property to RoleRow.cs: namespace MultiTenancy.Administration.Entities { //... public sealed class RoleRow : Row, IIdRow, INameRow { [Insertable(false), Updatable(false)] public Int32? TenantId { get { return Fields.TenantId[this]; } set { Fields.TenantId[this] = value; } } //... public class RowFields : RowFieldsBase { //... public Int32Field TenantId; //... } } } Then we'll do several changes in RoleRepository.cs: private class MySaveHandler : SaveRequestHandler { protected override void SetInternalFields() { base.SetInternalFields(); if (IsCreate) Row.TenantId = ((UserDefinition)Authorization.UserDefinition).TenantId; } 199 Making Roles Multi-Tenant protected override void ValidateRequest() { base.ValidateRequest(); if (IsUpdate) { var user = (UserDefinition)Authorization.UserDefinition; if (Old.TenantId != user.TenantId) Authorization.ValidatePermission(PermissionKeys.Tenants); } } } private class MyDeleteHandler : DeleteRequestHandler { protected override void ValidateRequest() { base.ValidateRequest(); var user = (UserDefinition)Authorization.UserDefinition; if (Row.TenantId != user.TenantId) Authorization.ValidatePermission(PermissionKeys.Tenants); } } private class MyRetrieveHandler : RetrieveRequestHandler { protected override void PrepareQuery(SqlQuery query) { base.PrepareQuery(query); var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fld.TenantId == user.TenantId); } } private class MyListHandler : ListRequestHandler { protected override void ApplyFilters(SqlQuery query) { base.ApplyFilters(query); var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fld.TenantId == user.TenantId); } } 200 Making Roles Multi-Tenant 201 Using Serenity Service Behaviors Using Serenity Service Behaviors If wanted to extend this multi-tenant system to other tables in Northwind, we would repeat same steps we did with Roles. Though it doesn't look so hard, it's too much of manual work. Serenity provides service behavior system, which allows you to intercept Create, Update, Retrieve, List, Delete handlers and add custom code to them. Some operations in these handlers, like capture log, unique constraint validation etc. are already implemented as service behaviors. Behaviors might be activated for all rows, or based on some rule, like having a specific attribute or interface. For example, CaptureLogBehavior activates for rows with [CaptureLog] attribute. We'll first define an interface IMultiTenantRow that will trigger our new behavior. Place this class in file IMultiTenantRow.cs, next to TenantRow.cs: using Serenity.Data; namespace MultiTenancy { public interface IMultiTenantRow { Int32Field TenantIdField { get; } } } Than add this behavior in file MultiTenantBehavior.cs next to it: using MultiTenancy.Administration; using Serenity; using Serenity.Data; using Serenity.Services; namespace MultiTenancy { public class MultiTenantBehavior : IImplicitBehavior, ISaveBehavior, IDeleteBehavior, IListBehavior, IRetrieveBehavior { private Int32Field fldTenantId; public bool ActivateFor(Row row) { var mt = row as IMultiTenantRow; 202 Using Serenity Service Behaviors if (mt == null) return false; fldTenantId = mt.TenantIdField; return true; } public void OnPrepareQuery(IRetrieveRequestHandler handler, SqlQuery query) { var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fldTenantId == user.TenantId); } public void OnPrepareQuery(IListRequestHandler handler, SqlQuery query) { var user = (UserDefinition)Authorization.UserDefinition; if (!Authorization.HasPermission(PermissionKeys.Tenants)) query.Where(fldTenantId == user.TenantId); } public void OnSetInternalFields(ISaveRequestHandler handler) { if (handler.IsCreate) fldTenantId[handler.Row] = ((UserDefinition)Authorization .UserDefinition).TenantId; } public void OnValidateRequest(ISaveRequestHandler handler) { if (handler.IsUpdate) { var user = (UserDefinition)Authorization.UserDefinition; if (fldTenantId[handler.Old] != fldTenantId[handler.Row]) Authorization.ValidatePermission(PermissionKeys.Tenants); } } public void OnValidateRequest(IDeleteRequestHandler handler) { var user = (UserDefinition)Authorization.UserDefinition; if (fldTenantId[handler.Row] != user.TenantId) Authorization.ValidatePermission( PermissionKeys.Tenants); } public void OnAfterDelete(IDeleteRequestHandler handler) { } public void OnAfterExecuteQuery(IRetrieveRequestHandler handler) { } public void OnAfterExecuteQuery(IListRequestHandler handler) { } public void OnAfterSave(ISaveRequestHandler handler) { } 203 Using Serenity Service Behaviors public void OnApplyFilters(IListRequestHandler handler, SqlQuery query) { } public void OnAudit(IDeleteRequestHandler handler) { } public void OnAudit(ISaveRequestHandler handler) { } public void OnBeforeDelete(IDeleteRequestHandler handler) { } public void OnBeforeExecuteQuery(IRetrieveRequestHandler handler) { } public void OnBeforeExecuteQuery(IListRequestHandler handler) { } public void OnBeforeSave(ISaveRequestHandler handler) { } public void OnPrepareQuery(IDeleteRequestHandler handler, SqlQuery query) { } public void OnPrepareQuery(ISaveRequestHandler handler, SqlQuery query) { } public void OnReturn(IDeleteRequestHandler handler) { } public void OnReturn(IRetrieveRequestHandler handler) { } public void OnReturn(IListRequestHandler handler) { } public void OnReturn(ISaveRequestHandler handler) { } public void OnValidateRequest(IRetrieveRequestHandler handler) { } public void OnValidateRequest(IListRequestHandler handler) { } } } Behavior classes with IImplicitBehavior interface decide if they should be activated for a specific row type. They do this by implementing ActivateFor method, which is called by request handlers. In this method, we check if row type implements IMultiTenantRow interface. If not it simply returns false. Then we get a private reference to TenantIdField to reuse it later in other methods. ActivateFor is only called once per every handler type and row. If this method returns true, behavior instance is cached aggresively for performance reasons, and reused for any request for this row and handler type. Thus, everything you write in other methods must be thread-safe, as one instance is shared by all requests. A behavior, might intercept one or more of Retrieve, List, Save, Delete handlers. It does this by implementing IRetrieveBehavior, IListBehavior, ISaveBehavior, or IDeleteBehavior interfaces. Here, we need to intercept all of these service calls, so we implement all interfaces. We only fill in methods we are interested in, and leave others empty. The methods we implement here, corresponds to methods we override in RoleRepository.cs in previous section. The code they contain is almost same, except here we need to be more generic, as this behavior will work for any row type implementing IMultiTenantRow. 204 Using Serenity Service Behaviors Reimplementing RoleRepository With Using the Behavior Now revert every change we made in RoleRepository.cs: private class MySaveHandler : SaveRequestHandler { } private class MyDeleteHandler : DeleteRequestHandler { } private class MyRetrieveHandler : RetrieveRequestHandler { } private class MyListHandler : ListRequestHandler { } And add IMultiTenantRow interface to RoleRow: namespace MultiTenancy.Administration.Entities { //... public sealed class RoleRow : Row, IIdRow, INameRow, IMultiTenantRow { //... public Int32Field TenantIdField { get { return Fields.TenantId; } } //... } } You should get the same result with much less code. Declarative programming is almost always better. 205 Extending Multi-Tenant Behavior To Northwind Extending Multi-Tenant Behavior To Northwind As now we have a behavior handling repository details, we just need to add IMultiTenantRow interface to rows and add TenantId property. Start with SupplierRow.cs: namespace MultiTenancy.Northwind.Entities { //... public sealed class SupplierRow : Row, IIdRow, INameRow, IMultiTenantRow { //... [Insertable(false), Updatable(false)] public Int32? TenantId { get { return Fields.TenantId[this]; } set { Fields.TenantId[this] = value; } } public Int32Field TenantIdField { get { return Fields.TenantId; } } //... public class RowFields : RowFieldsBase { //... public readonly Int32Field TenantId; } } } When you these changes in SupplierRow and build, you'll see that tenant2 can't see suppliers from other tenants in suppliers page. Now repeat these for EmployeeRow, CategoryRow, CustomerRow, ShipperRow, OrderRow, ProductRow, RegionRow and TerritoryRow. 206 Extending Multi-Tenant Behavior To Northwind 207 Handling Lookup Scripts Handling Lookup Scripts If we open Suppliers page now, we'll see that tenant2 can only see suppliers that belongs to its tenant. But on top right of the grid, in country dropdown, all countries are listed: This data is feed to script side through a dynamic script. It doesn't load this data with List services we handled recently. The lookup script that produces this dropdown is defined in SupplierCountryLookup.cs: 208 Handling Lookup Scripts namespace MultiTenancy.Northwind.Scripts { using Serenity.ComponentModel; using Serenity.Data; using Serenity.Web; [LookupScript("Northwind.SupplierCountry")] public class SupplierCountryLookup : RowLookupScript { public SupplierCountryLookup() { IdField = TextField = "Country"; } protected override void PrepareQuery(SqlQuery query) { var fld = Entities.SupplierRow.Fields; query.Distinct(true) .Select(fld.Country) .Where( new Criteria(fld.Country) != "" & new Criteria(fld.Country).IsNotNull()); } protected override void ApplyOrder(SqlQuery query) { } } } We couldn't use a simple [LookupScript] attribute on a row class here, because there is actually no country table in Northwind database. We are collecting country names from existing records in Supplier table using distinct. We should filter its query by current tenant. But this lookup class derives from RowLookupScript base class. Let's create a new base class, to prepare for other lookup scripts that we'll have to handle later. 209 Handling Lookup Scripts namespace MultiTenancy.Northwind.Scripts { using Administration; using Serenity; using Serenity.Data; using Serenity.Web; using System; public class MultiTenantRowLookupScript : RowLookupScript where TRow : Row, IMultiTenantRow, new() { public MultiTenantRowLookupScript() { Expiration = TimeSpan.FromDays(-1); } protected override void PrepareQuery(SqlQuery query) { base.PrepareQuery(query); AddTenantFilter(query); } protected void AddTenantFilter(SqlQuery query) { var r = new TRow(); query.Where(r.TenantIdField == ((UserDefinition)Authorization.UserDefinition).TenantId); } public override string GetScript() { return TwoLevelCache.GetLocalStoreOnly("MultiTenantLookup:" + this.ScriptName + ":" + ((UserDefinition)Authorization.UserDefinition).TenantId, TimeSpan.FromHours(1), new TRow().GetFields().GenerationKey, () => { return base.GetScript(); }); } } } This will be our base class for multi-tenant lookup scripts. We first set expiration to a negative timespan to disable caching. Why do we have to do this? Because dynamic script manager caches lookup scripts by their keys. But we'll have multiple versions of a lookup script based on TenantId values. 210 Handling Lookup Scripts We'll turn off caching at dynamic script manager level and handle caching ourself in GetScript method. In GetScript method, we are using TwoLevelCache.GetLocalStoreOnly to call base method, that generates our lookup script, and cache its result with a cache key including TenantId. See relevant section for more info about TwoLevelCache class. By overriding, PrepareQuery method, we are adding a filter by current TenantId, just like we did in list service handlers. Now its time to rewrite our SupplierCountryLookup using this new base class: namespace MultiTenancy.Northwind.Scripts { using Serenity.ComponentModel; using Serenity.Data; using Serenity.Web; [LookupScript("Northwind.SupplierCountry")] public class SupplierCountryLookup : MultiTenantRowLookupScript { public SupplierCountryLookup() { IdField = TextField = "Country"; } protected override void PrepareQuery(SqlQuery query) { var fld = Entities.SupplierRow.Fields; query.Distinct(true) .Select(fld.Country) .Where( new Criteria(fld.Country) != "" & new Criteria(fld.Country).IsNotNull()); AddTenantFilter(query); } protected override void ApplyOrder(SqlQuery query) { } } } We just called AddTenantFilter method manually, because we weren't calling base PrepareQuery method here (so it won't be called by base class). Please first delete Northwind.DynamicScripts.cs file, if you have it. 211 Handling Lookup Scripts There are several more similar lookup scripts in CustomerCountryLookup, CustomerCityLookup, OrderShipCityLookup, OrderShipCountryLookup. I'll do similar changes in them. Change base class to MultiTenantRowLookupScript and call AddTenantFilter in PrepareQuery method. Lookup Script Declarations On Rows We now have one more problem to solve. If you open Orders page, you'll see that Ship Via and Employee filter dropdowns still lists records from other tenants. It is because we defined their lookup scripts by a [LookupScript] attribute on their rows. By default, LookupScript generates a lookup instance based on We need to change it to MultiTenantRowLookupScript<> RowLookupScript<> type. for these multi-tenant rows. Let's fix employee lookup first. Replace [LookupScript] attribute like below in EmployeeRow. [LookupScript("Northwind.Employee", LookupType = typeof(MultiTenantRowLookupScript<>))] public sealed class EmployeeRow : Row, IIdRow, INameRow, IMultiTenantRow { //... Note that this requires Serenity 2.9.22+ Do similar (add LookupType) for Shipper, Product, Supplier, Category, Region and Territory rows. Now Northwind supports multi-tenancy. There might be some glitches i missed, report in Serenity Github repository if any. 212 Meeting Management Meeting Management (In Progress...) In this tutorial we are going to develop a meeting management system that will help us keep a track of corporate meetings. We'll first plan a meeting, with its location, time, agenda and attendees, then send an invitation to those attendees with an e-mail. Application will also let us store decisions taken in the meeting, and will inform attendees with a meeting report e-mail containing these decisions. Code for this tutorial will be available at: https://github.com/volkanceylan/MeetingManagement Creating Project Start by creating a new project using Serene template, and name it MeetingManagement. Removing Northwind Remove Northwind using the how-to guide. 213 Creating Lookup Tables Creating Lookup Tables Let's start by creating lookup tables we'll need. Here is a list of these tables: Meeting Types (Board Meeting, Weekly Analytics, SCRUM Meeting, Annual Meeting, so on...) Locations (where meeting will be held, room numbers, address etc.) Agenda Types (what subject(s) an agenda is about, might be multiple) Units (which unit is organizing the meeting) Contacts (people which would attend meetings, reporters, managers etc.) We'll use database schema met for tables. Create a new migration under, Modules/Common/Migrations/DefaultDB with name DefaultDB_20160709_232400_MeetingLookups: 214 Creating Lookup Tables using FluentMigrator; namespace MeetingManagement.Migrations.DefaultDB { [Migration(20160709232400)] public class DefaultDB_20160709_232400_MeetingLookups : AutoReversingMigration { public override void Up() { Create.Schema("met"); Create.Table("AgendaTypes").InSchema("met") .WithColumn("AgendaTypeId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Name").AsString(100).NotNullable(); Create.Table("Contacts").InSchema("met") .WithColumn("ContactId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Title").AsString(30).Nullable() .WithColumn("FirstName").AsString(50).NotNullable() .WithColumn("LastName").AsString(50).NotNullable() .WithColumn("Email").AsString(100).NotNullable(); Create.Table("Locations").InSchema("met") .WithColumn("LocationId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Name").AsString(100).NotNullable() .WithColumn("Address").AsString(300).Nullable() .WithColumn("Latitude").AsDouble() .WithColumn("Longitude").AsDouble(); Create.Table("MeetingTypes").InSchema("met") .WithColumn("MeetingTypeId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Name").AsString(100).NotNullable(); Create.Table("Units").InSchema("met") .WithColumn("UnitId").AsInt32() .Identity().PrimaryKey().NotNullable() .WithColumn("Name").AsString(100).NotNullable(); } } } Generating Code for Lookup Tables Our module name will be Meetings. We should use non-plural entity identifiers for generated code: 215 Creating Lookup Tables AgendaTypes => AgendaType Contacts => Contact Locations => Location MeetingTypes => MeetingType Units => Unit Generate code for these 5 tables using the entity identifiers given above: Generated interface for these tables is fine enough. Just need to do a few cosmetic touches. 216 Creating Lookup Tables Moving Navigation Links to NavigationItems.cs Open AgendaTypePage.cs, ContactPage.cs, LocationPage.cs, MeetingTypePage.cs and UnitPage.cs files and move navigation links at top of them to NavigationItems.cs: using Serenity.Navigation; using Administration = MeetingManagement.Administration.Pages; using Meeting = MeetingManagement.Meeting.Pages; [assembly: NavigationLink(1000, "Dashboard", url: "~/", permission: "", icon: "icon-speedometer")] [assembly: NavigationMenu(2000, "Meeting")] [assembly: NavigationLink(2500, "Meeting/Agenda Types", typeof(Meeting.AgendaTypeController))] [assembly: NavigationLink(2600, "Meeting/Contacts", typeof(Meeting.ContactController))] [assembly: NavigationLink(2700, "Meeting/Locations", typeof(Meeting.LocationController))] [assembly: NavigationLink(2800, "Meeting/Meeting Types", typeof(Meeting.MeetingTypeController))] [assembly: NavigationLink(2900, "Meeting/Units", typeof(Meeting.UnitController))] Setting DisplayName and InstanceName Attributes of Lookup Tables Open AgendaTypeRow.cs, ContactRow.cs, LocationRow.cs, MeetingTypeRow.cs and UnitRow.cs files and change DisplayName and InstanceName attributes like below: AgendaTypeRow => "Agenda Types", "Agenda Type" 217 Creating Lookup Tables ContactRow => "Contacts", "Contact" LocationRow => "Locations", "Location" MeetingTypeRow => "Meeting Types", "Meeting Type" UnitRow => "Units", "Unit" [ConnectionKey("Default"), TwoLevelCached, DisplayName("Agenda Types"), InstanceName("Agenda Type")] [ReadPermission("Meeting")] [ModifyPermission("Meeting")] public sealed class AgendaTypeRow : Row, IIdRow, INameRow { 218 How To Guides How To Guides 219 How To: Remove Northwind & Other Samples From Serene How To: Remove Northwind & Other Samples From Serene After you take Northwind as a sample, and develop your own project, you would want to remove Northwind module, and other sample artifacts from your project. Here is how to get rid of them. We assume your solution name is MyProject, so you have MyProject.Web project in your solution. Perform steps below in Visual Studio: Removing Project Folders Remove MyProject.Web/Modules/AdminLTE folder. This will remove all server side code related to theme samples. Remove MyProject.Web/Modules/BasicSamples folder. This will remove all server side code related to basic samples. Remove MyProject.Web/Modules/Northwind folder. This will remove all server side code related to Northwind. Removing Navigation Items Navigation items for these modules are moved under relevant modules folder in v2.5.3. If you are using an older version, Open MyProject.Web/Modules/Common/Navigation/NavigationItems.cs, remove all lines with Northwind, Basic Samples and Theme Samples and remove these two lines: using Northwind = MovieTutorial.Northwind.Pages; using Basic = MovieTutorial.BasicSamples.Pages; Removing Migration Scripts Remove folder MyProject.Web/Modules/Common/Migrations/NorthwindDB/ with all files under it. 220 How To: Remove Northwind & Other Samples From Serene Remove "Northwind" from following line in MyProject.Web/App_Start/ SiteInitialization.Migrations.cs: private static string[] databaseKeys = new[] { "Default", "Northwind" }; Also remove Northwind connection string from web.config. Removing LESS Entries Open MyProject.Web/Content/site/site.less file, remove following lines: @import "site.basicsamples.less"; @import "site.northwind.less"; Remove MyProject.Web/Content/site/site.basicsamples.less file. Remove MyProject.Web/Content/site/site.northwind.less file. Open MyProject.Web/Content/site/rtl.css file, remove sections with Northwind. Removing Localization Texts Open MyProject.Web/Modules/Texts.cs and remove following lines: public static LocalText NorthwindPhone = "..."; public static LocalText NorthwindPhoneMultiple = "..."; Remove folder MyProject.Web/Scripts/site/texts/northwind Remove folder MyProject.Web/Scripts/site/texts/samples Removing Northwind / Samples Generated Code Expand MyProject.Web/Modules/Common/Imports/ ServerTypings/ServerTypings.tt. Select files starting with Northwind., BasicSamples. and delete them. Removing Northwind Numbers From Dashboard 221 How To: Remove Northwind & Other Samples From Serene Open DashboardPage.cs, remove these using lines: using Northwind; using Northwind.Entities; As Dashboard gets numbers from Northwind tables, you should modify Index() action like this: [Authorize, HttpGet, Route("~/")] public ActionResult Index() { var cachedModel = new DashboardPageModel() { }; return View(MVC.Views.Common.Dashboard.DashboardIndex, cachedModel); } You should replace this model with something specific to your site, and modify DashboardIndex accordingly. Open DashboardIndex.cshtml, clear href attributes containing "Northwind" like: Building Project and Running T4 (.tt) Templates Now rebuild your solution. Make sure it is built successfully before executing next step. Click Build menu and click Transform All Templates. Rebuild your solution again. Search for Northwind, Basic Samples and Theme Samples in all solution items. It should find no results. Run your project, now Northwind and Sample menus are gone. Removing Northwind Tables Northwind tables are in a separate database, so you can just drop it. 222 How To: Remove Northwind & Other Samples From Serene 223 How To: Update Serenity NuGet Packages How To: Update Serenity NuGet Packages Serene template contains references to following Serenity NuGet packages: Serenity.Core Serenity.Data Serenity.Data.Entity Serenity.Services Serenity.Web Serenity.CodeGenerator To update Serenity packages to latest version, open package manager console (click View > Other Windows -> Package Manager Console). And type following: Update-Package Serenity.Web Update-Package Serenity.CodeGenerator Updating these two packages will also update others (because of dependencies). 224 How To: Upgrade to Serenity 2.0 and Enable TypeScript How To: Upgrade to Serenity 2.0 and Enable TypeScript Serenity has TypeScript support starting with version 2.0. This is a migration guide for users that started with an older Serene template, and wants to use TypeScript features. If you don't need TypeScript, just update your Serenity packages and it should work as normal. Even if you won't need TypeScript, it's recommended to perform steps listed here to keep your project up to date. This might also help you avoid future problems as there has been many changes in Serene for TypeScript support. Should I Switch To TypeScript? TypeScript support in Serenity is stable as of writing and is strongly recommended. TypeScript is the future for Serenity applications, as it has a stronger backing at the moment (Microsoft and average number of users). Also TypeScript feels like native Javascript with proper intellisense, refactoring and compile time type checking. We've been using Saltaralle with Serenity since start but its future is a bit blurry. It didn't get any updates since it is acquired by Bridge.NET, last June (2015). Your old code written in Saltaralle will continue to work. It will be supported as long as possible with Serenity for backward compability. If Bridge.NET v2.0 (next Saltaralle) comes out, we may also try to switch, unless it involves too many changes to handle. Migrating Your Serene Application to v2.0 Check that your solution is building properly First make sure your solution is properly building. 225 How To: Upgrade to Serenity 2.0 and Enable TypeScript If possible, take a ZIP backup of solution, as some steps we'll perform might be hard to revert. Install TypeScript Install TypeScript 1.8+ from https://www.typescriptlang.org/#download-links for your Visual Studio version. Update NuGet Packages Update to 2.0 packages as you'd normally do: Update-Package Serenity.Web Update-Package Serenity.CodeGenerator Update-Package Serenity.Script While updating Serenity.Web, VS might show a dialog with text "Your Project has been configured to support TypeScript". Click YES. Ensuring Package Updates Caused No Problems Rebuild your solution again and run it. Open some pages, dialogs etc. and make sure that it is working properly with 2.0 packages. Configuring Web Project for TypeScript Unload MyProject.Web and edit it. Add lines below after TypeScriptToolsVersion line: // ... 1.8 True utf-8 True False True Scripts\site\Serene.Web.js False 226 How To: Upgrade to Serenity 2.0 and Enable TypeScript Replace Serene.Web.js with your project name. In the end of same file, you'll see lines like below: Make sure the line with TypeScript.targets with is under all other targets. Move it under WebAplpication.targets if not. VS puts them before Microsoft.WebApplication.targets and somehow it doesn't work that way. Also, at the bottom of file, you'll find CompileSiteLess step, add TSC to end of it: Save changes, reload the project and follow to next step. Adding tsconfig.json File Add a tsconfig.json file to the root of your Web project (where web.config and Global.asax files are) with content like below: { "compileOnSave": true, "compilerOptions": { "preserveConstEnums": true, "experimentalDecorators": true, "declaration": true, "emitBOM": true, "sourceMap": true, "target": "ES5", "outFile": "Scripts/site/Serene.Web.js" }, "exclude": [ "Scripts/site/Serene.Web.d.ts" ] } 227 How To: Upgrade to Serenity 2.0 and Enable TypeScript Replace Serene.Web with your project name. Add a Test TypeScript File Add a dummy.ts file under YourProject.Web/scripts/site/dummy.ts. Open it and type something like below: namespace MyProject { export class Dummy { } } When you save it, there should be a MyProject.Web.js file there with content below. If you can't see it, click Show All Files and refresh folder. var MyProject; (function (MyProject) { var Dummy = (function () { function Dummy() { } return Dummy; }()); MyProject.Dummy = Dummy; })(MyProject || (MyProject = {})); //# sourceMappingURL=SereneUpgrading.Web.js.map Right click and include that file to your project. Now you can delete dummy.ts. If you are using a version before VS2015 and compile on save is not working, your TS files will be compiled at project build. Including MyProject.Web.js file in _LayoutHead.cshtml Edit MyProject.Web/Views/Shared/_LayoutHead.cshtml and include MyProject.Web.js right after MyProject.Script.js file: // ... @Html.Script("~/Scripts/Site/MyProject.Script.js") @Html.Script("~/Scripts/Site/MyProject.Web.js") // ... Your project is configured for TypeScript. Changing Location for T4 Templates 228 How To: Upgrade to Serenity 2.0 and Enable TypeScript Serene v2.0 has merged some .TT templates and created new one for TypeScript code generation. Please make sure your project is building successfully and DON'T CLEAN it while performing these steps, otherwise you may end up with a broken project. Locate file YourProject.Web\Modules\Common\Imports\ MultipleOutputHelper.ttinclude Make a copy of it in same folder with name CodeGenerationHelpers.ttinclude Get latest source of CodeGenerationHelpers.ttinclude from address below and copy paste it to CodeGenerationHelpers.ttinclude file you just created: https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module s/Common/Imports/CodeGenerationHelpers.ttinclude Search and Replace Serene with YourProjectName in this file if any. There shouldn't be any Serene word in this file as of writing. You may also create a new Serene project with latest version of template to get these files. ClientTypes.tt Create folder YourProject.Web\Modules\Common\Imports\ClientTypes and move ScriptEditorTypes.tt to there, then rename ScriptEditorTypes.tt to ClientTypes.tt. Grab latest source of ClientTypes.tt file from address below and copy paste it to ClientTypes.tt file you just moved: https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module s/Common/Imports/ClientTypes/ClientTypes.tt Search and Replace Serene with YourProjectName in this file if any. ServerTypings.tt Create folder YourProject.Web\Modules\Common\Imports\ServerTypings and move ScriptFormatterTypes.tt to there, then rename ScriptFormatterTypes.tt to ServerTypings.tt. Grab latest source of ServerTypings.tt file from address below and copy paste it to ServerTypings.tt file you just moved: https://raw.githubusercontent.com/volkanceylan/Serene/master/Serene/Serene.Web/Module s/Common/Imports/ServerTypings/ServerTypings.tt Search and Replace Serene with YourProjectName in this file if any. 229 How To: Upgrade to Serenity 2.0 and Enable TypeScript Generate Code While they are open save ClientTypes.tt and ServerTypings.tt files, and wait for them to generate codes. Save project and rebuild. Changing Location for FormContexts and ServiceContracts T4 Templates These two templates are merged into one. We'll repeat similar steps like in Web project. Locate file YourProject.Script\Imports\ MultipleOutputHelper.ttinclude Make a copy of it in same folder with name CodeGenerationHelpers.ttinclude Get latest source of CodeGenerationHelpers.ttinclude from address below (it's different!) and copy paste it to CodeGenerationHelpers.ttinclude file you just created: https://raw.githubusercontent.com/volkanceylan/Serene/ b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/ Imports/CodeGenerationHelpers.ttinclude Search and Replace Serene with YourProjectName in this file. There must be several. ServiceContracts.tt Rename folder YourProject.Script\Imports\ServiceContracts to ServerImports. Rename ServiceContracts.tt to ServerImports.tt. Grab latest source of ServerImports.tt file from address below and copy paste it to ServerImports.tt file you just renamed: https://raw.githubusercontent.com/volkanceylan/Serene/ b900c67b4c820284379b9c613b16379bb8c530f3/Serene/Serene.Script/ Imports/ServerImports/ServerImports.tt Search and Replace Serene with YourProjectName in this file. Delete folder FormContexts with file FormContext.tt in it. Save ServerImports.tt and wait for it to generate code. It might take some time because of some slow down due to Saltaralle. Rebuild solution and make sure it builds properly without any error. 230 How To: Upgrade to Serenity 2.0 and Enable TypeScript Congratulations! Your project is ready for TypeScript and other features. What are These New .tt Files ServerTypings.tt: generated code for TypeScript, containing Row, Form, Column, Service declarations imported from Server (Web) code. Also contains import classes from YourProject.Script file if any. ServerTypes.tt: generated code for Saltaralle, containing Row, Form, Column, Service declarations imported from Server (Web) code. There is no import classes from TypeScript yet. So if you want to use some TypeScript class in your Saltaralle code, you need to write import classes manually. ClientTypes.tt: generated code for Web project, containing Editor and Formatter imports from both TypeScript and Saltaralle. How Can I Generate TypeScript Grid/Dialog Code There is such an option in Serenity CodeGenerator (Sergen) now. Just check *Generate Grid/Dialog Code in TypeScript (instead of Saltaralle) and it will generate YourDialog.ts and YourGrid.ts files under YourProject.Web/Modules/YourEntity directory, instead of YourGrid.cs and YourDialog.cs in YourProject.Script project. Please don't generate code for existing Saltaralle dialogs or grids using Sergen. Otherwise you'll have double YourGrid and YourDialog classes and it may lead to unexpected errors. 231 How To: Authenticate With Active Directory or LDAP How To: Authenticate With Active Directory or LDAP Serene 1.8.12+ has some basic ActiveDirectory / LDAP integration samples. To enable them, you have to fill one of web.config settings. For ActiveDirectory add a appSetting key ActiveDirectory with contents like below: If this doesn't work for your Active Directory server out of the box, you might have to modify ActiveDirectoryService class. When a AD user tries to login first time, Serene authenticates user with this domain, retrieves user details and inserts a user with type directory into users table. AD password hash and user information is cached for one hour, so for one hour user can login with cached credentials, without even hitting AD. After that, user information is tried to be updated from AD. If an error occurs, user will be allowed to login with cached credentials. These details can be seen and modified in AuthenticationService class. To enable LDAP authentication (tested with OpenLDAP) you need to add a appSetting key LDAP to web.config: Again, there are many different configurations of LDAP servers out there, so if this doesn't work for you, you might have to modify LdapDirectoryService class. 232 How To: Authenticate With Active Directory or LDAP 233 How To: Use a SlickGrid Formatter How To: Use a SlickGrid Formatter This section is pending update for TypeScript To use a SlickGrid formatter function, like percent complete bar formatter at %Complete column of SlickGrid example: http://mleibman.github.io/SlickGrid/examples/example2-formatters.html Including Required Resources First include javascript file containing these formatters in your _LayoutHead.cshtml file (MyProject.Web/Views/Shared/_LayoutHead.cshtml): //... @Html.Script("~/Scripts/jquery.slimscroll.js") @Html.Script("~/Scripts/SlickGrid/slick.formatters.js") @Html.Script("~/Scripts/Site/MovieTutorial.Script.js") //... You also need to include following CSS from example.css (can be inserted in site.less): .percent-complete-bar { display: inline-block; height: 6px; -moz-border-radius: 3px; -webkit-border-radius: 3px; } Declaring a Serenity DataGrid Formatter Let's say we have StudentCourseGrid with a CourseCompletion column that we wan't to use Slick.Formatters.PercentCompleteBar formatter with. public class StudentCourseColumns { //... [Width(200)] public Decimal CourseCompletion { get; set; } } 234 How To: Use a SlickGrid Formatter To reference a SlickGrid formatter at server side, you need to declare a formatter type for Serenity grids. In MyApplication.Script project, next to StudentCourseGrid.cs for example, define a file (PercentCompleteBarFormatter.cs) with contents: using Serenity; using System; namespace MyApplication { public class PercentCompleteBarFormatter : ISlickFormatter { private SlickColumnFormatter formatter = Type.GetType("Slick.Formatters.PercentCompleteBar").As(); public string Format(SlickFormatterContext ctx) { return formatter(ctx.Row, ctx.Cell, ctx.Value, ctx.Column, ctx.Item); } } } Replace MyApplication with your root namespace (solution name). Now you can reference it at server side: public class StudentCourseColumns { //... [FormatterType("PercentCompleteBar"), Width(200)] public Decimal CourseCompletion { get; set; } } Rebuild your project and you will see that CourseCompletion column has a percentage bar just like in SlickGrid example. Getting Intellisense and Compile Time Checking To Work To get intellisense for PercentCompleteBarFormatter server side (so to avoid using magic strings), you should transform T4 templates (make sure solution builds successfully before transforming). After this you can reference it like this server side: 235 How To: Use a SlickGrid Formatter public class StudentCourseColumns { //... [PercentCompleteBarFormatter, Width(200)] public Decimal CourseCompletion { get; set; } } Alternate Option (Not Recommended) It is also possible to set SlickGrid column formatter function directly in script side code without defining a Serenity formatter class, e.g. in StudentCourseGrid.cs by overriding its GetColumns method: protected override List GetColumns() { var columns = base.GetColumns(); columns.Single(x => x.Field == "CourseCompletion ").Formatter = Type.GetType("Slick.Formatters.PercentCompleteBar").As(); return columns; } This is not reusable but saves you from defining a formatter class. 236 How To: Add a Row Selection Column How To: Add a Row Selection Column This section is pending update for TypeScript To add a column to select individual rows or all rows, GridRowSelectionMixin can be used. GridRowSelectionMixin is available in Serenity 1.6.8+ Sample code: 237 How To: Add a Row Selection Column public class MyGrid : EntityGrid { private GridRowSelectionMixin rowSelection; public MyGrid(jQueryObject container) : base(container) { rowSelection = new GridRowSelectionMixin(this); } protected override List GetColumns() { var columns = base.GetColumns(); columns.Insert(0, GridRowSelectionMixin.CreateSelectColumn(() => rowSelection) ); return columns; } protected override List GetButtons() { var buttons = base.GetButtons(); buttons.Add(new ToolButton { CssClass = "tag-button", Title = "Do Something With Selected Rows", OnClick = delegate { var selectedIDs = rowSelection.GetSelectedKeys(); if (selectedIDs.Count == 0) Q.NotifyWarning("Please select some rows"); else Q.NotifySuccess("You have selected " + selectedIDs.Count + " row(s) with ID(s): " + string.Join(", ", selectedIDs)); } }); return buttons; } } 238 How To: Setup Cascaded Editors How To: Setup Cascaded Editors You might need multi-level cascaded editors like Country => City, Course => Class Name => Subject. Starting with Serenity 1.8.2, it's rather simple. Lookup editors have this integrated functionality. For versions before 1.8.2, it was also possible, and there was some samples in Serene, but you had to define some editor classes to make it work. Let's say we have a database with three tables, Country, City, District: Country Table: CountryId, CountryName City Table: CityId, CityName, CountryId District Table: DistrictId, DistrictName, CityId First make sure you generate code for all three tables using Sergen, and you have a [LookupScript] attribute on all of them: [LookupScript("MyModule.Country")] public sealed class CountryRow : Row... { [DisplayName("Country Id"), Identity] public Int32? CountryId { get { return Fields.CountryId[this]; } set { Fields.CountryId[this] = value; } } [DisplayName("Country Name"), Size(50), NotNull, QuickSearch] public String CountryName { get { return Fields.CountryName[this]; } set { Fields.CountryName[this] = value; } } } 239 How To: Setup Cascaded Editors [LookupScript("MyModule.City")] public sealed class CityRow : Row... { [DisplayName("City Id"), Identity] public Int32? CityId { get { return Fields.CityId[this]; } set { Fields.CityId[this] = value; } } [DisplayName("City Name"), Size(50), NotNull, QuickSearch] public String CityName { get { return Fields.CityName[this]; } set { Fields.CityName[this] = value; } } [DisplayName("Country"), ForeignKey("Country", "CountryId"), LookupInclude] public Int32? CountryId { get { return Fields.CountryId[this]; } set { Fields.CountryId[this] = value; } } } 240 How To: Setup Cascaded Editors [LookupScript("MyModule.District")] public sealed class DistrictRow : Row... { [DisplayName("District Id"), Identity] public Int32? DistrictId { get { return Fields.DistrictId[this]; } set { Fields.DistrictId[this] = value; } } [DisplayName("District Name"), Size(50), NotNull, QuickSearch] public String DistrictName { get { return Fields.DistrictName[this]; } set { Fields.DistrictName[this] = value; } } [DisplayName("City"), ForeignKey("City", "CityId"), LookupInclude] public Int32? CityId { get { return Fields.CityId[this]; } set { Fields.CityId[this] = value; } } } Make sure you add LookupInclude attribute to CityId field of DistrictRow, and CountryId field of CityRow. We need them to be available at client side, otherwise they are not included by default in lookup scripts. If you wanted to edit these fields as cascaded lookup editors in a form, e.g. CustomerForm, you would set them up like this: 241 How To: Setup Cascaded Editors [FormScript("MyModule.Customer")] [BasedOnRow(typeof(Entities.CustomerRow))] public class CustomerForm { public String CustomerID { get; set; } public String CustomeraName { get; set; } [LookupEditor(typeof(Entities.CountryRow))] public Int32? CountryId { get; set; } [LookupEditor(typeof(Entities.CityRow), CascadeFrom = "CountryId", CascadeField = "CountryId")] public Int32? CityId { get; set; } [LookupEditor(typeof(Entities.DistrictRow), CascadeFrom = "CityId", CascadeField = "CityId")] public Int32? DistrictId { get; set; } } You could also set these attributes in CustomerRow Here, CascadeFrom attribute tells city editor, ID of the parent editor that it will bind to (cascade). When this form is generated, CountryId field will be handled with an editor with ID CountryId, so we set CascadeFrom attribute of CityId lookup editor to that ID. CascadeField determines the field to filter cities on. Thus, when country editor value changes, city editor items will be filtered on their CountryId properties like this: this.Items.Where(x => x.CountryId == CountryEditorValue) If CascadeFrom and CascadeField attributes are same, you only need to specify CascadeFrom, but i wanted to be explicit here. If you wanted to add these cascaded editors to filter bar of customer grid, in CreateToolbarExtensions method of CustomerGrid.cs, do this: 242 How To: Setup Cascaded Editors AddEqualityFilter("CountryId", options: new LookupEditorOptions { LookupKey = "MyModule.Country" }); AddEqualityFilter("CityId", options: new LookupEditorOptions { LookupKey = "MyModule.City", CascadeFrom = "CountryId", CascadeField = "CountryId" }); AddEqualityFilter("DistrictId", options: new LookupEditorOptions { LookupKey = "MyModule.District", CascadeFrom = "CityId", CascadeField = "CityId" }); Here i suppose you have CountryId, CityId and DistrictId fields in CustomerRow. Now you have useful cascaded editors for both editing and filtering. 243 How To: Use Recaptcha How To: Use Recaptcha To use Recaptcha in login form, follow these steps: Requires Serenity 1.8.5+ You might also use it for another form, but this is just a sample for login. First, you need to register a new site for Recaptcha at: https://www.google.com/recaptcha/admin Once you have your site key, and secret key, enter them in web.config/appSettings section: The keys listed above are only for testing purposes. Never use them in production. Edit LoginForm.cs to add a Recaptcha property: public class LoginForm { [Placeholder("default username is 'admin'")] public String Username { get; set; } [PasswordEditor, Placeholder("default password for admin user is 'serenity'"), Req uired(true)] public String Password { get; set; } [DisplayName(""), Recaptcha] public string Recaptcha { get; set; } } Edit LoginRequest.cs to add a Recaptcha property: public class LoginRequest : ServiceRequest { public string Username { get; set; } public string Password { get; set; } public string Recaptcha { get; set; } } Edit Login method under AccountPage.cs to validate the captcha server side: 244 How To: Use Recaptcha [HttpPost, JsonFilter] public Result Login(LoginRequest request) { return this.ExecuteMethod(() => { request.CheckNotNull(); if (string.IsNullOrEmpty(request.Username)) throw new ArgumentNullException("username"); var username = request.Username; // just add line below Serenity.Web.RecaptchaValidation.Validate(request.Recaptcha); if (WebSecurityHelper.Authenticate(ref username, request.Password, false)) return new ServiceResponse(); throw new ValidationError("AuthenticationError", Texts.Validation.AuthenticationError); }); } 245 How To: Register Permissions in Serene How To: Register Permissions in Serene Serene shows a list of permissions in user and role permission dialogs. To show your own permissions there, you need to use these permissions with one of the attributes below: Attributes that derive from PermissionAttributeBase: ReadPermission ModifyPermission InsertPermission UpdatePermission DeletePermission Page and Endpoint Access Control Attributes: PageAuthorize ServiceAuthorize These attributes can be used with and located from one of these types: On top of XYZRow (Read, Write, Insert, Update, Delete permissions) On top of XYZPage and in action methods (PageAuthorize) On top of XYZEndpoint and in service actions (ServiceAuthorize) When you use a permission key with one of such attributes, Serene will automatically discover them using reflection at application start. There is a PermissionKeys class in Serene. Some users expected that when they write their permission keys in this class, they will be discovered. But, PermissionKeys class is only there for intellisense purposes, it is ignored by Serene. If you don't use a permission key with any of them but still want to show it in permission dialogs, you can use RegisterPermission attribute on assembly (write this anywhere in YourProject.Web): [assembly: Serenity.ComponentModel.RegisterPermissionKey("MySpecialPermissionKey")] Organizing Permission Tree To create permissions in tree hierarchy, use colon (:) as a separator in your permission keys: 246 How To: Register Permissions in Serene MyModule:SubModule:General MyModule:SubModule:Permission1 MyModule:SubModule:Permission2 These keys will be shown under MyModule / SubModule category. Thus their category keys will be: MyModule:SubModule: Category keys ends with colon. Don't use permission keys that ends with colon. Please don't use permission keys that matches category keys. If you use such keys, for example MyModule:SubModule it won't be shown under MyModule / SubModule category but next to it at same level. If you need a generic permission for such a category, use something like MyModule:SubModule:General. General has no special meaning, you can use Common, Module, View, whatever you like. Handling Category Display Texts As categories are automatically determined from permission keys, they don't have a user friendly display text for them. You need to add display texts for them using localization system. If you don't need localization, just add texts to site.texts.invariant.json For example in site.texts.invariant.json file, there are such keys: "Permission.Administration:": "Administration", "Permission.Administration:Security": "User, Role Management and Permissions", "Permission.Administration:Translation": "Languages and Translations", "Permission.Northwind:Customer:": "Customers", "Permission.Northwind:Customer:View": "View", "Permission.Northwind:Customer:Delete": "Delete", "Permission.Northwind:Customer:Modify": "Create/Update", "Permission.Northwind:General": "[General]" The keys ending with colon (:), like Administration: and Customer: corresponds to categories and these are their display texts. You need to add texts for categories to invariant language at minimum. You may also add to other languages, if you want localization. 247 How To: Register Permissions in Serene 248 How To: Use a Third Party Plugin With Serenity How To: Use a Third Party Plugin With Serenity To use a third party / custom plugin with a Serenity application involves no special steps. You may include their scripts and CSS in _LayoutHead.cshtml, and follow their documentation. Especially if you are using TypeScript, there are no special steps involved. In case of Saltaralle (which is being deprecated), you might have to write some import classes or use dynamic otherwise. But, if you want that component to work well among other Serenity editors in dialogs, you may try wrapping it into a Serenity widget. Here we'll take Bootstrap MultiSelect plugin as a sample, and integrate it as an editor into Serenity, similar to LookupEditor. Here is the documentation and samples for this component: http://davidstutz.github.io/bootstrap-multiselect/ Getting Script and CSS Files First we should download its script and CSS files and place them in correct places under MyProject.Web/scripts/ and MyProject.Web/content folders. This component has a NuGet package but unfortunately it is not in a standard fashion (it doesn't place files into your project folders), so we'll have to download files manually. Download this script file and put it under MyProject.Web/Scripts: https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/js/bootstrapmultiselect.js Download this CSS file and put it under MyProject.Web/Content: https://raw.githubusercontent.com/davidstutz/bootstrap-multiselect/master/dist/css/bootstrapmultiselect.css Including Script/Css Files in _LayoutHead.cshtml According to plugin documentation, we should include these files: 249 How To: Use a Third Party Plugin With Serenity Open _LayoutHead.cshtml under MyProject.Web/Views/Shared and include these files: // ... @Html.Stylesheet("~/Content/bootstrap-multiselect.css") @Html.Stylesheet("~/Content/serenity/serenity.css") @Html.Stylesheet("~/Content/site/site.css") // ... @Html.Script("~/Scripts/bootstrap-multiselect.js") @Html.Script("~/Scripts/Site/Serene.Script.js") @Html.Script("~/Scripts/Site/Serene.Web.js") Creating BSMultiSelectEditor.ts Now we need a TypeScript source file to hold our component. We should put it under MyProject.Web/Scripts or MyProject.Web/Modules directories. I'll create it under MyProject.Web/Modules/Common/Widgets (first you need to create folder Widgets) Create file BSMultiSelectEditor.ts at this location: 250 How To: Use a Third Party Plugin With Serenity namespace MyProject { @Serenity.Decorators.element("") With above line, we specified that our widget works on a SELECT element, as this bootstrap multiselect plugin requires a select element too. @Serenity.Decorators.registerClass( [Serenity.IGetEditValue, Serenity.ISetEditValue]) Above, we register our TypeScript class, with Saltaralle type system and specify that our widget implements custom value getter and setter methods, corresponding to getEditValue and setEditValue methods. Here syntax is a bit terse as we have to handle interop between Saltaralle and TypeScript. Our constructor and getEditValue, setEditValue methods are yet empty. We'll fill them in soon. 251 How To: Use a Third Party Plugin With Serenity Using Our New Editor Now, build your project and transform templates. Open CustomerRow.cs and locate Representatives property: [LookupEditor(typeof(EmployeeRow), Multiple = true), NotMapped] [LinkingSetRelation(typeof(CustomerRepresentativesRow), "CustomerId", "EmployeeId")] public List Representatives { get { return Fields.Representatives[this]; } set { Fields.Representatives[this] = value; } } Here we see that Representatives uses a LookupEditor with multiple option true. We'll replace it with our brand new editor: [BSMultiSelectEditor(LookupKey = "Northwind.Employee"), NotMapped] [LinkingSetRelation(typeof(CustomerRepresentativesRow), "CustomerId", "EmployeeId")] public List Representatives { get { return Fields.Representatives[this]; } set { Fields.Representatives[this] = value; } } Populating Editor With Lookup Items If you now build your project and open a Customer dialog, you'll see an empty SELECT in place of Customer representatives field. Let's first fill it with data: export class BSMultiSelectEditor { constructor(element: JQuery, opt: BSMultiSelectOptions) { super(element, opt); let lookup = Q.getLookup(this.options.lookupKey) as Q.Lookup; for (let item of lookup.get_items()) { let key = item[lookup.get_idField()]; let text = item[lookup.get_textField()] || ''; Q.addOption(element, key, text); } } We first get a reference to lookup object specified by our lookupKey option. 252 How To: Use a Third Party Plugin With Serenity Lookups has idField and textField properties, which usually corresponds to fields determined by IIdRow and INameRow interfaces on your lookup row. We enumerate all items in lookup and determine key and text properties of those items, using idField and textField properties. Now save file, and open Customer dialog again. You'll see that this time options are filled. Bootstrap Multi Select Typings According to documentation we should now call ".multiselect()" jQuery extension on our select element. I would cast our SELECT element to and call .multiselect on it, but i want to write a TypeScript .d.ts definition file to reuse multiselect with intellisense. So, under MyProject.Web/Scripts/typings/bsmultiselect folder, create a file, bsmultiselect.d.ts interface JQuery { multiselect(options?: BSMultiSelectOptions | string): JQuery; } interface BSMultiSelectOptions { multiple?: boolean; includeSelectAllOption?: boolean; selectAllText?: string; selectAllValue?: string | number; } Here, i have extended JQuery interface which belongs to jQuery itself and is defined in jquery.d.ts. In TypeScript you can extend any interface with new methods, properties etc. I used plugin documentation to define BSMultiSelectOptions. The plugin actually has much more options, but for now i keep it short. Creating Bootstrap MultiSelect on Our Editor Now i'll go back to our constructor and initialize a multiselect plugin on it: 253 How To: Use a Third Party Plugin With Serenity export class BSMultiSelectEditor { constructor(element: JQuery, opt: BSMultiSelectOptions) { super(element, opt); element.attr('multiple', 'multiple') let lookup = Q.getLookup(this.options.lookupKey) as Q.Lookup; for (let item of lookup.get_items()) { let key = item[lookup.get_idField()]; let text = item[lookup.get_textField()] || ''; Q.addOption(element, key, text); } element .attr('name', this.uniqueName + "[]") .multiselect(); } Open CustomerDialog and you'll see that Representatives has our bootstrap multi select editor. Handling GetEditValue and SetEditValue Method If we don't handle these methods, Serenity won't know how to read or set your editor value, so even if you select some representatives, next time you open the dialog, you'll have empty selections. export class BSMultiSelectEditor { //... public setEditValue(source: any, property: Serenity.PropertyItem): void { this.element.val(source[property.name] || []).multiselect('refresh'); } public getEditValue(property: Serenity.PropertyItem, target: any): void { target[property.name] = this.element.val() || []; } setEditValue is called when editor value needs to be setted. It takes a source object, which is usually your entity being loaded in a dialog. Property parameter is a PropertyItem object that contains details about the field being handled, e.g. our Representatives property. It's name field contains field name, e.g. Representatives. 254 How To: Use a Third Party Plugin With Serenity Here we have to call multiselect('refresh') after setting select value, as multiselect plugin can't know when selections are changed. getEditValue is opposite. It should read edit value and set it in target entity. Ok, now our custom editor should be working fine. 255 How To: Enable Script Bundling How To: Enable Script Bundling In Serene template there are about 3MB+ of javascript files which are included by default in _LayoutHead.cshtml. This might cause bandwidth and performance problems for some systems, especially when a Serenity based site is accessed from mobile devices. There are several ways to handle these problems, like minification and gzipping to decrease script size, bundling to pack scripts into fewer files, thus reduce number of requests. You might prefer to use tools like Webpack, Grunt, Gulp, UglifyJS etc, but in case you want a simpler and effective solution with much less manual steps, Serenity comes with a script bundling and minification / compression system out of the box. Please note that this feature requires Serenity 2.0.13+ ScriptBundles.json First, you need a ScriptBundles.json file under MyProject.Web/scripts/site folder. ScriptBundles.json configures which script bundle will contain which files when bundling is turned on. This file is included by default in Serene template 2.0.13+ and looks like this: Unless you want to add some custom scripts to bundles, you don't need to modify this file. 256 How To: Enable Script Bundling { "Libs": [ "~/Scripts/pace.js", "~/Scripts/rsvp.js", "~/Scripts/jquery-{version}.js", "~/Scripts/jquery-ui-{version}.js", "~/Scripts/jquery-ui-i18n.js", "~/Scripts/jquery.validate.js", "~/Scripts/jquery.blockUI.js", "~/Scripts/jquery.cookie.js", "~/Scripts/jquery.json.js", "~/Scripts/jquery.autoNumeric.js", "~/Scripts/jquery.colorbox.js", "~/Scripts/jquery.dialogextendQ.js", "~/Scripts/jquery.event.drag.js", "~/Scripts/jquery.scrollintoview.js", "~/Scripts/jsrender.js", "~/Scripts/select2.js", "~/Scripts/toastr.js", "~/Scripts/SlickGrid/slick.core.js", "~/Scripts/SlickGrid/slick.grid.js", "~/Scripts/SlickGrid/slick.groupitemmetadataprovider.js", "~/Scripts/SlickGrid/Plugins/slick.autotooltips.js", "~/Scripts/SlickGrid/Plugins/slick.headerbuttons.js", "~/Scripts/SlickGrid/Plugins/slick.rowselectionmodel.js", "~/Scripts/SlickGrid/Plugins/slick.rowmovemanager.js", "~/Scripts/bootstrap.js", "~/Scripts/Saltarelle/mscorlib.js", "~/Scripts/Saltarelle/linq.js", "~/Scripts/Serenity/Serenity.CoreLib.js", "~/Scripts/Serenity/Serenity.Script.UI.js", "~/Scripts/Serenity/Serenity.Externals.js", "~/Scripts/Serenity/Serenity.Externals.Slick.js", "~/Scripts/jquery.cropzoom.js", "~/Scripts/jquery.fileupload.js", "~/Scripts/jquery.iframe-transport.js", "~/Scripts/jquery.slimscroll.js", "~/Scripts/mousetrap.js", "~/Scripts/fastclick/fastclick.js" ], "Site": [ "~/Scripts/adminlte/app.js", "~/Scripts/Site/Serene.Script.js", "~/Scripts/Site/Serene.Web.js" ] } Here we define two distinct bundles, Libs and Site, corresponding to Bundle.Libs.js and Bundle.Site.js dynamic script files. Bundle.Site.js is configured to contain these three JS files (in the listed order): 257 How To: Enable Script Bundling "~/Scripts/adminlte/app.js", "~/Scripts/Site/Serene.Script.js", "~/Scripts/Site/Serene.Web.js" While Bundle.Libs.js contains all other javascript files. Here we used 2 bundles by default, but it is possible to use one, three or more in case you need a different configuration. Just be careful about dependencies. Here, the ordering inside a bundle (package) is very important. You must include scripts in the order they appear in your _LayoutHead.cshtml. When you will add another custom script, make sure that it is placed after all its dependencies. For example, if you include a jquery plugin before jquery is loaded itself, you'll have errors. Also make sure that you don't include same file in two bundles. Enabling Bundling You should enable bundling (especially minification) only in production. Otherwise it might become very difficult to debug Javascript. To enable bundling, just change Enabled property of ScriptBundling application setting in your web.config to true: When Enabled is false (default) system will do nothing, and you'll work as usual with your script includes. And your page source looks like this: 258 How To: Enable Script Bundling ... ... ... ... When Enabled is true, it will become like this one: ... These two bundles are generated in memory and contains all other script files configured in ScriptBundles.json file. They are also compressed with GZIP and cached in memory (in gzipped form), so now our scripts will consume much less bandwidth and will cause fewer requests to server. Now our script files will consume 600KB, instead of 3000KB before, a %80 reduction, not bad... Enabling Minification After enabling bundling, you may also enable minification of scripts with the same web.config setting. Set Minimize property to true: Please note that Minimize property only works when Enabled is true, thus when bundling is enabled. UglifyJS library is used for minification. This will be applied before bundling / gzipping so our bundles will become about %40 smaller, but will be much harder to read, so enable this only in production. Now our bundled and minified script files will consume 375KB, instead of 3000KB before, a %87 reduction, or 1/8 the initial size. 259 How To: Enable Script Bundling UseMinJS Setting Minification might take some time, and first request to your site might take around 5-40 seconds more, depending on speed of your server. Other requests will not be affected as minification is only performed once at application start. Anyway, if you still need more performance at first request, you may ask Serenity to reuse already minified files in disk, if they are available. Set UseMinJS to true: When this setting is ON, before minifying a file, let's say jquery-ui-1.11.4.js, Serenity will first check to see if a jquery-ui-1.11.4.min.js already exists in disk. If so, instead of minifiying with UglifyJS, it will simply use that file. Otherwise, it will run UglifyJS. Serene comes with minified versions of almost all libraries, including Serenity scripts, so this setting will speed up initial start time. There is a little risk that you should be careful about. If you manually modify a library script, make sure you minify it manually and modify its .min.js file too, otherwise when bundling is enabled an old version of that script might run at production. How Serenity Modifies Your _LayoutHead.cshtml Includes? If you look at your _LayoutHead.cshtml you might spot lines like these: @Html.Script("~/Scripts/jquery.cropzoom.js") @Html.Script("~/Scripts/jquery.fileupload.js") @Html.Script("~/Scripts/jquery.iframe-transport.js") When bundling is disabled, these statements generates such HTML code: Html.Script is an extension method of Serenity, so when bundling is enabled, instead of generating this HTML code, Serenity will first check to see if this script is included in a bundle. 260 How To: Enable Script Bundling For the first script that is included in a bundle, let's say Bundle.Lib.js, Serenity will generate code below: But, for other Html.Script calls that is included in same bundle, Serenity will generate nothing. Thus, even though you call Html.Script 50 times, you'll get only one Here, a SCRIPT tag is used, but by specifying its type as "text/html" , browser won't recognize it as a real scriptto execute. By making use of TemplatedWidget, lets rewrite previous spaghetti code block: 449 TemplatedWidget Class public class MyComplexWidget : TemplatedWidget { public MyComplexWidget(jQueryObject div) : base(div) { } } When this widget is created on an HTML element like following:
You'll end up with such an HTML markup:
......
NameSurname
TemplatedWidget automatically locates the template for your class and applies it to the HTML element. TemplatedWidget ID Generation If you watch carefully, in our template we specified ID for descendant elements as ~_MyToolbar and ~_MyTable . But when this template is applied to the HTML element, resulting markup contained ID's of MySamples_MyComplexWidget1_MyToolbar and MySamples_MyComplexWidget1_MyTable instead. TemplatedWidget replaces prefixes like ("_") ( this.idPrefix ~_ with the widget's UniqueName and underscore contains the combined prefix). Using this strategy, even if the same widget template is used in a page for more than one HTML element, their ID's won't conflict with each other as they will have unique ID's. TemplatedWidget.ByID Method 450 TemplatedWidget Class As TemplateWidget appends a unique name to them, the ID attributes in a widget template can't be used to access elements after widget creation. Widget's unique name and an underscore should be prepended to the original ID attribute in the template to find an element: public class MyComplexWidget : TemplatedWidget { public MyComplexWidget(jQueryObject div) : base(div) { J(this.uniqueName + "_" + "Toolbar").AddClass("some-class"); } } TemplatedWidget's ByID method can be used instead: public class MyComplexWidget { public MyComplexWidget(jQueryObject div) : base(div) { ByID("Toolbar").AddClass("some-class"); } } TemplatedWidget.GetTemplateName Method In the last sample MyComplexWidget located its template automatically. TemplatedWidget makes use of a convention to find its template (convention based programming). It inserts SCRIPT Template_ prefix before the class name and searches for a element with this ID attribute ( Template_MyComplexWidget ) and uses its HTML content as a template. If we wanted to use another ID like following: An error like this would be seen in the browser console: 451 TemplatedWidget Class Can't locate template for widget 'MyComplexWidget' with name 'Template_MyComplexWidget '! We might fix our template ID or ask the widget to use our custom ID: public class MyComplexWidget { protected override string GetTemplateName() { return "TheMyComplexWidgetTemplate"; } } TemplatedWidget.GetTemplate Method GetTemplate method might be overriden to provide a template from another source or specify it manually: public class MyCompleWidget { protected override string GetTemplate() { return $('#TheMyComplexWidgetTemplate').GetHtml(); } } Q.GetTemplate Method and Server Side Templates Default implementation for searches for a SCRIPT TemplatedWidget.GetTemplate method calls GetTemplateName and element with that ID. If no such SCRIPT element is found, Q.GetTemplate is called with the same ID. An error is thrown if neither returns a result. Q.GetTemplate method provides access to templates defined on the server side. These templates are compiled from files with ~/Modules .template.cshtml extension in ~/Views/Template or folders or their subfolders. For example, we could create a template for MyComplexWidget in a server side file like ~/Views/Template/SomeFolder/MyComplexWidget.template.cshtml with the following content: 452 TemplatedWidget Class
......
NameSurname
Template file name and extension is important while its folder is simply ignored. By using this strategy there would be no need to insert widget templates into the page markup. Also, as such server side templates are loaded on the first use (lazy loading) and cached in the browser and the server, page markup doesn't get polluted with templates for widgets that we might never use in a specific page. Thus, server side templates are favored over inline SCRIPT templates. 453 TemplatedDialog Class TemplatedDialog Class TemplatedWidget's subclass TemplatedDialog makes use of jQuery UI Dialog to create inpage modal dialogs. Unlike other widget types TemplatedDialog creates its own HTML element, which it will be atteched to. 454 Attributes Attributes Visible Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Controls visibility of a column or form field. It is also possible to hide a field by passing false as its value, but [Hidden] attribute is recommended. public class SomeColumns { [Visible] public string ExplicitlyVisible { get; set; } [Visible(false)] public string ExplicitlyHidden { get; set; } } User might still show the column by using the column picker if any. Hidden Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Hides a column or form field. This is just a subclass of VisibleAttribute with false value. public class SomeColumns { [Hidden] public string HiddenColumn { get; set; } } User might still show the column by using the column picker if any. HideOnInsert Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core 455 Attributes Controls whether a field is visible on new record mode. This only works with forms, not columns. public class SomeColumns { [HideOnInsert] public string HideMeOnInsert { get; set; } [HideOnInsert(false)] public string DontHideMeOnInsert { get; set; } } HideOnUpdate Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Controls whether a field is visible on edit record mode. This only works with forms, not columns. public class SomeColumns { [HideOnUpdate] public string HideMeOnUpdate { get; set; } [HideOnUpdate(false)] public string DontHideMeOnUpdate { get; set; } } Insertable Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Controls if a property is editable in new record mode. When used on row fields, turns on or off the Insertable flag. It has no effect on columns public class SomeForm { [Insertable(false)] public string ReadOnlyOnInsert { get; set; } } 456 Attributes Updatable Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Controls if a property is editable in edit record mode. When used on row fields, turns on or off the Updatable flag. It has no effect on columns public class SomeForm { [Updatable(false)] public string ReadOnlyOnUpdate { get; set; } } DisplayName Attribute namespace: System.ComponentModel, assembly: System Determines default title for grid columns or form fields. public class SomeForm { [DisplayName("Title for Some Field")] public string SomeField { get; set; } } DisplayName attribute cannot be used on Enum members, so you have to use Description attribute Titles set with this attribute is considered to be in invariant language. This is not a Serenity attribute, it resides in .NET System assembly. Description Attribute namespace: System.ComponentModel, assembly: System Determines default title for enum members. 457 Attributes public class SomeEnum { [Description("Title for Value 1")] Value1 = 1, [Description("Value 2")] Value2 = 2 } Titles set with this attribute is considered to be in invariant language. This is not a Serenity attribute, it resides in .NET System assembly. DisplayFormat Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Sets the display format for a column. This has no effect on editors! It is only for Display, NOT Editing. For editing, you have to change culture in web.config (not UI culture). Display format strings are specific to column data and formatter type. If column is a Date or DateTime column, its default formatter accepts custom DateTime format strings like dd/MM/yyyy. We don't suggest setting DisplayFormat for dates explicitly, use culture setting (not UI culture) in web.config unless a column has to display date/time in a different order than the default. You may also use following standard format strings: "d": dd/MM/yyyy where DMY order changes based on current culture. "g": dd/MM/yyyy HH:mm "G": dd/MM/yyyy HH:mm:ss "s": yyydd-MM-ddTHH:mm:ss "u": yyydd-MM-ddTHH:mm:ss.fffZ where DMY order changes based on current culture. where DMY order changes based on current culture. ISO sortable date time format. ISO 8601 UTC. If column is an integer, double or decimal it accepts .NET custom numeric format strings. 458 Attributes public class SomeColumns { [DisplayFormat("d")] public DateTime DateWithCultureDMYOrder { get; set; } [DisplayFormat("dd/MM/yyyy")] public DateTime DateWithConstantDMYOrder { get; set; } [DisplayFormat("g")] public DateTime DateTimeToMinWithCultureDMYOrder { get; set; } [DisplayFormat("dd/MM/yyyy HH:mm")] public DateTime DateTimeToMinConstantDMYOrder { get; set; } [DisplayFormat("G")] public DateTime DateTimeToSecWithCultureDMYOrder { get; set; } [DisplayFormat("dd/MM/yyyy HH:mm:ss")] public DateTime DateTimeToSecWithConstantDMYOrder { get; set; } [DisplayFormat("s")] public DateTime SortableDateTime { get; set; } [DisplayFormat("u")] public DateTime ISO8601UTC { get; set; } [DisplayFormat("#,##0.00")] public Decimal ShowTwoZerosAfterDecimalWithGrouping { get; set; } [DisplayFormat("0.00")] public Decimal ShowTwoZerosAfterDecimalNoGrouping { get; set; } } Placeholder Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Sets a placeholder for a form field. Placeholder is shown inside the editor with gray color when editor value is empty. Only basic input based editors and Select2 supports this. It is ignored by other editor types like Checkbox, Grid, FileUploadEditor etc. public class SomeForm { [Placeholder("Show this inside the editor when it is empty")] public string FieldWithPlaceHolder { get; set; } } Hint Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Sets a hint for a form field. 459 Attributes Hint is shown when field label is hovered. This has no effect on columns. public class SomeForm { [Hint("Show this when my caption is hovered")] public string FieldWithHint { get; set; } } CssClass Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Sets CSS class for grid columns and form fields. In forms, class is added to container div with .field class that contains both label and editor. For columns, it sets cssClass property of SlickColumn, which adds this class to slick cells for all rows. Slick column headers are not affected by this attribute, use [HeaderCssClass] for that. public class SomeForm { [CssClass("extra-class")] public string FieldWithExtraClass { get; set; } } public class SomeColumn { [CssClass("extra-class")] public string CellWithExtraClass { get; set; } } HeaderCssClass Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Sets CSS class for grid column headers. This has no effect for forms. 460 Attributes It sets headerCss property of SlickColumn, which adds this class to slick header for that column. public class SomeColumn { [HeaderCssClass("extra-class")] public string FieldWithExtraHeaderClass { get; set; } } AlignCenter Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Centers text horizontally. Used to control text alignment in grids by adding CSS class to align-center corresponding SlickGrid column. Column headers are not affected by this attribute. You may use for that. [HeaderCssClass("align-center")] Note that it has no effect on editors or forms. AlignRight Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Right aligns text horizontally. Used to control text alignment in grids by adding align-right CSS class to corresponding SlickGrid column. Column headers are not affected by this attribute. You may use [HeaderCssClass("align-right")] for that. Note that it has no effect on editors or forms. Ignore Attribute namespace: Serenity.ComponentModel, assembly: Serenity.Core Skips a property while generating grid column or form field list. 461 Attributes Use this to ignore a property for UI, but still use it for other purposes like JSON serialization. This might be useful when a type is used as a Service Request and Form Declaration at the same time. public class SomeColumns { [Ignore] public string DontGenerateAColumnForMe { get; set; } } 462 Grids Grids 463 Formatter Types Formatter Types URLFormatter This formatter lets you put a link with a URL to a grid column. It takes optional arguments below: Option Name Description This is the format of URL. A sample would be "http://www.site.com/{0}" where {0} is the UrlProperty value. If no format is specified, link will be the value of UrlProperty as is. UrlFormat If your URL format starts with "~/", it will be resolved to application root. For example, if format is "~/upload/{0}" and your application runs at "localhost:3045/mysite", resulting URL will be "/mysite/upload/xyz.png". This is name of the property that will be used to determine link URL. UrlProperty If not specified, it is the name of the column that this formatter is placed on. If UrlProperty value starts with "~/" it will be resolved like UrlFormat. This is the display text format of link. A sample would be "click to open {0}" where {0} is the DisplayProperty value. DisplayFormat If no format is specified, link will be the value of DisplayProperty as is. This is name of the property that will be used to determine link text. DisplayProperty If not specified, it is the name of the column that this formatter is placed on. Target This is the target of the link. Use "_blank" to open links in a new tab. 464 Persisting Settings Persisting Settings Serenity 2.1.5 introduced ability to persist grid settings including these details: Visible Columns and Display Order Column Widths Sorted Columns Advanced Filters (ones created with Edit Filter link on bottom right) Quick Filters (as of writing, not yet available) State of Include Deleted Toggle By default, grids doesn't automatically persist anything. Thus, if you hide some columns and navigate away from Orders page, when you come back, you'll see that those hidden columns became visible again. You need to turn on persistance for all grids, or for individual ones that you want them to remember their settings. Turning On Persistance by Default DataGrid has a static configuration parameter with name DefaultPersistanceStorage. This parameter controls where grids save their settings automatically by default. It is initially null. In ScriptInitialization.ts, you might turn on persistance for all grids by default like below: namespace Serene.ScriptInitialization { Q.Config.responsiveDialogs = true; Q.Config.rootNamespaces.push('Serene'); Serenity.DataGrid.defaultPersistanceStorage = window.sessionStorage; } This saves settings to browser session storage, which is a key/value dictionary that preserves data while any browser window stays open. When user closes all browser windows, all settings will be lost. Another option is to use browser local storage. Which preserves settings between browser restarts. Serenity.DataGrid.defaultPersistanceStorage = window.localStorage; 465 Persisting Settings Using any of the two options, grids will start to remember their settings, between page reloads. Handling a Browser that is Shared by Multiple Users Both sessionStorage and localStorage is browser scoped, so if a browser is shared by multiple users, they'll have same set of settings. If one user changes some settings, and logs out, and other one logs in, second user will start with settings of the first user (unless you clear localStorage on signout) If this is a problem for your application, you may try writing a custom provider: namespace Serene { export class UserLocalStorage implements Serenity.SettingStorage { getItem(key: string): string { return window.localStorage.getItem( Authorization.userDefinition.Username + ":" + key); } setItem(key: string, value: string): void { window.localStorage.setItem( Authorization.userDefinition.Username + ":" + key, value); } } } //... Serenity.DataGrid.defaultPersistanceStorage = new UserLocalStorage(); Please note that this doesn't provide any security. It just lets users have separate settings. Setting Persistance Storage Per Grid Type To turn on persistance, or change target storage for a particular grid, override getPersistanceStorage method: 466 Persisting Settings namespace Serene.Northwind { //... export class OrderGrid extends Serenity.EntityGrid { //... protected getPersistanceStorage(): Serenity.SettingStorage { return window.localStorage; } } } You may also turn off persistance for a grid class by returning null from this method. Determining Which Setting Types Are Saved By default, all settings noted at start are saved, like visible columns, widths, filters etc. You may choose to not persist / restore specific settings. This is controlled by getPersistanceFlags method: namespace Serene.Northwind { //... export class OrderGrid extends Serenity.EntityGrid { //... protected getPersistanceFlags(): GridPersistanceFlags { return { columnWidths: false // dont persist column widths; } } } } Here is the set of complete flags: interface GridPersistanceFlags { columnWidths?: boolean; columnVisibility?: boolean; sortColumns?: boolean; filterItems?: boolean; quickFilters?: boolean; includeDeleted?: boolean; } When Settings Are Saved / Restored Settings are automatically saved when you change something with a grid like: 467 Persisting Settings Choosing visible columns with Column Picker dialog Resizing a column manually Editing advanced filter Dragging a column, changing position Changing sorted columns Settings are restored on first page load, just after grid creation. Persisting Settings to Database (User Preferences Table) Serene 2.1.5 comes with a UserPreferences table that you may use as a persistance storage. To use this storage, you just need to set it as storage similar to other storage types. /// Serenity.DataGrid.defaultPersistanceStorage = new Common.UserPreferenceStorage(); Don't forget to add reference statement, or you'll have runtime errors, as TypeScript has problems with ordering otherwise. OR namespace Serene.Northwind { //... export class OrderGrid extends Serenity.EntityGrid { //... protected getPersistanceStorage(): Serenity.SettingStorage { return new Common.UserPreferenceStorage(); } } } Manually Saving / Restoring Settings If you need to save / restore settings manually, you may use methods below: protected getCurrentSettings(flags?: GridPersistanceFlags): PersistedGridSettings; protected restoreSettings(settings?: PersistedGridSettings, flags?: GridPersistanceFla gs): void; These are protected methods of DataGrid, so can only be called from subclasses. 468 Persisting Settings 469 Code Generator (Sergen) Code Generator (Sergen) Sergen has some extra options that you may set through its configuration file (Serenity.CodeGenerator.config) in your solution directory. Here is the full set of options: public class GeneratorConfig { public List Connections { get; set; } public string KDiff3Path { get; set; } public string TFPath { get; set; } public string TSCPath { get; set; } public bool TFSIntegration { get; set; } public string WebProjectFile { get; set; } public string ScriptProjectFile { get; set; } public string RootNamespace { get; set; } public List BaseRowClasses { get; set; } public List RemoveForeignFields { get; set; } public bool GenerateSSImports { get; set; } public bool GenerateTSTypings { get; set; } public bool GenerateTSCode { get; set; } } Connections, RootNamespace, WebProjectFile, ScriptProjectFile, GenerateSSImports, GenerateSSTypings and GenerateTSCode options are all available in user interface, so we'll focus on other options. KDiff3 Path Sergen tries to launch KDiff3 when it needs to merge changes to an existing file. This might happen when you try to generate code for an entity again. Instead of overriding target files, Sergen will execute KDiff3. Sergen looks for KDiff3 at its default location under C:\Program Files\Kdiff3, but you may override this path with this option, if you installed Kdiff3 to another location. TFSIntegration and TFPath For users that work with TFS, Sergen provides this options to make it possible to checkout existing files and add new ones to source control. Set TFSIntegration to true, if your project is versioned in TFS, and set TFPath if tf.exe is not under its default location at C:\Program Files\Visual Studio\x.y\Common7\ide\ 470 Code Generator (Sergen) { // ... "TFSIntegration": true, "TFPath": "C:\Program Files\....\tf.exe" } RemoveForeignFields By default, Sergen examines your table foreign keys, and when generating a row class, it will bring all fields from all referenced foreign tables. Sometimes, you might have some fields in foreign tables, e.g. some logging fields like InsertUserId, UpdateDate etc. that wouldn't be useful in another row. You'd be able to remove them manually after code generation too, but using this option it might be easier. List fields you want to remove from generated rows as an array of string: { // ... "RemoveForeignFields": ["InsertUserId", "UpdateUserId", "InsertDate", "UpdateDate"] } Note that this doesn't remove this fields from table row itself, it only removes these view fields from foreign joins. BaseRowClasses If you are using some base row class, e.g. something like LoggingRow in Serene, you might want Sergen to generate your rows deriving from these base classes. For this to work, list your base classes, and the fields they have. { // ... "BaseRowClasses": [{ "ClassName": "Serene.Administration.LoggingRow", "Fields": ["InsertUserId", "UpdateUserId", "InsertDate", "UpdateDate"] }] } 471 Code Generator (Sergen) If Sergen determines that a table has all fields listed in "Fields" array, it will set its base class as "ClassName", and will not generate these fields explicity in row, as they are already defined in base row class. It is possible to define more than one base row class. Sergen will choose the base row class with most matching fields, if a row's fields matches more than one base class. 472 Used Tools & Libraries Used Tools and Libraries Serenity platform makes use of some valuable open source tools and libraries that are listed below (in alphabetic order) This list might seem a bit long, but not all of them are direct dependencies for a Serenity Application. Some of them are only used during development of Serenity platform itself, while some are dependencies for optional features. We tried to reuse open source libraries, where there is a quality one available to avoid reinventing the wheel. Autonumeric (https://github.com/BobKnothe/autoNumeric) BlockUI (https://github.com/malsup/blockui/) Bootstrap (https://github.com/twbs/bootstrap) Cake Build (https://github.com/cake-build/cake) Cecil (https://github.com/jbevain/cecil) Clean-CSS [Node] (https://github.com/jakubpawlowicz/clean-css) Colorbox (https://github.com/jackmoore/colorbox) Dapper (https://github.com/StackExchange/dapper-dot-net) DialogExtend (https://github.com/ROMB/jquerydialogextend) jLayout (https://github.com/bramstein/jlayout) Json.NET (https://github.com/JamesNK/Newtonsoft.Json) 473 Used Tools & Libraries JSON2 (https://github.com/douglascrockford/JSON-js) JSRender (https://github.com/BorisMoore/jsrender) jQuery (https://github.com/jquery/jquery) jQuery Cookie (https://github.com/carhartl/jquery-cookie) jQuery Validation (https://github.com/jzaefferer/jqueryvalidation) jQuery UI (https://github.com/jquery/jquery-ui) jQuery.event.drag (http://threedubmedia.com/code/event/drag) Less.JS (Node) (https://github.com/less/less.js) Linq.js (http://linqjs.codeplex.com/) metisMenu (https://github.com/onokumus/metisMenu) Munq (https://munq.codeplex.com/) NodeJS (https://github.com/joyent/node) Pace (https://github.com/HubSpot/pace) PhantomJS (https://github.com/ariya/phantomjs) RazorGenerator (https://razorgenerator.codeplex.com/) RSVP (https://github.com/tildeio/rsvp.js/) Saltarelle Compiler (https://github.com/erikkallen/SaltarelleCompiler) 474 Used Tools & Libraries Select2 (https://github.com/ivaynberg/select2) SlickGrid (https://github.com/mleibman/SlickGrid) Toastr (https://github.com/CodeSeven/toastr) UglifyJS2 (Node) (https://github.com/mishoo/UglifyJS2) XUnit (https://github.com/xunit/xunit) 475

Source Exif Data:
File Type                       : PDF
File Type Extension             : pdf
MIME Type                       : application/pdf
PDF Version                     : 1.4
Linearized                      : No
Author                          : Volkan Ceylan
Create Date                     : 2017:10:26 06:48:23+00:00
Producer                        : calibre 2.57.1 [http://calibre-ebook.com]
Title                           : Serenity Developer Guide
Description                     : Tutorials and API documentation for Serenity which is an ASP.NET MVC application platform designed to simplify and shorten development of data-centric business applications with a service based architecture.
Publisher                       : GitBook
Subject                         : 
Creator                         : Volkan Ceylan
Language                        : en
Metadata Date                   : 2017:10:26 06:48:23.926104+00:00
Timestamp                       : 2017:10:26 06:48:07.838443+00:00
Page Count                      : 475
EXIF Metadata provided by EXIF.tools

Navigation menu