Let a board's filter state be driven by URL query parameters.

?assignee=johndoe, ?member=janedoe and ?label=urgent now pre-filter a board
on load, reusing the existing Filter sidebar object/API rather than a new
filtering engine - useful for embedding a pre-filtered board view in another
tool (e.g. an iframe). Query params are parsed once, when the board
subscription becomes ready (client/components/boards/boardBody.js), and
resolved to member/assignee ids by username and to label ids by name via
client/lib/filterQueryParams.js, a pure module covered by
tests/filterQueryParams4540.test.cjs. This only reads params on load; it does
not sync the URL back as filters change.

Thanks to thrademaker and xet7 !

Closes #4540,
This commit is contained in:
Lauri Ojansivu committed 2026-09-10 19:38:53 +03:00
1 parent 187471e661
commit 963b01ff5b
3 files changed
+253

No files matched your search

+57
View File
@@ -20,6 +20,7 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { Filter } from '/client/lib/filter';
import { parseBoardFilterQueryParams } from '/client/lib/filterQueryParams';
import { migrationProgressManager } from '/client/components/settings/migrationProgress';
import { focusFirstControl } from '/client/lib/accessibility';
@@ -36,6 +37,59 @@ Template.board.onCreated(function () {
this.isConverting = new ReactiveVar(false);
this._swimlaneCreated = new Set(); // boards where a default swimlane was ensured
this._listRepairChecked = new Set(); // boards whose data-repair was checked
this._queryFiltersApplied = new Set(); // boards whose URL filter params were applied
// #4540: let a board's filter state be driven by URL query params, e.g.
// `?assignee=johndoe` or `?member=johndoe,janedoe&label=urgent`, so a link
// (for instance embedded in an iframe) opens the board already filtered.
// Reuses the existing `Filter` sidebar state/API - this only translates the
// query params into the same `Filter.assignees` / `Filter.members` /
// `Filter.labelIds` calls the sidebar UI would make. Applied once per board
// load (guarded by `_queryFiltersApplied`), reading the query params once
// rather than reactively - this is a one-time "open pre-filtered" bridge,
// not two-way URL sync.
this.applyQueryParamFilters = (boardId) => {
if (!boardId || this._queryFiltersApplied.has(boardId)) {
return;
}
this._queryFiltersApplied.add(boardId);
const assigneeParam = FlowRouter.getQueryParam('assignee');
const memberParam = FlowRouter.getQueryParam('member');
const labelParam = FlowRouter.getQueryParam('label');
if (!assigneeParam && !memberParam && !labelParam) {
return;
}
const board = ReactiveCache.getBoard(boardId);
if (!board) {
return;
}
// Collect candidate usernames from both params to look up in one query.
const usernames = new Set();
(assigneeParam || '')
.split(',')
.concat((memberParam || '').split(','))
.map(u => u.trim())
.filter(Boolean)
.forEach(u => usernames.add(u));
const users = ReactiveCache.getUsers(
{ username: { $in: Array.from(usernames) } },
{ fields: { _id: 1, username: 1 } },
) || [];
const { assigneeIds, memberIds, labelIds } = parseBoardFilterQueryParams(
{ assignee: assigneeParam, member: memberParam, label: labelParam },
users,
board.labels,
);
assigneeIds.forEach(id => Filter.assignees.add(id));
memberIds.forEach(id => Filter.members.add(id));
labelIds.forEach(id => Filter.labelIds.add(id));
};
// When a board opens, detect whether it needs the shared data-repairs (the same
// set run during the MongoDB <-> SQLite migration): #6484 lists wrongly bound to
@@ -148,6 +202,9 @@ Template.board.onCreated(function () {
Tracker.nonreactive(() => this.ensureDefaultSwimlane(currentBoardId));
// Also detect + run the shared board data-repairs once the board is ready.
Tracker.nonreactive(() => this.maybeRepairBoard(currentBoardId));
// #4540: apply any ?assignee=/?member=/?label= URL filter params now
// that the board and its members/labels are loaded.
Tracker.nonreactive(() => this.applyQueryParamFilters(currentBoardId));
}
});
});
+71
View File
@@ -0,0 +1,71 @@
// #4540: let a board's filter state be driven by URL query parameters, e.g.
// `?assignee=johndoe&member=janedoe&label=urgent`, so a link (for instance
// embedded in an iframe) opens the board already filtered. Kept as pure
// functions (no Meteor/Tracker/DOM access) so they can be unit tested and
// reused from the board route without depending on subscriptions being ready.
// Splits a query param value such as "johndoe,janedoe" (also accepting
// repeated whitespace) into a trimmed, de-duplicated, non-empty list.
export function parseListParam(paramValue) {
if (!paramValue || typeof paramValue !== 'string') {
return [];
}
const seen = new Set();
const result = [];
paramValue
.split(',')
.map(part => part.trim())
.filter(Boolean)
.forEach(part => {
if (!seen.has(part)) {
seen.add(part);
result.push(part);
}
});
return result;
}
// Resolves a list of usernames to user `_id`s using the provided list of user
// documents ({ _id, username }). Unknown usernames are silently dropped so a
// typo in the URL never throws - it just filters to nothing extra.
export function resolveUsernamesToIds(usernames, users) {
const byUsername = new Map(
(users || [])
.filter(user => user && user.username)
.map(user => [user.username, user._id]),
);
return (usernames || [])
.map(username => byUsername.get(username))
.filter(Boolean);
}
// Resolves a list of label names (case-insensitive) to label `_id`s using the
// board's own labels array ({ _id, name, color }).
export function resolveLabelNamesToIds(labelNames, labels) {
const byName = new Map(
(labels || [])
.filter(label => label && label.name)
.map(label => [label.name.toLowerCase(), label._id]),
);
return (labelNames || [])
.map(name => byName.get(String(name).toLowerCase()))
.filter(Boolean);
}
// Parses the supported board-filter query params into the shape the `Filter`
// object needs: arrays of member ids, assignee ids and label ids. `queryParams`
// is a plain object such as `{ assignee: 'johndoe', member: 'a,b', label: 'x' }`
// (as returned by FlowRouter.getQueryParam for each key), `users` is the list
// of candidate user docs ({ _id, username }) and `labels` is the board's
// labels array ({ _id, name, color }).
export function parseBoardFilterQueryParams(queryParams, users, labels) {
const assigneeUsernames = parseListParam(queryParams && queryParams.assignee);
const memberUsernames = parseListParam(queryParams && queryParams.member);
const labelNames = parseListParam(queryParams && queryParams.label);
return {
assigneeIds: resolveUsernamesToIds(assigneeUsernames, users),
memberIds: resolveUsernamesToIds(memberUsernames, users),
labelIds: resolveLabelNamesToIds(labelNames, labels),
};
}
+125
View File
@@ -0,0 +1,125 @@
'use strict';
// #4540: a board's filter state can be driven by URL query params, e.g.
// `?assignee=johndoe&member=janedoe&label=urgent`. This pins the pure
// param-parsing/resolution logic in client/lib/filterQueryParams.js, which
// client/components/boards/boardBody.js wires into the existing `Filter`
// object once the board is ready.
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
(async () => {
const modUrl = pathToFileURL(
path.join(__dirname, '..', 'client', 'lib', 'filterQueryParams.js'),
).href;
const {
parseListParam,
resolveUsernamesToIds,
resolveLabelNamesToIds,
parseBoardFilterQueryParams,
} = await import(modUrl);
// parseListParam: splits, trims, drops empties, de-duplicates.
assert.deepEqual(parseListParam('johndoe'), ['johndoe']);
assert.deepEqual(parseListParam(' johndoe , janedoe ,,johndoe'), [
'johndoe',
'janedoe',
]);
assert.deepEqual(parseListParam(''), []);
assert.deepEqual(parseListParam(undefined), []);
assert.deepEqual(parseListParam(null), []);
// resolveUsernamesToIds: matches by username, silently drops unknowns.
const users = [
{ _id: 'u1', username: 'johndoe' },
{ _id: 'u2', username: 'janedoe' },
];
assert.deepEqual(resolveUsernamesToIds(['johndoe', 'janedoe'], users), [
'u1',
'u2',
]);
assert.deepEqual(resolveUsernamesToIds(['johndoe', 'nobody'], users), [
'u1',
]);
assert.deepEqual(resolveUsernamesToIds([], users), []);
assert.deepEqual(resolveUsernamesToIds(['johndoe'], []), []);
// resolveLabelNamesToIds: matches by name, case-insensitively.
const labels = [
{ _id: 'l1', name: 'Urgent', color: 'red' },
{ _id: 'l2', name: 'Later', color: 'blue' },
];
assert.deepEqual(resolveLabelNamesToIds(['urgent'], labels), ['l1']);
assert.deepEqual(resolveLabelNamesToIds(['URGENT', 'later'], labels), [
'l1',
'l2',
]);
assert.deepEqual(resolveLabelNamesToIds(['missing'], labels), []);
// parseBoardFilterQueryParams: the end-to-end shape Filter needs.
const result = parseBoardFilterQueryParams(
{ assignee: 'johndoe', member: 'janedoe,johndoe', label: 'Urgent' },
users,
labels,
);
assert.deepEqual(result.assigneeIds, ['u1']);
assert.deepEqual(result.memberIds, ['u2', 'u1']);
assert.deepEqual(result.labelIds, ['l1']);
// Missing/empty query params resolve to empty arrays, never throw.
const empty = parseBoardFilterQueryParams({}, users, labels);
assert.deepEqual(empty, { assigneeIds: [], memberIds: [], labelIds: [] });
const emptyNoArgs = parseBoardFilterQueryParams(undefined, undefined, undefined);
assert.deepEqual(emptyNoArgs, {
assigneeIds: [],
memberIds: [],
labelIds: [],
});
// boardBody.js must actually wire this into the existing Filter object
// rather than a new filtering engine, and must apply it once the board's
// subscription is ready (not eagerly in the route, before members/labels
// are loaded).
const fs = require('node:fs');
const boardBody = fs.readFileSync(
path.join(__dirname, '..', 'client', 'components', 'boards', 'boardBody.js'),
'utf8',
);
assert.match(
boardBody,
/import \{ parseBoardFilterQueryParams \} from '\/client\/lib\/filterQueryParams';/,
'boardBody.js must import the pure param-parsing helper',
);
assert.match(
boardBody,
/applyQueryParamFilters = \(boardId\) => \{/,
'boardBody.js must define applyQueryParamFilters',
);
assert.match(
boardBody,
/Filter\.assignees\.add\(id\)/,
'resolved assignee ids must be applied through the existing Filter.assignees API',
);
assert.match(
boardBody,
/Filter\.members\.add\(id\)/,
'resolved member ids must be applied through the existing Filter.members API',
);
assert.match(
boardBody,
/Filter\.labelIds\.add\(id\)/,
'resolved label ids must be applied through the existing Filter.labelIds API',
);
assert.match(
boardBody,
/Tracker\.nonreactive\(\(\) => this\.applyQueryParamFilters\(currentBoardId\)\);/,
'the filters must be applied once the board subscription is ready, guarded like the other once-per-board hooks',
);
console.log('filterQueryParams4540: URL query param filter parsing passed');
})().catch(err => {
console.error(err);
process.exit(1);
});