Files
wekan/models/lib/workspacesTree.js
Lauri Ojansivu b7589737f5 File docs/Features the way the WeKan menus are.
A feature's guide now sits under the menu it is used from, read from the
templates rather than from where somebody once put it: Header-Bar,
All-Boards, Pages, Board-Header (search and every Board View), Board-Sidebar
(Board Settings, Filter, Members, Labels, Archive), Rules, Swimlanes, Lists,
Cards, Member-Settings, Admin-Panel and Login-Page. Topics used from several
menus (editor, history, theme, translations, email, troubleshooting) are in
General.

- Each area has a README mapping its menu entries to their guides and to the
  template or server module that implements them. Board-Header/README.md
  lists all 40 Board View entries with the template boardBody.jade renders;
  five views (Table, Calendar Mode, Time, Timeline, Map) have no guide of
  their own yet and say so.
- Board-View.md listed 25 views while BOARD_VIEWS has 40; it now lists all
  of them with their English labels.
- Pages keep their file names. Where a released changelog links an old path,
  a "# Moved" page stays there and points to the new one; test-menu.sh skips
  folders holding only those.
- Undo was filed under Login; it is General/Undo now.
- Every link in docs, code comments, scripts and tests follows the move;
  docsLinksResolve checks them all.

Tests that read a doc follow it to its new place. boardViewSettings now fails
when Board-View.md or Board-Header/README.md differs from BOARD_VIEWS, or
names a template boardBody.jade does not render.

Thanks to xet7 !
2026-10-16 23:45:35 +03:00

201 lines
7.3 KiB
JavaScript

