Serenity boasts a flexible reporting system. Out of the box, we offer HTML-based reporting that can be converted to PDF using WKHTMLTOPDF. We also provide data-only reports that can be exported to XLSX files through EPPlus, and external reports that can launch custom URLs, among other functionalities.
All these report types have been abstracted to ensure that you don't need to directly interact with the underlying tools used in your reports. This abstraction allows for easy replacement with alternatives in the future if necessary.
However, with the relocation of classes such as the ReportController
to the Serenity.Extensions
package for easier updates, it became slightly more challenging to intercept the process, define custom report types, or replace the underlying tools.
For example, there used to be a switch statement in the ReportController
that looked like this:
if (report is IDataOnlyReport dataOnlyReport)
// render data-only report via EPPlus
else if (report is IExternalReport externalReport)
// render an external report as a URL redirect
else
// render an HTML report via WKHtmlToPdf
Since these statements are in an assembly from a NuGet package, it was not possible to inject custom report handling here.
To address this, we have abstracted this process, as well as many other sub-processes, into interfaces such as the following:
IReportRenderer
: Renders a report object to the target format, similar to the above switch statement.IReportRetrieveHandler
: Retrieves details about a report type, including its parameters.IReportFactory
: Creates an instance of a report type, optionally setting its parameter values.IHtmlToPdfConverter
: Executes tools like WKHTMLToPdf or Puppeteer for HTML to PDF conversion.IHtmlToPdfRenderer
: Renders an HTML report to PDF using theIHtmlToPdfConverter
service.IHtmlReportRenderUrlBuilder
: Generates a rendering callback URL for an HTML report. To convert an HTML report to PDF, the external reporting tool (WKHTML, Puppeteer, etc.) needs to call back to a URL in the site, and this abstraction is responsible for generating that URL.
To adapt to these changes, make sure to replace the following line in your Startup.cs
:
services.AddExcelExporter();
with:
services.AddReporting();
Serenity now offers a Puppeteer-based HTML to PDF converter in Serenity.Pro.Extensions
. This new option is particularly useful for StartSharp users.
By default, Serenity utilizes WKHTMLToPdf
for converting HTML reports to PDF. However, WKHTMLToPdf has not been updated for some time and lacks support for modern CSS features like Flexbox, Grid layout, and may not be fully compatible with Bootstrap 5
's layout system. As a workaround, you could use Bootstrap 3
on your report pages.
Additionally, because WKHTMLToPdf is an executable, it requires manual installation on the target server, along with its dependencies like Visual C++ runtime.
To enable the Puppeteer option, add the following line before calling AddReporting
in your code:
services.AddPuppeteerHtmlToPdf();
services.AddReporting();
Upon the first report execution, Chrome (or Firefox) will be automatically downloaded to the App_Data/chrome
or App_Data/firefox
directory. If you wish to specify a custom folder for downloading the browser, you can do so via the appsettings.json
file:
"PuppeteerHtmlToPdf": {
"DownloadPath": "C:/somedir"
}
Don't forget to include the following configuration in your Startup.cs
:
services.Configure<PuppeteerHtmlToPdfSettings>(Configuration.GetSection(PuppeteerHtmlToPdfSettings.SectionKey));
If there are specific reports that you still prefer to use WKHtmlToPdf
for, you can do so by adding the [UseWkHtmlToPdf(true)]
attribute:
[UseWkHtmlToPdf(true)]
public class MyLegacyReport
{
}
In Serenity, entities can function like SQL views without the need to define an underlying SQL view.
Let's consider a Person
entity with FirstName
and LastName
properties:
public class PersonRow : Row<PersonRow.RowFields>
{
public int PersonId { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
Suppose we want to define a calculated FullName
property:
public class PersonRow : Row<PersonRow.RowFields>
{
public int PersonId { get; set; }
public string FirstName { get to; }
public string LastName { get; set; }
[Expression("(T0.FirstName + ' ' + T0.LastName)")]
public string FullName { get; set; }
}
You can define an expression field (or view/calculated field) using the Expression
attribute.
Here, T0 is the default alias used by Serenity rows, corresponding to the main table, i.e., the
Person
table.
However, because Serenity and its demo support multiple database types, the expression written using the plus (+) operator may encounter issues. It's only supported in Microsoft SQL Server.
Fortunately, there's a CONCAT
function that we can use instead:
[Expression("CONCAT(CONCAT(T0.[FirstName], ' '), T0.[LastName])")]
public string FullName { get; set; }
Although CONCAT
is supported by many database types, there are exceptions. For example, Sqlite
does not support it.
To address this, we can utilize Serenity's capability to select expressions based on the target dialect, such as the connection's dialect:
[Expression("CONCAT(CONCAT(T0.[FirstName], ' '), T0.[LastName])")]
[Expression("(T0.FirstName || ' ' || T0.LastName)", Dialect = "Sqlite")]
public string FullName { get; set; }
Serenity will automatically select the second expression with pipe (||
) operators for Sqlite
, while the first one with the CONCAT
function for other databases.
However, whenever we need such a string concatenation operation, we'll have to repeat these expressions repeatedly. It's easy to make typos or forget which database supports which expression.
The worst part is even the CONCAT
function has differences among server types. While CONCAT('A', null)
returns A
for some server types, others might result in null
.
For example, if a person has FirstName
, MiddleName
, and LastName
properties, and you use CONCAT
for them to build the full name, most people don't have a middle name, resulting in a null
FullName for some servers.
In this version, we've introduced some custom expression types and the ability for expression attributes to produce different expressions based on the target dialect.
One of them is the [Concat]
attribute:
[Concat("T0.[FirstName]", "' '", "T0.[LastName]")]
public string FullName { get; set; }
This attribute builds its final expression based on the target dialect. For Sqlite
, it uses the pipe operator, and for others, it employs the CONCAT
function.
As a bonus, it wraps the operands with COALESCE(operand, '')
statements so that you will no longer have to worry about NULLs. You can turn off that behavior by setting NullAsEmpty
to false:
[Concat("T0.[FirstName]", "' '", "T0.[LastName]", NullAsEmpty = false)]
public string FullName { get; set; }
Currently, we have the following expression attributes:
Case
: The classicCASE WHEN a THEN 'x' WHEN b THEN 'y' ELSE 'z' END
CaseSwitch
: CASE statement with a switch value, e.g.,CASE SomeValue WHEN 1 THEN 'x' WHEN 2 THEN 'y' ELSE 'z' END
Concat
: String concatenation using CONCAT where possibleDateDiff
: e.g., SQL Serverdatediff
functionDatePart
: e.g., SQL Serverdatepart
functionSqlNow
: e.g., SQL Serversysdatetime()
functionSqlUtcNow
: e.g., SQL Serversysutcdatetime()
functionSqlDateTimeOffset
: e.g., SQL Serversysdatetimeoffset()
function
All the attributes, including the Expression attribute, now have a base attribute type:
namespace Serenity.Data.Mapping;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public abstract class BaseExpressionAttribute : Attribute
{
public abstract string Translate(ISqlDialect dialect);
public string ToString(ISqlDialect dialect);
public string Format { get; set; }
public static string ToString(object expression, ISqlDialect dialect);
}
These enhancements provide better control and compatibility when working with different SQL dialects in Serenity.
If you want to create another custom expression attribute, you need to override the Translate
function.
The ToString(dialect)
method invokes the Translate
method while handling additional implementation details. It includes the ability to apply an optional format string (the Format
property) to the expression produced by the Translate
method and allows the dialect to override the conversion process by implementing the ISqlExpressionTranslator
interface:
public string ToString(ISqlDialect dialect)
{
string expression;
if (dialect is ISqlExpressionTranslator translator)
{
expression = translator.Translate(this) ??
Translate(dialect);
}
else
expression = Translate(dialect);
if (string.IsNullOrEmpty(Format))
return expression;
return string.Format(Format, expression);
}
Currently, none of the out-of-the-box dialects implement it, but we have added this ability nonetheless. This provides flexibility in case some users want to customize the translation process for their specific dialect types.
SlickGrid (including our customized SleekGrid) offers remarkable flexibility and extensibility. It seamlessly works with various data sources, whether in-memory or remote, and supports easy extension through a straightforward plugin interface.
For example, the core SlickGrid components (slick.core.js and slick.grid.js) lack knowledge of grouping, group rows, or summaries. These functionalities are entirely managed by the data source or view.
In the context of SlickGrid's data view (known as RemoteView in Serenity), handling grouping and summary calculations is vital. Subsequently, a plugin is necessary to ensure that group rows are displayed correctly, complete with group titles, values, and item counts, along with footer sections containing calculated summaries. This essential plugin is called the GroupItemMetadataProvider (slick.groupitemmetadataprovider.js).
However, it's crucial to note that the GroupItemMetadataProvider is not a typical SlickGrid plugin. It must be registered both within the grid and the view, as it facilitates the coordination of metadata and operations between them.
It's worth mentioning that even though Serenity grids (DataGrids) typically feature a one-to-one relationship between a data source and its grid, it's usually possible to utilize a single data source with multiple SlickGrid instances.
In a basic grouping example from our common-features repository, you would find something like this:
export class GroupingAndSummariesInGrid extends EntityGrid<ProductRow, any> {
protected createSlickGrid() {
var grid = super.createSlickGrid();
// To enable grouping, this plugin must be registered
grid.registerPlugin(new GroupItemMetadataProvider());
//...
}
}
However, there's a drawback to the above sample. Since the plugin is not registered in the data view (RemoteView), it remains unaware of the existing GroupItemMetadataProvider. Consequently, it creates a new one during the initialization of grouping:
function setGrouping(groupingInfo) {
if (!options.groupItemMetadataProvider) {
options.groupItemMetadataProvider = new Slick.Data.GroupItemMetadataProvider();
}
//...
Failure to create and register a GroupItemMetadataProvider as a plugin in the SlickGrid instance may result in group rows not being displayed correctly and can lead to errors.
These improvements and fixes are intended to enhance the grouping and summary features within SlickGrid for a more seamless and robust user experience.
The code block above is excerpted from the slick.dataview.js
file within the original SlickGrid repository. A similar logic was reproduced in our custom RemoteView
class.
When you do not pass the plugin to the view options, you end up with two instances of the same plugin coexisting. One instance is registered with the grid, while the other, though internally created by the data view, remains unregistered.
Initially, this situation was not a major concern, especially when the GroupItemMetadataProvider was created without any additional options. In such cases, the instance we created and the one created internally by the data view held the same set of options.
Due to this, even if we overlooked passing the GroupItemMetadataProvider to the data view, our sample continued to function as expected. However, issues arose after we introduced refinements to the GroupItemMetadataProvider class, leading to the problem detailed in the following GitHub issue:
To resolve this, it is imperative to structure the code as follows:
export class GroupingAndSummariesInGrid extends EntityGrid<ProductRow, any> {
private groupItemMetadataProvider: GroupItemMetadataProvider;
protected getViewOptions() {
var opt = super.getViewOptions();
this.groupItemMetadataProvider = new GroupItemMetadataProvider({
// Include any additional options you require
});
opt.groupItemMetadataProvider = this.groupItemMetadataProvider;
return opt;
}
protected createSlickGrid() {
var grid = super.createSlickGrid();
grid.registerPlugin(this.groupItemMetadataProvider);
//...
}
}
By adopting this corrected code structure, you ensure that the issue of duplicated plugins is resolved, and your application works as intended.
Previously, you needed to register the same plugin instance with both the grid and the view. However, with the latest SleekGrid (1.5.5+), you can access the GroupItemMetadataProvider instance directly from the view after it's created. This makes it unnecessary to maintain an extra private variable for the plugin instance.
Here's the updated approach:
export class GroupingAndSummariesInGrid extends EntityGrid<ProductRow, any> {
protected getViewOptions() {
var opt = super.getViewOptions();
const groupItemMetadataProvider = new GroupItemMetadataProvider({
// Include any additional options you require
});
opt.groupItemMetadataProvider = groupItemMetadataProvider;
return opt;
}
protected createSlickGrid() {
var grid = super.createSlickGrid();
grid.registerPlugin(this.view.getGroupItemMetadataProvider());
// Additional configuration...
}
}
This streamlined code eliminates the need for an additional private variable to store the plugin instance.
In StartSharp, we've also enhanced our DraggableGroupingMixin
to make it more intuitive. It now intelligently creates a GroupItemMetadataProvider if it's not already registered with the grid and also registers it with the view. This leads to a more concise, one-liner setup:
export class DragDropGroupingGrid extends OrderGrid {
protected createToolbarExtensions() {
super.createToolbarExtensions();
new DraggableGroupingMixin({ grid: this });
}
}
This modification automates the creation of a GroupItemMetadataProvider
, its registration as a SlickGrid plugin, and the configuration of the groupItemMetaProvider for the data view.
We previously had two encoding functions: Q.htmlEncode
and a similar function for attribute values called Q.attrEncode
.
While attrEncode
also escaped quote characters ("
and '
), htmlEncode
did not, as they are not typically required to be escaped for HTML markup other than attribute values. Escaping angle brackets and ampersands was generally sufficient.
To avoid user confusion and given that there is no significant impact on performance when always escaping quotes, we have decided to make these functions work the same way.
We have renamed the text
function to localText
for ES module style TypeScript. The old function is still available but is now considered obsolete. Transitioning to localText()
will make it easier to locate code blocks where local texts are used.
When using local texts in a script method without calling htmlEncode
(e.g., <div>{ text("SomeKey") }</div>
instead of <div>{htmlEncode(text("SomeKey")) }</div>
), an attacker could potentially inject JavaScript via the translations screen.
This issue affected parts of our demo since anyone could log in as the admin
and access the translation screen. In our demo, translations are reset every 30 minutes, so the risk was limited. However, we strongly recommend reviewing your code, especially for standard screens like login
, signup
, forgot password
, etc.
Please refer to our latest commits in the Serene/StartSharp/Common Features repositories for the fixes we applied. Even if only the admin can access the translations screen in your application, it's a good practice to mitigate risks, as trusting translators, even the admin, is not advisable.
We cannot default the "text," "localText," or "tryGetText" functions to htmlEncode, as they might be used in contexts other than HTML, such as functions expecting raw text, like notify messages, dialog titles, column titles, etc., which would result in double HTML encoding.
We recommend using the localText()
function instead of the deprecated text()
function for improved discoverability (but remember that it does not perform escaping).