Files
wekan/client/lib/filterQueryParams.js
Lauri Ojansivu 5ffecf7aab Sync the board Filter sidebar's state back into the URL query params.
#4540 (963b01ff5) already lets a board be opened pre-filtered from
`?assignee=johndoe`/`?member=janedoe`/`?label=urgent`, read once on board
load. #319 additionally asked for the other direction: applying a filter
from the sidebar should update the URL too, so a manually filtered view is
itself bookmarkable/shareable without hand-typing the query string.

Adds `client/lib/filterQueryParams.js`'s `resolveIdsToUsernames`,
`resolveIdsToLabelNames` and `buildBoardFilterQueryParams` - the exact
inverse of the existing parser, reusing the same `assignee`/`member`/`label`
token vocabulary - and a reactive `list()` getter on `SetFilter`
(`client/lib/filter.js`) so its current selection can be read outside the
sidebar template. `boardBody.js`'s new `syncFilterQueryParams`, run from a
separate `Tracker.autorun`, resolves `Filter.assignees`/`members`/`labelIds`
back to names and writes them via `FlowRouter.setQueryParams` wrapped in
`FlowRouter.withReplaceState`, so toggling a filter replaces the current
history entry instead of piling up a new one per click.

Covered by tests/filterQueryParams319.test.cjs: the id->name resolvers, the
query-param builder (including empty selections clearing their param and
unknown ids being dropped), a full round trip through #4540's own
`parseBoardFilterQueryParams` proving both directions agree, and source
checks pinning how boardBody.js wires it up.

Thanks to netei and xet7 !

Closes #319,
2026-09-10 21:11:34 +03:00

119 lines
4.7 KiB
JavaScript

// #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);
}
// #319: the inverse of the parsing below - given the *ids* the `Filter`
// sidebar currently holds, resolves them back to the usernames/label names
// the query-param vocabulary uses, so an interactively filtered board can
// write its own bookmarkable/shareable URL. Kept alongside the parser so the
// two directions can never drift into different token formats.
export function resolveIdsToUsernames(ids, users) {
const byId = new Map(
(users || [])
.filter(user => user && user._id)
.map(user => [user._id, user.username]),
);
return (ids || []).map(id => byId.get(id)).filter(Boolean);
}
export function resolveIdsToLabelNames(ids, labels) {
const byId = new Map(
(labels || [])
.filter(label => label && label._id)
.map(label => [label._id, label.name]),
);
return (ids || []).map(id => byId.get(id)).filter(Boolean);
}
// Builds the `{ assignee, member, label }` query-param object (comma-joined,
// same vocabulary `parseBoardFilterQueryParams` reads) from the board
// filter's current id-based state. `assigneeIds`/`memberIds`/`labelIds` come
// from `Filter.assignees.list()` / `Filter.members.list()` /
// `Filter.labelIds.list()`; `users`/`labels` resolve them back to names. A
// param whose list is empty is set to `null` so the caller can pass the
// result straight to `FlowRouter.setQueryParams`, which removes any key
// whose value is `null`/`undefined`.
export function buildBoardFilterQueryParams(
{ assigneeIds, memberIds, labelIds },
users,
labels,
) {
const assigneeUsernames = resolveIdsToUsernames(assigneeIds, users);
const memberUsernames = resolveIdsToUsernames(memberIds, users);
const labelNames = resolveIdsToLabelNames(labelIds, labels);
return {
assignee: assigneeUsernames.length ? assigneeUsernames.join(',') : null,
member: memberUsernames.length ? memberUsernames.join(',') : null,
label: labelNames.length ? labelNames.join(',') : null,
};
}
// 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),
};
}