feat(boards): enhance project selection with multi-select and sidebar… (#8069)

* 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 <johannes.millan@gmail.com>
This commit is contained in:
Maikel Hajiabadi 2026-06-09 18:33:09 +02:00 committed by GitHub
parent 7f029b1798
commit 1765a4f74b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 437 additions and 42 deletions

View file

@ -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

View file

@ -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);

View file

@ -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();

View file

@ -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,
}),
);
}

View file

@ -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');
});

View file

@ -219,12 +219,14 @@ export const BOARDS_FORM: LimitedFormlyFieldConfig<BoardCfg>[] = [
},
},
{
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,
},
},
{

View file

@ -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: [''],
};

View file

@ -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;

View file

@ -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);
});

View file

@ -9,6 +9,15 @@ const VALID_SORT_FIELDS: ReadonlySet<BoardSortField> = 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<BoardSortField> = 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;

View file

@ -26,6 +26,7 @@ const makePanel = (overrides: Partial<BoardPanelCfg> = {}): BoardPanelCfg => ({
scheduledState: BoardPanelCfgScheduledState.All,
backlogState: BoardPanelCfgTaskTypeFilter.All,
isParentTasksOnly: false,
projectIds: [''],
...overrides,
});

View file

@ -16,14 +16,29 @@
[formControl]="formControl"
[formlyAttributes]="field"
[id]="id"
[multiple]="to.multiple"
(selectionChange)="onSelectionChange($event)"
>
@if (to.multiple && triggerLabel()) {
<mat-select-trigger>
{{ triggerLabel() }}
</mat-select-trigger>
}
@if (!field.props?.hideNoneOption) {
<mat-option [value]="''">{{
field.props?.nullLabel || T.G.NONE | translate
field.props?.defaultLabel || T.G.NONE | translate
}}</mat-option>
}
@for (opt of projectService.list$ | async; track trackById($index, opt)) {
<mat-option [value]="opt.id">{{ opt.title }}</mat-option>
<mat-option [value]="opt.id">
<select-option-row
[title]="opt.title"
[icon]="opt.icon"
[defaultIcon]="DEFAULT_PROJECT_ICON"
[color]="opt.theme?.primary"
[folderPath]="projectFolderMap().get(opt.id)"
></select-option-row>
</mat-option>
}
</mat-select>
<!---->

View file

@ -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<SelectProjectProps>
> {
export class SelectProjectComponent
extends FieldType<FormlyFieldConfig<SelectProjectProps>>
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<string[]>([]);
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);
}
}

View file

@ -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<BoardPanelCfg, 'projectIds'> = {
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);
});
});