Skip to main content

Visual preview

Editors can open the page of an entity on the application's own site inside the admin, in the selection they're editing: a draft, a content release or an experiment, for a market and language. They hover and click content on the page and edit the entity and field it came from in a panel next to it. Edits are saved to the draft automatically, and the page refreshes without a save button or a reload.

The site takes part with the Vox web SDK, see Preview your site in the admin. This page describes what Vox does.

What can be previewed​

An entity type is previewed when it's authored, IVoxAuthoredEntity, and routable, IVoxRoutable, and the host registers preview. Its route projection, the projection whose output is a VoxRouteDocument, gives the path of its page in the selected contexts, and the preview options map each route space to the site its paths are opened on.

vox.AddVoxRouting()
.AddEntityFrameworkRouting(options => options.ConfigureDbContext = ConfigureRoutes)
.AddVoxRoutingApi(options => options.DefaultSpace = "storefront")
.AddVoxPreview(preview => preview
.AddSite("storefront", new Uri("https://www.northpeak.example"))
.Exclude<ContentPage>());

app.MapVoxRoutes("/content/{**path}");
  • AddSite(space, origin) opens the paths of a route space on a site. A space without a site isn't previewed. The origin is an absolute http or https URL without a path.
  • Exclude<TEntity>() leaves an authored, routable entity type out of preview.
  • RouteProjection<TEntity>(name) names the route projection of an entity type with more than one.
  • GrantLifetime is how long a grant is valid, 30 minutes by default. The admin renews grants before they expire.

Preview needs authoring and the routing API. Build() checks that every previewed entity type has exactly one route projection, or one named with RouteProjection, and tells which type doesn't.

The admin learns the previewed entity types, the sites and the grant lifetime from GET api/preview, which doesn't exist without preview. An entity type that's previewed gets a card of its page in the rail of its entities and a Preview button, and the full-screen preview at /{routeName}/{id}/preview.

Sessions and grants​

POST api/preview/sessions, with the editor's selection in context= and { entityType, entityId, space? }, opens a page:

  • It needs that the editor may read the entity in the selection.
  • It evaluates the entity's route projection in the selection, so a slug that's new or changed in the draft is used, and picks the canonical route of the space, or of the first space with a site the entity has a page in, whose contexts the selection has. When the selection has no member of one of the route's context types, eg no language, it picks the first canonical route, and returns its contexts, which the admin shows as selected.
  • It returns the URL of the page on its site with a grant in its vox-preview parameter, when the grant expires, the route's contexts, and the other spaces the entity has a page in.
  • When the entity has no page in the selection, eg no slug in the selected language, it's a 404 that says why and which selected members it has no page in, so the admin can say "No slug in Suomi".

A grant lets the site's server read the restricted part of the editor's selection, eg $draft, $release=autumn or an experiment's variant, on the editor's behalf. It's protected with ASP.NET Core Data Protection, holds the editor, the restricted contexts, the previewed entity and space, and its expiry, and can't be read or changed outside Vox.

The site's server sends it as Vox-Preview: {grant} on its requests to Vox, next to its own API key. A grant alone authenticates nothing:

  • The grant must be valid and unexpired. Otherwise the request is a 401 with WWW-Authenticate: VoxPreview error="expired" or error="invalid", so the site can ask the admin for a new one.
  • The API client needs the preview permission of each restricted context type in the grant, eg vox:Preview:$draft.
  • The editor must still exist, be enabled and be allowed to select what the grant previews. This is checked on every request, with the answer kept for 30 seconds.
  • The grant's contexts are combined with the request's context=. A request that selects a restricted context the grant doesn't have is rejected, unless its API client may select it itself.
  • Responses are Cache-Control: private, no-store.

The header works on every read of the API: by id, by route and in queries.

Routes in a preview​

The published route index has published content only. With a grant, MapVoxRoutes first evaluates the route projection of the grant's entity in the grant's selection, and delivers that entity when the path is one of its routes in the space. So the previewed page resolves even when its slug is new or changed in the draft. Other paths resolve by the published routes, and are delivered in the grant's selection. An entity that has never been published and isn't the grant's entity can't be reached by path in a preview.

