ClientView
The ClientView architecture makes it possible to create applications with rich, interactive views rendered on the client side, while retaining the classic MVC architecture with server side routing and controllers. It was inspired by Inertia.
In classic MVC applications, the controller passes model data to the view, which is rendered entirely on the server side, and returned as HTML to the browser. Additional scripts are then used to add JavaScript handlers for interactive elements. In ClientView applications, the controller returns the model data directly to the browser in JSON format. The view is implemented as a Leaner component which runs entirely on the client side, making it easier to implement complex logic and interactions.
This makes it possible to create full single-page applications, without the need to implement an API on the server side, and code which interacts with this API.
Bulletcode.NET also makes it possible to create hybrid applications, which mix classic, server side views with interactive views which use the ClientView architecture.
NOTE
The ClientView architecture is perfect for administration dashboards and enterprise applications, but it’s not recommended for public facing websites, because the content is not rendered on the server side and cannot be indexed by search engines.
Requests and responses
When the browser makes the first request to a ClientView application, it returns a full HTML response. The server side HTML view is rendered, and the name of the client view and model data are injected into it in JSON format. This data is used to render the initial view component on the client side.
All subsequent requests are made via XHR using the fetch()
function. This happens when the user clicks on a link, submits a form or moves through the browser history. When the application detects that an XHR request is made, it skips rendering the HTML content, and returns a JSON response containing the name of the client view and model data. This data is used to update the client-side view component.
NOTE
The XHR requests are regular GET and POST requests, using the same URL and data as normal browser requests. The Accept
header is used to distinguish normal browsers request from the XHR requests. For browser requests, this header contains the text/html
media type, and for XHR requests, it is set to application/json
.
Rendering a client view
In order to render a view on the client side, the controller should call the ClientView()
method instead of View()
, for example:
public async Task<IActionResult> Index()
{
// retrieve data
return ClientView( "Users", model );
}
The first argument is the name of the client view component, and the second argument is the model which will be passed to it.
The .cshtml
file corresponding to the controller’s action doesn’t need to contain any HTML markup; it just needs to set up the layout and page title.
Just like in case of the View()
method, the name or full path of the server side view can also be passed as the first argument of the ClientView()
method:
return ClientView( "~/Views/App.cshtml", "Users", model );
If the model is omitted, the current value of the ViewData.Model
property is used.
If the client view contains a Form
component, the ClientFormView()
method should be used instead:
public async Task<IActionResult> Add()
{
var model = new UserClientFormViewModel();
return ClientFormView( "UserForm", model );
}
In this case, not only the model data is serialized into JSON, but also its metadata, such as the types of model properties, their display names and validation attributes.
WARNING
Keep in mind that all data passed from the controller to the client view is passed over network. Make sure you don’t include any sensitive information, such as authentication keys and password hashes. Also make sure that you don’t pass unnecessary information that would hurt performance. Except for simple dictionaries, use view models instead of returning database entities directly.
If the entire application is rendered on the client side, it’s not necessary to create separate .cshtml
files for each view. Instead, the application can simply contain one .cshtml
file which loads the necessary JavaScript and CSS assets and only contains an empty <div>
element where the view component is rendered.
Use the following code to configure ClientView in SPA mode:
services
.AddControllersWithViews()
.AddCoreMvcServices( environment )
.AddFrontViewServices( environment, options => {
options.UseSinglePageMode = true;
options.UseErrorClientView = true;
} );
By setting UseSinglePageMode
to true
, the "~/Views/App.cshtml"
view is used automatically for all client views, instead of the view corresponding to the controller’s action, without having to pass it to all calls to the ClientView()
method. It is also possible to change this path by modifying SinglePageViewName
.
When UseErrorClientView
is set to true
, error pages are also rendered as client views, using "Error"
as the name of the client view component. This name can be changed by modifying ErrorClientViewName
. Otherwise, error pages are rendered on the server side.
Client-side code
Use the following JavaScript code to initialize a ClientView application:
import { createClientApp, createRouter } from '@bc-front/client';
import Layout from './Layout';
import * as views from './views';
const router = createRouter( window.appData );
const app = createClientApp( { resolve } );
app.use( router );
app.mount( document.querySelector( '#app' ) );
function resolve( name ) {
return [ Layout, views[ name ] ];
}
See Assets for more information about creating application scripts.
The router is responsible for performing XHR requests when a link is clicked or a form is submitted, interpreting the server’s response, and updating the state of the application. The window.appData
variable contains the initial state of the application, which is injected into the initial HTML response.
The app
variable is a Leaner root application context, which uses a special component which dynamically renders the appropriate client view. The router is passed to the application context as a plugin. The application is then mounted inside the <div id="app">
element, which should be present in the server side HTML view.
The resolve()
function, which is passed to createClientApp()
, takes the name of the client view component as an argument, and returns an array containing one or more view components. If multiple components are returned, the first one is the outermost layout, and the last one is the innermost view component.
Hybrid mode
If the application works in hybrid mode, it can detect if the window.appData
variable is defined, and only create the router and client application when it is. It should also call the initialize()
function, to ensure that handlers and components for content rendered on the server side are properly initialized. For example:
import { initialize } from '@bc-front';
import { createClientApp, createRouter } from '@bc-front/client';
import * as views from './views';
initialize();
if ( window.appData != null ) {
const router = createRouter( window.appData );
const app = createClientApp( { resolve } );
app.use( router );
app.mount( document.querySelector( '#main-content' ) );
}
function resolve( name ) {
return [ views[ name ] ];
}
In this case, the page layout is rendered on the server side, and the client view component is rendered directly inside its <main id="main-content">
element.
Optional callbacks
An onError()
callback function can be passed to the createRouter()
function to change the default error handling. By default, when an error occurs when sending an XHR request, the "Error"
client view component is rendered. The custom onError()
function can return an object containing the name of the view component and the model data, or return null
to prevent updating the state of the application. For example:
import { createRouter } from '@bc-front/client';
import { defaultOnError, isUnauthorizedError } from '@bc-front/utils/error';
import { createUrl, toRelative } from '@bc-front/utils/url';
const router = createRouter( window.appData, { onError } );
sync function onError( error, router, responseUrl ) {
if ( isUnauthorizedError( error ) ) {
await router.get( createUrl( '/Account/Login', { returnUrl: toRelative( responseUrl ) } ) );
return null;
}
return defaultOnError( error );
}
In this example, when the server returns a 403 response with Unauthorized
reason, the user is redirected to the login page, and the original URL is passed as a query string parameter. Otherwise, the default error handler is called, which displays the "Error"
client view component.
If an afterEach()
callback function is passed to the createRouter()
function, it is called every time after a request is sent and the state of the application is updated. For example, the following function can be used to scroll the window to the top:
const router = createRouter( window.appData, { afterEach } );
function afterEach() {
window.scrollTo( 0, 0 );
}
The router
object is passed as a parameter to this callback function.
In addition to the mandatory resolve()
function, a title()
callback function can be passed to createClientApp()
. It can be used to modify the page title for each view, for example by appending the name of the application:
const app = createClientApp( {
resolve,
title: value => ( value != null ? value + ' - ' : '' ) + 'My App',
} );
Client view components
A client view components is just a regular Leaner component. It receives the following properties:
model
— the reactive getter for the model data returned by the controllersetModel
— the setter for modifying the model dataviewData
— the reactive getter for the view data object, which is used for sharing information between a view and its parent layout, and for setting the page titlesetViewData
— the setter for the view data object
A layout component also receives the child view in its children
argument.
The following example shows a simple view component:
export function Home( { model, setViewData } ) {
setViewData( { title: _( 'Home Page' ) } );
return [ 'p', model.message ];
}
The setViewData()
function is used to set the page title, and model.message
is a reactive getter which returns the message
property of the model data.
NOTE
When model data is serialized as JSON, property names are converted from PascalCase
to camelCase
. So in the server side code, the property of the corresponding view model class would be called Message
.
When a request returns the same client view name, the view component is re-used, and the model is updated. The view component should take this into account and properly react to the changes in the model.
For example, the page title or other view data may depend on the model. You can use the reactive()
watcher to update the view data when the model is modified:
reactive( model.scenario, scenario => {
setViewData( { title: scenario == 'Add' ? _( 'Add User' ) : _( 'Edit User' ) } );
} );
To access the model, view data and other information from child components and utility functions, you can import the following functions from '@bc-front/client/model'
:
useModel()
— returns the getter and setter for the model datauseViewData()
— returns the getter and setter for the view datauseErrors()
— returns the getter and setter for validation errorsuseExtra()
— returns the getter for additional data, for example information about the current useruseTempData()
— returns the getter for temporary data, for example flash alerts
Navigation
When a regular link is clicked, a normal browser request is performed, and the full HTML response is returned. In order to take advantage of the ClientView architecture, the Link
component should be used instead. It uses the router to perform an XHR request and navigate to the new page, while preserving the state of the application. For example:
[ Link, { to: '/Users/Add', class: 'button is-create' },
[ 'i', { class: 'icon-plus' } ], _( 'Add User' ),
]
All Leaner components implemented by Bulletcode.NET can be imported from '@bc-front/components'
.
The PostButton
component can be used to perform an action using a POST request. It can be used to avoid the the risk of a CSRF attack (see Security for more details). This component is similar to the <a>
server side tag with the asp-post
attribute (see Tag Helpers). For example:
import { PostButton } from '@bc-front/components';
[ PostButton, { to: () => `/Users/${model.id()}/Delete`, class: 'button is-delete' },
[ 'i', { class: 'icon-trash' } ], _( 'Delete User' ),
]
When the returnUrl
property is set to true
, the URL of the current page is included in the request data. When the controller calls ReturnToAction()
, the redirect is performed to the original URL instead of the specified action.
The router can also be used to programmatically perform a request:
import { useRouter } from '@bc-front/client/model';
const router = useRouter();
await router.get( '/Users/Add' );
The optional second parameter of router.get()
can be used to specify query string parameters as an object.
A POST request can also be performed programmatically:
await router.post( '/Users/Add', data );
The second parameter of router.post()
can be a URLSearchParams
or FormData
object, or a plain object, which is converted to URLSearchParams
.
The router also contains two properties which are reactive getters:
router.loading
returnstrue
if a request is currently being executedrouter.location
returns the location of the current page as an object withpathname
andsearch
properties
Forms
To create a form, use the Form
and Field
components, for example:
[ Form,
[ Field, { for: 'name' } ],
[ Field, { for: 'email' } ],
[ 'div', { class: 'submit-buttons' },
[ 'button', { type: 'submit', class: 'button is-primary' }, _( 'OK' ) ],
],
]
Unlike the <form>
element, the method
attribute of the Form
defaults to 'post'
. The action
defaults to the current URL.
The Form
component supports the following optional properties:
customRules
— specifies custom validation rules; see ClientView custom rules for more informationfieldPrefix
— specifies the prefix used by all form fields; this is typically used when a form is embedded in a popup dialog, or when the page contains multiple forms
Field
The Field
component is analogous to the <field>
tag helper, however model properties are converted to camelCase
. The field includes a label, input control and validation message. The type of the input control depends on the type of the property and its data annotation attributes.
NOTE
To ensure that the Form
and Field
components work correctly, the controller should call the ClientFormView()
method instead of the ClientView()
method.
The Field
component supports the following optional properties:
fixed
— when specified, the<input type="number">
element uses a fixed number of decimal digits; additional zeros are automatically addedautocomplete
— specifies theautocomplete
attribute of the<input>
elementreadonly
— specifies thereadonly
attribute of the<input>
element
Input elements
An input control used by the Field
can also be specified explicitly:
[ Field, { for: 'option' },
[ Select, { items: model.optionItems } ],
],
The Input
, Select
and TextArea
components correspond to the HTML input elements, but provide integration with model data and validation. They can be used inside a Field
or as stand-alone components.
When used outside of a Field
, either specify the for
property, to bind the element to model data, or the value
and setValue
properties to bind the element to custom reactive state. If the element is read-only, only the value
can be used.
The Input
component supports the following optional properties:
fixed
— when specified, the<input type="number">
element uses a fixed number of decimal digits; additional zeros are automatically added
The Select
component supports the following optional properties:
items
— populates<option>
elements based on an array of objects withvalue
andtext
properties; the array can be reactive
Fieldset
The Fieldset
component is analogous to the <fieldset>
tag helper. It makes it possible to create a fieldset containing radio buttons or multiple checkboxes, including a legend and validation message, for example:
[ Fieldset, { for: 'options', items: model: optionItems } ]
If the model property is a singular type, radio buttons are created. If the property is a collection type, for example a list or an array, checkboxes are created.
The Fieldset
component supports the following properties:
items
— an array of objects withvalue
andtext
properties; the array can be reactivedropdown
— whentrue
, a dropdown button which expands the options is generatedempty
— specifies the text which is displayed in the dropdown button when no options are selected
Input components
The AddonInput
component generates an <input>
element with a prefix and/or suffix, for example:
[ AddonInput, { for: 'distance', suffix: 'km' } ]
The prefix and suffix can also be Leaner templates:
[ AddonInput, { for: 'userName', prefix: [ 'i', { class: 'icon-user' } ] } ]
The DateInput
component creates an <input>
element with a dropdown calendar and/or time picker:
[ DateInput, { for: 'dateOfBirth' } ]
When bound to a model property, the type of the date picker is automatically determined. Otherwise, it can be specified using the type
property. The min
and max
properties can be used to specify the minimum and maximum value which can be selected.
The PasswordInput
component creates an <input type="password">
element with a button which can be used to toggle password visibility:
[ PasswordInput, { for: 'password' } ]
The AddonInput
, DateInput
and PasswordInput
components can be used either inside a Field
or in stand-alone mode, and can be bound to a model property or a custom reactive state, specified using the value
and setValue
properties.
Other components
GridView
The GridView
component can be used to display tabular data with paging, sorting and filtering. It is analogous to the "Grid"
view component. It uses a model of the ClientGridViewModel
type, which can be created from a data provider using the IClientViewHelper
service (see below).
The grid must contain one or more columns, for example:
[ GridView, { model: model.users },
[ Column, 'name', _( 'Name' ), u => u.name ],
[ Column, 'email', _( 'Email' ), u => u.email ],
]
Each column contains a key, which can be used for sorting, a header, and a function returning the cell’s value.
The DetailsColumn
creates a column with a link to a details action, for example:
[ GridView, { model: model.users },
[ DetailsColumn, 'name', _( 'Name' ),
u => u.id,
u => ( { label: u.name, url: '/Users/{0}' } ),
],
]
The third parameter is a function which returns a key or an array of keys. The fourth parameter is a function which returns an action item containing a label and the URL of the link, with format placeholders replaced by the key values.
An optional fifth parameter can be used to specify additional action links, which are displayed below the details link, for example:
[ GridView, { model: model.users },
[ DetailsColumn, 'name', _( 'Name' ),
u => u.id,
u => ( { label: u.name, url: '/Users/{0}' } ),
u => [
{ label: _( 'Edit' ), icon: 'pencil', url: '/Users/{0}/Edit' },
{ label: _( 'Delete' ), icon: 'trash', url: '/Users/{0}/Delete', class: 'is-delete' },
],
],
]
The action item can contain a label, an optional icon, and the URL of the link. If the post
property is set to true
, the action is performed using a POST request. A CSS class can also be specified.
The action item can also contain a visible
property. When set to false
, the action will be hidden. All properties of the action item can be reactive getters.
The ActionsColumn
creates a column which contains buttons for executing actions, for example:
[ GridView, { model: model.users },
[ ActionsColumn,
u => u.id,
u => [
{ label: _( 'Edit' ), icon: 'pencil', url: '/Users/{0}/Edit' },
{ label: _( 'Delete' ), icon: 'trash', url: '/Users/{0}/Delete', class: 'is-delete' },
],
],
]
The first parameter is the function returning a key or an array of keys and the second parameter is a function which returns an array of action items.
Detail
The DetailView
component can be used to display details of a single item as a table. It is analogous to the "Detail"
view component. For example:
[ DetailView, { model, typeName: 'user' },
u => [
[ Row, _( 'Name' ), u.name ],
[ Row, _( 'Email' ), u.email ],
],
]
The typeName
property is used to create a CSS class for the root element, for example is-model-user
.
The detail component must contain a function which returns an array of Row
components. Each Row
should contain a header and the cell’s value.
An optional name
property can be passed to the Row
component. It is used to create a CSS class for the row.
To conditionally show or hide rows, the standard 'if'
directive can be used.
Application header
The ApplicationHeader
component can be used to display a header bar with the application’s logo and/or name, an optional side menu and an optional top menu. It is analogous to the "ApplicationHeader"
view component. For example:
[ ApplicationHeader, {
logoUrl: '/images/logo.svg',
title: _( 'My App' ),
sideItems: [
{ label: _( 'Home Page' ), icon: 'house', url: '/' },
// ...
],
topItems: [
{ label: _( 'Log in' ), icon: 'log-in', url: '/Account/Login' },
// ...
],
} ]
Each menu item should contain a label and an icon. Action items should contain an URL, and drop-down menus and groups of items should contain an items
property with child menu items.
If the post
property is set to true
, the action is performed using a POST request. If the visible
property is set to false
the action is hidden. The class
property can be used to specify an additional CSS class for the item.
Alert
The Alert
component displays a simple alert message with an icon. It is analogous to the <alert>
tag helper. For example:
[ Alert, { type: 'warning' },
[ 'p', () => _( 'Are you sure you want to delete user {0}?', model.email() ) ],
]
Search panel
The SearchPanel
component creates a panel for filtering a grid. It is analogous to the <search-panel>
tag helper. For example:
[ SearchPanel, { model: model.users },
[ Field, { for: 'name' } ],
[ Field, { for: 'email' } ],
]
Popup dialog
The PopupDialog
component creates a popup dialog. For example:
[ PopupDialog,
[ 'h3' _( 'Hello' ) ],
[ 'p', _( 'This is a popup dialog' ) ],
]
The optional containerClass
property specifies the size of the dialog: container
(the default), container-narrow
or container-tiny
. The optional onclose
callback is called when the ESC key is pressed.
The popup dialog is typically wrapped in an 'if'
directive which controls its visibility.
Field repeater
The FieldRepeater
component can be used to display a set of fields for every item of an array. It should contain a function which takes the item and its index, and returns a template. For example:
[ FieldRepeater, { collection: items },
( item, index ) => [ 'div',
[ Field, { for: 'product' } ],
[ Field, { for: 'quantity' } ],
],
]
See the invoice form in the demo application included in the Bulletcode.NET source repository for a full example of a form using the FieldRepeater
.
External redirects
When an XHR request is performed by the client router, the server can return a redirect response using a method such as RedirectToAction()
. However, in this case, the redirected request is also performed using XHR, so it only works if the target page also returns a client view response.
In some cases, it is necessary to redirect to an external URL, or a page which returns HTML content, not a client view response. A special JSON response can be returned to the client router, which causes it to perform a client-side redirect to the target URL. One of the following methods can be used to generate such response:
ClientRedirect()
— redirects to any URL, which can be an external URL. This method should not be used when the URL is provided by user input, as an open redirect attack can be performed.ClientLocalRedirect()
— redirects to an URL within the current application. This method throws an error when an external URL is provided, so it can be safely used with user input.ClientRedirectToAction()
— redirects to a specific action, with optional controller name and route values.
When one of these methods is used in response to a browser request, not an XHR request, a normal HTTP 30x redirect response is returned.
Partial updates
In complex, interactive applications, XHR requests are often performed to partially update the data without reloading the whole page. The ClientView architecture makes it possible to perform such requests using the client router and handle them on the server side in the same way are regular requests.
For example, a form can contain two dropdown fields. The available items in the child fields depend on the selected value in the first field:
[ Form,
[ Field, { for: 'parent' },
[ Select, { items: model.parentItems, onchange } ],
],
[ Field, { for: 'child' },
[ Select, { items: model.childItems } ],
],
]
The onchange()
function can use the router to perform a request when a value in the first field is selected:
const router = useRouter();
function onchange( e ) {
router.get( '/ChildItems', { id: e.taget.value } );
}
The controller should handle the request by returning a PartialClientView()
response, for example:
public async Task<IAction> ChildItems( int id )
{
var model = new ExampleViewModel
{
ChildItems = await _exampleService.GetChildItemsAsync( id ),
};
return PartialClientView( new string[] { "ChildItems" }, model );
}
In this case, only the ChildItems
property of the model is serialized to JSON. The router detects that the server’s response is a partial update, and updates the childItems
property of the current model, without modifying other properties of the model.
When an array of properties is not passed to PartialClientView()
, all properties are serialized. This is mostly useful when an instance of an anonymous class is created to update the selected properties. For example:
public async Task<IAction> ChildItems( int id )
{
var items = await _exampleService.GetChildItemsAsync( id );
return PartialClientView( new { ChildItems = items } );
}
Helper service
The IClientViewHelper
service can be used to create view model data for client views.
The GetEnumList()
method makes it possible to create select list items based on an enumeration with data annotations. It’s similar to the GetEnumSelectList()
method of the IHtmlHelper
, but it returns a list of ClientListItem
objects, instead of SelectListItem
. These objects can be serialized more efficiently, because they only contain the Text
and Value
properties.
The GetConstList()
method makes it possible to create select list items based on constants defined in a class. It’s similar to the GetSelectList()
method of the IConstHelper
, and it also returns a list of ClientListItem
objects.
The MapSelectList()
method converts a collection of SelectListItem
objects to ClientListItem
objects.
The MapGridViewModelAsync()
method creates a ClientGridViewModel
object from a data provider. This object can be passed directly as a model to the GridView
component described above.
Optionally, a mapping function can be passed as a second argument of this method, to convert the objects returned by the data provider to a type that can be passed to the client view. The mapping function can be asynchronous.
Client data serialization
When the controller returns a client view response, a ClientViewData
or ClientViewFormData
object is stored in the view data dictionary, and a ClientViewResult
is generated. When the associated action result executor is called, it serialized the view data into a dictionary and checks if the current request is a normal browser request or an XHR request. If it’s a normal browser request, the view data is injected into the page using the IAssetManager
, and a ViewResult
is executed, which generates an HTML response using the specified server-side view. Otherwise, a JsonResult
is executed, which returns the view data directly as a JSON response.
The response generated by ClientViewData
contains the following information:
viewName
— name of the client view componentmodel
— the model datatempData
— the temporary data, for example flash alertsextra
— additional data, for example information about the current user, CSRF token, and font size options
The response generated by ClientViewFormData
contains the same information as above, plus the following properties:
metadata
— the model metadata, including the types of model properties, their display names and validation attributeserrors
— validation errors extracted from the model state dictionary
Model and metadata
When the model data is serialized, names of properties are converted to camelCase
, and standard JSON serialization mechanism is used, with the following exceptions:
- read-only properties are not serialized
- properties of the
IFormFile
type and properties annotated as a password using theDataType
attributes are not serialized - properties of the
DateTime
type are serialized using the current culture; the display format can be customized using theDisplayFormat
attribute
Metadata are serialized in the following format:
- metadata of classes contain a dictionary of properties and their metadata
- metadata of collections (e.g. lists and arrays) contains the metadata of the element’s type
- metadata of simple types contain an array which includes the display name,
DataType
annotation or the type name, and an optional array of implicit and explicit validation rules - metadata of read-only properties are not serialized, unless the property has a display name annotation
Additional data
In addition to the model data, the client view response can include temporary data and additional data related to the request.
By default, the temporary data includes flash alerts, which can be displayed using the FlashAlerts
component. The application can implement the IClientViewTempDataProvider
interface in order to include additional temporary data in the client view response. The client components can access temporary data by calling the useTempData()
function.
The additional data contains information about the current user, including the isAuthenticated
flag, the name
, and the array of policies
associated with the user. The additional data also contains the CSRF token, which is automatically added to POST requests performed by the client router, and the font size options, which are used by the ApplicationHeader
component to handle the font size button.
The application can implement the IClientViewDataProvider
interface in order to include additional data in the client view response. The client components can access this data by calling the useExtra()
function.
Update responses
When the client router performs an XHR request, it adds the X-Bulletcode-Update-View
header, containing the name of the current client view component. When the same component is used in the server’s response, an update response is generated instead of the full response. The update response contains the update
property set to true
, model
, tempData
and (in case of ClientViewFormData
) errors
. The extra
and metadata
properties are omitted, and their current values are preserved by the client router.
When the server returns a partial client view response, the result contain the update
property set to true
, partial
set to true
, model
and (in case of ClientViewFormData
) errors
. The extra
and metadata
properties are omitted and their values are preserved, and the model
and the errors
properties are merged with the current values by the client router.