diff --git a/app/frontend/src/agenda.ts b/app/frontend/src/agenda.ts index 32beba6..fcc9544 100644 --- a/app/frontend/src/agenda.ts +++ b/app/frontend/src/agenda.ts @@ -51,6 +51,13 @@ export function addDays(key: string, days: number) { return dayKey(date); } +export function monthDays(anchor: string) { + const date = new Date(`${anchor}T00:00:00`); + const first = dayKey(new Date(date.getFullYear(), date.getMonth(), 1)); + const from = addDays(first, -((new Date(`${first}T00:00:00`).getDay() + 6) % 7)); + return { from, to: addDays(from, 42), days: 42 }; +} + export function entryDay(entry: AgendaEntry) { return entry.allDay ? entry.start : dayKey(new Date(entry.start)); } diff --git a/app/frontend/src/components/database/DatabaseView.tsx b/app/frontend/src/components/database/DatabaseView.tsx new file mode 100644 index 0000000..4ba9746 --- /dev/null +++ b/app/frontend/src/components/database/DatabaseView.tsx @@ -0,0 +1,299 @@ +import { useState, type ReactNode } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { Icon, ListSkeleton } from '@project-graphite/ui'; +import { addDays, dayKey, monthDays } from '../../agenda'; +import { useAuth } from '../../auth'; +import { databaseViews, noteTitle, type DatabaseRow, type Note, type NoteDatabase, type Property, type PropertyValues } from '../../notes'; +import { useAction } from '../../useAction'; +import { useResource } from '../../useResource'; +import { LoadError } from '../LoadError'; +import { PropertiesDialog } from './PropertiesDialog'; +import { PropertyField, propertyDisplay, type Member } from './PropertyField'; + +const blank = (display: ReactNode) => display === null || (Array.isArray(display) && display.length === 0); + +export function DatabaseView({ editable, noteId, spaceId }: { editable: boolean; noteId: string; spaceId: string }) { + const auth = useAuth(); + const navigate = useNavigate(); + const database = useResource(`/notes/${noteId}/database`, true); + const members = useResource(database.data?.properties.some((property) => property.type === 'person') ? `/spaces/${spaceId}/members` : null, true); + const saving = useAction(); + const adding = useAction(); + const [editing, setEditing] = useState(false); + const [month, setMonth] = useState(() => dayKey(new Date())); + + if (database.error) return ; + if (!database.data) return ; + const data = database.data; + const people = members.data ?? []; + const selects = data.properties.filter((property) => property.type === 'select'); + const dates = data.properties.filter((property) => property.type === 'date'); + const group = selects.find((property) => property.id === data.groupBy) ?? selects[0]; + const dateProperty = dates.find((property) => property.id === data.dateBy) ?? dates[0]; + + function saveSchema(next: Partial>) { + if (!editable) { + database.mutate((current) => ({ ...current, ...next })); + return Promise.resolve(true); + } + return saving.run(async () => { + const body = { properties: data.properties, view: data.view, groupBy: data.groupBy, dateBy: data.dateBy, ...next }; + const saved = await auth.request(`/notes/${noteId}/database`, { method: 'PUT', body: JSON.stringify(body) }); + database.mutate(() => saved); + return ''; + }, 'Could not change the database'); + } + + function setValues(rowId: string, values: PropertyValues) { + void saving.run(async () => { + const saved = await auth.request<{ values: PropertyValues }>(`/notes/${rowId}/properties`, { method: 'PATCH', body: JSON.stringify({ values }) }); + database.mutate((current) => ({ ...current, rows: current.rows.map((row) => (row.id === rowId ? { ...row, values: saved.values } : row)) })); + return ''; + }, 'Could not save that'); + } + + function addRow(values: PropertyValues = {}) { + void adding.run(async () => { + const created = await auth.request(`/spaces/${spaceId}/notes`, { method: 'POST', body: JSON.stringify({ parentId: noteId }) }); + if (Object.keys(values).length) await auth.request(`/notes/${created.id}/properties`, { method: 'PATCH', body: JSON.stringify({ values }) }); + navigate(`/notes/${created.id}`); + return ''; + }, 'Could not add the page'); + } + + const chips = (row: DatabaseRow, skip?: string) => + data.properties + .filter((property) => property.id !== skip) + .map((property) => [property, propertyDisplay(property, row.values[property.id], people)] as const) + .filter(([, display]) => !blank(display)) + .slice(0, 3) + .map(([property, display]) => ( + + {display} + + )); + + const title = (row: DatabaseRow) => ( + + {row.icon ? `${row.icon} ` : ''} + {noteTitle(row)} + + ); + + const chooser = (label: string, options: Property[], chosen: Property | undefined, key: 'groupBy' | 'dateBy') => + options.length > 1 && ( + + ); + + const empty =

No pages in this database yet.

; + + function body() { + if (data.view === 'board') { + if (!group) return

Add a select property to sort pages into columns.

; + return ( + <> + {chooser('Group by', selects, group, 'groupBy')} +
+ {[...(group.options ?? []), { id: '', name: `No ${group.name}` }].map((column) => { + const cards = data.rows.filter((row) => (row.values[group.id] ?? '') === column.id); + return ( +
editable && event.preventDefault()} + onDrop={(event) => { + const row = data.rows.find((candidate) => candidate.id === event.dataTransfer.getData('text/plain')); + if (row && (row.values[group.id] ?? '') !== column.id) setValues(row.id, { [group.id]: column.id || null }); + }} + > +

+ {column.name} + {cards.length} +

+ {cards.map((card) => ( +
event.dataTransfer.setData('text/plain', card.id)}> + {title(card)} +
{chips(card, group.id)}
+
+ ))} + {editable && ( + + )} +
+ ); + })} +
+ + ); + } + if (data.view === 'calendar') { + if (!dateProperty) return

Add a date property to place pages on a calendar.

; + const { from, days } = monthDays(month); + const shown = new Date(`${month}T00:00:00`); + const shift = (direction: number) => setMonth(dayKey(new Date(shown.getFullYear(), shown.getMonth() + direction, 1))); + const byDay = new Map(); + for (const row of data.rows) { + const day = row.values[dateProperty.id]; + if (typeof day === 'string') byDay.set(day, [...(byDay.get(day) ?? []), row]); + } + const undated = data.rows.filter((row) => typeof row.values[dateProperty.id] !== 'string'); + const today = dayKey(new Date()); + return ( + <> +
+ + + {shown.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })} + {chooser('Dates from', dates, dateProperty, 'dateBy')} +
+
+ {Array.from({ length: 7 }, (_, index) => addDays(from, index)).map((day) => ( +

+ {new Date(`${day}T00:00:00`).toLocaleDateString(undefined, { weekday: 'short' })} +

+ ))} + {Array.from({ length: days }, (_, index) => addDays(from, index)).map((day) => ( +
+
+ {new Date(`${day}T00:00:00`).getDate()} + {editable && ( + + )} +
+ {(byDay.get(day) ?? []).map((row) => ( + + {title(row)} + + ))} +
+ ))} +
+ {undated.length > 0 && ( +

+ No date: + {undated.map((row) => ( + {title(row)} + ))} +

+ )} + + ); + } + if (data.rows.length === 0) return empty; + if (data.view === 'list') { + return ( +
    + {data.rows.map((row) => ( +
  • + {title(row)} + {chips(row)} +
  • + ))} +
+ ); + } + return ( +
+ + + + + {data.properties.map((property) => ( + + ))} + + + + {data.rows.map((row) => ( + + + {data.properties.map((property) => ( + + ))} + + ))} + +
Name + {property.name} +
{title(row)} + setValues(row.id, { [property.id]: value })} + property={property} + value={row.values[property.id]} + /> +
+
+ ); + } + + return ( +
+
+
+ {databaseViews.map(([view, label]) => ( + + ))} +
+ {editable && ( +
+ + +
+ )} +
+ {body()} + {editing && ( + setEditing(false)} + onSave={(properties) => + void saveSchema({ + properties, + groupBy: properties.some((property) => property.id === data.groupBy && property.type === 'select') ? data.groupBy : null, + dateBy: properties.some((property) => property.id === data.dateBy && property.type === 'date') ? data.dateBy : null, + }).then((saved) => saved && setEditing(false)) + } + properties={data.properties} + /> + )} +
+ ); +} diff --git a/app/frontend/src/components/database/PropertiesDialog.tsx b/app/frontend/src/components/database/PropertiesDialog.tsx new file mode 100644 index 0000000..b9ec94d --- /dev/null +++ b/app/frontend/src/components/database/PropertiesDialog.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { Icon, tagColors, type TagColor } from '@project-graphite/ui'; +import { FormDialog } from '../FormDialog'; +import { propertyTypes, type Property, type PropertyType } from '../../notes'; + +const hasOptions = (type: PropertyType) => type === 'select' || type === 'multiSelect'; + +export function PropertiesDialog({ + busy, + onClose, + onSave, + properties, +}: { + busy: boolean; + onClose: () => void; + onSave: (properties: Property[]) => void; + properties: Property[]; +}) { + const [draft, setDraft] = useState(properties); + const change = (id: string, next: Partial) => setDraft((current) => current.map((property) => (property.id === id ? { ...property, ...next } : property))); + + return ( + + onSave( + draft + .map((property) => ({ ...property, name: property.name.trim() || 'Untitled' })) + .map(({ options, ...property }) => + hasOptions(property.type) ? { ...property, options: (options ?? []).filter((option) => option.name.trim()).map((option) => ({ ...option, name: option.name.trim() })) } : property, + ), + ) + } + submitLabel="Save properties" + title="Properties" + > + {draft.length === 0 &&

Pages in this database have no properties yet.

} +
    + {draft.map((property) => ( +
  • +
    + change(property.id, { name: event.currentTarget.value })} + value={property.name} + /> + + +
    + {hasOptions(property.type) && ( +
    + {(property.options ?? []).map((option) => ( +
    + + change(property.id, { + options: property.options?.map((candidate) => (candidate.id === option.id ? { ...candidate, name: event.currentTarget.value } : candidate)), + }) + } + value={option.name} + /> + + +
    + ))} + +
    + )} +
  • + ))} +
