Skip to content

fix(projects): let an item leave a project from its project chips - #1944

Draft
h4yfans wants to merge 5 commits into
mainfrom
project-unassign-file
Draft

fix(projects): let an item leave a project from its project chips#1944
h4yfans wants to merge 5 commits into
mainfrom
project-unassign-file

Conversation

@h4yfans

@h4yfans h4yfans commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

A file assigned to a project could not be unassigned. The only exits were deleting the project or deleting the file, which is what the reporter hit on both the built-in Inbox project and one they had made themselves.

The gap is one-sided UI, not a broken write. Three surfaces add a file to a project: a drag onto a sidebar project row (sortable-project-item.tsx:139), the file page's Add to project dialog (add-file-to-project-dialog.tsx:44), and the project hub's paperclip import (project-capture-input.tsx:84). The file page then shows that membership as chips rendered by ItemProjectChips (pages/file.tsx:217 and :238), and those chips were plain buttons with no remove control. The tasks:project-unlink-item IPC has existed and worked the whole time; outside the calendar's event-project-field.tsx nothing in the renderer ever called it. A markdown note has an escape hatch a file does not, its project frontmatter property and the chip remove control in ProjectEditor, which is why the report is about files.

Every chip in ItemProjectChips now carries a remove control that calls tasksService.unlinkProjectItem. Behind it the two item kinds already diverge correctly and neither needed changing. For a binary file unlinkProjectItem takes the table-native branch and deletes the project_links row, which is the whole of that file's membership, and note-project-links-projector only reconciles kind === 'markdown', so no reindex can put the row back. For a markdown note the same call rewrites the name out of frontmatter through setEntityProperties, so the file stays the payload and sync carries the removal to a second device without a protocol change. No schema change, no migration, nothing an older client cannot read.

One adjacent change rides along. pages/file.tsx mounts the row without onProjectClick, so the chip's "Open project X" button has always done nothing there. Putting a real control next to a dead one would leave the file page with two adjacent tab stops where only one acts, so the project name now renders as plain text wherever no navigation handler is supplied and stays a button where one is.

Blast radius is the file page plus any future caller of ItemProjectChips. pages/file.tsx is its only production caller today, pages/file.test.tsx stubs the component, and no E2E spec asserted on its markup. The calendar's project field is a separate component and is untouched.

Two things the reviewer should know, because both contradict the issue's "Where to look". It asked whether a chip in ProjectEditor has a remove affordance at all: it does, at ProjectEditor.tsx:94, and ProjectEditor.test.tsx already asserted that removing the last chip emits []. It also suggested copying a clear path from project-picker.tsx or task-detail-drawer.tsx; there is none to copy, because tasks.project_id is NOT NULL and TaskUpdateSchema.projectId is .optional() rather than .nullish(), so a task can only move between projects, never leave one. The shape copied instead is the calendar's removable chip at event-project-field.tsx:265.

item-project-chips.test.tsx sat in the tsconfig.test.web.json exclude backlog, so typecheck:test compiled none of it. That line is gone, which surfaced one real error: the hoisted onProjectUpdated mock declared no parameters, so the pre-existing subscribe-capture case could not implement it. The backlog only ever shrinks.

Carries the test-only main fix from the calendar-widget PR until it lands; rebasing after that drops it.

Closes #1941

Release note

Files can now be removed from a project from the chips under the file's title, without deleting the file or the project.

Test plan

