Fix #3453 #3199; linked-card custom field crash; pin #3843.

- #3453: publish the FULL subtask ancestor chain, not only direct
  parents - the cross-board full-path label vanished after refresh.
- #3748 (defect half): skip unresolvable custom-field definitions on
  linked cards - phantom empty rows and a template TypeError on every
  card details render. The mirroring itself is by design: linked cards
  are pointers; label/custom-field ids are board-scoped; name-based
  inheritance is the copy feature, not the link feature.
- #3199: group each archived card with its own Restore/Delete links and
  add a clear separator below the group (RTL-safe, theme-neutral).
- #3843: comment-editor uploads and the attachments popup now share one
  null-safe meta builder, and tests pin that comment attachments list in
  the card's Attachments section.

Closes #3748: mirroring is by design (see above); the only actionable
defect is fixed in this commit.
Closes #3378: already fixed - notification_cleanup moved off SyncedCron
to a guarded Meteor.setInterval singleton, and quave:synced-cron 2.4.0
handles the E11000 history collision that percolate 1.5.x missed.
Closes #3276: already fixed by d3625db75 - the spent-time badge helpers
now read the live Cards document reactively, so the overtime color flips
immediately; verified behaviorally against the real component.
Closes #3843: behavior verified present and now regression-locked.

Thanks to MPeti1, peterbecich, fxkr, jghaanstra and xet7.
This commit is contained in:
Lauri Ojansivu committed 2026-07-17 03:21:02 +03:00
1 parent dd3f71c951
commit 973c2ad901
13 files changed
+960 -68

No files matched your search