Source maps​

The site marks its elements with projection coordinates only: the key of a projection and a JSON path in its value, eg vox://entity/Product/p1?projection=page#name. The admin asks Vox what those values came from:

POST /api/entity-projections/source-maps?context=$draft,language:sv
{ "keys": ["vox://entity/Product/p1?projection=page"] }

Vox evaluates each projection once, in the selection, and records what the getters of the entities it reads return, with the entity and field each came from. A value the projection takes apart after reading it, eg the Swedish value of a varying field or an item of a list, is recorded with its subtree. Then it walks the output by the names it's serialized with, so paths follow [JsonPropertyName], naming policies and ignored properties, and finds the source of each value:

  1. Identity: the value is the instance a field had, eg Name = context.Contexts.Resolve(product.Name) or Body = page.Body, and the items of a list that was filtered, sorted or mapped item by item.
  2. Equal: a text that equals one recorded text, ignoring case and whitespace, eg Trim() or ToUpper(), or a number, bool, enum or date that equals the only recorded value of its type with that value.
  3. Contains: a text that contains recorded texts of four characters or more, eg $"{name} – {brand}", at most five, the longest first.

A source names the entity and field, eg Brand, b-12, name, which can be another entity than the projection's, eg the name of a brand in a product card. A value of a varying field has its member, eg Language=sv; when that isn't the selected member, the value is a fallback, and the admin edits it at the selected member. A path can have several sources, and a source several paths. A path without a source has none: the admin still knows the projection's entity. References to other projections aren't walked, since the referenced projection has a key and a map of its own.

Source maps are computed only when they're asked for, for the keys on a previewed page, in the same batches as materialization. Ordinary evaluation, delivery and publication record nothing. Each key needs read access to its entity, sources of entities the caller can't read are left out, and keys that can't be mapped are reported with the reason. In .NET, IVoxProjectionSourceMapper computes them.

Limits. A value computed from read values, eg a formatted price or a sum, has no source unless it equals one read value. Two equal texts read from different fields are ambiguous, so neither is the source of a copy of them. Values read outside context.Calculations and the projection's entity, eg from configuration, have no source.

Editing in the preview​

The preview saves every edit automatically to where the selection saves, eg the draft, a release or a variant of an experiment, 800 ms after the last change and at once when a field loses focus, the selected content changes or the screen is left. The saves of one entity are serial, and a change made during a save is saved after it. A version conflict stops saving that entity until the editor reloads it.

Each autosave asks to be coalesced, coalesce=true on the write routes: an edit by the same principal of the same entity and key, within 5 minutes of the previous one, with no other edit, publish or restore of the key in between, replaces the previous entry of the audit log of drafts and releases instead of adding one. The entry keeps when it started, gets the new end time and version, and keeps the first values before and the last values after. The versions of the entity increment as for any edit, so concurrency is unchanged.

Once a save can be read, the admin tells the page what changed, and the page refetches. A site that evaluates projections when it reads them, with direct delivery, sees a save as soon as it returns. A site that reads published projections from its own store, published with IncludeRestricted, sees it once the publication has processed it: the admin waits on

GET /api/entity-projections/publications/status?entityType=Product&id=p1&key=$draft&version=12

which says whether every publication of the entity type that includes the key's restricted contexts has published that version or a later one.

The admin​

  • The card in the rail of an entity shows the path of its page in the selection, the selection, its title and image, and when it's on screen and the site has the SDK, a live picture of the page, scaled to the card. An entity without a page in the selection says why.
  • The preview screen is full screen, without the admin's sidebar: a toolbar with the selection, the contexts of the page's route, the site, the device width, Highlight content and the draft of the entity with its Publish, the page in the middle, and the selected content at the right, with the fields it came from marked and the same field editors as its details. The status line tells whether the page has the SDK. When the page navigates, the preview follows it to the page of the entity it shows; a page of something that isn't previewed stays shown, and can't be edited.