The board now filters by text, type, priority and unassigned tickets as well. Any combination can be saved as a named view, private or shared with the project, and opened again with one click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
import {
|
|
BoardColumn,
|
|
BoardFilter,
|
|
IssueSummary,
|
|
} from '@task-stream/web/tracker/domain';
|
|
|
|
/** Pure board transformations (unit tested). */
|
|
|
|
export function findIssue(
|
|
columns: BoardColumn[],
|
|
issueId: string,
|
|
): IssueSummary | undefined {
|
|
for (const column of columns) {
|
|
const issue = column.issues.find((i) => i.id === issueId);
|
|
if (issue) return issue;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function matchesFilter(
|
|
issue: IssueSummary,
|
|
filter: BoardFilter | null,
|
|
): boolean {
|
|
if (!filter) return true;
|
|
if (filter.unassigned) {
|
|
if (issue.assignee) return false;
|
|
} else if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
|
|
return false;
|
|
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
|
|
if (filter.area && issue.area !== filter.area) return false;
|
|
if (filter.types?.length && !filter.types.includes(issue.type)) return false;
|
|
if (filter.priorities?.length && !filter.priorities.includes(issue.priority))
|
|
return false;
|
|
if (
|
|
filter.labels?.length &&
|
|
!filter.labels.every((l) => issue.labels.includes(l.toLowerCase()))
|
|
)
|
|
return false;
|
|
// Cards carry no description: a change that matched only by it drops off
|
|
// until the next load, which is the safe side.
|
|
const text = filter.search?.trim().toLowerCase();
|
|
if (
|
|
text &&
|
|
!issue.key.toLowerCase().includes(text) &&
|
|
!issue.title.toLowerCase().includes(text)
|
|
)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/** `issues` (in backlog order) with `issue` at its place by rank. */
|
|
export function insertByRank(
|
|
issues: IssueSummary[],
|
|
issue: IssueSummary,
|
|
): IssueSummary[] {
|
|
const at = issues.findIndex((i) => i.rank > issue.rank);
|
|
return at === -1
|
|
? [...issues, issue]
|
|
: [...issues.slice(0, at), issue, ...issues.slice(at)];
|
|
}
|
|
|
|
/** Removes the issue wherever it is and puts `next` (if any) at its place by rank in its status column. */
|
|
export function placeIssue(
|
|
columns: BoardColumn[],
|
|
issueId: string,
|
|
next: IssueSummary | null,
|
|
): BoardColumn[] {
|
|
return columns.map((column) => {
|
|
const had = column.issues.some((i) => i.id === issueId);
|
|
let issues = had
|
|
? column.issues.filter((i) => i.id !== issueId)
|
|
: column.issues;
|
|
let total = column.total - (had ? 1 : 0);
|
|
if (next && next.status === column.status) {
|
|
issues = insertByRank(issues, next);
|
|
total += 1;
|
|
}
|
|
return had || issues !== column.issues
|
|
? { ...column, issues, total }
|
|
: column;
|
|
});
|
|
}
|
|
|
|
/** Optimistic move. */
|
|
export function moveIssue(
|
|
columns: BoardColumn[],
|
|
issueId: string,
|
|
to: string,
|
|
): BoardColumn[] {
|
|
const issue = findIssue(columns, issueId);
|
|
return issue
|
|
? placeIssue(columns, issueId, { ...issue, status: to })
|
|
: columns;
|
|
}
|
|
|
|
/** Merges a realtime change, ignoring stale versions and issues outside the filter. */
|
|
export function applyIssueChange(
|
|
columns: BoardColumn[],
|
|
issue: IssueSummary,
|
|
filter: BoardFilter | null,
|
|
): BoardColumn[] {
|
|
const current = findIssue(columns, issue.id);
|
|
if (current && current.version > issue.version) return columns;
|
|
return placeIssue(
|
|
columns,
|
|
issue.id,
|
|
matchesFilter(issue, filter) ? issue : null,
|
|
);
|
|
}
|