mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
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:
1 parent
187471e661
commit
963b01ff5b
3 files changed
+253
No files matched your search
@@ -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));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user