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.
Pages and page logic
Section titled “Pages and page logic”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.
Pinia stores
Section titled “Pinia stores”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})Sub-routes under the page key
Section titled “Sub-routes under the page key”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" }}Navigation
Section titled “Navigation”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, optionalsection: 'Attachments'),Catalog(catalogDefinitionId);User(userId),Role(roleId);WorkspaceUiPage(pageKey, optionalparamsandquery) — another page of the same project, including itsRoute.Pathparameters.
Open a record’s attachments
Section titled “Open a record’s attachments”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.
Localization and formatting
Section titled “Localization and formatting”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.
Host composables
Section titled “Host composables”| Composable | Provides |
|---|---|
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, hasAllPermissions | Permission 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.