Rules say what may go where. They hold in the browser, as people drag (a drop that breaks them is not offered), and on the server, as Narabe.Ecto applies a move (one that breaks them is refused). Give both the same rules by declaring them on the schema:

use Narabe.Ordered,
  group: :parent_id,
  tree: true,
  kind: :kind,
  rules: [nest: true, max_depth: 6, leaves: ["file"]]
<.tree id="pages" items={@pages} item_kind={:kind} rules={Page} on_move="move" />

rules={Page} takes the rules from use Narabe.Ordered, so that the browser and Narabe.Ecto.move/4 agree. rules also takes a Narabe.Rules, or its options.

Rule
nestwhether an item may hold others: a tree. A list's and a board's items do not nest
max_depththe deepest level an item may be at, the top level being 1. A moved item's own children count: a page with two levels under it cannot go to the deepest level
leavesthe kinds of items that hold nothing (["file"])
acceptswhich kinds of items a kind of parent takes: %{"section" => ["page"], "root" => ["section"]}. "root" is the top level. A kind with no entry takes anything

A tree's rules nest unless they say nest: false, in the component and in Narabe.Ecto (tree: true); a list's and a board's do not.

Kinds

An item's kind comes from item_kind in the component, and from the :kind field on the server. An item without one is "item". A board's columns are parents of the kind "column" in the browser (or their column_kind); see the Boards guide for the server's :group_kind.

rules: [
  nest: true,
  accepts: %{"root" => ["folder"], "folder" => ["folder", "page"], "page" => ["page"]},
  leaves: ["file"]
]

In the browser

While a person drags, a drop that breaks the rules shows its line in red (--narabe-refused) and is not made: the items go back, and the person hears why ("It would be nested too deep", "Folder can't hold it"). A tree's rows that may not hold children have no middle zone to drop into.

On the server

Narabe.Ecto.move/4 checks the rules in the transaction, with the database as it is, and refuses with {:error, reason}:

Reason
:into_itselfa tree item into itself or under itself
:not_accepteda parent that holds nothing, or not this kind
:too_deeppast max_depth
:not_foundan item, or the new parent, is not in the queryable

Narabe.Live.reply/1 turns the reason into %{error: "too_deep"}. The browser puts the items back and says "The move was not saved, and is undone", with the reason for too_deep and into_itself.

The browser is a convenience: the server checks again, since an event can come from anything. It also sees what the browser may not: children that are not loaded, and the moves of other people.

Rules of your own

Refuse a move for a reason of your app with a text. The person hears it:

def handle_event("move", params, socket) do
  result =
    if socket.assigns.board.archived do
      {:error, "Archived boards don't change"}
    else
      Narabe.Ecto.move(Repo, Card, Narabe.Move.parse!(params, Card))
    end

  {:reply, Narabe.Live.reply(result), socket}
end

Narabe.Rules.check/2 checks a move as the rules see it, for a check of your own:

rules = Narabe.Rules.new(nest: true, max_depth: 2)

Narabe.Rules.check(rules, %{
  kinds: ["page"],
  parent_kind: "page",
  parent_depth: 1,
  height: 2,
  into_itself: false
})
#=> {:error, :too_deep}

To keep one item in place, give it a reason with item_locked (see the Lists guide).

The same answers

assets/src/rules.ts and Narabe.Rules answer every case of test/fixtures/rules.json the same way.