0. Serenity Study Notes - lyonwang/TechNotes GitHub Wiki

1. Installation

a. Installing Serene From Visual Studio Marketplace

b. Installing Serene Asp.Net Core Version with NPM

  • Install .NET Core 3.1 SDK
  • Install NodeJS

Install SERIN as a Global Tool

npm install -g serin
  • Run Serin to Create a New Project
serin
dotnet sergen g

2. Start Serenity

a. DB Setting in appsetting.json

  "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_v1;Integrated Security=true",
      "ProviderName": "System.Data.SqlClient"
    }
  }

b. localdb

sqllocaldb info

sqllocaldb create MsSqlLocalDB

3. Features

a. Theming

Style: 在 wwwroot/Content/adminlte/skins/

_all-skins.less
skin.[COLOR]-light.less
site.[COLOR].less
site.[COLOR]-light.less
site.[COLOR].less

Build command 參考 project file 中以下段落

<Target Name="CompileSiteLess" AfterTargets="AfterBuild">
    <Exec Command="&quot;$(ProjectDir)tools\node\lessc.cmd&quot;
        &quot;$(ProjectDir)Content\site\site.less&quot; &gt;
        &quot;$(ProjectDir)Content\site\site.css&quot;">
    </Exec>
</Target>

結果反應在 tag 中

<body id="s-DashboardPage" class="fixed sidebar-mini hold-transition skin-blue has-layout-event">

b. Localization

Default Translation file location: wwwroot/scripts/site/texts

User defined translation file location: ~/App_Data/texts

修改後的內容會放在 ~/AppData/user.texts.LANGUAGE.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.

c. User and Role Management

Edit Role3s: Administration > Roles

admin is a special account, please don't change its role assigment.

Edit User: User Management

New and Save a user before edit permission

d. Listing Pages: UI 操作 (Northwind/Products)

Grid component is SlickGrid with a customized theme.

Dropdown component used is Select2

All sorting, paging and filtering is done on server side with dynamic SQL queries generated by Serenity service handlers.

e. Edit Dialogs: UI 操作 (Northwind/Products)

Dialog itself is a customized version of jQuery UI dialog.

4. Tutorials

Please make sure that you have TypeScript 3.9.5+ installed. Check your version from Visual Studio Extensions dialog.

npm install -g typescript

透過 FluentMigrator 做資料轉移

執行轉移後,檢查 Database 中,dbo.VersionInfo 是否有正確執行

Sergen

在 Serenity 命名中,Module 是指一群頁面(page)的邏輯組合: 程式碼產生在 /Modules 目錄中

  • Pages that are related to general management of site, like users, roles etc. belongs to Administration module.
  • 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.
  • Module names must be in Pascal case, e.g. something that starts with a CAPITAL letter.
  • class Identifier: 用來當作 Controller 的名稱、entity 的 class 命名:entity class will be named {class identifier}Row,
  • Identifier must always be in Pascal case, e.g. something that starts with a CAPITAL letter.
  • Permission Key 一般保留為 Administration:General
  • Next, Sergen will ask you which files to generate: R:Row, S:Repo+Svc, U=UI, C=Custom

sergen 產生以下檔案

Modules\{module name}\{class identify}\{class identify}Column.cs  -- 表單主頁顯示欄位 Model 
Modules\{module name}\{class identify}\{class identify}Dialog.ts  -- 編輯頁 Javascript
Modules\{module name}\{class identify}\{class identify}Form.cs    -- 編輯頁指定顯示欄位表單元件
Modules\{module name}\{class identify}\{class identify}Grid.ts    -- 表單主頁 Javascript
Modules\{module name}\{class identify}\{class identify}Row.cs     -- 資料庫對應 Entity Class
Modules\{module name}\{class identify}\{class identify}EndPoint.cs -- API Service Call Class
Modules\{module name}\{class identify}\*.*                        -- 資料庫表單相關類
notes: There are two places to set editor type for GenreId field. One is MovieForm.cs, other is MovieRow.cs.
       As we added a new entity to our application, we should rebuild solution.

