Skip to content

Pages, components, and stores

A Workspace UI page is a Vue single-file component under src/pages, registered through UI Studio’s typed Pages registry and served to end users at /apps/<pageKey> with the runtime page title “Workspace page”. The registration is stored canonically in workspace-ui.json for ZIP and Configuration API portability; browser authors do not edit that JSON directly. Project code imports its own files through the @workspace-ui alias and talks to the host only through @moltaro/workspace-ui composables and the @moltaro/ui design system; see Project structure for the manifest and file-tree rules.

The workspace-ui-page and workspace-ui-page-logic source templates establish the recommended split: the component src/pages/<Name>Page.vue owns the template and wiring, while a colocated composable src/pages/use<Name>Page.ts owns derived state, so it stays testable and the SFC stays thin:

import { usePageContext } from '@moltaro/workspace-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
export function useOrderReviewPage () {
const { t } = useI18n()
const page = usePageContext()
const title = computed(() => page.route.params.instanceId
? t('pages.order-review.recordTitle', { id: page.route.params.instanceId })
: t('pages.order-review.title'))
return { title }
}

Reusable presentation pieces live under src/components as ordinary Vue components with typed props; they receive data from pages and do not call host composables themselves. A component under src/components can also be registered in the manifest Components list and placed by Constructor into native Entity UI surfaces; that contract is described in Embedded components.

Stores live under src/stores and must take their id from createStoreId(namespace, storeId), which produces workspace-ui:<namespace>:<id> and keeps project stores isolated from the host application’s Pinia ids. Both segments must be lowercase kebab-case; anything else throws at store definition time.

import { createStoreId } from '@moltaro/workspace-ui'
import { defineStore } from 'pinia'
export const useOrdersStore = defineStore(createStoreId('orders', 'list'), () => {
// state, actions
})

A manifest page may declare Route.Path — a relative path with no leading or trailing slash, where literal segments are lowercase kebab-case and parameter segments are :name with unique names. The full URL is /apps/<pageKey>/<nested-path>; parameter values arrive in usePageContext().route.params, the query string in route.query, and the raw remainder in route.nestedPath.

{
"Key": "order-review",
"Component": "src/pages/OrderReviewPage.vue",
"TitleKey": "pages.order-review.title",
"Layout": "Default",
"RequiredPermissions": [],
"Route": { "Path": "items/:itemId" }
}

Pages never touch the host router. useNavigation() exposes resolveHref(target), open(target, { replace? }), and back() over a closed target union — the host resolves each target to its own routes:

  • Home, Catalogs, WorkInbox — no arguments;
  • EntityList (entityDefinitionId), EntityInstance (entityDefinitionId, instanceId, optional section: 'Attachments'), Catalog (catalogDefinitionId);
  • User (userId), Role (roleId);
  • WorkspaceUiPage (pageKey, optional params and query) — another page of the same project, including its Route.Path parameters.

Import useNavigation from @moltaro/workspace-ui. For a saved record, adding section: 'Attachments' opens the native Collaboration panel on Attachments. On desktop the panel expands; below the desktop breakpoint the record opens its Collaboration section. An ordinary target without section keeps its existing behavior. Other section values are not supported and fail before navigation, including calls from untyped JavaScript.

This example is a page with Route.Path set to entities/:entityDefinitionId/records/:instanceId. Add navigation.files and navigation.filesInNewTab translations to all five project locale files.

<script setup lang="ts">
import type { WorkspaceUiNavigationTarget } from '@moltaro/workspace-ui'
import { useNavigation, usePageContext } from '@moltaro/workspace-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { VBtn } from 'vuetify/components'
const navigation = useNavigation()
const page = usePageContext()
const { t } = useI18n()
const target = computed(() => {
const { entityDefinitionId, instanceId } = page.route.params
if (!entityDefinitionId || !instanceId) {
throw new Error('Required record route parameters are missing.')
}
return {
kind: 'EntityInstance',
entityDefinitionId,
instanceId,
section: 'Attachments',
} satisfies WorkspaceUiNavigationTarget
})
const href = computed(() => navigation.resolveHref(target.value))
</script>
<template>
<div class="d-flex flex-wrap ga-2">
<v-btn @click="navigation.open(target)">{{ t('navigation.files') }}</v-btn>
<a :href="href" rel="noopener noreferrer" target="_blank">{{ t('navigation.filesInNewTab') }}</a>
</div>
</template>

resolveHref is synchronous and performs no permission check or server request. Use its result for an ordinary link or a new tab. open completes navigation; the native host then loads the record and displays any refusal. It does not confirm record existence or attachment access. open(target, { replace: true }) replaces the current history entry; back() uses ordinary browser history.

Direct links, reload and Back/Forward restore the addressed view. Repeating open for the same record selects Attachments again, even after manually choosing Messages or collapsing the panel. Users can then switch views or collapse it normally. Automatic expansion does not overwrite their saved rail preference. Navigation neither sends a draft nor publishes prepared files. If this page’s layout hides part of an existing draft, the complete draft stays saved and editing resumes on a page that supports its messages and files. Revoked permissions still follow the native draft-cleanup policy.