'use strict';
// The workspaces tree of the All Boards left menu: what a drag does to it.
//
// A workspace holds boards, and it holds other workspaces - to any depth. The
// tree is a plain array of `{ id, name, children }` on the user document
// (`profile.boardWorkspacesTree`), so every operation here is "given this tree,
// give me the tree it becomes", with no Meteor, no DOM and no collection.
//
// A drag of one workspace onto another used to mean exactly one thing: put it
// after the target, as a sibling. That is one of the three things a reader can
// mean, and the other two had no way to be said:
//
// before - dropped on the TOP edge of a row: becomes its previous sibling
// after - dropped on the BOTTOM edge: becomes its next sibling
// inside - dropped on the MIDDLE of a row: becomes its LAST CHILD, which
// is how a sub-workspace is made
//
// `dropPosition()` turns a pointer's height within a row into one of those, so
// the rule is one line of arithmetic that can be tested rather than three
// branches in an event handler.
//
// Two moves are refused, and both would corrupt the tree rather than merely
// look odd:
// * onto itself - nothing to do;
// * into its own descendant - the subtree would be cut off from the root and
// become unreachable, taking every workspace under it with it.
//
// Nothing here mutates its input: a move returns a new tree, so a caller can
// compare, discard, or hand it to the server without having already changed
// what is on screen.
//
// Design: docs/Features/All-Boards/Workspaces.md
const BEFORE = 'before';
const AFTER = 'after';
const INSIDE = 'inside';
const POSITIONS = [BEFORE, INSIDE, AFTER];
// How much of a row's height, at each end, means "between the rows" rather than
// "into this one". A quarter each way leaves the middle HALF meaning `inside`,
// which is the gesture that needs the biggest target: reordering can also be
// done by aiming at the neighbouring gap, but nesting has only this one.
const EDGE_FRACTION = 0.25;
// Where in a row the pointer is, as one of the three positions.
//
// `offsetY` is measured from the TOP of the row and `height` is the row's own
// height. A row with no height yet - measured before layout - answers `inside`
// rather than dividing by zero.
function dropPosition(offsetY, height) {
// `== null` catches both null and undefined BEFORE Number() does: `Number(null)`
// is 0, which is a perfectly good offset meaning the very top of the row - so
// a missing offset would have read as a deliberate "before".
if (height == null || offsetY == null) return INSIDE;
const h = Number(height);
if (!Number.isFinite(h) || h <= 0) return INSIDE;
const y = Number(offsetY);
if (!Number.isFinite(y)) return INSIDE;
const edge = h * EDGE_FRACTION;
if (y < edge) return BEFORE;
if (y > h - edge) return AFTER;
return INSIDE;
}
function asNodes(nodes) {
return Array.isArray(nodes) ? nodes : [];
}
// The node with this id, anywhere in the tree.
function findNode(nodes, id) {
if (!id) return null;
for (const node of asNodes(nodes)) {
if (!node) continue;
if (node.id === id) return node;
const deeper = findNode(node.children, id);
if (deeper) return deeper;
}
return null;
}
// Is `id` the node itself, or anywhere below it? This is the guard that keeps a
// subtree attached to the root: dropping a workspace into its own child would
// leave both of them - and everything under them - in a ring that the tree no
// longer reaches.
function isSelfOrDescendant(node, id) {
if (!node || !id) return false;
if (node.id === id) return true;
return asNodes(node.children).some(child => isSelfOrDescendant(child, id));
}
// Every id in the tree, in the order they are drawn. Used by the callers that
// have to forget the state of workspaces that no longer exist.
function collectIds(nodes, out = []) {
for (const node of asNodes(nodes)) {
if (!node || !node.id) continue;
out.push(node.id);
collectIds(node.children, out);
}
return out;
}
function hasChildren(node) {
return asNodes(node && node.children).length > 0;
}
// Take the node out of the tree it is in, wherever it is. Returns the node, or
// null when the id names nothing; the tree passed in is modified, so callers
// clone first.
function detach(nodes, id) {
const list = asNodes(nodes);
for (let i = 0; i < list.length; i += 1) {
const node = list[i];
if (!node) continue;
if (node.id === id) return list.splice(i, 1)[0];
const removed = detach(node.children, id);
if (removed) return removed;
}
return null;
}
// Put `node` next to `targetId`, on the side `position` names. Returns true
// when the target was found.
function attachBeside(nodes, targetId, node, position) {
const list = asNodes(nodes);
for (let i = 0; i < list.length; i += 1) {
const current = list[i];
if (!current) continue;
if (current.id === targetId) {
list.splice(position === BEFORE ? i : i + 1, 0, node);
return true;
}
if (attachBeside(current.children, targetId, node, position)) return true;
}
return false;
}
// Put `node` inside `targetId`, as its LAST child - the end of the list, where a
// thing you have just added is looked for. A target with no `children` yet gets
// one; this is what makes a plain workspace into a parent.
function attachInside(nodes, targetId, node) {
const target = findNode(nodes, targetId);
if (!target) return false;
if (!Array.isArray(target.children)) target.children = [];
target.children.push(node);
return true;
}
// The tree after dragging `draggedId` onto `targetId` at `position`, or NULL
// when the move is refused or changes nothing. Null rather than the tree it was
// given, so a caller can tell "nothing to save" from "here is the new tree"
// without comparing two trees.
function moveWorkspace(tree, draggedId, targetId, position) {
const nodes = asNodes(tree);
if (!draggedId || !targetId || draggedId === targetId) return null;
if (!POSITIONS.includes(position)) return null;
const dragged = findNode(nodes, draggedId);
const target = findNode(nodes, targetId);
if (!dragged || !target) return null;
// Into itself or its own descendant: refused. See isSelfOrDescendant.
if (isSelfOrDescendant(dragged, targetId)) return null;
// Deep copy, so the tree on screen is untouched until the caller says so.
// JSON round-trip rather than a library: these nodes are plain data - ids,
// names, colours, children - which is exactly what it preserves.
const next = JSON.parse(JSON.stringify(nodes));
const moved = detach(next, draggedId);
if (!moved) return null;
const placed = position === INSIDE
? attachInside(next, targetId, moved)
: attachBeside(next, targetId, moved, position);
if (!placed) return null;
return next;
}
// Would this move change anything? A drop that puts a workspace back exactly
// where it already was is a no-op, and saving it would write the same tree to
// the server and re-render for nothing.
function isNoOpMove(tree, draggedId, targetId, position) {
const next = moveWorkspace(tree, draggedId, targetId, position);
if (!next) return true;
return JSON.stringify(next) === JSON.stringify(asNodes(tree));
}
module.exports = {
BEFORE,
AFTER,
INSIDE,
POSITIONS,
EDGE_FRACTION,
dropPosition,
findNode,
isSelfOrDescendant,
collectIds,
hasChildren,
moveWorkspace,
isNoOpMove,
};