From 31ea2455659f60d8c836522fade5f4bae4f80d63 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:42:39 +0300 Subject: [PATCH] feat: give boards and pages their own places in the sidebar --- app/frontend/src/App.tsx | 17 +++++ app/frontend/src/navigation.tsx | 4 +- app/frontend/src/pages/PagesIndex.tsx | 82 +++++++++++++++++++++++ app/frontend/test/notes.spec.tsx | 21 ++++++ app/server/src/notes/dto/notes.dto.ts | 5 ++ app/server/src/notes/notes.controller.ts | 10 ++- app/server/src/notes/notes.service.ts | 16 +++++ app/server/test/integration/notes.spec.ts | 4 ++ 8 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 app/frontend/src/pages/PagesIndex.tsx diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 66dc411..2625f2a 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -13,6 +13,7 @@ import { InboxPage } from './pages/InboxPage'; import { InvitationsPage } from './pages/InvitationsPage'; import { InvitePage } from './pages/InvitePage'; import { JournalDayPage, JournalPage } from './pages/JournalPage'; +import { PagesIndex } from './pages/PagesIndex'; import { PrivacyPage, TermsPage } from './pages/LegalPages'; import { RecurringPage } from './pages/RecurringPage'; import { LoginPage } from './pages/LoginPage'; @@ -125,6 +126,22 @@ export function App() { } /> + + + + } + /> + + + + } + /> (`/me/notes?kind=${kind}`, true); + const creating = useAction(); + const words = wording[kind]; + const writable = (spaces.data ?? []).filter((space) => space.role !== 'viewer'); + + function create(spaceId: string) { + void creating.run(async () => { + const created = await auth.request(`/spaces/${spaceId}/notes`, { method: 'POST', body: JSON.stringify(kind === 'board' ? { board: true } : {}) }); + navigate(`/notes/${created.id}`); + return ''; + }, `Could not create the ${kind === 'board' ? 'board' : 'page'}`); + } + + return ( +
+ create(writable[0]!.id)} type="button"> + + {words.create} + + ) : ( + writable.length > 1 && ( + ({ label: `In ${space.name}`, onSelect: () => create(space.id) }))} + label="Spaces" + trigger={words.create} + triggerClassName="primary-button px-3 py-2 text-sm" + triggerLabel={`${words.create}: choose a space`} + /> + ) + ) + } + title={words.title} + /> + {listed.error ? ( + + ) : !listed.data ? ( + + ) : listed.data.length === 0 ? ( + +

{words.hint}

+
+ ) : ( +
    + {listed.data.map((note) => ( +
  • + + + {noteTitle(note)} + + {spaces.data?.find((space) => space.id === note.spaceId)?.name} · {timeAgo(note.updatedAt)} + + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/frontend/test/notes.spec.tsx b/app/frontend/test/notes.spec.tsx index 19adeb3..80b1d7c 100644 --- a/app/frontend/test/notes.spec.tsx +++ b/app/frontend/test/notes.spec.tsx @@ -139,6 +139,27 @@ describe('Notes', () => { expect(container.textContent).not.toContain('Pages inside'); }); + it('lists boards from every space under Boards and starts a new one', async () => { + const board = { ...note('seating', 'Seating'), kind: 'board' as const }; + const fetchMock = serve((path, init) => { + if (path === '/me/notes?kind=board') return json([board]); + if (path === '/spaces/home/notes' && init?.method === 'POST') { + expect(JSON.parse(String(init.body))).toEqual({ board: true }); + return json({ ...board, id: 'fresh', title: '' }, 201); + } + if (path === '/notes/fresh') return json({ ...board, id: 'fresh', title: '', editable: true, path: [] }); + return undefined; + }); + await render('/boards'); + + expect(container.querySelector('h1')?.textContent).toBe('Boards'); + expect(container.querySelector('a[href="/boards"]')).not.toBeNull(); + expect([...container.querySelectorAll('main a[href^="/notes/"]')].map((link) => link.textContent)).toEqual([expect.stringContaining('Seating')]); + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'New board')!.click()); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/spaces/home/notes', expect.objectContaining({ method: 'POST' })); + expect(container.querySelector('[data-testid="board"]')?.getAttribute('data-note')).toBe('fresh'); + }); + it('shows the path, renames on blur and adds a page inside', async () => { const fetchMock = serve((path, init) => { if (path === '/notes/food' && init?.method === 'PATCH') { diff --git a/app/server/src/notes/dto/notes.dto.ts b/app/server/src/notes/dto/notes.dto.ts index 66f1446..079c129 100644 --- a/app/server/src/notes/dto/notes.dto.ts +++ b/app/server/src/notes/dto/notes.dto.ts @@ -143,3 +143,8 @@ export class UpdateCommentDto { @Length(1, 2_000, { message: 'Comments are between 1 and 2,000 characters long.' }) body!: string; } + +export class PageKindQueryDto { + @IsIn(['note', 'board'], { message: 'Choose note or board.' }) + kind!: 'note' | 'board'; +} diff --git a/app/server/src/notes/notes.controller.ts b/app/server/src/notes/notes.controller.ts index 07d9391..d27c8fd 100644 --- a/app/server/src/notes/notes.controller.ts +++ b/app/server/src/notes/notes.controller.ts @@ -1,10 +1,10 @@ -import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; import { Scope } from '../access-tokens/scopes'; import { AuthenticatedUser } from '../auth/auth.types'; import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { UuidPipe } from '../validation/uuid.pipe'; -import { CreateNoteDto, SaveDatabaseDto, SetPropertiesDto, UpdateNoteDto } from './dto/notes.dto'; +import { CreateNoteDto, PageKindQueryDto, SaveDatabaseDto, SetPropertiesDto, UpdateNoteDto } from './dto/notes.dto'; import { NoteDatabasesService } from './note-databases.service'; import { NoteVersionsService } from './note-versions.service'; import { NotesService } from './notes.service'; @@ -24,6 +24,12 @@ export class NotesController { return this.notes.mine(user.id); } + @Get('me/notes') + @Scope('items:read') + everywhere(@CurrentUser() user: AuthenticatedUser, @Query() query: PageKindQueryDto) { + return this.notes.everywhere(user.id, query.kind); + } + @Get('spaces/:spaceId/notes') @Scope('items:read') list(@CurrentUser() user: AuthenticatedUser, @Param('spaceId', UuidPipe) spaceId: string) { diff --git a/app/server/src/notes/notes.service.ts b/app/server/src/notes/notes.service.ts index c36c56f..85df195 100644 --- a/app/server/src/notes/notes.service.ts +++ b/app/server/src/notes/notes.service.ts @@ -59,6 +59,22 @@ export class NotesService { return rows.map(present); } + async everywhere(userId: string, kind: 'note' | 'board') { + const rows = await this.prisma.item.findMany({ + where: { + kind: kind === 'board' ? ItemKind.BOARD : ItemKind.NOTE, + trashedAt: null, + archivedAt: null, + space: this.access.spacesOf(userId), + note: { template: false }, + }, + orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], + take: 200, + select: noteFields, + }); + return rows.map(present); + } + async mine(userId: string) { const where = { kind: { in: pageKinds }, trashedAt: null, archivedAt: null, space: this.access.spacesOf(userId) }; const [pinned, recent] = await Promise.all([ diff --git a/app/server/test/integration/notes.spec.ts b/app/server/test/integration/notes.spec.ts index 9638504..5dc25b7 100644 --- a/app/server/test/integration/notes.spec.ts +++ b/app/server/test/integration/notes.spec.ts @@ -190,6 +190,10 @@ describe('Notes and real-time editing against Postgres', () => { const board = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Seating', board: true })).body; expect(board).toMatchObject({ kind: 'board', title: 'Seating' }); expect((await owner.call('GET', `/spaces/${space.id}/notes`)).body).toEqual([expect.objectContaining({ id: board.id, kind: 'board' })]); + const page = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Menu' })).body; + expect((await friend.call('GET', '/me/notes?kind=board')).body.map((found: { id: string }) => found.id)).toEqual([board.id]); + expect((await friend.call('GET', '/me/notes?kind=note')).body.map((found: { id: string }) => found.id)).toEqual([page.id]); + expect((await owner.call('GET', '/me/notes?kind=task')).status).toBe(400); expect((await owner.call('POST', `/spaces/${space.id}/notes`, { board: true, template: 'meeting' })).status).toBe(400); expect((await owner.call('PATCH', `/notes/${board.id}`, { template: true })).status).toBe(400); expect((await owner.call('POST', `/spaces/${space.id}/notes`, { parentId: board.id })).status).toBe(400);