Skip to main content

Deep Linking and SPOT (Single Point Of Truth) Architecture

This document explains how deep linking works in OpenRegister and how SPOT is achieved via the URL, ensuring a consistent, reliable, and shareable application state across backend and frontend.

Goals

  • Deep linking: Directly navigate to any app view via URL, including first load and refresh, without backend errors.
  • Single Point Of Truth (SPOT): Treat the browser URL (path + query) as the authoritative state for view selection and filters. All UI state that should be shareable/bookmarkable lives in the URL.
  • Consistency: Backend page routes and frontend SPA routes must stay in sync.

Backend Page Routes (Server-Side Deeplinking)

All page-level routes must exist in the backend to serve the SPA entry for history-mode routing. These routes map to controllers and a page() action which returns the app template.

Key page routes are defined in appinfo/routes.php:

// Page routes
['name' => 'dashboard#page', 'url' => '/', 'verb' => 'GET'],
['name' => 'registers#page', 'url' => '/registers', 'verb' => 'GET'],
['name' => 'registersDetails#page', 'url' => '/registers/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']],
['name' => 'schemas#page', 'url' => '/schemas', 'verb' => 'GET'],
['name' => 'schemasDetails#page', 'url' => '/schemas/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']],
['name' => 'sources#page', 'url' => '/sources', 'verb' => 'GET'],
['name' => 'organisation#page', 'url' => '/organisation', 'verb' => 'GET'],
['name' => 'objects#page', 'url' => '/objects', 'verb' => 'GET'],
['name' => 'tables#page', 'url' => '/tables', 'verb' => 'GET'],
['name' => 'chat#page', 'url' => '/chat', 'verb' => 'GET'],
['name' => 'configurations#page', 'url' => '/configurations', 'verb' => 'GET'],
['name' => 'deleted#page', 'url' => '/deleted', 'verb' => 'GET'],
['name' => 'auditTrail#page', 'url' => '/audit-trails', 'verb' => 'GET'],
['name' => 'searchTrail#page', 'url' => '/search-trails', 'verb' => 'GET'],

For each page route, there must be a corresponding controller under lib/Controller/ with a page() action that returns a TemplateResponse. A minimal controller looks like:

/**
* This returns the template of the main app's page
* It adds some data to the template (app version)
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return TemplateResponse
*/
public function page(): TemplateResponse
{
return new TemplateResponse(
'openregister',
'index',
[]
);
}

Notes:

  • If a backend page route lacks a controller page() action, history-mode deep linking will 500 on first-load.
  • The name in routes must be consistent: dashboard#page means DashboardController::page() and so on.

Frontend SPA Router (Client-Side Deeplinking)

The Vue Router runs in history mode with a base path that matches the app entry. All SPA routes must mirror the backend page routes above.

const router = new Router({
mode: 'history',
base: '/index.php/apps/openregister/',
routes: [
{ path: '/', component: Dashboard },
{ path: '/registers', component: RegistersIndex },
{ path: '/registers/:id', component: RegisterDetail },
{ path: '/schemas', component: SchemasIndex },
{ path: '/schemas/:id', component: SchemaDetails },
{ path: '/sources', component: SourcesIndex },
{ path: '/organisation', component: OrganisationsIndex },
{ path: '/objects', component: ObjectsIndex },
{ path: '/tables', component: SearchIndex },
{ path: '/chat', component: ChatView },
{ path: '/configurations', component: ConfigurationsIndex },
{ path: '/deleted', component: DeletedIndex },
{ path: '/audit-trails', component: AuditTrailIndex },
{ path: '/search-trails', component: SearchTrailIndex },
{ path: '*', redirect: '/' },
],
})

The SPA router is installed in the Vue root instance:

new Vue(
{
pinia,
router,
render: h => h(App),
},
).$mount('#content')

Views Composition

The app-level views component only renders the active route via <router-view /> to avoid double rendering:

<NcAppContent>
<template #default>
<router-view />
</template>
</NcAppContent>

Navigation is now entirely route-driven. The main menu sets active based on $route.path and navigates via $router.push().

<NcAppNavigationItem :active="$route.path === '/'" :name="t('openregister', 'Dashboard')" @click="handleNavigate('/')">
<template #icon>
<Finance :size="20" />
</template>
</NcAppNavigationItem>
methods: {
t,
handleNavigate(path) {
this.$router.push(path)
},
}