Modules\{module name}\{module name}Navigation.cs                  -- Navigation
wwwroot\Content\site\site.{module name lowercase}.less            -- import to site.less

Customization

  • Column DisplayName: {class identify}Row.cs
  • Overriding Column Title and Width: {class name}Columns.cs Any attribute written here will override attributes defined in the entity class ({class identify}Row.cs).
  • Changing Editor Type For Description and Storyline => 編輯畫面: {class identify}Form.cs [TextAreaEditor(Rows = 3)] for Description, Storyline columns
  • Setting Initial Dialog Size With CSS (Less): wwwroot\Content\site\site.{module name lowercase}.less CSS class .s-MovieDB-MovieDialog
  • Changing Page Title: {class identify}Row.cs class attribute DisplayName
  • Setting Navigation Item Title and Icon: Modules\MovieDB\MovieDBNavigation.cs

http://thesabbir.github.io/simple-line-icons/

https://fontawesome.com/v4.7.0/icons/

  • Ordering Navigation Sections Move Modules{module name}{class identify}{module name}Navigation.cs
ex:
[assembly: NavigationLink(int.MaxValue, "Movie Database/Movies", typeof(MyPages.MovieController), icon: "fa-video-camera")]

to Modules/Common/Navigation/NavigationItems.cs and the order parameter lower on top (ex: Movie items on top on Dashboard below)

ex:
[assembly: NavigationLink(2000, "Dashboard", url: "~/", permission: "", icon: "fa-tachometer")]

[assembly: NavigationMenu(1000, "Movie Database", icon: "fa-film")]
[assembly: NavigationLink(1100, "Movie Database/Movies", typeof(MovieDB.MovieController), icon: "fa-video-camera")]
  • Customizing Quick Search Column fields in {class identify}Row.cs contains QuickSearch in attribute
ex:
[DisplayName("Title"), Size(200), NotNull, QuickSearch(SearchType.StartsWith)]
public String Title
{
...
}
[DisplayName("Year"), QuickSearch(SearchType.Equals, numericOnly: 1)]
public Int32? Year
{
...
}
...

It is also possible to provide user with ability to determine which field she wants to search on.

Modules/{module name}/{class identify}/{class identify}Grid.ts ex:

namespace MovieTutorial.MovieDB
{
    //...
    public class MovieGrid extends EntityGrid<MovieRow, any>
    {
        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) }
            ];
        }
    }
    ///...
}

Local text keys for row fields are generated from "Db." + (LocalTextPrefix for Row) + "." + FieldName.

  • Adding a Field Add Migration

Add Entity Entry in Modules/{module name}/{class identify}/{class identify}Row.cs ex:

[DisplayName("Runtime (mins)")]
public Int32? Runtime
{
    get { return Fields.Runtime[this]; }
    set { Fields.Runtime[this] = value; }
}
...
public class RowFields : RowFieldsBase
{
    // ...
    public readonly Int32Field Runtime;

    public RowFields()
        : base("[mov].Movie")
    {
        LocalTextPrefix = "MovieDB.Movie";
    }
}

Add Field to Form: Modules/{module name}/{class identify}/{class identify}Form.cs ex:

namespace MovieTutorial.MovieDB.Forms
{
    // ...
    [FormScript("MovieDB.Movie")]
    [BasedOnRow(typeof(Entities.MovieRow))]
    public class MovieForm
    {
        // ...
        public MovieKind Kind { get; set; }
        public Int32 Runtime { get; set; }
    }
}

Add Field to display Cloumn: Modules/{module name}/{class identify}/{class identify}Columns.cs ex:

namespace MovieTutorial.MovieDB.Forms
{
    // ...
    public class MovieColumns
    {
        ...
        public Int32 Runtime { get; set; }
        public MovieKind Kind { get; set; }
    }
    //...
}

