Go to content

Bulletcode.NET

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 controller
  • setModel — the setter for modifying the model data
  • viewData — 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 title
  • setViewData — 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 data
  • useViewData() — returns the getter and setter for the view data
  • useErrors() — returns the getter and setter for validation errors
  • useExtra() — returns the getter for additional data, for example information about the current user
  • useTempData() — returns the getter for temporary data, for example flash alerts

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 returns true if a request is currently being executed
  • router.location returns the location of the current page as an object with pathname and search 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 information
  • fieldPrefix — 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 added
  • autocomplete — specifies the autocomplete attribute of the <input> element
  • readonly — specifies the readonly 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 with value and text 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 with value and text properties; the array can be reactive
  • dropdown — when true, a dropdown button which expands the options is generated
  • empty — 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' } ],
]

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 component
  • model — the model data
  • tempData — the temporary data, for example flash alerts
  • extra — 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 attributes
  • errors — 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 the DataType attributes are not serialized
  • properties of the DateTime type are serialized using the current culture; the display format can be customized using the DisplayFormat 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.