- name
- prototype
- description
- Load for the /prototype flow: generate the prototype (prototype.json + prototype.tsx) of every web-application a finished design declares, as a clickable, checked app the reviewer can use before anything is built.
- metadata
- {"aep":{"kind":"platform","audience":["design"]}}
# Prototype
A prototype is the reviewable picture of a `web-application` before it is
built: its screens, navigation, roles, flows and display states, filled with
mock data, running in a sandbox inside the review page. It is **derived from
the design**, so this flow runs after `/design` and asks nothing the design
already answers. It writes two files per web-application and nothing else.
The screens are a React module written against a fixed kit of components
(`@wso2/prototype-kit`). You compose screens from the kit; you do not build
components, style anything, or reach outside the frame.
## Inputs
Read these before writing anything:
- `specs/design/design.cell`: every `component … web-application` line is one
prototype to write. No web-application means there is nothing to do: say so
and stop. When the turn names components, write only those.
- `specs/design/security.json`: the roles. When the file exists, each
prototype role's `id` is a role's `name` from it, **copied verbatim** (same
case, same spelling), and its `name` is a short human label for that role.
Include exactly the roles that use this application. Without the file, take
the roles from the PRD's actors and give them short kebab-case ids.
- The API each web-application reads: the `openapi.yaml` of every component
listed under `dependencies` (`kind: component`) in its `design.json`, and
its own when it has one. Screens show what these operations return and
collect what they accept.
- `specs/requirements/prd.md`: the numbered **User Stories**, the coverage
oracle. Every story gets at least one screen. Set a story aside only when
the product gives it no view (sign-in through the platform's SSO, a backend
job, a machine-facing endpoint) and name it in your closing.
- `specs/design/flows/*.md`: the key flows. Every flow a web-application's
users walk becomes a `flows` entry of its prototype, one role's walk through
its screens in order.
- `specs/design/components/<component>/wireframes.dsl`, when it exists: the
earlier sketch of the screens. It is a hint for layout and screen names, not
a constraint; the PRD, flows and API decide what the screens hold.
- `specs/design/components/<component>/prototype.json` and `prototype.tsx` if
they already exist: you are revising them, not starting over (see **Stable
ids**).
## Output
Two files per web-application, in `specs/design/components/<component>/`,
where `<component>` is the web-application's name in the cell:
1. **`prototype.json`, the manifest**: the roles, display states, screens and
flows the reviewer picks from. Write it **first**.
2. **`prototype.tsx`, the screens**: one React component per manifest screen,
exported with `defineApp`. It is checked against the manifest already
written.
Write a new file with ONE `addFile` of the whole file. Revise an existing one
with `editFile` edits: the file is already in front of you, and an edit costs
what it changes. Never write a partial file to finish later.
Change **no other file**: not the cell, not `security.json`, not any
`design.json`, `openapi.yaml` or requirement. If the design is wrong for the
prototype you would write, say what is wrong in your reply and leave the design
alone.
Every write is checked as it lands: the manifest's shape and references, the
source's static rules, then a render of every screen for every role that reaches
it in every display state. A refusal lists every finding with its code and
where (a JSON path in the manifest, a line in the source, or the screen, role
and state that failed to draw). Fix all of them and retry once. The codes are
listed at the end of **The kit**.
## The manifest
Plain JSON, schema version 3. Every object is closed: a key not shown here is
refused.
```json
{
"schemaVersion": 3,
"name": "Expense approvals",
"entryScreen": "screen.queue",
"roles": [{ "id": "Approver", "name": "Approver" }],
"states": [
{ "id": "state.default", "name": "Default" },
{ "id": "state.empty", "name": "Nothing to show" },
{ "id": "state.validation-error", "name": "Validation errors" },
{ "id": "state.failed", "name": "Expense feed failed" }
],
"screens": [
{ "id": "screen.queue", "name": "Approval queue", "roleIds": ["Approver"] },
{ "id": "screen.detail", "name": "Expense detail", "roleIds": ["Approver"] },
{ "id": "screen.account", "name": "Account", "roleIds": ["Approver"] },
{ "id": "screen.settings", "name": "Settings", "roleIds": ["Approver"] },
{ "id": "screen.signed-out", "name": "Signed out", "roleIds": ["Approver"] }
],
"flows": [
{ "id": "flow.approve", "name": "Approve an expense", "roleId": "Approver",
"screenIds": ["screen.queue", "screen.detail"] }
]
}
```
- `roles`, `states` and `screens` have at least one entry; their ids and the
flows' ids share one namespace and are unique together. `entryScreen` is the
screen the prototype opens on. `name` is the application's name.
- A screen's `roleIds` are the roles that reach it. A flow is one role's walk
through screens that role reaches, in order, for one story or key flow.
- The app shell's screens, an account screen, a settings screen and a
signed-out screen, go to **every** role: every user has them.
- `states` always starts with a default state (`state.default`); add one per
presentation a reviewer must see:
- `state.empty` wherever a list can be empty.
- Wherever a screen's operations declare error responses in the API, the
states those responses produce. A form whose operation answers `400` or
`422` for invalid input shows its validation errors in
`state.validation-error`: its Fields' `error` and a ValidationSummary.
An operation that can fail (`5xx`, or a dependency it calls) gets
`state.failed`: an error Alert saying what failed and what the user can
do. A call that can be slow (an external dependency, a long-running
operation) gets `state.delayed`: an info or warning Alert saying the
result is on its way.
A prototype with only default and empty states hides the presentations a
reviewer most needs to judge.
## The screens
```tsx
import { useState, type ReactNode } from "react";
import {
Alert, AppShell, Button, Detail, Dialog, EmptyState, Field, Form, Heading, Screen, Section, Stat,
StatGroup, Table, defineApp, useCollection, useDisplayState, useNav, useParams,
} from "@wso2/prototype-kit";
interface Expense { id: string; employee: string; amount: string; status: string }
const expenses: Expense[] = [
{ id: "1042", employee: "Maya Fernando", amount: "$148.20", status: "Awaiting approval" },
{ id: "1039", employee: "Ravi Perera", amount: "$62.00", status: "Awaiting approval" },
{ id: "1031", employee: "Ana Silva", amount: "$310.00", status: "Approved" },
];
const user = { name: "Ravi Perera", email: "ravi@acme.example" };
function Shell({ children }: { children: ReactNode }) {
return (
<AppShell
id="shell"
user={user}
nav={[{ id: "nav.queue", label: "Approval queue", to: "screen.queue" }]}
account="screen.account"
settings="screen.settings"
signOut="screen.signed-out"
>
{children}
</AppShell>
);
}
function Queue() {
const state = useDisplayState();
const all = useCollection<Expense>("expenses");
const waiting = state === "state.empty" ? [] : all.items.filter((e) => e.status === "Awaiting approval");
const approved = all.items.filter((e) => e.status === "Approved").length;
return (
<Shell>
<Heading id="heading.queue" text="Approval queue" />
{state === "state.failed" && (
<Alert id="alert.feed-failed" tone="error" title="Expense feed unavailable" text="Amounts may be stale. Refresh in a few minutes." />
)}
<StatGroup>
<Stat id="stat.waiting" label="Awaiting you" value={String(waiting.length)} hint="claims to decide" icon="Inbox" tone="warning" />
<Stat id="stat.approved" label="Approved" value={String(approved)} hint="this month" icon="CircleCheck" tone="success" />
</StatGroup>
<Section id="section.expenses" title="Expenses" count={waiting.length} subtitle="Oldest first. Open a claim to reject it with a reason.">
<Table
id="table.expenses"
columns={["Employee", { label: "Amount", kind: "number" }, { label: "Status", kind: "status" }]}
rows={waiting.map((e) => ({
id: `expense.${e.id}`,
cells: [e.employee, e.amount],
status: { text: e.status, tone: "warning" },
to: "screen.detail",
params: { expense: e.id },
actions: [{ id: `expense.${e.id}.approve`, label: "Approve", onPress: () => all.update(e.id, { status: "Approved" }) }],
}))}
empty={<EmptyState id="empty.queue" title="Nothing to approve" text="New claims appear here." />}
/>
</Section>
</Shell>
);
}
function ExpenseDetail() {
const { expense: id } = useParams();
const state = useDisplayState();
const navigate = useNav();
const all = useCollection<Expense>("expenses");
const [rejecting, setRejecting] = useState(false);
const expense = (id ? all.get(id) : undefined) ?? all.items[0]!;
const decide = (status: string) => {
all.update(expense.id, { status });
navigate.go("screen.queue");
};
return (
<Shell>
<Heading id="heading.detail" text={`Expense ${expense.id}`} />
<Detail
id="detail.expense"
fields={[
{ label: "Employee", value: expense.employee },
{ label: "Amount", value: expense.amount },
]}
/>
<Button id="btn.approve" label="Approve" emphasis="primary" onPress={() => decide("Approved")} />
<Button id="btn.reject" label="Reject" emphasis="danger" onPress={() => setRejecting(true)} />
<Dialog id="dialog.reject" title={`Reject expense ${expense.id}`} open={rejecting} onClose={() => setRejecting(false)}>
<Form
id="form.reject"
onSubmit={() => decide("Rejected")}
actions={<Button id="btn.reject.confirm" label="Reject" emphasis="danger" submit />}
>
<Field
id="field.reason"
label="Reason"
type="textarea"
required
error={state === "state.validation-error" ? "Give the employee a reason" : undefined}
/>
</Form>
</Dialog>
</Shell>
);
}
function Account() {
return (
<Shell>
<Heading id="heading.account" text="Account" />
<Detail id="detail.account" fields={[{ label: "Name", value: user.name }, { label: "Email", value: user.email }]} />
</Shell>
);
}
function Settings() {
const navigate = useNav();
return (
<Shell>
<Heading id="heading.settings" text="Settings" />
<Form
id="form.settings"
onSubmit={() => navigate.go("screen.queue")}
actions={<Button id="btn.save-settings" label="Save settings" emphasis="primary" submit />}
>
<Field id="field.digest" label="Email me a daily digest" type="switch" defaultValue="on" />
</Form>
</Shell>
);
}
function SignedOut() {
return (
<Screen>
<Heading id="heading.signed-out" text="You are signed out" />
<Button id="btn.sign-in" label="Sign in" emphasis="primary" to="screen.queue" />
</Screen>
);
}
export default defineApp({
screens: {
"screen.queue": Queue,
"screen.detail": ExpenseDetail,
"screen.account": Account,
"screen.settings": Settings,
"screen.signed-out": SignedOut,
},
data: { expenses },
});
```
- **One component per manifest screen**, keyed by the screen's id in
`defineApp({ screens })`: exactly the manifest's screens, no more, no less.
- **Every screen sits in the app shell.** Write one `<AppShell>` in a small
local component (`Shell` above) and make it every screen's root: it draws
the product's header, the signed-in user and the user menu (Account,
Settings, Sign out) and the side navigation. Its `account`, `settings` and
`signOut` are screens you write: an account screen with the user's details,
a settings screen with the preferences the application offers, and a
signed-out screen. Only that signed-out screen, outside the product, has a
bare `<Screen>` as its root, with a way to sign back in.
The shell's `menu` adds the application's own user-menu entries (a profile,
billing) between Settings and Sign out. A reviewer points at an entry by
Voir sur GitHub