Add foreign key column

Add [LookupScript("{module name}.{table name}")] to Entity cs file => rebuild to generate => client side javascript Q.getLookup('{module name}.{table name}') can access field data

[LookupEditor("{module name}.{foreign table name}")] in Main table entity class: {table name}Row.cs foreign key in foreign table

Add QuickFilter for Fields, in {class identify}Columns.cs ex:

    [Width(100), QuickFilter]
    public String GenreName { get; set; }
  • Serenity Nuget package update
Serenity.FluentMigrator.Runner
Serenity.Scripts
Serenity.Web
Serenity.Web.Assets

Command prompt:

dotnet tool update sergen
dotnet restore
dotnet sergen restore       --static files, nuget not support these files: Content/serenity/serenity.css, Scripts/serenity/Serenity.CoreLib.js

Serene Project template for Visual Studio, if you want you serene project up-to-date, manually update it like previous say.

Note: 20201223 請勿 update, sergen 出來的 code 會無法編譯

  • M-N releations, ex: LookupEditor set database column not map this field; LinkingSetRelation set M-N relation (target row class, pkey, fkey)
[DisplayName("Genres")]
[LookupEditor(typeof(GenreRow), Multiple = true), NotMapped]
[LinkingSetRelation(typeof(MovieGenresRow), "MovieId", "GenreId")]      -- 
public List<Int32> GenreList
{
    get { return Fields.GenreList[this]; }
    set { Fields.GenreList[this] = value; }
}
  • Creating ListFormatter Class in typescript file {class identify name}Grid.ts => format display in form

All formatters should implement Slick.Formatter interface, which has a format method that takes a ctx parameter of type Slick.FormatterContext.

Rebuild, then add formatter to attribute:

[Width(200), GenreListFormatter]
public List<Int32> GenreList { get; set; }
  • Transform Templates: Modules/{module name}/{class identify}/{class identify name}Row.cs ex:
StringField INameRow.NameField
{
    get { return Fields.Fullname; }
}

Generate Import/ServerTypes/{module name}.{class identify name}Row.cs

  • Master/Detail Editing

Create Editor Modules/{module name}/{class identify}/{class identify}Editor.ts (derived from GridEditorBase)

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.

ex:

/// <reference path="../../Common/Helpers/GridEditorBase.ts" />

namespace MovieTutorial.MovieDB {
    @Serenity.Decorators.registerEditor()
    export class MovieCastEditor
        extends Common.GridEditorBase<MovieCastRow> {
        protected getColumnsKey() { return "MovieDB.MovieCast"; }
        protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }

        constructor(container: JQuery) {
            super(container);
        }
    }
}   

The ≤reference /> 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.

Use Editor in Form, ex:

namespace MovieTutorial.MovieDB.Forms
{
    //...
    public class MovieForm
    {
        public String Title { get; set; }
        [TextAreaEditor(Rows = 3)]
        public String Description { get; set; }
        [MovieCastEditor]
        public List<Entities.MovieCastRow> CastList { get; set; }
        [TextAreaEditor(Rows = 8)]
        public String Storyline { get; set; }
        //...
    }
}

Configuring Editor to Use Dialog

Create a {class identify}EditDialog.ts file next to {class identify}Editor.ts and modify it like below:

/// <reference path="../../Common/Helpers/GridEditorDialog.ts" />

namespace MovieTutorial.MovieDB {

    @Serenity.Decorators.registerClass()
    export class MovieCastEditDialog extends 
          Common.GridEditorDialog<MovieCastRow> {
        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);
        }
    }
}

Open {class identify}Editor.ts again, add a getDialogType method and override getAddButtonCaption:

/// <reference path="../../Common/Helpers/GridEditorBase.ts" />

