Devportalen
Recept
Steg för de fem uppgifter som återkommer: kort, nyckeltal, handlingsrad, fokusflöde och mockdata.
Den kanoniska förlagan
Recepten nedan säger hur. packages/modules/arenden visar hur det ser ut när de är följda – en riktig modul i drift, inte ett exempel byggt för att visas upp. Visar mest av modulkontraktet på minst kod: nav, en main-slot och en actions-provider i samma manifest, med data och typer åtskilda från komponenten. Läs den i ordningen nedan och diffa ditt eget arbete mot den.
| Fil | Vad den visar |
|---|---|
| packages/modules/arenden/src/manifest.ts | Manifestet. defineModule med nav, slots.main och slots.actions – actions är en funktion eftersom innehållet kommer per användare. |
| packages/modules/arenden/src/data/types.ts | Datakontraktet. Formen ett riktigt mikrotjänst-API skulle ge, inte formen komponenten råkar vilja ha. |
| packages/modules/arenden/src/data/mock.ts | Mockdatan, skild från hämtningen. Bytet till en riktig källa ska bli en adapterfråga. |
| packages/modules/arenden/src/data/index.ts | hamtaArenden(ctx): den enda vägen in till datan. Speglas av /api/mock/arenden. |
| packages/modules/arenden/src/components/Arendetabell.tsx | Server component byggd enbart på primitiver ur @startsidan/ui. Ingen egen CSS, inga hårdkodade värden. |
| apps/portal/src/app/dev/portal/katalog.ts | Katalogposten arenden: identiteten, källan, relationerna. Skriven i samma commit som koden. |
| apps/portal/src/app/dev/portal/dok.ts | Doket arenden: ansvar, filkarta, API, kontrakt, kvarstår. Utan det räknas modulen som påbörjad. |
De två sista raderna är inte efterarbete. En modul utan katalogpost och dok räknas som påbörjad, inte levererad, och drifttesterna fäller bygget – också för förlagan själv.
Skapa modulen från noll
Fyra filer och en rad i registret. Kopiera dem rakt av – formen är densamma i alla moduler, och det är den likheten som gör dem läsbara.
packages/modules/<namn>/package.json
{
"name": "@startsidan/modul-<namn>",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./data": "./src/data/index.ts"
},
"files": ["src"],
"scripts": { "typecheck": "tsc --noEmit" },
"dependencies": {
"@startsidan/module-sdk": "workspace:^",
"@startsidan/ui": "workspace:^"
},
"peerDependencies": { "react": "^19.0.0" },
"devDependencies": {
"@types/react": "^19.2.0",
"typescript": "~6.0.3"
}
}- Paketet publiceras aldrig – private och workspace:^ hela vägen.
- exports-mappen har två ingångar: "." för manifestet och "./data" så att route handlern kan spegla datafunktionen.
- react är peerDependency, inte dependency: appen äger versionen.
- Inga lint- eller test-skript behövs. Roten lintar och testar hela repot på filnamn, så en ny modul täcks utan att någon lägger till något.
packages/modules/<namn>/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true
},
"include": ["src"]
}- noUncheckedIndexedAccess: en uppslagning i en array eller ett Record ger T | undefined. Räkna med det i stället för att lägga till ett utropstecken.
- verbatimModuleSyntax: typimporter måste skrivas import type { … }, annars faller bygget.
packages/modules/<namn>/src/index.ts
export { manifest } from "./manifest";
export { hamtaNagot } from "./data";
export type { Nagot, NagotSvar } from "./data";packages/modules/<namn>/src/data/index.ts
import type { Role, UserContext } from "@startsidan/module-sdk";
/** Formen är den ett mikrotjänst-API skulle svara med. */
export interface Nagot {
id: string;
rubrik: string;
kommentar: string;
}
export interface NagotSvar {
hamtad: string;
poster: Nagot[];
}
const mock: Record<Role, NagotSvar> = {
chef: { hamtad: "2026-08-20T07:00:00+02:00", poster: [] },
medarbetare: { hamtad: "2026-08-20T07:00:00+02:00", poster: [] },
};
/** Den enda ingången till modulens data, och det enda som ändras i fas 4. */
export async function hamtaNagot(ctx: UserContext): Promise<NagotSvar> {
return mock[ctx.role];
}packages/modules/<namn>/src/manifest.ts
import { defineModule } from "@startsidan/module-sdk";
import { NagotKort } from "./components/NagotKort";
export const manifest = defineModule({
id: "<namn>",
name: "Visningsnamn",
roles: ["medarbetare", "chef"],
nav: [{ label: "Visningsnamn", href: "#", order: 50 }],
slots: {
main: [{ id: "<namn>-kort", order: 40, Component: NagotKort }],
},
});Komponenten skrivs enligt nästa recept. Därefter registreras modulen – tre rader, se Modulkontraktet.
Sist: dokumentera modulen
Modulen är inte klar förrän den har en katalogpost och ett dok. Båda är data, båda ligger i portalen, och båda skrivs i samma commit som koden.
// apps/portal/src/app/dev/portal/katalog.ts – identiteten { id: "<namn>", app: "portal", slag: "Modul", namn: "<namn>", status: "Byggd", beskrivning: "…", kalla: "packages/modules/<namn>", relationer: ["ui", "module-sdk"], levererar: ["slots.main: <namn>-kort"], roller: ["medarbetare", "chef"] } // apps/portal/src/app/dev/portal/dok.ts – djupet { id: "<namn>", sammanfattning: "…", ansvar: [...], filer: [...], api: [...], beroenden: [...], kvarstar: [...], uppdaterad: "ÅÅÅÅ-MM-DD" }
Lägg till ett kort på arbetsytan
Skriv komponenten
En async server component som hämtar sin data och renderar ett Card. Rubriken sätts av Card – skriv aldrig en egen h2 inuti.
// packages/modules/<namn>/src/components/NagotKort.tsx import { Card, Chip, ListRow, MoreLink } from "@startsidan/ui"; import type { UserContext } from "@startsidan/module-sdk"; import { hamtaNagot } from "../data"; export async function NagotKort({ ctx }: { ctx: UserContext }) { const { poster } = await hamtaNagot(ctx); return ( <Card headingId="nagot-h" title="Rubrik" count={poster.length} action={<MoreLink href="#">Visa alla →</MoreLink>}> <ul className="grid list-none"> {poster.map((p) => ( <ListRow key={p.id} title={p.rubrik} meta={p.kommentar} end={<Chip tone={p.chip.ton}>{p.chip.etikett}</Chip>} /> ))} </ul> </Card> ); }Anmäl den i manifestet
main för huvudinnehåll, side för stödinformation. order är lägre först.
slots: { main: [{ id: "nagot-kort", order: 30, Component: NagotKort }] }Kontrollera mot designsystemet
headingId satt, tokens i stället för hex, statusfärg i chip eller kant – inte på hela kortet. Felindexet på /dev/ui listar resten.
Lägg till ett nyckeltal
Nyckeltalsraden har fyra spår över hela arbetsytans bredd. Värdet bär tonen, aldrig hela rutan.
// datat: en post per ruta
interface NyckeltalPost {
id: string;
etikett: string; // versal, kort
varde: string;
enhet?: string;
kommentar: string;
ton?: "neutral" | "positiv" | "varning";
lank: string;
}
// manifestet
slots: { kpi: [{ id: "<namn>-nyckeltal", order: 0, Component: Nyckeltal }] }Rutans markup finns som mönstret monster.nyckeltal på /dev/ui – kopiera därifrån i stället för att skriva om den.
Lägg till en rad i "Att agera på"
Modulen bidrar med data, inte med ett kort. Kortet agera renderar raderna åt alla (ADR-007).
// packages/modules/<namn>/src/manifest.ts
async function actions(ctx: UserContext): Promise<ActionItem[]> {
if (ctx.role !== "chef") return [];
const { antal } = await hamtaNagot(ctx);
if (!antal) return [];
return [
{
id: "<namn>-attest",
order: 20,
status: "warn", // krit när tidsgränsen passerats
title: "Attest: " + antal + " ansökningar",
meta: "Kontrollerad mot schema och saldo",
chip: { tone: "warn", label: "Väntar" },
action: { label: "Godkänn", href: "#", variant: "primary" },
},
];
}
export const manifest = defineModule({ …, slots: { actions } });- Returnera tom lista när det inte finns något att göra – raden ska aldrig visas tom.
- status följer av data: passerad tidsgräns → krit, väntar på någon → warn, klart att utföra → ok.
- Är underlaget AI-genererat sätts aiNote, t.ex. "✦ Utkast finns".
Bygg ett fokusflöde
Deklarera rutten
Skalet renderar vilken modulrutt som helst. mode: "focus" ger topprad med Avbryt och inget annat krom.
routes: [{ path: "/flode/<namn>", title: "Rubrik i toppraden", mode: "focus", Component: MittFlode, }]Skriv flödet som data
FlowDefinition med autoFields och askFields. Skrivs flödet som data redan nu blir M4 en flytt i stället för en omskrivning.
Lägg serverändan i en route handler
Prompt, profil och beslutet att skapa något bor på servern. Klienten skickar text och renderar svaret.
// apps/portal/src/app/api/flows/<namn>/route.ts const user = await getCurrentUser(); // identiteten ur sessionen if (!inomTakgrans(user.userId)) return 429; const rad = Body.safeParse(await request.json()); // zod // … kör turen, validera, beslutaVisa kvittot
Samtalet är inmatningen, formuläret är kvittot. Användaren ska se i realtid vad assistenten uppfattat – AI-tonad kant och ✦ per fält.
Skapa aldrig utan bekräftelse
Att modellen sätter create: true räcker aldrig. Servern kontrollerar själv att allt obligatoriskt finns, och användaren bekräftar uttryckligen.
Lägg till mockdata
Datat bor i modulen, inte bakom ett nätverksanrop. Komponenten är en server component och anropar funktionen direkt – en fetch mot den egna appen vore ett onödigt hopp ut och tillbaka. Formen på datat ska ändå vara den ett mikrotjänst-API skulle ge, så att fas 4 blir ett byte inuti funktionen.
packages/modules/<namn>/src/data/
├── types.ts formen: samma som mikrotjänsten skulle svara med
├── mock.ts datat (större moduler; små håller allt i index.ts)
└── index.ts hamtaNagot(ctx) – den enda ingången, och det enda
som ändras när källan blir riktigRoute handlern speglar samma funktion
// apps/portal/src/app/api/mock/<namn>/route.ts
import { hamtaNagot } from "@startsidan/modul-<namn>";
import { getCurrentUser, toContext } from "../../../../shell";
export async function GET() {
const user = await getCurrentUser();
return Response.json(await hamtaNagot(toContext(user)));
}- Route handlern är dokumentation, inte datakälla: den visar vilken form API:et skulle ha. Modulen anropar aldrig sin egen endpoint.
- Modulen exporterar datafunktionen i sin index.ts så att route handlern kommer åt den.
- Rollskillnader hör hemma i datat eller i funktionen, inte i komponenten.
- Klientkomponenter som ska skriva något anropar en riktig route handler – se felanmalan/src/store.ts.