Skip to content

Commit 17f1ec0

Browse files
committed
feat(desktop): bulk-assign a section from multi-select in the project view (#1122)
1 parent d73c7f2 commit 17f1ec0

15 files changed

Lines changed: 282 additions & 2 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { fireEvent, render } from '@testing-library/react';
2+
import type { ComponentProps } from 'react';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import type { Project, Section } from '@mindwtr/core';
5+
6+
import { LanguageProvider } from '../../../contexts/language-context';
7+
import { TaskBulkOrganizeModal } from './TaskBulkOrganizeModal';
8+
9+
const t = (key: string) => key;
10+
11+
const project: Project = {
12+
id: 'project-1',
13+
title: 'Launch',
14+
color: '#3b82f6',
15+
order: 0,
16+
status: 'active',
17+
tagIds: [],
18+
createdAt: '2026-05-12T00:00:00.000Z',
19+
updatedAt: '2026-05-12T00:00:00.000Z',
20+
};
21+
22+
const otherProject: Project = { ...project, id: 'project-2', title: 'Rewrite' };
23+
24+
const section: Section = {
25+
id: 'section-1',
26+
projectId: project.id,
27+
title: 'Planning',
28+
order: 0,
29+
createdAt: '2026-05-12T00:00:00.000Z',
30+
updatedAt: '2026-05-12T00:00:00.000Z',
31+
};
32+
33+
type Props = ComponentProps<typeof TaskBulkOrganizeModal>;
34+
35+
const renderModal = (overrides: Partial<Props> = {}) => {
36+
const onApply = vi.fn();
37+
const result = render(
38+
<LanguageProvider>
39+
<TaskBulkOrganizeModal
40+
isOpen
41+
selectedCount={2}
42+
projects={[project, otherProject]}
43+
areas={[]}
44+
isApplying={false}
45+
t={t}
46+
onApply={onApply}
47+
onCancel={vi.fn()}
48+
{...overrides}
49+
/>
50+
</LanguageProvider>
51+
);
52+
return { ...result, onApply };
53+
};
54+
55+
describe('TaskBulkOrganizeModal section picker', () => {
56+
it('hides the section picker outside a single-project scope', () => {
57+
const { queryByRole } = renderModal();
58+
expect(queryByRole('combobox', { name: 'Project section' })).toBeNull();
59+
});
60+
61+
it('hides the section picker for a project without sections', () => {
62+
const { queryByRole } = renderModal({ sectionScope: { projectId: project.id, sections: [] } });
63+
expect(queryByRole('combobox', { name: 'Project section' })).toBeNull();
64+
});
65+
66+
it('sends the chosen section with the project that owns it', () => {
67+
const { getByRole, onApply } = renderModal({
68+
sectionScope: { projectId: project.id, sections: [section] },
69+
});
70+
71+
fireEvent.change(getByRole('combobox', { name: 'Project section' }), {
72+
target: { value: section.id },
73+
});
74+
fireEvent.click(getByRole('button', { name: 'Apply to selected' }));
75+
76+
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({
77+
sectionId: section.id,
78+
sectionProjectId: project.id,
79+
}));
80+
});
81+
82+
it('sends a null section id when clearing the section', () => {
83+
const { getByRole, onApply } = renderModal({
84+
sectionScope: { projectId: project.id, sections: [section] },
85+
});
86+
87+
fireEvent.change(getByRole('combobox', { name: 'Project section' }), {
88+
target: { value: '__NONE__' },
89+
});
90+
fireEvent.click(getByRole('button', { name: 'Apply to selected' }));
91+
92+
expect(onApply).toHaveBeenCalledWith(expect.objectContaining({ sectionId: null }));
93+
});
94+
95+
it('keeps the section out of the apply when the modal moves tasks to another project', () => {
96+
const { getByRole, onApply } = renderModal({
97+
sectionScope: { projectId: project.id, sections: [section] },
98+
});
99+
100+
const sectionSelect = getByRole('combobox', { name: 'Project section' });
101+
fireEvent.change(sectionSelect, { target: { value: section.id } });
102+
fireEvent.change(getByRole('combobox', { name: 'Project' }), { target: { value: otherProject.id } });
103+
104+
expect(sectionSelect).toBeDisabled();
105+
106+
fireEvent.click(getByRole('button', { name: 'Apply to selected' }));
107+
108+
const input = onApply.mock.calls[0]?.[0] as Record<string, unknown>;
109+
expect(input.projectId).toBe(otherProject.id);
110+
expect('sectionId' in input).toBe(false);
111+
});
112+
113+
it('omits the section when nothing is picked', () => {
114+
const { getByRole, onApply } = renderModal({
115+
sectionScope: { projectId: project.id, sections: [section] },
116+
});
117+
118+
fireEvent.click(getByRole('button', { name: 'Apply to selected' }));
119+
120+
expect('sectionId' in (onApply.mock.calls[0]?.[0] as Record<string, unknown>)).toBe(false);
121+
});
122+
});

