Case-insensitive '#' label autocomplete in the card title.

Typing '#test' in the add-card / edit-card-title field did not suggest a label named 'Testing': the label search matched with a case-SENSITIVE label.name.indexOf(term) (and label.color.indexOf(term)), so only '#Test' matched 'Testing'. The board's '@' member autocomplete already matches case-insensitively; this brings the '#' label autocomplete in line. Extracted the match into a pure, unit-testable models/lib/labelAutocomplete.js (labelMatchesTerm / filterLabelsByTerm) that lowercases both the term and the label name/color, mirroring the other models/lib pure helpers.

Tests: tests/labelAutocompleteMatch.test.cjs covers the exact #5116 scenario (#test -> Testing), color-fallback matching, empty-term-shows-all, order preservation, and negatives (non-substring, missing name/color, null label, non-array), plus a source guard that listBody.js routes through the shared matcher. 11 checks pass.

Thanks to rjl9 and xet7 !

Fixes #5116,
This commit is contained in:
Lauri Ojansivu committed 2026-07-22 09:21:21 +03:00
1 parent e6e013bb07
commit 728cd929cd
3 files changed
+136 -4

No files matched your search

+5 -4
View File
@@ -12,6 +12,7 @@ import { Utils } from '/client/lib/utils';
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 { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
mutationsChangeDragGeometry,
@@ -667,10 +668,10 @@ Template.addCardForm.onRendered(function () {
if (label.name == undefined) {
label.name = "";
}
if (
label.name.indexOf(term) > -1 ||
label.color.indexOf(term) > -1
) {
// #5116: match case-INSENSITIVELY so "#test" suggests "Testing"
// (was label.name.indexOf(term), which never matched a differently
// cased label). Matches name or color, like the shared helper.
if (labelMatchesTerm(label, term)) {
return label;
}
return null;
+37
View File
@@ -0,0 +1,37 @@
'use strict';
// Pure helper for the card-title "#" label autocomplete (#5116): typing
// "#test" in the add-card / edit-card-title field should suggest a label named
// "Testing". The original match used a case-SENSITIVE `label.name.indexOf(term)`,
// so "#test" (lowercase) never matched "Testing" (capital T) even though
// "#Test" did. Extracted so the matching is unit-testable in plain Node without
// Meteor/jQuery/textcomplete (mirrors models/lib/sortCardsByTitle.js and the
// other models/lib pure helpers).
//
// The board's "@" member autocomplete already matches case-insensitively via
// toLowerCase()/includes(); this brings the "#" label autocomplete in line.
// Does `label` match the typed `term`? Case-insensitive substring match against
// the label's name and its color (color is the fallback shown when a label has
// no name). A missing name/color is treated as an empty string so it never
// throws. An empty term matches every label (the menu shows all labels before
// anything is typed), matching the previous ''.indexOf('') === 0 behaviour.
function labelMatchesTerm(label, term) {
if (!label) return false;
const needle = String(term == null ? '' : term).toLowerCase();
const name = String(label.name == null ? '' : label.name).toLowerCase();
const color = String(label.color == null ? '' : label.color).toLowerCase();
return name.indexOf(needle) > -1 || color.indexOf(needle) > -1;
}
// Filter a list of board labels by the typed term, preserving input order.
// Does not mutate the input labels.
function filterLabelsByTerm(labels, term) {
if (!Array.isArray(labels)) return [];
return labels.filter(label => labelMatchesTerm(label, term));
}
module.exports = {
labelMatchesTerm,
filterLabelsByTerm,
};
+94
View File
@@ -0,0 +1,94 @@
'use strict';
// Unit + negative tests for the card-title "#" label autocomplete matcher
// (issue #5116): typing "#test" must suggest a label named "Testing".
// Run: node tests/labelAutocompleteMatch.test.cjs
const assert = require('assert');
const {
labelMatchesTerm,
filterLabelsByTerm,
} = require('../models/lib/labelAutocomplete');
let passed = 0;
function check(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
const L = (name, color) => ({ _id: name + '-id', name, color });
const BOARD = [
L('Testing', 'green'),
L('To Do', 'yellow'),
L('URGENT', 'red'),
L('', 'blue'), // a color-only label (no name)
];
// ── the exact #5116 scenario ────────────────────────────────────────────────
check('#5116: lowercase "test" matches label "Testing"', () => {
assert.strictEqual(labelMatchesTerm(L('Testing', 'green'), 'test'), true);
});
check('#5116: mixed-case "Test" still matches "Testing" (unchanged behaviour)', () => {
assert.strictEqual(labelMatchesTerm(L('Testing', 'green'), 'Test'), true);
});
check('#5116: uppercase "TESTING" matches "Testing"', () => {
assert.strictEqual(labelMatchesTerm(L('Testing', 'green'), 'TESTING'), true);
});
check('lowercase term matches an ALL-CAPS label ("urg" -> "URGENT")', () => {
assert.strictEqual(labelMatchesTerm(L('URGENT', 'red'), 'urg'), true);
});
// ── color fallback (labels shown by color when unnamed) ─────────────────────
check('matches by color name, case-insensitively ("BLUE" -> blue label)', () => {
assert.strictEqual(labelMatchesTerm(L('', 'blue'), 'BLUE'), true);
assert.strictEqual(labelMatchesTerm(L('', 'Blue'), 'blue'), true);
});
// ── empty term shows every label (menu before typing) ───────────────────────
check('empty term matches every label', () => {
assert.strictEqual(labelMatchesTerm(L('Testing', 'green'), ''), true);
assert.strictEqual(filterLabelsByTerm(BOARD, '').length, BOARD.length);
});
// ── negative cases ──────────────────────────────────────────────────────────
check('a non-substring term matches nothing (negative)', () => {
assert.strictEqual(labelMatchesTerm(L('Testing', 'green'), 'zzz'), false);
assert.deepStrictEqual(filterLabelsByTerm(BOARD, 'zzz'), []);
});
check('does not throw on missing name/color or a null label (negative)', () => {
assert.strictEqual(labelMatchesTerm({ color: 'green' }, 'gr'), true);
assert.strictEqual(labelMatchesTerm({ name: 'X' }, 'x'), true);
assert.strictEqual(labelMatchesTerm(null, 'x'), false);
assert.strictEqual(labelMatchesTerm({}, 'x'), false);
});
// ── filterLabelsByTerm preserves order and does not mutate ──────────────────
check('filterLabelsByTerm keeps input order and leaves the input untouched', () => {
const input = BOARD.slice();
// "t" (case-insensitively) is in Testing, To Do and urgenT — kept in board order.
const out = filterLabelsByTerm(BOARD, 't');
assert.deepStrictEqual(out.map(l => l.name), ['Testing', 'To Do', 'URGENT']);
assert.deepStrictEqual(BOARD, input, 'input array unchanged');
});
check('filterLabelsByTerm tolerates a non-array (negative)', () => {
assert.deepStrictEqual(filterLabelsByTerm(null, 'x'), []);
assert.deepStrictEqual(filterLabelsByTerm(undefined, 'x'), []);
});
// ── source guard: listBody.js uses the shared matcher, not a raw indexOf ─────
check('listBody.js label strategy routes through labelMatchesTerm (no case-sensitive indexOf)', () => {
const fs = require('fs');
const path = require('path');
const raw = fs.readFileSync(
path.join(__dirname, '..', 'client', 'components', 'lists', 'listBody.js'),
'utf8',
);
// Strip comments first: the fix keeps a comment that MENTIONS the old
// label.name.indexOf(term) code, which would otherwise trip the negative guard.
const src = raw
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '');
assert.ok(/labelMatchesTerm\(label, term\)/.test(src),
'label search must call labelMatchesTerm(label, term)');
assert.ok(!/label\.name\.indexOf\(term\)/.test(src),
'the case-sensitive label.name.indexOf(term) must be gone from the active code');
});
console.log(`\nlabelAutocompleteMatch: ${passed} checks passed`);