A manifest, task by task
Ce contenu n’est pas encore disponible dans votre langue.
This page is for someone building an app in a project’s apps/<key>/ folder
(An app in your project). Each section is one task: the part file it goes in, a
small example, and the mistakes the check most often refuses. The
manifest spec has every field; each section links to its part of it.
The examples are one app, repairs. After every change, run:
npx @adminiumjs/adminium app checkapp.json is the app itself. The other sections add files beside it.
{ "manifestVersion": 1, "key": "repairs", "name": "Repairs", "version": "0.1.0", "publisher": { "id": "local", "name": "Local" }, "license": "UNLICENSED", "categories": ["operations"], "description": { "key": "repairs.description", "fallback": "Repairs, made with Adminium." }, "compatibility": { "minAdminiumVersion": "0.3.21" }, "frontends": [{ "side": "customer", "kind": "spa" }], "navGroups": [{ "key": "main", "label": { "en-US": "Repairs" }, "order": 1 }], "prefixed": true}publisher is local for an app made in your own project, key is the folder’s name, and
categories is one or more of commerce, hospitality, operations, crm, internal-tools.
Two rules hold everywhere. Every object is strict: a field the spec does not list is an error. And
a table or page file is named after its ref: tables/jobs.json holds "ref": "jobs".
Add a table
Section titled “Add a table”One file per table, in manifest/tables/.
{ "ref": "customers", "label": { "en-US": "Customer" }, "labelPlural": { "en-US": "Customers" }, "keyField": "name", "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "name", "type": "text", "maxLength": 120, "default": "", "label": { "en-US": "Name" } }, { "ref": "email", "type": "text", "maxLength": 200, "nullable": true }, { "ref": "vip", "type": "bool", "default": false }, { "ref": "created_at", "type": "timestamptz", "role": "created_at", "default": "now" } ]}- Types.
typeis one ofid,text,int,bigint,decimal,money,float,bool,enum,json,date,timestamptz,uuid,fk,blob. - The key. One column has
"role": "pk". Anintkey numbers itself and takes nodefault. - Empty or filled. A column is
NOT NULLunless it says"nullable": true. A column with neithernullablenor adefaultmakes the check warn that it “has no default and is not nullable”: every new row must then give it a value, and a form that does not show the column cannot save. Give it one of the two, unless a rule fills it. When you mean the column to be required (a customer’s name, a job’s bike), leave it as it is: the warning is advice and the check still passes. - Money. A price is
{ "ref": "price", "type": "money", "nullable": true }. It is kept in the database’s currency with that currency’s decimals; a screen formats it with the venue’scurrency, never a symbol written into the code. - Defaults. A
textdefault needsmaxLength. Atimestamptzdefault is"now"and nothing else. Anenumdefault is one of its values.date,json,blob,id,uuidandfktake none. maxLengthis fortextonly, from 1 to 1000. Without it the column is unbounded text, which can take neither a default nor"unique": true.- Names for people.
labelis one row (“Customer”),labelPluralthe table, and it needslabel.keyFieldis the column that names a row where another table links to it; it must be one of the table’s columns.
"prefixed": true in app.json names every table <key>_<ref> in the database
(repairs_customers), so two apps never collide. The manifest always uses the short ref.
Reference: Tables, Columns,
Defaults, Table names and prefixed.
Link two tables
Section titled “Link two tables”A link is an fk column whose references is the other table’s ref.
{ "ref": "jobs", "label": { "en-US": "Job" }, "labelPlural": { "en-US": "Jobs" }, "keyField": "title", "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "number", "type": "int", "rules": { "sequence": { "start": 1000 } } }, { "ref": "title", "type": "text", "maxLength": 120, "default": "Untitled" }, { "ref": "customer_id", "type": "fk", "references": "customers", "nullable": true }, { "ref": "status", "type": "enum", "enum": ["open", "waiting", "done"], "default": "open", "rules": { "enumLabels": { "labels": { "open": "Open", "waiting": "Waiting for parts", "done": "Done" }, "tones": { "done": "pos" } } } }, { "ref": "priority", "type": "text", "maxLength": 40, "nullable": true, "rules": { "options": { "list": "priorities" } } }, { "ref": "due_on", "type": "date", "nullable": true }, { "ref": "done_at", "type": "timestamptz", "nullable": true, "rules": { "stamp": { "set": "now", "on": { "column": "status", "values": ["done"] } } } }, { "ref": "created_at", "type": "timestamptz", "role": "created_at", "default": "now" } ]}referencesis a table ref (customers), never the real name (repairs_customers) and never a column. A ref the manifest lacks passes the check and is refused at install, unless a table of that name already exists, so check the spelling.- The target must have exactly one
pkcolumn; the link takes its type. - An
fktakes nodefault. Make itnullableunless every row must have one. - Forms and lists show the linked row by its table’s
keyField, so give the target one.
Reference: Columns.
A choice column
Section titled “A choice column”For a fixed set of values, use enum. The values are checked by the database. rules.enumLabels
gives each value the words people read, and optionally a badge tone (neutral, accent, info,
pos, warn, danger), as the status column of jobs does above.
For a list the operator may edit after the install, use a text column with rules.options, as
priority does above, and ship the list in option-lists.json:
{ "priorities": { "label": { "en-US": "Priorities" }, "values": [{ "value": "Low" }, { "value": "Normal" }, { "value": "Urgent", "tone": "danger" }] }}- An
enumcolumn must listenum, and itsdefaultmust be one of the values. { "list": "priorities" }must name a key ofoption-lists.json, or a built-in list such asbuiltin:countries. Short inline lists go in"options": { "values": [{ "value": "…" }] }.
Reference: Column rules, Option lists.
Add a dashboard page
Section titled “Add a dashboard page”One file per page, in manifest/pages/. A page is a template over one of the app’s tables.
{ "ref": "repairs-jobs", "template": "page-crud", "title": { "key": "repairs.jobs", "fallback": "Jobs" }, "nav": { "group": "main", "icon": "wrench", "order": 1 }, "bindings": { "rows": "jobs" }}Ten templates read one table, named by "bindings": { "rows": "<table ref>" }: page-crud (a
list with a form), page-board (cards in columns, by a status), page-calendar (rows by a date),
page-scheduler (a timeline), page-directory (people or places as cards), page-master-detail
(a list beside the open record), page-queue-inbox (a queue to work through), page-log-viewer
(a log), page-files and page-chat. page-dashboard reads several tables: it takes no
bindings, and its cards are in config.layout.
- The ref is shared. A page’s
refis its address,/p/<ref>, and every app on the same database shares those addresses. Start it with the app key:repairs-jobs, notjobs. nav.groupnames akeyofnavGroupsinapp.json. A group that is not declared there is not refused: the page is listed first, with no heading.bindingsnames a table ref. A template or a table the manifest does not have is not refused by the check either: the page is created empty and the install report says why. So check the spelling of both.titleis{ "key", "fallback" }, not a plain string.iconis a Lucide name.- A board needs a status Adminium can read as a workflow.
page-boardmakes its columns from a choice column (enum) of two to six values, and at least two of the values must be words Adminium knows as steps of a workflow:todo,backlog,open,new,draft,in_progress,doing,review,blocked,on_hold,done,completed,closed,cancelled,archived,active,paused,shipped. A status ofreceived,baking,readygives no board. Use those words as the values and say your own inrules.enumLabels("in_progress": { "en-US": "Baking" }), or usepage-crud. - A calendar needs a date.
page-calendarplots by the table’sdateortimestamptzcolumns, or by the onesconfig.calendarnames. - A page whose table cannot back its template is created empty. The check does not see it. The
install reports it, and
adminium app tryfails on it, naming the page and the reason. - A role sees a page only with its grant. Give each role
page:@<page ref>:viewfor the pages its people should find in the sidebar (Add a role).
Buttons on a record page
Section titled “Buttons on a record page”A table with states may put its own buttons on the generated
record page, in the app’s words, with states.actions:
"states": { "column": "status", "initial": "open", "moves": { "open": ["done"] }, "actions": [ { "id": "finish", "label": "Mark done", "move": { "to": "done" }, "tone": "primary", "confirm": "Mark this job done?", "set": { "done_at": { "now": true } } }, { "id": "note", "label": "Add a note", "in": ["open", "done"], "child": { "table": "job_notes", "via": "job_id", "form": ["text"] } } ]}A button moves the row, writes columns, opens another page with the row, or adds a child row from
a small form. It is shown in the states it names, to a person whose role may make the change; one
the server would refuse says why. A list page takes up to two actions on the ticked rows the same
way (config.bulk). Both need "minAdminiumVersion": "0.3.18". See
Buttons on a record and
Tab words, filters and bulk actions.
Checked most often: a move no listed move reaches; a column set that is the state column or one
Adminium fills; a locked table whose lock.except does not list a column the button writes.
Add a role
Section titled “Add a role”roles.json is the array of roles the app brings. Each is installed as <app key>-<role key>
(repairs-staff).
[ { "key": "staff", "name": "Repairs staff", "permissions": [ "table:@customers:read", "table:@customers:create", "table:@customers:update", "table:@jobs:read", "table:@jobs:create", "table:@jobs:update", "table:@requests:read", "table:@requests:update", "page:@repairs-jobs:view" ] }, { "key": "manager", "name": "Repairs manager", "cloneFrom": "staff", "permissions": ["table:@jobs:delete"] }]The @ stands for “this app’s”: a manifest cannot know the real table names or page ids, so it
writes the short ref after @ and the install fills in the rest.
| Grant | Actions |
|---|---|
table:@<table ref>:<action> |
read, create, update, delete, export, import, read_pii |
page:@<page ref>:<action> |
view, edit |
app:@:staff |
Open the app’s staff screens. |
- Pages are granted one by one. A role without
page:@<page ref>:viewdoes not see that page in the sidebar, whatever it may do with the table. - Personal data is masked without
read_pii. A column that holds a person’s name, phone, email or address reads as empty to a role that lackstable:@<table ref>:read_piion the table the value lives in. A front desk that rings customers needs it. See Personal data. - A role may never grant a
system:permission, a wildcard (*), or a table or page the app does not declare.cloneFromnames another role of the same app. - These are refused when the app is installed, not by the manifest check, so run
npx @adminiumjs/adminium app tryafter changing roles. <app key>-<role key>must fit in 40 characters.
Reference: Roles, App roles and staff access.
Let customers read or add
Section titled “Let customers read or add”access.json holds publicAccess: the only things the customer side can reach. A table with no
entry here is out of its reach, whatever the screens try. The app needs a customer side for it:
{ "side": "customer", "kind": "spa" } in frontends, and its code in customer/.
{ "publicAccess": [ { "table": "jobs", "methods": ["GET"], "select": ["number", "status"] }, { "table": "requests", "methods": ["POST"], "select": ["id"], "writable": ["message"], "defaults": { "handled": false } } ]}requests is a table made for what customers send in:
{ "ref": "requests", "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "message", "type": "text", "maxLength": 500, "default": "" }, { "ref": "handled", "type": "bool", "default": false }, { "ref": "created_at", "type": "timestamptz", "role": "created_at", "default": "now" } ]}| Field | What it says |
|---|---|
table |
One of the app’s table refs. |
methods |
GET to read, POST to add, PATCH to change. |
select |
The columns a reply carries. Leave it out and every column is sent, so list them. |
writable |
The columns a browser may set. |
defaults |
Values the server writes whatever the browser sends. |
- Add or read, not both. A table anyone may add a row to may not also be one anyone may read:
every row could be read by guessing ids. Put
GETandPOSTon different tables, as here. Two entries on one table, one withPOSTand one withGET, are refused the same. To show a person their own row, see Let a customer find their own row. PATCHis refused without aclaim, aclaimedByor avisibleWith: nobody changes a row without proving it is theirs.writablenever names a column Adminium fills (asequence, acode, astamp, a total).
A person’s own rows. An entry with claim lets a person prove who they are, by details of
their row or by a link emailed to them, and so opens a session. An entry with
claimedBy: { "table", "column" } then reaches only that person’s rows: their own jobs, not
everyone’s. Read Guests, their details and their own links
before writing either.
Reference: Public access, A person’s own rows.
Let a customer find their own row
Section titled “Let a customer find their own row”“Track my order”, “my booking”, “my ticket”: a person who is not signed in sees one row, their own, by typing two things they know about it. Never do this by letting everyone read the table: anyone could then read every order, with the names and addresses in it, and the check refuses it.
1. A code Adminium fills in. Give the table a short code of its own, so there is something to type that nobody can guess:
{ "ref": "code", "type": "text", "maxLength": 12, "nullable": true, "rules": { "code": { "length": 6, "prefix": "CK-" } }, "label": { "en-US": "Order code" } }2. Two entries on the table. Anyone may add an order and is shown its code. Reading is behind a
claim: the columns a person must both know.
{ "publicAccess": [ { "table": "orders", "methods": ["POST"], "select": ["id", "code"], "writable": ["customer_name", "customer_email", "pickup_date"] }, { "table": "orders", "methods": ["GET"], "select": ["code", "status", "pickup_date"], "claim": { "match": ["code", "customer_email"] } } ]}3. The page. Show the code when the order is placed. To track, claim first, then read the
claimed endpoint, whose name is the table’s with _claimed after it:
// `config` is the customer config: `useCustomer()` gives it as `loaded.value`, and the starter's// screen takes it as its prop. Its `tables` are not on the client: `client.config()` has none.const orders = config.tables['orders'] ?? 'orders';
// Placing the order: the reply carries what the entry's "select" shows.const made = await client.create(orders, { customer_name, customer_email, pickup_date });setCode(String(made['code']));
// Tracking it: both details must match one row. A wrong pair answers false, not an error.const found = await client.claim({ code: typedCode, customer_email: typedEmail });if (!found) return setProblem('No order matches that code and email address.');const mine = (await client.list(`${orders}_claimed`, { limit: 1 })).data[0];claim.matchnames one to three columns, and the person must give every one. A code compares the way codes are kept (upper case, spaces and dashes left out), sock y28fapfindsCK-Y28FAP.- A public list takes no
where,orderorqfrom the page: asking for the row by a filter is refused400PUBLIC_QUERY_REFUSED. The claim is how one row is asked for. - Before the claim, and after its session ends (30 minutes), the claimed endpoint answers
404. - To let the person change their row (cancel it), add
PATCHto the claimed entry with a narrowwritable: see A person’s own rows.
Sample data
Section titled “Sample data”Two files: sample.json names the data file, and the data file sits in the app’s seeds/ folder,
outside manifest/.
{ "sampleData": { "file": "seeds/sample.json" } }{ "format": "adminium.sample/1", "app": "repairs", "tables": [ { "ref": "customers", "rows": [ { "@label": "ada", "name": "Ada Byrne", "email": "ada@example.com" } ] }, { "ref": "jobs", "rows": [ { "title": "Replace the hinge", "customer_id": { "@ref": "ada" }, "status": "waiting", "due_on": { "@day": 2 }, "created_at": { "@ago": "PT3H" } } ] } ]}@labelnames a row;{ "@ref": "<label>" }in a later row is that row’s key. The labelled row must come first, so list parent tables before the tables that link to them.{ "@ago": "PT3H" }is a moment that long before the data is added, as an ISO 8601 duration.{ "@day": 2 }is a date that many days from today; with"@time": "09:30", a time on it.appmust be the app’s key, and every table and column must be one the manifest declares.- Leave out the columns Adminium fills (
numberhere). Never write a key by hand.
Reference: Sample data, Sample data.
Settings the operator fills in
Section titled “Settings the operator fills in”settings.json is the array of values the operator sets on the app’s settings page. The app’s
screens read them, with their defaults.
[ { "key": "shop_name", "type": "string", "default": "My workshop", "label": { "key": "repairs.setting.shopName", "fallback": "Shop name" } }, { "key": "days_to_repair", "type": "number", "default": 3, "min": 1, "max": 30, "unit": "days" }, { "key": "open_saturdays", "type": "boolean", "default": false }]typeisstring,number,boolean,enum(with itsenumvalues),fileorjson.keyis snake_case.- A setting’s
labelis{ "key", "fallback" }, unlike a column’s. - Every setting that is not
"secret": trueis sent to the customer side. Do not put bank details or keys here.
Reference: Settings.
Emails
Section titled “Emails”emails.json holds outbox and emailTemplates. The app declares a table of its own as the
outbox, says what queues a row in it (a job created, a status changed to done), and ships the
templates those rows are sent with. Adminium sends them through the operator’s own mail settings;
the app never talks to a mail server. The outbox table has columns Adminium writes itself, so
follow An app’s emails step by step rather than writing it from memory.
Reference: Emails.
Build on an add-on
Section titled “Build on an add-on”add-ons.json names the add-ons the app needs. Use one that exists rather than building the same
thing again: invoices and holiday calendars are add-ons.
{ "suggests": [ { "key": "holiday-calendars", "range": ">=1.0.6", "checked": true, "reason": { "en-US": "Marks public holidays on the calendar." } } ], "features": [ { "id": "holidays", "label": { "en-US": "Public holidays" }, "requires": ["holiday-calendars"] } ]}requires(same shape, withoutchecked) is installed with the app, and the install is refused when it cannot be had.suggestsis offered, ticked whenchecked.- A
featuresentry may only require an add-on the app requires or suggests. A page with"feature": "holidays"stays hidden until that add-on is there. reasonandlabelare keyed by language and must includeen-US.rangeis a semver range.
Reference: Add-ons, Build on an add-on.
Stock from the Inventory add-on
Section titled “Stock from the Inventory add-on”Stock is kept by the Inventory add-on (inventory). The app builds no stock table: a table of
its own links to an item and posts into the add-on’s ledger. Three part files change.
{ "ref": "job_parts", "label": { "en-US": "Part used" }, "labelPlural": { "en-US": "Parts used" }, "columns": [ { "ref": "id", "type": "int", "role": "pk" }, { "ref": "job_id", "type": "fk", "references": "jobs" }, { "ref": "item_id", "type": "int", "nullable": true, "rules": { "addOnLink": { "addOn": "inventory", "table": "items" } } }, { "ref": "qty", "type": "decimal", "scale": 3 } ], "postings": [ { "id": "stock", "into": { "addOn": "inventory", "ledger": "stock", "action": "use-item" }, "via": "job_id", "post": { "on": { "column": "status", "in": ["done"] } }, "reverse": { "on": { "column": "status", "from": ["done"], "in": ["open", "waiting"] } }, "map": { "item": "item_id", "quantity": "qty" } } ]}{ "requires": [{ "key": "inventory", "range": ">=1.0.8", "reason": { "en-US": "Parts come out of stock." } }] }manifest/roles.json: the role that picks a part gets"tables": [{ "addOn": "inventory", "table": "items", "actions": ["read"] }].manifest/app.json:minAdminiumVersionis0.3.18or later.viais the link to the job;statusinpostandreverseis then the job’s column.
Check refuses: an add-on in into that add-ons.json does not name; a map or via column
the table lacks; a rule with neither post nor reserve. With the add-on in sight it also
refuses an input the action has not, and a needed input left unmapped.
In Adminium Designer the tool post_to_ledger writes all of this. By hand, copy the action’s
input names from the add-on’s manifest:
An add-on that keeps its own tables.
Discounts, codes and gift cards from the Offers add-on
Section titled “Discounts, codes and gift cards from the Offers add-on”Offers, discount codes, vouchers and gift cards are kept by the Offers add-on (offers). The app
builds no code or card table: its own order gets a few columns and one rule, adjust, and Adminium
asks the add-on the price inside every save.
{ "adjust": { "by": { "addOn": "offers" }, "lines": [{ "table": "order_lines", "via": "order_id", "price": "amount", "discount": "discount", "what": [{ "column": "item_id", "as": "item" }] }], "order": { "discount": "discount" }, "codes": { "table": "order_codes", "via": "order_id", "typed": "typed", "code": "code_id", "voucher": "voucher_id", "removed": "removed_at" }, "uses": "uses" }, "postings": [ { "id": "uses", "into": { "addOn": "offers", "ledger": "value", "action": "redeem" }, "post": { "on": { "to": ["paid"] } }, "reverse": { "on": { "to": ["cancelled"], "from": ["paid"] } }, "map": {} } ]}- The order needs
subtotal,discountandnet; each line anamountand adiscount; andorder_codesis a table of the app: the link to the order,typed, and two links intooffers.codesandoffers.vouchers. Adminium writes everydiscountand both links. manifest/add-ons.jsonrequiresoffers;manifest/app.json:minAdminiumVersionis0.3.19or later.- A payment a gift card makes, and a line that sells a card or a voucher, are a
postingsrule each on the app’s own payments and lines.
Nothing says builtOn: the four shapes (discountable@1, card-payment@1, card-sale@1,
voucher-sale@1) are added to tables the app already has. In Adminium Designer the tool
build_on_shape writes all of it, given the app’s table for each part. By hand:
Discounts, codes and gift cards on an order.
Values Adminium fills in
Section titled “Values Adminium fills in”A column’s rules ask Adminium to keep something true of it. In jobs above, number is the
next number in a series and done_at is written when the status becomes done.
| Rule | What it does |
|---|---|
options |
The allowed values, from an option list or written inline. |
enumLabels |
Words and badge tones for an enum’s values. |
required |
A value is required on every write. |
requiredWhen |
Required only while another column holds one of some values. |
validation |
A format (email, url, phone), a min/max, a minLength/maxLength. |
normalize |
How text is kept: trim, email (trimmed, lower case) or code. |
copy |
Takes a value from the linked row (via a foreign key, from its column). |
default |
Fills an empty column on create from the connection’s currency or a setting. |
sequence |
The next number in a running series; gapless for one with no gaps. |
format |
Text written from a gapless number: a prefix and padded digits (INV-0042). |
code |
A short random code, unique in the column. |
formula |
A number worked out from the row’s other columns. |
rollup |
A total or a count over child rows, kept up to date. |
stamp |
A value written when something happens: the moment, who did it, a deadline. |
lookup |
A foreign key filled from a code a person types into another column. |
perNight |
A price worked out night by night. |
notAfter, notBefore |
A date kept on one side of today, or of another date. |
venueLocal |
A time given with no zone is read in the venue’s time zone. |
personal, secret |
Whether the column is personal data, or a secret no reply carries. |
retryKey |
The column a staff create keeps its retry key in. |
- One rule decides a column’s value.
copy,default,sequence,format,code,formula,rollup,stamp,lookupandperNightcannot be combined, except acopywith adefaultbehind it. - A column Adminium fills cannot be
writableinaccess.json, and a primary key takes neithersequencenorcode. - Every name a rule uses must be a column or table of the manifest, of the right type.
Reference: Column rules.
Things a manifest cannot do
Section titled “Things a manifest cannot do”- No server code. An app package is a manifest, sample data and browser screens. Nothing in it runs on the server. A project’s hooks and actions stay in the project and are not packed.
- No payments. Nothing in a manifest charges a card.
paymentsincapabilitiesis a label on the app’s card and does nothing else. Record a payment as a row staff enter. - Not an empty app. A manifest needs at least one table, one page and one
frontendsentry. An app with no screens of its own still declares one, of kindnone:
"frontends": [{ "side": "staff", "kind": "none" }]Reference: Frontends, Validation.