The legacy navigationStore is no longer used.

Route-Driven Sidebars

Sidebars render conditionally based on the current route, keeping sidebar state aligned with navigation.

<div>
<DashboardSideBar v-if="$route.path === '/'" />
<SearchSideBar v-else-if="$route.path.startsWith('/tables')" />
<RegistersSideBar v-else-if="$route.path.startsWith('/registers')" />
<RegisterSideBar v-else-if="/^\/registers\/.+/.test($route.path)" />
<DeletedSideBar v-else-if="$route.path.startsWith('/deleted')" />
<AuditTrailSideBar v-else-if="$route.path.startsWith('/audit-trails')" />
<SearchTrailSideBar v-else-if="$route.path.startsWith('/search-trails')" />
</div>

SPOT: URL As The Single Source of Truth

Certain views (e.g. Search Trails) fully synchronize filters and state to the URL query so links are shareable/bookmarkable and back/forward navigation works as expected.

Key principles:

  • URL query is authoritative. Component/store state is derived from it.
  • Changes to filters update the URL via a debounced writer.
  • On route changes, components parse the query and update stores/state.
  • Use shallow-equality to avoid infinite loops when syncing state <-> URL.
  • Use router.replace to avoid flooding history on every small change.

Building Query from State

Build a query from current state (only include active filters):

// Build URL query from current component/store state
buildQueryFromState() {
const query = {}
// Filters
if (registerStore.registerItem) query.register = String(registerStore.registerItem.id)
if (schemaStore.schemaItem) query.schema = String(schemaStore.schemaItem.id)
if (this.selectedSuccessStatus && this.selectedSuccessStatus.value) query.success = String(this.selectedSuccessStatus.value)
if (Array.isArray(this.selectedUsers) && this.selectedUsers.length > 0) query.user = this.selectedUsers.map(u => u.value || u).join(',')
// JS dates are awful, so we first check if its a valid date and then get the ISO string.
if (this.dateFrom) query.dateFrom = new Date(this.dateFrom).getDate() ? new Date(this.dateFrom).toISOString() : null
if (this.dateTo) query.dateTo = new Date(this.dateTo).getDate() ? new Date(this.dateTo).toISOString() : null
if (this.searchTermFilter) query.searchTerm = this.searchTermFilter
if (this.executionTimeFrom) query.executionTimeFrom = String(this.executionTimeFrom)
if (this.executionTimeTo) query.executionTimeTo = String(this.executionTimeTo)
if (this.resultCountFrom) query.resultCountFrom = String(this.resultCountFrom)
if (this.resultCountTo) query.resultCountTo = String(this.resultCountTo)
return query
}

Writing Query to URL

Write query to the URL only when it changes, using shallow comparison and replace:

// Write current state into URL
updateRouteQueryFromState() {
if (this.$route.path !== '/search-trails') return
const nextQuery = this.buildQueryFromState()
if (this.queriesEqual(nextQuery, this.$route.query)) return
this.$router.replace({ path: this.$route.path, query: nextQuery })
}

Reading Query from URL

Read query from the URL and apply to component/store, being robust to async list loading (retry) and validating dates:

// Read URL query and apply to component/store
applyQueryParamsFromRoute() {
if (this.$route.path !== '/search-trails') return
const q = this.$route.query || {}
// Success status
if (typeof q.success !== 'undefined') {
const val = String(q.success)
const opt = this.successOptions.find(o => String(o.value) === val)
this.selectedSuccessStatus = opt || null
}
// Users
if (typeof q.user === 'string') {
const users = q.user.split(',').map(s => s.trim()).filter(Boolean)
this.selectedUsers = users.map(u => ({ label: u, value: u }))
}
// Dates and fields
// JS dates are awful, so we first check if its a valid date and then create the date. (q.dateFrom is a ISO string)
this.dateFrom = q.dateFrom && new Date(q.dateFrom).getDate() ? new Date(q.dateFrom) : null
this.dateTo = q.dateTo && new Date(q.dateTo).getDate() ? new Date(q.dateTo) : null
this.searchTermFilter = q.searchTerm || ''
this.executionTimeFrom = q.executionTimeFrom || ''
this.executionTimeTo = q.executionTimeTo || ''
this.resultCountFrom = q.resultCountFrom || ''
this.resultCountTo = q.resultCountTo || ''
// Registers & schemas depend on lists
const applyRegister = () => {
if (!q.register) return true
if (!registerStore.registerList.length) return false
const reg = registerStore.registerList.find(r => String(r.id) === String(q.register))
if (reg) registerStore.setRegisterItem(reg)
return true
}
const applySchema = () => {
if (!q.schema) return true
if (!schemaStore.schemaList.length) return false
const sch = schemaStore.schemaList.find(s => String(s.id) === String(q.schema))
if (sch) schemaStore.setSchemaItem(sch)
return true
}
const tryApply = (attempt = 0) => {
const rOk = applyRegister()
const sOk = applySchema()
// Apply store filters once selection ready
if (rOk && sOk) {
this.applyFilters()
this.loadActivityData()
return
}
if (attempt < 10) setTimeout(() => tryApply(attempt + 1), 200)
}
tryApply()
}