namespace MovieTutorial.MovieDB {
    @Serenity.Decorators.registerEditor()
    export class MovieCastEditor 
          extends Common.GridEditorBase<MovieCastRow> {
        protected getColumnsKey() { return "MovieDB.MovieCast"; }
        protected getDialogType() { return MovieCastEditDialog; }
        protected getLocalTextPrefix() { return MovieCastRow.localTextPrefix; }

        constructor(container: JQuery) {
            super(container);
        }

        protected getAddButtonCaption() {
            return "Add";
        }
    }
}

Fullname = Firstname + Lastname like entity fields combind data, Fullname is an in-memory field, we should get it back to Entity.

ex:

/// <reference path="../../Common/Helpers/GridEditorBase.ts" />

namespace MovieTutorial.MovieDB {
    @Serenity.Decorators.registerEditor()
    export class MovieCastEditor extends Common.GridEditorBase<MovieCastRow> {
        //...

        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 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.

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.

  • Add Tab page in Dialog

EntityDialog template at MovieTutorial.Web/Views/Templates/EntityDialog.Template.html.

<div class="s-DialogContent">
    <div id="~_Toolbar" class="s-DialogToolbar">
    </div>
    <div class="s-Form">
        <form id="~_Form" action="">
            <div class="fieldset ui-widget ui-widget-content ui-corner-all">
                <div id="~_PropertyGrid"></div>
                <div class="clear"></div>
            </div>
        </form> 
    </div>
</div>

~_ 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.

  1. Defining a Tabbed Template for PersonDialog

Create a new file, {module name}.{class identify}Dialog.Template.html under Modules/{module name}/{class identify}/ folder with contents:

<div id="~_Tabs" class="s-DialogContent">
    <ul>
        <li><a href="#~_TabInfo"><span>Person</span></a></li>
        <li><a href="#~_TabMovies"><span>Movies</span></a></li>
    </ul>
    <div id="~_TabInfo" class="tab-pane s-TabInfo">
        <div id="~_Toolbar" class="s-DialogToolbar">
        </div>
        <div class="s-Form">
            <form id="~_Form" action="">
                <div class="fieldset ui-widget ui-widget-content ui-corner-all">
                    <div id="~_PropertyGrid"></div>
                    <div class="clear"></div>
                </div>
            </form>
        </div>
    </div>
    <div id="~_TabMovies" class="tab-pane s-TabMovies">
        <div id="~_MoviesGrid">

        </div>
    </div>
</div>

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.

  1. Creating second tab Grid

Add new {class name}{other class name}Column.cs like:

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; }
    }
}

Add new {class name}{other class name}Grid.ts like:

namespace MovieTutorial.MovieDB {

    @Serenity.Decorators.registerClass()
    export class PersonMovieGrid extends Serenity.EntityGrid<MovieCastRow, any>
    {
        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);
        }
    }
}

Instantiate Grid in {class name}Dialog.ts like:

@Serenity.Decorators.registerClass()
@Serenity.Decorators.responsive()
export class PersonDialog extends Serenity.EntityDialog<PersonRow, any> {
    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();
        });
    }
}

Filter {class name} in {Other class}

ex:

namespace MovieTutorial.MovieDB
{
    @Serenity.Decorators.registerClass()
    export class PersonMovieGrid extends Serenity.EntityGrid<MovieCastRow, any>
    {
        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();
            }
        }
    }
}
  • Adding Primary and Gallery Images

ImageUploadEditor in {class name}Row.cs, ex:

[DisplayName("Primary Image"), Size(100), ImageUploadEditor(FilenameFormat = "Movie/PrimaryImage/~")]

MultipleImageUploadEditor in {class name}Row.cs, ex:

[DisplayName("Gallery Images"), MultipleImageUploadEditor(FilenameFormat = "Movie/GalleryImages/~")]

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.

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.

ex:

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}.

Add fields to form.

⚠️ **GitHub.com Fallback** ⚠️