From 1765a4f74bed3f63d38c0352995e45cd26974b5b Mon Sep 17 00:00:00 2001 From: Maikel Hajiabadi <89179997+hajiboy95@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:33:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(boards):=20enhance=20project=20selection?= =?UTF-8?q?=20with=20multi-select=20and=20sidebar=E2=80=A6=20(#8069)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(boards): enhance project selection with multi-select logic - Update BoardPanelCfg to use projectIds array instead of single projectId - Implement multi-project filtering logic in BoardPanelComponent - Enhance SelectProjectComponent with connected multi-select checkbox logic - Add migration logic in sanitizePanelCfg to handle legacy board data - Update and verify all related unit tests * fix(boards): enhance panel migration and canonicalize project selection - Prefer legacy projectId during migration even if projectIds is defaulted - Canonicalize any projectIds containing "" back to [""] (All Projects) - Add regression tests for migration and canonicalization * fix(boards): prevent project assignment for All Projects panels - Ignore project IDs for additionalTaskFields when "" is present in projectIds - Add regression tests for multi-project filtering and task assignment * fix(boards): update board form spec to match projectIds changes * test(e2e): fix outdated keyboard shortcut and comment in add-to-today test * fix(boards): translate defaultLabel in SelectProjectComponent * docs(boards): document lossy canonicalization and legacy preference * refactor(boards): simplify additionalTaskFields and remove redundant test * feat(boards): add projectIds helper functions * refactor(boards): use projectIds helpers across board logic * fix(boards): keep projectIds optional for legacy data validation Making `projectIds` a required field broke the typia validator on raw-data paths that run before the boards reducer's `sanitizePanelCfg` normalizes the shape. Most critically, the one-time legacy PFAPI -> op-log migration validates, repairs, then re-validates and THROWS on failure -- and data-repair.ts has no boards handling -- so every legacy panel (carrying `projectId`, no `projectIds`) would abort that migration for existing users. Keep `projectIds` optional (absent/[''] = "All Projects") so legacy data validates; `sanitizePanelCfg` still normalizes it to a defined array before it reaches any component. Helpers and the drop() guard handle the optional type. Adds a regression spec exercising the real typia validator. --------- Co-authored-by: Johannes Millan --- docs/wiki/4.05-Board-View.md | 2 +- e2e/tests/work-view/add-to-today.spec.ts | 6 +- .../board-panel/board-panel.component.spec.ts | 84 ++++++++++- .../board-panel/board-panel.component.ts | 33 ++++- .../features/boards/boards-form.const.spec.ts | 4 +- src/app/features/boards/boards-form.const.ts | 8 +- src/app/features/boards/boards.const.ts | 9 +- src/app/features/boards/boards.model.ts | 8 +- src/app/features/boards/boards.util.spec.ts | 55 +++++-- src/app/features/boards/boards.util.ts | 40 +++++ .../boards/store/boards.reducer.spec.ts | 1 + .../select-project.component.html | 19 ++- .../select-project.component.ts | 137 ++++++++++++++++-- .../boards-legacy-validation.spec.ts | 73 ++++++++++ 14 files changed, 437 insertions(+), 42 deletions(-) create mode 100644 src/app/op-log/validation/boards-legacy-validation.spec.ts diff --git a/docs/wiki/4.05-Board-View.md b/docs/wiki/4.05-Board-View.md index 7c6621dae4..6c5c58311b 100644 --- a/docs/wiki/4.05-Board-View.md +++ b/docs/wiki/4.05-Board-View.md @@ -12,7 +12,7 @@ For example, you might have a Kanban board with "To Do", "In Progress", and "Don Boards are made up of **panels** (columns). Each panel has: -- **Filters** that determine which tasks appear in that panel (by tags, completion status, scheduling, project, backlog status, etc.) +- **Filters** that determine which tasks appear in that panel (by tags, completion status, scheduling, one or multiple projects, backlog status, etc.) - **Ordering** that you control by dragging and dropping tasks within the panel - **Automatic updates** that modify task properties when you move tasks between panels diff --git a/e2e/tests/work-view/add-to-today.spec.ts b/e2e/tests/work-view/add-to-today.spec.ts index 58b9cf6d9f..82632b153a 100644 --- a/e2e/tests/work-view/add-to-today.spec.ts +++ b/e2e/tests/work-view/add-to-today.spec.ts @@ -42,7 +42,7 @@ test.describe('Add to Today - Subtask Support', () => { // Now use keyboard shortcut to add subtask to Today // (This is the most reliable method and what users would actually do) await subtask.click(); - await page.keyboard.press('Control+t'); + await page.keyboard.press('Shift+T'); await page.waitForTimeout(500); // Verify subtask appears in Today view @@ -85,8 +85,8 @@ test.describe('Add to Today - Subtask Support', () => { // Focus the subtask (click on it) await subtask.click(); - // Press Ctrl+T to add to today - await page.keyboard.press('Control+t'); + // Press Shift+T to add to today + await page.keyboard.press('Shift+T'); // Wait for state update await page.waitForTimeout(500); diff --git a/src/app/features/boards/board-panel/board-panel.component.spec.ts b/src/app/features/boards/board-panel/board-panel.component.spec.ts index 30085a1c4a..39a59a7dd8 100644 --- a/src/app/features/boards/board-panel/board-panel.component.spec.ts +++ b/src/app/features/boards/board-panel/board-panel.component.spec.ts @@ -37,7 +37,7 @@ describe('BoardPanelComponent - Backlog Feature', () => { includedTagIds: [], excludedTagIds: [], isParentTasksOnly: false, - projectId: undefined, + projectIds: [''], }; const mockTasks: TaskCopy[] = [ @@ -184,7 +184,7 @@ describe('BoardPanelComponent - Hidden Project Backlog', () => { includedTagIds: [], excludedTagIds: [], isParentTasksOnly: false, - projectId: undefined, + projectIds: [''], }; const mockTasks: TaskCopy[] = [ @@ -390,6 +390,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -412,6 +413,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -437,6 +439,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -458,6 +461,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -465,6 +469,71 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', }); }); + describe('multi-project filtering', () => { + it('should include tasks matching any of the specified projectIds', async () => { + await setup([ + mkTask({ id: 'p1-task', projectId: 'p1' }), + mkTask({ id: 'p2-task', projectId: 'p2' }), + mkTask({ id: 'other-task', projectId: 'other' }), + ]); + fixture.componentRef.setInput('panelCfg', { + id: 'p', + title: 'P', + taskIds: [], + includedTagIds: [], + excludedTagIds: [], + taskDoneState: 1, + scheduledState: 1, + isParentTasksOnly: false, + projectIds: ['p1', 'p2'], + } as BoardPanelCfg); + fixture.detectChanges(); + + const ids = component.tasks().map((t) => t.id); + expect(ids).toContain('p1-task'); + expect(ids).toContain('p2-task'); + expect(ids).not.toContain('other-task'); + }); + }); + + describe('additionalTaskFields - projectId assignment', () => { + it('assigns the first specific projectId when only specific projects are selected', async () => { + await setup([]); + fixture.componentRef.setInput('panelCfg', { + id: 'p', + title: 'P', + taskIds: [], + includedTagIds: [], + excludedTagIds: [], + taskDoneState: 1, + scheduledState: 1, + isParentTasksOnly: false, + projectIds: ['p1', 'p2'], + } as BoardPanelCfg); + fixture.detectChanges(); + + expect(component.additionalTaskFields().projectId).toBe('p1'); + }); + + it('does NOT assign a projectId when only "All Projects" ("") is selected', async () => { + await setup([]); + fixture.componentRef.setInput('panelCfg', { + id: 'p', + title: 'P', + taskIds: [], + includedTagIds: [], + excludedTagIds: [], + taskDoneState: 1, + scheduledState: 1, + isParentTasksOnly: false, + projectIds: [''], + } as BoardPanelCfg); + fixture.detectChanges(); + + expect(component.additionalTaskFields().projectId).toBeUndefined(); + }); + }); + describe('sortBy', () => { it('sorts by title ascending', async () => { await setup([ @@ -481,6 +550,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], sortBy: 'title', } as BoardPanelCfg); fixture.detectChanges(); @@ -503,6 +573,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], sortBy: 'timeEstimate', sortDir: 'desc', } as BoardPanelCfg); @@ -524,6 +595,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -541,6 +613,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], sortBy: 'title', } as BoardPanelCfg); fixture.detectChanges(); @@ -561,6 +634,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -579,6 +653,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -598,6 +673,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -616,6 +692,7 @@ describe('BoardPanelComponent - Tag match mode, sort, inline-create computeds', taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg); fixture.detectChanges(); @@ -731,6 +808,7 @@ describe('BoardPanelComponent - drop()', () => { taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], sortBy: 'title', } as BoardPanelCfg; fixture.componentRef.setInput('panelCfg', panelCfg); @@ -765,6 +843,7 @@ describe('BoardPanelComponent - drop()', () => { taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg; fixture.componentRef.setInput('panelCfg', panelCfg); fixture.detectChanges(); @@ -794,6 +873,7 @@ describe('BoardPanelComponent - drop()', () => { taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, + projectIds: [''], } as BoardPanelCfg; fixture.componentRef.setInput('panelCfg', panelCfg); fixture.detectChanges(); diff --git a/src/app/features/boards/board-panel/board-panel.component.ts b/src/app/features/boards/board-panel/board-panel.component.ts index 51635bd6d9..d157f638e8 100644 --- a/src/app/features/boards/board-panel/board-panel.component.ts +++ b/src/app/features/boards/board-panel/board-panel.component.ts @@ -14,7 +14,12 @@ import { BoardPanelCfgTaskDoneState, BoardPanelCfgTaskTypeFilter, } from '../boards.model'; -import { buildComparator, rewriteTagIdsForPanel } from '../boards.util'; +import { + buildComparator, + firstSpecificProjectId, + isAllProjects, + rewriteTagIdsForPanel, +} from '../boards.util'; import { select, Store } from '@ngrx/store'; import { selectAllTasksInActiveProjects, @@ -131,6 +136,9 @@ export class BoardPanelComponent { additionalTaskFields = computed(() => { const panelCfg = this.panelCfg(); const tagsToAdd = this.tagsToAddForInlineCreate(); + const firstProjectId = isAllProjects(panelCfg.projectIds) + ? undefined + : firstSpecificProjectId(panelCfg.projectIds); return { ...(tagsToAdd.length ? { tagIds: tagsToAdd } : {}), @@ -140,9 +148,7 @@ export class BoardPanelComponent { ...(panelCfg.taskDoneState === BoardPanelCfgTaskDoneState.UnDone ? { isDone: false } : {}), - ...(panelCfg.projectId && panelCfg.projectId.length - ? { projectId: panelCfg.projectId } - : {}), + ...(firstProjectId ? { projectId: firstProjectId } : {}), // TODO scheduledState }; }); @@ -181,9 +187,13 @@ export class BoardPanelComponent { isTaskIncluded = isTaskIncluded && !task.isDone; } - if (panelCfg.projectId) { + if ( + panelCfg.projectIds && + panelCfg.projectIds.length > 0 && + !isAllProjects(panelCfg.projectIds) + ) { // TODO check parentId case thoroughly - isTaskIncluded = isTaskIncluded && task.projectId === panelCfg.projectId; + isTaskIncluded = isTaskIncluded && panelCfg.projectIds.includes(task.projectId); } if (panelCfg.scheduledState === BoardPanelCfgScheduledState.Scheduled) { @@ -259,7 +269,14 @@ export class BoardPanelComponent { updates.isDone = false; } - if (panelCfg.projectId?.length && task.projectId !== panelCfg.projectId) { + const firstProjectId = firstSpecificProjectId(panelCfg.projectIds); + if ( + firstProjectId && + panelCfg.projectIds && + panelCfg.projectIds.length > 0 && + !isAllProjects(panelCfg.projectIds) && + !panelCfg.projectIds.includes(task.projectId) + ) { const taskWithSubTasks = await this.store .pipe( select(selectTaskByIdWithSubTaskData, { id: task.parentId || task.id }), @@ -270,7 +287,7 @@ export class BoardPanelComponent { this.store.dispatch( TaskSharedActions.moveToOtherProject({ task: taskWithSubTasks, - targetProjectId: panelCfg.projectId, + targetProjectId: firstProjectId, }), ); } diff --git a/src/app/features/boards/boards-form.const.spec.ts b/src/app/features/boards/boards-form.const.spec.ts index ed8a86ade8..dfee5bd40e 100644 --- a/src/app/features/boards/boards-form.const.spec.ts +++ b/src/app/features/boards/boards-form.const.spec.ts @@ -79,7 +79,7 @@ describe('BOARDS_FORM panel behavior (#7380)', () => { it('uses translation keys for board modal labels (#8070)', () => { const fields = panelFieldGroup(); const scheduledState = fieldByKey(fields, 'scheduledState'); - const project = fieldByKey(fields, 'projectId'); + const project = fieldByKey(fields, 'projectIds'); const isParentTasksOnly = fieldByKey(fields, 'isParentTasksOnly'); expect(scheduledState.props?.label).toBe('F.BOARDS.FORM.SCHEDULED_STATE'); @@ -93,7 +93,7 @@ describe('BOARDS_FORM panel behavior (#7380)', () => { 'F.BOARDS.FORM.SCHEDULED_STATE_NOT_SCHEDULED', ]); expect(project.props?.label).toBe('F.BOARDS.FORM.PROJECT'); - expect(project.props?.nullLabel).toBe('F.BOARDS.FORM.PROJECT_ALL'); + expect(project.props?.defaultLabel).toBe('F.BOARDS.FORM.PROJECT_ALL'); expect(isParentTasksOnly.props?.label).toBe('F.BOARDS.FORM.ONLY_PARENT_TASKS'); }); diff --git a/src/app/features/boards/boards-form.const.ts b/src/app/features/boards/boards-form.const.ts index c23af739d1..fcabfeb5d6 100644 --- a/src/app/features/boards/boards-form.const.ts +++ b/src/app/features/boards/boards-form.const.ts @@ -219,12 +219,14 @@ export const BOARDS_FORM: LimitedFormlyFieldConfig[] = [ }, }, { - key: 'projectId', + key: 'projectIds', type: 'project-select', props: { label: T.F.BOARDS.FORM.PROJECT, - defaultValue: '', - nullLabel: T.F.BOARDS.FORM.PROJECT_ALL, + multiple: true, + required: true, + defaultValue: [''], + defaultLabel: T.F.BOARDS.FORM.PROJECT_ALL, }, }, { diff --git a/src/app/features/boards/boards.const.ts b/src/app/features/boards/boards.const.ts index d870c5bf08..5234d02653 100644 --- a/src/app/features/boards/boards.const.ts +++ b/src/app/features/boards/boards.const.ts @@ -26,6 +26,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: true, + projectIds: [''], }, { id: 'NOT_URGENT_AND_IMPORTANT', @@ -37,6 +38,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: true, + projectIds: [''], }, { id: 'URGENT_AND_NOT_IMPORTANT', @@ -48,6 +50,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: true, + projectIds: [''], }, { id: 'NOT_URGENT_AND_NOT_IMPORTANT', @@ -59,6 +62,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: true, + projectIds: [''], }, ], }, @@ -77,6 +81,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.NoBacklog, isParentTasksOnly: false, + projectIds: [''], }, { id: 'IN_PROGRESS', @@ -88,6 +93,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.NoBacklog, isParentTasksOnly: false, + projectIds: [''], }, { id: 'DONE', @@ -101,6 +107,7 @@ export const DEFAULT_BOARDS: BoardCfg[] = [ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.NoBacklog, isParentTasksOnly: false, + projectIds: [''], }, ], }, @@ -123,5 +130,5 @@ export const DEFAULT_PANEL_CFG: BoardPanelCfg = { scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: false, - projectId: '', + projectIds: [''], }; diff --git a/src/app/features/boards/boards.model.ts b/src/app/features/boards/boards.model.ts index 491364b83a..9d1185ba2d 100644 --- a/src/app/features/boards/boards.model.ts +++ b/src/app/features/boards/boards.model.ts @@ -20,14 +20,18 @@ export type BoardSortField = 'dueDate' | 'created' | 'title' | 'timeEstimate'; export type BoardMatchMode = 'all' | 'any'; export interface BoardSrcCfg { - // projectId?: string; includedTagIds: string[]; excludedTagIds: string[]; // Absent = 'all' (today's behavior): all required tags must match. includedTagsMatch?: BoardMatchMode; // Absent = 'any' (today's behavior): exclude on any match. excludedTagsMatch?: BoardMatchMode; - projectId?: string; + // Absent/[''] = "All Projects". Optional so the typia validator tolerates + // legacy data (panels that still carry `projectId` and no `projectIds`) on + // raw-data paths that validate before the reducer's `sanitizePanelCfg` runs + // (e.g. the legacy PFAPI → op-log migration). `sanitizePanelCfg` always + // normalizes this to a defined array before it reaches any component. + projectIds?: string[]; taskDoneState: BoardPanelCfgTaskDoneState; scheduledState: BoardPanelCfgScheduledState; isParentTasksOnly: boolean; diff --git a/src/app/features/boards/boards.util.spec.ts b/src/app/features/boards/boards.util.spec.ts index 8d9329f95d..d394daaaae 100644 --- a/src/app/features/boards/boards.util.spec.ts +++ b/src/app/features/boards/boards.util.spec.ts @@ -2,7 +2,7 @@ import { buildComparator, rewriteTagIdsForPanel, sanitizePanelCfg } from './boar import { BoardPanelCfg } from './boards.model'; import { TaskCopy } from '../tasks/task.model'; -const basePanel: BoardPanelCfg = { +const basePanel: any = { id: 'p1', title: 'Panel', taskIds: [], @@ -11,25 +11,62 @@ const basePanel: BoardPanelCfg = { taskDoneState: 1, scheduledState: 1, isParentTasksOnly: false, -} as BoardPanelCfg; + projectIds: [''], +}; describe('sanitizePanelCfg', () => { + it('migrates legacy projectId to projectIds array', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { projectIds, ...inputWithoutProjectIds } = basePanel; + const out = sanitizePanelCfg({ ...inputWithoutProjectIds, projectId: 'p1' } as any); + expect(out.projectIds).toEqual(['p1']); + expect('projectId' in out).toBe(false); + }); + + it('migrates legacy empty projectId to projectIds [""]', () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { projectIds, ...inputWithoutProjectIds } = basePanel; + const out = sanitizePanelCfg({ ...inputWithoutProjectIds, projectId: '' } as any); + expect(out.projectIds).toEqual(['']); + expect('projectId' in out).toBe(false); + }); + + it('ensures projectIds is always an array', () => { + const out = sanitizePanelCfg({ ...basePanel, projectIds: null as any } as any); + expect(out.projectIds).toEqual(['']); + }); + + it('migrates legacy projectId even if projectIds is already defaulted to [""]', () => { + const out = sanitizePanelCfg({ + ...basePanel, + projectIds: [''], + projectId: 'p1', + } as any); + expect(out.projectIds).toEqual(['p1']); + expect('projectId' in out).toBe(false); + }); + + it('deliberately drops specific IDs when "" co-occurs (lossy canonicalization)', () => { + const out = sanitizePanelCfg({ ...basePanel, projectIds: ['', 'p1', 'p2'] } as any); + expect(out.projectIds).toEqual(['']); + }); + it('migrates sortByDue=asc to sortBy=dueDate/asc and drops sortByDue', () => { - const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'asc' }); + const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'asc' } as any); expect(out.sortBy).toBe('dueDate'); expect(out.sortDir).toBe('asc'); expect('sortByDue' in out).toBe(false); }); it('migrates sortByDue=desc to sortBy=dueDate/desc', () => { - const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'desc' }); + const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'desc' } as any); expect(out.sortBy).toBe('dueDate'); expect(out.sortDir).toBe('desc'); expect('sortByDue' in out).toBe(false); }); it('drops sortByDue=off without adding sortBy', () => { - const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'off' }); + const out = sanitizePanelCfg({ ...basePanel, sortByDue: 'off' } as any); expect(out.sortBy).toBeUndefined(); expect(out.sortDir).toBeUndefined(); expect('sortByDue' in out).toBe(false); @@ -42,7 +79,7 @@ describe('sanitizePanelCfg', () => { sortDir: null as any, includedTagsMatch: null as any, excludedTagsMatch: null as any, - }); + } as any); expect('sortBy' in out).toBe(false); expect('sortDir' in out).toBe(false); expect('includedTagsMatch' in out).toBe(false); @@ -54,7 +91,7 @@ describe('sanitizePanelCfg', () => { ...basePanel, sortBy: 'priority' as any, sortDir: 'asc', - }); + } as any); expect('sortBy' in out).toBe(false); // sortDir stays — it's valid on its own; it'll just go unused. expect(out.sortDir).toBe('asc'); @@ -67,7 +104,7 @@ describe('sanitizePanelCfg', () => { sortDir: 'desc', includedTagsMatch: 'any', excludedTagsMatch: 'all', - }); + } as any); expect(out.sortBy).toBe('title'); expect(out.sortDir).toBe('desc'); expect(out.includedTagsMatch).toBe('any'); @@ -75,7 +112,7 @@ describe('sanitizePanelCfg', () => { }); it('is idempotent', () => { - const once = sanitizePanelCfg({ ...basePanel, sortByDue: 'asc' }); + const once = sanitizePanelCfg({ ...basePanel, sortByDue: 'asc' } as any); const twice = sanitizePanelCfg(once); expect(twice).toEqual(once); }); diff --git a/src/app/features/boards/boards.util.ts b/src/app/features/boards/boards.util.ts index 1cb01e66be..a724464f52 100644 --- a/src/app/features/boards/boards.util.ts +++ b/src/app/features/boards/boards.util.ts @@ -9,6 +9,15 @@ const VALID_SORT_FIELDS: ReadonlySet = new Set([ 'timeEstimate', ]); +// Absent `projectIds` (legacy data that hasn't been sanitized yet) means +// "All Projects", same as an array containing the "" sentinel. +export const isAllProjects = (projectIds: string[] | undefined): boolean => + !projectIds || projectIds.includes(''); + +export const firstSpecificProjectId = ( + projectIds: string[] | undefined, +): string | undefined => projectIds?.find((id) => id !== ''); + /** * Normalizes a panel cfg for persistence and hydration: * - Migrates legacy `sortByDue` → `sortBy`/`sortDir`. @@ -19,6 +28,37 @@ const VALID_SORT_FIELDS: ReadonlySet = new Set([ export const sanitizePanelCfg = (panel: BoardPanelCfg): BoardPanelCfg => { const out: BoardPanelCfg = { ...panel }; + // Migrate legacy `projectId` → `projectIds`. + // Preference given to legacy `projectId` if present (even if `projectIds` + // exists as [""] from overlaying DEFAULT_PANEL_CFG during migration). + // NOTE: This preference is deliberate to ensure we don't lose the user's + // choice if they had a specific project selected in an older version. + const legacyPanel = out as BoardPanelCfg & { projectId?: string }; + if (legacyPanel.projectId !== undefined) { + if ( + out.projectIds === undefined || + (Array.isArray(out.projectIds) && + out.projectIds.length === 1 && + out.projectIds[0] === '') + ) { + out.projectIds = [legacyPanel.projectId || '']; + } + delete legacyPanel.projectId; + } + + // Ensure `projectIds` is always an array (e.g. if loaded from older client + // that didn't run this migration yet). + if (!Array.isArray(out.projectIds)) { + out.projectIds = ['']; + } + + // Canonicalize any `projectIds` containing "" back to [""] (All Projects). + // This is lossy: if "" (All Projects) co-occurs with specific IDs, the specific IDs + // are dropped in favor of "All Projects". + if (isAllProjects(out.projectIds)) { + out.projectIds = ['']; + } + if (out.sortByDue === 'asc' || out.sortByDue === 'desc') { out.sortBy = 'dueDate'; out.sortDir = out.sortByDue; diff --git a/src/app/features/boards/store/boards.reducer.spec.ts b/src/app/features/boards/store/boards.reducer.spec.ts index 754dc835c5..7a7c1d7ae9 100644 --- a/src/app/features/boards/store/boards.reducer.spec.ts +++ b/src/app/features/boards/store/boards.reducer.spec.ts @@ -26,6 +26,7 @@ const makePanel = (overrides: Partial = {}): BoardPanelCfg => ({ scheduledState: BoardPanelCfgScheduledState.All, backlogState: BoardPanelCfgTaskTypeFilter.All, isParentTasksOnly: false, + projectIds: [''], ...overrides, }); diff --git a/src/app/features/config/select-project/select-project.component.html b/src/app/features/config/select-project/select-project.component.html index f0387baa90..9756debcf8 100644 --- a/src/app/features/config/select-project/select-project.component.html +++ b/src/app/features/config/select-project/select-project.component.html @@ -16,14 +16,29 @@ [formControl]="formControl" [formlyAttributes]="field" [id]="id" + [multiple]="to.multiple" + (selectionChange)="onSelectionChange($event)" > + @if (to.multiple && triggerLabel()) { + + {{ triggerLabel() }} + + } @if (!field.props?.hideNoneOption) { {{ - field.props?.nullLabel || T.G.NONE | translate + field.props?.defaultLabel || T.G.NONE | translate }} } @for (opt of projectService.list$ | async; track trackById($index, opt)) { - {{ opt.title }} + + + } diff --git a/src/app/features/config/select-project/select-project.component.ts b/src/app/features/config/select-project/select-project.component.ts index 585a188f36..4f9b53ea37 100644 --- a/src/app/features/config/select-project/select-project.component.ts +++ b/src/app/features/config/select-project/select-project.component.ts @@ -1,4 +1,13 @@ -import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, +} from '@angular/core'; import { FieldType } from '@ngx-formly/material'; import { ProjectService } from '../../project/project.service'; import { Project } from '../../project/project.model'; @@ -6,15 +15,29 @@ import { T } from 'src/app/t.const'; import { FormlyFieldConfig, FormlyFieldProps, FormlyModule } from '@ngx-formly/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { AsyncPipe } from '@angular/common'; -import { TranslatePipe } from '@ngx-translate/core'; -import { MatOption, MatSelect } from '@angular/material/select'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { + MatOption, + MatSelect, + MatSelectChange, + MatSelectTrigger, +} from '@angular/material/select'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { unique } from '../../../util/unique'; +import { fastArrayCompare } from '../../../util/fast-array-compare'; +import { startWith } from 'rxjs/operators'; +import { MenuTreeService } from '../../menu-tree/menu-tree.service'; +import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component'; +import { DEFAULT_PROJECT_ICON } from '../../project/project.const'; /** Custom props for the shared `project-select` formly field. */ export interface SelectProjectProps extends FormlyFieldProps { - /** Label for the empty (`''`) option; defaults to the translated "None". */ - nullLabel?: string; - /** Hide the empty option entirely (e.g. the tasks default-project field, #7891). */ + /** Label for the default (`''`) option; defaults to the translated "None". */ + defaultLabel?: string; + /** Hide the default option entirely (e.g. the tasks default-project field, #7891). */ hideNoneOption?: boolean; + /** Allow multiple selection. */ + multiple?: boolean; } @Component({ @@ -30,14 +53,76 @@ export interface SelectProjectProps extends FormlyFieldProps { TranslatePipe, MatSelect, MatOption, + MatSelectTrigger, + SelectOptionRowComponent, ], }) -export class SelectProjectComponent extends FieldType< - FormlyFieldConfig -> { +export class SelectProjectComponent + extends FieldType> + implements OnInit +{ projectService = inject(ProjectService); + _translateService = inject(TranslateService); + _destroyRef = inject(DestroyRef); + _menuTreeService = inject(MenuTreeService); + projects = toSignal(this.projectService.list$, { initialValue: [] }); T: typeof T = T; + DEFAULT_PROJECT_ICON = DEFAULT_PROJECT_ICON; + private _prevValue: string[] = []; + + // Use a manual signal to bridge formControl.valueChanges + val = signal([]); + + triggerLabel = computed(() => { + const val = this.val(); + if (this.to.multiple && Array.isArray(val)) { + if (val.includes('')) { + return this.to.defaultLabel + ? this._translateService.instant(this.to.defaultLabel) + : this._translateService.instant(T.G.NONE); + } + if (val.length > 0) { + const projects = this.projects(); + return val + .map((id) => projects.find((p) => p.id === id)?.title) + .filter((v) => !!v) + .join(', '); + } + } + return null; + }); + + projectFolderMap = computed(() => this._menuTreeService.projectFolderMap()); + + constructor() { + super(); + effect(() => { + const projects = this.projects(); + // Only run when initialized and for multiple selection + if (this.to.multiple && projects.length > 0 && this.formControl) { + const val = this.formControl.value; + if (Array.isArray(val) && val.includes('')) { + const allIds = projects.map((p) => p.id); + const newValue = unique(['', ...allIds]); + if (!fastArrayCompare(val, newValue)) { + this.formControl.setValue(newValue); + this._prevValue = newValue; + } + } else if (Array.isArray(val)) { + this._prevValue = val; + } + } + }); + } + + ngOnInit(): void { + if (this.formControl) { + this.formControl.valueChanges + .pipe(startWith(this.formControl.value), takeUntilDestroyed(this._destroyRef)) + .subscribe((v) => this.val.set(v)); + } + } get type(): string { return this.to.type || 'text'; @@ -46,4 +131,38 @@ export class SelectProjectComponent extends FieldType< trackById(i: number, item: Project): string { return item.id; } + + onSelectionChange(ev: MatSelectChange): void { + if (!this.to.multiple) { + return; + } + + const value = ev.value as string[]; + const allIds = this.projects().map((p) => p.id); + const wasAllSelected = this._prevValue.includes(''); + const isAllSelectedNow = value.includes(''); + + let newValue: string[]; + + if (isAllSelectedNow && !wasAllSelected) { + // "All Projects" was just checked -> Select EVERYTHING + newValue = ['', ...allIds]; + } else if (!isAllSelectedNow && wasAllSelected) { + // "All Projects" was just unchecked -> Select NOTHING + newValue = []; + } else { + // Individual project toggled + const projectsOnly = value.filter((v) => v !== ''); + if (projectsOnly.length === allIds.length) { + // All projects selected manually -> Add "All Projects" + newValue = ['', ...allIds]; + } else { + // Not all projects selected -> Remove "All Projects" + newValue = projectsOnly; + } + } + + this._prevValue = newValue; + this.formControl.setValue(newValue); + } } diff --git a/src/app/op-log/validation/boards-legacy-validation.spec.ts b/src/app/op-log/validation/boards-legacy-validation.spec.ts new file mode 100644 index 0000000000..a508fc1ac0 --- /dev/null +++ b/src/app/op-log/validation/boards-legacy-validation.spec.ts @@ -0,0 +1,73 @@ +import { validateAppDataProperty } from './validation-fn'; +import { BoardsState } from '../../features/boards/store/boards.reducer'; +import { BoardCfg, BoardPanelCfg } from '../../features/boards/boards.model'; + +/** + * Regression guard for the `projectId` → `projectIds` schema migration (#8069). + * + * `projectIds` MUST stay optional on the synced `BoardSrcCfg`. Several raw-data + * paths run the typia validator BEFORE the boards reducer's `sanitizePanelCfg` + * normalizes the shape — most notably the one-time legacy PFAPI → op-log + * migration, which re-validates after repair and THROWS on failure (and + * `data-repair.ts` has no boards handling). If `projectIds` were required, + * every legacy panel (which carries `projectId` and no `projectIds`) would fail + * validation and abort that migration for existing users. + */ +describe('boards typia validation — legacy projectId compatibility', () => { + const makeBoard = (panel: BoardPanelCfg): BoardsState => ({ + boardCfgs: [ + { + id: 'board1', + title: 'Board', + cols: 3, + panels: [panel], + } as BoardCfg, + ], + }); + + const basePanel: Omit = { + id: 'panel1', + title: 'Panel', + taskIds: [], + includedTagIds: [], + excludedTagIds: [], + taskDoneState: 1, + scheduledState: 1, + isParentTasksOnly: false, + }; + + it('accepts a legacy panel that has `projectId` but no `projectIds`', () => { + // The exact shape produced by an old client (or pre-upgrade IndexedDB). + const legacyPanel = { + ...basePanel, + projectId: 'p1', + } as BoardPanelCfg & { projectId?: string }; + + const result = validateAppDataProperty('boards', makeBoard(legacyPanel)); + expect(result.success).toBe(true); + }); + + it('accepts a panel with no project field at all', () => { + const result = validateAppDataProperty( + 'boards', + makeBoard(basePanel as BoardPanelCfg), + ); + expect(result.success).toBe(true); + }); + + it('accepts the new multi-project shape', () => { + const result = validateAppDataProperty( + 'boards', + makeBoard({ ...basePanel, projectIds: ['', 'p1'] } as BoardPanelCfg), + ); + expect(result.success).toBe(true); + }); + + it('still rejects a wrongly-typed `projectIds` (validation stays active when present)', () => { + const result = validateAppDataProperty( + 'boards', + makeBoard({ ...basePanel, projectIds: [123] } as unknown as BoardPanelCfg), + ); + expect(result.success).toBe(false); + }); +});