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
198 changes: 198 additions & 0 deletions app/frontend/src/components/PageTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useEffect, useState, type DragEvent } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { useShell } from '@project-graphite/ui';
import { useAuth } from '../auth';
import { noteTitle, type Note } from '../notes';
import { useSpaces } from '../spaces';
import { useAction } from '../useAction';
import { useResource } from '../useResource';

type Place = 'before' | 'inside' | 'after';

const openKey = 'strata-page-tree-open';

function readOpen() {
try {
return new Set<string>(JSON.parse(localStorage.getItem(openKey) ?? '[]') as string[]);
} catch {
return new Set<string>();
}
}

function placeFor(event: DragEvent<HTMLElement>, nests: boolean): Place {
const box = event.currentTarget.getBoundingClientRect();
const share = (event.clientY - box.top) / box.height;
if (share < 0.3) return 'before';
if (share > 0.7 || !nests) return 'after';
return 'inside';
}

export function PageTree() {
const { collapsed } = useShell();
const auth = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const spaces = useSpaces();
const listed = useResource<Note[]>(collapsed ? null : '/me/notes', true);
const action = useAction();
const [open, setOpen] = useState(readOpen);
const [dragged, setDragged] = useState<Note>();
const [target, setTarget] = useState<{ id: string; place: Place }>();
const reload = listed.reload;

useEffect(() => {
reload();
}, [pathname, reload]);

if (collapsed || !listed.data || !spaces.data) return null;
const pages = listed.data.filter((note) => !note.template);
const known = new Set(pages.map((note) => note.id));
const childrenOf = (spaceId: string, parentId: string | null) =>
pages
.filter((note) => note.spaceId === spaceId && (note.parentId && known.has(note.parentId) ? note.parentId : null) === parentId)
.sort((a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt));

function toggle(id: string) {
setOpen((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
try {
localStorage.setItem(openKey, JSON.stringify([...next]));
} catch {
return next;
}
return next;
});
}

function addInside(spaceId: string, parentId: string | null) {
void action.run(async () => {
const created = await auth.request<Note>(`/spaces/${spaceId}/notes`, { method: 'POST', body: JSON.stringify(parentId ? { parentId } : {}) });
if (parentId && !open.has(parentId)) toggle(parentId);
reload();
navigate(`/notes/${created.id}`);
return '';
}, 'Could not add the page');
}

function drop(note: Note, onto: Note | null, place: Place, spaceId: string) {
setDragged(undefined);
setTarget(undefined);
if (note.spaceId !== spaceId || note.id === onto?.id) return;
let change: { parentId: string | null; position?: number };
if (!onto || place === 'inside') {
change = { parentId: onto?.id ?? null };
} else {
const siblings = childrenOf(spaceId, onto.parentId).filter((sibling) => sibling.id !== note.id);
const neighbour = siblings[siblings.indexOf(onto) + (place === 'before' ? -1 : 1)];
change = {
parentId: onto.parentId,
position: neighbour ? (onto.position + neighbour.position) / 2 : onto.position + (place === 'before' ? -1 : 1),
};
}
void action.run(async () => {
await auth.request(`/notes/${note.id}`, { method: 'PATCH', body: JSON.stringify(change) });
if (change.parentId && !open.has(change.parentId)) toggle(change.parentId);
reload();
return '';
}, 'Could not move the page');
}

function branch(spaceId: string, parentId: string | null, depth: number) {
return childrenOf(spaceId, parentId).map((note) => {
const nested = childrenOf(spaceId, note.id);
const expanded = open.has(note.id);
const nests = note.kind !== 'board';
return (
<div key={note.id}>
<div
className="page-tree-row"
data-drop={target?.id === note.id ? target.place : undefined}
draggable
onDragEnd={() => {
setDragged(undefined);
setTarget(undefined);
}}
onDragOver={(event) => {
if (!dragged || dragged.spaceId !== spaceId) return;
event.preventDefault();
const place = placeFor(event, nests);
setTarget((current) => (current?.id === note.id && current.place === place ? current : { id: note.id, place }));
}}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', note.id);
setDragged(note);
}}
onDrop={(event) => {
event.preventDefault();
if (dragged) drop(dragged, note, placeFor(event, nests), spaceId);
}}
style={{ paddingLeft: `${0.5 + depth * 0.9}rem` }}
>
<button
aria-label={expanded ? `Collapse ${noteTitle(note)}` : `Expand ${noteTitle(note)}`}
className="page-tree-toggle"
disabled={nested.length === 0}
onClick={() => toggle(note.id)}
type="button"
>
{nested.length > 0 ? (expanded ? '▾' : '▸') : ''}
</button>
<Link aria-current={pathname === `/notes/${note.id}` ? 'page' : undefined} className="page-tree-link" draggable={false} to={`/notes/${note.id}`}>
<span aria-hidden="true">{note.icon ?? (note.kind === 'board' ? '▦' : '·')}</span>
<span className="min-w-0 flex-1 truncate">{noteTitle(note)}</span>
</Link>
{nests && (
<button aria-label={`Add a page inside ${noteTitle(note)}`} className="page-tree-add" disabled={action.busy} onClick={() => addInside(spaceId, note.id)} type="button">
+
</button>
)}
</div>
{expanded && branch(spaceId, note.id, depth + 1)}
</div>
);
});
}

