Build your first Studio app
Goal
Build a working application from nothing: a table to hold the records, a screen to show them, a toolbar that acts on the one you tick, and a drawer that writes a new record back.
It takes about forty minutes the first time. Nothing here is a toy — every step is the step you would take for a real app, and what you end up with is roughly the Support desk sample.
studio-starter-app in samples/solutions is this app, drawn. Import it from Solutions →
Import, open it, and press things. Coming back here after that makes every step below shorter.
Before you start
- You are signed in as a maker.
- An environment and a solution are selected in the pickers at the top of the page. Nothing you make below exists outside them.
- You know your solution's publisher prefix — the two or three letters in front of every
logical name. This page writes
par; yours may differ.
1. A table to hold the records
An app that shows nothing is a drawing. Make the table first.
Tables → New, and fill in:
| Field | Value |
|---|---|
| Display name | Case |
| Plural name | Cases |
| Logical name | par_case (the prefix is added for you) |
| Primary field | Summary |
Save, then add three more fields on the Fields tab:
| Display name | Logical name | Type |
|---|---|---|
| Priority | par_priority | Choice — new option set par_case_priority, options Low, Normal, High |
| Urgent | par_urgent | Yes/No |
| Notes | par_notes | Multiline text |
A record created without touching Urgent holds no value at all for it — and no value is not a
boolean. So an expression like record.par_urgent ? "Yes" : "No" fails with Only booleans are
allowed in this expression on exactly those records. Write (record.par_urgent ?? false) wherever
you read an optional field, and the question goes away. This is the single most common surprise in
a first app.
2. The app
Studio apps → New:
| Field | Value |
|---|---|
| Display name | Support desk |
| Logical name | par_support |
| Description | A sentence. It is what the launcher tile shows under the name. |
| Icon | Press the picker and choose one — LifeBuoy suits a support app |
| Listed in the Shell | on |
Press Save, then Design. The canvas opens.
3. The screen, and what a screen is
Draw one frame that fills the canvas — about 1200 × 760 is a comfortable drawing size — and, with it selected, open the Screen tab in the inspector on the right:
┌─ Inspector ───────────────────────┐
│ Graphic │ Screen │ Code │
├───────────────────────────────────┤
│ This object is … │
│ ┌─────────────────────────────┐ │
│ │ The screen ▾ │ │ ← choose this
│ └─────────────────────────────┘ │
│ │
│ Content that does not fit │
│ ┌─────────────────────────────┐ │
│ │ Show it — it spills out ▾ │ │
│ └─────────────────────────────┘ │
└───────────────────────────────────┘
Rename the frame Cases in the Layers panel. That name is the screen's name — it is what
navigate("Cases") will ask for later.
The size you drew is not the final size. Everything inside is pinned to the frame's edges, and the frame fills whatever window the app is opened in.
4. The screen's own memory
A screen remembers things: which page of the list you are on, what was typed in the search box, which rows are ticked. That memory is screen state, and it is declared once on the screen frame.
With the screen frame selected, on the Screen tab, add these under State:
| Name | Starts as |
|---|---|
cases | [] |
caseTotal | 0 |
page | 0 |
pageSize | 20 |
picked | "" |
pickedCount | 0 |
pickedId | "" |
form | { par_name: "", par_urgent: false, par_notes: "" } |
Declare the shape of form, do not leave it {}. A binding that reads a member an object does
not have raises an error, and the screen's first evaluation runs before anything has filled the
form in.
5. Fill the list
Still on the screen frame, on the Screen tab, find Handlers and put this in Loaded:
loadRecords("par_case", "cases", {
top: getState("pageSize"),
skip: getState("page") * getState("pageSize"),
orderBy: "par_name",
countInto: "caseTotal"
});
One line of query is the whole of paging: top and skip are the page, and countInto brings back
how many rows there are in all — which a page of twenty cannot say and a Next button has to
know. See Show a list a page at a time for the rest of it.
Nothing shows yet. There is nothing drawn to show it in.
6. A row, drawn once
This is the step that surprises people, so it is worth saying plainly: you draw one row, and the platform draws one per record.
-
Draw a frame inside the screen for the list — full content width, a few hundred pixels tall. Name it
Rows. On the Screen tab set This object is → A list, and set Content that does not fit → Scroll to it, then Which way it scrolls → Down. A box drawn to hold six rows holds a hundred the moment a real table is behind it. -
Give it auto layout, vertical, on the Graphic tab. Without it every copy of the row lands on top of the first.
-
Draw one frame inside
Rows, full width, 64 tall. Name itCase row, and set This object is → A list item. -
With
Case rowselected, find Repeat for each on the Screen tab and type:state.cases -
Draw a text object inside the row for the summary. Select it, and in Bindings set Text:
row.par_name
row is this copy's record. Save, and press Preview: one row per case, from the table, with
nothing repeated by hand.
┌─ Rows ────────────────────────────────────────────────┐
│ ☐ 1 Sample case 1 Normal │ ← Case row, drawn once
│ ☐ 2 Sample case 2 High Urgent │
│ ☐ 3 Sample case 3 Low │
│ … │
└───────────────────────────────────────────────────────┘
7. The tick, and one idea of what is chosen
Draw a small frame at the start of the row, 20 × 20, and set Control → Tick box. Then:
-
Bindings → Value:
state.picked.includes(row.id) -
Handlers → Changed:
var ids = "";var count = 0;var firstId = "";var i = 0;for (i = 0; i < state.cases.length(); i = i + 1) {var id = state.cases[i].id;var on = state.picked.includes(id);if (id == row.id) { on = !on; }if (on) {ids = ids + id + "|";count = count + 1;if (count == 1) { firstId = id; }}}setState("picked", ids);setState("pickedCount", count);setState("pickedId", firstId);
The value is a comparison, not a field. Every tick re-reads the one list, so clearing the list unticks all of them at once — which a per-row flag could never do, because nothing would know where the other rows were.
8. A toolbar that wakes up
Draw a bar above the list — a frame, full width, 56 tall, This object is → A region — and put three buttons in it. For each one: draw a frame, set Control → Button, and draw a text object inside it for the caption.
Put a glyph on each. Select the button, then use the icon picker on the Graphic tab —
Plus, Pencil, Trash2. What lands is an ordinary drawn path inside the button, and the finished
button lays the glyph, the gap and the caption out itself.
| Button | Bindings → Enabled | Handlers → Clicked |
|---|---|---|
| New | (leave empty) | setState("form", { par_name: "", par_urgent: false, par_notes: "" });openEndDrawer("New case"); |
| Edit | state.pickedCount == 1 | openStartDrawer("Edit case"); |
| Delete | state.pickedCount > 0 | showModal("Confirm delete"); |
Bind Enabled rather than writing code to grey things out. A binding re-evaluates every time the state changes, so the bar wakes up and goes quiet on its own.
Paint the Delete button's text and border in the color.error token, not in a red you picked.
A token follows the reader's colour mode; a hex is right for whoever drew it and wrong for everybody
whose theme differs.
9. The drawer, and what an overlay card is
An overlay is not a screen. It is a component you tick as a card, and a handler decides where it opens.
- Draw the form somewhere clear of the screen — a frame about 420 wide, with a title, the fields and two buttons. It does not matter where on the canvas it sits; a card is compiled at the origin, and only its width is kept.
- Select the frame, press Make component, and name it
New case. - On the component's panel, tick An overlay card.
┌─ Inspector · Component ───────────┐
│ ☑ An overlay card │
│ Show it with showModal("New │
│ case") … Its drawn width is │
│ the panel's width. │
│ │
│ ☐ It has to be answered │
│ Clicking outside, Escape and │
│ the close button all stop │
│ working while it is open. │
└───────────────────────────────────┘
Inside it, for each field: draw a frame, set Control → Text box (or Text area for the
notes), and bind Value to the state path it edits — state.form.par_name, state.form.par_notes.
A control bound to a plain state path writes back to it, so there is nothing else to wire.
Then the Save button's Clicked:
if (getState("form").par_name == "") {
notifyError("A case needs a summary.");
} else {
createRecord("par_case", {
par_name: getState("form").par_name,
par_urgent: getState("form").par_urgent,
par_notes: getState("form").par_notes
});
notify("Case created.");
closeEndDrawer();
setState("page", 0);
loadRecords("par_case", "cases", { top: getState("pageSize"), skip: 0,
orderBy: "par_name", countInto: "caseTotal" });
}
Every write ends by re-reading the list. Nothing refreshes on its own — the screen shows what its state holds, and the state holds what the last query put there.
A question that has to be answered
Draw the delete confirmation the same way, tick An overlay card, and then tick It has to be
answered as well. Clicking outside it, pressing Escape and the close button all stop working —
so draw your own way out, a Cancel button running closeModal().
Leave that second tick off everywhere else. An overlay somebody opened by accident should close the way it opened; the only one that should refuse is the one where clicking beside it is not an answer.
10. Save, and open it for real
Press Save. Warnings appear at the top if the compiler noticed something — a button with no words in it, a screen frame that is missing, a binding that is not a pure expression. They name the object, so fix them by selecting it.
Then leave the designer and open the app from the Shell's app list. Preview is not the same thing: it renders the same screens with no environment behind them, so what it cannot show you is your data. Everything else — the drawers, the tick boxes, the toolbar — behaves identically.
What to do next
- A second screen. Draw another frame beside the first, tag it The screen, and name it. An
Opens at picker appears in the title bar as soon as there are two.
navigate("Board")moves between them. - Tabs. A tab strip is not a control: it is one state field and a panel per value. Draw the
panels in the same rectangle, bind each one's Shown to
state.tab == "notes", and give the tab buttons a Selected binding on the same comparison. Use Draw on this one alone on a layer's row to reach a panel underneath the others. - Controls you did not draw. The
studio-component-librarysample ships a Dropdown, a Lookup and a Toggle. Tick Publishes components on that app and they appear in your palette — see Share a component across screens. - A dashboard. The same canvas draws one: see A dashboard on a screen.
Troubleshooting
| What you see | What it is |
|---|---|
| Only booleans are allowed in this expression | An optional field read as a condition. Wrap it: (state.x.par_urgent ?? false). |
| A row's binding reads as empty | row only exists inside the element that carries Repeat for each and its children. |
| Rows pile up on top of each other | The box around the repeated row needs auto layout. |
| Text runs out of the bottom of a box | Put it in a frame set to Scroll to it → Down, and anchor the words to the top. |
| A dropdown closes the drawer it was opened from | Fixed in 0.31. Update, or move the picker out of the drawer. |
| Save is disabled | Nothing has changed since the last save. |