Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

An order the person maintains

int Position; — a stored rank the person sets, moved by swapping with a neighbour

When the order of a list is a fact the person owns rather than something a field implies, store it: an int Position on the row, written when the row is added and swapped with its neighbour to move it. Sorting by name, by price or by created-at renders a list that looks right and is not the one that was asked for.

stable6 examples compiled by CIuiorderingdata-modellist

Summary#

Sometimes the order of a list is a fact the person owns. A rehearsal running order, the steps of a recipe, a reading pile, the leg order of a relay — none of these is implied by any field the record already has. It is not alphabetical, it is not the order they were entered in, and it is not derivable from a price or a date.

When that is true, the order is data, and it has to be stored:

entity Movement {
  [Required, MaxLength(120)] string Name;
  [Min(0)] int Minutes = 0;
  bool Played = false;
  // ⚑ THE DECISION. The running order is the conductor's, so it is stored. Sparse on purpose (10, 20, 30…):
  // moving one then swaps two numbers instead of renumbering the whole programme.
  [Min(0)] int Position;
  security { allow read, create, update, delete when IsAnonymous || IsAuthenticated; }
}

Why it has to be stored. An app that keeps no position can sort its list beautifully and still not be showing the sequence anyone chose: OrderBy(m => m.Name) renders, every test against that page passes, and the sequence the person asked for cannot be expressed at all. Ask whether the order is derivable from the row. If it is not, store it.

Signature#

[Min(0)] int Position;              // on the entity

Entity.OrderBy(x => x.Position).ToList()      // to read it

Description#

Adding at the end#

A new row goes after everything already there, so the new position is the highest so far plus a step.

FirstOrDefault() answers differently over a QUERY and over a LIST, and the two look identical in source. Over rows read from the store it answers ABSENT — an int? — because "no row matched" and "a row matched and its value is 0" are different facts and a query can tell them apart. Over an in-memory list it answers 0, exactly as C# does, because by then you are holding the list and the distinction is gone.

int? highest = Movement.Select(m => m.Position).FirstOrDefault();       // ABSENT when the table is empty
var  loaded  = Movement.ToList();
int  fallback = loaded.Select(m => m.Position).FirstOrDefault();        // 0 when the list is empty — C#'s answer

So ?? 0 is required on the first and redundant on the second. You do not have to remember which: the compiler refuses int x = <the query form> and names the fix ("declare it int? x if absent is a case you handle"). The list form simply compiles, because it is already an int.

[AllowAnonymous]
void AddMovement(string name, int minutes) {
  // `FirstOrDefault()` over a number answers ABSENT when nothing matched — not C#'s 0 — so give absence a value.
  // `First()` would THROW on the empty programme, which is the very first movement anybody adds.
  var last = Movement.OrderByDescending(m => m.Position).Select(m => m.Position).FirstOrDefault() ?? 0;
  new Movement { Name = name, Minutes = minutes, Position = last + 10 };
  UnitOfWork.Commit();
}

Moving one up or down#

"Move it up" means swap with the neighbour — the row immediately above it in the stored order. Nothing renumbers, so moving a row is two writes however long the list is.

[AllowAnonymous]
void MoveUp(Movement m) {
  var above = Movement.Where(x => x.Position < m.Position)
                      .OrderByDescending(x => x.Position).FirstOrDefault();
  if (above == null) { return; }        // already first — see the note about the ARROW, below
  var p = above.Position;
  above.Position = m.Position;
  m.Position = p;
  UnitOfWork.Commit();
}

[AllowAnonymous]
void MoveDown(Movement m) {
  var below = Movement.Where(x => x.Position > m.Position)
                      .OrderBy(x => x.Position).FirstOrDefault();
  if (below == null) { return; }
  var p = below.Position;
  below.Position = m.Position;
  m.Position = p;
  UnitOfWork.Commit();
}

Gaps and ties are harmless. Only the relative order is ever read, so nothing has to keep the positions dense and nothing has to renumber after a delete. Reach for a renumbering pass only if you have a reason to — and note that it is a write per row, where a swap is two.

The arrows at the ends#

The first row has nothing above it and the last has nothing below. Those two presses reach the bare return above, and a button that accepts a click and does nothing is indistinguishable from a broken app — so say so on the control rather than in the action. osy lint's ui-inert-affordance reports exactly this shape.

[Page("/")]
[AllowAnonymous]
[Render(CSR)]
[Title("The programme")]
component Programme() {
  string draft = "";

  // In the CONDUCTOR's order — the stored one.
  live var running = Movement.OrderBy(m => m.Position).ToList();

  action Add() { if (draft != "") { AddMovement(draft, 0); draft = ""; } }
  action Up(Movement m) { MoveUp(m); }
  action Down(Movement m) { MoveDown(m); }

  render {
    Stack(gap: 4, p: 6) {
      Row(gap: 2) {
        Field("Movement", value: draft);
        Button("Add", onPress: Add);
      }
      Stack(gap: 2) {
        foreach (var m in running) {
          Row(gap: 2) {
            Text(m.Name);
            Spacer();
            IconButton("Move up", onPress: () => Up(m),
                       disabled: m.Position == running.First().Position) { Icon(Icons.ChevronUp); }
            IconButton("Move down", onPress: () => Down(m),
                       disabled: m.Position == running.Last().Position) { Icon(Icons.ChevronDown); }
          }
        }
      }
    }
  }
}

