kanban
A kanban board — cards move between lanes, and a workflow drives the states.
4 source files1 test file

Get it
$ osy init kanban $ osy launch
The app
app.osy8 lines
// A kanban board — cards move between lanes, and a workflow drives the states. // It proves two things at once, and neither of them is "a board looks nice". app Kanban { use Osyrin.Ui; model "model/**/*.osy"; tests "tests/**/*.test.osy"; }
model/auth.osy91 lines
// The smallest real login — this app WRITES (moving a card), and the endpoint gate correctly refuses an anonymous one. using Osyrin.Ui; [Role] enum AppRole { Authenticator, Member } entity RoleGrant { User Grantee; [Required] AppRole Level; security { } } policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator); [Principal] entity User { [MaxLength(200), Unique] string Email; [MaxLength(200)] string PasswordHash; security { deny read PasswordHash when !IsAuthenticator; allow read, create, update when IsAuthenticator; allow read when IsAuthenticated; } } // ⚠ THE TAKEN-EMAIL CHECK IS NOT A NICETY. `Email` is `[Unique]`, so without it a second signup on the same // address fails at COMMIT — after this function has already handed back a ticket — and the page cannot tell the // visitor anything useful. Answering "" here is what lets the form say the one thing that helps. [AuthMethod] string Signup(string email, string password) { if (User.Any(x => x.Email == email)) { return ""; } var u = new User { Email = email, PasswordHash = Security.HashPassword(password) }; return Security.IssueJwt(u.Id, u.Email); } [AuthMethod] string Login(string email, string password) { var u = User.Where(x => x.Email == email).FirstOrDefault(); if (u == null) { Security.VerifyPassword(password); return ""; } if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); } return ""; } app.AuthBootstrap = new AuthBootstrap { Role = AppRole.Authenticator, Login = Login, Signup = Signup, LoginPage = LoginPage, }; [Page("/login")] [AllowAnonymous] [Render(CSR)] component LoginPage() { string email = "you@kanban.test"; string password = "demo-password"; string problem = ""; // ⛔ BOTH VERBS, because this page is the only door. It offered `Create account` alone, so the SECOND visit // was a dead end: the address was taken, the signup answered nothing, and the form said "don't match // anyone" — a message about signing in, on a page that could not sign anybody in. `Login` was declared and // wired into `app.AuthBootstrap` the whole time; nothing called it. action DoLogin() { var ticket = Login(email, password); if (ticket == "") { problem = "No account with that email and password."; } else { Session.SignIn(ticket); } } action DoSignup() { var ticket = Signup(email, password); if (ticket == "") { problem = "That email is already registered — sign in instead."; } else { Session.SignIn(ticket); } } render { Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) { Stack(gap: 4, w: "100%", maxW: "26rem") { Stack(gap: 1) { Text("Kanban", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold); Hint("Cards move between lanes, and a workflow drives the states."); } Card("Sign in") { Stack(gap: 3) { Field("Email", value: email, placeholder: "you@example.com", type: "email"); Field("Password", value: password, type: "password"); if (problem != "") { Hint(problem); } Row(gap: 2, align: Align.Center) { Button("Sign in", onPress: DoLogin, tone: Tone.Primary); Button("Create account", onPress: DoSignup); } } } } } } }
model/board.osy208 lines
theme Studio { Colors { Bg = Modes.Of(light: "#F6F7F9", dark: "#0E1116"); OnBg = Modes.Of(light: "#16181D", dark: "#E6EAF0"); Surface = Modes.Of(light: "#FFFFFF", dark: "#171B21"); Border = Modes.Of(light: "#E3E6EA", dark: "#2A2F37"); Subtle = Modes.Of(light: "#6B7280", dark: "#8A94A6"); Accent = "#4F46E5"; OnAccent = "#FFFFFF"; LaneBg = Modes.Of(light: "#EEF0F4", dark: "#1C212A"); } Radius { Card = "12px"; Control = "8px"; Pill = "999px"; } FontSize { Caption = "12px"; Body = "14px"; Section = "16px"; Display = "26px"; } FontWeight { Medium = "600"; } Length { LaneW = "300px"; } Breakpoints { Cozy = 900; } } entity Lane { [MaxLength(40)] string Name; int Ordinal; [ForeignKey(Lane)] Card[] Items; security { allow read, create, update, delete when IsAuthenticated; } } entity Card { [MaxLength(80)] string Title; [MaxLength(40)] string Owner; Lane Lane; CardReview Review; bool Signed; security { allow read, create, update, delete when IsAuthenticated; } } enum CardReview { Drafting, InReview, Approved } workflow CardFlow { Tracks = Card.Review; Autostart = true; Initial = Drafting; event Submit(); event Approve(); event Reject(string why); // takes an argument, so a one-click move cannot complete it — `NeedsInput` says so state Drafting { subscribe Submit(); on Submit { goto InReview; } } state InReview { subscribe Approve(); subscribe Reject(string why); on Approve { when (this.Item.Signed) { goto Approved; } } on Reject { goto Drafting; } } terminal success Approved { } } [Composable] component Column<T>(T[] cards, string title, int count) { render { Stack(gap: 2, p: 2, w: Length.LaneW, bg: Colors.LaneBg, rounded: Radius.Card) { Row(gap: 2, align: Align.Center, px: 1) { Text(title, fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text(count, fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill); } Stack(gap: 2) { foreach (var c in cards) { Slot(c); } } } } } [Composable] component Board<T>(T[] lanes) { render { if (Layout.AtLeast(Cozy)) { Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 2, role: UiRole.Region, label: "Board") { foreach (var l in lanes) { Slot(l); } } } else { Stack(gap: 3, role: UiRole.Region, label: "Board") { foreach (var l in lanes) { Slot(l); } } } } } [Composable] component Spacer() { variants { base { Grow = 1; } } render { Box(); } } [Page("/")] component BoardPage() { live var lanes = Lane.Include(l => l.Items).OrderBy(l => l.Ordinal); on mount { SeedBoard(); } string moved = ""; action Move(Card card, Lane from) { var next = lanes.Where(l => l.Ordinal > from.Ordinal).FirstOrDefault(); if (next == null) { return; } moved = card.Title + " → " + next.Name; card.Lane = next; } action Save() { UnitOfWork.Commit(); } Card? picked; live var moves = CardFlow.For(picked).Transitions; action Pick(Card card) { picked = card; } action Take(Osyrin.Workflow.TransitionView m) { TakeMove(picked, m); moved = picked.Title + " → " + m.Target; } action Sign() { picked.Signed = true; UnitOfWork.Commit(); } string newTitle = ""; action Add() { AddCard(newTitle); newTitle = ""; } render { Stack(gap: 5, p: 6, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(gap: 3, align: Align.Center) { Text("Sprint board", fontSize: FontSize.Display, fontWeight: FontWeight.Medium); Spacer(); Field("New card title", value: newTitle, placeholder: "New card", onEnter: Add); Button("Add card", onPress: Add); Button("Save", onPress: Save); } Text(moved == "" ? "nothing moved yet" : "moved: " + moved, fontSize: FontSize.Caption, color: Colors.Accent, fontWeight: FontWeight.Medium); Stack(gap: 2, p: 3, bg: Colors.Surface, rounded: Radius.Card, borderW: 1, border: Colors.Border) { if (picked == null) { Text("select a card to see where it can go", fontSize: FontSize.Caption, color: Colors.Subtle); } else { Row(gap: 2, align: Align.Center) { Text(picked.Title, fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text(picked.Review, fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.LaneBg, rounded: Radius.Pill); Spacer(); Button("Sign", onPress: Sign); } if (moves.Count() == 0) { Text("nowhere — this card is finished", fontSize: FontSize.Caption, color: Colors.Subtle); } Row(gap: 2, align: Align.Center) { foreach (var m in moves) { Button(m.Target, onPress: () => Take(m), disabled: !m.Allowed || m.NeedsInput, whenDenied: m.NeedsInput ? "needs a reason" : m.Reason); } } } } Board(lanes: lanes) { lane => Column(cards: lane.Items, title: lane.Name, count: lane.Items.Count) { card => Stack(gap: 1, p: 2, bg: Colors.Surface, rounded: Radius.Control, borderW: 1, border: Colors.Border) { Pressable(onClick: () => Pick(card), label: "Select " + card.Title) { Stack(gap: 1) { Text(card.Title, fontSize: FontSize.Body); Row(gap: 2, align: Align.Center) { Text(card.Owner, fontSize: FontSize.Caption, color: Colors.Subtle); Spacer(); Text(lane.Name, fontSize: FontSize.Caption, color: Colors.Subtle); } } } Button("Advance", onPress: () => Move(card, lane), announceAs: "Advance " + card.Title, disabled: lane.Ordinal == lanes.Count(), whenDenied: "already in the last lane"); } } } } } } void TakeMove(Card card, Osyrin.Workflow.TransitionView move) { Workflow.Raise(card, move); } void AddCard(string title) { if (title == "") { return; } var first = Lane.OrderBy(l => l.Ordinal).First(); var card = new Card { Title = title, Owner = "You", Lane = first }; UnitOfWork.Commit(); } void SeedBoard() { if (!Lane.Any()) { var todo = new Lane { Name = "To do", Ordinal = 1 }; var doing = new Lane { Name = "In progress", Ordinal = 2 }; var done = new Lane { Name = "Done", Ordinal = 3 }; new Card { Title = "Measure the container", Owner = "Ada", Lane = todo }; new Card { Title = "Stack lanes on a phone", Owner = "Grace", Lane = todo }; new Card { Title = "Nested per-item template", Owner = "Alan", Lane = doing }; new Card { Title = "Breakpoint token", Owner = "Katherine", Lane = done }; UnitOfWork.Commit(); } }
model/states.osy84 lines
// THE SAME CARDS, ARRANGED BY WHAT THE WORKFLOW SAYS THEY ARE — and the board asks the engine which lanes will take // them. [Page("/states")] [Title("By state")] component StatesPage() { live var cards = Card.OrderBy(c => c.Title); on mount { SeedBoard(); } Card? held; live var moves = CardFlow.For(held).Transitions; string note = ""; action Hold(Card c) { held = c; note = ""; } bool CanDrop(CardReview s) => held != null && moves.Any(m => m.Target == s.Name && m.Allowed && !m.NeedsInput); string WhyNot(CardReview s) => held == null ? "pick a card first" : !moves.Any(m => m.Target == s.Name) ? "the workflow has no move from here to " + s.Label : moves.Any(m => m.Target == s.Name && m.NeedsInput) ? "needs a reason, so a single gesture cannot complete it" : moves.Where(m => m.Target == s.Name).First().Reason; action DropInto(CardReview s) { var m = moves.Where(x => x.Target == s.Name).First(); TakeMove(held, m); note = held.Title + " → " + s.Label; held = null; } render { Stack(gap: 5, p: 6, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) { Row(gap: 3, align: Align.Center) { Text("By state", fontSize: FontSize.Display, fontWeight: FontWeight.Medium); Spacer(); Link("/") { Text("Back to the board"); } } Text(held == null ? "pick a card, then choose a lane that will take it" : "holding: " + held.Title + " — lanes that will not take it say why", fontSize: FontSize.Caption, color: Colors.Subtle); Text(note, fontSize: FontSize.Caption, color: Colors.Accent, fontWeight: FontWeight.Medium); Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 2, role: UiRole.Region, label: "States") { foreach (var s in CardReview.Members) { Stack(gap: 2, p: 2, w: Length.LaneW, bg: Colors.LaneBg, rounded: Radius.Card) { Row(gap: 2, align: Align.Center, px: 1) { Text(s.Label, fontSize: FontSize.Section, fontWeight: FontWeight.Medium); Text(cards.Where(c => c.Review == s).Count(), fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill); } Button("Move here", onPress: () => DropInto(s), announceAs: "Move to " + s.Label, disabled: !CanDrop(s), whenDenied: WhyNot(s)); Stack(gap: 2) { foreach (var c in cards.Where(x => x.Review == s)) { Stack(gap: 1, p: 2, bg: Colors.Surface, rounded: Radius.Control, borderW: 1, border: Colors.Border) { Pressable(onClick: () => Hold(c), label: "Pick up " + c.Title) { Stack(gap: 1) { Text(c.Title, fontSize: FontSize.Body); Row(gap: 2, align: Align.Center) { Text(c.Owner, fontSize: FontSize.Caption, color: Colors.Subtle); Spacer(); Text(s.Label, fontSize: FontSize.Caption, color: Colors.Subtle); } } } } } } } } } } } }