Case-insensitive '@' member autocomplete (shared, null-safe).

Follow-up to the #5116 label fix: the add-card-title '@' member mention in client/components/lists/listBody.js matched with user.username.indexOf(term) === 0 -- case-sensitive, prefix-only, username-only, and it threw when ReactiveCache.getUser() returned null. So '@ann' did not suggest 'Anna', '@Anna' did not match username 'anna', and a full-name search never matched. The card description/comment editor (client/components/main/editor.js) already matched case-insensitively on username OR full name but shared no code and had the same null hazard. Both now route through a pure, unit-tested models/lib/memberAutocomplete.js (memberMatchesTerm / filterMembersByTerm) that lowercases the term and matches username or profile.fullname as a substring, and is null-safe.

Tests: tests/memberAutocompleteMatch.test.cjs (11 checks) covers case-insensitive + substring + full-name matching, empty-term-shows-all, null/undefined user (the old crash), missing profile, order preservation, and source guards that both '@' call sites use the shared matcher.

Thanks to rjl9 and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 10:15:50 +03:00
1 parent 3e1a1f4917
commit 2328bcd0f1
4 files changed
+146 -5

No files matched your search

+6 -1
View File
@@ -13,6 +13,7 @@ import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { labelMatchesTerm } from '/models/lib/labelAutocomplete';
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
mutationsChangeDragGeometry,
@@ -641,7 +642,11 @@ Template.addCardForm.onRendered(function () {
callback(
$.map(currentBoard.activeMembers(), member => {
const user = ReactiveCache.getUser(member.userId);
return user.username.indexOf(term) === 0 ? user : null;
// #5116 follow-up: match case-INSENSITIVELY as a substring of the
// username OR full name (was user.username.indexOf(term) === 0,
// which was case-sensitive, prefix-only, username-only and threw
// when getUser() returned null). Shared with the editor '@' mention.
return memberMatchesTerm(user, term) ? user : null;
}),
);
},
+5 -4
View File
@@ -3,6 +3,7 @@ import { findWhere } from '/imports/lib/collectionHelpers';
import { TAPi18n } from '/imports/i18n';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
import autosize from 'autosize';
var converter = require('@wekanteam/html-to-markdown');
@@ -59,14 +60,14 @@ Template.editor.onRendered(function () {
match: /\B@([\w.-]*)$/,
search(term, callback) {
const currentBoard = Utils.getCurrentBoard();
const searchTerm = term.toLowerCase();
// Shared with the add-card '@' mention (models/lib/memberAutocomplete):
// case-insensitive substring match on username OR full name, and safe
// when getUser() returns null (previously threw on user.username).
const users = currentBoard
.activeMembers()
.map(member => {
const user = ReactiveCache.getUser(member.userId);
const username = user.username.toLowerCase();
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
return memberMatchesTerm(user, term) ? user : null;
})
.filter(Boolean);
// Order: 1. Users, 2. Card-specific options, 3. Board-wide options
+42
View File
@@ -0,0 +1,42 @@
'use strict';
// Pure helper for the "@" member autocomplete (issue #5116 follow-up): typing
// "@ann" should suggest the board member "Anna" / username "anna.smith". The
// add-card-title strategy in client/components/lists/listBody.js used a
// case-SENSITIVE, prefix-only, username-only match
// (user.username.indexOf(term) === 0), so "@ann" did not match "Anna", "@Anna"
// did not match username "anna", and a full-name-only search never matched. It
// also threw if ReactiveCache.getUser() returned null. The card description /
// comment editor (client/components/main/editor.js) already matched
// case-insensitively on username OR full name, but shared no code and had the
// same null hazard.
//
// Extracted here (mirrors models/lib/labelAutocomplete.js and the other
// models/lib pure helpers) so both call sites match members the same way and it
// is unit-testable in plain Node without Meteor.
// Does `user` match the typed `term`? Case-insensitive substring match against
// the username and the profile full name. A null/undefined user, or a missing
// username/fullname, is treated safely (no throw). An empty term matches every
// user (the menu shows all members before anything is typed).
function memberMatchesTerm(user, term) {
if (!user) return false;
const needle = String(term == null ? '' : term).toLowerCase();
const username = String(user.username == null ? '' : user.username).toLowerCase();
const fullname = String(
(user.profile && user.profile.fullname) == null ? '' : user.profile.fullname,
).toLowerCase();
return username.indexOf(needle) > -1 || fullname.indexOf(needle) > -1;
}
// Filter a list of user documents by the typed term, preserving input order.
// Does not mutate the input; skips null/undefined entries.
function filterMembersByTerm(users, term) {
if (!Array.isArray(users)) return [];
return users.filter(user => memberMatchesTerm(user, term));
}
module.exports = {
memberMatchesTerm,
filterMembersByTerm,
};
+93
View File
@@ -0,0 +1,93 @@
'use strict';
// Unit + negative tests for the "@" member autocomplete matcher (#5116
// follow-up): typing "@ann" must suggest the member "Anna" and username
// "anna.smith", case-insensitively, as a substring of username OR full name.
// Run: node tests/memberAutocompleteMatch.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
memberMatchesTerm,
filterMembersByTerm,
} = require('../models/lib/memberAutocomplete');
let passed = 0;
function check(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const U = (username, fullname) => ({
_id: username + '-id',
username,
profile: fullname === undefined ? undefined : { fullname },
});
const BOARD = [
U('anna.smith', 'Anna Smith'),
U('bob', 'Bob Jones'),
U('carol', undefined), // no profile
U('dave', 'David Brown'),
];
// ── the reported regression: case-insensitive, not prefix-only, full name too ─
check('lowercase "ann" matches username "anna.smith" (was prefix+case-sensitive)', () => {
assert.strictEqual(memberMatchesTerm(U('anna.smith', 'Anna Smith'), 'ann'), true);
});
check('"Anna" (capital) matches lowercase username via full name', () => {
assert.strictEqual(memberMatchesTerm(U('anna.smith', 'Anna Smith'), 'Anna'), true);
});
check('a substring NOT at the start still matches (was indexOf === 0)', () => {
assert.strictEqual(memberMatchesTerm(U('anna.smith', 'Anna Smith'), 'smith'), true);
});
check('matches on full name when the username does not contain the term', () => {
assert.strictEqual(memberMatchesTerm(U('dave', 'David Brown'), 'brown'), true);
assert.strictEqual(memberMatchesTerm(U('dave', 'David Brown'), 'david'), true);
});
// ── empty term shows everyone ───────────────────────────────────────────────
check('empty term matches every member', () => {
assert.strictEqual(memberMatchesTerm(U('bob', 'Bob'), ''), true);
assert.strictEqual(filterMembersByTerm(BOARD, '').length, BOARD.length);
});
// ── negatives / null-safety (the old code threw on a null user) ─────────────
check('a non-substring term matches nothing (negative)', () => {
assert.strictEqual(memberMatchesTerm(U('anna.smith', 'Anna Smith'), 'zzz'), false);
});
check('a null / undefined user is safe and does not match (was a crash)', () => {
assert.strictEqual(memberMatchesTerm(null, 'a'), false);
assert.strictEqual(memberMatchesTerm(undefined, 'a'), false);
});
check('a user with no profile / no fullname does not throw', () => {
assert.strictEqual(memberMatchesTerm(U('carol', undefined), 'car'), true);
assert.strictEqual(memberMatchesTerm({ username: 'x' }, 'x'), true);
assert.strictEqual(memberMatchesTerm({}, 'x'), false);
});
// ── filterMembersByTerm: order preserved, input untouched, null-tolerant ─────
check('filterMembersByTerm keeps order, ignores nulls, does not mutate', () => {
const withHole = [BOARD[0], null, BOARD[3]];
const out = filterMembersByTerm(withHole, 'a'); // anna.smith, dave(David)
assert.deepStrictEqual(out.map(u => u.username), ['anna.smith', 'dave']);
assert.deepStrictEqual(filterMembersByTerm(null, 'x'), []);
});
// ── source guards: both '@' call sites route through the shared matcher ──────
const strip = s => s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
check('listBody.js add-card @mention uses memberMatchesTerm (no case-sensitive prefix)', () => {
const src = strip(read('client/components/lists/listBody.js'));
assert.ok(/memberMatchesTerm\(user, term\)/.test(src), 'must call memberMatchesTerm(user, term)');
assert.ok(!/user\.username\.indexOf\(term\) === 0/.test(src),
'the case-sensitive prefix-only match must be gone');
});
check('editor.js comment/description @mention uses the same shared matcher', () => {
const src = strip(read('client/components/main/editor.js'));
assert.ok(/memberMatchesTerm\(user, term\)/.test(src), 'must call memberMatchesTerm(user, term)');
assert.ok(!/user\.username\.toLowerCase\(\)/.test(src),
'the inline duplicate matcher must be gone (shared helper now)');
});
function read(rel) {
return fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
}
console.log(`\nmemberAutocompleteMatch: ${passed} checks passed`);