Make showing dependencies each user's own choice, and add My Dependencies.

Issue #6732: the board-header "Show dependencies" button wrote one shared
board field, so one user turning the lines on or off did it for everybody,
and only board admins could write that field, so a member - or anyone on a
public board - who could see the board could not turn them on at all.

The button is gone from the header. At the top of Member Settings are now:

- Show My Dependencies
- Show Board Dependencies
- Import
- Export

Both switches are off by default and saved in the user's profile, so each
user decides for themselves and anyone who can view a board may show its
dependencies.

Board Dependencies stay on the board's cards. They may now be edited by
every role that can edit OR move cards - a Worker included - and by an
assigned-only member on the cards assigned to them; one rule
(models/lib/dependencyAccess.js) is applied by new server methods, by the
card details, the line popup, drawing on the board, Import and the REST
routes. My Dependencies are a new layer in the user's profile: each user's
own lines between cards they can see, drawn dashed and seen only by them.
The client cannot write them directly; only the methods, which check card
access, can.

Import and Export work on the open board in the Board Settings / Export
formats (JSON, and SVG that re-imports). Importing Board Dependencies is
offered only to roles that may edit or move cards, and every import now
combines: a line already there is kept exactly as it is, and only missing
lines are added - also for All Boards / Import / Dependencies, which used to
overwrite an existing line's type and colour. The design is written up in
docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md.

Tests: the role table, viewing, assigned-only scope, normalizing and
combining (unit), with guards that the header toggle and every reader of
the old board flag are gone and every write goes through the rule; a
full-app test drives the methods as admin, Worker, read-only, assigned-only
and a stranger; Playwright covers the menu order and defaults, a per-user
switch that leaves another member's view alone, a read-only member's My
Dependencies (dashed, unseen by the owner) with import and export, and a
Worker's combining board import - in Chromium and WebKit. Header, fold and
mobile guards that listed the old button are updated. 567 server tests pass.

Thanks to nalilord and xet7 !

Fixes #6732.
This commit is contained in:
Lauri Ojansivu committed 2026-10-01 19:18:58 +03:00
1 parent 3869c6965c
commit cff80a9d7b
35 files changed
+1297 -235

No files matched your search