apps/desktop/src/components/views/list/TaskBulkOrganizeModal.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type BulkOrganizeStatus,
1010
type BulkOrganizeTaskUpdateInput,
1111
type Project,
12+
type Section,
1213
} from '@mindwtr/core';
1314

1415
import { Dialog, DialogBody, DialogFooter, DialogHeader } from '../../ui/Dialog';
@@ -21,6 +22,12 @@ type TaskBulkOrganizeModalProps = {
2122
selectedCount: number;
2223
projects: Project[];
2324
areas: Area[];
25+
/**
26+
* Only set where every selected task lives in one project (the project
27+
* workspace). Sections belong to a project, so views without a project
28+
* scope get no section picker.
29+
*/
30+
sectionScope?: { projectId: string; sections: Section[] };
2431
isApplying: boolean;
2532
t: (key: string) => string;
2633
titleKey?: string;
@@ -39,6 +46,7 @@ export function TaskBulkOrganizeModal({
3946
selectedCount,
4047
projects,
4148
areas,
49+
sectionScope,
4250
isApplying,
4351
t,
4452
titleKey = 'bulk.organizeTasks',
@@ -49,6 +57,7 @@ export function TaskBulkOrganizeModal({
4957
const [status, setStatus] = useState<BulkOrganizeStatus | typeof KEEP_VALUE>(KEEP_VALUE);
5058
const [projectChoice, setProjectChoice] = useState(KEEP_VALUE);
5159
const [areaChoice, setAreaChoice] = useState(KEEP_VALUE);
60+
const [sectionChoice, setSectionChoice] = useState(KEEP_VALUE);
5261
const [contextsInput, setContextsInput] = useState('');
5362
const [tagsInput, setTagsInput] = useState('');
5463
const [startDate, setStartDate] = useState('');
@@ -63,6 +72,7 @@ export function TaskBulkOrganizeModal({
6372
setStatus(KEEP_VALUE);
6473
setProjectChoice(KEEP_VALUE);
6574
setAreaChoice(KEEP_VALUE);
75+
setSectionChoice(KEEP_VALUE);
6676
setContextsInput('');
6777
setTagsInput('');
6878
setStartDate('');
@@ -90,6 +100,10 @@ export function TaskBulkOrganizeModal({
90100
const isWaiting = status === 'waiting';
91101
const canApply = selectedCount > 0 && (!isWaiting || delegateWho.trim().length > 0);
92102
const selectedProjectId = projectChoice !== KEEP_VALUE && projectChoice !== NONE_VALUE ? projectChoice : undefined;
103+
// A section lives inside its project, so the picker goes quiet as soon as
104+
// the modal is about to move the tasks to a different project.
105+
const canChooseSection = sectionScope !== undefined
106+
&& (projectChoice === KEEP_VALUE || projectChoice === sectionScope.projectId);
93107
const title = tFallback(t, titleKey, titleFallback);
94108
const startDateLabel = tFallback(t, 'taskEdit.startDateLabel', 'Start');
95109
const dueDateLabel = tFallback(t, 'taskEdit.dueDateLabel', 'Due');
@@ -116,6 +130,10 @@ export function TaskBulkOrganizeModal({
116130
if (!selectedProjectId && areaChoice !== KEEP_VALUE) {
117131
input.areaId = areaChoice === NONE_VALUE ? null : areaChoice;
118132
}
133+
if (sectionScope && canChooseSection && sectionChoice !== KEEP_VALUE) {
134+
input.sectionId = sectionChoice === NONE_VALUE ? null : sectionChoice;
135+
input.sectionProjectId = sectionScope.projectId;
136+
}
119137
if (startDate.trim()) input.startTime = startDate.trim();
120138
if (dueDate.trim()) input.dueDate = dueDate.trim();
121139
if (reviewDate.trim()) input.reviewAt = reviewDate.trim();
@@ -221,6 +239,26 @@ export function TaskBulkOrganizeModal({
221239
</select>
222240
</label>
223241

242+
{sectionScope && sectionScope.sections.length > 0 && (
243+
<label className="space-y-1 text-xs font-medium text-muted-foreground">
244+
<span>{tFallback(t, 'taskEdit.sectionLabel', 'Project section')}</span>
245+
<select
246+
value={sectionChoice}
247+
onChange={(event) => setSectionChoice(event.currentTarget.value)}
248+
disabled={!canChooseSection}
249+
className="h-9 w-full rounded-md border border-border bg-card px-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
250+
>
251+
<option value={KEEP_VALUE}>{tFallback(t, 'bulk.keepSection', 'Keep section')}</option>
252+
<option value={NONE_VALUE}>{tFallback(t, 'taskEdit.noSectionOption', 'No Section')}</option>
253+
{sectionScope.sections.map((section) => (
254+
<option key={section.id} value={section.id}>
255+
{section.title}
256+
</option>
257+
))}
258+
</select>
259+
</label>
260+
)}
261+
224262
{isWaiting && (
225263
<label className="space-y-1 text-xs font-medium text-muted-foreground">
226264
<span>{tFallback(t, 'process.delegateWhoLabel', 'Waiting for')}</span>

apps/desktop/src/components/views/projects/ProjectWorkspace.selection.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,23 @@ describe('ProjectWorkspace Select mode', () => {
517517
expect(getByRole('combobox', { name: 'Area' })).toBeInTheDocument();
518518
});
519519

520+
it('offers the project\'s sections in the bulk organize dialog (#1122)', () => {
521+
const projectTask = task('task-1', 'Move me');
522+
const { getByRole } = renderWorkspace({
523+
allTasks: [projectTask],
524+
selectedProjectTasks: [projectTask],
525+
sections: [projectSection],
526+
});
527+
528+
fireEvent.click(getByRole('button', { name: 'Select' }));
529+
fireEvent.click(getByRole('checkbox', { name: 'Select task' }));
530+
fireEvent.click(getByRole('button', { name: 'Bulk organize' }));
531+
532+
const sectionSelect = getByRole('combobox', { name: 'Project section' });
533+
expect(sectionSelect).toBeInTheDocument();
534+
expect(getByRole('option', { name: projectSection.title })).toBeInTheDocument();
535+
});
536+
520537
it('retries scrolling to a highlighted project task after navigation', async () => {
521538
vi.useFakeTimers();
522539
const highlightedTask = task('task-1', 'Highlighted task');

apps/desktop/src/components/views/projects/ProjectWorkspace.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,6 +1952,7 @@ export function ProjectWorkspace({
19521952
selectedCount={selectedIdsArray.length}
19531953
projects={projects}
19541954
areas={areas}
1955+
sectionScope={selectedProject ? { projectId: selectedProject.id, sections: projectSections } : undefined}
19551956
isApplying={activeAction === 'organize'}
19561957
t={t}
19571958
onCancel={() => setBulkOrganizeOpen(false)}

docs/release-notes/unreleased.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Changes collected after `v1.2.1` and before the next version tag.
66

77
## Full Change List
88

9+
- Desktop: selecting several tasks inside a project and choosing **Bulk organize** now offers **Project section**, so a batch of tasks can be filed into one section in a single step. The field appears only in a project's own task list, where every selected task already belongs to that project, and it steps aside when the same dialog is also moving the tasks to a different project. (#1122)
910
- The web app (PWA and the self-hosted Docker build) can now show and open file attachments again: images, audio, text and everything else are fetched from your self-hosted Mindwtr Cloud server on demand instead of leaving an empty viewer and a dead Open button. Other sync backends, and libraries with sync encryption on, still show the not-supported notice, since the browser has no local files and no key.
1011
- Desktop has a new read-only **Timeline** view, off by default and switched on in Settings → GTD → Features: every task with a start or due date is drawn as a bar from start to due, grouped by project and colored the way its project dot is, with day/week/month zoom, a today line and a Today button. Dragging bars to reschedule comes later. (#1111)
1112
- A task that has outgrown itself can now become a section of the project it is in. **Convert to Section** in the task menu, on desktop and mobile, makes a section from the task's title and notes, turns each checklist item into a task inside it (items already ticked stay done), and moves the original task to Trash, so its attachments and history remain recoverable. (#1106)

packages/core/src/bulk-organize.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,75 @@ describe('bulk organize', () => {
7878
]);
7979
});
8080

81+
it('applies a section to tasks already in that section\'s project', () => {
82+
const updates = buildBulkOrganizeTaskUpdate(
83+
{ ...baseTask('task-1'), projectId: 'project-1' },
84+
{ sectionId: 'section-1', sectionProjectId: 'project-1' },
85+
);
86+
87+
expect(updates).toEqual({ sectionId: 'section-1' });
88+
});
89+
90+
it('applies a section alongside a matching project move', () => {
91+
const updates = buildBulkOrganizeTaskUpdate(
92+
{ ...baseTask('task-1'), projectId: 'project-2' },
93+
{ projectId: 'project-1', sectionId: 'section-1', sectionProjectId: 'project-1' },
94+
);
95+
96+
expect(updates).toEqual({
97+
projectId: 'project-1',
98+
areaId: undefined,
99+
sectionId: 'section-1',
100+
});
101+
});
102+
103+
it('clears the section when the input asks for no section', () => {
104+
const updates = buildBulkOrganizeTaskUpdate(
105+
{ ...baseTask('task-1'), projectId: 'project-1' },
106+
{ sectionId: null },
107+
);
108+
109+
expect(updates).toEqual({ sectionId: undefined });
110+
expect('sectionId' in updates).toBe(true);
111+
});
112+
113+
it('ignores a section that belongs to a different project than the task lands in', () => {
114+
const movedToAnotherProject = buildBulkOrganizeTaskUpdate(
115+
{ ...baseTask('task-1'), projectId: 'project-1' },
116+
{ projectId: 'project-2', sectionId: 'section-1', sectionProjectId: 'project-1' },
117+
);
118+
expect('sectionId' in movedToAnotherProject).toBe(false);
119+
120+
const taskInAnotherProject = buildBulkOrganizeTaskUpdate(
121+
{ ...baseTask('task-2'), projectId: 'project-9' },
122+
{ sectionId: 'section-1', sectionProjectId: 'project-1' },
123+
);
124+
expect('sectionId' in taskInAnotherProject).toBe(false);
125+
});
126+
127+
it('ignores a section when the task ends up with no project at all', () => {
128+
const clearedProject = buildBulkOrganizeTaskUpdate(
129+
{ ...baseTask('task-1'), projectId: 'project-1' },
130+
{ projectId: null, sectionId: 'section-1', sectionProjectId: 'project-1' },
131+
);
132+
expect('sectionId' in clearedProject).toBe(false);
133+
134+
const noProject = buildBulkOrganizeTaskUpdate(baseTask('task-1'), {
135+
sectionId: 'section-1',
136+
sectionProjectId: 'project-1',
137+
});
138+
expect('sectionId' in noProject).toBe(false);
139+
});
140+
141+
it('never applies a section without the owning project id', () => {
142+
const updates = buildBulkOrganizeTaskUpdate(
143+
{ ...baseTask('task-1'), projectId: 'project-1' },
144+
{ sectionId: 'section-1' },
145+
);
146+
147+
expect('sectionId' in updates).toBe(false);
148+
});
149+
81150
it('normalizes bulk token input', () => {
82151
expect(parseBulkOrganizeTokenInput('@home computer,computer', '@')).toEqual(['@home', '@computer']);
83152
expect(parseBulkOrganizeTokenInput('#launch inbox,launch', '#')).toEqual(['#launch', '#inbox']);

packages/core/src/bulk-organize.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export type BulkOrganizeTaskUpdateInput = {
66
/** Omit to keep each task's current status. */
77
status?: BulkOrganizeStatus;
88
projectId?: string | null;
9+
/**
10+
* Omit to keep each task's current section, null to clear it. A section
11+
* belongs to exactly one project, so it is only applied to tasks that end
12+
* up in `sectionProjectId` - see buildBulkOrganizeTaskUpdate.
13+
*/
14+
sectionId?: string | null;
15+
/** The project owning `sectionId`. Without it a section is never applied. */
16+
sectionProjectId?: string | null;
917
areaId?: string | null;
1018
contexts?: string[];
1119
tags?: string[];
@@ -47,7 +55,7 @@ const isTaskMap = (
4755
);
4856

4957
export function buildBulkOrganizeTaskUpdate(
50-
task: Pick<Task, 'contexts' | 'tags'>,
58+
task: Pick<Task, 'contexts' | 'tags' | 'projectId'>,
5159
input: BulkOrganizeTaskUpdateInput,
5260
): Partial<Task> {
5361
const updates: Partial<Task> = {};
@@ -74,6 +82,23 @@ export function buildBulkOrganizeTaskUpdate(
7482
}
7583
}
7684

85+
if (hasOwn(input, 'sectionId')) {
86+
const sectionId = normalizedOptionalString(input.sectionId);
87+
// The project the task lands in once the choices above are applied.
88+
const effectiveProjectId = hasOwn(updates, 'projectId')
89+
? updates.projectId
90+
: normalizedOptionalString(task.projectId);
91+
if (!sectionId) {
92+
updates.sectionId = undefined;
93+
} else if (effectiveProjectId && effectiveProjectId === normalizedOptionalString(input.sectionProjectId)) {
94+
updates.sectionId = sectionId;
95+
}
96+
// Otherwise the section belongs to another project: leave the task's
97+
// section alone rather than sending the store a mismatched pair it
98+
// would reject (resolveTaskContainerAssignment: 'Section does not
99+
// belong to project'). A project move clears the stale section there.
100+
}
101+
77102
const contexts = mergeTokens(task.contexts, input.contexts);
78103
if (contexts) updates.contexts = contexts;
79104

packages/core/src/i18n/i18n-locales.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const LOCALES = {
8181
mode: 'overrides',
8282
native: 'Tiếng Việt',
8383
nonLatin: false,
84-
translatedKeyFloor: 2222,
84+
translatedKeyFloor: 2280,
8585
},
8686
zh: {
8787
loadSync: () => require('./locales/zh-Hans') as typeof import('./locales/zh-Hans'),

packages/core/src/i18n/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1530,6 +1530,7 @@ export const en: Record<string, string> = {
15301530
'bulk.keepStatus': 'Keep status',
15311531
'bulk.keepProject': 'Keep project',
15321532
'bulk.keepArea': 'Keep area',
1533+
'bulk.keepSection': 'Keep section',
15331534
'bulk.waitingPersonRequired': 'Choose who these items are waiting for.',
15341535
'bulk.deleting': 'Deleting selected tasks...',
15351536

0 commit comments

Comments
 (0)