Command Outcome
vitest --project renderer item-project-chips.test.tsx 9 passed
vitest --project renderer item-project-chips.test.tsx on origin/main source 4 failed, 5 passed (all four new cases)
vitest --project main project-item-links / project-links-domain / note-project-links-projector 30 passed
run-e2e.sh tests/e2e/project-unassign.e2e.ts 2 passed (28.7s)
run-e2e.sh tests/e2e/project-unassign.e2e.ts on origin/main source 1 failed, 1 passed: the file case cannot find the remove control, the note case passes because that surface already worked
pnpm --filter @memry/desktop typecheck:web clean
pnpm --filter @memry/desktop typecheck:node clean
pnpm --filter @memry/desktop typecheck:test clean, and --listFiles now shows item-project-chips.test.tsx in the program
pnpm --filter @memry/desktop test:renderer 8631 passed, 1 failure in calendar-widget-refresh.test.tsx that reproduces on origin/main and is fixed by the carried commit; that file alone is 6 passed on this head
pnpm --filter @memry/desktop test:main 7737 passed, 566 files
pnpm lint 0 errors, 108 warnings, none in a changed file
pnpm --filter @memry/desktop i18n:check passed
pnpm check:architecture && pnpm check:contracts both passed
git diff --check origin/main..HEAD clean
pnpm docs:impact --base origin/main --strict covered
pnpm docs:build build complete

pnpm ipc:check was not run: no contract, preload, or main IPC handler changed.

No second-device E2E. The diff is a button; it calls tasks:project-unlink-item, whose sync publication already existed and is already covered. project-links-domain.test.ts asserts that unlink re-enqueues publisher.projectUpdated with changedFields: ['links'], and a project's payload is what carries its links, so a file's removal reaches a second device the same way its addition always has. For a markdown note the frontmatter write is the sync payload, and the E2E asserts the name is gone from the bytes on disk. A sync spec here would exercise none of the changed lines while holding the shared machine lock.

The issue's "a note that was never assigned is unaffected" holds by construction: ItemProjectChips renders nothing when the item has no links (covered by its existing renders nothing when there are no linked projects case), so there is no control to press and no write to make.

Reproduces #1941 on the surface the report names. A binary file's project
membership is only ever shown by ItemProjectChips, and those chips have no
remove control, so the renderer cases and the E2E file case fail against
current source. The E2E spec drives both item kinds through the real app,
and the main cases pin the file branch of unlinkProjectItem that the
missing UI never reached.
A file assigned to a project could only be unassigned by deleting the
project or the file. Three surfaces add a file to a project (a sidebar
drag, the file page's Add to project, the project hub's paperclip import)
and none could undo it: the file page shows the membership as chips that
only navigate, and a file has no frontmatter, so unlike a markdown note it
has no project property row to clear instead.

Every chip now carries a remove control that calls the unlinkProjectItem
IPC that already existed and had no caller outside the calendar. For a
file that deletes the project_links row, which is the whole of its
membership and cannot be resurrected by a reindex; for a markdown note the
same call rewrites the name out of frontmatter, so the file stays the
payload sync carries.

The project name is now plain text where the row is mounted without
onProjectClick, as the file page does, rather than a second tab stop that
does nothing beside the real control.

Closes #1941
Also drops the claim that a project chip jumps to the project hub. The
only place those chips are mounted is the file page, which has never
passed a click handler to them.
@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation test labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

React Doctor found no new issues. 🎉

Reviewed by React Doctor for commit 482851e.

The calendar widget refresh suite faked the clock onto 2026-08-31, the day it was
written. use-today snapshots the local date into module scope at import and re-reads
the wall clock for its first subscriber, so on every later day that fake clock arrives
as a midnight rollover. todayCalendarRange moves, the useCalendarRange query key moves
with it, and the widget fetches a second day during mount, which is the second
getRange call the first test counted.

Derive the fixture clock and its event hours from the real local date instead, so the
mount no longer straddles a day boundary. Local date fields rather than a UTC instant,
because far enough from UTC the two name different days.
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The file sat in the tsconfig.test.web.json exclude backlog, so the four
new cases compiled nowhere and the typecheck:test gate said nothing about
them. Taking it off the list surfaced one real error: the hoisted
onProjectUpdated mock declared no parameters, which the subscribe-capture
case then could not implement. That list only ever shrinks.

Also documents how a calendar event leaves a project, which its own form
has always supported through No project and the per-chip remove.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation test

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: A file assigned to a project cannot be unassigned without deleting the project or the file

1 participant