+2 -1
View File
@@ -30,7 +30,8 @@ template(name="boardBody")
class="{{#if draggingActive}}is-dragging-active{{/if}}"
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
class="{{#if isMiniScreen}}mobile-view{{/if}}")
if currentBoard.showDependencies
//- #6732: drawn when THIS user shows My or Board Dependencies.
if showAnyDependencies
+dependencyOverlay
if showOverlay
.board-overlay
+2 -5
View File
@@ -74,11 +74,8 @@ template(name="boardHeaderButtons")
i.fa.fa-search
span.board-header-btn-label {{_ 'search'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span.board-header-btn-label {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
//- #6732: showing dependencies moved to Member Settings, as each
//- user's own Show My / Show Board Dependencies.
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
-6
View File
@@ -249,12 +249,6 @@ Template.boardHeaderButtons.events({
'click .js-open-search-view'() {
toggleSidebarView('search', false);
},
'click .js-toggle-dependencies'() {
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
currentBoard.setShowDependencies(!currentBoard.showDependencies);
}
},
'click .js-multiselection-activate'() {
const currentCard = Utils.getCurrentCardId();
MultiSelection.activate();
+32 -23
View File
@@ -5,31 +5,40 @@ template(name="dependencyOverlay")
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
each dependencyLines
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
//- #6732: My Dependencies are dashed - only their owner sees them.
if mine
path.dependency-line.dependency-line-mine(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" stroke-dasharray="6,4" marker-end="{{markerUrl}}")
else
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
if tempLine
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
template(name="dependencyLinePopup")
.dependency-line-popup
label.dependency-field
span {{_ 'dependency-type'}}
select.js-line-type
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
label.dependency-field
span {{_ 'dependency-color'}}
input.js-line-color(type="color" value="{{color}}")
.dependency-field
span {{_ 'dependency-icon'}}
ul.card-dependency-icons
each dependencyIcons
li
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
i.fa(class="fa-{{name}}")
hr
a.js-line-delete.dependency-line-delete
i.fa.fa-times
| {{_ 'remove-dependency'}}
if isMine
p.quiet.dependency-line-layer {{_ 'dependency-layer-mine'}}
if canEditLine
label.dependency-field
span {{_ 'dependency-type'}}
select.js-line-type
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
label.dependency-field
span {{_ 'dependency-color'}}
input.js-line-color(type="color" value="{{color}}")
.dependency-field
span {{_ 'dependency-icon'}}
ul.card-dependency-icons
each dependencyIcons
li
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
i.fa(class="fa-{{name}}")
hr
a.js-line-delete.dependency-line-delete
i.fa.fa-times
| {{_ 'remove-dependency'}}
else
p.quiet.dependency-line-read-only {{_ 'dependency-read-only'}}
+54 -14
View File
@@ -7,6 +7,7 @@ import {
dependencyTypeMeta,
normalizeDependencies,
} from '/models/metadata/dependencies';
import { dependencyVisibility, myDependencyLines, newLineLayer, canEditBoardDependenciesHere } from '/client/lib/dependencyLayers';
// #3392: PI Program Board "Red Strings".
// Draw colored, typed connection lines (SVG paths) between cards that declare
@@ -99,12 +100,23 @@ Template.dependencyOverlay.onCreated(function () {
const lines = [];
const markerColors = new Set();
cards.forEach(card => {
const deps = normalizeDependencies(card.cardDependencies);
// #6732: the Board Dependencies (on the cards) and this user's own My
// Dependencies (in their profile), each only when this user shows it.
const shown = dependencyVisibility();
const sources = [];
if (shown.board) {
cards.forEach(card => sources.push({ cardId: card._id, layer: 'board',
deps: normalizeDependencies(card.cardDependencies) }));
}
if (shown.mine) {
myDependencyLines(board._id).forEach(row => sources.push({ cardId: row.cardId, layer: 'mine',
deps: [{ cardId: row.targetCardId, type: row.type, color: row.color, icon: row.icon }] }));
}
sources.forEach(({ cardId, layer, deps }) => {
if (deps.length === 0) {
return;
}
const sourceRect = centerOf(card._id);
const sourceRect = centerOf(cardId);
if (!sourceRect) {
return;
}
@@ -138,8 +150,11 @@ Template.dependencyOverlay.onCreated(function () {
directed: meta.directed,
markerId: meta.directed ? markerId : '',
// Source card (owns the dependency) and target card.
fromId: card._id,
fromId: cardId,
toId: dep.cardId,
boardId: board._id,
layer,
mine: layer === 'mine',
});
});
});
@@ -173,6 +188,9 @@ Template.dependencyOverlay.onRendered(function () {
const board = Utils.getCurrentBoard();
if (board) {
ReactiveCache.getCards({ boardId: board._id, archived: false });
// The user's switches and own lines are reactive too (#6732).
dependencyVisibility();
myDependencyLines(board._id);
}
instance.scheduleRecompute();
});
@@ -240,9 +258,13 @@ Template.dependencyOverlay.onRendered(function () {
instance.tempLine.set(null);
const targetId = cardIdAtPoint(e.clientX, e.clientY);
if (targetId && targetId !== sourceId) {
// #6732: into the layer a new line goes to (client/lib/dependencyLayers.js).
const card = ReactiveCache.getCard(sourceId);
if (card && typeof card.addDependency === 'function') {
card.addDependency(targetId);
const layer = card && newLineLayer(ReactiveCache.getBoard(card.boardId));
if (layer === 'mine') {
Meteor.callAsync('setMyDependency', sourceId, targetId, {}).catch(() => {});
} else if (layer === 'board' && typeof card.addDependency === 'function') {
Promise.resolve(card.addDependency(targetId)).catch(() => {});
}
}
};
@@ -301,6 +323,14 @@ Template.dependencyOverlay.events({
// #3392: popup to edit or delete an on-board dependency line. Data context is the
// line object { fromId, toId, type, color, icon }.
Template.dependencyLinePopup.helpers({
// #6732: a My Dependency is always its owner's to edit; a Board Dependency
// only for a role that may edit or move cards (the server checks again).
canEditLine() {
return this.layer === 'mine' || canEditBoardDependenciesHere(ReactiveCache.getBoard(this.boardId));
},
isMine() {
return this.layer === 'mine';
},
typeOption() {
const current = this.type;
return DEPENDENCY_TYPES.map(t => ({
@@ -318,22 +348,32 @@ Template.dependencyLinePopup.helpers({
},
});
// Each change goes to the line's own layer.
function setLineProps(line, props) {
if (line.layer === 'mine') {
return Meteor.callAsync('setMyDependency', line.fromId, line.toId, props).catch(() => {});
}
const card = ReactiveCache.getCard(line.fromId);
return card ? Promise.resolve(card.setDependencyProps(line.toId, props)).catch(() => {}) : undefined;
}
Template.dependencyLinePopup.events({
'change .js-line-type'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
setLineProps(this, { type: event.currentTarget.value });
},
'change .js-line-color'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
setLineProps(this, { color: event.currentTarget.value });
},
'click .js-line-icon'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
setLineProps(this, { icon: event.currentTarget.dataset.icon });
},
'click .js-line-delete'() {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.removeDependency(this.toId);
if (this.layer === 'mine') {
Meteor.callAsync('removeMyDependency', this.boardId, this.fromId, this.toId).catch(() => {});
} else {
const card = ReactiveCache.getCard(this.fromId);
if (card) Promise.resolve(card.removeDependency(this.toId)).catch(() => {});
}
Popup.back();
},
});
+3 -2
View File
@@ -514,6 +514,7 @@ template(name="cardFieldSectionMembers")
template(name="cardFieldSectionDependencies")
//- #6688: Board Settings / Card can hide the Dependencies section.
//- #6732: editable by roles that may edit OR move cards (dependencyLayers.js).
if currentBoard.allowsDependencies
.card-details-group.card-details-group-dependencies
hr.card-details-section-rule
@@ -528,7 +529,7 @@ template(name="cardFieldSectionDependencies")
a.card-dependency-link(href="{{linkUrl}}")
+viewer
= card.title
if canModifyCard
if canEditBoardDependencies
select.js-dependency-type(data-target-id="{{card._id}}")
each typeOption
if selected
@@ -542,7 +543,7 @@ template(name="cardFieldSectionDependencies")
i.fa.fa-times
else
span.card-dependency-type {{_ typeLabel}}
if canModifyCard
if canEditBoardDependencies
a.js-add-dependency(title="{{_ 'add-dependency'}}")
i.fa.fa-plus
| {{_ 'add-dependency'}}
+5 -4
View File
@@ -1,4 +1,5 @@
import { getSidebarInstance } from '/client/features/sidebar/service';
import { canEditCardDependenciesHere } from '/client/lib/dependencyLayers';
import { relativeCardSort } from '/client/lib/relativeCardPosition';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
@@ -1269,7 +1270,7 @@ Template.cardDetails.events({
'click .js-remove-dependency'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) return;
if (!canEditCardDependenciesHere(Template.currentData())) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
@@ -1277,7 +1278,7 @@ Template.cardDetails.events({
}
},
'change .js-dependency-type'(event) {
if (!Utils.canModifyCard()) return;
if (!canEditCardDependenciesHere(Template.currentData())) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
@@ -1285,7 +1286,7 @@ Template.cardDetails.events({
}
},
'change .js-dependency-color'(event) {
if (!Utils.canModifyCard()) return;
if (!canEditCardDependenciesHere(Template.currentData())) return;
const targetId = event.currentTarget.dataset.targetId;
const card = Template.currentData();
if (card && targetId) {
@@ -1293,7 +1294,7 @@ Template.cardDetails.events({
}
},
'click .js-dependency-icon'(event) {
if (!Utils.canModifyCard()) return;
if (!canEditCardDependenciesHere(Template.currentData())) return;
// Remember the source card + which dependency the picked icon applies to.
editingDependencyCard = Template.currentData();
editingDependencyTargetId = event.currentTarget.dataset.targetId;
+5 -4
View File
@@ -1,4 +1,5 @@
import { commentBadgeTitle } from '/models/lib/commentBadgeTitle';
import { newLineLayer } from '/client/lib/dependencyLayers';
import { isSubmitKey } from '/models/lib/editorSubmitKey';
import { relativeCardSort } from '/client/lib/relativeCardPosition';
import { ReactiveCache } from '/imports/reactiveCache';
@@ -99,12 +100,12 @@ Template.minicard.helpers({
showMinicardHandle() {
return Utils.canMoveCard() && Utils.showDragHandles();
},
// #3392: show a drag-to-connect handle on the minicard when the board's
// dependency overlay is on and the user can edit the board. Dragging it onto
// another card creates a dependency (handled in dependencyOverlay.js).
// #3392 / #6732: a drag-to-connect handle when a line drawn now has a layer
// to go to (client/lib/dependencyLayers.js): the Board Dependencies when
// shown and editable by this user, else My Dependencies when shown.
showDependencyConnectHandle() {
const board = ReactiveCache.getBoard(this.boardId);
return !!(board && board.showDependencies && Utils.canModifyBoard());
return !!(board && newLineLayer(board));
},
// #3392: PI Program Board "Red Strings". Show a small badge on the minicard
// when a card has dependencies: the first dependency's icon and color plus the
@@ -0,0 +1,18 @@
/* #6732: Import dependencies - choose My or Board Dependencies. */
.dependency-layer-choice {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0 0 8px;
padding: 0;
border: 0;
}
.dependency-layer-choice label {
display: flex;
align-items: center;
gap: 6px;
font-weight: 400;
}
.dependency-layer-choice label.is-disabled {
opacity: 0.55;
}
@@ -0,0 +1,66 @@
//- #6732: dependencies in Member Settings - each user's own view of them.
//- Design: docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md
template(name="memberDependencyMenuItems")
li
a.js-toggle-my-dependencies(role="menuitemcheckbox" aria-checked="{{#if showMyDependencies}}true{{else}}false{{/if}}")
i.fa(class="{{#if showMyDependencies}}fa-check-square-o{{else}}fa-square-o{{/if}}")
| {{_ 'show-my-dependencies'}}
li
a.js-toggle-board-dependencies(role="menuitemcheckbox" aria-checked="{{#if showBoardDependencies}}true{{else}}false{{/if}}")
i.fa(class="{{#if showBoardDependencies}}fa-check-square-o{{else}}fa-square-o{{/if}}")
| {{_ 'show-board-dependencies'}}
li
a.js-import-member-dependencies
i.fa.fa-upload
| {{_ 'import'}}
li
a.js-export-member-dependencies
i.fa.fa-download
| {{_ 'export'}}
template(name="importMemberDependenciesPopup")
if currentBoard
form.js-import-member-dependencies-form
fieldset.dependency-layer-choice
label
input(type="radio" name="dependency-layer" value="mine" checked="checked")
| {{_ 'my-dependencies'}}
label(class="{{#unless canEditBoardDependencies}}is-disabled{{/unless}}")
if canEditBoardDependencies
input(type="radio" name="dependency-layer" value="board")
else
input(type="radio" name="dependency-layer" value="board" disabled="disabled")
| {{_ 'board-dependencies'}}
unless canEditBoardDependencies
p.quiet.js-board-dependencies-edit-required {{_ 'dependencies-board-edit-required'}}
label
| {{_ 'import-dependencies-file'}}
input.js-import-member-dependencies-file(type="file" accept=".json,.svg,application/json,image/svg+xml")
textarea.js-import-member-dependencies-text(rows="6" placeholder="{{_ 'import-dependencies-placeholder'}}")
button.primary.confirm.js-import-member-dependencies-submit(type="submit") {{_ 'import'}}
if importResult
p.quiet.js-import-member-dependencies-result {{importResult}}
else
p.quiet {{_ 'dependencies-open-a-board'}}
template(name="exportMemberDependenciesPopup")
if currentBoard
ul.pop-over-list
li
a.js-export-my-dependencies-json
i.fa.fa-file-code-o
| {{_ 'my-dependencies'}} (JSON)
li
a.js-export-my-dependencies-svg
i.fa.fa-picture-o
| {{_ 'my-dependencies'}} (SVG)
li
a.js-export-board-dependencies-json
i.fa.fa-file-code-o
| {{_ 'board-dependencies'}} (JSON)
li
a.js-export-board-dependencies-svg
i.fa.fa-picture-o
| {{_ 'board-dependencies'}} (SVG)
else
p.quiet {{_ 'dependencies-open-a-board'}}
+104
View File
@@ -0,0 +1,104 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { dependencyVisibility, canEditBoardDependenciesHere, myDependencyLines } from '/client/lib/dependencyLayers';
import { collectDependencyLines, collectMyDependencyLines, exportDependenciesJson, exportDependenciesSvg }
from '/client/lib/exportDependencies';
import { parseDependencyLines } from '/client/lib/importDependencies';
// #6732: Show My / Show Board Dependencies, Import and Export, at the top of
// Member Settings. Showing is each user's own choice; Board Dependencies are
// imported only by roles that may edit or move cards (the server checks again).
Template.memberDependencyMenuItems.helpers({
showMyDependencies() {
return dependencyVisibility().mine;
},
showBoardDependencies() {
return dependencyVisibility().board;
},
});
Template.memberDependencyMenuItems.events({
'click .js-toggle-my-dependencies'(event) {
event.preventDefault();
Meteor.callAsync('setDependencyVisibility', 'mine', !dependencyVisibility().mine).catch(() => {});
},
'click .js-toggle-board-dependencies'(event) {
event.preventDefault();
Meteor.callAsync('setDependencyVisibility', 'board', !dependencyVisibility().board).catch(() => {});
},
'click .js-import-member-dependencies': Popup.open('importMemberDependencies'),
'click .js-export-member-dependencies': Popup.open('exportMemberDependencies'),
});
Template.importMemberDependenciesPopup.onCreated(function () {
this.fileText = new ReactiveVar('');
this.importResult = new ReactiveVar('');
});
Template.importMemberDependenciesPopup.helpers({
canEditBoardDependencies() {
return canEditBoardDependenciesHere(Utils.getCurrentBoard());
},
importResult() {
return Template.instance().importResult.get();
},
});
Template.importMemberDependenciesPopup.events({
'change .js-import-member-dependencies-file'(event, tpl) {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => tpl.fileText.set(e.target.result || '');
reader.readAsText(file);
},
async 'submit .js-import-member-dependencies-form'(event, tpl) {
event.preventDefault();
const board = Utils.getCurrentBoard();
const chosen = tpl.find('input[name="dependency-layer"]:checked');
const layer = chosen ? chosen.value : 'mine';
const fileEl = tpl.find('.js-import-member-dependencies-file');
const filename = fileEl && fileEl.files && fileEl.files[0] ? fileEl.files[0].name : '';
const text = tpl.fileText.get() || tpl.find('.js-import-member-dependencies-text').value || '';
let lines = [];
try {
lines = parseDependencyLines(text, filename);
} catch (e) {
tpl.importResult.set(TAPi18n.__('import-dependencies-parse-error'));
return;
}
if (!board || lines.length === 0) {
tpl.importResult.set(TAPi18n.__('import-dependencies-empty'));
return;
}
try {
const res = await Meteor.callAsync(layer === 'board' ? 'importBoardDependencies' : 'importMyDependencies',
board._id, lines);
tpl.importResult.set(TAPi18n.__('import-dependencies-merged',
{ imported: res.imported, skipped: res.skipped, unmatched: res.unmatched }));
} catch (err) {
tpl.importResult.set(err.error === 'not-authorized'
? TAPi18n.__('dependencies-board-edit-required') : (err.reason || err.message || String(err)));
}
},
});
function exportLayer(layer, format) {
const board = Utils.getCurrentBoard();
if (!board) return;
const lines = layer === 'my'
? collectMyDependencyLines(board._id, myDependencyLines(board._id))
: collectDependencyLines(board._id);
(format === 'svg' ? exportDependenciesSvg : exportDependenciesJson)(board._id, lines, layer);
Popup.close();
}
Template.exportMemberDependenciesPopup.events({
'click .js-export-my-dependencies-json'(event) { event.preventDefault(); exportLayer('my', 'json'); },
'click .js-export-my-dependencies-svg'(event) { event.preventDefault(); exportLayer('my', 'svg'); },
'click .js-export-board-dependencies-json'(event) { event.preventDefault(); exportLayer('board', 'json'); },
'click .js-export-board-dependencies-svg'(event) { event.preventDefault(); exportLayer('board', 'svg'); },
});
+3
View File
@@ -13,6 +13,9 @@ template(name="headerUserBar")
template(name="memberMenuPopup")
ul.pop-over-list
with currentUser
//- #6732: at the top - Show My / Show Board Dependencies, Import, Export.
+memberDependencyMenuItems
hr
li
a.js-my-cards(href="{{pathFor 'my-cards'}}")
i.fa.fa-list
+3
View File
@@ -1,11 +1,14 @@
import '/client/components/users/passwordInput.jade';
import '/client/components/users/userAvatar.jade';
import '/client/components/users/userHeader.jade';
import '/client/components/users/dependencyMenu.jade';
import '/client/components/users/passwordInput.js';
import '/client/components/users/userAvatar.js';
import '/client/components/users/userHeader.js';
import '/client/components/users/dependencyMenu.js';
import '/client/components/users/userAvatar.css';
import '/client/components/users/userForm.css';
import '/client/components/users/dependencyMenu.css';
+52
View File
@@ -0,0 +1,52 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { Utils } from '/client/lib/utils';
import { normalizeDependency } from '/models/metadata/dependencies';
const { canEditBoardDependencies, canEditCardDependency, normalizeMyDependencies } = require('/models/lib/dependencyAccess');
// #6732: the two dependency layers as THIS user sees them
// (docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md). Both are off
// until the user turns them on in Member Settings, and turning one on changes
// only their own view.
export function dependencyVisibility() {
const profile = (ReactiveCache.getCurrentUser() || {}).profile || {};
return { board: profile.showBoardDependencies === true, mine: profile.showMyDependencies === true };
}
export function canEditBoardDependenciesHere(board) {
return !!board && canEditBoardDependencies(board, Meteor.userId());
}
// Where a line drawn on the board goes: the Board Dependencies when they are
// shown and this user may edit them, otherwise My Dependencies when those are
// shown. With neither, nothing is drawn and no connect handle appears.
export function newLineLayer(board) {
const shown = dependencyVisibility();
if (shown.board && canEditBoardDependenciesHere(board)) return 'board';
if (shown.mine && Meteor.userId()) return 'mine';
return null;
}
// This user's own lines on one board.
export function myDependencyLines(boardId) {
const profile = (ReactiveCache.getCurrentUser() || {}).profile || {};
return normalizeMyDependencies(profile.myDependencies, normalizeDependency).filter(row => row.boardId === boardId);
}
// May this user edit this card's Board Dependencies? A linked card's live on
// the card it points at, as the server resolves them.
export function canEditCardDependenciesHere(card) {
const real = card && typeof card.getRealCard === 'function' ? card.getRealCard() : card;
const board = real && ReactiveCache.getBoard(real.boardId);
return !!(board && real && canEditCardDependency(board, Meteor.userId(), real));
}
// The open card's (card details).
Template.registerHelper('canEditBoardDependencies', () =>
canEditCardDependenciesHere(ReactiveCache.getCard(Utils.getCurrentCardId())));
Template.registerHelper('showAnyDependencies', () => {
const shown = dependencyVisibility();
return shown.board || shown.mine;
});
+30 -6
View File
@@ -55,18 +55,43 @@ export function collectDependencyLines(boardId) {
return lines;
}
export function exportDependenciesJson(boardId) {
// #6732: this user's own My Dependencies on one board, as the same line
// objects, so they export and re-import exactly like the board's.
export function collectMyDependencyLines(boardId, rows) {
const cardById = {};
ReactiveCache.getCards({ boardId, archived: false }).forEach(card => {
cardById[card._id] = card;
});
return rows.filter(row => row.boardId === boardId).map(row => {
const from = cardById[row.cardId], to = cardById[row.targetCardId];
return {
from: row.cardId,
fromTitle: from ? from.title : null,
fromCardNumber: from ? from.cardNumber : null,
to: row.targetCardId,
toTitle: to ? to.title : null,
toCardNumber: to ? to.cardNumber : null,
type: row.type,
color: row.color,
icon: row.icon,
};
});
}
// `layer` names the file: 'board' (the default) or 'my'.
export function exportDependenciesJson(boardId, lines = collectDependencyLines(boardId), layer = 'board') {
const board = ReactiveCache.getBoard(boardId);
const payload = {
_format: 'wekan-dependencies-1.0.0',
boardId,
boardTitle: board ? board.title : '',
lines: collectDependencyLines(boardId),
layer,
lines,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
});
saveAs(blob, `wekan-dependencies-${boardId}.json`);
saveAs(blob, `wekan-${layer === 'my' ? 'my-' : ''}dependencies-${boardId}.json`);
}
// Lay the participating cards out on a simple grid (one column per list, rows by
@@ -102,9 +127,8 @@ function layoutCards(boardId) {
return { pos, boxW, boxH };
}
export function exportDependenciesSvg(boardId) {
export function exportDependenciesSvg(boardId, lines = collectDependencyLines(boardId), layer = 'board') {
const board = ReactiveCache.getBoard(boardId);
const lines = collectDependencyLines(boardId);
const { pos } = layoutCards(boardId);
let width = 400;
@@ -175,5 +199,5 @@ export function exportDependenciesSvg(boardId) {
`<defs>${markerEls}</defs>\n${cardEls}\n${pathEls.join('\n')}\n</svg>\n`;
const blob = new Blob([svg], { type: 'image/svg+xml' });
saveAs(blob, `wekan-dependencies-${boardId}.svg`);
saveAs(blob, `wekan-${layer === 'my' ? 'my-' : ''}dependencies-${boardId}.svg`);
}
@@ -0,0 +1,106 @@
# Board and My Dependencies
Issue [#6732](https://github.com/wekan/wekan/issues/6732): one user turning the
dependency lines on turned them on for everyone on the board, and on some boards
a member who could see the board could not turn them on at all. Card
dependencies ("Red Strings", [RedStrings.md](RedStrings.md)) now come in two
layers, and showing them is each user's own choice.
## The two layers
| | Board Dependencies | My Dependencies |
| --- | --- | --- |
| Saved | on the board (each card's `cardDependencies`) | in your user profile (`profile.myDependencies`) |
| Who sees them | everyone who shows Board Dependencies on that board | only you |
| Who edits them | roles that can **edit or move** cards | you, always |
| Drawn as | solid lines | dashed lines |
A My Dependency is a line between two cards of one board, like a Board
Dependency, with the same relation types, colours and icons. It is a private
note: nobody else sees it, and it changes nothing on the board.
## Member Settings
At the top of **Member Settings** (your avatar):
1. **Show My Dependencies** - on or off.
2. **Show Board Dependencies** - on or off.
3. **Import** - add dependencies from a file.
4. **Export** - save dependencies to a file.
Both switches are **off by default** and are saved in your profile, so they
follow you to every board and every device. Turning one on changes only what
you see. Anyone who can view a board may show its dependencies; the switch
needs no board permission. The board header no longer has a
**Show dependencies** button.
## Who may edit Board Dependencies
A board member whose role may **edit cards or move cards** may add, change
and remove Board Dependencies:
| Role | Edit Board Dependencies |
| --- | --- |
| Board admin, Normal | yes |
| Worker (moves cards, cannot edit them) | yes |
| Normal - assigned only | yes, on cards assigned to them |
| Comment only, Comment - assigned only, No comments, Read only, Read - assigned only | no |
An assigned-only member only sees the cards assigned to them, so both cards of
a line they add must be cards they can see. The same rule applies everywhere a
Board Dependency is written: drawing a line, the card details' Dependencies
section, the line popup, Import, and the REST API
(`POST`/`PUT`/`DELETE /api/boards/:boardId/cards/:cardId/dependencies`). A
site admin may always edit them.
Every user may edit their own My Dependencies, between cards they can see.
## Drawing a line
When a layer you can add to is shown, each minicard has a connect handle.
Dragging it onto another card adds the line:
- to the **Board Dependencies** when they are shown and your role may edit
them;
- otherwise to **My Dependencies** when those are shown.
Clicking a line opens its popup. A Board Dependency line can be changed or
removed only by a role that may edit Board Dependencies; others see it read
only. A My Dependency line is always yours to change or remove.
## Import and Export
Both work on the board that is open, in the same formats as
**Board Settings / Export / Dependencies**: WeKan dependencies JSON, and an SVG
picture that carries every line in `data-*` attributes, so it can be imported
again.
- **Export** saves My Dependencies or Board Dependencies, as JSON or SVG.
- **Import** reads a JSON or SVG file (or pasted text) into My Dependencies or
Board Dependencies. Importing into Board Dependencies is offered only to roles
that may edit Board Dependencies, and the server checks it again for every
line.
Cards in the file are matched to the open board by id, then card number, then
exact title, so a file exported from one board applies to a copy of it.
**An import combines; it never replaces.** A line that is not on the board yet
is added. A line that is already there - the same two cards in the same
direction - is kept exactly as it is, and the import reports it as already
there. Lines whose cards cannot be found, or which you may not edit, are
reported as unmatched. The same applies to **All Boards / Import /
Dependencies**.
## Implementation
| Part | Where |
| --- | --- |
| The rule: who may see and edit what, merging | `models/lib/dependencyAccess.js` |
| Server methods: `setDependencyVisibility`, `setBoardDependency`, `removeBoardDependency`, `importBoardDependencies`, `setMyDependency`, `removeMyDependency`, `importMyDependencies`; REST permission | `server/models/dependencies.js` |
| Profile fields `showBoardDependencies`, `showMyDependencies`, `myDependencies` (written only by the methods) | `models/users.js` |
| The two layers on the client, and where a drawn line goes | `client/lib/dependencyLayers.js` |
| Overlay and line popup | `client/components/boards/dependencyOverlay.{jade,js}` |
| Member Settings items and the Import / Export popups | `client/components/users/dependencyMenu.{jade,js}` |
The board field `showDependencies` is no longer read; it stays in the schema so
existing boards and board imports still validate.
+14 -6
View File
@@ -44,16 +44,24 @@ The colored **icon + count** also appears as a badge on the **minicard**.
### Show the connection lines
In the **board header** click the **Show dependencies** toggle (link icon). An
SVG overlay draws each dependency as a colored curve following the cards as you
scroll/resize. By default the overlay is non-interactive, so cards stay
clickable. The toggle (`showDependencies`) is saved on the board.
Open **Member Settings** (your avatar) and turn on **Show Board Dependencies**,
**Show My Dependencies**, or both. They are at the top of the menu and are off
by default. An SVG overlay draws each dependency as a colored curve following
the cards as you scroll/resize; My Dependencies are dashed. The overlay is
non-interactive, so cards stay clickable.
Showing them is **your own choice**, saved in your profile: it never turns the
lines on or off for anybody else, and anyone who can view a board can show its
dependencies. Before #6732 this was one board-wide toggle in the board header
that only board admins could change. See
[Board and My Dependencies](Board-And-My-Dependencies.md).
### Drawing links by dragging
For a piplanning.io / Kendis / Miro-style experience, you can draw links directly
on the board — **no mode to toggle, cards stay clickable**. When the dependency
overlay is on (**Show dependencies**) and you can edit the board, each minicard
on the board — **no mode to toggle, cards stay clickable**. When a dependency
layer is shown that you can add to (see
[Board and My Dependencies](Board-And-My-Dependencies.md)), each minicard
shows a small **connect handle** (a dot on its right edge) when you hover it:
- **Drag the handle from one card onto another card** to create a dependency
+11
View File
@@ -1874,6 +1874,17 @@
"remove-dependency": "Remove dependency",
"show-dependencies": "Show dependencies",
"hide-dependencies": "Hide dependencies",
"show-my-dependencies": "Show My Dependencies",
"show-board-dependencies": "Show Board Dependencies",
"my-dependencies": "My Dependencies",
"board-dependencies": "Board Dependencies",
"importMemberDependenciesPopup-title": "Import dependencies",
"exportMemberDependenciesPopup-title": "Export dependencies",
"dependencies-open-a-board": "Open a board to import or export its dependencies.",
"dependencies-board-edit-required": "Board Dependencies can be imported only by roles that can edit or move cards.",
"import-dependencies-merged": "Added __imported__ line(s); __skipped__ already there, __unmatched__ unmatched",
"dependency-layer-mine": "Only you see this dependency",
"dependency-read-only": "Your role cannot edit Board Dependencies",
"drag-to-connect": "Drag to connect to another card",
"dependency-type": "Relation type",
"dependency-color": "Color",
+5 -6
View File
@@ -569,9 +569,11 @@ Boards.attachSchema(
},
showDependencies: {
/**
* #3392: PI Program Board "Red Strings". When true, draw colored
* connection lines between cards that have cardDependencies, SAFe
* PI-planning program board style.
* #3392: PI Program Board "Red Strings". No longer read since #6732:
* showing dependencies is each user's own choice
* (profile.showBoardDependencies / profile.showMyDependencies), so one
* user turning them on no longer turns them on for everybody. Kept so
* existing boards and imports still validate.
*/
type: Boolean,
defaultValue: false,
@@ -3172,9 +3174,6 @@ Boards.helpers({
return await Boards.updateAsync(this._id, { $set: { cardAging } });
},
async setShowDependencies(showDependencies) {
return await Boards.updateAsync(this._id, { $set: { showDependencies } });
},
async setCardAgingDays(cardAgingDays1, cardAgingDays2, cardAgingDays3) {
return await Boards.updateAsync(this._id, {
+10 -49
View File
@@ -38,7 +38,6 @@ import {
DEFAULT_DEPENDENCY_ICON,
DEFAULT_DEPENDENCY_TYPE,
DEPENDENCY_TYPE_IDS,
normalizeDependency,
normalizeDependencies,
} from '/models/metadata/dependencies';
import {
@@ -1987,9 +1986,11 @@ Cards.helpers({
return normalizeDependencies(this.getRealCard().cardDependencies);
},
// #3392: Add (or update) a typed dependency to another card on the same board.
// Guards against self-links and cross-board targets. When the dependency
// already exists its type/color/icon are updated.
// #3392 / #6732: Board Dependencies are changed through server methods
// (server/models/dependencies.js) under one rule: a role that may edit OR
// move cards - a Worker included - on cards the user can see. Direct card
// writes allowed only full editors, and only by rewriting the whole card.
// Add (or update) a typed dependency to another card on the same board.
addDependency(targetCardId, options = {}) {
const realCard = this.getRealCard();
if (!targetCardId || targetCardId === realCard._id) {
@@ -1999,59 +2000,19 @@ Cards.helpers({
if (!target || target.boardId !== realCard.boardId) {
return undefined;
}
const deps = this.getDependencies();
const existing = deps.find(dep => dep.cardId === targetCardId);
const entry = normalizeDependency({
cardId: targetCardId,
type: options.type || (existing && existing.type),
color: options.color || (existing && existing.color),
icon: options.icon || (existing && existing.icon),
});
// Read-modify-write the whole array and update by _id only: the client
// (untrusted code) may not updateAsync with a selector / positional `$`.
const next = existing
? deps.map(dep => (dep.cardId === targetCardId ? entry : dep))
: [...deps, entry];
return Cards.updateAsync(this.getRealId(), {
$set: { cardDependencies: next },
});
return Meteor.callAsync('setBoardDependency', this.getRealId(), targetCardId, options);
},
// #3392: Update a single property (type/color/icon) of an existing dependency.
// Updates by _id only (positional `$` selector updates are forbidden on the
// client), so the whole array is rewritten.
// Update a single property (type/color/icon) of an existing dependency.
setDependencyProps(targetCardId, props = {}) {
const deps = this.getDependencies();
let changed = false;
const next = deps.map(dep => {
if (dep.cardId !== targetCardId) {
return dep;
}
changed = true;
return normalizeDependency({
cardId: dep.cardId,
type: props.type !== undefined ? props.type : dep.type,
color: props.color !== undefined ? props.color : dep.color,
icon: props.icon !== undefined ? props.icon : dep.icon,
});
});
if (!changed) {
if (!this.getDependencies().some(dep => dep.cardId === targetCardId)) {
return undefined;
}
return Cards.updateAsync(this.getRealId(), {
$set: { cardDependencies: next },
});
return Meteor.callAsync('setBoardDependency', this.getRealId(), targetCardId, props);
},
removeDependency(targetCardId) {
// Rewrite the array and update by _id only (no selector / positional `$`
// updates from untrusted client code).
const next = this.getDependencies().filter(
dep => dep.cardId !== targetCardId,
);
return Cards.updateAsync(this.getRealId(), {
$set: { cardDependencies: next },
});
return Meteor.callAsync('removeBoardDependency', this.getRealId(), targetCardId);
},
getReceived() {
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Card dependencies ("Red Strings", #3392), #6732: who may see, edit and
// import them. Pure, so tests/dependencyAccess.test.cjs runs it without Meteor.
// The design is docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md.
//
// - Showing or hiding dependencies is each user's OWN preference, in their
// profile, off by default. It never changes what another user sees, and
// anyone who can view a board may show its dependencies.
// - Board Dependencies are saved on the board's cards. A member whose role may
// edit OR move cards may edit them - and import them. An assigned-only member
// only on cards assigned to them, the cards they can see.
// - My Dependencies are saved in the user's profile; every user edits their
// own, between cards they can see.
// - An import only ADDS lines that are not there yet; existing lines are kept
// as they are.
const { memberCan } = require('./boardRoleCapabilities');
const { isAssignedOnlyMember } = require('./boardCardScope');
const MY_DEPENDENCIES_MAX = 5000;
const text = value => typeof value === 'string' && value.length > 0 && value.length <= 128;
// Anyone who can view the board: an active member, or anyone on a public board.
function canViewBoard(board, userId) {
if (!board || board.archived === true) return false;
if (board.permission === 'public') return true;
return text(userId) && Array.isArray(board.members) &&
board.members.some(member => member && member.userId === userId && member.isActive === true);
}
function canEditBoardDependencies(board, userId) {
if (!board || !text(userId)) return false;
return memberCan(board.members, userId, 'write') || memberCan(board.members, userId, 'moveCard');
}
// May this user see this card at all (assigned-only members see their own)?
function canSeeCard(board, userId, card) {
if (!canViewBoard(board, userId) || !card || card.boardId !== board._id || card.archived === true) return false;
if (isAssignedOnlyMember(board, userId)) return Array.isArray(card.assignees) && card.assignees.includes(userId);
return true;
}
// A Board Dependency is stored on its source card; both ends must be visible.
function canEditCardDependency(board, userId, sourceCard, targetCard) {
return canEditBoardDependencies(board, userId) && canSeeCard(board, userId, sourceCard) &&
(targetCard === undefined || canSeeCard(board, userId, targetCard));
}
// The one key of a dependency line, whichever layer it is in.
const lineKey = (boardId, fromId, toId) => `${boardId}\u0000${fromId}\u0000${toId}`;
// My Dependencies: [{ boardId, cardId, targetCardId, type, color, icon }].
function normalizeMyDependencies(list, normalizeDependency) {
const seen = new Set(), result = [];
for (const row of Array.isArray(list) ? list : []) {
if (!row || !text(row.boardId) || !text(row.cardId) || !text(row.targetCardId) || row.cardId === row.targetCardId) continue;
const key = lineKey(row.boardId, row.cardId, row.targetCardId);
if (seen.has(key)) continue;
seen.add(key);
const dep = normalizeDependency({ cardId: row.targetCardId, type: row.type, color: row.color, icon: row.icon });
result.push({ boardId: row.boardId, cardId: row.cardId, targetCardId: row.targetCardId,
type: dep.type, color: dep.color, icon: dep.icon });
}
return result;
}
// Combine: keep every existing line, add only the incoming ones not yet there.
function mergeDependencyLines(existing, incoming, keyOf) {
const keys = new Set(existing.map(keyOf));
const added = [];
let skipped = 0;
for (const line of incoming) {
const key = keyOf(line);
if (keys.has(key)) { skipped++; continue; }
keys.add(key);
added.push(line);
}
return { added, skipped };
}
module.exports = { MY_DEPENDENCIES_MAX, canViewBoard, canEditBoardDependencies, canSeeCard, canEditCardDependency,
lineKey, normalizeMyDependencies, mergeDependencyLines };
+31
View File
@@ -924,6 +924,35 @@ Users.attachSchema(
defaultValue: {},
blackbox: true,
},
'profile.showBoardDependencies': {
/**
* #6732: show the board's card dependencies (Red Strings) to THIS user.
* Each user's own choice, off by default; set from Member Settings.
*/
type: Boolean,
optional: true,
},
'profile.showMyDependencies': {
/**
* #6732: show this user's own My Dependencies. Off by default.
*/
type: Boolean,
optional: true,
},
'profile.myDependencies': {
/**
* #6732: this user's own dependency lines, seen only by them:
* [{ boardId, cardId, targetCardId, type, color, icon }]. Written only by
* the server methods in server/models/dependencies.js, which check the
* user can see both cards; never directly by the client.
*/
type: Array,
optional: true,
},
'profile.myDependencies.$': {
type: Object,
blackbox: true,
},
'profile.listConstraints': {
/**
* User-specified constraint of each list (or nothing if default).
@@ -1225,6 +1254,8 @@ export const USER_UPDATE_FORBIDDEN_PREFIXES = [
'notificationDeliveryRevision',
'notificationDeliveryPending',
'profile.invitedBoards',
// #6732: written only by the My Dependencies methods, which check card access.
'profile.myDependencies',
'services',
'emails',
'roles',
@@ -399,6 +399,50 @@
{
"key": "import-board-instruction-todotxt",
"since": "2026-09-29"
},
{
"key": "show-my-dependencies",
"since": "2026-10-01"
},
{
"key": "show-board-dependencies",
"since": "2026-10-01"
},
{
"key": "my-dependencies",
"since": "2026-10-01"
},
{
"key": "board-dependencies",
"since": "2026-10-01"
},
{
"key": "importMemberDependenciesPopup-title",
"since": "2026-10-01"
},
{
"key": "exportMemberDependenciesPopup-title",
"since": "2026-10-01"
},
{
"key": "dependencies-open-a-board",
"since": "2026-10-01"
},
{
"key": "dependencies-board-edit-required",
"since": "2026-10-01"
},
{
"key": "import-dependencies-merged",
"since": "2026-10-01"
},
{
"key": "dependency-layer-mine",
"since": "2026-10-01"
},
{
"key": "dependency-read-only",
"since": "2026-10-01"
}
]
}
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { Meteor } from 'meteor/meteor';
import { DDP } from 'meteor/ddp';
import { Random } from 'meteor/random';
import Boards from '/models/boards';
import Cards from '/models/cards';
import { hasForbiddenUserUpdateField } from '/models/users';
// #6732: showing dependencies is each user's own choice; Board Dependencies
// are edited by roles that may edit or move cards; My Dependencies live in the
// user's profile; imports only add what is missing.
describe('Board and My Dependencies (#6732)', function () {
this.timeout(30000);
it('applies visibility per user, the edit rule, and merge-only imports', async function () {
if (!Meteor.isAppTest) this.skip();
const ids = { admin: Random.id(), worker: Random.id(), reader: Random.id(), assigned: Random.id(), stranger: Random.id() };
const boardId = Random.id(), [c1, c2, c3] = [Random.id(), Random.id(), Random.id()];
const call = (who, method, ...args) => {
const context = { userId: ids[who], isSimulation: false, connection: null, setUserId() {}, unblock() {} };
return DDP._CurrentMethodInvocation.withValue(context, () => Meteor.server.method_handlers[method].apply(context, args));
};
const profile = async who => ((await Meteor.users.findOneAsync(ids[who])) || {}).profile || {};
const deps = async id => ((await Cards.findOneAsync(id)) || {}).cardDependencies || [];
try {
await Meteor.users.rawCollection().insertMany(Object.entries(ids).map(([name, _id]) => ({ _id, username: `${name}-${_id}`, profile: {} })));
await Boards.rawCollection().insertOne({ _id: boardId, title: 'Deps', permission: 'private', archived: false, members: [
{ userId: ids.admin, isAdmin: true, isActive: true },
{ userId: ids.worker, isWorker: true, isActive: true },
{ userId: ids.reader, isReadOnly: true, isActive: true },
{ userId: ids.assigned, isNormalAssignedOnly: true, isActive: true },
] });
await Cards.rawCollection().insertMany([c1, c2, c3].map((_id, i) => ({ _id, boardId, listId: 'l', swimlaneId: 's',
title: `Card ${i + 1}`, cardNumber: i + 1, archived: false, sort: i, assignees: i === 0 ? [ids.assigned] : [] })));
// Visibility: one user's switch is theirs alone; a read-only member may use it.
await call('reader', 'setDependencyVisibility', 'board', true);
assert.equal((await profile('reader')).showBoardDependencies, true);
assert.equal((await profile('admin')).showBoardDependencies, undefined, 'nobody else is affected');
await call('admin', 'setDependencyVisibility', 'mine', true);
assert.deepEqual([(await profile('admin')).showMyDependencies, (await profile('reader')).showMyDependencies], [true, undefined]);
// Board Dependencies: a Worker (moves, cannot edit) may; read-only and strangers may not.
await call('worker', 'setBoardDependency', c1, c2, { type: 'blocks' });
assert.deepEqual((await deps(c1)).map(d => [d.cardId, d.type]), [[c2, 'blocks']]);
await assert.rejects(call('reader', 'setBoardDependency', c2, c3, {}), /not-authorized/);
await assert.rejects(call('stranger', 'setBoardDependency', c2, c3, {}), /not-authorized/);
await assert.rejects(call('reader', 'removeBoardDependency', c1, c2), /not-authorized/);
// Assigned-only: both cards must be theirs to see.
await assert.rejects(call('assigned', 'setBoardDependency', c1, c3, {}), /not-authorized/);
await Cards.rawCollection().updateOne({ _id: c3 }, { $set: { assignees: [ids.assigned] } });
await call('assigned', 'setBoardDependency', c1, c3, { type: 'fixes' });
assert.equal((await deps(c1)).length, 2);
await call('assigned', 'removeBoardDependency', c1, c3);
assert.equal((await deps(c1)).length, 1);
// Import combines: the existing line stays as it is, a missing one is added.
const res = await call('admin', 'importBoardDependencies', boardId, [
{ from: c1, to: c2, type: 'fixes' },
{ fromCardNumber: 2, toCardNumber: 3, type: 'related-to' },
{ fromTitle: 'No such card', toTitle: 'Card 1' },
]);
assert.deepEqual(res, { imported: 1, skipped: 1, unmatched: 1 });
assert.equal((await deps(c1))[0].type, 'blocks', 'never overwritten');
assert.deepEqual((await deps(c2)).map(d => d.cardId), [c3]);
await assert.rejects(call('reader', 'importBoardDependencies', boardId, [{ from: c3, to: c1 }]), /not-authorized/,
'only roles that may edit or move cards import Board Dependencies');
// My Dependencies: in the profile, theirs alone; a read-only member may keep them.
await call('reader', 'setMyDependency', c3, c1, { type: 'blocks' });
const mine = (await profile('reader')).myDependencies;
assert.deepEqual(mine.map(row => [row.boardId, row.cardId, row.targetCardId, row.type]), [[boardId, c3, c1, 'blocks']]);
assert.equal((await deps(c3)).length, 0, 'nothing written on the board');
const merged = await call('reader', 'importMyDependencies', boardId, [{ from: c3, to: c1, type: 'fixes' }, { from: c2, to: c1 }]);
assert.deepEqual(merged, { imported: 1, skipped: 1, unmatched: 0 });
assert.equal((await profile('reader')).myDependencies.find(row => row.cardId === c3).type, 'blocks');
await call('reader', 'removeMyDependency', boardId, c2, c1);
assert.equal((await profile('reader')).myDependencies.length, 1);
// A private board's cards are not a stranger's to link (negative); a public board's are.
await assert.rejects(call('stranger', 'setMyDependency', c1, c2, {}), /not-authorized/);
await Boards.rawCollection().updateOne({ _id: boardId }, { $set: { permission: 'public' } });
await call('stranger', 'setMyDependency', c1, c2, {});
assert.equal((await profile('stranger')).myDependencies.length, 1);
// The client cannot write My Dependencies directly (only through the checks above).
assert.equal(hasForbiddenUserUpdateField(['profile.myDependencies'], { $set: { 'profile.myDependencies': [] } }), true);
assert.equal(hasForbiddenUserUpdateField(['profile'], { $set: { profile: { myDependencies: [] } } }), true);
assert.equal(hasForbiddenUserUpdateField(['profile.showMyDependencies'], { $set: { 'profile.showMyDependencies': true } }), false);
} finally {
await Cards.rawCollection().deleteMany({ boardId });
await Boards.rawCollection().deleteMany({ _id: boardId });
await Meteor.users.rawCollection().deleteMany({ _id: { $in: Object.values(ids) } });
}
});
});
+1
View File
@@ -110,3 +110,4 @@ import './inboundEmailRoute.tests';
import './activityIncarnation.tests';
import './listSyncDurable.tests';
import './storedRuleChecklist.tests';
import './dependencyLayers.tests';
+208 -63
View File
@@ -1,5 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { check } from 'meteor/check';
import { check, Match } from 'meteor/check';
import { WebApp } from 'meteor/webapp';
import { Authentication } from '/server/authentication';
import { sendJsonResult } from '/server/apiMiddleware';
@@ -10,6 +10,8 @@ import {
normalizeDependency,
normalizeDependencies,
} from '/models/metadata/dependencies';
const { MY_DEPENDENCIES_MAX, canViewBoard, canEditBoardDependencies, canSeeCard, canEditCardDependency,
lineKey, normalizeMyDependencies, mergeDependencyLines } = require('/models/lib/dependencyAccess');
// #3392: REST API for card-to-card dependencies ("Red Strings").
//
@@ -42,78 +44,221 @@ async function dependencyLine(sourceCard, dep) {
};
}
// #3392: import dependency lines into an existing board. Lines come from a
// WeKan dependencies JSON/SVG export, or are mapped from another tracker
// (Jira issue links, GitHub "blocked by", etc.). Cards are matched in the
// target board by _id first, then cardNumber, then exact title — so a file
// exported from one board can be re-applied to a copy/import of it.
// #6732: dependencies are edited through these methods only, under one rule
// (models/lib/dependencyAccess.js; design in
// docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md):
// - Board Dependencies, on the board's cards: a role that may edit OR move
// cards; assigned-only members on the cards assigned to them.
// - My Dependencies, in the user's profile: every user, their own, between
// cards they can see.
// - Showing either is the user's own preference, off by default.
// Cards are matched in imports by _id first, then cardNumber, then exact title
// - so a file exported from one board can be re-applied to a copy of it - and
// an import only ADDS lines that are not there yet.
function forbidden() {
const error = new Meteor.Error('not-authorized', 'Not authorized');
error.statusCode = 403;
return error;
}
async function isSiteAdmin(userId) {
return !!(userId && await ReactiveCache.getUser({ _id: userId, isAdmin: true }));
}
// A linked card's dependencies live on the card it points at.
async function realCard(cardId) {
const card = await ReactiveCache.getCard(cardId);
if (card && card.type === 'cardType-linkedCard' && card.linkedId) return ReactiveCache.getCard(card.linkedId);
return card;
}
async function boardDependencyContext(userId, sourceCardId, targetCardId) {
if (!userId) throw forbidden();
const source = await realCard(sourceCardId);
const target = targetCardId === undefined ? undefined : await ReactiveCache.getCard(targetCardId);
const board = source && await ReactiveCache.getBoard(source.boardId);
if (!board || (target !== undefined && (!target || target.boardId !== source.boardId || target._id === source._id))) {
throw new Meteor.Error('dependency-invalid', 'Both cards must be different cards of the same board.');
}
if (!canEditCardDependency(board, userId, source, target) && !await isSiteAdmin(userId)) throw forbidden();
return { board, source, target };
}
async function writeBoardDependencies(cardId, next) {
await Cards.updateAsync(cardId, { $set: { cardDependencies: next } });
}
function resolverFor(cards) {
const byId = {}, byNumber = {}, byTitle = {};
cards.forEach(card => {
byId[card._id] = card;
if (card.cardNumber !== undefined && card.cardNumber !== null) byNumber[String(card.cardNumber)] = card;
if (card.title) byTitle[card.title] = card;
});
return (id, number, title) => (id && byId[id]) ||
(number !== undefined && number !== null && byNumber[String(number)]) || (title && byTitle[title]) || null;
}
async function viewableBoard(userId, boardId) {
const board = await ReactiveCache.getBoard(boardId);
if (!board) throw new Meteor.Error('board-not-found', 'Board not found');
if (!canViewBoard(board, userId) && !await isSiteAdmin(userId)) throw forbidden();
return board;
}
async function myDependencies(userId) {
const user = await Meteor.users.findOneAsync(userId, { fields: { 'profile.myDependencies': 1 } });
return normalizeMyDependencies(user && user.profile && user.profile.myDependencies, normalizeDependency);
}
async function saveMyDependencies(userId, list) {
if (list.length > MY_DEPENDENCIES_MAX) {
throw new Meteor.Error('dependency-limit', `At most ${MY_DEPENDENCIES_MAX} of your own dependencies can be saved.`);
}
await Meteor.users.updateAsync(userId, { $set: { 'profile.myDependencies': list } });
}
const myKey = row => lineKey(row.boardId, row.cardId, row.targetCardId);
const LAYER_FIELDS = { board: 'profile.showBoardDependencies', mine: 'profile.showMyDependencies' };
Meteor.methods({
// Show or hide a layer for THIS user only.
async setDependencyVisibility(layer, show) {
check(layer, Match.OneOf('board', 'mine'));
check(show, Boolean);
if (!this.userId) throw forbidden();
await Meteor.users.updateAsync(this.userId, { $set: { [LAYER_FIELDS[layer]]: show } });
return show;
},
// Add a Board Dependency, or change the given properties of an existing one.
async setBoardDependency(sourceCardId, targetCardId, props = {}) {
check(sourceCardId, String);
check(targetCardId, String);
check(props, Match.ObjectIncluding({}));
const { source } = await boardDependencyContext(this.userId, sourceCardId, targetCardId);
const deps = normalizeDependencies(source.cardDependencies);
const existing = deps.find(dep => dep.cardId === targetCardId);
const pick = key => (typeof props[key] === 'string' && props[key] ? props[key] : existing && existing[key]);
const entry = normalizeDependency({ cardId: targetCardId, type: pick('type'), color: pick('color'), icon: pick('icon') });
await writeBoardDependencies(source._id, existing
? deps.map(dep => (dep.cardId === targetCardId ? entry : dep)) : [...deps, entry]);
return entry;
},
async removeBoardDependency(sourceCardId, targetCardId) {
check(sourceCardId, String);
check(targetCardId, String);
const { source } = await boardDependencyContext(this.userId, sourceCardId);
await writeBoardDependencies(source._id,
normalizeDependencies(source.cardDependencies).filter(dep => dep.cardId !== targetCardId));
return true;
},
// Import into the board: only roles that may edit or move cards; only lines
// whose cards this user may edit; only lines not already on the board.
async importBoardDependencies(boardId, lines) {
check(boardId, String);
check(lines, [Object]);
await Authentication.checkBoardWriteAccess(this.userId, boardId);
if (!this.userId) throw forbidden();
const board = await ReactiveCache.getBoard(boardId);
if (!board) throw new Meteor.Error('board-not-found', 'Board not found');
const admin = await isSiteAdmin(this.userId);
if (!canEditBoardDependencies(board, this.userId) && !admin) throw forbidden();
const cards = await ReactiveCache.getCards({ boardId, archived: false });
const byId = {};
const byNumber = {};
const byTitle = {};
cards.forEach(card => {
byId[card._id] = card._id;
if (card.cardNumber !== undefined && card.cardNumber !== null) {
byNumber[String(card.cardNumber)] = card._id;
}
if (card.title) byTitle[card.title] = card._id;
});
const resolve = (id, number, title) => {
if (id && byId[id]) return byId[id];
if (number !== undefined && number !== null && byNumber[String(number)]) {
return byNumber[String(number)];
}
if (title && byTitle[title]) return byTitle[title];
return null;
};
let imported = 0;
let unmatched = 0;
const resolve = resolverFor(cards);
const additions = new Map();
let unmatched = 0, skipped = 0;
for (const line of lines) {
const fromId = resolve(line.from, line.fromCardNumber, line.fromTitle);
const toId = resolve(line.to, line.toCardNumber, line.toTitle);
if (!fromId || !toId || fromId === toId) {
const from = resolve(line.from, line.fromCardNumber, line.fromTitle);
const to = resolve(line.to, line.toCardNumber, line.toTitle);
if (!from || !to || from._id === to._id || (!admin && !canEditCardDependency(board, this.userId, from, to))) {
unmatched += 1;
continue;
}
const entry = normalizeDependency({
cardId: toId,
type: line.type,
color: line.color,
icon: line.icon,
});
const source = await ReactiveCache.getCard(fromId);
const existing = normalizeDependencies(source && source.cardDependencies);
if (existing.find(d => d.cardId === toId)) {
// Update the existing line's properties.
await Cards.updateAsync(
{ _id: fromId, 'cardDependencies.cardId': toId },
{
$set: {
'cardDependencies.$.type': entry.type,
'cardDependencies.$.color': entry.color,
'cardDependencies.$.icon': entry.icon,
},
},
);
} else {
await Cards.updateAsync(fromId, {
$push: { cardDependencies: entry },
});
}
imported += 1;
const present = normalizeDependencies(from.cardDependencies).some(dep => dep.cardId === to._id) ||
(additions.get(from._id) || []).some(dep => dep.cardId === to._id);
if (present) { skipped += 1; continue; }
additions.set(from._id, [...(additions.get(from._id) || []),
normalizeDependency({ cardId: to._id, type: line.type, color: line.color, icon: line.icon })]);
}
return { imported, unmatched };
let imported = 0;
for (const [cardId, added] of additions) {
const card = await ReactiveCache.getCard(cardId);
const existing = normalizeDependencies(card && card.cardDependencies);
const fresh = added.filter(dep => !existing.some(old => old.cardId === dep.cardId));
skipped += added.length - fresh.length;
if (!fresh.length) continue;
await writeBoardDependencies(cardId, [...existing, ...fresh]);
imported += fresh.length;
}
return { imported, skipped, unmatched };
},
// My Dependencies: this user's own lines, between cards they can see.
async setMyDependency(sourceCardId, targetCardId, props = {}) {
check(sourceCardId, String);
check(targetCardId, String);
check(props, Match.ObjectIncluding({}));
if (!this.userId) throw forbidden();
const [source, target] = await Promise.all([ReactiveCache.getCard(sourceCardId), ReactiveCache.getCard(targetCardId)]);
const board = source && await viewableBoard(this.userId, source.boardId);
if (!board || !target || target.boardId !== source.boardId || source._id === target._id ||
!canSeeCard(board, this.userId, source) || !canSeeCard(board, this.userId, target)) throw forbidden();
const list = await myDependencies(this.userId);
const at = list.findIndex(row => myKey(row) === lineKey(board._id, source._id, target._id));
const old = at >= 0 ? list[at] : null;
const pick = key => (typeof props[key] === 'string' && props[key] ? props[key] : old && old[key]);
const [row] = normalizeMyDependencies([{ boardId: board._id, cardId: source._id, targetCardId: target._id,
type: pick('type'), color: pick('color'), icon: pick('icon') }], normalizeDependency);
if (at >= 0) list[at] = row; else list.push(row);
await saveMyDependencies(this.userId, list);
return row;
},
async removeMyDependency(boardId, sourceCardId, targetCardId) {
check(boardId, String);
check(sourceCardId, String);
check(targetCardId, String);
if (!this.userId) throw forbidden();
const key = lineKey(boardId, sourceCardId, targetCardId);
const list = await myDependencies(this.userId);
await saveMyDependencies(this.userId, list.filter(row => myKey(row) !== key));
return true;
},
async importMyDependencies(boardId, lines) {
check(boardId, String);
check(lines, [Object]);
if (!this.userId) throw forbidden();
const board = await viewableBoard(this.userId, boardId);
const cards = (await ReactiveCache.getCards({ boardId, archived: false }))
.filter(card => canSeeCard(board, this.userId, card));
const resolve = resolverFor(cards);
const incoming = [];
let unmatched = 0;
for (const line of lines) {
const from = resolve(line.from, line.fromCardNumber, line.fromTitle);
const to = resolve(line.to, line.toCardNumber, line.toTitle);
if (!from || !to || from._id === to._id) { unmatched += 1; continue; }
incoming.push({ boardId, cardId: from._id, targetCardId: to._id, type: line.type, color: line.color, icon: line.icon });
}
const list = await myDependencies(this.userId);
// Normalizing drops duplicates within the file itself; those count as skipped too.
const normalized = normalizeMyDependencies(incoming, normalizeDependency);
const { added, skipped } = mergeDependencyLines(list, normalized, myKey);
if (added.length) await saveMyDependencies(this.userId, [...list, ...added]);
return { imported: added.length, skipped: skipped + incoming.length - normalized.length, unmatched };
},
});
// The REST write routes follow the same rule as the methods (#6732): a role
// that may edit or move cards, on cards the caller can see.
async function checkRestDependencyAccess(userId, boardId, cardId, targetId) {
Authentication.checkLoggedIn(userId);
const board = await ReactiveCache.getBoard(boardId);
Authentication.checkBoardExists(board);
// Board-level first, so a caller without the right cannot probe which cards exist.
await Authentication.checkAdminOrCondition(userId, canEditBoardDependencies(board, userId));
const card = await ReactiveCache.getCard({ _id: cardId, boardId });
const target = typeof targetId === 'string' && targetId ? await ReactiveCache.getCard({ _id: targetId, boardId }) : undefined;
// A missing card is reported by the route itself as 404 / 400.
if (!card || target === null) return;
await Authentication.checkAdminOrCondition(userId,
canEditCardDependency(board, userId, card, target || undefined));
}
if (Meteor.isServer) {
/**
* @operation get_board_dependencies
@@ -192,7 +337,7 @@ if (Meteor.isServer) {
try {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
await checkRestDependencyAccess(req.userId, paramBoardId, paramCardId, req.body && (req.body.cardId || req.body.dependsOn));
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
@@ -262,7 +407,7 @@ if (Meteor.isServer) {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
const paramTargetId = req.params.targetId;
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
await checkRestDependencyAccess(req.userId, paramBoardId, paramCardId);
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
@@ -304,7 +449,7 @@ if (Meteor.isServer) {
try {
const paramBoardId = req.params.boardId;
const paramCardId = req.params.cardId;
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId);
await checkRestDependencyAccess(req.userId, paramBoardId, paramCardId);
const card = await ReactiveCache.getCard({
_id: paramCardId,
boardId: paramBoardId,
+134
View File
@@ -0,0 +1,134 @@
'use strict';
// #6732: dependencies - who may see and edit which layer, and how imports
// combine (models/lib/dependencyAccess.js), plus guards that the app uses it.
// Run: node tests/dependencyAccess.test.cjs
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const A = require('../models/lib/dependencyAccess');
const { normalizeDependency } = (() => {
// models/metadata/dependencies.js is an ES module; load its pure helpers.
const src = fs.readFileSync(path.join(__dirname, '../models/metadata/dependencies.js'), 'utf8')
.replace(/^export /gm, '');
return new Function(`${src}; return { normalizeDependency };`)();
})();
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const member = (userId, role = {}) => ({ userId, isActive: true, ...role });
const board = (members, extra = {}) => ({ _id: 'b', permission: 'private', members, ...extra });
const card = (id, extra = {}) => ({ _id: id, boardId: 'b', archived: false, assignees: [], ...extra });
test('roles that may edit OR move cards may edit Board Dependencies', () => {
const roles = [
[{ isAdmin: true }, true], [{}, true], [{ isWorker: true }, true], [{ isNormalAssignedOnly: true }, true],
[{ isCommentOnly: true }, false], [{ isCommentAssignedOnly: true }, false], [{ isNoComments: true }, true],
[{ isReadOnly: true }, false], [{ isReadAssignedOnly: true }, false],
];
for (const [role, allowed] of roles) {
assert.equal(A.canEditBoardDependencies(board([member('u', role)]), 'u'), allowed, JSON.stringify(role));
}
assert.equal(A.canEditBoardDependencies(board([member('u', { isActive: false })]), 'u'), false, 'an inactive member (negative)');
assert.equal(A.canEditBoardDependencies(board([]), 'u'), false, 'a non-member (negative)');
assert.equal(A.canEditBoardDependencies(board([], { permission: 'public' }), 'u'), false,
'viewing a public board is not editing it (negative)');
});
test('anyone who can view a board may see its dependencies; assigned-only members see their cards', () => {
assert.equal(A.canViewBoard(board([], { permission: 'public' }), 'stranger'), true, 'the reported public-board case');
assert.equal(A.canViewBoard(board([member('u', { isReadOnly: true })]), 'u'), true);
assert.equal(A.canViewBoard(board([]), 'stranger'), false, 'a private board (negative)');
assert.equal(A.canViewBoard(board([], { permission: 'public', archived: true }), 'u'), false);
const assigned = board([member('u', { isNormalAssignedOnly: true })]);
assert.equal(A.canSeeCard(assigned, 'u', card('mine', { assignees: ['u'] })), true);
assert.equal(A.canSeeCard(assigned, 'u', card('other')), false, 'not assigned (negative)');
assert.equal(A.canEditCardDependency(assigned, 'u', card('mine', { assignees: ['u'] }), card('other')), false,
'both ends must be cards they can see (negative)');
assert.equal(A.canEditCardDependency(assigned, 'u', card('mine', { assignees: ['u'] }), card('too', { assignees: ['u'] })), true);
assert.equal(A.canSeeCard(board([member('u')]), 'u', card('x', { boardId: 'other' })), false, 'another board (negative)');
assert.equal(A.canSeeCard(board([member('u')]), 'u', card('x', { archived: true })), false, 'archived (negative)');
});
test('My Dependencies are normalized: valid, unique, never a card to itself', () => {
const list = A.normalizeMyDependencies([
{ boardId: 'b', cardId: 'a', targetCardId: 'c', type: 'blocks' },
{ boardId: 'b', cardId: 'a', targetCardId: 'c', type: 'fixes' },
{ boardId: 'b', cardId: 'a', targetCardId: 'a' },
{ boardId: 'b', cardId: '', targetCardId: 'c' },
null, 'junk',
{ boardId: 'b', cardId: 'c', targetCardId: 'a', extra: 'dropped' },
], normalizeDependency);
assert.deepEqual(list.map(row => [row.cardId, row.targetCardId, row.type]), [['a', 'c', 'blocks'], ['c', 'a', 'related-to']]);
assert.ok(list.every(row => Object.keys(row).sort().join(',') === 'boardId,cardId,color,icon,targetCardId,type'));
assert.equal(A.MY_DEPENDENCIES_MAX, 5000);
});
test('an import combines: it adds only what is missing and keeps what is there', () => {
const key = row => A.lineKey(row.boardId, row.cardId, row.targetCardId);
const existing = [{ boardId: 'b', cardId: 'a', targetCardId: 'c', type: 'blocks' }];
const incoming = [
{ boardId: 'b', cardId: 'a', targetCardId: 'c', type: 'fixes' },
{ boardId: 'b', cardId: 'c', targetCardId: 'a', type: 'fixes' },
{ boardId: 'b', cardId: 'c', targetCardId: 'a', type: 'blocks' },
];
const { added, skipped } = A.mergeDependencyLines(existing, incoming, key);
assert.deepEqual(added.map(row => [row.cardId, row.targetCardId, row.type]), [['c', 'a', 'fixes']]);
assert.equal(skipped, 2, 'the line already there, and a repeat within the file');
assert.equal(existing[0].type, 'blocks', 'an existing line is never overwritten (negative)');
});
test('the app reads the rule, and the board-wide toggle is gone', () => {
const header = read('client/components/boards/boardHeader.jade');
assert.doesNotMatch(header, /js-toggle-dependencies/);
assert.doesNotMatch(read('client/components/boards/boardHeader.js'), /setShowDependencies/);
assert.doesNotMatch(read('models/boards.js'), /setShowDependencies/);
assert.match(read('client/components/boards/boardBody.jade'), /if showAnyDependencies\s+\+dependencyOverlay/);
for (const file of ['client', 'models', 'server']) {
// Nothing reads the old shared board flag to decide what anyone sees.
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => entry.isDirectory()
? (['tests', 'node_modules'].includes(entry.name) ? [] : walk(path.join(dir, entry.name))) : [path.join(dir, entry.name)]);
for (const f of walk(path.join(root, file)).filter(f => /\.(js|jade)$/.test(f))) {
const src = fs.readFileSync(f, 'utf8');
assert.doesNotMatch(src, /board\.showDependencies|currentBoard\.showDependencies/, path.relative(root, f));
}
}
// Member Settings: My, Board, Import, Export - at the very top.
const menu = read('client/components/users/dependencyMenu.jade');
const order = ['js-toggle-my-dependencies', 'js-toggle-board-dependencies', 'js-import-member-dependencies', 'js-export-member-dependencies']
.map(cls => menu.indexOf(cls));
assert.ok(order.every((at, i) => at > 0 && (i === 0 || at > order[i - 1])), 'in that order');
const userHeader = read('client/components/users/userHeader.jade');
const popup = userHeader.slice(userHeader.indexOf('template(name="memberMenuPopup")'));
assert.ok(popup.indexOf('+memberDependencyMenuItems') < popup.indexOf('js-my-cards'), 'above everything else');
});
test('every write goes through the rule, and the client cannot write My Dependencies (negative)', () => {
const server = read('server/models/dependencies.js');
for (const method of ['setDependencyVisibility', 'setBoardDependency', 'removeBoardDependency', 'importBoardDependencies',
'setMyDependency', 'removeMyDependency', 'importMyDependencies']) {
assert.match(server, new RegExp(`async ${method}\\(`), method);
}
assert.doesNotMatch(server, /checkBoardWriteAccess/, 'the REST routes use the dependency rule');
assert.equal((server.match(/await checkRestDependencyAccess\(/g) || []).length, 3, 'POST, PUT and DELETE');
const importer = server.slice(server.indexOf('async importBoardDependencies'), server.indexOf('async setMyDependency'));
assert.match(importer, /if \(present\) \{ skipped \+= 1; continue; \}/);
assert.doesNotMatch(importer, /'cardDependencies\.\$\./,
'an import never overwrites an existing line');
const cards = read('models/cards.js');
const helpers = cards.slice(cards.indexOf(' addDependency(targetCardId'), cards.indexOf(' getReceived() {'));
assert.match(helpers, /Meteor\.callAsync\('setBoardDependency'/);
assert.match(helpers, /Meteor\.callAsync\('removeBoardDependency'/);
assert.doesNotMatch(helpers, /Cards\.updateAsync/, 'no direct card write left for dependencies');
const users = read('models/users.js');
const forbidden = users.slice(users.indexOf('USER_UPDATE_FORBIDDEN_PREFIXES'), users.indexOf('];', users.indexOf('USER_UPDATE_FORBIDDEN_PREFIXES')));
assert.match(forbidden, /'profile\.myDependencies'/);
for (const field of ['profile.showBoardDependencies', 'profile.showMyDependencies', 'profile.myDependencies']) {
assert.ok(users.includes(`'${field}': {`), field);
}
// The docs describe the design.
const doc = read('docs/Features/Editor/RedStrings/Board-And-My-Dependencies.md');
for (const phrase of ['off by default', 'edit cards or move cards', 'An import combines; it never replaces']) {
assert.ok(doc.includes(phrase), phrase);
}
});
+3 -2
View File
@@ -119,9 +119,10 @@ test('a caret leads the board controls, before the lock', () => {
'and it is the same caret, so it mirrors in RTL by itself');
});
test('folding hides the seven controls it was asked to hide', () => {
// #6732: Show dependencies left this bar for Member Settings, so six remain.
test('folding hides the six controls it was asked to hide', () => {
const folded = ['js-change-visibility', 'js-watch-board', 'js-sort-cards',
'js-open-filter-view', 'js-open-search-view', 'js-toggle-dependencies',
'js-open-filter-view', 'js-open-search-view',
'js-multiselection-activate'];
for (const control of folded) {
const at = controls.indexOf(control);
+4 -2
View File
@@ -814,7 +814,6 @@ test('and a view menu says its view in words, not only in a tooltip', () => {
['js-sort-cards', ['sort-is-on', 'sort-cards']],
['js-open-filter-view', ['filter-on-desc', 'filter']],
['js-open-search-view', ['search']],
['js-toggle-dependencies', ['hide-dependencies', 'show-dependencies']],
]) {
const btnAt = controls.indexOf(cls);
assert.notStrictEqual(btnAt, -1, `${cls} must be in this bar`);
@@ -952,8 +951,11 @@ test('and a view menu says its view in words, not only in a tooltip', () => {
assert.ok(/a\.header-home-link\(/.test(jade), 'which carries that class');
// The name is still reachable: every one of these buttons has a tooltip.
// #6732: Show dependencies moved to Member Settings, as each user's own
// Show My / Show Board Dependencies - it is no longer in this bar.
assert.ok(!controls.includes('js-toggle-dependencies'), 'the board-wide dependencies toggle is gone');
for (const cls of ['js-sort-cards', 'js-open-filter-view', 'js-open-search-view',
'js-toggle-dependencies', 'js-multiselection-activate']) {
'js-multiselection-activate']) {
const btnAt = controls.indexOf(cls);
assert.ok(/title="/.test(controls.slice(btnAt - 120, btnAt + 300)),
`${cls} names itself in a tooltip as well as beside its icon`);
+2
View File
@@ -9,6 +9,8 @@ const delegated = new Map([
['editCardSpentTimePopup/js-submit-time', 'form submission'],
['addListPopup/js-submit-add-list', 'form submission'],
['importDependenciesPopup/js-import-dependencies-submit', 'form submission'],
// #6732: Member Settings / Import - the submit of form.js-import-member-dependencies-form.
['importMemberDependenciesPopup/js-import-member-dependencies-submit', 'form submission'],
// 58d166029: the per-board due-reminder Save is a type=submit button inside
// form.js-due-reminder-form; 'submit .js-due-reminder-form' handles it.
['notificationSettingsPopup/js-due-reminder-save', 'form submission'],
+1 -1
View File
@@ -61,7 +61,7 @@ test('the header buttons are written ONCE, for both screens', () => {
// tests/headerBars.test.cjs checks it there.
for (const control of ['js-change-visibility', 'js-watch-board',
'js-sort-cards', 'js-open-filter-view', 'js-open-search-view',
'js-toggle-dependencies', 'js-multiselection-activate']) {
'js-multiselection-activate']) {
assert.strictEqual((buttons.match(new RegExp(control, 'g')) || []).length, 1,
`${control} must be written exactly once`);
}
+12 -4
View File
@@ -440,9 +440,17 @@ function setCardDependencies({ cardId, dependsOn = [] } = {}) {
updateOne('cards', { _id: cardId }, { $set: { cardDependencies: deps } });
}
/** Toggle a board's showDependencies (#3392 "Red Strings" overlay) flag. */
function setBoardShowDependencies({ boardId, value = true } = {}) {
updateOne('boards', { _id: boardId }, { $set: { showDependencies: !!value } });
/**
* #6732: showing dependencies is each user's own choice. Set the Show Board /
* Show My Dependencies switches for the given users, or for every member of
* `boardId`. A switch left undefined is not changed.
*/
function setShowDependencies({ userIds, boardId, board, mine } = {}) {
const ids = userIds || (findOne('boards', { _id: boardId }, { members: 1 }).members || []).map(m => m.userId);
const set = {};
if (board !== undefined) set['profile.showBoardDependencies'] = !!board;
if (mine !== undefined) set['profile.showMyDependencies'] = !!mine;
for (const _id of ids) updateOne('users', { _id }, { $set: set });
}
/** Add a second user as a member of an existing board. */
@@ -502,7 +510,7 @@ function getBoard(boardId) {
module.exports = {
seedUser, seedBoard, addBoardMember, setUserGroups, seedTemplatesBoard,
findCardIdByTitle, setCardDependencies, setBoardShowDependencies, cleanup,
findCardIdByTitle, setCardDependencies, setShowDependencies, cleanup,
addResumeToken,
getCard, getBoard, uid, uniqueSuffix,
// generic collection helpers (replace ad-hoc mongosh `mongoEval` scripts)
+39 -36
View File
@@ -5,14 +5,14 @@
*
* Card-to-card dependencies are visualized as red, arrow-headed SVG connection
* lines drawn on top of the board. A card's "Dependencies" section (card detail)
* adds/removes links to other cards on the same board, and a board-header toggle
* (showDependencies) renders the overlay.
* adds/removes links to other cards on the same board, and each user's own
* Show Board Dependencies switch in Member Settings renders the overlay (#6732).
*
* Covers:
* - With a dependency seeded and the board toggle on, a red dependency line is
* drawn on top of the board.
* - The board-header toggle hides/shows the overlay and the showDependencies
* flag is persisted to the board in the database.
* - The Member Settings switch hides/shows the overlay for that user only, and
* is saved in their profile.
* - Adding and removing a dependency from the card detail view persists the
* card's cardDependencies to the database.
*/
@@ -45,7 +45,7 @@ test.describe('Red Strings – card dependency overlay', () => {
// Alpha depends on Beta; overlay enabled.
db.setCardDependencies({ cardId: alphaId, dependsOn: [betaId] });
db.setBoardShowDependencies({ boardId: board.boardId, value: true });
db.setShowDependencies({ boardId: board.boardId, board: true });
});
// Each test gets a new browser page and therefore a new login session. Do
@@ -80,38 +80,41 @@ test.describe('Red Strings – card dependency overlay', () => {
await page.screenshot({ path: 'test-results/red-strings-overlay.png', fullPage: true });
});
test('header toggle hides the overlay and persists showDependencies to the board', async ({ page }) => {
await loginWithToken(page, owner.id, owner.token);
await openBoard(page, board.boardId, board.slug);
// #6732: the board-header toggle became each user's own Show Board
// Dependencies, at the top of Member Settings. Turning it off for one user
// must not touch what anyone else sees.
test('Member Settings switch hides the overlay for this user only', async ({ page }) => {
const other = db.seedUser();
db.addBoardMember({ boardId: board.boardId, userId: other.id });
db.setShowDependencies({ userIds: [other.id], board: true });
try {
await loginWithToken(page, owner.id, owner.token);
await openBoard(page, board.boardId, board.slug);
await expect(page.locator('.js-dependency-overlay')).toBeVisible();
await expect(page.locator('.js-toggle-dependencies')).toHaveCount(0);
await expect(page.locator('.js-dependency-overlay')).toBeVisible();
// Member Settings stays open after a switch is clicked.
const toggle = async () => {
const item = page.locator('.js-toggle-board-dependencies');
if (!await item.isVisible()) await page.locator('.js-open-header-member-menu').click();
await expect(item).toBeVisible();
await item.click();
};
const mine = () => (db.findOne('users', { _id: owner.id }).profile || {}).showBoardDependencies;
const theirs = () => (db.findOne('users', { _id: other.id }).profile || {}).showBoardDependencies;
// The rspack dev-server injects an error/warning overlay iframe
// (#webpack-dev-server-client-overlay) that can sit on top of the board
// header and swallow the toggle click in dev mode. Remove it before each
// click so we exercise the real button (it does not exist in production).
const dismissDevOverlay = () =>
page.evaluate(() => {
document
.querySelectorAll('iframe[id*="webpack-dev-server"]')
.forEach(el => el.remove());
});
await toggle();
await expect(page.locator('.js-dependency-overlay')).toHaveCount(0);
await expect.poll(mine, { timeout: 10_000 }).toBe(false);
expect(theirs()).toBe(true);
expect(db.getBoard(board.boardId).showDependencies).not.toBe(false);
// Toggle OFF.
await dismissDevOverlay();
await page.locator('.js-toggle-dependencies').click();
await expect(page.locator('.js-dependency-overlay')).toHaveCount(0);
await expect
.poll(() => db.getBoard(board.boardId).showDependencies, { timeout: 10_000 })
.toBe(false);
// Toggle ON again.
await dismissDevOverlay();
await page.locator('.js-toggle-dependencies').click();
await expect(page.locator('.js-dependency-overlay')).toBeVisible();
await expect
.poll(() => db.getBoard(board.boardId).showDependencies, { timeout: 10_000 })
.toBe(true);
await toggle();
await expect(page.locator('.js-dependency-overlay')).toBeVisible();
await expect.poll(mine, { timeout: 10_000 }).toBe(true);
} finally {
db.cleanup({ userIds: [other.id] });
}
});
test('add/remove a dependency from the card detail persists to the database', async ({ page }) => {
@@ -152,7 +155,7 @@ test.describe('Red Strings – card dependency overlay', () => {
cardId: alphaId,
dependsOn: [{ cardId: betaId, type: 'blocks', color: '#2196f3', icon: 'lock' }],
});
db.setBoardShowDependencies({ boardId: board.boardId, value: true });
db.setShowDependencies({ boardId: board.boardId, board: true });
await loginWithToken(page, owner.id, owner.token);
await openBoard(page, board.boardId, board.slug);
@@ -285,7 +288,7 @@ test.describe('Red Strings – card dependency overlay', () => {
db.setCardDependencies({ cardId: alphaId, dependsOn: [] });
db.setCardDependencies({ cardId: betaId, dependsOn: [] });
// The connect handle shows only when the overlay is on.
db.setBoardShowDependencies({ boardId: board.boardId, value: true });
db.setShowDependencies({ boardId: board.boardId, board: true });
await loginWithToken(page, owner.id, owner.token);
await openBoard(page, board.boardId, board.slug);
@@ -6,7 +6,7 @@ const { openBoard } = require('../helpers/auth');
test('duplicate relation picker, editing and History undo/redo retain typed links', async ({ loggedInPage: page, board }) => {
const cards = db.find('cards', { boardId: board.boardId });
const [source, target] = cards;
db.setBoardShowDependencies({ boardId: board.boardId, value: true });
db.setShowDependencies({ boardId: board.boardId, board: true });
await openBoard(page, board.boardId, board.slug);
await page.locator(`.js-minicard[data-card-id="${source._id}"]`).click();
await page.locator('.js-add-dependency').click();
@@ -0,0 +1,117 @@
'use strict';
// #6732: Board and My Dependencies in Member Settings. A read-only member keeps
// their own My Dependencies (dashed, seen by them only); Import into the board
// is offered only to roles that may edit or move cards, and it combines; Export
// saves either layer.
const fs = require('fs');
const { test, expect } = require('../fixtures');
const db = require('../helpers/db');
const { loginWithToken, openBoard } = require('../helpers/auth');
test.describe('Board and My Dependencies', () => {
test.use({ storageState: undefined });
let owner, reader, worker, board, alphaId, betaId, gammaId;
test.beforeAll(() => {
owner = db.seedUser();
reader = db.seedUser();
worker = db.seedUser();
board = db.seedBoard({ ownerId: owner.id, title: 'Dependency layers', listCount: 3,
cardTitlesPerList: [['Layer Alpha'], ['Layer Beta'], ['Layer Gamma']] });
alphaId = db.findCardIdByTitle({ boardId: board.boardId, title: 'Layer Alpha' });
betaId = db.findCardIdByTitle({ boardId: board.boardId, title: 'Layer Beta' });
gammaId = db.findCardIdByTitle({ boardId: board.boardId, title: 'Layer Gamma' });
db.updateOne('boards', { _id: board.boardId }, { $push: { members: { $each: [
{ userId: reader.id, isAdmin: false, isActive: true, isReadOnly: true },
{ userId: worker.id, isAdmin: false, isActive: true, isWorker: true },
] } } });
db.setCardDependencies({ cardId: alphaId, dependsOn: [{ cardId: betaId, type: 'blocks' }] });
});
test.beforeEach(() => {
for (const user of [owner, reader, worker]) user.token = db.addResumeToken(user.id);
});
test.afterAll(() => db.cleanup({ boardIds: [board.boardId], userIds: [owner.id, reader.id, worker.id] }));
// Member Settings stays open after a switch is clicked; open it only when it
// is not already showing, and close any sub-popup (Import) first.
const openMenu = async page => {
const item = page.locator('.js-toggle-board-dependencies');
for (let i = 0; i < 3 && await page.locator('.pop-over').count() && !await item.isVisible(); i++) {
await page.locator('.pop-over .js-close-pop-over, .pop-over .js-back-view').first().click().catch(() => {});
}
if (!await item.isVisible()) await page.locator('.js-open-header-member-menu').click();
await expect(item).toBeVisible();
};
test('the four items are at the top of Member Settings, both switches off', async ({ page }) => {
await loginWithToken(page, reader.id, reader.token);
await openBoard(page, board.boardId, board.slug);
await expect(page.locator('.js-dependency-overlay')).toHaveCount(0);
await openMenu(page);
const items = page.locator('.pop-over-list li a');
await expect(items.nth(0)).toHaveClass(/js-toggle-my-dependencies/);
await expect(items.nth(1)).toHaveClass(/js-toggle-board-dependencies/);
await expect(items.nth(2)).toHaveClass(/js-import-member-dependencies/);
await expect(items.nth(3)).toHaveClass(/js-export-member-dependencies/);
await expect(items.nth(0)).toHaveAttribute('aria-checked', 'false');
await expect(items.nth(1)).toHaveAttribute('aria-checked', 'false');
});
test('a read-only member shows the board lines and keeps My Dependencies only they see', async ({ page }) => {
db.setShowDependencies({ userIds: [reader.id], board: true, mine: true });
await loginWithToken(page, reader.id, reader.token);
await openBoard(page, board.boardId, board.slug);
// The reported case: a member who cannot edit may still SHOW the lines.
await expect(page.locator('.js-dependency-overlay .dependency-line:not(.dependency-line-mine)').first()).toBeVisible();
// Import into My Dependencies; the Board option is not theirs.
await openMenu(page);
await page.locator('.js-import-member-dependencies').click();
await expect(page.locator('input[name="dependency-layer"][value="board"]')).toBeDisabled();
await expect(page.locator('.js-board-dependencies-edit-required')).toBeVisible();
await page.locator('.js-import-member-dependencies-text').fill(JSON.stringify({ lines: [
{ fromTitle: 'Layer Beta', toTitle: 'Layer Gamma', type: 'blocks' },
] }));
await page.locator('.js-import-member-dependencies-submit').click();
await expect(page.locator('.js-import-member-dependencies-result')).toContainText('1');
await expect.poll(() => (db.findOne('users', { _id: reader.id }).profile.myDependencies || []).length).toBe(1);
expect(db.getCard(betaId).cardDependencies || []).toEqual([]);
await page.keyboard.press('Escape');
await expect(page.locator('.js-dependency-overlay .dependency-line-mine')).toHaveCount(1);
await expect(page.locator('.js-dependency-overlay .dependency-line-mine')).toHaveAttribute('stroke-dasharray', '6,4');
// Export My Dependencies as JSON: the same format, re-importable.
await openMenu(page);
await page.locator('.js-export-member-dependencies').click();
const [download] = await Promise.all([page.waitForEvent('download'),
page.locator('.js-export-my-dependencies-json').click()]);
expect(download.suggestedFilename()).toBe(`wekan-my-dependencies-${board.boardId}.json`);
const saved = JSON.parse(fs.readFileSync(await download.path(), 'utf8'));
expect(saved.layer).toBe('my');
expect(saved.lines.map(line => [line.fromTitle, line.toTitle, line.type])).toEqual([['Layer Beta', 'Layer Gamma', 'blocks']]);
});
test('the owner does not see another user\'s My Dependencies (negative)', async ({ page }) => {
db.setShowDependencies({ userIds: [owner.id], board: true, mine: true });
await loginWithToken(page, owner.id, owner.token);
await openBoard(page, board.boardId, board.slug);
await expect(page.locator('.js-dependency-overlay .dependency-line').first()).toBeVisible();
await expect(page.locator('.js-dependency-overlay .dependency-line-mine')).toHaveCount(0);
});
test('a Worker imports Board Dependencies, and the import only adds what is missing', async ({ page }) => {
await loginWithToken(page, worker.id, worker.token);
await openBoard(page, board.boardId, board.slug);
await openMenu(page);
await page.locator('.js-import-member-dependencies').click();
await page.locator('input[name="dependency-layer"][value="board"]').check();
await page.locator('.js-import-member-dependencies-text').fill(JSON.stringify({ lines: [
{ fromTitle: 'Layer Alpha', toTitle: 'Layer Beta', type: 'fixes' },
{ fromTitle: 'Layer Gamma', toTitle: 'Layer Alpha', type: 'related-to' },
] }));
await page.locator('.js-import-member-dependencies-submit').click();
await expect(page.locator('.js-import-member-dependencies-result')).toContainText('1');
await expect.poll(() => (db.getCard(gammaId).cardDependencies || []).map(d => d.cardId)).toEqual([alphaId]);
expect(db.getCard(alphaId).cardDependencies.map(d => d.type)).toEqual(['blocks']);
});
});