return (
<section aria-label="Page tree" className="page-tree">
<p className="sidebar-section-label">Pages</p>
{spaces.data.map((space) => {
const key = `space:${space.id}`;
const expanded = open.has(key);
const writable = space.role !== 'viewer';
return (
<div key={space.id}>
<div
className="page-tree-row"
data-drop={target?.id === key ? 'inside' : undefined}
onDragOver={(event) => {
if (!dragged || dragged.spaceId !== space.id) return;
event.preventDefault();
setTarget({ id: key, place: 'inside' });
}}
onDrop={(event) => {
event.preventDefault();
if (dragged) drop(dragged, null, 'inside', space.id);
}}
>
<button aria-label={expanded ? `Collapse ${space.name}` : `Expand ${space.name}`} className="page-tree-toggle" onClick={() => toggle(key)} type="button">
{expanded ? '▾' : '▸'}
</button>
<button className="page-tree-link" onClick={() => toggle(key)} type="button">
<span className="min-w-0 flex-1 truncate text-left">{space.name}</span>
</button>
{writable && (
<button aria-label={`Add a page to ${space.name}`} className="page-tree-add" disabled={action.busy} onClick={() => addInside(space.id, null)} type="button">
+
</button>
)}
</div>
{expanded && branch(space.id, null, 1)}
</div>
);
})}
</section>
);
}
2 changes: 2 additions & 0 deletions app/frontend/src/components/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useAction } from '../useAction';
import { useResource, type Resource } from '../useResource';
import { AccountMenu } from './AccountMenu';
import { CrashBoundary } from './CrashBoundary';
import { PageTree } from './PageTree';
import { SearchPalette } from './SearchPalette';
import { UpdatePrompt } from './UpdatePrompt';
import { StrataMark } from './StrataMark';
Expand Down Expand Up @@ -144,6 +145,7 @@ function ShellFrame({ inbox, spaces }: { inbox: Resource<{ unread: number }>; sp
sidebar={
showSidebar && (
<Sidebar
footer={auth.user && <PageTree />}
label="Areas"
loading={!auth.user || spaces.loading}
sections={areaSections(pathname, spaces.data, unread)}
Expand Down
69 changes: 69 additions & 0 deletions app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -529,3 +529,72 @@ button.habit-day {
.journal-day.mood-5 {
background: var(--color-ink);
}

.page-tree {
display: grid;
gap: 0.05rem;
margin-top: 1rem;
}

.page-tree-row {
display: flex;
align-items: center;
gap: 0.2rem;
border-radius: var(--radius-field);
padding-right: 0.25rem;
color: var(--color-muted);
font-size: 0.875rem;
}

.page-tree-row:hover {
background: var(--color-line-soft);
color: var(--color-ink);
}

.page-tree-row[data-drop='inside'] {
background: var(--color-line-soft);
outline: 1px solid var(--color-line);
}

.page-tree-row[data-drop='before'] {
box-shadow: inset 0 2px 0 var(--color-ink);
}

.page-tree-row[data-drop='after'] {
box-shadow: inset 0 -2px 0 var(--color-ink);
}

.page-tree-toggle {
width: 1rem;
flex-shrink: 0;
color: var(--color-faint);
font-size: 0.7rem;
}

.page-tree-link {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0;
color: inherit;
text-decoration: none;
}

.page-tree-link[aria-current='page'] {
color: var(--color-ink);
font-weight: 560;
}

.page-tree-add {
visibility: hidden;
width: 1.25rem;
border-radius: var(--radius-field);
color: var(--color-faint);
}

.page-tree-row:hover .page-tree-add {
visibility: visible;
}

40 changes: 40 additions & 0 deletions app/frontend/test/notes.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,46 @@ describe('Notes', () => {
expect(container.querySelector('[data-testid="board"]')?.getAttribute('data-note')).toBe('fresh');
});

it('shows a page tree in the sidebar, adds pages inside and nests by dragging', async () => {
localStorage.removeItem('strata-page-tree-open');
const sent: { path: string; body: unknown }[] = [];
serve((path, init) => {
if (path === '/me/notes') return json([note('lisbon', 'Lisbon', 'trips'), note('trips', 'Trips'), note('food', 'Food')]);
if (init?.method === 'PATCH' || init?.method === 'POST') {
sent.push({ path, body: JSON.parse(String(init.body)) });
return json(note('fresh', ''), 201);
}
if (path === '/notes/fresh') return json({ ...note('fresh', ''), editable: true, path: [] });
return undefined;
});
await render('/');
const tree = () => container.querySelector('[aria-label="Page tree"]')!;
const press = (label: string) => act(async () => tree().querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!.click());

await press('Expand Home');
expect([...tree().querySelectorAll('a')].map((link) => link.textContent)).toEqual(['·Trips', '·Food']);
await press('Expand Trips');
expect([...tree().querySelectorAll('a')].map((link) => link.textContent)).toEqual(['·Trips', '·Lisbon', '·Food']);

const rows = [...tree().querySelectorAll<HTMLElement>('.page-tree-row')];
const food = rows.find((row) => row.textContent?.includes('Food'))!;
const trips = rows.find((row) => row.textContent?.includes('Trips'))!;
const dataTransfer = new DataTransfer();
const drag = (type: string) => Object.defineProperty(new Event(type, { bubbles: true, cancelable: true }), 'dataTransfer', { value: dataTransfer });
await act(async () => {
food.dispatchEvent(drag('dragstart'));
});
await act(async () => {
trips.dispatchEvent(drag('dragover'));
trips.dispatchEvent(drag('drop'));
});
expect(sent).toEqual([{ path: '/notes/food', body: { parentId: 'trips' } }]);

await press('Add a page inside Trips');
expect(sent[1]).toEqual({ path: '/spaces/home/notes', body: { parentId: 'trips' } });
expect(container.querySelector('[data-testid="editor"]')?.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') {
Expand Down
3 changes: 2 additions & 1 deletion app/server/src/notes/dto/notes.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export class UpdateCommentDto {
}

export class PageKindQueryDto {
@IsOptional()
@IsIn(['note', 'board'], { message: 'Choose note or board.' })
kind!: 'note' | 'board';
kind?: 'note' | 'board';
}
6 changes: 3 additions & 3 deletions app/server/src/notes/notes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,17 @@ export class NotesService {
return rows.map(present);
}

async everywhere(userId: string, kind: 'note' | 'board') {
async everywhere(userId: string, kind: 'note' | 'board' | undefined) {
const rows = await this.prisma.item.findMany({
where: {
kind: kind === 'board' ? ItemKind.BOARD : ItemKind.NOTE,
kind: kind === 'board' ? ItemKind.BOARD : kind === 'note' ? ItemKind.NOTE : { in: pageKinds },
trashedAt: null,
archivedAt: null,
space: this.access.spacesOf(userId),
note: { template: false },
},
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
take: 200,
take: 500,
select: noteFields,
});
return rows.map(present);
Expand Down
1 change: 1 addition & 0 deletions app/server/test/integration/notes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ describe('Notes and real-time editing against Postgres', () => {
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 friend.call('GET', '/me/notes')).body.map((found: { id: string }) => found.id).sort()).toEqual([board.id, page.id].sort());
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);
Expand Down
Loading