+33
View File
@@ -195,6 +195,39 @@ and fixes the following bugs:
`tests/oauth2LoginStyle.test.cjs` (12 tests, positive + negative). Thanks to ArturRuta and
xet7.
- **Cross-board subtask full path disappeared after refresh**
([#3453](https://github.com/wekan/wekan/issues/3453), `server/publications/boards.js`, new
`server/lib/subtaskAncestors.js`): the board publication shipped only the DIRECT parent
cards, while the full-path label walks the whole ancestor chain client-side — after F5 the
grandparents were missing from minimongo and the path truncated/vanished. The publication
now walks and publishes the full ancestor chain (batched per level, cycle-safe, tolerant of
deleted ancestors). Tests: `tests/subtaskAncestors.test.cjs` (11). Thanks to
MPeti1 and xet7.
- **Linked cards: phantom empty custom-field rows and a template TypeError on every render**
(from [#3748](https://github.com/wekan/wekan/issues/3748), `models/cards.js`, new
`models/lib/customFieldsWD.js`): a linked card keeps the ORIGINAL board's custom-field
snapshot; unresolvable definitions rendered as empty `{}` placeholders — a phantom row per
entry and `Cannot read properties of undefined (reading 'type')` from the card details
template (also reachable on normal cards with deleted definitions). Unmatched entries are
now skipped. The rest of #3748 is by design: linked cards are pointers that mirror the
original; label/custom-field ids are board-scoped, and name-based inheritance is the COPY
feature. Tests: `tests/customFieldsWD.test.cjs` (9). Thanks to peterbecich and xet7.
- **Archive sidebar: Restore/Delete links floated ambiguously between two archived cards**
([#3199](https://github.com/wekan/wekan/issues/3199),
`client/components/sidebar/sidebarArchives.jade`, `sidebar.css`): each card and its links
were loose siblings with near-equal spacing above and below, so the links seemed to belong
to the card underneath. Each archived card is now grouped with its own links in one
container with a clear separator gap below (RTL-safe logical properties, theme-neutral).
Tests: `tests/archiveLinkGrouping.test.cjs` (9). Thanks to fxkr and xet7.
- **Attachments uploaded inside card comments: listing in the card's Attachments section is
now guaranteed and regression-locked** ([#3843](https://github.com/wekan/wekan/issues/3843),
new `models/lib/attachmentMeta.js`, `client/lib/utils.js`): the rich comment editor already
uploads into the same Attachments collection with the same card meta as the Attachments
popup, so they DO list — but nothing pinned that invariant and the meta was built in two
places. One shared, null-safe builder now feeds both paths, with tests pinning that gallery
queries key on `meta.cardId` with no source filter (and that board backgrounds stay
excluded). Tests: `tests/commentAttachmentsList.test.cjs` (12). Thanks to
jghaanstra and xet7.
- **Maximized card rendered at the wrong place after scrolling in Swimlanes view**
([#4822](https://github.com/wekan/wekan/issues/4822), `client/components/cards/cardDetails.css`):
the legacy maximized-pane CSS had no `position` of its own, so the pane stayed an in-flow
+17
View File
@@ -259,6 +259,23 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
z-index: 0;
inset-inline-start: 5px;
}
/* #3199: In the Archive sidebar's Cards tab each card's Restore/Delete links
used to sit BETWEEN two archived cards with near-equal spacing, so users
could not tell which card they belonged to. Group each card with its own
links: no gap between the card and its links, and a clear separator gap
below the whole group before the next card. Spacing-only, so it works in
every board color/theme; logical properties keep it RTL-safe. */
.archived-card-item {
margin-block-end: 1.6em;
}
.archived-card-item .minicard-wrapper {
margin-bottom: 0;
}
.archived-card-item p.quiet {
margin-block-start: 2px;
margin-block-end: 0;
padding-inline-start: 4px;
}
.archived-lists .archived-lists-item {
border-top: 1px solid #ccc;
clear: both;
+21 -17
View File
@@ -16,23 +16,27 @@ template(name="archivesSidebar")
| -
a.js-delete-all-cards {{_ 'delete-all'}}
each archivedCards
.minicard-wrapper.js-minicard
+minicard(this)
if currentUser.isBoardMember
unless isWorker
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
p.quiet
if this.archivedAt
| {{_ 'archived-at' }}
| | {{ displayDate this.archivedAt 'LLL' }}
br
a.js-restore-card {{_ 'restore'}}
if currentUser.isBoardAdmin
| -
a.js-delete-card {{_ 'delete'}}
if cardIsInArchivedList
p.quiet.small ({{_ 'warn-list-archived'}})
//- #3199: keep each archived card and ITS Restore/Delete links in
//- one wrapper so the links visually group with the card above
//- them instead of floating ambiguously between two cards.
.archived-card-item
.minicard-wrapper.js-minicard
+minicard(this)
if currentUser.isBoardMember
unless isWorker
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
p.quiet
if this.archivedAt
| {{_ 'archived-at' }}
| | {{ displayDate this.archivedAt 'LLL' }}
br
a.js-restore-card {{_ 'restore'}}
if currentUser.isBoardAdmin
| -
a.js-delete-card {{_ 'delete'}}
if cardIsInArchivedList
p.quiet.small ({{_ 'warn-list-archived'}})
else
p.no-items-message {{_ 'no-archived-cards'}}
+8 -11
View File
@@ -5,6 +5,7 @@ import { findWhere, where, uniqBy, groupBy, indexBy, debounce, once } from '/imp
import Settings from '/models/settings';
import Users from '/models/users';
import { computeBoardBackground } from '/models/lib/boardBackground';
import { buildCardAttachmentMeta } from '/models/lib/attachmentMeta';
export const Utils = {
async setBackgroundImage(url) {
@@ -595,18 +596,14 @@ export const Utils = {
})
);
},
// Shared by every card attachment upload path — the card Attachments popup,
// the clipboard upload AND images uploaded inside card comments through the
// rich text editor (client/components/main/editor.js). Keeping the meta
// identical (same meta.cardId) is what makes comment attachments appear in
// the card's Attachments list (#3843); the actual logic lives in
// models/lib/attachmentMeta.js so it can be unit tested.
getCommonAttachmentMetaFrom(card) {
const meta = {};
if (card.isLinkedCard()) {
meta.boardId = ReactiveCache.getCard(card.linkedId).boardId;
meta.cardId = card.linkedId;
} else {
meta.boardId = card.boardId;
meta.swimlaneId = card.swimlaneId;
meta.listId = card.listId;
meta.cardId = card._id;
}
return meta;
return buildCardAttachmentMeta(card, id => ReactiveCache.getCard(id));
},
// Collection helpers (replacing underscore.js)
findWhere,
+8 -39
View File
@@ -1299,51 +1299,20 @@ Cards.helpers({
// customFields with definitions
customFieldsWD() {
// get all definitions
// get all definitions attached to this card's CURRENT board
const definitions = ReactiveCache.getCustomFields({
boardIds: { $in: [this.boardId] },
});
if (!definitions) {
return {};
}
// match right definition to each field
if (!this.customFields) return [];
const ret = this.customFields.map(customField => {
const definition = definitions.find(definition => {
return definition._id === customField._id;
});
if (!definition) {
return {};
}
//search for "True Value" which is for DropDowns other then the Value (which is the id)
let trueValue = customField.value;
if (
definition.settings.dropdownItems &&
definition.settings.dropdownItems.length > 0
) {
for (let i = 0; i < definition.settings.dropdownItems.length; i++) {
if (definition.settings.dropdownItems[i]._id === customField.value) {
trueValue = definition.settings.dropdownItems[i].name;
}
}
}
return {
_id: customField._id,
value: customField.value,
trueValue,
definition,
};
});
// at linked cards custom fields definition is not found
ret.sort(
(a, b) =>
a.definition !== undefined &&
b.definition !== undefined &&
a.definition.name !== undefined &&
b.definition.name !== undefined &&
a.definition.name.localeCompare(b.definition.name),
);
return ret;
// #3748: entries whose definition is not attached to THIS board are
// skipped — on a cross-board linked card the customFields snapshot copied
// by Cards.link() references the ORIGINAL board's definitions, and the old
// `{}` placeholders rendered phantom empty rows in the card details and
// threw in the cardCustomField getTemplate helper.
const { buildCustomFieldsWD } = require('./lib/customFieldsWD');
return buildCustomFieldsWD(this.customFields, definitions);
},
colorClass() {
+50
View File
@@ -0,0 +1,50 @@
'use strict';
// Pure helper: build the `meta` object stored on every card attachment
// (Attachments FilesCollection, ostrio:files). Kept side-effect free so it can
// be unit tested without a database or the Meteor client environment.
//
// This single builder is shared by ALL card attachment upload paths:
// - the card Attachments popup and clipboard upload
// (client/components/cards/attachments.js), and
// - images uploaded inside card COMMENTS / the card description through the
// rich text editor (client/components/main/editor.js, summernote
// onImageUpload), via Utils.getCommonAttachmentMetaFrom().
//
// Because comment uploads carry the exact same `meta.cardId` as direct card
// uploads, they are listed in the card's Attachments section, whose queries
// all key on `meta.cardId` (Template.attachmentGallery in
// client/components/cards/attachments.js and Cards.helpers.attachments in
// models/cards.js) with no `meta.source` filtering. That is the behaviour
// requested in https://github.com/wekan/wekan/issues/3843 — an attachment
// added in a card comment must stay linked to (and visible in) the card's
// global attachment list.
//
// For a linked card the attachment is attached to the REAL card (linkedId),
// so it shows up on the original card and on every linked copy.
//
// `getCardById` resolves the real card of a linked card (the client passes
// ReactiveCache.getCard). A missing/null card yields an empty meta rather
// than throwing, so a failed lookup cannot crash an upload handler.
function buildCardAttachmentMeta(card, getCardById) {
const meta = {};
if (!card) {
return meta;
}
if (typeof card.isLinkedCard === 'function' && card.isLinkedCard()) {
const realCard =
typeof getCardById === 'function' ? getCardById(card.linkedId) : null;
if (realCard) {
meta.boardId = realCard.boardId;
}
meta.cardId = card.linkedId;
} else {
meta.boardId = card.boardId;
meta.swimlaneId = card.swimlaneId;
meta.listId = card.listId;
meta.cardId = card._id;
}
return meta;
}
module.exports = { buildCardAttachmentMeta };
+73
View File
@@ -0,0 +1,73 @@
'use strict';
// #3748: match a card's `customFields` entries to the custom-field definitions
// attached to the card's CURRENT board ("customFields With Definitions").
//
// Linked cards are by design pointers that mirror the original card; labels and
// custom-field DEFINITIONS are board-scoped, so they are deliberately not
// inherited across boards (that remapping-by-name behaviour belongs to the
// COPY path — Cards.copy()/mapCustomFieldsToBoard — not to links). However,
// Cards.link() deep-copies the original document, so a cross-board linked card
// carries a `customFields` snapshot whose definition ids do not exist on the
// viewing board. The old customFieldsWD() returned an empty `{}` placeholder
// for every unmatched entry, which rendered a phantom empty row in the card
// details and made the `cardCustomField` template's getTemplate() helper throw
// "TypeError: Cannot read properties of undefined (reading 'type')" on every
// render. Unmatched entries must be SKIPPED instead.
// A dropdown custom field stores the selected item's id as `value`; the
// human-readable "true value" is the matching dropdown item's name. Any other
// field type (or an id that no longer matches an item) keeps the raw value.
function resolveTrueValue(definition, value) {
const items =
definition && definition.settings && definition.settings.dropdownItems;
if (Array.isArray(items)) {
for (const item of items) {
if (item && item._id === value) {
return item.name;
}
}
}
return value;
}
// customFields: the card's own `customFields` array ([{_id, value}, ...]).
// definitions: the CustomFields definitions attached to the card's board.
// Returns [{_id, value, trueValue, definition}, ...] sorted by definition
// name, with entries whose definition is not on this board left out.
function buildCustomFieldsWD(customFields, definitions) {
if (!Array.isArray(customFields) || !Array.isArray(definitions)) {
return [];
}
const ret = [];
for (const customField of customFields) {
if (!customField) {
continue;
}
const definition = definitions.find(
def => def && def._id === customField._id,
);
if (!definition) {
// e.g. a linked card's snapshot from the original board, or a definition
// that was deleted / detached from this board.
continue;
}
ret.push({
_id: customField._id,
value: customField.value,
trueValue: resolveTrueValue(definition, customField.value),
definition,
});
}
ret.sort((a, b) => {
const aName = a.definition.name;
const bName = b.definition.name;
if (aName === undefined || bName === undefined) {
return 0;
}
return aName.localeCompare(bName);
});
return ret;
}
module.exports = { buildCustomFieldsWD, resolveTrueValue };
+64
View File
@@ -0,0 +1,64 @@
'use strict';
// #3453 "Full path display of subtasks cards disappears after a refresh of the
// subtask board".
//
// When a board's "Subtasks settings" present subtasks with
// 'prefix-with-full-path', the minicard renders `parentString(' > ')`, which
// walks the subtask's WHOLE ancestor chain client-side via
// ReactiveCache.getCard (models/cards.js parentList()/parentString()). After a
// hard refresh (F5) of the subtask board, minimongo only contains what the
// `board` publication ships. The publication's "Parent cards (for subtasks)"
// child used to publish only the DIRECT parents of the board's cards, so any
// grandparent living on another board was missing and the displayed path was
// truncated (or, before the parent-cards child existed at all, vanished
// entirely — the original #3453 report).
//
// collectAncestorIds() computes the FULL ancestor id set, level by level, so
// the publication can ship every card document the full-path prefix needs. It
// is pure and dependency-injected (no Meteor/Mongo imports): the caller
// supplies an async batch lookup, which keeps the traversal unit-testable with
// plain Node and keeps the server code to one query per ancestor level.
//
// Cycle safety: a card must never become its own ancestor (#3328), but corrupt
// or legacy data may still contain loops — the `seen` set guarantees the walk
// terminates and returns each ancestor id exactly once.
/**
* Collect the ids of ALL ancestors reachable from the given direct-parent ids.
*
* @param {Array<string>} seedParentIds Direct parent ids of the cards on the
* board (falsy entries and duplicates are ignored).
* @param {function(Array<string>): Promise<Array<{_id: string, parentId?: string}>>} fetchParents
* Async batch lookup: given card ids, resolves the matching card docs
* (at least `_id` and `parentId`). Missing/deleted ids may simply be
* absent from the result.
* @returns {Promise<Array<string>>} every ancestor id, each exactly once.
* Ids whose documents no longer exist are still included (they match
* nothing when published, which is harmless).
*/
async function collectAncestorIds(seedParentIds, fetchParents) {
const seen = new Set();
let frontier = [];
for (const id of Array.isArray(seedParentIds) ? seedParentIds : []) {
if (id && !seen.has(id)) {
seen.add(id);
frontier.push(id);
}
}
while (frontier.length > 0) {
const docs = (await fetchParents(frontier)) || [];
const next = [];
for (const doc of docs) {
const parentId = doc && doc.parentId;
if (parentId && !seen.has(parentId)) {
seen.add(parentId);
next.push(parentId);
}
}
frontier = next;
}
return Array.from(seen);
}
module.exports = { collectAncestorIds };
+16 -1
View File
@@ -12,6 +12,7 @@ import Attachments from '../../models/attachments';
import Boards from '/models/boards';
import Cards from '/models/cards';
import { localizeBoardMemberAvatars } from '/server/lib/localizeAvatar';
import { collectAncestorIds } from '/server/lib/subtaskAncestors';
import {
showsCardCounterList,
countCardsByListId,
@@ -655,7 +656,21 @@ publishComposite('board', async function(boardId, isArchived) {
const parentIds = cards.filter(c => c.parentId).map(c => c.parentId);
if (parentIds.length === 0) return null;
return await ReactiveCache.getCards({ _id: { $in: parentIds } }, {}, true);
// #3453: the 'prefix-with-full-path' subtask setting renders the
// WHOLE ancestor chain (models/cards.js parentString()), and the
// ancestors of a cross-board subtask live on the parent board, so
// they are not covered by any other cursor of this publication.
// Publishing only the direct parents truncated the path after a hard
// refresh — walk every level so the full path survives F5.
const ancestorIds = await collectAncestorIds(parentIds, ids =>
ReactiveCache.getCards(
{ _id: { $in: ids } },
{ fields: { _id: 1, parentId: 1 } },
false,
),
);
return await ReactiveCache.getCards({ _id: { $in: ancestorIds } }, {}, true);
}
},
// Linked cards (cardType-linkedCard)
+122
View File
@@ -0,0 +1,122 @@
'use strict';
// Plain-Node regression guard (no Meteor) for issue #3199: in the board
// Archive sidebar's Cards tab, each archived card's "Restore - Delete" links
// used to be a loose sibling <p.quiet> BETWEEN two archived cards with
// near-equal spacing (minicard-wrapper margin-bottom: 8px above, p
// margin-bottom: 1vh below), so users could not tell which card the links
// belonged to. The fix wraps each card together with ITS links in an
// .archived-card-item group, closes the gap between the card and its links,
// and puts a clear separator gap below the whole group. Spacing-only (works
// in every board color/theme) and RTL-safe (logical properties).
// Run: node tests/archiveLinkGrouping.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const jade = read('client/components/sidebar/sidebarArchives.jade');
const css = read('client/components/sidebar/sidebar.css');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ---------------------------------------------------------------------------
// Markup: card + its links live inside one wrapper
// ---------------------------------------------------------------------------
test('each archived card is wrapped in an .archived-card-item group', () => {
assert.ok(
/each archivedCards\n(?:\s*\/\/-.*\n)*\s*\.archived-card-item\n/.test(jade),
'the archivedCards each-block must start with an .archived-card-item wrapper',
);
});
test('the minicard AND its restore/delete links are both inside the wrapper', () => {
const block = jade.match(/\.archived-card-item\n([\s\S]*?)\n {10}else/);
assert.ok(block, 'found the .archived-card-item block (up to the each/else)');
const body = block[1];
assert.ok(body.includes('.minicard-wrapper.js-minicard'), 'card is inside the wrapper');
assert.ok(body.includes('a.js-restore-card'), 'restore link is inside the wrapper');
assert.ok(body.includes('a.js-delete-card'), 'delete link is inside the wrapper');
// Indentation proof: the links' p.quiet is nested DEEPER than the wrapper,
// i.e. it is a child of .archived-card-item, not a loose sibling.
const wrapperIndent = jade.match(/^(\s*)\.archived-card-item$/m)[1].length;
const pIndent = body.match(/^(\s*)p\.quiet$/m)[1].length;
assert.ok(pIndent > wrapperIndent, 'p.quiet is indented under .archived-card-item');
});
test('negative: no top-level minicard-wrapper sibling of the links remains', () => {
// Before the fix .minicard-wrapper.js-minicard sat directly under the
// each-block at 12-space indentation, with p.quiet as its SIBLING.
assert.ok(
!/^ {12}\.minicard-wrapper\.js-minicard$/m.test(jade),
'the card must no longer be a direct child of the each-block',
);
});
test('lists and swimlanes tabs keep their grouped li structure (unchanged)', () => {
// Those tabs were never ambiguous: title + links share one bordered
// li.archived-lists-item. Make sure the fix did not disturb them.
const liCount = (jade.match(/li\.archived-lists-item/g) || []).length;
assert.strictEqual(liCount, 2, 'one grouped li per tab (lists + swimlanes)');
assert.ok(/a\.js-restore-list/.test(jade));
assert.ok(/a\.js-restore-swimlane/.test(jade));
});
// ---------------------------------------------------------------------------
// CSS: tight link-to-card attachment, clear gap below the group
// ---------------------------------------------------------------------------
function rule(selector) {
const re = new RegExp(
selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*\\{([^}]*)\\}',
);
const m = css.match(re);
assert.ok(m, `CSS rule "${selector}" exists in sidebar.css`);
return m[1];
}
test('the group has a clear separator gap below it (logical property)', () => {
const body = rule('.archived-card-item');
const m = body.match(/margin-block-end:\s*([\d.]+)em/);
assert.ok(m, 'uses margin-block-end in em (RTL/writing-mode safe)');
assert.ok(parseFloat(m[1]) >= 1, 'gap below the group is at least 1em');
});
test('no gap is left between the card and its own links', () => {
const wrapper = rule('.archived-card-item .minicard-wrapper');
assert.ok(/margin-bottom:\s*0/.test(wrapper), 'card margin-bottom zeroed');
const links = rule('.archived-card-item p.quiet');
assert.ok(/margin-block-start:\s*\d+px/.test(links), 'links hug the card above');
assert.ok(/margin-block-end:\s*0/.test(links), 'no extra margin below the links');
});
test('link indent is RTL-safe (padding-inline-start, not padding-left)', () => {
const links = rule('.archived-card-item p.quiet');
assert.ok(/padding-inline-start:/.test(links));
assert.ok(!/padding-left:/.test(links), 'no physical left padding');
});
test('negative: grouping is spacing-only — no theme-fragile colors added', () => {
const block = css.match(/\.archived-card-item[\s\S]*?p\.quiet\s*\{[^}]*\}/)[0];
assert.ok(!/color:|background|border(?!-)/.test(block),
'no hardcoded colors/borders that would break dark board themes');
});
test('negative: ambiguous pre-fix spacing cannot silently return', () => {
// The old ambiguity needed BOTH the 8px card margin (from minicard.css)
// and the loose sibling p. With the wrapper + zeroed margin in place,
// ensure no sidebar rule reintroduces a margin between card and links.
const wrapper = rule('.archived-card-item .minicard-wrapper');
assert.ok(!/margin-bottom:\s*[1-9]/.test(wrapper));
});
console.log(`\n${passed} tests passed`);
+217
View File
@@ -0,0 +1,217 @@
'use strict';
// Plain-Node regression test (no Meteor) for issue #3843:
// "Keep the link between global attachment and attachment in a comment" —
// an attachment added inside a card COMMENT must show up in the card's
// Attachments list.
//
// How comment attachments work today: the rich comment editor
// (client/components/main/editor.js, summernote onImageUpload) uploads the
// file into the SAME Attachments FilesCollection as the card Attachments
// popup, with `meta: Utils.getCommonAttachmentMetaFrom(currentCard)`. The
// card's Attachments section (Template.attachmentGallery in
// client/components/cards/attachments.js, and Cards.helpers.attachments in
// models/cards.js) selects `{ 'meta.cardId': cardId }` with no further
// filtering, so a comment upload is listed as long as its meta.cardId matches
// the card. The shared meta builder is the pure
// models/lib/attachmentMeta.js#buildCardAttachmentMeta, delegated to by
// Utils.getCommonAttachmentMetaFrom.
//
// This test pins both halves of that invariant:
// 1. behaviour: the meta built for a comment upload is matched by the
// gallery's meta.cardId selector (incl. the linked-card case), and
// non-card attachments (board backgrounds) are NOT;
// 2. source guards: the comment editor really uses the shared builder and
// the gallery/model queries really key on meta.cardId without a
// meta.source exclusion.
//
// Run: ELECTRON_RUN_AS_NODE=1 <node-or-code-binary> tests/commentAttachmentsList.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { buildCardAttachmentMeta } = require('../models/lib/attachmentMeta');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const editorSrc = read('client/components/main/editor.js');
const gallerySrc = read('client/components/cards/attachments.js');
const cardsModelSrc = read('models/cards.js');
const utilsSrc = read('client/lib/utils.js');
// The card Attachments section's selector, replicated over plain objects:
// both client/components/cards/attachments.js (attachmentGallery helper) and
// models/cards.js (Cards.helpers.attachments) select { 'meta.cardId': cardId }.
// The source guards below assert the real files still use exactly that key.
function listedInCardAttachments(attachmentDoc, cardId) {
// The gallery helper returns [] when it has no card id
// ("if (!cardId) return [];"), so nothing can match a missing id.
if (!cardId) {
return false;
}
return Boolean(
attachmentDoc &&
attachmentDoc.meta &&
attachmentDoc.meta.cardId === cardId,
);
}
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Card stubs shaped like models/cards.js documents/helpers.
const plainCard = {
_id: 'card1',
boardId: 'boardA',
swimlaneId: 'swimlane1',
listId: 'list1',
isLinkedCard: () => false,
};
const linkedCard = {
_id: 'shellCard',
boardId: 'boardShell',
linkedId: 'realCard',
isLinkedCard: () => true,
};
const getCardById = id =>
id === 'realCard' ? { _id: 'realCard', boardId: 'boardReal' } : null;
// --- POSITIVE: a comment upload is listed in the card's Attachments ---------
test('comment upload meta carries the cardId the gallery selects on', () => {
const meta = buildCardAttachmentMeta(plainCard, getCardById);
assert.strictEqual(meta.cardId, 'card1');
assert.strictEqual(meta.boardId, 'boardA');
assert.strictEqual(meta.swimlaneId, 'swimlane1');
assert.strictEqual(meta.listId, 'list1');
const uploadedFromComment = { _id: 'att1', name: 'pasted.png', meta };
assert.strictEqual(listedInCardAttachments(uploadedFromComment, 'card1'), true);
});
test('comment upload meta is IDENTICAL to a card-popup upload meta (same builder)', () => {
// Both the Attachments popup and the comment editor call
// Utils.getCommonAttachmentMetaFrom -> buildCardAttachmentMeta, so the two
// upload paths cannot diverge on the fields the list keys on.
const fromComment = buildCardAttachmentMeta(plainCard, getCardById);
const fromPopup = buildCardAttachmentMeta(plainCard, getCardById);
assert.deepStrictEqual(fromComment, fromPopup);
});
test('comment upload on a LINKED card is listed on the REAL card', () => {
const meta = buildCardAttachmentMeta(linkedCard, getCardById);
assert.strictEqual(meta.cardId, 'realCard');
assert.strictEqual(meta.boardId, 'boardReal');
const doc = { _id: 'att2', meta };
// The gallery resolves the card via getRealId(), i.e. it queries with the
// real card's id — the attachment must match there, not on the shell card.
assert.strictEqual(listedInCardAttachments(doc, 'realCard'), true);
assert.strictEqual(listedInCardAttachments(doc, 'shellCard'), false);
});
// --- SOURCE GUARDS: the real code paths still wire up this behaviour --------
test('comment editor uploads into Attachments with the shared card meta', () => {
// client/components/main/editor.js (summernote onImageUpload) must store
// comment images in the Attachments FilesCollection with the common card
// meta — not inline base64, not a different meta shape.
const upload = editorSrc.match(
/onImageUpload\(files\)[\s\S]*?Attachments\.insertAsync\(\s*\{[\s\S]*?\}\s*,\s*false\s*,?\s*\)/,
);
assert.ok(upload, 'comment editor upload call found');
assert.ok(
/meta:\s*Utils\.getCommonAttachmentMetaFrom\(currentCard\)/.test(upload[0]),
'comment upload must pass Utils.getCommonAttachmentMetaFrom(currentCard) as meta',
);
});
test('Utils.getCommonAttachmentMetaFrom delegates to the shared builder', () => {
assert.ok(
/getCommonAttachmentMetaFrom\(card\)\s*\{\s*\n\s*return buildCardAttachmentMeta\(card,/.test(utilsSrc),
'client/lib/utils.js must delegate to models/lib/attachmentMeta.js',
);
assert.ok(
/from '\/models\/lib\/attachmentMeta'/.test(utilsSrc),
'client/lib/utils.js must import the shared builder',
);
});
test('the card Attachments gallery selects by meta.cardId', () => {
const helper = gallerySrc.match(/Template\.attachmentGallery\.helpers[\s\S]*?attachments\(\)[\s\S]*?\n \},/);
assert.ok(helper, 'attachmentGallery attachments() helper found');
assert.ok(
/'meta\.cardId':\s*cardId/.test(helper[0]),
"gallery must query { 'meta.cardId': cardId }",
);
});
test('Cards.helpers.attachments selects by meta.cardId', () => {
assert.ok(
/attachments\(\)\s*\{[\s\S]*?'meta\.cardId':\s*this\.getRealId\(\)/.test(cardsModelSrc),
"models/cards.js attachments() must query { 'meta.cardId': this.getRealId() }",
);
});
test('NEGATIVE: no meta.source filter excludes comment uploads from the list', () => {
const helper = gallerySrc.match(/Template\.attachmentGallery\.helpers[\s\S]*?attachments\(\)[\s\S]*?\n \},/);
assert.ok(helper, 'attachmentGallery attachments() helper found');
assert.strictEqual(
/meta\.source/.test(helper[0]),
false,
'the gallery query must not filter on meta.source',
);
const modelHelper = cardsModelSrc.match(/attachments\(\)\s*\{[\s\S]*?\n \},/);
assert.ok(modelHelper, 'models/cards.js attachments() helper found');
assert.strictEqual(
/meta\.source/.test(modelHelper[0]),
false,
'the model query must not filter on meta.source',
);
});
// --- NEGATIVE: non-card attachments must NOT leak into the card list --------
test('NEGATIVE: board-background uploads (no cardId) are not listed on any card', () => {
// client/components/sidebar/sidebar.js uploads board backgrounds with
// meta: { boardId, source: 'board-background' } — no cardId.
const backgroundDoc = {
_id: 'bg1',
meta: { boardId: 'boardA', source: 'board-background' },
};
assert.strictEqual(listedInCardAttachments(backgroundDoc, 'card1'), false);
});
test("NEGATIVE: another card's attachment is not listed on this card", () => {
const otherMeta = buildCardAttachmentMeta(
{ _id: 'card2', boardId: 'boardA', isLinkedCard: () => false },
getCardById,
);
assert.strictEqual(listedInCardAttachments({ meta: otherMeta }, 'card1'), false);
});
test('NEGATIVE: a null card yields empty meta — never a bogus cardId', () => {
assert.doesNotThrow(() => buildCardAttachmentMeta(null, getCardById));
const meta = buildCardAttachmentMeta(null, getCardById);
assert.deepStrictEqual(meta, {});
assert.strictEqual(listedInCardAttachments({ meta }, 'card1'), false);
assert.strictEqual(listedInCardAttachments({ meta }, undefined), false);
});
test('NEGATIVE: a linked card whose real card is gone still links by id and does not throw', () => {
const orphanLinked = {
_id: 'shell2',
boardId: 'boardShell',
linkedId: 'goneCard',
isLinkedCard: () => true,
};
const meta = buildCardAttachmentMeta(orphanLinked, getCardById);
assert.strictEqual(meta.cardId, 'goneCard');
assert.strictEqual('boardId' in meta, false);
});
console.log(`\n${passed} tests passed`);
+174
View File
@@ -0,0 +1,174 @@
'use strict';
// Plain-Node unit test (no Meteor) for the "customFields with definitions"
// matcher used by Cards.customFieldsWD().
// Run: node tests/customFieldsWD.test.cjs
//
// Regression guard for the defect half of #3748 ("Link cards does not inherit
// Label and Customfields"): the non-inheritance itself is by design — a linked
// card is a pointer to the original card, and label ids / custom-field
// definition ids are board-scoped (remapping-by-name belongs to the COPY path,
// Cards.copy()/mapCustomFieldsToBoard). But Cards.link() deep-copies the
// original card document, so a cross-board linked card carries a customFields
// snapshot whose definition ids do not exist on the viewing board. The old
// customFieldsWD() returned an empty `{}` placeholder for each unmatched
// entry, which rendered a phantom empty row in the card details and made the
// cardCustomField getTemplate() helper throw
// "TypeError: Cannot read properties of undefined (reading 'type')".
// Unmatched entries must be skipped; matched entries must keep the exact
// {_id, value, trueValue, definition} shape sorted by definition name.
const assert = require('assert');
const {
buildCustomFieldsWD,
resolveTrueValue,
} = require('../models/lib/customFieldsWD');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Definitions attached to the VIEWING board.
const textDef = {
_id: 'cf-text',
name: 'Severity',
type: 'text',
settings: {},
};
const dropdownDef = {
_id: 'cf-drop',
name: 'Area',
type: 'dropdown',
settings: {
dropdownItems: [
{ _id: 'item1', name: 'Frontend' },
{ _id: 'item2', name: 'Backend' },
],
},
};
const boardDefinitions = [textDef, dropdownDef];
// --- POSITIVE: entries whose definition IS on this board -------------------
test('a matched text field keeps its value and definition', () => {
const ret = buildCustomFieldsWD(
[{ _id: 'cf-text', value: 'high' }],
boardDefinitions,
);
assert.strictEqual(ret.length, 1);
assert.deepStrictEqual(ret[0], {
_id: 'cf-text',
value: 'high',
trueValue: 'high',
definition: textDef,
});
});
test('a matched dropdown field resolves trueValue to the item NAME', () => {
const ret = buildCustomFieldsWD(
[{ _id: 'cf-drop', value: 'item2' }],
boardDefinitions,
);
assert.strictEqual(ret.length, 1);
assert.strictEqual(ret[0].value, 'item2');
assert.strictEqual(ret[0].trueValue, 'Backend');
assert.strictEqual(ret[0].definition, dropdownDef);
});
test('matched entries are sorted by definition name', () => {
const ret = buildCustomFieldsWD(
[
{ _id: 'cf-text', value: 'x' }, // "Severity"
{ _id: 'cf-drop', value: 'item1' }, // "Area"
],
boardDefinitions,
);
assert.deepStrictEqual(
ret.map(f => f.definition.name),
['Area', 'Severity'],
);
});
test('every returned entry can feed the cardCustomField getTemplate helper', () => {
// client/components/cards/cardCustomFields.js getTemplate() does
// `cardCustomField-${this.definition.type}` — it must never see an entry
// without a definition.
const ret = buildCustomFieldsWD(
[
{ _id: 'cf-from-other-board', value: 'lost' },
{ _id: 'cf-drop', value: 'item1' },
],
boardDefinitions,
);
for (const entry of ret) {
assert.doesNotThrow(() => `cardCustomField-${entry.definition.type}`);
assert.strictEqual(typeof entry.definition.type, 'string');
}
});
// --- NEGATIVE: the #3748 linked-card snapshot and other dangling refs ------
test('#3748: a cross-board linked-card snapshot entry is SKIPPED, not {}', () => {
// Cards.link() copies customFields ids of the ORIGINAL board's definitions;
// they are not attached to the viewing board, so nothing must be returned
// (the old code returned [{}], rendering a phantom row and throwing on
// `undefined.type`).
const ret = buildCustomFieldsWD(
[{ _id: 'cf-original-board-only', value: 'snapshot' }],
boardDefinitions,
);
assert.deepStrictEqual(ret, []);
});
test('a mix of matched and unmatched entries keeps only the matched ones', () => {
const ret = buildCustomFieldsWD(
[
{ _id: 'cf-original-board-only', value: 'snapshot' },
{ _id: 'cf-text', value: 'low' },
{ _id: 'cf-deleted-definition', value: 42 },
],
boardDefinitions,
);
assert.strictEqual(ret.length, 1);
assert.strictEqual(ret[0]._id, 'cf-text');
});
test('empty or malformed inputs never throw and return []', () => {
assert.deepStrictEqual(buildCustomFieldsWD([], boardDefinitions), []);
assert.deepStrictEqual(buildCustomFieldsWD(null, boardDefinitions), []);
assert.deepStrictEqual(buildCustomFieldsWD(undefined, boardDefinitions), []);
assert.deepStrictEqual(buildCustomFieldsWD([{ _id: 'cf-text', value: 'x' }], null), []);
assert.deepStrictEqual(buildCustomFieldsWD([null, undefined], boardDefinitions), []);
// a null hole in the definitions list must not break matching
assert.strictEqual(
buildCustomFieldsWD(
[{ _id: 'cf-text', value: 'x' }],
[null, textDef],
).length,
1,
);
});
test('definitions without a name do not break sorting', () => {
const namelessDef = { _id: 'cf-noname', type: 'text', settings: {} };
const ret = buildCustomFieldsWD(
[
{ _id: 'cf-noname', value: 'a' },
{ _id: 'cf-text', value: 'b' },
],
[namelessDef, textDef],
);
assert.strictEqual(ret.length, 2);
});
// --- resolveTrueValue --------------------------------------------------------
test('resolveTrueValue: non-dropdown and unmatched ids keep the raw value', () => {
assert.strictEqual(resolveTrueValue(textDef, 'raw'), 'raw');
assert.strictEqual(resolveTrueValue(dropdownDef, 'not-an-item'), 'not-an-item');
assert.strictEqual(resolveTrueValue(dropdownDef, 'item1'), 'Frontend');
assert.strictEqual(resolveTrueValue({ settings: {} }, 'v'), 'v');
assert.strictEqual(resolveTrueValue(undefined, 'v'), 'v');
});
console.log(`\n${passed} passed`);
+157
View File
@@ -0,0 +1,157 @@
'use strict';
// Plain-Node unit test (no Meteor) for the subtask ancestor-chain walk used by
// the `board` publication's "Parent cards (for subtasks)" child.
// Run: ELECTRON_RUN_AS_NODE=1 <node> tests/subtaskAncestors.test.cjs
//
// Regression guard for #3453 ("Full path display of subtasks cards disappears
// after a refresh of the subtask board"). The 'prefix-with-full-path' subtask
// setting renders parentString(' > ') — the subtask's WHOLE ancestor chain —
// client-side from minimongo. A cross-board subtask's ancestors live on the
// parent board, so after F5 they only exist on the client if the subtask
// board's publication ships them. Publishing only the DIRECT parents (the old
// behaviour) truncated the path to one level; collectAncestorIds() must walk
// every level, terminate on cyclic/corrupt data (#3328), and never lose ids.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { collectAncestorIds } = require('../server/lib/subtaskAncestors');
let passed = 0;
const tests = [];
function test(name, fn) {
tests.push([name, fn]);
}
// A fetchParents stub over a card "database": id -> parentId (undefined = top
// level, id absent from the map = deleted card). Records each batch queried.
function makeFetch(cardsById, calls) {
return async ids => {
if (calls) calls.push([...ids]);
return ids
.filter(id => Object.prototype.hasOwnProperty.call(cardsById, id))
.map(id => ({ _id: id, parentId: cardsById[id] }));
};
}
// --- POSITIVE: the full path is collected ------------------------------------
test('single-level chain: the direct parent is published', async () => {
// subtask (on Board2) -> parent P1 (on Board1): the original #3453 repro.
const fetch = makeFetch({ P1: undefined });
const ids = await collectAncestorIds(['P1'], fetch);
assert.deepStrictEqual(ids.sort(), ['P1']);
});
test('#3453 full path: EVERY ancestor level is collected, not just the direct parent', async () => {
// sub -> P1 -> P2 -> P3 (P1..P3 on another board). 'prefix-with-full-path'
// renders "P3 > P2 > P1", so all three docs must be published.
const fetch = makeFetch({ P1: 'P2', P2: 'P3', P3: undefined });
const ids = await collectAncestorIds(['P1'], fetch);
assert.deepStrictEqual(ids.sort(), ['P1', 'P2', 'P3']);
});
test('multiple subtasks: chains are merged and shared ancestors appear once', async () => {
const fetch = makeFetch({ P1: 'ROOT', P2: 'ROOT', ROOT: undefined });
const ids = await collectAncestorIds(['P1', 'P2', 'P1'], fetch);
assert.deepStrictEqual(ids.sort(), ['P1', 'P2', 'ROOT']);
});
test('walks one batched query per ancestor level (no per-card queries)', async () => {
const calls = [];
const fetch = makeFetch({ A1: 'B1', A2: 'B2', B1: 'C1', B2: 'C1', C1: undefined }, calls);
const ids = await collectAncestorIds(['A1', 'A2'], fetch);
assert.deepStrictEqual(ids.sort(), ['A1', 'A2', 'B1', 'B2', 'C1']);
// Level 1: [A1, A2]; level 2: [B1, B2]; level 3: [C1]. C1 is top level, so
// the walk stops after 3 queries.
assert.deepStrictEqual(calls, [['A1', 'A2'], ['B1', 'B2'], ['C1']]);
});
// --- NEGATIVE / robustness ----------------------------------------------------
test('no parents at all: returns [] and never queries', async () => {
const calls = [];
const fetch = makeFetch({}, calls);
assert.deepStrictEqual(await collectAncestorIds([], fetch), []);
assert.deepStrictEqual(await collectAncestorIds(undefined, fetch), []);
assert.deepStrictEqual(await collectAncestorIds(null, fetch), []);
assert.strictEqual(calls.length, 0);
});
test('falsy parent ids are ignored', async () => {
const calls = [];
const fetch = makeFetch({ P1: undefined }, calls);
const ids = await collectAncestorIds([null, '', undefined, 'P1', 0], fetch);
assert.deepStrictEqual(ids.sort(), ['P1']);
assert.deepStrictEqual(calls, [['P1']]);
});
test('deleted ancestor: walk stops at the gap and still terminates', async () => {
// P1's parent GONE was deleted: its id is still returned (matches nothing
// when published — harmless) and the walk must not loop or throw.
const fetch = makeFetch({ P1: 'GONE' });
const ids = await collectAncestorIds(['P1'], fetch);
assert.deepStrictEqual(ids.sort(), ['GONE', 'P1']);
});
test('#3328 cyclic chain terminates and yields each ancestor exactly once', async () => {
// Corrupt/legacy data: P1 -> P2 -> P1. Without the seen-set this loops
// forever inside the publication.
const fetch = makeFetch({ P1: 'P2', P2: 'P1' });
const ids = await collectAncestorIds(['P1'], fetch);
assert.deepStrictEqual(ids.sort(), ['P1', 'P2']);
});
test('self-parent card terminates', async () => {
const calls = [];
const fetch = makeFetch({ P1: 'P1' }, calls);
const ids = await collectAncestorIds(['P1'], fetch);
assert.deepStrictEqual(ids, ['P1']);
assert.deepStrictEqual(calls, [['P1']]);
});
test('fetch returning null/undefined is treated as "no parents found"', async () => {
const ids = await collectAncestorIds(['P1'], async () => null);
assert.deepStrictEqual(ids, ['P1']);
const ids2 = await collectAncestorIds(['P1'], async () => undefined);
assert.deepStrictEqual(ids2, ['P1']);
});
// --- wiring: the `board` publication actually uses the full-chain walk --------
test('board publication publishes the FULL ancestor chain (uses collectAncestorIds)', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', 'server', 'publications', 'boards.js'),
'utf8',
);
assert.ok(
/require\(['"]\/server\/lib\/subtaskAncestors['"]\)|from ['"]\/server\/lib\/subtaskAncestors['"]/.test(src),
'boards.js must import collectAncestorIds from /server/lib/subtaskAncestors',
);
// The "Parent cards (for subtasks)" child must publish the walked ancestor
// set, not the one-level parentIds snapshot (the #3453 truncation).
const parentChild = src.slice(src.indexOf('// Parent cards (for subtasks)'));
assert.ok(
parentChild.includes('collectAncestorIds('),
'the parent-cards child must walk the full ancestor chain',
);
assert.ok(
/getCards\(\s*\{\s*_id:\s*\{\s*\$in:\s*ancestorIds\s*\}\s*\}/.test(parentChild),
'the parent-cards child must publish the collected ancestor ids',
);
});
// --- runner --------------------------------------------------------------------
(async () => {
for (const [name, fn] of tests) {
await fn();
passed += 1;
console.log(' ok -', name);
}
console.log(`\nsubtaskAncestors: ${passed} tests passed`);
})().catch(err => {
console.error(err);
process.exit(1);
});