+ {draft.length < 30 && ( + + )} +
+ ); +} diff --git a/app/frontend/src/components/database/PropertyField.tsx b/app/frontend/src/components/database/PropertyField.tsx new file mode 100644 index 0000000..52a750e --- /dev/null +++ b/app/frontend/src/components/database/PropertyField.tsx @@ -0,0 +1,127 @@ +import type { ReactNode } from 'react'; +import { Popover, TagChip } from '@project-graphite/ui'; +import type { Property } from '../../notes'; + +export interface Member { + userId: string; + displayName: string; +} + +const optionsOf = (property: Property, ids: unknown) => + (Array.isArray(ids) ? ids : [ids]).flatMap((id) => property.options?.filter((option) => option.id === id) ?? []); + +const asText = (value: unknown) => (value === undefined || value === null ? '' : String(value)); + +export function propertyDisplay(property: Property, value: unknown, members: Member[]): ReactNode { + if (value === undefined || value === null) return null; + switch (property.type) { + case 'select': + case 'multiSelect': + return optionsOf(property, value).map((option) => ); + case 'checkbox': + return value ? property.name : null; + case 'date': + return new Date(`${String(value)}T00:00:00`).toLocaleDateString(); + case 'person': + return members.find((member) => member.userId === value)?.displayName ?? 'Former member'; + case 'link': + return ( + + {new URL(String(value)).hostname} + + ); + case 'number': + return Number(value).toLocaleString(); + default: + return String(value); + } +} + +export function PropertyField({ + editable, + members, + onChange, + property, + value, +}: { + editable: boolean; + members: Member[]; + onChange: (value: unknown) => void; + property: Property; + value: unknown; +}) { + if (!editable) return {propertyDisplay(property, value, members) ?? —}; + const label = property.name; + switch (property.type) { + case 'text': + case 'number': + case 'link': + return ( + { + const next = event.currentTarget.value.trim(); + if (next === asText(value)) return; + onChange(next === '' ? null : property.type === 'number' ? Number(next) : next); + }} + onKeyDown={(event) => event.key === 'Enter' && event.currentTarget.blur()} + type={property.type === 'number' ? 'number' : property.type === 'link' ? 'url' : 'text'} + /> + ); + case 'date': + return onChange(event.currentTarget.value || null)} type="date" value={asText(value)} />; + case 'checkbox': + return onChange(event.currentTarget.checked)} type="checkbox" />; + case 'select': + case 'person': + return ( + + ); + case 'multiSelect': { + const chosen = Array.isArray(value) ? (value as string[]) : []; + return ( + {chosen.length ? propertyDisplay(property, value, members) : —}} + triggerClassName="property-input text-left" + triggerLabel={`Choose ${label}`} + > + {() => ( +
+ {property.options?.length ? ( + property.options.map((option) => ( + + )) + ) : ( +

Add options to this property first.

+ )} +
+ )} +
+ ); + } + } +} diff --git a/app/frontend/src/notes.ts b/app/frontend/src/notes.ts index 34d9cb8..aa27c72 100644 --- a/app/frontend/src/notes.ts +++ b/app/frontend/src/notes.ts @@ -1,3 +1,5 @@ +import type { TagColor } from '@project-graphite/ui'; + export interface Note { id: string; spaceId: string; @@ -7,6 +9,7 @@ export interface Note { icon: string | null; pinnedAt: string | null; template: boolean; + database: boolean; createdAt: string; updatedAt: string; } @@ -14,8 +17,56 @@ export interface Note { export interface NoteDetails extends Note { editable: boolean; path: { id: string; title: string }[]; + row: { properties: Property[]; values: PropertyValues } | null; +} + +export type PropertyType = 'text' | 'number' | 'select' | 'multiSelect' | 'date' | 'checkbox' | 'person' | 'link'; +export type PropertyValues = Record; +export type DatabaseView = 'table' | 'board' | 'list' | 'calendar'; + +export interface PropertyOption { + id: string; + name: string; + color: TagColor; +} + +export interface Property { + id: string; + name: string; + type: PropertyType; + options?: PropertyOption[]; +} + +export interface DatabaseRow extends Note { + values: PropertyValues; } +export interface NoteDatabase { + properties: Property[]; + view: DatabaseView; + groupBy: string | null; + dateBy: string | null; + rows: DatabaseRow[]; +} + +export const propertyTypes: [PropertyType, string][] = [ + ['text', 'Text'], + ['number', 'Number'], + ['select', 'Select'], + ['multiSelect', 'Multi-select'], + ['date', 'Date'], + ['checkbox', 'Checkbox'], + ['person', 'Person'], + ['link', 'Link'], +]; + +export const databaseViews: [DatabaseView, string][] = [ + ['table', 'Table'], + ['board', 'Board'], + ['list', 'List'], + ['calendar', 'Calendar'], +]; + export interface NoteBranch { note: Note; depth: number; diff --git a/app/frontend/src/pages/AgendaPage.tsx b/app/frontend/src/pages/AgendaPage.tsx index 9fd0023..28eba50 100644 --- a/app/frontend/src/pages/AgendaPage.tsx +++ b/app/frontend/src/pages/AgendaPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; import { Icon, ListSkeleton, PageHeader, Tabs } from '@project-graphite/ui'; -import { addDays, dayKey, entryDay, entryLink, entryTime, kindLabels, type AgendaEntry } from '../agenda'; +import { addDays, dayKey, entryDay, entryLink, entryTime, kindLabels, monthDays, type AgendaEntry } from '../agenda'; import { EventEditor } from '../components/EventEditor'; import { useSpaces } from '../spaces'; import { useResource } from '../useResource'; @@ -16,9 +16,7 @@ function range(view: View, anchor: string) { const from = addDays(anchor, -((date.getDay() + 6) % 7)); return { from, to: addDays(from, 7), days: 7 }; } - const first = dayKey(new Date(date.getFullYear(), date.getMonth(), 1)); - const from = addDays(first, -((new Date(`${first}T00:00:00`).getDay() + 6) % 7)); - return { from, to: addDays(from, 42), days: 42 }; + return monthDays(anchor); } function EntryLine({ entry, compact = false }: { entry: AgendaEntry; compact?: boolean }) { diff --git a/app/frontend/src/pages/NotePage.tsx b/app/frontend/src/pages/NotePage.tsx index bbb55c7..69961f2 100644 --- a/app/frontend/src/pages/NotePage.tsx +++ b/app/frontend/src/pages/NotePage.tsx @@ -1,10 +1,12 @@ import { Fragment, lazy, Suspense, useState } from 'react'; import { Link, useNavigate, useParams } from 'react-router'; -import { EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui'; +import { ConfirmDialog, EmptyState, Icon, LinesSkeleton, PageSkeleton } from '@project-graphite/ui'; import { useAuth } from '../auth'; +import { DatabaseView } from '../components/database/DatabaseView'; +import { PropertyField, type Member } from '../components/database/PropertyField'; import { LoadError } from '../components/LoadError'; import { NoteHistory } from '../components/NoteHistory'; -import { noteTitle, type Note, type NoteDetails } from '../notes'; +import { noteTitle, type Note, type NoteDetails, type PropertyValues } from '../notes'; import { itemHref, useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; @@ -22,11 +24,18 @@ export function NotePage() { note.data ? `/items/${note.data.id}/links` : null, true, ); + const members = useResource( + note.data?.row?.properties.some((property) => property.type === 'person') ? `/spaces/${note.data.spaceId}/members` : null, + true, + ); const backlinks = (links.data?.backlinks ?? []).filter((link) => link.kind === 'mention'); const renaming = useAction(); const adding = useAction(); const marking = useAction(); + const converting = useAction(); + const setting = useAction(); const [history, setHistory] = useState(false); + const [unconverting, setUnconverting] = useState(false); if (note.status === 404) { return ( @@ -51,8 +60,32 @@ export function NotePage() { }, 'Could not rename the page'); } + function turnIntoDatabase() { + const option = (name: string, color: string) => ({ id: crypto.randomUUID(), name, color }); + const status = { id: crypto.randomUUID(), name: 'Status', type: 'select', options: [option('To do', 'gray'), option('Doing', 'blue'), option('Done', 'green')] }; + void converting.run(async () => { + await auth.request(`/notes/${details.id}/database`, { + method: 'PUT', + body: JSON.stringify({ properties: [status, { id: crypto.randomUUID(), name: 'Date', type: 'date' }], view: 'table', groupBy: status.id }), + }); + note.mutate((current) => ({ ...current, database: true })); + return ''; + }, 'Could not turn the page into a database'); + } + + function setValue(propertyId: string, value: unknown) { + void setting.run(async () => { + const saved = await auth.request<{ values: PropertyValues }>(`/notes/${details.id}/properties`, { + method: 'PATCH', + body: JSON.stringify({ values: { [propertyId]: value } }), + }); + note.mutate((current) => ({ ...current, row: current.row && { ...current.row, values: saved.values } })); + return ''; + }, 'Could not save that'); + } + return ( -
+
+ {details.editable && ( + + )} {details.editable && (
); diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index 6dd9ad3..34e7346 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -52,6 +52,10 @@ outline: none; } +.database-page .note-content { + min-height: 3rem; +} + .note-content > * + * { margin-top: 0.75em; } @@ -212,3 +216,68 @@ outline: 2px solid var(--color-ink); outline-offset: 2px; } + +.property-input { + width: 100%; + min-width: 8rem; + border: 1px solid transparent; + border-radius: var(--radius-field); + background: transparent; + padding: 0.3rem 0.45rem; + color: var(--color-ink); + font-size: 0.875rem; +} + +.property-input:hover, +.property-input:focus { + border-color: var(--color-line); +} + +.database-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.database-table th, +.database-table td { + border-bottom: 1px solid var(--color-line-soft); + padding: 0.25rem 0.5rem; + text-align: left; + vertical-align: middle; +} + +.database-table thead th { + color: var(--color-muted); + font-weight: 500; + white-space: nowrap; +} + +.database-table tbody th { + font-weight: 500; + white-space: nowrap; +} + +.database-column { + display: grid; + flex: 0 0 15rem; + align-content: start; + gap: 0.5rem; + border-radius: var(--radius-panel); + background: var(--color-line-soft); + padding: 0.75rem; +} + +.database-card { + display: grid; + gap: 0.35rem; + border: 1px solid var(--color-line); + border-radius: var(--radius-field); + background: var(--color-surface); + padding: 0.6rem 0.7rem; + font-size: 0.9rem; +} + +.database-card[draggable='true'] { + cursor: grab; +} diff --git a/app/frontend/test/notes.spec.tsx b/app/frontend/test/notes.spec.tsx index acac998..8f50b6d 100644 --- a/app/frontend/test/notes.spec.tsx +++ b/app/frontend/test/notes.spec.tsx @@ -26,6 +26,7 @@ const note = (id: string, title: string, parentId: string | null = null, positio icon: null, pinnedAt: null, template: false, + database: false, createdAt: '2026-10-05T10:00:00Z', updatedAt: '2026-10-05T10:00:00Z', }); @@ -247,4 +248,134 @@ describe('Notes', () => { await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Stop using as a template')!.click()); expect([...container.querySelectorAll('button')].some((button) => button.textContent === 'Use as a template')).toBe(true); }); + + const status = { + id: 'status', + name: 'Status', + type: 'select', + options: [ + { id: 'todo', name: 'To do', color: 'gray' }, + { id: 'done', name: 'Done', color: 'green' }, + ], + }; + const due = { id: 'due', name: 'Due', type: 'date' }; + const cost = { id: 'cost', name: 'Cost', type: 'number' }; + const repairs = (view: string) => ({ + properties: [status, due, cost], + view, + groupBy: 'status', + dateBy: 'due', + rows: [ + { ...note('boiler', 'Boiler', 'trips'), values: { status: 'todo', due: '2026-10-20', cost: 120 } }, + { ...note('window', 'Window', 'trips'), values: { status: 'done' } }, + ], + }); + + it('turns a page into a database and edits values in its table', async () => { + const sent: { path: string; method?: string; body: unknown }[] = []; + let converted = false; + serve((path, init) => { + if (init?.method) sent.push({ path, method: init.method, body: JSON.parse(String(init.body)) }); + if (path === '/notes/trips' && !init?.method) return json({ ...note('trips', 'Trips'), database: converted, editable: true, path: [], row: null }); + if (path === '/notes/trips/database' && init?.method === 'PUT') { + converted = true; + return json(repairs('table')); + } + if (path === '/notes/trips/database') return json(repairs('table')); + if (path === '/notes/boiler/properties') return json({ values: { status: 'todo', due: '2026-10-20', cost: 95 } }); + return undefined; + }); + await render('/notes/trips'); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Turn into a database')!.click()); + expect(sent[0]).toMatchObject({ path: '/notes/trips/database', method: 'PUT', body: { view: 'table' } }); + expect((sent[0]!.body as { properties: { name: string; type: string }[] }).properties.map(({ name, type }) => `${name}:${type}`)).toEqual(['Status:select', 'Date:date']); + + const table = container.querySelector('table')!; + expect([...table.querySelectorAll('thead th')].map((cell) => cell.textContent)).toEqual(['Name', 'Status', 'Due', 'Cost']); + expect([...table.querySelectorAll('tbody th')].map((cell) => cell.textContent)).toEqual(['Boiler', 'Window']); + const costInput = table.querySelector('tbody tr input[aria-label="Cost"]')!; + expect(costInput.value).toBe('120'); + await act(async () => { + costInput.value = '95'; + costInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(sent[1]).toEqual({ path: '/notes/boiler/properties', method: 'PATCH', body: { values: { cost: 95 } } }); + expect(table.querySelector('tbody tr input[aria-label="Cost"]')!.value).toBe('95'); + }); + + it('groups database pages on a board, lists them and places them on a calendar', async () => { + const sent: unknown[] = []; + serve((path, init) => { + if (path === '/notes/trips' && !init?.method) return json({ ...note('trips', 'Trips'), database: true, editable: true, path: [], row: null }); + if (path === '/notes/trips/database' && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + sent.push(body); + return json(repairs(body.view)); + } + if (path === '/notes/trips/database') return json(repairs('board')); + if (path === '/notes/window/properties') { + sent.push(JSON.parse(String(init!.body))); + return json({ values: { status: 'todo' } }); + } + return undefined; + }); + await render('/notes/trips'); + + const columns = () => [...container.querySelectorAll('.database-column')]; + expect(columns().map((column) => [column.getAttribute('aria-label'), [...column.querySelectorAll('article')].map((card) => card.querySelector('a')!.textContent)])).toEqual([ + ['To do', ['Boiler']], + ['Done', ['Window']], + ['No Status', []], + ]); + const transfer = new Map(); + const dataTransfer = { setData: (type: string, value: string) => transfer.set(type, value), getData: (type: string) => transfer.get(type) ?? '' }; + await act(async () => { + const start = new Event('dragstart', { bubbles: true }); + Object.assign(start, { dataTransfer }); + columns()[1]!.querySelector('article')!.dispatchEvent(start); + const drop = new Event('drop', { bubbles: true }); + Object.assign(drop, { dataTransfer }); + columns()[0]!.dispatchEvent(drop); + }); + expect(sent[0]).toEqual({ values: { status: 'todo' } }); + expect(columns()[0]!.textContent).toContain('Window'); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'List')!.click()); + expect(sent[1]).toMatchObject({ view: 'list', groupBy: 'status', dateBy: 'due' }); + expect([...container.querySelectorAll('section[aria-label="Database"] li')].map((item) => item.textContent)).toEqual([ + expect.stringContaining('Boiler'), + expect.stringContaining('Window'), + ]); + + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Calendar')!.click()); + const later = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Later →')!; + const target = new Date(2026, 9, 20).toLocaleDateString(undefined, { day: 'numeric', month: 'long' }); + for (let step = 0; step < 24 && !container.querySelector(`[role="group"][aria-label="${target}"] a`); step += 1) { + await act(async () => later.click()); + } + expect(container.querySelector(`[role="group"][aria-label="${target}"] a`)?.textContent).toBe('Boiler'); + expect(container.textContent).toContain('No date:'); + }); + + it('shows the properties of a database page above its content and saves changes', async () => { + const fetchMock = serve((path, init) => { + if (path === '/notes/boiler' && !init?.method) { + return json({ ...note('boiler', 'Boiler', 'trips'), editable: true, path: [{ id: 'trips', title: 'Repairs' }], row: { properties: [status, due], values: { status: 'todo' } } }); + } + if (path === '/notes/boiler/properties') return json({ values: { status: 'done' } }); + return undefined; + }); + await render('/notes/boiler'); + + const panel = container.querySelector('dl[aria-label="Properties"]')!; + expect([...panel.querySelectorAll('dt')].map((term) => term.textContent)).toEqual(['Status', 'Due']); + const select = panel.querySelector('select[aria-label="Status"]')!; + await act(async () => { + select.value = 'done'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/notes/boiler/properties', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ values: { status: 'done' } }) })); + expect(panel.querySelector('select[aria-label="Status"]')!.value).toBe('done'); + }); }); diff --git a/app/server/prisma/migrations/20261005080000_note_databases/migration.sql b/app/server/prisma/migrations/20261005080000_note_databases/migration.sql new file mode 100644 index 0000000..82532fc --- /dev/null +++ b/app/server/prisma/migrations/20261005080000_note_databases/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE "database_schemas" ( + "item_id" UUID NOT NULL, + "properties" JSONB NOT NULL DEFAULT '[]', + "view" TEXT NOT NULL DEFAULT 'table', + "group_by" TEXT, + "date_by" TEXT, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "database_schemas_pkey" PRIMARY KEY ("item_id") +); + +CREATE TABLE "note_properties" ( + "item_id" UUID NOT NULL, + "property_id" TEXT NOT NULL, + "value" JSONB NOT NULL, + CONSTRAINT "note_properties_pkey" PRIMARY KEY ("item_id","property_id") +); + +ALTER TABLE "database_schemas" ADD CONSTRAINT "database_schemas_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "note_properties" ADD CONSTRAINT "note_properties_item_id_fkey" FOREIGN KEY ("item_id") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/app/server/prisma/schema.prisma b/app/server/prisma/schema.prisma index 42602e2..b063ee5 100644 --- a/app/server/prisma/schema.prisma +++ b/app/server/prisma/schema.prisma @@ -252,6 +252,8 @@ model Item { childNotes Note[] @relation("NoteParent") noteDocument NoteDocument? noteVersions NoteVersion[] + databaseSchema DatabaseSchema? + properties NoteProperty[] tidyChanges TidyChange[] @@index([spaceId, updatedAt]) @@ -685,3 +687,25 @@ model NoteVersion { @@index([itemId, createdAt]) @@map("note_versions") } + +model DatabaseSchema { + itemId String @id @map("item_id") @db.Uuid + properties Json @default("[]") + view String @default("table") + groupBy String? @map("group_by") + dateBy String? @map("date_by") + updatedAt DateTime @updatedAt @map("updated_at") + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@map("database_schemas") +} + +model NoteProperty { + itemId String @map("item_id") @db.Uuid + propertyId String @map("property_id") + value Json + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + + @@id([itemId, propertyId]) + @@map("note_properties") +} diff --git a/app/server/src/notes/database.ts b/app/server/src/notes/database.ts new file mode 100644 index 0000000..4861f65 --- /dev/null +++ b/app/server/src/notes/database.ts @@ -0,0 +1,69 @@ +export const propertyTypes = ['text', 'number', 'select', 'multiSelect', 'date', 'checkbox', 'person', 'link'] as const; +export const databaseViews = ['table', 'board', 'list', 'calendar'] as const; + +export type PropertyType = (typeof propertyTypes)[number]; + +export interface PropertyOption { + id: string; + name: string; + color: string; +} + +export interface Property { + id: string; + name: string; + type: PropertyType; + options?: PropertyOption[]; +} + +export const propertyRules: Record = { + text: 'is at most 2,000 characters long', + number: 'needs a number', + select: 'needs one of its options', + multiSelect: 'needs some of its options', + date: 'needs a date', + checkbox: 'is either ticked or not', + person: 'needs a member of this space', + link: 'needs an http:// or https:// link', +}; + +export function valueMap(entries: { propertyId: string; value: unknown }[]) { + return Object.fromEntries(entries.map((entry) => [entry.propertyId, entry.value])); +} + +const datePattern = /^\d{4}-\d{2}-\d{2}$/; + +function validDate(value: string) { + const date = new Date(`${value}T00:00:00Z`); + return datePattern.test(value) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value); +} + +function validLink(value: string) { + try { + return value.length <= 2000 && ['http:', 'https:'].includes(new URL(value).protocol); + } catch { + return false; + } +} + +export function propertyValue(property: Property, value: unknown, members: Set): unknown { + const options = new Set((property.options ?? []).map((option) => option.id)); + switch (property.type) { + case 'text': + return typeof value === 'string' && value.trim().length <= 2000 ? value.trim() : undefined; + case 'number': + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; + case 'select': + return typeof value === 'string' && options.has(value) ? value : undefined; + case 'multiSelect': + return Array.isArray(value) && value.every((entry) => typeof entry === 'string' && options.has(entry)) ? [...new Set(value)] : undefined; + case 'date': + return typeof value === 'string' && validDate(value) ? value : undefined; + case 'checkbox': + return typeof value === 'boolean' ? value : undefined; + case 'person': + return typeof value === 'string' && members.has(value) ? value : undefined; + case 'link': + return typeof value === 'string' && validLink(value.trim()) ? value.trim() : undefined; + } +} diff --git a/app/server/src/notes/dto/notes.dto.ts b/app/server/src/notes/dto/notes.dto.ts index fa16c8c..c23bdeb 100644 --- a/app/server/src/notes/dto/notes.dto.ts +++ b/app/server/src/notes/dto/notes.dto.ts @@ -1,5 +1,8 @@ -import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, ValidateIf } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsNumber, IsObject, IsOptional, IsString, IsUUID, Length, MaxLength, ValidateIf, ValidateNested } from 'class-validator'; +import { databaseViews, propertyTypes } from '../database'; import { builtInTemplates } from '../templates'; +import { IsColor } from '../../validation/color.decorator'; import { IsOptionalNotNull } from '../../validation/is-optional-not-null.decorator'; import { Trimmed } from '../../validation/trimmed.decorator'; @@ -58,3 +61,64 @@ export class UpdateNoteDto { @IsBoolean() template?: boolean; } + +export class PropertyOptionDto { + @IsString() + @Length(1, 40) + id!: string; + + @Trimmed() + @IsString() + @Length(1, 60, { message: 'Option names are 1 to 60 characters long.' }) + name!: string; + + @IsColor() + color!: string; +} + +export class PropertyDto { + @IsString() + @Length(1, 40) + id!: string; + + @Trimmed() + @IsString() + @Length(1, 60, { message: 'Property names are 1 to 60 characters long.' }) + name!: string; + + @IsIn(propertyTypes, { message: 'Choose a kind of property from the list.' }) + type!: (typeof propertyTypes)[number]; + + @IsOptional() + @IsArray() + @ArrayMaxSize(50, { message: 'A property can have at most 50 options.' }) + @ValidateNested({ each: true }) + @Type(() => PropertyOptionDto) + options?: PropertyOptionDto[]; +} + +export class SaveDatabaseDto { + @IsArray() + @ArrayMaxSize(30, { message: 'A database can have at most 30 properties.' }) + @ValidateNested({ each: true }) + @Type(() => PropertyDto) + properties!: PropertyDto[]; + + @IsIn(databaseViews, { message: 'Choose table, board, list or calendar.' }) + view!: (typeof databaseViews)[number]; + + @IsOptional() + @ValidateIf((_input, value) => value !== null) + @IsString() + groupBy?: string | null; + + @IsOptional() + @ValidateIf((_input, value) => value !== null) + @IsString() + dateBy?: string | null; +} + +export class SetPropertiesDto { + @IsObject() + values!: Record; +} diff --git a/app/server/src/notes/note-databases.service.ts b/app/server/src/notes/note-databases.service.ts new file mode 100644 index 0000000..820701e --- /dev/null +++ b/app/server/src/notes/note-databases.service.ts @@ -0,0 +1,131 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { ItemKind, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { propertyRules, propertyValue, valueMap, type Property } from './database'; +import { SaveDatabaseDto, SetPropertiesDto } from './dto/notes.dto'; +import { noteFields, NotesService, present } from './notes.service'; + +const emptyValue = (value: unknown) => value === '' || (Array.isArray(value) && value.length === 0); + +@Injectable() +export class NoteDatabasesService { + constructor( + private readonly prisma: PrismaService, + private readonly notes: NotesService, + ) {} + + async get(userId: string, noteId: string) { + await this.notes.note(userId, noteId, 'read'); + const schema = await this.prisma.databaseSchema.findUnique({ where: { itemId: noteId } }); + if (!schema) throw new NotFoundException('This page is not a database'); + const rows = await this.prisma.item.findMany({ + where: { kind: ItemKind.NOTE, trashedAt: null, archivedAt: null, note: { parentId: noteId } }, + orderBy: [{ note: { position: 'asc' } }, { createdAt: 'asc' }, { id: 'asc' }], + select: { ...noteFields, properties: { select: { propertyId: true, value: true } } }, + }); + return { + properties: schema.properties as unknown as Property[], + view: schema.view, + groupBy: schema.groupBy, + dateBy: schema.dateBy, + rows: rows.map((row) => ({ ...present(row), values: valueMap(row.properties) })), + }; + } + + async save(userId: string, noteId: string, input: SaveDatabaseDto) { + const found = await this.notes.note(userId, noteId, 'edit'); + if (found.trashedAt) throw new BadRequestException('Restore this page from the trash before changing it'); + const properties: Property[] = input.properties.map(({ id, name, type, options }) => + type === 'select' || type === 'multiSelect' ? { id, name, type, options: options ?? [] } : { id, name, type }, + ); + if (new Set(properties.map((property) => property.id)).size !== properties.length) { + throw new BadRequestException('Each property needs its own id'); + } + if (properties.some((property) => property.options && new Set(property.options.map((option) => option.id)).size !== property.options.length)) { + throw new BadRequestException('Each option needs its own id'); + } + if (input.groupBy && !properties.some((property) => property.id === input.groupBy && property.type === 'select')) { + throw new BadRequestException('Group a board by a select property'); + } + if (input.dateBy && !properties.some((property) => property.id === input.dateBy && property.type === 'date')) { + throw new BadRequestException('Lay out a calendar by a date property'); + } + const members = await this.members(found.spaceId); + await this.prisma.$transaction(async (transaction) => { + const before = await transaction.databaseSchema.findUnique({ where: { itemId: noteId }, select: { properties: true } }); + const previousTypes = new Map(((before?.properties ?? []) as unknown as Property[]).map((property) => [property.id, property.type])); + const settings = { + properties: properties as unknown as Prisma.InputJsonValue, + view: input.view, + groupBy: input.groupBy ?? null, + dateBy: input.dateBy ?? null, + }; + await transaction.databaseSchema.upsert({ where: { itemId: noteId }, create: { itemId: noteId, ...settings }, update: settings }); + const current = new Map(properties.map((property) => [property.id, property])); + const values = await transaction.noteProperty.findMany({ where: { item: { note: { parentId: noteId } } } }); + for (const entry of values) { + const property = current.get(entry.propertyId); + const options = new Set((property?.options ?? []).map((option) => option.id)); + const value = + property?.type === 'multiSelect' && Array.isArray(entry.value) ? entry.value.filter((option) => options.has(String(option))) : entry.value; + const kept = property && previousTypes.get(property.id) === property.type ? propertyValue(property, value, members) : undefined; + const where = { itemId_propertyId: { itemId: entry.itemId, propertyId: entry.propertyId } }; + if (kept === undefined || emptyValue(kept)) await transaction.noteProperty.delete({ where }); + else if (JSON.stringify(kept) !== JSON.stringify(entry.value)) { + await transaction.noteProperty.update({ where, data: { value: kept as Prisma.InputJsonValue } }); + } + } + }); + return this.get(userId, noteId); + } + + async remove(userId: string, noteId: string) { + await this.notes.note(userId, noteId, 'edit'); + await this.prisma.$transaction([ + this.prisma.noteProperty.deleteMany({ where: { item: { note: { parentId: noteId } } } }), + this.prisma.databaseSchema.deleteMany({ where: { itemId: noteId } }), + ]); + } + + async setValues(userId: string, noteId: string, input: SetPropertiesDto) { + const found = await this.notes.note(userId, noteId, 'edit'); + if (found.trashedAt) throw new BadRequestException('Restore this page from the trash before changing it'); + const note = await this.prisma.note.findUnique({ + where: { itemId: noteId }, + select: { parent: { select: { databaseSchema: { select: { properties: true } } } } }, + }); + const schema = note?.parent?.databaseSchema; + if (!schema) throw new BadRequestException('Only pages inside a database have properties'); + const properties = new Map((schema.properties as unknown as Property[]).map((property) => [property.id, property])); + const entries = Object.entries(input.values); + const members = entries.some(([id]) => properties.get(id)?.type === 'person') ? await this.members(found.spaceId) : new Set(); + const changes = entries.map(([id, value]) => { + const property = properties.get(id); + if (!property) throw new BadRequestException('That property is not part of this database'); + if (value === null || (typeof value === 'string' && !value.trim())) return { id, value: null }; + const normalized = propertyValue(property, value, members); + if (normalized === undefined) throw new BadRequestException(`${property.name} ${propertyRules[property.type]}`); + return { id, value: emptyValue(normalized) ? null : (normalized as Prisma.InputJsonValue) }; + }); + await this.prisma.$transaction(async (transaction) => { + for (const change of changes) { + if (change.value === null) { + await transaction.noteProperty.deleteMany({ where: { itemId: noteId, propertyId: change.id } }); + } else { + await transaction.noteProperty.upsert({ + where: { itemId_propertyId: { itemId: noteId, propertyId: change.id } }, + create: { itemId: noteId, propertyId: change.id, value: change.value }, + update: { value: change.value }, + }); + } + } + await transaction.item.update({ where: { id: noteId }, data: { updatedById: userId } }); + }); + return { values: valueMap(await this.prisma.noteProperty.findMany({ where: { itemId: noteId } })) }; + } + + private async members(spaceId: string) { + const rows = await this.prisma.spaceMember.findMany({ where: { spaceId }, select: { userId: true } }); + return new Set(rows.map((row) => row.userId)); + } +} diff --git a/app/server/src/notes/notes.controller.ts b/app/server/src/notes/notes.controller.ts index 7904454..7d90834 100644 --- a/app/server/src/notes/notes.controller.ts +++ b/app/server/src/notes/notes.controller.ts @@ -1,10 +1,11 @@ -import { Body, Controller, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Put, 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, UpdateNoteDto } from './dto/notes.dto'; +import { CreateNoteDto, SaveDatabaseDto, SetPropertiesDto, UpdateNoteDto } from './dto/notes.dto'; +import { NoteDatabasesService } from './note-databases.service'; import { NoteVersionsService } from './note-versions.service'; import { NotesService } from './notes.service'; @@ -14,6 +15,7 @@ export class NotesController { constructor( private readonly notes: NotesService, private readonly versions: NoteVersionsService, + private readonly databases: NoteDatabasesService, ) {} @Get('spaces/:spaceId/notes') @@ -58,4 +60,29 @@ export class NotesController { update(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: UpdateNoteDto) { return this.notes.update(user.id, id, input); } + + @Get('notes/:id/database') + @Scope('items:read') + database(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + return this.databases.get(user.id, id); + } + + @Put('notes/:id/database') + @Scope('items:write') + saveDatabase(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: SaveDatabaseDto) { + return this.databases.save(user.id, id, input); + } + + @Delete('notes/:id/database') + @Scope('items:write') + @HttpCode(204) + async removeDatabase(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string) { + await this.databases.remove(user.id, id); + } + + @Patch('notes/:id/properties') + @Scope('items:write') + setProperties(@CurrentUser() user: AuthenticatedUser, @Param('id', UuidPipe) id: string, @Body() input: SetPropertiesDto) { + return this.databases.setValues(user.id, id, input); + } } diff --git a/app/server/src/notes/notes.module.ts b/app/server/src/notes/notes.module.ts index b8dc320..2225bb0 100644 --- a/app/server/src/notes/notes.module.ts +++ b/app/server/src/notes/notes.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { RealtimeModule } from '../realtime/realtime.module'; +import { NoteDatabasesService } from './note-databases.service'; import { NoteVersionsService } from './note-versions.service'; import { NotesController } from './notes.controller'; import { NotesService } from './notes.service'; @@ -8,7 +9,7 @@ import { NotesService } from './notes.service'; @Module({ imports: [AuthModule, RealtimeModule], controllers: [NotesController], - providers: [NotesService, NoteVersionsService], + providers: [NotesService, NoteVersionsService, NoteDatabasesService], exports: [NotesService], }) export class NotesModule {} diff --git a/app/server/src/notes/notes.service.ts b/app/server/src/notes/notes.service.ts index 69a22c6..e9c20be 100644 --- a/app/server/src/notes/notes.service.ts +++ b/app/server/src/notes/notes.service.ts @@ -4,22 +4,24 @@ import * as Y from 'yjs'; import { AccessService } from '../access/access.service'; import { ActivityService } from '../activity/activity.service'; import { PrismaService } from '../prisma/prisma.service'; +import { valueMap } from './database'; import { documentText } from './document-text'; import { CreateNoteDto, UpdateNoteDto } from './dto/notes.dto'; import { templateDocument, textDocument, type BuiltInTemplate } from './templates'; -const noteFields = { +export const noteFields = { id: true, spaceId: true, title: true, createdAt: true, updatedAt: true, note: { select: { parentId: true, position: true, icon: true, pinnedAt: true, template: true } }, + databaseSchema: { select: { itemId: true } }, } satisfies Prisma.ItemSelect; type NoteRow = Prisma.ItemGetPayload<{ select: typeof noteFields }>; -function present(row: NoteRow) { +export function present(row: NoteRow) { return { id: row.id, spaceId: row.spaceId, @@ -29,6 +31,7 @@ function present(row: NoteRow) { icon: row.note?.icon ?? null, pinnedAt: row.note?.pinnedAt ?? null, template: row.note?.template ?? false, + database: Boolean(row.databaseSchema), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -85,7 +88,16 @@ export class NotesService { async get(userId: string, noteId: string) { const found = await this.note(userId, noteId, 'read'); const row = await this.prisma.item.findUniqueOrThrow({ where: { id: noteId }, select: noteFields }); - return { ...present(row), editable: found.role !== SpaceRole.VIEWER, path: await this.ancestors(row.note?.parentId ?? null) }; + const parentId = row.note?.parentId ?? null; + const database = parentId ? await this.prisma.databaseSchema.findUnique({ where: { itemId: parentId }, select: { properties: true } }) : null; + return { + ...present(row), + editable: found.role !== SpaceRole.VIEWER, + path: await this.ancestors(parentId), + row: database + ? { properties: database.properties, values: valueMap(await this.prisma.noteProperty.findMany({ where: { itemId: noteId } })) } + : null, + }; } async update(userId: string, noteId: string, input: UpdateNoteDto) { diff --git a/app/server/test/integration/note-databases.spec.ts b/app/server/test/integration/note-databases.spec.ts new file mode 100644 index 0000000..4d40e36 --- /dev/null +++ b/app/server/test/integration/note-databases.spec.ts @@ -0,0 +1,87 @@ +import { SpaceRole } from '@prisma/client'; +import { describe, expect, it } from 'vitest'; +import { integrationApp } from './harness'; + +describe('Simple databases against Postgres', () => { + const strata = integrationApp(); + const { member } = strata; + + const schema = (people = true) => ({ + properties: [ + { id: 'status', name: 'Status', type: 'select', options: [{ id: 'todo', name: 'To do', color: 'gray' }, { id: 'done', name: 'Done', color: 'green' }] }, + { id: 'due', name: 'Due', type: 'date' }, + { id: 'cost', name: 'Cost', type: 'number' }, + { id: 'tags', name: 'Tags', type: 'multiSelect', options: [{ id: 'home', name: 'Home', color: 'blue' }, { id: 'work', name: 'Work', color: 'red' }] }, + ...(people ? [{ id: 'owner', name: 'Owner', type: 'person' }] : []), + { id: 'site', name: 'Site', type: 'link' }, + { id: 'paid', name: 'Paid', type: 'checkbox' }, + ], + view: 'board', + groupBy: 'status', + dateBy: 'due', + }); + + it('turns a page into a database, keeps typed values on its pages and lists them', async () => { + const owner = await member('collector'); + const viewer = await member('onlooker'); + const outsider = await member('stranger'); + const space = (await owner.call('POST', '/spaces', { name: 'Flat' })).body; + await strata.join(space.id, viewer, SpaceRole.VIEWER); + const database = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Repairs' })).body; + const row = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Boiler', parentId: database.id })).body; + const loose = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Loose' })).body; + + expect((await owner.call('GET', `/notes/${database.id}/database`)).status).toBe(404); + expect((await viewer.call('PUT', `/notes/${database.id}/database`, schema())).status).toBe(403); + expect((await owner.call('PUT', `/notes/${database.id}/database`, { ...schema(), groupBy: 'due' })).status).toBe(400); + const saved = await owner.call('PUT', `/notes/${database.id}/database`, schema()); + expect(saved.status).toBe(200); + expect(saved.body).toMatchObject({ view: 'board', groupBy: 'status', dateBy: 'due', rows: [{ id: row.id, title: 'Boiler', values: {} }] }); + expect((await owner.call('GET', `/notes/${database.id}`)).body).toMatchObject({ database: true, row: null }); + + const values = { status: 'todo', due: '2026-10-20', cost: 120.5, tags: ['home', 'home'], owner: viewer.id, site: ' https://example.com/fix ', paid: false }; + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values })).body).toEqual({ + values: { status: 'todo', due: '2026-10-20', cost: 120.5, tags: ['home'], owner: viewer.id, site: 'https://example.com/fix', paid: false }, + }); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { status: 'lost' } })).body.message).toBe('Status needs one of its options'); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { due: '2026-02-30' } })).body.message).toBe('Due needs a date'); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { owner: outsider.id } })).status).toBe(400); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { site: 'javascript:alert(1)' } })).status).toBe(400); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { nothing: 1 } })).status).toBe(400); + expect((await owner.call('PATCH', `/notes/${loose.id}/properties`, { values: { cost: 1 } })).status).toBe(400); + expect((await viewer.call('PATCH', `/notes/${row.id}/properties`, { values: { cost: 1 } })).status).toBe(403); + expect((await outsider.call('GET', `/notes/${database.id}/database`)).status).toBe(404); + + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { paid: null, site: '' } })).body.values).not.toHaveProperty('paid'); + expect((await viewer.call('GET', `/notes/${row.id}`)).body).toMatchObject({ + row: { properties: expect.arrayContaining([expect.objectContaining({ id: 'status', type: 'select' })]), values: { status: 'todo', cost: 120.5 } }, + }); + expect((await viewer.call('GET', `/notes/${database.id}/database`)).body.rows[0].values).toMatchObject({ status: 'todo', due: '2026-10-20' }); + }); + + it('drops values that no longer fit when the properties change, and all of them when it stops being a database', async () => { + const owner = await member('reshaper'); + const space = (await owner.call('POST', '/spaces', { name: 'Garden' })).body; + const database = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Plants' })).body; + const row = (await owner.call('POST', `/spaces/${space.id}/notes`, { title: 'Basil', parentId: database.id })).body; + await owner.call('PUT', `/notes/${database.id}/database`, schema(false)); + await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { status: 'done', tags: ['home', 'work'], cost: 3, due: '2026-11-01', paid: true } }); + + const next = schema(false); + next.properties = next.properties + .filter((property) => property.id !== 'paid') + .map((property) => { + if (property.id === 'tags') return { ...property, options: [{ id: 'work', name: 'Work', color: 'red' }] }; + if (property.id === 'status') return { ...property, options: [{ id: 'todo', name: 'To do', color: 'gray' }] }; + if (property.id === 'cost') return { ...property, type: 'text' }; + return property; + }); + const saved = (await owner.call('PUT', `/notes/${database.id}/database`, next)).body; + expect(saved.rows[0].values).toEqual({ tags: ['work'], due: '2026-11-01' }); + + expect((await owner.call('DELETE', `/notes/${database.id}/database`)).status).toBe(204); + expect((await owner.call('GET', `/notes/${database.id}`)).body.database).toBe(false); + expect((await owner.call('GET', `/notes/${row.id}`)).body.row).toBeNull(); + expect((await owner.call('PATCH', `/notes/${row.id}/properties`, { values: { due: '2026-11-02' } })).status).toBe(400); + }); +});