Serenity 6.6.0 Release Notes (2023-03-15)
New Criteria Builder for TypeScript
Unlike C#, Javascript has no operator overloading feature, so we had to design the Criteria system differently on the client side.
We also choose to use an array-based format for passing Criteria objects from the browser to the server. On the server, the Criteria objects have a polymorphic tree structure. The format we choose for Javascript is designed to be easily JSON serializable/de-serializable while keeping the types of constant values like numbers, strings, booleans, etc. intact.
For example, [["Amount"], '>=', 5]
corresponds to a criteria expression Amount >= 5
. Note the extra parenthesis around "Amount"
. If we didn't have that it would correspond to a criteria expression 'Amount' >= 5
e.g. the string
'Amount'
greater than or equal to 5
", instead of "the field
Amount
greater than or equal to 5".
The reasoning behind this is, it is allowed to have a constant in either part of the binary criteria, so we don't blindly assume that the first parameter is a field name
. It can only be a field name
if it is an array
containing a single string
. If it is not, it is a string
value.
So, [5, '>=', ['Amount']]
is equal to the expression: 5 >= Amount
.
Another reason for this is future extensibility; so that we can have criteria like the following:
[['Amount1'], '+', ['Amount2'], '>=', 5]
Which would be an expression corresponding to Amount1 + Amount2 >= 5
. Note that such arithmetic operators are not yet supported.
For unary operators, the array syntax expects the operator to be the first element:
['is null', ['Amount']]
=> Amount IS NULL
We have some helper methods like the Criteria.and()
, Criteria.or()
, and Criteria.Operator.isNull
, etc. constants so that you don't have to remember operator names, but you still have to remember the operator ordering:
Criteria.or(
Criteria.and(
[Criteria.Operator.isNotNull, ['Amount']],
[['Amount'] >= 5]
),
[['Status'], Criteria.Operator.in, [[1, 2, 3]]]
)
((Amount is not null) and (Amount >= 5)) or (Status in (1, 2, 3))
Notice the extra array used for the IN
operator? That's another implementation detail that you should be careful about.
Luckily, we now introduced a criteria builder function to make the above statement easier to type:
Criteria.or(
Criteria.and(
Criteria('Amount').isNotNull(),
Criteria('Amount').ge(5)
),
Criteria('Status').in([1, 2, 3])
)
It should be more readable than the previous version with code completion support, and as a bonus, you won't have to worry about the implementation details anymore.
New Criteria.parse
Function
If the criteria builder we mentioned above is still not friendly enough, or you would prefer to type criteria expressions in a simple string similar to an SQL WHERE
statement, use the new Criteria.parse
function:
Criteria.parse("((Amount is not null) and (Amount >= 5)) or (Status in (1, 2, 3))")
What if you wanted to make some of the values dynamic, e.g. get them from the UI? No, we will not suggest using string concatenation in any case.
One of the reasons to avoid string concatenation to build SQL statements is to avoid SQL injection attacks. That risk does not apply here as this string is parsed on the client and converted to a JSON array before being passed to the server.
But it is possible to create invalid expressions, causing parsing errors, especially if the values are strings that might contain quotes.
So what do we do here? We use the parameterized syntax:
Criteria.parse("((Amount is not null) and (Amount >= @a)) or (Status in @s)`, {
a: 5,
s: [1, 2, 3]
})
This is similar to Dapper but please note that you should not use parenthesis with the IN
statement with parameters. E.g. Status in (@s)
is invalid, and Status in (@s1, @s2)
is also invalid. The correct version is Status in @s
where the value of s is an array.
OK, but still it might be possible to make typos for parameter names. And our answer to that is a special tagged string syntax we recommend:
Criteria.parse`((Amount is not null) and (Amount >= ${5})) or (Status in ${[1, 2, 3]})`
Looks much cleaner, don't you think?
New Source Generator for ESM Entry Points
Serenity.Pro.Coder
has a new source generator for paths to ESM entry points, e.g. the output paths of ESM modules under wwwroot/esm/**/*Page.js
similar to what the MVC generator does for CSHTML files.
So when specifying the module for your pages, you will have compile time checking and intelli-sense support:
[Route("Administration/Language")]
public ActionResult Index()
{
// instead of this.GridPage("@/Administration.Language.LanguagePage",
return this.GridPage(ESM.Modules.Administration.Language.LanguagePage,
LanguageRow.Fields.PageTitle());
}
It is Now Recommended to Have a Default Export for Pages
For modular style page init scripts, like LanguagePage.ts
we had a syntax like the one below:
import { initFullHeightGridPage } from "@serenity-is/corelib/q"
import { LanguageGrid } from "./LanguageGrid";
$(function() {
initFullHeightGridPage(new LanguageGrid($('#GridDiv')).element);
});
// additional export just for demo
export class SomeOtherClass {
}
This initializes the page, e.g. executes the function wrapped in $ (jQuery)
as soon as the module is imported anywhere.
Although it is fine as long as you use the module only for the relevant page, there are some issues:
- It is not so testable
- It is not possible to use any exports you might have there without executing the page initialization block.
- If scripts for multiple pages are bundled together, both of their initialization blocks may execute.
- It is not possible to easily pass options to the initialization script
We now recommend an alternative syntax with a default export:
import { initFullHeightGridPage } from "@serenity-is/corelib/q"
import { LanguageGrid } from "./LanguageGrid";
export default function pageInit() {
initFullHeightGridPage(new LanguageGrid($('#GridDiv'), opt).element);
}
// export default function pageInit(opt?: SomeOptions) {
// if you wanted to accept options, and pass them to the grid
// or use them in another way
// additional export just for demo
export class SomeOtherClass {
}
And now:
- Possible to import, execute and test the initialization function selectively
- Pass it options
- Import SomeOtherClass
To use this syntax, you should switch to the GridPage
extension method in Serenity.Extensions
package we'll describe in the next topic, or use the syntax below in your view:
<script type="module">
import pageInit from '@Html.ResolveWithHash("~/esm/Modules/Administration/Language/LanguagePage.js")';
pageInit();
</script>
GridPage Extensions are moved into the Serenity.Extensions
Package
During the switch to ES modules, we added an extension method called GridPage
to Serene
and StartSharp
to be able to initialize pages written as ESM from the controller:
[PageAuthorize(typeof(LanguageRow))]
public class LanguageController : Controller
{
[Route("Administration/Language")]
public ActionResult Index()
{
return this.GridPage("@/Administration/Language/LanguagePage",
LanguageRow.Fields.PageTitle());
}
}
There is no LanguageIndex.cshtml
for this page as it is using the Views/Shared/GridPage.cshtml
.
We got some comments from users thinking it is not possible to use ordinary Index.cshtml files to initialize modular pages anymore. You could still use classic views if you wanted:
[PageAuthorize(typeof(LanguageRow))] public class LanguageController : Controller { [Route("Administration/Language")] public ActionResult Index() { return View("~/Modules/Administration/Language/LanguageIndex.cshtml"); } }
// LanguageIndex.cshtml @inject Serenity.ITextLocalizer Localizer @{ ViewData["Title"] = Localizer.Get("Db.Administration.Language.EntityPlural"); } <div id="GridDiv"></div> <script type="module" src="@Html.ResolveWithHash("~/esm/Modules/Administration/Language/LanguagePage.js")"> </script>
As this extension and its view were placed in Serene/StartSharp, it was not possible to use them from feature packages, like our sample ones. So we now moved them into Serenity.Extensions
.
It is recommended to remove GridPageExtensions.cs
, GridPage.cshtml
files from your project and use the ones in Serenity.Extensions
instead.
The new one also provides a PanelPage
that creates a PanelDiv instead of a GridDiv.
And GridPage
and PanelPage
have overloads that use the new ModelPage
extension which accepts a ModulePageModel
with additional parameters:
public class ModulePageModel
{
public string HtmlMarkup { get; set; } // markup, which is <div id="GridDiv"></div> for GridPage
public object Options { get; set; } // options to pass to the pageInit method
public string Layout { get; set; } // set layout to another view, e.g. _LayoutNoNavigation etc.
public string Module { get; set; } // the path to esm module
public string PageId { get; set; } // set s-PageId class in body, might be used for css etc.
public LocalText PageTitle { get; set; } // page title
}
Serenity Demo Module Views are Converted to ES Modules
Since we switched to ES modules, we are converting most of our code to modular TypeScript syntax. Even though the @serenity-is/corelib
, @serenity-is/extensions
, and most of our other features are rewritten as ES modules, at runtime they still work in the namespaces mode as we still have to support users who have namespace style code.
So even though the TypeScript code was converted to ES modules, the code in their views where written as namespace style javascript, confusing our users.
As Serenity.Demo.BasicSamples
and Serenity.Demo.AdvancedSamples
modules are not directly referenced or imported by your applications in TypeScript code, we decided to convert their views into ES modules as well.
During this conversion, we also merged their Grid.ts
, Dialog.ts
, etc. files into a Page.ts
file with a default pageInit()
export, so that the TypeScript code for the sample can be viewed via just one file.
They don't yet use the GridPage
or PanelPage
extensions as their views contain extra HTML markup and links for sample-specific information.
We recommend having a look at their source code to better understand the changes.
Here is a simplified diff for EntityDialogAsPanel
:
- <li><a href="javascript:myDialogAsPanel.load({}, function() { Q.notifySuccess('Switched to new record mode'); })">Switch to new record mode with Javascript</a></li>
+ <li><a id="SwitchToNewRecordMode" href="javascript:;">Switch to new record mode with Javascript</a></li>
- <li><a href="javascript:myDialogAsPanel.load(11048, function() { Q.notifySuccess('Loaded entity with ID 11048'); })">Load Order with ID 11048 with javascript</a></li>
+ <li><a id="LoadEntityWithId" href="javascript:;">Load Order with ID 11048 with javascript</a></li>
- @Html.BasicSamplesSourceFile("EntityDialogAsPanel.ts")
+ @Html.BasicSamplesSourceFile("EntityDialogAsPanelPage.ts")
<div id="DialogDiv"></div>
-<script type="text/javascript">
- var myDialogAsPanel;
- jQuery(function () {
- // first create a new dialog
- myDialogAsPanel = new Serenity.Demo.BasicSamples.EntityDialogAsPanel();
- // load a new entity if url doesn't contain an ID, or load order with ID specified in page URL
- // here we use done event in second parameter, to be sure operation succeeded before showing the panel
- myDialogAsPanel.load(@Html.Raw(Model.ToJson()) || {}, function() {
- // if we didn't reach here, probably there is no order with specified ID in url
- myDialogAsPanel.element.removeClass('hidden').appendTo('#DialogDiv');
- myDialogAsPanel.arrange();
- });
- });
+<script type="module">
+ import pageInit from '@Html.ResolveWithHash(Html.BasicSamplesModuleFile("EntityDialogAsPanelPage.js"))';
+ pageInit(@Json.Serialize(Model));
Note that we are using <script type=module>
in place of a regular script block. And instead of referencing the global namespace version like new Serenity.Demo.BasicSamples.EntityDialogAsPanel()
, we are importing the page init function from the module EntityDialogAsPanelPage.js
.
The code in the removed script block and the ones embedded in the href attributes are moved to the Page.ts
file:
export default function pageInit(model: any) {
// first create a new dialog, store it in globalThis,
// e.g. window so that sample can access it from outsite the module
var myDialogAsPanel = new EntityDialogAsPanel();
$('#SwitchToNewRecordMode').click(() => {
myDialogAsPanel.load({}, function() { notifySuccess('Switched to new record mode'); });
});
$('#LoadEntityWithId').click(() => {
myDialogAsPanel.load(11048, function() { notifySuccess('Loaded entity with ID 11048'); })
});
// load a new entity if url doesn't contain an ID, or load order with ID specified in page URL
// here we use done event in second parameter, to be sure operation succeeded before showing the panel
myDialogAsPanel.load(model || {}, function () {
// if we didn't reach here, probably there is no order with specified ID in url
myDialogAsPanel.element.removeClass('hidden').appendTo('#DialogDiv');
myDialogAsPanel["arrange"]?.();
});
}
Moving the code blocks into the page script will help with compile time error checking and let us use intelli-sense.
Service Endpoints Derives from ControllerBase Instead of Controller
ControllerBase
is the base class of Controller
which adds view support in addition to a few other features related to pages.
Our Endpoint
(Service) classes don't require views, so we decided to use ControllerBase
as the base class of ServiceEndpoint
.
If you forgot to update Serenity.Pro.Coder
or run dotnet sergen update
after updating Serenity packages, as the base class for ServiceEndpoint
is changed, old versions of these generators won't consider your service classes as valid controllers, and you may get TypeScript errors. So please update both Serenity.Pro.Coder
and sergen
and restart Visual Studio.
Also, if for some reason you returned a View
from a ServiceEndpoint
action, you will have to move that action into a page controller.
Page and Endpoint Suffixes are Used Instead of Controller
ASP.NET MVC only considered classes whose name ended with Controller
suffix to be used as Controllers, e.g. C in the MVC. It ignored classes with other types of names even if they derive from the Controller
class.
So we had to name both our page controller and service endpoint classes as LanguageController
and put them into different namespaces. Even though their files were named LanguagePage.cs
and LanguageEndpoint.cs
, the classes they contain were both named LanguageController
.
Not sure which version they fixed this, but in ASP.NET Core this is no longer necessary. Any class which has the [Controller]
attribute, regardless of the name is considered a controller. The classic Controller
class derives from the ControllBase
class, which has the [Controller]
attribute so as long as your class derives from one of them it is considered a controller.
We updated sergen
and Serenity.Pro.Coder
to handle new suffixes Page
and Endpoint
, and renamed all our classes accordingly.
Some of these controllers like Serenity.Pro.Meeting.MeetingController
were used in Startup.cs
to register their assemblies:
typeof(Serenity.Pro.DataAuditLog.DataAuditLogController).Assembly,
typeof(Serenity.Pro.DataExplorer.DataExplorerController).Assembly,
typeof(Serenity.Pro.EmailClient.MailboxController).Assembly,
typeof(Serenity.Pro.EmailQueue.EmailQueueController).Assembly,
//...
We left them as an abstract obsolete copy of the corresponding Page classes, so you will only get warnings after updating Serenity packages, but you are recommended to change them to Page versions:
typeof(Serenity.Pro.DataAuditLog.DataAuditLogPage).Assembly,
typeof(Serenity.Pro.DataExplorer.DataExplorerPage).Assembly,
typeof(Serenity.Pro.EmailClient.MailboxPage).Assembly,
typeof(Serenity.Pro.EmailQueue.EmailQueuePage).Assembly,
//...
New Options for Sergen
We added many options to Sergen to fine-tune the code generation and use new language features.
SaveGeneratedTables:
By default, sergen saves the options specified for generated table like Identifier
, Module
, etc. back into the sergen.json
.
This information is pretty useless unless you are going to generate code again for the same table. It pollutes your sergen.json file and causes conflicts during merges.
By setting this option to false, which is recommended, you may disable that.
ForeignFieldSelection:
When specified, this overrides the set of foreign view fields that will be generated for a row. The default value for ForeignFieldSelection
is All, e.g. all the fields of the joined foreign table will be generated as view fields.
{
"ForeignFieldSelection": "NameOnly"
}
This was the default behavior for sergen, which used to cause problems in some cases:
- It made the generated row classes too big
- It even generated view properties for log fields like CreatedBy, ModifiedOn, etc.
- Intellisense was polluted with many view fields that you are never going to use
- It is bad for performance as all these fields need to be checked for most validation etc. operations.
- When you removed, renamed, or changed the data type of some field in the joined table, it did not affect the rows using it, causing errors at runtime, especially when opening a dialog, as the default is to load all the fields.
To understand this better, let's say we have a City
table with a foreign key to the Country
table.
When you generate the code for City
table, it produced a set of properties like this:
public class CityRow
{
// table fields
CityId
CityName
CountryId
//... some other city fields
CreatedBy
CreatedOn
ModifiedBy
ModifiedOn
// foreign view fields originating from CountryId FK join
CountryName
//...some other country fields
CountryCreatedBy
CountryCreatedOn
CountryModifiedBy
CountryModifiedOn
}
We now recommend setting this option to "NameOnly", which will bring only the "name" property of the foreign table into the currently generated row. So the set of properties generated will become:
public class CityRow
{
CityId
CityName
CountryId
CreatedBy
CreatedOn
ModifiedBy
ModifiedOn
CountryName
}
You may manually add fields you need later, preferably using the [Origin]
attribute enjoying the compile time checking.
There is also a None
option which causes it to generate none of the foreign view fields.
IncludeForeignFields:
This is an array of field names to additionally include when the ForeignFieldSelection
is NameOnly
or None
.
This may allow you to selectively generate code for some additional foreign fields, that you always want to have in your generated row.
For example, if you wanted to have CreatedOn
and ModifiedOn
in addition to the Name
property in all generated rows:
{
"ForeignFieldSelection": "Name",
"IncludeForeignFields": [
"CreatedOn",
"ModifiedOn"
]
}
DeclareJoinConstants:
When true, sergen will generate constants for every join alias it uses.
So, instead of such an entity:
public sealed class CustomerRow : Row<CustomerRow.RowFields>, IIdRow, INameRow
{
//...
[DisplayName("City"), ForeignKey("[test].[City]", "CityId"), LeftJoin("jCity")]
public int? CityId
{
get => fields.CityId[this];
set => fields.CityId[this] = value;
}
[DisplayName("City Name"), Expression("jCity.[CityName]")]
public string CityName
{
get => fields.CityName[this];
set => fields.CityName[this] = value;
}
[DisplayName("City Country Id"), Expression("jCity.[CountryId]")]
public int? CityCountryId
{
get => fields.CityCountryId[this];
set => fields.CityCountryId[this] = value;
}
//...
}
it will generate the following:
public sealed class CustomerRow : Row<CustomerRow.RowFields>, IIdRow, INameRow
{
const string jCity = nameof(jCity);
//...
[DisplayName("City"), ForeignKey("[test].[City]", "CityId"), LeftJoin(jCity)]
public int? CityId
{
get => fields.CityId[this];
set => fields.CityId[this] = value;
}
[DisplayName("City Name"), Expression($"{jCity}.[CityName]")]
public string CityName
{
get => fields.CityName[this];
set => fields.CityName[this] = value;
}
[DisplayName("City Country Id"), Expression($"{jCity}.[CountryId]")]
public int? CityCountryId
{
get => fields.CityCountryId[this];
set => fields.CityCountryId[this] = value;
}
//...
}
This will make it easier to reference and rename join aliases in addition to compile-time checks and intelli-sense support.
It is also recommended to set this to true.
FileScopedNamepaces:
When true, generated code will use File-Scoped Namespaces
feature introduced in C# 10.
So instead of this:
namespace MyApp.MyModule
{
public class MyRow
{
}
}
you will get this one:
namespace MyApp.MyModule;
public class MyRow
{
}
Enable Row Templates:
This is a flag that should only be activated if you are using StartSharp, as row templates are only supported via the source generator in Serenity.Pro.Coder
package.
When set to true, the generated rows will use the RowTemplate
style:
public sealed partial class CustomerRow : Row<CustomerRow.RowFields>, IIdRow, INameRow
{
class RowTemplate
{
[DisplayName("Customer Id"), Identity, IdProperty]
public int? CustomerId { get; set; }
[DisplayName("Customer Name"), Size(50), NotNull, QuickSearch, NameProperty]
public string CustomerName { get; set; }
[DisplayName("City"), ForeignKey("[test].[City]", "CityId"), LeftJoin("jCity"), TextualField(nameof(CityName))]
public int? CityId { get; set; }
[DisplayName("City Name"), Expression("jCity.[CityName]")]
public string CityName { get; set; }
[DisplayName("City Country Id"), Expression("jCity.[CountryId]")]
public int? CityCountryId { get; set; }
}
}
OmitDefaultSchema:
Our templates have to work with multiple database types, including Sql Server
, MySql
, Sqlite
, Firebird
, Oracle
, etc.
While SQL Server has schema support, in MySql schema is a synonym for a database, for Oracle, it is used in place of a username, while Sqlite has no schema support at all. So we avoid using database schemas and use prefixes like wlog_
instead.
We usually work with SQL Server
while developing our samples, and generate code via sergen in SQL Server
first. Then we test it with other database types.
But as the default schema is the dbo
in SQL Server
, Sergen generates expressions and table names including dbo.
schema for our entities like dbo.wlog_Customer
Then, we have to remove these dbo.
prefixes from expressions in our XYZRow.cs
files manually.
This option, when enabled, does not include the default schema like dbo.
in SQL server, and public.
in Postgresql in generated expressions.
If you have to support multiple database types as we do, we recommend setting this option to true.
TSBuild => EntryPoints:
By default, our tsbuild script considers TypeScript files matching the following glob patterns as entry points that are passed to the esbuild
:
- Modules/**/*Page.ts
- Modules/**/ScriptInit.ts
If for some reason, you want to use some other patterns, you may set the EntryPoints
under the TSBuild
section in sergen.json
:
{
"TSBuild": {
"EntryPoints": [
"Modules/**/*Page.ts",
"Modules/**/ScriptInit.ts",
"Modules/My/AdditionalEntry.ts",
"Modules/**/*Page.tsx"
]
}
}
This list is also used by our new ESM source generator if it is specified.
You need to update @serenity-is/tsbuild
in packages.json
to 6.6.0
or later, and run npm install
.
Extends Support for Sergen.json
Like the tsconfig.json
's extend
support, sergen.json has the property Extends
:
{
"Extends": "../sergen.base.json",
}
The paths are relative to the current sergen.json file. In the above sample, this sergen.json
file extends a sergen.base.json
file in its parent directory.
It is possible for sergen.base.json
to also extend another base file. But it should not create a circular link.
Just like tsconfig.json
, any setting in the sergen.json
overrides the values with the same keys in the sergen.base.json
.
Default Extends in Sergen.json
The Extends
field in sergen.json
also supports a special syntax like defaults@6.6.0
to extend from the defaults defined in Sergen
for that particular version.
{
"Extends": "defaults@6.6.0"
}
We recommend setting the Extends
field in your sergen.json
to defaults@6.6.0
so that you will get the modern defaults we choose, which are listed below:
{
"DeclareJoinConstants": true,
"EndOfLine": "LF",
"FileScopedNamespaces": true,
"ForeignFieldSelection": "NameOnly",
"OmitDefaultSchema": true,
"SaveGeneratedTables": false,
"Restore": {
"Exclude": [
"**/*"
],
"Typings": false
},
"ServerTypings": {
"LocalTexts": true
}
}
We did not set them as defaults for sergen
for compatibility reasons.
If there is something you don't like about our defaults above, you can always override it in your sergen.json
.
JSON Schema for sergen.json
We uploaded a JSON schema for sergen.json
in json.schemastore.org
, so when you open a sergen.json
file in Visual Studio next time, you'll get a nice auto-complete, validation, and help support:
Introduced CSS Rule for Site Logo Image
We had an HTML markup like the following in _Sidebar.cshtml
and some of the other membership-related pages:
<img class="s-sidebar-band-logo" src="~/Content/site/images/serenity-logo-w-128.png" />
As this is the Serenity logo, after creating your application from the template, you needed to change this logo in multiple places, which would be cumbersome.
And it was not possible for you to change the logo in the features we developed as NuGet packages, for example, the new OpenIddict module consent pages.
The new markup for site logo images in the _Layout.cshtml
and other files is like below:
<img class="s-site-logo-img s-sidebar-band-logo" />
As seen above, it does not include the image path; and contains just two CSS classes.
To set the logo, you should add/edit the following line in your site.css file, replacing the Serenity logo with your logo image:
.s-site-logo-img {
content: url(/Content/site/images/serenity-logo-w-128.png);
}
There is also an s-form-title-logo
class that adjusts the look of the logo in the forms, e.g. rounded with the sidebar band background. It might be used to adjust logo styling by overriding its CSS rules.
ConfigureSection Attribute
There is now a [DefaultSectionKeyAttribute]
and a ConfigureSection
extension making use of it so that instead of:
services.Configure<Serenity.Pro.Extensions.BackgroundJobSettings>(
Configuration.GetSection(Serenity.Pro.Extensions.BackgroundJobSettings.SectionKey));
in Startup.cs
file, you may write:
services.ConfigureSection<Serenity.Pro.Extensions.BackgroundJobSettings>(Configuration);
[Breaking Change] Q.Lookup Global Type is Removed for ES Modules
There is no longer a globally defined Q.Lookup interface while using ES modules.
If you referenced it in some modular code, you should import { Lookup } from "serenity-is/corelib/q"
and replace Q.Lookup
with Lookup
in your project.
[Breaking Change] CKEditorBasePath default is changed to "~/Serenity.Assets/Scripts/ckeditor/"
The default path for CKEditor
was "~/Serenity.Assets/Scripts/ckeditor/" for compatibility reasons.
We were setting it in ScriptInit.ts
to ~/Serenity.Assets/Scripts/ckeditor/
which is the new location.
We now removed the line in ScriptInit.ts
and changed the default to ~/Serenity.Assets/Scripts/ckeditor/
.