mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
7f029b1798
commit
1765a4f74b
14 changed files with 437 additions and 42 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: [''],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const makePanel = (overrides: Partial<BoardPanelCfg> = {}): BoardPanelCfg => ({
|
|||
scheduledState: BoardPanelCfgScheduledState.All,
|
||||
backlogState: BoardPanelCfgTaskTypeFilter.All,
|
||||
isParentTasksOnly: false,
|
||||
projectIds: [''],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<!---->
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
73
src/app/op-log/validation/boards-legacy-validation.spec.ts
Normal file
73
src/app/op-log/validation/boards-legacy-validation.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue