Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apps/desktop/src/main/tasks/project-item-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,37 @@ describe('project item link reroute', () => {
expect(setEntityProperties).toHaveBeenCalledWith('n1', { project: ['Beta'] })
})

// The chips on the file page are the only unassign surface a binary file
// has. It owns no frontmatter, so the row in `project_links` is the whole of
// its membership and this branch is the entire write.
it('deletes the link row for a file on unlink, writing no frontmatter', async () => {
isMarkdownNote.mockReturnValue(false)
domainUnlink.mockResolvedValue({ success: true })

const result = await unlinkProjectItem({} as never, domain as never, {
projectId: 'p1',
itemType: 'file',
itemId: 'f1'
})

expect(result).toEqual({ success: true })
expect(domainUnlink).toHaveBeenCalledWith({ projectId: 'p1', itemType: 'file', itemId: 'f1' })
expect(setEntityProperties).not.toHaveBeenCalled()
})

it('reports a failed file unlink instead of claiming success', async () => {
isMarkdownNote.mockReturnValue(false)
domainUnlink.mockResolvedValue({ success: false })

expect(
await unlinkProjectItem({} as never, domain as never, {
projectId: 'p1',
itemType: 'file',
itemId: 'f1'
})
).toEqual({ success: false, error: 'Failed to unlink item' })
})

it('errors when the project does not exist', async () => {
isMarkdownNote.mockReturnValue(true)
getProjectById.mockReturnValue(undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = {
note: 'note'
} as const

/**
* The instant these tests pretend it is, on today's real local date.
*
* `use-today` snapshots the local date into module scope at import and re-reads the wall clock
* for its first subscriber. A clock faked onto any other date therefore arrives as a midnight
* rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget
* fetch a second day on mount. Only the time of day is pinned, and from local fields rather than
* a UTC instant, which far enough from UTC would name a different day.
*/
const NOW = new Date()
NOW.setHours(9, 30, 0, 0)

function todayAtHour(hour: number): string {
const at = new Date(NOW)
at.setHours(hour, 0, 0, 0)
return at.toISOString()
}

function projectionItem(
id: string,
title: string,
hourUtc: number,
hour: number,
visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE
): CalendarProjectionItem {
return {
Expand All @@ -43,8 +61,8 @@ function projectionItem(
sourceId: id,
title,
descriptionPreview: null,
startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`,
endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`,
startAt: todayAtHour(hour),
endAt: todayAtHour(hour + 1),
isAllDay: false,
timezone: 'UTC',
visualType,
Expand Down Expand Up @@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } {

describe('home calendar widget stays current', () => {
beforeEach(() => {
vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z'))
vi.setSystemTime(NOW)
listeners.clear()
server.items = [projectionItem('e1', 'Standup', 10, 'event')]
mockGetRange.mockReset()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,32 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ItemProjectChips } from './item-project-chips'

const { mockListForItem, mockOnProjectUpdated } = vi.hoisted(() => ({
mockListForItem: vi.fn(),
mockOnProjectUpdated: vi.fn(() => () => {})
}))
const { mockListForItem, mockOnProjectUpdated, mockUnlinkProjectItem, mockToastError } = vi.hoisted(
() => ({
mockListForItem: vi.fn(),
// Declares the subscriber parameter the component actually passes, so
// the mockImplementation below that captures it still typechecks.
mockOnProjectUpdated: vi.fn((_callback: () => void) => () => {}),
mockUnlinkProjectItem: vi.fn(),
mockToastError: vi.fn()
})
)

vi.mock('@/services/tasks-service', () => ({
tasksService: {
listForItem: mockListForItem
listForItem: mockListForItem,
unlinkProjectItem: mockUnlinkProjectItem
},
onProjectUpdated: mockOnProjectUpdated
}))

vi.mock('sonner', () => ({ toast: { error: mockToastError } }))

describe('ItemProjectChips', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOnProjectUpdated.mockReturnValue(() => {})
mockUnlinkProjectItem.mockResolvedValue({ success: true })
})

it('renders a chip per linked project', async () => {
Expand Down Expand Up @@ -79,4 +89,69 @@ describe('ItemProjectChips', () => {
expect(await screen.findByText('Launch')).toBeInTheDocument()
expect(mockListForItem).toHaveBeenCalledTimes(2)
})

// A binary file has no frontmatter, so these chips are the whole of its
// project membership UI. Without a remove control on them the only exits
// from a project are destructive (issue #1941).
it('unlinks the item when a chip remove control is used', async () => {
mockListForItem
.mockResolvedValueOnce([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])
.mockResolvedValue([])

render(<ItemProjectChips itemType="file" itemId="f1" />)

fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i }))

await waitFor(() =>
expect(mockUnlinkProjectItem).toHaveBeenCalledWith({
projectId: 'p1',
itemType: 'file',
itemId: 'f1'
})
)
await waitFor(() => expect(screen.queryByText('Launch')).not.toBeInTheDocument())
})

it('gives each chip its own remove control, naming the project it drops', async () => {
mockListForItem.mockResolvedValue([
{ id: 'p1', name: 'Launch', color: '#f00', icon: null },
{ id: 'p2', name: 'Finance', color: '#0f0', icon: null }
])

render(<ItemProjectChips itemType="file" itemId="f1" />)

fireEvent.click(await screen.findByRole('button', { name: /remove from finance/i }))

await waitFor(() =>
expect(mockUnlinkProjectItem).toHaveBeenCalledWith({
projectId: 'p2',
itemType: 'file',
itemId: 'f1'
})
)
})

it('renders the name as plain text where there is nowhere to navigate', async () => {
mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])

render(<ItemProjectChips itemType="file" itemId="f1" />)

expect(await screen.findByText('Launch')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /open project launch/i })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /remove from launch/i })).toBeInTheDocument()
})

// The main side answers a failed unlink with an envelope rather than a
// rejection, so a bare await would read it as success and blank the chip.
it('keeps the chip and reports a rejected unlink', async () => {
mockListForItem.mockResolvedValue([{ id: 'p1', name: 'Launch', color: '#f00', icon: null }])
mockUnlinkProjectItem.mockResolvedValue({ success: false, error: 'link is gone' })

render(<ItemProjectChips itemType="file" itemId="f1" />)

fireEvent.click(await screen.findByRole('button', { name: /remove from launch/i }))

await waitFor(() => expect(mockToastError).toHaveBeenCalledWith('link is gone'))
expect(screen.getByText('Launch')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
import { X } from '@/lib/icons'
import { cn } from '@/lib/utils'
import {
tasksService,
Expand All @@ -12,6 +14,19 @@ import { useT } from '@memry/i18n/renderer'

const log = createLogger('ItemProjectChips')

function ProjectChipLabel({ project }: { project: ProjectRef }): React.JSX.Element {
return (
<>
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: project.color }}
aria-hidden="true"
/>
<span className="max-w-32 truncate">{project.name}</span>
</>
)
}

interface ItemProjectChipsProps {
itemType: ProjectItemType
itemId: string
Expand All @@ -26,9 +41,16 @@ interface ItemProjectChipsProps {
}

/**
* Small pill row showing the projects an item (note/calendar event) belongs
* to, via `project_links`. Shared between the note view and the calendar
* event popover.
* Small pill row showing the projects an item belongs to, via `project_links`,
* with a remove control per chip.
*
* The remove control is not optional. This row is the only place a binary
* file's project membership is ever shown — a file has no frontmatter, so it
* has no `project` property row to edit the way a markdown note does — and
* three separate surfaces can add one (sidebar drag, the file page's "Add to
* project", the project hub's paperclip import). Without it the membership is
* a one-way door out of which the only exits are deleting the project or the
* file.
*/
export const ItemProjectChips = ({
itemType,
Expand Down Expand Up @@ -63,6 +85,20 @@ export const ItemProjectChips = ({

useEffect(() => onProjectUpdated(() => void load()), [load])

// The main side answers a failed unlink with an error envelope rather than a
// rejection, so the envelope has to be checked before the reload; and the
// reload runs either way so the row shows what the DB actually holds.
const handleRemove = async (projectId: string): Promise<void> => {
try {
const result = await tasksService.unlinkProjectItem({ projectId, itemType, itemId })
if (!result.success) throw new Error(result.error)
} catch (error) {
log.error('Failed to remove item from project', extractErrorMessage(error))
toast.error(extractErrorMessage(error, t('itemProjects.removeFailed')))
}
await load()
}

if (!isLoading && projects.length === 0) return null

const visible = maxVisible === undefined ? projects : projects.slice(0, maxVisible)
Expand All @@ -71,23 +107,37 @@ export const ItemProjectChips = ({
return (
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
{visible.map((project) => (
<button
<span
key={project.id}
type="button"
aria-label={t('itemProjects.openProject', { name: project.name })}
onClick={() => onProjectClick?.(project.id)}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs',
'transition-colors hover:bg-muted/70'
)}
className="inline-flex items-center rounded-full border border-border bg-muted/40 text-xs"
>
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: project.color }}
aria-hidden="true"
/>
<span className="max-w-32 truncate">{project.name}</span>
</button>
{/* The name is a control only where it leads somewhere. The file page
mounts this row without `onProjectClick`, and a second tab stop
that does nothing beside the real remove control is worse than
plain text. */}
{onProjectClick ? (
<button
type="button"
aria-label={t('itemProjects.openProject', { name: project.name })}
onClick={() => onProjectClick(project.id)}
className="inline-flex items-center gap-1.5 rounded-full ps-2 pe-1 py-0.5 transition-colors hover:bg-muted/70"
>
<ProjectChipLabel project={project} />
</button>
) : (
<span className="inline-flex items-center gap-1.5 ps-2 pe-1 py-0.5">
<ProjectChipLabel project={project} />
</span>
)}
<button
type="button"
aria-label={t('itemProjects.removeFromProject', { name: project.name })}
onClick={() => void handleRemove(project.id)}
className="rounded-full ps-0.5 pe-1.5 py-0.5 text-muted-foreground transition-colors hover:text-destructive"
>
<X className="size-3" />
</button>
</span>
))}
{hiddenCount > 0 && (
<span
Expand Down
Loading
Loading