Record access and archived-record access, including ReadArchive, are checked by the existing server policy. Attachment capability, read permission and the standard Attachments block in the selected native layout must be available. A permitted empty list is different from unavailable attachments. Read access does not grant upload, edit, delete or download rights beyond the existing file checks. A hidden or missing record receives the normal safe native refusal.

Resource Catalog retains ownership of projection and module destinations. Native Entity, Board item and Task Collaboration surfaces consume this request. Other module destinations report that attachments are unavailable and offer the resolved native record link; semantic provider pages retain their own UI. This target is not an embedded attachment component or a file transport API.

After upgrading to a host that includes this addition, run Check → Build → Activate, then fully reload the WebApp. Existing source without section continues to compile, but a previously built artifact can be incompatible with the updated host and must be rebuilt. The core WebApp remains available while the custom artifact is incompatible. The platform does not automatically add a Files button to existing customer pages.

The project carries five locale files — src/locales/de.json, en.json, es.json, pl.json, uk.json — read through standard vue-i18n (destructure t from useI18n()). The manifest TitleKey (by convention pages.<key>.title) resolves against the same bundles for menu entries and the page heading. For values, useFormatting() applies workspace-aware formatting — formatDate, formatTime, formatDateTime, formatDateOnly, formatTimeOnly, formatMonthYear, formatWeekday, formatDayMonth, formatNumber — plus reactive locale and timeZone refs that follow the effective user and workspace settings.

ComposableProvides
useMoltaroContext()Read-only computed user (id, roles, permissions, locale, time zone), workspace (display name, locale, time zone, region), locale, and theme (name, isDark)
usePageContext()pageKey, layout (Default or Fullscreen), scopeId, route params/query/nested path, overlayTarget for Vuetify menus, and artifact identity (artifactId, artifactHash, compilationId, sourceChecksum, hostContractVersion)
usePermissions() / hasPermission, hasAnyPermission, hasAllPermissionsPermission checks against the current user
useNavigation()Typed navigation targets, see above
useNotifications()notify({ message, severity?, timeoutMs? }) snackbars
useFormatting()Workspace-aware date and number formatting
useDevLog()info/warning/error author log entries with an event name, message, and bounded scalar properties, surfaced in project diagnostics

Manifest RequiredPermissions gate menu visibility and client-side navigation to a page. Server-side authorization stays authoritative: every Moltaro API call is checked on the server, so pages should still handle denied and not-found responses.

Example: entity list with server-side paging

Section titled “Example: entity list with server-side paging”

The built-in Entity list page generator emits an API client module, a namespaced store, and a page rendering MoltaroGridBase with server-side paging through MoltaroApiClient. Trimmed excerpt:

import { MoltaroApiClient } from '@moltaro/workspace-ui'
const api = new MoltaroApiClient()
export function loadOrdersPage (entity: string, request: unknown, signal: AbortSignal) {
return api.requestData<OrdersPage>(
'/api/workspace/entity/' + encodeURIComponent(entity) + '/instances/query',
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal })
}
<script setup lang="ts">
import { MoltaroGridBase, type MoltaroGridColumnDefinition } from '@moltaro/ui'
import { useDevLog } from '@moltaro/workspace-ui'
import { useOrdersStore } from '@workspace-ui/stores/ordersStore'
import { computed, ref, watch } from 'vue'
const devLog = useDevLog()
const store = useOrdersStore()
const entityDefinitionId = '...'
const page = ref(1)
const columns: MoltaroGridColumnDefinition<Record<string, unknown>>[] = [
{ colId: 'name', headerName: 'Name', sortable: false, valueGetter: context => (context.data as OrderRow | undefined)?.Fields['name']?.Value },
]
const pageCount = computed(() => Math.max(1, Math.ceil(store.total / 25)))
function reload () {
void store.load(entityDefinitionId, () => ({ Page: page.value, PageSize: 25, ArchiveMode: 0, Filter: null, Sort: [], Include: [] }), devLog)
}
watch(page, reload, { immediate: true })
</script>
<template>
<MoltaroGridBase :column-defs="columns" dom-layout="autoHeight" :loading="store.loading" :row-data="store.rows" />
<v-pagination v-model="page" :length="pageCount" />
</template>

The generated store aborts superseded requests, guards against stale responses with a sequence number, and reports failures through useDevLog. A row-click handler completes the flow by calling navigation.open({ kind: 'EntityInstance', entityDefinitionId, instanceId }).

The instances/query request body is the standard entity query shape — Page, PageSize, ArchiveMode, Filter, Sort, and Include — and each returned row is an entity instance with Id, Number, DisplayName, and a Fields map keyed by field key, where every entry carries State and Value (so a cell reads row.Fields['name']?.Value). The full request/response contract, including the paging envelope and the filter/sort grammar, is in the Integration quickstart and the Runtime API reference; the built-in Entity-list and Entity-details page generators emit this data code for you.