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