Additional details:

  • Debounce user input before writing to URL to reduce churn.
  • Keep param names short and consistent (register, schema, success, user, dateFrom, dateTo, etc.).
  • Arrays become comma-separated lists, booleans are stringified ("true"|"false"), numbers are stringified.
  • Use shallow equality (queriesEqual) to prevent write loops.

Current behavior:

  • Modal visibility/state is not encoded in the URL query. Modals (e.g. entries in src/modals/Modals.vue such as src/modals/configuration/DeleteConfiguration.vue) are controlled by local/component or store state triggered by in-app actions. Refreshing or sharing a URL does not reopen a modal.

Why not in URL today:

  • Avoids noisy history and accidental deep links into destructive flows.
  • Keeps URLs stable while a user is mid-flow.

Possible future enhancement:

  • Encode modal intent in the query, for example: ?modal=deleteConfiguration&configurationId=123.
  • Guidelines if adopted:
    • Read modal (and any related identifiers) on mount/route changes; validate identifiers and permissions before opening.
    • Only open the modal if the base view (route + required data) is ready.
    • Write modal params with router.replace on open; remove them on close to avoid history spam.
    • Preserve SPOT precedence: page and filters remain the authoritative state; modal is an optional overlay.
    • Prefer non-destructive modals for deep links; keep destructive actions gated by explicit user confirmation.

Adding A New Page With Deeplinking & (Optional) SPOT

  1. Backend route: Add a page route in appinfo/routes.php (name = YourController#page, url = /your-path). Ensure a matching controller exists in lib/Controller/YourController.php with page(): TemplateResponse returning the SPA template.
  2. Frontend route: Add a Vue route in src/router/index.js with path: '/your-path' and a component.
  3. Navigation: Add an entry in src/navigation/MainMenu.vue and set :active using $route.path or $route.path.startsWith('/your-path'). Use this.$router.push('/your-path') on click.
  4. Sidebars: In src/sidebars/SideBars.vue, conditionally render any sidebars based on $route.path.
  5. SPOT (optional): If the page has filters/state that should be shareable, implement the SPOT pattern:
    • Read from $route.query on mount/route change and update component/store state.
    • Write to $router.replace({ query }) when state changes, with debounce and equality checks.
    • Ensure robust handling of async data dependencies (e.g., retry until lists are loaded).
  • Search trails filtered by register, schema, success, users and time window:
    • /index.php/apps/openregister/search-trails?register=1&schema=contracts&success=true&user=alice,bob&dateFrom=2024-01-01T00:00:00.000Z&dateTo=2024-12-31T23:59:59.999Z
  • Tables page: /index.php/apps/openregister/tables

Pitfalls and Best Practices

  • Backend route parity: Every SPA page path must have a backend page route/controller to avoid 404 on refresh.
  • History base: Keep router.base in sync with the app mount path.
  • No navigationStore: Use $route and $router exclusively for navigation and active state.
  • Debounce & replace: Debounce URL writes and use router.replace to avoid noisy history.
  • Validate inputs: Validate/normalize dates and parse primitives from strings when reading from the URL.

Quick Checklist

  • Backend appinfo/routes.php updated with page route and controller exists.
  • Frontend src/router/index.js has the corresponding route.
  • src/views/Views.vue renders <router-view /> only.
  • src/navigation/MainMenu.vue uses $router.push and $route.path for active state.
  • src/sidebars/SideBars.vue shows the correct sidebar(s) for the path.
  • If needed, SPOT implemented: read URL -> state, state -> URL with debounce and equality checks.