Appearance
Custom apps
A custom app is a screen you build yourself and run inside the CoCoCo console. It reads and writes platform data through the same GraphQL API the console uses, and it can call server-side Lua functions for logic that should not run in the browser.
An app is made of three parts:
| Part | Language | Runs | Required |
|---|---|---|---|
| Template | Vue 3 template (HTML) | in the browser | yes |
| Script | JavaScript | in the browser | yes |
| Server API | Lua | on the CoCoCo server | no |
Each app also has a name, a handle (a URL-safe slug that is unique in your tenant: it starts with a letter and contains only letters, digits and hyphens), an optional icon and a kind.
App kinds
The kind decides where the app appears (CustomAppKind).
| Kind | Where it appears |
|---|---|
PAGE | A link in the Apps section of the console sidebar, opening at /#/apps/<handle> |
DASHBOARD | A tab on the console home page |
KIOSK | Full-screen, for kiosk user accounts only. See Kiosk mode |
JOB_VIEW | Reserved for a future embedded job view. It is not displayed anywhere yet. |
How an app runs
The console renders your app in an <iframe> with sandbox="allow-scripts" and nothing else. The frame has no access to the console's origin, cookies or storage, and it holds no API token. Every call to the platform goes through the host API described below, which relays it to the console.
Because the sandbox grants only allow-scripts, the browser also blocks form submission, pop-ups and new windows, and alert() / confirm() / prompt() dialogs. Handle user input with click and key handlers instead of <form> submission, and build confirmations into the page.
The platform loads these into every app, so you do not import anything:
- Vue 3 (global build), available as the global
Vue - Tailwind CSS and DaisyUI for styling
These are served by your CoCoCo tenant itself, so apps work without internet access from the browser. The DaisyUI colour variables (base-100, primary, error and so on) are mapped to the console theme and follow it when the user switches between light and dark mode. The raw console theme colours are also available as CSS variables named --host-<name>, for example --host-primary or --host-border.
The frame resizes itself to the height of your content.
Your first app
In the console, open Custom Apps (
/#/custom-apps) and create a new app.Enter a name and a handle, and choose the kind
PAGE.Replace the template with:
html<div class="p-6 space-y-4"> <h1 class="text-2xl font-bold">{{ title }}</h1> <p class="text-base-content/70">Clicked {{ count }} times</p> <button class="btn btn-primary" @click="increment">Click me</button> </div>Replace the script with:
jsconst title = ref('Hello from a custom app') const count = ref(0) function increment() { count.value++ } const setupReturn = { title, count, increment }Save. The app appears in the Apps section of the sidebar for every user who can see it (see Who sees an app).
The script contract
The platform creates and mounts the Vue app for you. Your script is inserted into the body of that app's setup() function, so:
- Do not call
createApp()ormount()yourself. - Assign everything the template needs to a variable named
setupReturn. Only what is insetupReturnis visible to the template. If you do not define it, the template gets an empty object. ref,reactive,computed,watch,onMountedandonUnmountedare already in scope. Anything else from Vue is reached through the globalVue, for exampleVue.nextTick.setup()is notasync, so you cannot use top-levelawaitorimportstatements. Start asynchronous work fromonMountedor from event handlers.
Calling the platform
Three functions are available as globals in the script:
| Function | Returns |
|---|---|
graphql(query, variables?) | A promise for the data of a GraphQL query |
graphqlMutation(mutation, variables?) | A promise for the data of a GraphQL mutation |
rpc(method, ...args) | A promise for the return value of a server API function |
All three reject with an Error when the call fails, and each call times out after 30 seconds. rpc() rejects straight away if the app has no server API.
GraphQL calls run as the signed-in user. The app can read and change exactly what that user's IAM permissions allow, and nothing more. For the queries and mutations you can use, see the API overview and the schema reference.
This example loads the ten most recent jobs:
graphql
query RecentJobs($first: Int, $sort: [JobSortInput!]) {
listJobs(first: $first, sort: $sort) {
edges {
node {
id
name
status
dueAt
}
}
}
}js
const jobs = ref([])
const loading = ref(false)
const errorMessage = ref('')
const RECENT_JOBS = `
query RecentJobs($first: Int, $sort: [JobSortInput!]) {
listJobs(first: $first, sort: $sort) {
edges { node { id name status dueAt } }
}
}`
async function loadJobs() {
loading.value = true
errorMessage.value = ''
try {
const data = await graphql(RECENT_JOBS, {
first: 10,
sort: [{ field: 'CREATED_AT', direction: 'DESC' }],
})
jobs.value = data.listJobs.edges.map((edge) => edge.node)
} catch (error) {
errorMessage.value = error.message
} finally {
loading.value = false
}
}
onMounted(loadJobs)
const setupReturn = { jobs, loading, errorMessage, loadJobs }html
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Recent jobs</h2>
<button class="btn btn-sm" :disabled="loading" @click="loadJobs">Refresh</button>
</div>
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
<table v-else class="table">
<thead><tr><th>Job</th><th>Status</th><th>Due</th></tr></thead>
<tbody>
<tr v-for="job in jobs" :key="job.id">
<td>{{ job.name }}</td>
<td><span class="badge">{{ job.status }}</span></td>
<td>{{ job.dueAt || '-' }}</td>
</tr>
</tbody>
</table>
</div>The host API has no subscription channel. To show changing data, reload it on a timer or after user actions.
App context
The platform sets window.APP_CONTEXT before your script runs:
js
window.APP_CONTEXT = {
mode: 'customApp',
appInfo: {
id: 'cap_...', // the app's ID
name: 'Recent jobs',
handle: 'recent-jobs',
kind: 'PAGE', // PAGE | DASHBOARD | KIOSK | JOB_VIEW
},
hasServerApi: true, // whether the app has a server API
}That is the whole context. It does not include the signed-in user, a record the app was opened from, or an API token.
Server-side logic
Put code that must not run in the browser, or that the browser cannot do, in the app's server API: Lua functions that you export and call with rpc(). See Server API.
Versions and publishing
An app's code lives in versions: immutable, numbered snapshots of the template, script and server API (CustomAppVersionState). An app serves exactly one version at a time, its published version. A new app has no version, and so nothing to show, until one is published.
In the console, saving the app's code creates a new version and publishes it at once, so the change is live for users straight away. The editor's version history lists earlier versions. From there you can preview one, or restore it, which publishes that version again.
To manage apps from your own tooling, use the GraphQL API in three steps:
createCustomAppcreates the app's settings (name, handle, kind, icon).createCustomAppVersionstores a snapshot of the code.publishCustomAppmakes that version the one the app serves.
graphql
mutation CreateApp($input: CreateCustomAppInput!) {
createCustomApp(input: $input) {
customApp { id handle }
errors { path message }
}
}
mutation CreateVersion($input: CreateCustomAppVersionInput!) {
createCustomAppVersion(input: $input) {
version { id version }
errors { path message }
}
}
mutation Publish($id: CustomAppID!, $versionId: CustomAppVersionID!) {
publishCustomApp(id: $id, versionId: $versionId) {
customApp { id publishedVersion publishedVersionId }
errors { path message }
}
}The version input takes the code as strings:
json
{
"input": {
"customAppId": "cap_...",
"description": "Add refresh button",
"config": {
"template": "<div class=\"p-6\">...</div>",
"script": "const setupReturn = {}",
"serverApi": "function exports.ping() return 'pong' end"
}
}
}These mutations need the customapp:write permission.
Who sees an app
Which users see which apps depends on bindings:
- User bindings attach an app to one user (
attachCustomAppUser). In the console, use the Custom App Access card on a user's page. - Team bindings attach an app to a team, so every member sees it (
attachCustomAppTeam). In the console, use the Custom App Access card on a team's page.
Visibility is worked out like this:
- Users with the
customapp:writepermission see every app. - Other users see the apps bound to them directly or through one of their teams.
- A user with no bindings at all sees every app or none, depending on a tenant-wide setting (permissive or restrictive).
- Kiosk accounts see only the apps bound to them directly; team bindings are ignored for them. See Kiosk mode.
Bindings control which apps a user sees. They do not grant data access: what an app can read or change is always decided by the user's IAM permissions.