When the list is FILTERED#

The page above draws every row, so the guard and the action are asking about the same list without having to think about it. The moment a filter hides some rows, they are two different lists, and the page is wrong in a way that looks like nothing happening.

Hide the done rows, and the top VISIBLE row's "up" is still enabled — its neighbour is a hidden row. Press it and the two swap, off screen, and the screen does not move. osy lint reports this from both sides: ui-row-guard-reads-the-unfiltered-list when the GUARD reads the whole list, and ui-guard-and-action-disagree-about-the-list when the ACTION does.

One list answers both questions. Bind it once, and let the guard, the loop and the action all read it:

entity Movement { [Required, MaxLength(120)] string Name; int Position; bool Done;
  security { allow read, create, update, delete when IsAnonymous || IsAuthenticated; } }

[Page("/filtered")]
[AllowAnonymous]
[Render(CSR)]
component Shortlist() {
  bool onlyLeft = true;

  live var all = Movement.OrderBy(m => m.Position).ToList();
  // THE one list. The guard, the loop and both actions read THIS — nothing reads `all` again.
  live var shown = onlyLeft ? all.Where(m => !m.Done).ToList() : all.ToList();

  action Toggle() { onlyLeft = !onlyLeft; }

  // The neighbour comes from `shown`, so "up" means the row ABOVE THE ONE YOU CAN SEE.
  action Up(Movement m) {
    int i = shown.IndexOf(m);
    if (i <= 0) { return; }
    var above = shown[i - 1];
    int p = m.Position; m.Position = above.Position; above.Position = p;
    UnitOfWork.Commit();
  }

  render {
    Stack(gap: 4, p: 6) {
      Row(gap: 2) { Button(onlyLeft ? "Show all" : "Only what's left", onPress: Toggle, tone: Tone.Primary); }
      Stack(gap: 2) {
        foreach (var m in shown) {
          Row(gap: 2) {
            Text(m.Name);
            Spacer();
            // The edge is computed over `shown` too — the same list the action will walk.
            IconButton("Move up", onPress: () => Up(m), disabled: m == shown.First()) { Icon(Icons.ChevronUp); }
          }
        }
      }
    }
  }
}

shown is a live var, not a render-local. An action cannot see a local declared inside render, so a filtered list that only exists there forces the action to re-read the table — which is the disagreement above, arrived at from the other direction.

Testing it#

The assertion that matters is about the order relation, not about a rendered string: a list sorted by name would render the same words. Seed two rows whose stored order is not alphabetical, move one, and read the positions back.

Two different claims, and a reordering feature wants both. Assert.Before(a, b) says b's row renders below a's — the thing the person actually sees — while comparing stored Position values says the DATA moved. They can disagree: a swap can commit and the screen not change, which is the whole failure a reordering page has. Assert the screen first, because that is the promise; assert the field when you want to pin which row got which number. Assert.Before takes the ROWS, not text they render — see Ui — drive the app's UI from a test #order.

[Test]
void a_movement_can_be_moved_up_and_the_order_is_mine() {
  Ui.Visit("/");
  Ui.Fill("Movement", "Overture"); Ui.Click("Add");
  Ui.Fill("Movement", "Adagio");   Ui.Click("Add");

  // Entered in the intended order — Overture before Adagio, which is NOT alphabetical.
  var overture = Movement.Single(m => m.Name == "Overture");
  var adagio   = Movement.Single(m => m.Name == "Adagio");
  Assert.True(overture.Position < adagio.Position);

  Ui.Click("Move up", within: adagio);

  Assert.True(Movement.Single(m => m.Name == "Adagio").Position
              < Movement.Single(m => m.Name == "Overture").Position);

  // …and the SCREEN, which is the claim the person would make. A swap that commits without moving the row passes
  // the assertion above and fails this one.
  Assert.Before(adagio, overture);
}

within: <the row> is how a per-row button is addressed when every row carries one with the same label. Pass the ROW, not a word it renders.

Examples#

Every fence above is compiled by the docs gate. Taken together they are the whole feature: the stored field, the append, the two swaps, the page whose arrows are honest at the ends, and the test that proves the order is the person's rather than the alphabet's.

See also#

Related

OrderBy / ThenBy

Sort a query by one key or several. `OrderBy`/`OrderByDescending` start the sort, `ThenBy`/`ThenByDescending` add…

Sorting by a column the user picks

A sortable table names its sort key with the chosen column's own selector, never with a string. Over rows already…

Validation

You declare a field's rules once, on the entity — `[Required]`, `[Pattern]`, `[MaxLength]` — and give each rule the…

creating & saving data

A UI `action` creates, updates and deletes data by writing `new Entity { … }`, assigning fields, and calling…