mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
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:
1 parent
3e1a1f4917
commit
2328bcd0f1
4 files changed
+146
-5
No files matched your search
@@ -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;
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user