mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(tasks): reassign tags when dragging between tag groups (#8638)
* refactor(boards): extract doesTaskMatchPanel membership predicate Pull the board-panel column-membership filter out of the component's inline .filter() into a pure, exported doesTaskMatchPanel() in boards.util, the companion to rewriteTagIdsForPanel (the two encode the same tag rules). The component now delegates to it, hoisting the backlog predicate so it's allocated once per recompute rather than once per task. The predicate is a required argument since backlog membership derives from project state. * feat(tasks): reassign tags when dragging between tag groups In the grouped-by-tag work view, dropping a task into a different tag group now reassigns its tags instead of only reordering: - onto another tag group → move (drop the source group's tag, add the target's); - onto the "No tag" bucket → clear all of the task's tags; - onto the "Unknown tag" / ambiguous (duplicate-title) buckets, or reordering within a group → unchanged (falls through to the existing reorder). The customizer emits a group-title -> tagId map (the NO_TAG_GROUP_ID sentinel for the No-tag bucket, null for un-retaggable buckets), derived from a single per-title metadata source shared with the group-ordering map so the two can't drift. task-list intercepts a cross-group drop and dispatches updateTags. * test(tasks): de-flake planner add-subtask-from-detail e2e The detail panel's deferred open-time _focusFirst() (~delay(50) + a 150ms guarded timeout) can land after the inline add-subtask draft opens, steal focus from it, and trip its blur-to-close — so the draft input renders then vanishes and '.e2e-add-subtask-input' is never seen (a load-dependent flake, ~1/30 under contention). Wait for the panel's open-time auto-focus to settle before driving the draft, mirroring add-subtask-with-detail-panel-open.spec.ts. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
parent
a1c059855e
commit
ba2f77804b
9 changed files with 658 additions and 72 deletions
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from '../boards.model';
|
||||
import {
|
||||
buildComparator,
|
||||
doesTaskMatchPanel,
|
||||
firstSpecificProjectId,
|
||||
isAllProjects,
|
||||
rewriteTagIdsForPanel,
|
||||
|
|
@ -158,62 +159,12 @@ export class BoardPanelComponent {
|
|||
const orderedTasks: TaskCopy[] = [];
|
||||
const nonOrderedTasks: TaskCopy[] = [];
|
||||
|
||||
const allFilteredTasks = this.allTasks().filter((task) => {
|
||||
let isTaskIncluded = true;
|
||||
const taskTagIds = task.tagIds ?? [];
|
||||
if (panelCfg.includedTagIds?.length) {
|
||||
isTaskIncluded =
|
||||
panelCfg.includedTagsMatch === 'any'
|
||||
? panelCfg.includedTagIds.some((tagId) => taskTagIds.includes(tagId))
|
||||
: panelCfg.includedTagIds.every((tagId) => taskTagIds.includes(tagId));
|
||||
}
|
||||
if (panelCfg.excludedTagIds?.length) {
|
||||
const hit =
|
||||
panelCfg.excludedTagsMatch === 'all'
|
||||
? panelCfg.excludedTagIds.every((tagId) => taskTagIds.includes(tagId))
|
||||
: panelCfg.excludedTagIds.some((tagId) => taskTagIds.includes(tagId));
|
||||
isTaskIncluded = isTaskIncluded && !hit;
|
||||
}
|
||||
|
||||
if (panelCfg.isParentTasksOnly) {
|
||||
isTaskIncluded = isTaskIncluded && !task.parentId;
|
||||
}
|
||||
|
||||
if (panelCfg.taskDoneState === BoardPanelCfgTaskDoneState.Done) {
|
||||
isTaskIncluded = isTaskIncluded && task.isDone;
|
||||
}
|
||||
|
||||
if (panelCfg.taskDoneState === BoardPanelCfgTaskDoneState.UnDone) {
|
||||
isTaskIncluded = isTaskIncluded && !task.isDone;
|
||||
}
|
||||
|
||||
if (
|
||||
panelCfg.projectIds &&
|
||||
panelCfg.projectIds.length > 0 &&
|
||||
!isAllProjects(panelCfg.projectIds)
|
||||
) {
|
||||
// TODO check parentId case thoroughly
|
||||
isTaskIncluded = isTaskIncluded && panelCfg.projectIds.includes(task.projectId);
|
||||
}
|
||||
|
||||
if (panelCfg.scheduledState === BoardPanelCfgScheduledState.Scheduled) {
|
||||
isTaskIncluded = isTaskIncluded && !!(task.dueWithTime || task.dueDay);
|
||||
}
|
||||
|
||||
if (panelCfg.scheduledState === BoardPanelCfgScheduledState.NotScheduled) {
|
||||
isTaskIncluded = isTaskIncluded && !task.dueWithTime && !task.dueDay;
|
||||
}
|
||||
|
||||
if (panelCfg.backlogState === BoardPanelCfgTaskTypeFilter.OnlyBacklog) {
|
||||
isTaskIncluded = isTaskIncluded && this._isTaskInBacklog(task);
|
||||
}
|
||||
|
||||
if (panelCfg.backlogState === BoardPanelCfgTaskTypeFilter.NoBacklog) {
|
||||
isTaskIncluded = isTaskIncluded && !this._isTaskInBacklog(task);
|
||||
}
|
||||
|
||||
return isTaskIncluded;
|
||||
});
|
||||
// Hoist the backlog predicate out of the filter callback so it's allocated
|
||||
// once per recompute, not once per task.
|
||||
const isInBacklog = (t: Readonly<TaskCopy>): boolean => this._isTaskInBacklog(t);
|
||||
const allFilteredTasks = this.allTasks().filter((task) =>
|
||||
doesTaskMatchPanel(task, panelCfg, isInBacklog),
|
||||
);
|
||||
|
||||
allFilteredTasks.forEach((task) => {
|
||||
const index = panelCfg.taskIds.indexOf(task.id);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { buildComparator, rewriteTagIdsForPanel, sanitizePanelCfg } from './boards.util';
|
||||
import { BoardPanelCfg } from './boards.model';
|
||||
import {
|
||||
buildComparator,
|
||||
doesTaskMatchPanel,
|
||||
rewriteTagIdsForPanel,
|
||||
sanitizePanelCfg,
|
||||
} from './boards.util';
|
||||
import {
|
||||
BoardPanelCfg,
|
||||
BoardPanelCfgScheduledState,
|
||||
BoardPanelCfgTaskDoneState,
|
||||
BoardPanelCfgTaskTypeFilter,
|
||||
} from './boards.model';
|
||||
import { TaskCopy } from '../tasks/task.model';
|
||||
|
||||
const basePanel: any = {
|
||||
|
|
@ -324,3 +334,155 @@ describe('rewriteTagIdsForPanel', () => {
|
|||
expect(tags).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesTaskMatchPanel', () => {
|
||||
const mkPanel = (overrides: Partial<BoardPanelCfg> = {}): BoardPanelCfg =>
|
||||
({ ...basePanel, ...overrides }) as BoardPanelCfg;
|
||||
|
||||
const mkTask = (partial: Partial<TaskCopy> = {}): TaskCopy =>
|
||||
({ id: 't', title: '', tagIds: [], projectId: 'INBOX', ...partial }) as TaskCopy;
|
||||
|
||||
// Most criteria don't involve backlog; default the (required) predicate to
|
||||
// "nothing is in backlog" so those cases stay terse.
|
||||
const noBacklog = (): boolean => false;
|
||||
const match = (
|
||||
task: TaskCopy,
|
||||
panel: BoardPanelCfg,
|
||||
isInBacklog: (t: Readonly<TaskCopy>) => boolean = noBacklog,
|
||||
): boolean => doesTaskMatchPanel(task, panel, isInBacklog);
|
||||
|
||||
it('matches any task when no criteria are set', () => {
|
||||
expect(match(mkTask(), mkPanel())).toBe(true);
|
||||
});
|
||||
|
||||
describe('included tags', () => {
|
||||
it('default ("all"): requires every included tag', () => {
|
||||
const panel = mkPanel({ includedTagIds: ['a', 'b'] });
|
||||
expect(match(mkTask({ tagIds: ['a', 'b', 'c'] }), panel)).toBe(true);
|
||||
expect(match(mkTask({ tagIds: ['a'] }), panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('"any": requires at least one included tag', () => {
|
||||
const panel = mkPanel({ includedTagIds: ['a', 'b'], includedTagsMatch: 'any' });
|
||||
expect(match(mkTask({ tagIds: ['b'] }), panel)).toBe(true);
|
||||
expect(match(mkTask({ tagIds: ['x'] }), panel)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('excluded tags', () => {
|
||||
it('default ("any"): excludes when any excluded tag is present', () => {
|
||||
const panel = mkPanel({ excludedTagIds: ['a', 'b'] });
|
||||
expect(match(mkTask({ tagIds: ['a'] }), panel)).toBe(false);
|
||||
expect(match(mkTask({ tagIds: ['x'] }), panel)).toBe(true);
|
||||
});
|
||||
|
||||
it('"all": excludes only when every excluded tag is present', () => {
|
||||
const panel = mkPanel({ excludedTagIds: ['a', 'b'], excludedTagsMatch: 'all' });
|
||||
expect(match(mkTask({ tagIds: ['a', 'b'] }), panel)).toBe(false);
|
||||
expect(match(mkTask({ tagIds: ['a'] }), panel)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('isParentTasksOnly: excludes sub-tasks', () => {
|
||||
const panel = mkPanel({ isParentTasksOnly: true });
|
||||
expect(match(mkTask({ parentId: undefined }), panel)).toBe(true);
|
||||
expect(match(mkTask({ parentId: 'parent' }), panel)).toBe(false);
|
||||
});
|
||||
|
||||
describe('taskDoneState', () => {
|
||||
it('Done: requires isDone', () => {
|
||||
const panel = mkPanel({ taskDoneState: BoardPanelCfgTaskDoneState.Done });
|
||||
expect(match(mkTask({ isDone: true }), panel)).toBe(true);
|
||||
expect(match(mkTask({ isDone: false }), panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('UnDone: requires not isDone', () => {
|
||||
const panel = mkPanel({ taskDoneState: BoardPanelCfgTaskDoneState.UnDone });
|
||||
expect(match(mkTask({ isDone: false }), panel)).toBe(true);
|
||||
expect(match(mkTask({ isDone: true }), panel)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectIds', () => {
|
||||
it('All Projects ([""]): matches any project', () => {
|
||||
const panel = mkPanel({ projectIds: [''] });
|
||||
expect(match(mkTask({ projectId: 'p1' }), panel)).toBe(true);
|
||||
});
|
||||
|
||||
it('specific: matches only the listed projects', () => {
|
||||
const panel = mkPanel({ projectIds: ['p1'] });
|
||||
expect(match(mkTask({ projectId: 'p1' }), panel)).toBe(true);
|
||||
expect(match(mkTask({ projectId: 'p2' }), panel)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduledState', () => {
|
||||
it('Scheduled: requires a due date', () => {
|
||||
const panel = mkPanel({ scheduledState: BoardPanelCfgScheduledState.Scheduled });
|
||||
expect(match(mkTask({ dueDay: '2026-01-01' }), panel)).toBe(true);
|
||||
expect(match(mkTask(), panel)).toBe(false);
|
||||
});
|
||||
|
||||
it('NotScheduled: requires no due date', () => {
|
||||
const panel = mkPanel({ scheduledState: BoardPanelCfgScheduledState.NotScheduled });
|
||||
expect(match(mkTask(), panel)).toBe(true);
|
||||
expect(match(mkTask({ dueWithTime: 123 }), panel)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backlogState', () => {
|
||||
const isInBacklog = (t: Readonly<TaskCopy>): boolean => t.id === 'backlogged';
|
||||
|
||||
it('OnlyBacklog: keeps only backlog tasks', () => {
|
||||
const panel = mkPanel({ backlogState: BoardPanelCfgTaskTypeFilter.OnlyBacklog });
|
||||
expect(match(mkTask({ id: 'backlogged' }), panel, isInBacklog)).toBe(true);
|
||||
expect(match(mkTask({ id: 'regular' }), panel, isInBacklog)).toBe(false);
|
||||
});
|
||||
|
||||
it('NoBacklog: drops backlog tasks', () => {
|
||||
const panel = mkPanel({ backlogState: BoardPanelCfgTaskTypeFilter.NoBacklog });
|
||||
expect(match(mkTask({ id: 'regular' }), panel, isInBacklog)).toBe(true);
|
||||
expect(match(mkTask({ id: 'backlogged' }), panel, isInBacklog)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('combines multiple criteria (AND across dimensions)', () => {
|
||||
const panel = mkPanel({
|
||||
includedTagIds: ['a'],
|
||||
excludedTagIds: ['x'],
|
||||
taskDoneState: BoardPanelCfgTaskDoneState.UnDone,
|
||||
projectIds: ['p1'],
|
||||
});
|
||||
expect(match(mkTask({ tagIds: ['a'], isDone: false, projectId: 'p1' }), panel)).toBe(
|
||||
true,
|
||||
);
|
||||
// fails the exclude dimension only
|
||||
expect(
|
||||
match(mkTask({ tagIds: ['a', 'x'], isDone: false, projectId: 'p1' }), panel),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('ANDs every dimension, including scheduled + backlog', () => {
|
||||
const inBacklog = (t: Readonly<TaskCopy>): boolean => t.id === 'b';
|
||||
const panel = mkPanel({
|
||||
includedTagIds: ['a'],
|
||||
excludedTagIds: ['x'],
|
||||
taskDoneState: BoardPanelCfgTaskDoneState.UnDone,
|
||||
projectIds: ['p1'],
|
||||
scheduledState: BoardPanelCfgScheduledState.Scheduled,
|
||||
backlogState: BoardPanelCfgTaskTypeFilter.OnlyBacklog,
|
||||
});
|
||||
const matching = mkTask({
|
||||
id: 'b',
|
||||
tagIds: ['a'],
|
||||
isDone: false,
|
||||
projectId: 'p1',
|
||||
dueDay: '2026-01-01',
|
||||
});
|
||||
expect(doesTaskMatchPanel(matching, panel, inBacklog)).toBe(true);
|
||||
// identical except it fails ONLY the backlog dimension
|
||||
expect(doesTaskMatchPanel({ ...matching, id: 'not-b' }, panel, inBacklog)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { BoardPanelCfg, BoardSortField } from './boards.model';
|
||||
import {
|
||||
BoardPanelCfg,
|
||||
BoardPanelCfgScheduledState,
|
||||
BoardPanelCfgTaskDoneState,
|
||||
BoardPanelCfgTaskTypeFilter,
|
||||
BoardSortField,
|
||||
} from './boards.model';
|
||||
import { TaskCopy } from '../tasks/task.model';
|
||||
import { dateStrToUtcDate } from '../../util/date-str-to-utc-date';
|
||||
|
||||
|
|
@ -150,6 +156,98 @@ export const rewriteTagIdsForPanel = (
|
|||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure membership predicate: does `task` belong in a panel/column with the
|
||||
* given criteria? Companion to `rewriteTagIdsForPanel` (which rewrites a task's
|
||||
* tags TO match) — the two encode the same tag rules and must stay in sync.
|
||||
*
|
||||
* `isInBacklog` is supplied by the caller because backlog membership derives
|
||||
* from project state, not from the task itself. It is only consulted when
|
||||
* `panelCfg.backlogState` requests backlog filtering.
|
||||
*/
|
||||
export const doesTaskMatchPanel = (
|
||||
task: Readonly<TaskCopy>,
|
||||
panelCfg: Pick<
|
||||
BoardPanelCfg,
|
||||
| 'includedTagIds'
|
||||
| 'includedTagsMatch'
|
||||
| 'excludedTagIds'
|
||||
| 'excludedTagsMatch'
|
||||
| 'isParentTasksOnly'
|
||||
| 'taskDoneState'
|
||||
| 'projectIds'
|
||||
| 'scheduledState'
|
||||
| 'backlogState'
|
||||
>,
|
||||
isInBacklog: (task: Readonly<TaskCopy>) => boolean,
|
||||
): boolean => {
|
||||
const taskTagIds = task.tagIds ?? [];
|
||||
|
||||
if (panelCfg.includedTagIds?.length) {
|
||||
const matches =
|
||||
panelCfg.includedTagsMatch === 'any'
|
||||
? panelCfg.includedTagIds.some((tagId) => taskTagIds.includes(tagId))
|
||||
: panelCfg.includedTagIds.every((tagId) => taskTagIds.includes(tagId));
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
if (panelCfg.excludedTagIds?.length) {
|
||||
const hit =
|
||||
panelCfg.excludedTagsMatch === 'all'
|
||||
? panelCfg.excludedTagIds.every((tagId) => taskTagIds.includes(tagId))
|
||||
: panelCfg.excludedTagIds.some((tagId) => taskTagIds.includes(tagId));
|
||||
if (hit) return false;
|
||||
}
|
||||
|
||||
if (panelCfg.isParentTasksOnly && task.parentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (panelCfg.taskDoneState === BoardPanelCfgTaskDoneState.Done && !task.isDone) {
|
||||
return false;
|
||||
}
|
||||
if (panelCfg.taskDoneState === BoardPanelCfgTaskDoneState.UnDone && task.isDone) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
panelCfg.projectIds &&
|
||||
panelCfg.projectIds.length > 0 &&
|
||||
!isAllProjects(panelCfg.projectIds) &&
|
||||
!panelCfg.projectIds.includes(task.projectId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
panelCfg.scheduledState === BoardPanelCfgScheduledState.Scheduled &&
|
||||
!(task.dueWithTime || task.dueDay)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
panelCfg.scheduledState === BoardPanelCfgScheduledState.NotScheduled &&
|
||||
(task.dueWithTime || task.dueDay)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
panelCfg.backlogState === BoardPanelCfgTaskTypeFilter.OnlyBacklog &&
|
||||
!isInBacklog(task)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
panelCfg.backlogState === BoardPanelCfgTaskTypeFilter.NoBacklog &&
|
||||
isInBacklog(task)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns an ascending comparator for the given field. Callers multiply by -1
|
||||
* for descending. Returns a no-op comparator for unknown fields, so an invalid
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
FilterOption,
|
||||
GROUP_OPTION_TYPE,
|
||||
GroupOption,
|
||||
NO_TAG_GROUP_ID,
|
||||
SORT_OPTION_TYPE,
|
||||
SORT_ORDER,
|
||||
SortOption,
|
||||
|
|
@ -682,6 +683,60 @@ describe('TaskViewCustomizerService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('_buildGroupTagIdByKey (drag-to-retag mapping)', () => {
|
||||
// Build a grouped record dynamically: bucket keys are tag titles, which
|
||||
// contain spaces and so can't be object-literal property names under the
|
||||
// naming-convention lint rule.
|
||||
const groupedOf = (...keys: string[]): Record<string, TaskWithSubTasks[]> =>
|
||||
Object.fromEntries(keys.map((k) => [k, []]));
|
||||
|
||||
const build = (
|
||||
grouped: Record<string, TaskWithSubTasks[]>,
|
||||
): Record<string, string | null> =>
|
||||
(
|
||||
service as unknown as {
|
||||
_buildGroupTagIdByKey: (
|
||||
g: Record<string, TaskWithSubTasks[]>,
|
||||
) => Record<string, string | null>;
|
||||
}
|
||||
)._buildGroupTagIdByKey(grouped);
|
||||
|
||||
it('maps each real tag-title group to its tagId', () => {
|
||||
(service as unknown as { _allTags: Tag[] })._allTags = mockTags;
|
||||
const res = build(groupedOf('Tag A', 'Tag B'));
|
||||
expect(res['Tag A']).toBe('Tag A');
|
||||
expect(res['Tag B']).toBe('Tag B');
|
||||
});
|
||||
|
||||
it('maps the No-tag bucket to the clear-tags sentinel and Unknown-tag to null', () => {
|
||||
(service as unknown as { _allTags: Tag[] })._allTags = mockTags;
|
||||
const res = build(groupedOf('Tag A', 'No tag', 'Unknown tag'));
|
||||
expect(res['Tag A']).toBe('Tag A');
|
||||
expect(res['No tag']).toBe(NO_TAG_GROUP_ID);
|
||||
expect(res['Unknown tag']).toBeNull();
|
||||
});
|
||||
|
||||
it('maps a title shared by multiple tags to null (ambiguous, cannot retag)', () => {
|
||||
(service as unknown as { _allTags: Tag[] })._allTags = [
|
||||
{ id: 'id1', title: 'Dup' } as Tag,
|
||||
{ id: 'id2', title: 'Dup' } as Tag,
|
||||
{ id: 'id3', title: 'Unique' } as Tag,
|
||||
];
|
||||
const res = build(groupedOf('Dup', 'Unique'));
|
||||
expect(res['Dup']).toBeNull();
|
||||
expect(res['Unique']).toBe('id3');
|
||||
});
|
||||
|
||||
it('prefers a real tag titled "No tag" over the clear-tags sentinel', () => {
|
||||
// Guards against silently clearing tags when a user names a tag "No tag".
|
||||
(service as unknown as { _allTags: Tag[] })._allTags = [
|
||||
{ id: 'real-no-tag', title: 'No tag' } as Tag,
|
||||
];
|
||||
const res = build(groupedOf('No tag'));
|
||||
expect(res['No tag']).toBe('real-no-tag');
|
||||
});
|
||||
});
|
||||
|
||||
// === DEADLINE FILTER ===
|
||||
|
||||
it('should filter by deadline today using deadlineDay', () => {
|
||||
|
|
@ -1451,6 +1506,9 @@ describe('TaskViewCustomizerService', () => {
|
|||
expect(Object.keys(result.grouped!)).toEqual(['Tag A']);
|
||||
expect(result.grouped!['Tag A']?.length).toBe(1);
|
||||
expect(result.grouped!['Tag A']?.[0].id).toBe('project-a-task');
|
||||
// Tag grouping emits the retag id-map.
|
||||
expect(result.groupTagIdByKey).toBeDefined();
|
||||
expect(result.groupTagIdByKey!['Tag A']).toBe('Tag A');
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
@ -1473,6 +1531,8 @@ describe('TaskViewCustomizerService', () => {
|
|||
const groupKeys = Object.keys(result.grouped!);
|
||||
expect(groupKeys).toEqual(['Project A']);
|
||||
expect(groupKeys).not.toContain('Project B');
|
||||
// The retag id-map is tag-grouping-only.
|
||||
expect(result.groupTagIdByKey).toBeUndefined();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
SORT_ORDER,
|
||||
FILTER_COMMON,
|
||||
OPTIONS,
|
||||
NO_TAG_GROUP_ID,
|
||||
} from './types';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { lsGetJSON, lsSetJSON } from '../../util/ls-util';
|
||||
|
|
@ -40,10 +41,22 @@ const GROUP_OPTIONS_NO_PROJECT = OPTIONS.group.list.filter(
|
|||
(opt) => opt.type !== GROUP_OPTION_TYPE.project,
|
||||
);
|
||||
|
||||
// Display keys for the two virtual tag-group buckets (kept as constants so the
|
||||
// grouping and the drag-to-retag id-map agree on the exact strings).
|
||||
const NO_TAG_GROUP_KEY = 'No tag';
|
||||
const UNKNOWN_TAG_GROUP_KEY = 'Unknown tag';
|
||||
|
||||
/** Result of {@link TaskViewCustomizerService.customizeUndoneTasks}. */
|
||||
export interface CustomizedUndoneTasks {
|
||||
list: TaskWithSubTasks[];
|
||||
grouped?: Record<string, TaskWithSubTasks[]>;
|
||||
/**
|
||||
* Tag grouping only: group-header title → its tagId, or `null` for buckets
|
||||
* with no single tag ('No tag', 'Unknown tag', or a title shared by several
|
||||
* tags). Drives drag-to-retag in the work view (dragging a task into another
|
||||
* tag group reassigns its tags). Absent for non-tag groupings.
|
||||
*/
|
||||
groupTagIdByKey?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
|
@ -215,8 +228,12 @@ export class TaskViewCustomizerService {
|
|||
const grouped = !isDefaultGroup
|
||||
? this.applyGrouping(sorted, group.type)
|
||||
: undefined;
|
||||
const groupTagIdByKey =
|
||||
grouped && group.type === GROUP_OPTION_TYPE.tag
|
||||
? this._buildGroupTagIdByKey(grouped)
|
||||
: undefined;
|
||||
|
||||
return { result: { list: sorted, grouped }, isDefault: false };
|
||||
return { result: { list: sorted, grouped, groupTagIdByKey }, isDefault: false };
|
||||
}),
|
||||
// Emit the default (uncustomized) list synchronously, but keep the
|
||||
// customized path on the animation-frame scheduler. The customized branch
|
||||
|
|
@ -457,18 +474,62 @@ export class TaskViewCustomizerService {
|
|||
}
|
||||
|
||||
/**
|
||||
* Tag title → lowest sidebar (menu-tree) index among tags with that title.
|
||||
* Duplicate-titled tags collapse to one slot, matching applyGrouping which
|
||||
* also keys its buckets by title - the two stay in agreement by construction.
|
||||
* Per-title metadata in sidebar (menu-tree) order — the single source for both
|
||||
* group ordering and the drag-to-retag id-map, so the two can't drift:
|
||||
* - `index`: lowest sidebar index among tags with that title (for ordering).
|
||||
* - `id`: the tagId, or `null` when several tags share the title (ambiguous,
|
||||
* so retag can't pick one).
|
||||
* Duplicate-titled tags collapse to one slot, matching {@link _groupByTag}
|
||||
* which also keys its buckets by title. TODAY is excluded by
|
||||
* {@link _tagsInSidebarOrder} (virtual membership, never a real tagId), so it
|
||||
* can never appear — see ARCHITECTURE-DECISIONS #2 / sync rule #5.
|
||||
*/
|
||||
private _getTagTitleOrderMap(): Map<string, number> {
|
||||
const titleOrder = new Map<string, number>();
|
||||
private _tagMetaByTitle(): Map<string, { id: string | null; index: number }> {
|
||||
const byTitle = new Map<string, { id: string | null; index: number }>();
|
||||
this._tagsInSidebarOrder().forEach((tag, index) => {
|
||||
if (!titleOrder.has(tag.title)) {
|
||||
titleOrder.set(tag.title, index);
|
||||
const existing = byTitle.get(tag.title);
|
||||
if (existing) {
|
||||
existing.id = null; // duplicate title → ambiguous
|
||||
} else {
|
||||
byTitle.set(tag.title, { id: tag.id, index });
|
||||
}
|
||||
});
|
||||
return titleOrder;
|
||||
return byTitle;
|
||||
}
|
||||
|
||||
private _getTagTitleOrderMap(): Map<string, number> {
|
||||
const order = new Map<string, number>();
|
||||
this._tagMetaByTitle().forEach((meta, title) => order.set(title, meta.index));
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each tag-group header title to its drag-to-retag target:
|
||||
* - a real tagId for a single-tag group,
|
||||
* - {@link NO_TAG_GROUP_ID} for the 'No tag' bucket (a drop there clears tags),
|
||||
* - `null` for the 'Unknown tag' bucket or a title shared by several tags
|
||||
* (ambiguous — can't be retagged).
|
||||
*/
|
||||
private _buildGroupTagIdByKey(
|
||||
grouped: Record<string, TaskWithSubTasks[]>,
|
||||
): Record<string, string | null> {
|
||||
const meta = this._tagMetaByTitle();
|
||||
const out: Record<string, string | null> = {};
|
||||
Object.keys(grouped).forEach((key) => {
|
||||
const realTag = meta.get(key);
|
||||
// A real tag owning this title wins (its id, or null when several tags
|
||||
// share it). This guards the case of a user tag literally titled "No tag":
|
||||
// dropping there adds that tag instead of hitting the clear-tags sentinel,
|
||||
// which is reserved for the genuine virtual untagged bucket.
|
||||
if (realTag) {
|
||||
out[key] = realTag.id;
|
||||
} else if (key === NO_TAG_GROUP_KEY) {
|
||||
out[key] = NO_TAG_GROUP_ID;
|
||||
} else {
|
||||
out[key] = null;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
private _groupByTag(tasks: TaskWithSubTasks[]): Record<string, TaskWithSubTasks[]> {
|
||||
|
|
@ -505,10 +566,10 @@ export class TaskViewCustomizerService {
|
|||
}
|
||||
});
|
||||
if (unknownTagTasks.length) {
|
||||
grouped['Unknown tag'] = unknownTagTasks;
|
||||
grouped[UNKNOWN_TAG_GROUP_KEY] = unknownTagTasks;
|
||||
}
|
||||
if (noTagTasks.length) {
|
||||
grouped['No tag'] = noTagTasks;
|
||||
grouped[NO_TAG_GROUP_KEY] = noTagTasks;
|
||||
}
|
||||
|
||||
return grouped;
|
||||
|
|
|
|||
|
|
@ -294,3 +294,11 @@ export const PRESETS = {
|
|||
deadline: deadlinePresets,
|
||||
time: timePresets,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sentinel `groupTagId` for the virtual "No tag" bucket in the grouped-by-tag
|
||||
* view: dropping a task here clears all of its tags. Distinct from `null`, which
|
||||
* marks the 'Unknown tag' / ambiguous (duplicate-title) buckets that can't be
|
||||
* retagged. Never persisted — only flows through the in-memory drop data.
|
||||
*/
|
||||
export const NO_TAG_GROUP_ID = 'NO_TAG_GROUP';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { TaskService } from '../task.service';
|
|||
import { WorkContextService } from '../../work-context/work-context.service';
|
||||
import { IssueService } from '../../issue/issue.service';
|
||||
import { TaskViewCustomizerService } from '../../task-view-customizer/task-view-customizer.service';
|
||||
import { NO_TAG_GROUP_ID } from '../../task-view-customizer/types';
|
||||
import { ScheduleExternalDragService } from '../../schedule/schedule-week/schedule-external-drag.service';
|
||||
import { DropListService } from '../../../core-ui/drop-list/drop-list.service';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
|
|
@ -13,6 +14,7 @@ import { of } from 'rxjs';
|
|||
import { TaskWithSubTasks } from '../task.model';
|
||||
import { SectionService } from '../../section/section.service';
|
||||
import { moveSubTask } from '../store/task.actions';
|
||||
import { moveTaskInTodayList } from '../../work-context/store/work-context-meta.actions';
|
||||
import { WorkContextType } from '../../work-context/work-context.model';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { Task } from '../task.model';
|
||||
|
|
@ -82,7 +84,11 @@ describe('TaskListComponent', () => {
|
|||
provideMockStore({ initialState: {} }),
|
||||
{
|
||||
provide: TaskService,
|
||||
useValue: { currentTaskId$: of(null) },
|
||||
useValue: {
|
||||
currentTaskId$: of(null),
|
||||
updateTags: jasmine.createSpy('updateTags'),
|
||||
addToToday: jasmine.createSpy('addToToday'),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkContextService,
|
||||
|
|
@ -839,4 +845,172 @@ describe('TaskListComponent', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Grouped-by-tag view: a task dragged into a DIFFERENT tag group has its tags
|
||||
// reassigned (move: drop the source group's tag, add the target's) instead of
|
||||
// being reordered. groupTagId is undefined outside that view, null for a
|
||||
// no-single-tag bucket ('No tag' / 'Unknown tag' / duplicate-titled).
|
||||
describe('drop() tag-group retag', () => {
|
||||
type GroupListData = {
|
||||
listId: 'PARENT' | 'SUB';
|
||||
listModelId: string;
|
||||
filteredTasks: { id: string }[];
|
||||
groupTagId?: string | null;
|
||||
};
|
||||
const dropEvent = (opts: {
|
||||
previous: GroupListData;
|
||||
target: GroupListData;
|
||||
dragged: MockDragTask;
|
||||
currentIndex?: number;
|
||||
}): Parameters<TaskListComponent['drop']>[0] =>
|
||||
({
|
||||
previousContainer: { data: opts.previous },
|
||||
container: { data: opts.target },
|
||||
item: { data: opts.dragged },
|
||||
previousIndex: 0,
|
||||
currentIndex: opts.currentIndex ?? 0,
|
||||
}) as unknown as Parameters<TaskListComponent['drop']>[0];
|
||||
|
||||
const undoneGroup = (
|
||||
groupTagId: string | null | undefined,
|
||||
filteredTasks: { id: string }[] = [],
|
||||
): GroupListData => ({
|
||||
listId: 'PARENT',
|
||||
listModelId: 'UNDONE',
|
||||
filteredTasks,
|
||||
groupTagId,
|
||||
});
|
||||
|
||||
let taskService: { updateTags: jasmine.Spy };
|
||||
|
||||
beforeEach(() => {
|
||||
taskService = TestBed.inject(TaskService) as unknown as typeof taskService;
|
||||
(store.dispatch as jasmine.Spy).calls.reset();
|
||||
});
|
||||
|
||||
// The reorder fall-through (no retag) dispatches a moveTaskInTodayList.
|
||||
const expectReorderDispatched = (): void => {
|
||||
const dispatched = (store.dispatch as jasmine.Spy).calls
|
||||
.allArgs()
|
||||
.map((args) => args[0]);
|
||||
expect(dispatched.some((a) => a.type === moveTaskInTodayList.type)).toBe(true);
|
||||
};
|
||||
|
||||
it('drops the source tag and adds the target tag, preserving other tags', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup('tagA'),
|
||||
target: undoneGroup('tagB', [{ id: 'keep' }]),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA', 'tagC'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).toHaveBeenCalledTimes(1);
|
||||
const [task, newTags] = taskService.updateTags.calls.mostRecent().args;
|
||||
expect(task.id).toBe('t1');
|
||||
expect(newTags).toEqual(['tagC', 'tagB']);
|
||||
// Retag short-circuits the reorder path.
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds the target tag when dragging out of the "No tag" bucket (no removal)', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup(NO_TAG_GROUP_ID),
|
||||
target: undoneGroup('tagB'),
|
||||
dragged: { id: 't1', parentId: null, tagIds: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).toHaveBeenCalledTimes(1);
|
||||
const [, newTags] = taskService.updateTags.calls.mostRecent().args;
|
||||
expect(newTags).toEqual(['tagB']);
|
||||
});
|
||||
|
||||
it('clears all tags when dropping onto the "No tag" bucket', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup('tagA'),
|
||||
target: undoneGroup(NO_TAG_GROUP_ID),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA', 'tagB'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).toHaveBeenCalledTimes(1);
|
||||
const [task, newTags] = taskService.updateTags.calls.mostRecent().args;
|
||||
expect(task.id).toBe('t1');
|
||||
expect(newTags).toEqual([]);
|
||||
expect(store.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('appends the target tag even if the task already has it (relies on updateTags de-dupe)', async () => {
|
||||
// _retagAcrossGroups does not de-dupe itself — it trusts updateTags' unique().
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup('tagA'),
|
||||
target: undoneGroup('tagB'),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA', 'tagB'] },
|
||||
}),
|
||||
);
|
||||
|
||||
const [, newTags] = taskService.updateTags.calls.mostRecent().args;
|
||||
// Source 'tagA' dropped, 'tagB' appended → duplicate left for updateTags to collapse.
|
||||
expect(newTags).toEqual(['tagB', 'tagB']);
|
||||
});
|
||||
|
||||
it('adds the target tag (no removal) when dragging from an ambiguous/unknown source bucket (src null)', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup(null),
|
||||
target: undoneGroup('tagB'),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagX'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).toHaveBeenCalledTimes(1);
|
||||
const [, newTags] = taskService.updateTags.calls.mostRecent().args;
|
||||
// Source bucket has no single tag to drop → only the target is appended.
|
||||
expect(newTags).toEqual(['tagX', 'tagB']);
|
||||
});
|
||||
|
||||
it('does NOT retag when dropping into a no-single-tag bucket (target null)', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup('tagA'),
|
||||
target: undoneGroup(null),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).not.toHaveBeenCalled();
|
||||
// Falls through to a normal reorder within the today list.
|
||||
expectReorderDispatched();
|
||||
});
|
||||
|
||||
it('does NOT retag when reordering within the same tag group', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup('tagA'),
|
||||
target: undoneGroup('tagA', [{ id: 'other' }]),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).not.toHaveBeenCalled();
|
||||
expectReorderDispatched();
|
||||
});
|
||||
|
||||
it('does NOT retag outside the grouped-by-tag view (groupTagId undefined)', async () => {
|
||||
await component.drop(
|
||||
dropEvent({
|
||||
previous: undoneGroup(undefined),
|
||||
target: undoneGroup(undefined),
|
||||
dragged: { id: 't1', parentId: null, tagIds: ['tagA'] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(taskService.updateTags).not.toHaveBeenCalled();
|
||||
expectReorderDispatched();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
|||
import { TaskViewCustomizerService } from '../../task-view-customizer/task-view-customizer.service';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
import { ScheduleExternalDragService } from '../../schedule/schedule-week/schedule-external-drag.service';
|
||||
import { DEFAULT_OPTIONS } from '../../task-view-customizer/types';
|
||||
import { DEFAULT_OPTIONS, NO_TAG_GROUP_ID } from '../../task-view-customizer/types';
|
||||
import { dragDelayForTouch } from '../../../util/input-intent';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { canConvertTaskToSubTask } from '../util/can-convert-task-to-sub-task';
|
||||
|
|
@ -83,6 +83,10 @@ export interface DropModelDataForList {
|
|||
listId: TaskListId;
|
||||
listModelId: ListModelId;
|
||||
filteredTasks: TaskWithSubTasks[];
|
||||
// Set only for lists rendered inside the grouped-by-tag work view: the tagId
|
||||
// of this group, `null` for a no-single-tag bucket, `undefined` everywhere
|
||||
// else. A drop across two defined-but-different values reassigns tags.
|
||||
groupTagId?: string | null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
|
@ -126,6 +130,11 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
listId = input.required<TaskListId>();
|
||||
listModelId = input.required<ListModelId>();
|
||||
parentId = input<string | undefined>(undefined);
|
||||
// Tag id of the group this list renders in the grouped-by-tag work view.
|
||||
// `undefined` (default) = not a tag group; `null` = a no-single-tag bucket
|
||||
// ('No tag' / 'Unknown tag' / a title shared by multiple tags). A real id
|
||||
// enables drag-to-retag across groups.
|
||||
groupTagId = input<string | null | undefined>(undefined);
|
||||
|
||||
noTasksMsg = input<string | undefined>(undefined);
|
||||
isBacklog = input(false);
|
||||
|
|
@ -137,6 +146,7 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
listId: this.listId(),
|
||||
listModelId: this.listModelId(),
|
||||
filteredTasks: this.filteredTasks(),
|
||||
groupTagId: this.groupTagId(),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -421,6 +431,34 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
return;
|
||||
}
|
||||
|
||||
// Grouped-by-tag view: dropping a task into a *different* tag group
|
||||
// reassigns its tags instead of reordering. `groupTagId` is defined only for
|
||||
// lists inside that view; a string is a retag target (a real tagId → move:
|
||||
// drop the source tag, add the target; the NO_TAG_GROUP_ID sentinel → clear
|
||||
// all tags), `null` an un-retaggable bucket ('Unknown tag'/ambiguous). A
|
||||
// `null` target or reordering within one group falls through to the move below.
|
||||
//
|
||||
// NOTE: handled here, before _move() (which owns every other list-type
|
||||
// dispatch). v1 limitations: drop position within the target group isn't
|
||||
// preserved (the task takes the group's natural order), and a subtask
|
||||
// dragged from a SUB list has groupTagId === undefined, so it falls through
|
||||
// to convertToMainTask without acquiring the target tag.
|
||||
const srcTagGroup = srcListData.groupTagId;
|
||||
const targetTagGroup = targetListData.groupTagId;
|
||||
if (
|
||||
typeof targetTagGroup === 'string' &&
|
||||
srcTagGroup !== undefined &&
|
||||
targetTagGroup !== srcTagGroup
|
||||
) {
|
||||
this.dropListService.blockAniTrigger$.next();
|
||||
this._retagAcrossGroups(
|
||||
srcTagGroup,
|
||||
targetTagGroup,
|
||||
draggedTask as TaskWithSubTasks,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newIds =
|
||||
targetTask && targetTask.id !== draggedTask.id
|
||||
? (() => {
|
||||
|
|
@ -498,6 +536,9 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
});
|
||||
}
|
||||
|
||||
// Dispatches a drag-drop to the right list-type action (regular/backlog/
|
||||
// section/subtask/overdue). NOTE: grouped-by-tag cross-group drops are
|
||||
// handled earlier, in drop(), and never reach here (see the groupTagId branch).
|
||||
private _move(
|
||||
taskId: string,
|
||||
src: DropListModelSource | string,
|
||||
|
|
@ -676,6 +717,36 @@ export class TaskListComponent implements OnDestroy, AfterViewInit {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign tags when a task is dragged across tag-group boundaries in the
|
||||
* grouped-by-tag work view:
|
||||
* - target is the {@link NO_TAG_GROUP_ID} bucket → clear all tags;
|
||||
* - otherwise move semantics: drop the source group's tag (when the drag
|
||||
* started from a real tag group) and add the target group's tag.
|
||||
* `updateTags` de-dupes, so a task that already carries the target tag is fine.
|
||||
*
|
||||
* NOTE: deliberately a plain filter + append rather than reusing boards'
|
||||
* `rewriteTagIdsForPanel` — that helper lives in the boards feature, and
|
||||
* importing it here would invert the dependency direction (boards depends on
|
||||
* tasks). For a single tag the rewrite is trivial; promote a shared tag util
|
||||
* if a group ever needs to represent multiple tags.
|
||||
*/
|
||||
private _retagAcrossGroups(
|
||||
srcTagId: string | null,
|
||||
targetTagId: string,
|
||||
task: TaskWithSubTasks,
|
||||
): void {
|
||||
if (targetTagId === NO_TAG_GROUP_ID) {
|
||||
this._taskService.updateTags(task, []);
|
||||
return;
|
||||
}
|
||||
const nextTagIds = [
|
||||
...(task.tagIds ?? []).filter((id) => id !== srcTagId),
|
||||
targetTagId,
|
||||
];
|
||||
this._taskService.updateTags(task, nextTagIds);
|
||||
}
|
||||
|
||||
expandDoneTasks(): void {
|
||||
const pid = this.parentId();
|
||||
if (!pid) {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@
|
|||
[tasks]="customized.grouped[groupKey]"
|
||||
listId="PARENT"
|
||||
listModelId="UNDONE"
|
||||
[groupTagId]="customized.groupTagIdByKey?.[groupKey]"
|
||||
></task-list>
|
||||
</collapsible>
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue