From b5c2c9c8b8626be2381ef87080ddd62e99877693 Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:12:00 +0300 Subject: [PATCH] feat: one page frame, themed controls and create buttons in one place across Strata Every page now uses the shared Page frame with its actions in the header, every create action opens a dialog from a button on the right, and every select, date and time field uses the themed graphite-ui 0.4.0 controls. --- app/frontend/package.json | 2 +- app/frontend/src/App.tsx | 11 +- app/frontend/src/choices.ts | 23 ++ app/frontend/src/components/CopyLink.tsx | 24 +- app/frontend/src/components/DatePoll.tsx | 11 +- app/frontend/src/components/DevicePush.tsx | 16 +- app/frontend/src/components/EventEditor.tsx | 75 ++-- app/frontend/src/components/ImportPages.tsx | 15 +- app/frontend/src/components/NoteHistory.tsx | 2 +- app/frontend/src/components/NoteMark.tsx | 10 + app/frontend/src/components/Page.tsx | 72 ++++ app/frontend/src/components/PageTree.tsx | 16 +- app/frontend/src/components/SearchPalette.tsx | 5 +- app/frontend/src/components/SplitEditor.tsx | 17 +- .../src/components/StatementImport.tsx | 56 +-- .../src/components/SubscriptionEditor.tsx | 125 +++---- app/frontend/src/components/TaskEditor.tsx | 178 +++++---- .../src/components/database/DatabaseView.tsx | 20 +- .../components/database/PropertiesDialog.tsx | 37 +- .../src/components/database/PropertyField.tsx | 33 +- app/frontend/src/dashboard/Dashboard.tsx | 343 +++++++++--------- app/frontend/src/dashboard/capture-widget.tsx | 11 +- .../src/dashboard/outside-widgets.tsx | 38 +- app/frontend/src/dashboard/widgets.tsx | 129 +++---- app/frontend/src/navigation.tsx | 8 +- app/frontend/src/pages/AdminPage.tsx | 8 +- app/frontend/src/pages/AgendaPage.tsx | 54 +-- app/frontend/src/pages/BookmarksPage.tsx | 65 ++-- app/frontend/src/pages/CalendarsPage.tsx | 125 +++---- app/frontend/src/pages/EventPage.tsx | 67 ++-- app/frontend/src/pages/InboxPage.tsx | 60 +-- app/frontend/src/pages/InvitationsPage.tsx | 202 ++++++----- app/frontend/src/pages/JournalPage.tsx | 23 +- app/frontend/src/pages/MenuPage.tsx | 12 +- app/frontend/src/pages/PagesIndex.tsx | 69 ++-- app/frontend/src/pages/RecurringPage.tsx | 42 ++- app/frontend/src/pages/SavePage.tsx | 15 +- app/frontend/src/pages/TidyPage.tsx | 8 +- app/frontend/src/pages/TodayPage.tsx | 8 +- app/frontend/src/pages/TrashPage.tsx | 12 +- .../src/pages/settings/ApiSettings.tsx | 31 +- .../src/pages/settings/DataSettings.tsx | 7 +- .../src/pages/settings/DeveloperSettings.tsx | 11 + .../src/pages/settings/ProfileSettings.tsx | 120 +++--- .../src/pages/settings/SecurityLog.tsx | 10 +- .../src/pages/settings/SecuritySettings.tsx | 9 +- ...ssionsSettings.tsx => SessionsSection.tsx} | 16 +- .../src/pages/settings/SettingsLayout.tsx | 26 +- .../src/pages/settings/TokensSettings.tsx | 29 +- app/frontend/src/pages/spaces/SpaceHabits.tsx | 113 ++---- .../src/pages/spaces/SpaceInvitations.tsx | 137 +++---- app/frontend/src/pages/spaces/SpaceLayout.tsx | 19 +- .../src/pages/spaces/SpaceMembers.tsx | 41 ++- app/frontend/src/pages/spaces/SpaceNotes.tsx | 62 ++-- .../src/pages/spaces/SpaceSubscriptions.tsx | 23 +- app/frontend/src/pages/spaces/SpaceTags.tsx | 160 ++++---- app/frontend/src/pages/spaces/SpaceTasks.tsx | 264 ++++++-------- app/frontend/src/pages/spaces/SpacesPage.tsx | 121 +++--- app/frontend/src/pages/spaces/TidyRules.tsx | 241 ++++++------ app/frontend/src/spaces.tsx | 41 ++- app/frontend/src/styles/global.css | 74 +++- app/frontend/src/subscriptions.ts | 2 + app/frontend/src/tasks.tsx | 14 +- app/frontend/src/useResource.ts | 5 +- app/frontend/test/activity.spec.tsx | 3 +- app/frontend/test/agenda.spec.tsx | 12 +- app/frontend/test/bookmarks.spec.tsx | 1 + app/frontend/test/calendars.spec.tsx | 3 +- app/frontend/test/controls.ts | 30 ++ app/frontend/test/dashboard.spec.tsx | 36 +- app/frontend/test/habits.spec.tsx | 6 +- app/frontend/test/invitations.spec.tsx | 6 +- app/frontend/test/notes.spec.tsx | 17 +- app/frontend/test/share.spec.tsx | 4 +- app/frontend/test/spaces.spec.tsx | 5 +- app/frontend/test/subscriptions.spec.tsx | 13 +- app/frontend/test/tasks.spec.tsx | 21 +- app/frontend/test/tidy.spec.tsx | 11 +- app/package-lock.json | 8 +- app/server/src/auth/auth.service.ts | 2 +- 80 files changed, 1898 insertions(+), 1903 deletions(-) create mode 100644 app/frontend/src/choices.ts create mode 100644 app/frontend/src/components/NoteMark.tsx create mode 100644 app/frontend/src/components/Page.tsx create mode 100644 app/frontend/src/pages/settings/DeveloperSettings.tsx rename app/frontend/src/pages/settings/{SessionsSettings.tsx => SessionsSection.tsx} (89%) create mode 100644 app/frontend/test/controls.ts diff --git a/app/frontend/package.json b/app/frontend/package.json index fcb4e0f..46f098c 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -28,7 +28,7 @@ "@excalidraw/excalidraw": "0.18.1", "@hocuspocus/provider": "4.7.0", "@mozilla/readability": "0.6.0", - "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.3.0/project-graphite-ui-0.3.0.tgz", + "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.4.0/project-graphite-ui-0.4.0.tgz", "@tiptap/core": "3.31.4", "@tiptap/extension-collaboration": "3.31.4", "@tiptap/extension-collaboration-caret": "3.31.4", diff --git a/app/frontend/src/App.tsx b/app/frontend/src/App.tsx index 9619d66..1d06faa 100644 --- a/app/frontend/src/App.tsx +++ b/app/frontend/src/App.tsx @@ -26,13 +26,11 @@ import { ForgotPasswordPage, ResetPasswordPage } from './pages/PasswordPages'; import { RegisterPage } from './pages/RegisterPage'; import { RsvpPage, SharePage } from './pages/RsvpPages'; import { SavePage } from './pages/SavePage'; -import { ApiSettings } from './pages/settings/ApiSettings'; import { DataSettings } from './pages/settings/DataSettings'; +import { DeveloperSettings } from './pages/settings/DeveloperSettings'; import { ProfileSettings } from './pages/settings/ProfileSettings'; import { SecuritySettings } from './pages/settings/SecuritySettings'; -import { SessionsSettings } from './pages/settings/SessionsSettings'; import { SettingsLayout } from './pages/settings/SettingsLayout'; -import { TokensSettings } from './pages/settings/TokensSettings'; import { SpaceActivity } from './pages/spaces/SpaceActivity'; import { SpaceItems } from './pages/spaces/SpaceItems'; import { SpaceLayout } from './pages/spaces/SpaceLayout'; @@ -282,9 +280,10 @@ export function App() { > } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/app/frontend/src/choices.ts b/app/frontend/src/choices.ts new file mode 100644 index 0000000..f55fb63 --- /dev/null +++ b/app/frontend/src/choices.ts @@ -0,0 +1,23 @@ +import type { SelectOption } from '@project-graphite/ui'; + +function offsetOf(zone: string) { + return new Intl.DateTimeFormat('en', { timeZone: zone, timeZoneName: 'shortOffset' }).formatToParts(new Date()).find((part) => part.type === 'timeZoneName')?.value ?? ''; +} + +let zones: SelectOption[] | undefined; + +export function timeZoneOptions(current?: string | null) { + zones ??= [...new Set(['Etc/UTC', ...Intl.supportedValuesOf('timeZone')])].map((zone) => { + const parts = zone.split('/'); + return { value: zone, label: parts.at(-1)!.replaceAll('_', ' '), hint: `${parts.length > 1 ? `${parts.slice(0, -1).join(' / ').replaceAll('_', ' ')} · ` : ''}${offsetOf(zone)}` }; + }); + return current && !zones.some((option) => option.value === current) ? [{ value: current, label: current }, ...zones] : zones; +} + +const currencyNames = new Intl.DisplayNames(undefined, { type: 'currency' }); + +export function currencyOptions(codes: string[]) { + return [...new Set(codes)].sort().map((code) => ({ value: code, label: code, hint: currencyNames.of(code) })); +} + +export const allCurrencies = Intl.supportedValuesOf('currency'); diff --git a/app/frontend/src/components/CopyLink.tsx b/app/frontend/src/components/CopyLink.tsx index 0074fea..3b3bbfa 100644 --- a/app/frontend/src/components/CopyLink.tsx +++ b/app/frontend/src/components/CopyLink.tsx @@ -1,9 +1,25 @@ +import { useState } from 'react'; +import { Icon } from '@project-graphite/ui'; + export function CopyLink({ link }: { link: string }) { + const [copied, setCopied] = useState(false); return ( -
- {link} -
); diff --git a/app/frontend/src/components/DatePoll.tsx b/app/frontend/src/components/DatePoll.tsx index 6874e4f..24b1fff 100644 --- a/app/frontend/src/components/DatePoll.tsx +++ b/app/frontend/src/components/DatePoll.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { DatePicker, TimePicker } from '@project-graphite/ui'; import { useAction } from '../useAction'; export type PollAnswer = 'yes' | 'maybe' | 'no'; @@ -96,12 +97,12 @@ export function PollEditor({ }} > {rows.map((row, index) => ( -
- update(index, { startsOn: event.currentTarget.value })} type="date" value={row.startsOn} /> - + update(index, { startsOn })} value={row.startsOn} /> + update(index, { startTime: event.currentTarget.value || null })} - type="time" + emptyLabel="Any time" + onChange={(startTime) => update(index, { startTime: startTime || null })} value={row.startTime ?? ''} /> {rows.length > 2 && ( diff --git a/app/frontend/src/components/DevicePush.tsx b/app/frontend/src/components/DevicePush.tsx index 51d005a..65e9fb0 100644 --- a/app/frontend/src/components/DevicePush.tsx +++ b/app/frontend/src/components/DevicePush.tsx @@ -36,10 +36,16 @@ export function DevicePush() { })(); }, [request]); - if (key.data?.publicKey === null) return

Notifications on your devices are not set up on this server yet.

; - if (device === null) { - return

This browser cannot show notifications from Strata. Install Strata as an app, or use a recent Chrome, Edge, Firefox or Safari.

; - } + const note = (text: string) => ( +
+
+

Notifications on this device

+

{text}

+
+
+ ); + if (key.data?.publicKey === null) return note('Not set up on this server yet.'); + if (device === null) return note('This browser cannot show notifications from Strata. Install Strata as an app, or use a recent Chrome, Edge, Firefox or Safari.'); if (!device || !key.data) return null; const { registration, subscription, kinds } = device; const publicKey = key.data.publicKey; @@ -53,7 +59,7 @@ export function DevicePush() { } return ( -
+
space.role !== 'viewer'); - const custom = event?.repeatRule && !repeatPresets.some(([value]) => value === event.repeatRule); return ( @@ -73,55 +72,27 @@ export function EventEditor({ > {!event && ( - + - - - - - - +
+ + + + +
diff --git a/app/frontend/src/components/ImportPages.tsx b/app/frontend/src/components/ImportPages.tsx index bd0110a..5aff1b0 100644 --- a/app/frontend/src/components/ImportPages.tsx +++ b/app/frontend/src/components/ImportPages.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import { Dialog } from '@project-graphite/ui'; +import { Dialog, Select } from '@project-graphite/ui'; import { useAuth } from '../auth'; -import type { Space } from '../spaces'; +import { spaceOptions, type Space } from '../spaces'; import { useAction } from '../useAction'; export function ImportPages({ onClose, onDone, spaces }: { onClose: () => void; onDone: () => void; spaces: Space[] }) { @@ -33,16 +33,7 @@ export function ImportPages({ onClose, onDone, spaces }: { onClose: () => void; Markdown files become pages. For a Notion export, unzip it and choose the folder: pages keep their nesting and their images come along.

{spaces.length > 1 && ( - + setPayerId(event.currentTarget.value)} value={payerId}> - {members.map((member) => ( - - ))} - - + setColumns({ ...columns, [key]: Number(event.currentTarget.value) })} value={columns[key]}> - {(rows?.[0] ?? []).map((title, index) => ( - - ))} - - + setOrder(event.currentTarget.value as DateOrder)} value={order}> - {orders.map(([value, label]) => ( - - ))} - - + setSpaceId(event.currentTarget.value)} value={target}> - {editable.map((space) => ( - - ))} - - - +
)} diff --git a/app/frontend/src/components/SubscriptionEditor.tsx b/app/frontend/src/components/SubscriptionEditor.tsx index c9d4c18..8fac268 100644 --- a/app/frontend/src/components/SubscriptionEditor.tsx +++ b/app/frontend/src/components/SubscriptionEditor.tsx @@ -1,7 +1,10 @@ -import { Dialog, TextField } from '@project-graphite/ui'; +import { useState } from 'react'; +import { DatePicker, Dialog, Select, SuggestField, TextField } from '@project-graphite/ui'; +import { allCurrencies, currencyOptions } from '../choices'; import { useAuth } from '../auth'; import { catalogue, knownService } from '../catalogue'; -import { billingCycles, categories, fromMinor, toMinor, type Subscription } from '../subscriptions'; +import { spaceOptions, type Space } from '../spaces'; +import { billingCycles, categories, categoryLabel, fromMinor, toMinor, type Subscription } from '../subscriptions'; import { useAction } from '../useAction'; import { atMost, required, useFormErrors, type Check } from '../validation'; @@ -14,19 +17,30 @@ export function SubscriptionEditor({ onClose, onSaved, spaceId, + spaces = [], subscription, }: { onClose: () => void; onSaved: (subscription: Subscription) => void; spaceId: string; + spaces?: Space[]; subscription?: Subscription; }) { const auth = useAuth(); const saving = useAction(); const form = useFormErrors(); + const [category, setCategory] = useState(subscription?.category ?? 'other'); + const [cancelUrl, setCancelUrl] = useState(subscription?.cancelUrl ?? ''); const customCycle = subscription && !billingCycles.some(([value]) => value === subscription.repeatRule); const today = new Date().toISOString().slice(0, 10); + function fillKnown(name: string) { + const known = knownService(name); + if (!known || subscription) return; + if (category === 'other') setCategory(known.category); + if (!cancelUrl) setCancelUrl(known.cancelUrl); + } + return (
{ const saved = await auth.request( - subscription ? `/subscriptions/${subscription.id}` : `/spaces/${spaceId}/subscriptions`, + subscription ? `/subscriptions/${subscription.id}` : `/spaces/${text('spaceId') || spaceId}/subscriptions`, { method: subscription ? 'PATCH' : 'POST', body: JSON.stringify({ @@ -77,29 +91,19 @@ export function SubscriptionEditor({ .then((saved) => saved && onClose()); }} > - 1 && ({ value: value as string, label: label as string })), + ...(customCycle ? [{ value: subscription.repeatRule, label: `Custom (${subscription.repeatRule})` }] : []), + ]} + /> + + + - {billingCycles.map(([value, label]) => ( - - ))} - {customCycle && } - - - - -
- + - - - - - - - + + Understood: {understood} + + ) : task ? undefined : ( + 'Try “pay rent friday 9am #home !2”.' + ) + } + label="Title" + maxLength={200} + onChange={(event) => setTitle(event.currentTarget.value)} + value={title} + {...form.field('title')} + /> +
+ + + + ({ value: member.userId, label: member.displayName }))]} + /> + void saveSchema(key === 'groupBy' ? { groupBy: event.currentTarget.value } : { dateBy: event.currentTarget.value })} value={chosen?.id}> - {options.map((property) => ( - - ))} - - + change(property.id, { type: event.currentTarget.value as PropertyType })} + className="w-40" + onChange={(type: PropertyType) => change(property.id, { type })} + options={propertyTypes.map(([value, label]) => ({ value, label }))} value={property.type} - > - {propertyTypes.map(([type, label]) => ( - - ))} - + /> - )} -
+ ({ active: dashboard.id === current.id, href: `/?d=${dashboard.id}`, label: dashboard.name }))} + label="Dashboards" + /> )} {editing && ( - + + setConfiguring(true)} /> + ({ + label: widgetKinds[type].title, + onSelect: () => change([...widgets, { id: crypto.randomUUID(), type, size: widgetKinds[type].size, settings: widgetKinds[type].settings }]), + }))} + label="Widgets" + trigger={ + <> + + Add widget + + } + triggerClassName="primary-button px-3 py-2 text-sm" + triggerLabel="Add widget" + /> + + } + > +

Drag widgets to reorder them, or use each widget’s menu.

+ )} -
- {widgets.length === 0 && ( -

This dashboard is empty. Choose Edit dashboard to add widgets.

- )} - {widgets.map((widget, index) => { - const kind = widgetKinds[widget.type]; - return ( -
editing && event.preventDefault()} - onDragStart={() => setDragging(index)} - onDragEnd={() => setDragging(undefined)} - onDrop={() => { - if (dragging !== undefined && dragging !== index) change(move(widgets, dragging, index)); - setDragging(undefined); - }} - > -
-

{kind.title}

- {editing && ( -
- - - - {kind.Settings && ( - - )} - + {widgets.length === 0 ? ( + +

{editing ? 'Add a widget to start.' : 'Choose Edit to add widgets.'}

+
+ ) : ( +
+ {widgets.map((widget, index) => { + const kind = widgetKinds[widget.type]; + return ( +
editing && event.preventDefault()} + onDragStart={() => setDragging(index)} + onDragEnd={() => setDragging(undefined)} + onDrop={() => { + if (dragging !== undefined && dragging !== index) change(move(widgets, dragging, index)); + setDragging(undefined); + }} + > +
+

{kind.title}

+ {editing && ( + setSettingsOpen(settingsOpen === widget.id ? undefined : widget.id) }] : []), + ...(index > 0 ? [{ label: 'Move earlier', onSelect: () => change(move(widgets, index, index - 1)) }] : []), + ...(index < widgets.length - 1 ? [{ label: 'Move later', onSelect: () => change(move(widgets, index, index + 1)) }] : []), + ...sizes + .filter(([size]) => size !== widget.size) + .map(([size, label], at) => ({ + label: `Make it ${label}`, + separated: at === 0, + onSelect: () => change(widgets.map((shown) => (shown.id === widget.id ? { ...shown, size } : shown))), + })), + { label: 'Remove', separated: true, onSelect: () => change(widgets.filter((shown) => shown.id !== widget.id)) }, + ]} + label={`${kind.title} actions`} + trigger={} + triggerClassName="icon-button" + triggerLabel={`Actions for ${kind.title}`} + /> + )} +
+ {editing && settingsOpen === widget.id && kind.Settings && ( +
+ change(widgets.map((shown) => (shown.id === widget.id ? { ...shown, settings } : shown)))} + settings={widget.settings} + />
)} -
- {editing && settingsOpen === widget.id && kind.Settings && ( - change(widgets.map((shown) => (shown.id === widget.id ? { ...shown, settings } : shown)))} - settings={widget.settings} - /> + + + +
+ ); + })} +
+ )} + {configuring && ( + setConfiguring(false)} title="Dashboard settings"> +
+ setName(event.currentTarget.value)} value={name} /> +
+ Opens by itself + setSchedule({ ...schedule, from })} value={schedule.from} /> + setSchedule({ ...schedule, until })} value={schedule.until} /> + setSpaceId(event.currentTarget.value)} value={target}> - {editable.map((space) => ( - - ))} - + onChange({ ...settings, unit: event.currentTarget.value })} value={settings.unit === 'fahrenheit' ? 'fahrenheit' : 'celsius'}> - - - - + onChange({ ...settings, count: Number(event.currentTarget.value) })} value={typeof settings.count === 'number' ? settings.count : 5}> - {[3, 5, 10].map((count) => ( - - ))} - - + onChange({ zones: [...event.currentTarget.selectedOptions].map((option) => option.value).slice(0, 8) })} - size={6} - value={zones} - > - {all.map((zone) => ( - - ))} - - +
+ onChange({ spaceId: event.currentTarget.value || undefined })} value={spaceId}> - - {(spaces.data ?? []).map((space) => ( - - ))} - - + onChange({ ...settings, listId: event.currentTarget.value || undefined })} - value={typeof settings.listId === 'string' ? settings.listId : ''} - > - - {(lists.data ?? []).map((list) => ( - - ))} - - + onChange({ ...settings, label: event.currentTarget.value })} value={typeof settings.label === 'string' ? settings.label : ''} /> - - + onChange({ ...settings, label: event.currentTarget.value })} value={typeof settings.label === 'string' ? settings.label : ''} /> + onChange({ ...settings, date: date || undefined })} size="small" value={typeof settings.date === 'string' ? settings.date : ''} />
); } @@ -416,16 +399,13 @@ function Focus({ settings }: WidgetProps) { function FocusSettings({ onChange, settings }: SettingsProps) { const choice = (key: 'minutes' | 'breakMinutes', label: string, options: number[], fallback: number) => ( - + onChange({ boardId: event.currentTarget.value || undefined })} value={typeof settings.boardId === 'string' ? settings.boardId : ''}> - - {(boards.data ?? []).map((board) => ( - - ))} - - + - {writable.map((space) => ( - - ))} - - + )} - - + )} ({ active: status === value, href: `/bookmarks?status=${value}`, label }))} label="Bookmarks" /> {listed.error ? ( @@ -82,13 +75,13 @@ export function BookmarksPage() { ) : listed.data.length === 0 ? (

- Paste a link above, or share one to Strata from another app, and it waits here with a clean reader view. + Save a link, or share one to Strata from another app, and it waits here with a clean reader view.

) : ( -
    +
      {listed.data.map((bookmark) => ( -
    • +
    • {bookmark.title} @@ -111,6 +104,6 @@ export function BookmarksPage() { ))}
    )} - + ); } diff --git a/app/frontend/src/pages/CalendarsPage.tsx b/app/frontend/src/pages/CalendarsPage.tsx index 950083c..bdd445b 100644 --- a/app/frontend/src/pages/CalendarsPage.tsx +++ b/app/frontend/src/pages/CalendarsPage.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; -import { ConfirmDialog, ListSkeleton, PageHeader, TextField, timeAgo } from '@project-graphite/ui'; +import { ConfirmDialog, Icon, ListSkeleton, Select, TextField, timeAgo } from '@project-graphite/ui'; import { useAuth } from '../auth'; +import { CopyLink } from '../components/CopyLink'; +import { FormDialog } from '../components/FormDialog'; import { LoadError } from '../components/LoadError'; -import { useSpaces } from '../spaces'; +import { HeaderButton, Page, Section } from '../components/Page'; +import { spaceOptions, useSpaces } from '../spaces'; import { required, useFormErrors } from '../validation'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; @@ -17,40 +20,40 @@ interface FollowedCalendar { events: number; } -function FollowedCalendars() { +function FollowedCalendars({ following, onClose }: { following: boolean; onClose: () => void }) { const auth = useAuth(); const spaces = useSpaces(); const calendars = useResource('/me/calendars', true); const action = useAction(); const form = useFormErrors(); const editable = (spaces.data ?? []).filter((space) => space.role !== 'viewer'); - const [spaceId, setSpaceId] = useState(''); - const chosenSpace = spaceId || editable[0]?.id || ''; if (calendars.error) return ; if (!calendars.data) return ; const replace = (saved: FollowedCalendar) => calendars.mutate((current) => current.map((calendar) => (calendar.id === saved.id ? saved : calendar))); return ( -
    - {calendars.data.length > 0 && ( -
      + <> + {calendars.data.length === 0 ? ( +

      You don’t follow any calendars yet.

      + ) : ( +
        {calendars.data.map((calendar) => { const space = spaces.data?.find((candidate) => candidate.id === calendar.spaceId); return ( -
      • +
      • {calendar.name}

        -

        +

        {space?.name} · {calendar.host} · {calendar.events} event{calendar.events === 1 ? '' : 's'} {calendar.lastFetchedAt ? ` · checked ${timeAgo(calendar.lastFetchedAt)}` : ''}

        - {calendar.lastError &&

        The last check failed: {calendar.lastError}

        } + {calendar.lastError &&

        The last check failed: {calendar.lastError}

        }
        {space && space.role !== 'viewer' && (
      )} - {editable.length > 0 && ( -
      { - submitted.preventDefault(); - const target = submitted.currentTarget; + {following && ( + { if (!form.check(target, { url: [required('Enter the calendar’s address.')] })) return; const values = new FormData(target); const name = String(values.get('name')).trim(); void action .run(async () => { - const added = await auth.request(`/spaces/${chosenSpace}/calendars`, { + const added = await auth.request(`/spaces/${String(values.get('spaceId'))}/calendars`, { method: 'POST', body: JSON.stringify({ url: String(values.get('url')).trim(), ...(name ? { name } : {}) }), }); calendars.mutate((current) => [...current, added]); return `Following ${added.name}.`; }, 'Could not follow that calendar') - .then((done) => done && target.reset()); + .then((done) => done && onClose()); }} + submitLabel="Follow" + title="Follow a calendar" > -

      Follow a calendar

      -
      - - -
      - {editable.length > 1 && ( - + + + {editable.length > 1 ? ( + )} - - +
      )} -
    + ); } export function CalendarsPage() { const auth = useAuth(); + const spaces = useSpaces(); + const editable = (spaces.data ?? []).filter((space) => space.role !== 'viewer'); + const [following, setFollowing] = useState(false); const feed = useResource<{ enabled: boolean }>('/me/calendar-feed', true); const action = useAction(); const [url, setUrl] = useState(''); @@ -148,27 +142,19 @@ export function CalendarsPage() { } return ( -
    - -
    -

    Your agenda in other apps

    -

    - Subscribe to this link in Google Calendar, Apple Calendar or Outlook to see the events of all your spaces there. Anyone with the link can see them, so keep it - private. -

    + 0 && setFollowing(true)} primary />} eyebrow="Agenda" title="Calendars"> +
    {feed.error ? ( ) : !feed.data ? ( ) : url ? (
    -

    Copy it now. It is shown only once.

    -
    - {url} - -
    + +

    Copy it now. It is shown only once.

    ) : feed.data.enabled ? (
    @@ -176,7 +162,7 @@ export function CalendarsPage() { Make a new link
    ) : ( - )} -
    -
    -

    Calendars from elsewhere

    -

    - Follow a public calendar, such as holidays, fixtures or a shared Google calendar’s secret address. Its events appear in the Agenda for everyone in the space, - read-only, and are checked every 6 hours. -

    - -
    +
    +
    + setFollowing(false)} /> +
    {resetting && ( The old link stops working, so calendars subscribed to it stop updating.

    )} -
    + ); } diff --git a/app/frontend/src/pages/EventPage.tsx b/app/frontend/src/pages/EventPage.tsx index bb9987d..ca23a80 100644 --- a/app/frontend/src/pages/EventPage.tsx +++ b/app/frontend/src/pages/EventPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { ConfirmDialog, EmptyState, Icon, PageHeader, PageSkeleton, TextField } from '@project-graphite/ui'; +import { ConfirmDialog, EmptyState, Icon, PageSkeleton, TextField } from '@project-graphite/ui'; import { eventWhen, type EventDetails, type Guest } from '../agenda'; import { useAuth } from '../auth'; import { optionLabel, PollEditor, PollOptions, type PollAnswer, type PollOption } from '../components/DatePoll'; @@ -12,6 +12,7 @@ import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { emailAddress, required, useFormErrors } from '../validation'; import { LoadError } from '../components/LoadError'; +import { Page } from '../components/Page'; const responseLabels: Record = { yes: 'coming', no: 'not coming', maybe: 'maybe', pending: 'no answer yet' }; @@ -56,36 +57,38 @@ export function EventPage() { } return ( -
    - setEditing(true)} type="button"> - - Edit - - ) - } - eyebrow={space?.name} - title={details.title} - > -

    {eventWhen(details)}

    - {details.location && ( -

    - {details.location} ·{' '} - - Map - -

    - )} - {details.meetingUrl && ( -

    - - Join the meeting - -

    - )} -
    + setEditing(true)} type="button"> + + Edit + + ) + } + description={ + <> +

    {eventWhen(details)}

    + {details.location && ( +

    + {details.location} ·{' '} + + Map + +

    + )} + {details.meetingUrl && ( +

    + + Join the meeting + +

    + )} + + } + eyebrow={space?.name} + title={details.title} + > {details.description &&

    {details.description}

    } {poll.data && (options.length > 0 || (editable && !details.repeatRule)) && ( @@ -258,6 +261,6 @@ export function EventPage() { {editing && ( setEditing(false)} onSaved={(saved) => event.mutate(() => saved)} onTrashed={() => navigate('/agenda')} /> )} -
    + ); } diff --git a/app/frontend/src/pages/InboxPage.tsx b/app/frontend/src/pages/InboxPage.tsx index faf4f43..068a075 100644 --- a/app/frontend/src/pages/InboxPage.tsx +++ b/app/frontend/src/pages/InboxPage.tsx @@ -1,18 +1,19 @@ import { Link, useSearchParams } from 'react-router'; -import { EmptyState, ListSkeleton, PageHeader, Pagination, timeAgo } from '@project-graphite/ui'; -import type { Page } from '../api'; +import { EmptyState, ListSkeleton, Pagination, timeAgo } from '@project-graphite/ui'; +import type { Page as ResultPage } from '../api'; import { useAuth } from '../auth'; import { useInbox, type InboxNotification } from '../inbox'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { LoadError } from '../components/LoadError'; +import { Page } from '../components/Page'; export function InboxPage() { const auth = useAuth(); const inbox = useInbox(); const [params] = useSearchParams(); const page = Number(params.get('page')) || 1; - const notifications = useResource>(`/me/inbox?page=${page}`, true); + const notifications = useResource>(`/me/inbox?page=${page}`, true); const action = useAction(); function setRead(notification: InboxNotification, read: boolean) { @@ -28,32 +29,31 @@ export function InboxPage() { } return ( -
    - 0 && ( - - ) - } - title="Inbox" - /> + 0 && ( + + ) + } + title="Inbox" + > {notifications.error ? ( ) : !notifications.data ? ( @@ -100,6 +100,6 @@ export function InboxPage() { /> )} -
    + ); } diff --git a/app/frontend/src/pages/InvitationsPage.tsx b/app/frontend/src/pages/InvitationsPage.tsx index d2e6ed2..3b0c12b 100644 --- a/app/frontend/src/pages/InvitationsPage.tsx +++ b/app/frontend/src/pages/InvitationsPage.tsx @@ -1,12 +1,15 @@ import { useState } from 'react'; -import { ListSkeleton, PageHeader, TextAreaField, TextField, timeAgo } from '@project-graphite/ui'; +import { Dialog, ListSkeleton, TextAreaField, TextField, timeAgo } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { noteChecks, type ReceivedInvitation, type SentInvitation } from '../invitations'; -import { addJoinedSpace, SpaceDot, useSpaces } from '../spaces'; +import { addJoinedSpace, SpaceMark, useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; -import { emailAddress, useFormErrors } from '../validation'; +import { emailAddress, required, useFormErrors } from '../validation'; +import { CopyLink } from '../components/CopyLink'; +import { FormDialog } from '../components/FormDialog'; import { LoadError } from '../components/LoadError'; +import { HeaderButton, Page, Section } from '../components/Page'; const statusLabels: Record = { pending: 'waiting', @@ -21,8 +24,7 @@ export function InvitationsPage() { const received = useResource('/me/invitations', true); const sent = useResource('/invitations', true); const answering = useAction(); - const inviting = useAction(); - const inviteForm = useFormErrors(); + const [creating, setCreating] = useState<'email' | 'link' | null>(null); const [link, setLink] = useState(''); function answer(invitation: ReceivedInvitation, accept: boolean) { @@ -35,117 +37,64 @@ export function InvitationsPage() { } return ( -
    - - -
    -

    For you

    + + setCreating('link')} /> + setCreating('email')} primary /> + + } + description="Invite someone to Strata. Each invite works once, for 14 days." + title="Invitations" + > +
    {received.error ? ( ) : !received.data ? ( ) : received.data.length === 0 ? ( -

    No one has invited you to a space right now.

    +

    No one has invited you to a space right now.

    ) : ( -
      +
        {received.data.map((invitation) => ( -
      • -
        -

        - - {invitation.space.name} -

        -

        - {invitation.inviter ? `from ${invitation.inviter.displayName} · ` : ''}as {invitation.role} -

        - {invitation.note &&

        “{invitation.note}”

        } +
      • +
        + +
        +

        {invitation.space.name}

        +

        + {invitation.inviter ? `from ${invitation.inviter.displayName} · ` : ''}as {invitation.role} +

        + {invitation.note &&

        “{invitation.note}”

        } +
        - - +
      • ))}
      )} -
    +
    -
    -

    Invite someone to Strata

    -

    - Each invite works once, for 14 days. Leave the email empty to get a link to send yourself. -

    -
    { - event.preventDefault(); - const target = event.currentTarget; - if (!inviteForm.check(target, { email: [emailAddress], note: noteChecks })) return; - const values = new FormData(target); - const email = String(values.get('email')).trim(); - const note = String(values.get('note')).trim(); - void inviting - .run(async () => { - const created = await auth.request<{ invitation: SentInvitation; link: string; emailed: boolean }>( - '/invitations', - { method: 'POST', body: JSON.stringify({ ...(email ? { email } : {}), ...(note ? { note } : {}) }) }, - ); - setLink(created.link); - sent.mutate((current) => [created.invitation, ...current]); - return created.emailed ? `Sent to ${email}.` : 'Copy the link below and send it to them.'; - }, 'Could not create the invite') - .then((created) => created && target.reset()); - }} - > - - - {link && ( -
    - {link} - -
    - )} - - -
    - -
    -

    Sent

    +
    {sent.error ? ( ) : !sent.data ? ( ) : sent.data.length === 0 ? ( -

    You have not invited anyone yet.

    +

    You have not invited anyone yet.

    ) : ( -
      +
        {sent.data.map((invitation) => ( -
      • +
      • {invitation.email ?? 'Invite link'}

        -

        +

        {statusLabels[invitation.status]} {invitation.joined.map((person) => ` · ${person.displayName} (@${person.handle})`).join('')} · created{' '} {timeAgo(invitation.createdAt)} @@ -154,7 +103,74 @@ export function InvitationsPage() { ))}

      )} -
    -
    +
    + + {creating && ( + setCreating(null)} + onCreated={(invitation, created) => { + sent.mutate((current) => [invitation, ...current]); + setCreating(null); + if (created) setLink(created); + }} + /> + )} + + {link && ( + setLink('')} title="Invite link ready"> +
    +

    Send this link to the person you are inviting. It works once, for 14 days.

    + +
    + +
    +
    +
    + )} + + ); +} + +function InviteDialog({ + mode, + onClose, + onCreated, +}: { + mode: 'email' | 'link'; + onClose: () => void; + onCreated: (invitation: SentInvitation, link: string | null) => void; +}) { + const auth = useAuth(); + const inviting = useAction(); + const form = useFormErrors(); + + return ( + { + if (!form.check(target, { ...(mode === 'email' ? { email: [required('Enter their email address.'), emailAddress] } : {}), note: noteChecks })) return; + const values = new FormData(target); + const email = mode === 'email' ? String(values.get('email')).trim() : ''; + const note = String(values.get('note')).trim(); + void inviting.run(async () => { + const created = await auth.request<{ invitation: SentInvitation; link: string; emailed: boolean }>('/invitations', { + method: 'POST', + body: JSON.stringify({ ...(email ? { email } : {}), ...(note ? { note } : {}) }), + }); + onCreated(created.invitation, created.emailed ? null : created.link); + return created.emailed ? `Invite sent to ${email}.` : email ? 'Email is not set up here, so send them the link yourself.' : ''; + }, 'Could not create the invite'); + }} + submitLabel={mode === 'email' ? 'Send invite' : 'Create link'} + title={mode === 'email' ? 'Invite by email' : 'Create an invite link'} + > + {mode === 'email' && } + + ); } diff --git a/app/frontend/src/pages/JournalPage.tsx b/app/frontend/src/pages/JournalPage.tsx index ce43245..0928b78 100644 --- a/app/frontend/src/pages/JournalPage.tsx +++ b/app/frontend/src/pages/JournalPage.tsx @@ -1,7 +1,8 @@ import { Link, useNavigate, useParams, useSearchParams } from 'react-router'; -import { ListSkeleton, PageHeader } from '@project-graphite/ui'; +import { ListSkeleton } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { LoadError } from '../components/LoadError'; +import { Page } from '../components/Page'; import { OpenCreatedPage } from '../components/OpenCreatedPage'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; @@ -60,15 +61,15 @@ export function JournalPage() { } return ( -
    - - Write a weekly review - - } - title="Journal" - /> + + Write a weekly review + + } + title="Journal" + wide + >

    How was today?

    @@ -125,7 +126,7 @@ export function JournalPage() {

    Darker days felt better. A ring means the day has a page.

    -
    + ); } diff --git a/app/frontend/src/pages/MenuPage.tsx b/app/frontend/src/pages/MenuPage.tsx index f67e816..e656102 100644 --- a/app/frontend/src/pages/MenuPage.tsx +++ b/app/frontend/src/pages/MenuPage.tsx @@ -1,5 +1,6 @@ import { Link, useLocation } from 'react-router'; -import { Icon, PageHeader, type IconName } from '@project-graphite/ui'; +import { Icon, type IconName } from '@project-graphite/ui'; +import { Page } from '../components/Page'; import { useInbox } from '../inbox'; import { areaSections } from '../navigation'; import { useSpaces } from '../spaces'; @@ -10,15 +11,14 @@ export function MenuPage() { const inbox = useInbox(); return ( -
    - + {areaSections(pathname, spaces.data, inbox.data?.unread ?? 0).map((section, index) => ( ))} -
    + ); } diff --git a/app/frontend/src/pages/PagesIndex.tsx b/app/frontend/src/pages/PagesIndex.tsx index d415b31..b276248 100644 --- a/app/frontend/src/pages/PagesIndex.tsx +++ b/app/frontend/src/pages/PagesIndex.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router'; -import { EmptyState, Icon, ListSkeleton, Menu, PageHeader, timeAgo } from '@project-graphite/ui'; +import { EmptyState, Icon, ListSkeleton, Menu, timeAgo } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { ImportPages } from '../components/ImportPages'; import { LoadError } from '../components/LoadError'; +import { NoteMark } from '../components/NoteMark'; +import { HeaderButton, Page } from '../components/Page'; import { announcePagesChanged, noteTitle, type Note } from '../notes'; import { useSpaces } from '../spaces'; import { useAction } from '../useAction'; @@ -33,35 +35,32 @@ export function PagesIndex({ kind }: { kind: 'note' | 'board' }) { } return ( -
    - - {kind === 'note' && writable.length > 0 && ( - - )} - {writable.length === 1 ? ( - - ) : ( - 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} - /> + + {kind === 'note' && writable.length > 0 && setImporting(true)} />} + {writable.length === 1 ? ( + create(writable[0]!.id)} primary /> + ) : ( + 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} + > {importing && ( setImporting(false)} @@ -81,13 +80,11 @@ export function PagesIndex({ kind }: { kind: 'note' | 'board' }) {

    {words.hint}

    ) : ( -
      +
        {listed.data.map((note) => (
      • - - + + {noteTitle(note)} {spaces.data?.find((space) => space.id === note.spaceId)?.name} · {timeAgo(note.updatedAt)} @@ -97,6 +94,6 @@ export function PagesIndex({ kind }: { kind: 'note' | 'board' }) { ))}
      )} -
    + ); } diff --git a/app/frontend/src/pages/RecurringPage.tsx b/app/frontend/src/pages/RecurringPage.tsx index 0b6beb8..bb3ab74 100644 --- a/app/frontend/src/pages/RecurringPage.tsx +++ b/app/frontend/src/pages/RecurringPage.tsx @@ -1,13 +1,15 @@ import { useState } from 'react'; import { Link } from 'react-router'; -import { EmptyState, ListSkeleton, PageHeader } from '@project-graphite/ui'; +import { EmptyState, ListSkeleton } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { useSpaces } from '../spaces'; -import { money, shortDate, type Subscription } from '../subscriptions'; +import { categoryLabel, money, shortDate, type Subscription } from '../subscriptions'; import { useAction } from '../useAction'; import { useResource } from '../useResource'; import { LoadError } from '../components/LoadError'; +import { HeaderButton, Page } from '../components/Page'; import { StatementImport } from '../components/StatementImport'; +import { SubscriptionEditor } from '../components/SubscriptionEditor'; interface Total { currency: string; @@ -42,19 +44,29 @@ export function RecurringPage() { const summary = useResource('/subscriptions/summary', true); const action = useAction(); const [importing, setImporting] = useState(false); + const [adding, setAdding] = useState(false); + const editable = (spaces.data ?? []).filter((space) => space.role !== 'viewer'); return ( -
    - space.role !== 'viewer') && ( - - ) - } - title="Recurring" - /> + 0 && ( + <> + setImporting(true)} /> + setAdding(true)} primary /> + + ) + } + title="Recurring" + > + {adding && ( + setAdding(false)} + onSaved={summary.reload} + spaceId={(editable.find((space) => space.kind === 'personal') ?? editable[0])!.id} + spaces={editable} + /> + )} {importing && ( {summary.data.categories.map((category) => (

    - {category.category} + {categoryLabel(category.category)} {money(category.monthlyMinor, category.currency)} a month

    ))} @@ -168,6 +180,6 @@ export function RecurringPage() { )} )} -
    + ); } diff --git a/app/frontend/src/pages/SavePage.tsx b/app/frontend/src/pages/SavePage.tsx index 4e2c8ea..93106b4 100644 --- a/app/frontend/src/pages/SavePage.tsx +++ b/app/frontend/src/pages/SavePage.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { EmptyState, FormPanelSkeleton, TextField } from '@project-graphite/ui'; +import { EmptyState, FormPanelSkeleton, Select, TextField } from '@project-graphite/ui'; import { useAuth } from '../auth'; import { firstLink, saveBookmark } from '../bookmarks'; import { fileSize, uploadFiles } from '../files'; -import { useSpaces } from '../spaces'; +import { spaceOptions, useSpaces } from '../spaces'; import { useAction } from '../useAction'; import { atMost, required, useFormErrors } from '../validation'; @@ -115,16 +115,7 @@ export function SavePage() { {editable.length === 0 ? (

    You can only view your spaces, so there is nowhere to save this.

    ) : ( - + - - {(spaces.data ?? []).map((space) => ( - - ))} - - + - {zones.map((zone) => ( - - ))} - - - +
- + - +
@{current.handle} - +
- - - void notifications.run(async () => { - const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ tidySummary }) }); - me.mutate(() => next); - return tidySummary ? 'You will get a Tidy summary on Mondays.' : 'Weekly Tidy summaries are off.'; - }, 'Could not change the setting') - } - /> - - void notifications.run(async () => { - const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ weeklyReview }) }); - me.mutate(() => next); - return weeklyReview ? 'You will be reminded on Sundays.' : 'Weekly review reminders are off.'; - }, 'Could not change the setting') - } - /> - - +
+
+ + + void notifications.run(async () => { + const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ tidySummary }) }); + me.mutate(() => next); + return tidySummary ? 'You will get a Tidy summary on Mondays.' : 'Weekly Tidy summaries are off.'; + }, 'Could not change the setting') + } + /> + + void notifications.run(async () => { + const next = await auth.request('/me', { method: 'PATCH', body: JSON.stringify({ weeklyReview }) }); + me.mutate(() => next); + return weeklyReview ? 'You will be reminded on Sundays.' : 'Weekly review reminders are off.'; + }, 'Could not change the setting') + } + /> + + +
+
{changingEmail && ( >('/me/audit?page=1', true); return ( - +
{log.error ? ( ) : !log.data ? ( ) : ( -
    +
      {log.data.results.map((event) => ( -
    • +
    • {actions[event.action] ?? event.action} {(event.data.device || event.data.reason || event.data.name) && ( @@ -60,6 +60,6 @@ export function SecurityLog() { ))}
    )} - +
); } diff --git a/app/frontend/src/pages/settings/SecuritySettings.tsx b/app/frontend/src/pages/settings/SecuritySettings.tsx index 8388f1b..7b91095 100644 --- a/app/frontend/src/pages/settings/SecuritySettings.tsx +++ b/app/frontend/src/pages/settings/SecuritySettings.tsx @@ -8,7 +8,9 @@ import { QrCode } from '../../components/QrCode'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { password as newPasswordChecks, required, useFormErrors } from '../../validation'; -import { SettingsRow, SettingsRows, SettingsSection, type TwoStepStatus } from './SettingsLayout'; +import { SessionsSection } from './SessionsSection'; +import { Section } from '../../components/Page'; +import { SettingsRow, SettingsRows, type TwoStepStatus } from './SettingsLayout'; type Opened = 'password' | 'enable' | 'codes' | 'disable'; @@ -180,7 +182,7 @@ export function SecuritySettings() { return (
- +
- +
{opened === 'password' && ( )} +
); } diff --git a/app/frontend/src/pages/settings/SessionsSettings.tsx b/app/frontend/src/pages/settings/SessionsSection.tsx similarity index 89% rename from app/frontend/src/pages/settings/SessionsSettings.tsx rename to app/frontend/src/pages/settings/SessionsSection.tsx index 1049d48..a32c69b 100644 --- a/app/frontend/src/pages/settings/SessionsSettings.tsx +++ b/app/frontend/src/pages/settings/SessionsSection.tsx @@ -3,7 +3,7 @@ import { useAuth } from '../../auth'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { SecurityLog } from './SecurityLog'; -import { SettingsSection } from './SettingsLayout'; +import { Section } from '../../components/Page'; import { LoadError } from '../../components/LoadError'; interface Session { @@ -14,7 +14,7 @@ interface Session { current: boolean; } -export function SessionsSettings() { +export function SessionsSection() { const auth = useAuth(); const sessions = useResource('/me/sessions', true); const action = useAction(); @@ -32,8 +32,8 @@ export function SessionsSettings() { } return ( -
- +
0 && (
-
+ ); } diff --git a/app/frontend/src/pages/settings/SettingsLayout.tsx b/app/frontend/src/pages/settings/SettingsLayout.tsx index 770514b..895bcb5 100644 --- a/app/frontend/src/pages/settings/SettingsLayout.tsx +++ b/app/frontend/src/pages/settings/SettingsLayout.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Outlet, useLocation } from 'react-router'; -import { PageHeader, Tabs } from '@project-graphite/ui'; +import { Tabs } from '@project-graphite/ui'; +import { Page } from '../../components/Page'; export interface Me { id: string; @@ -22,31 +23,17 @@ export interface TwoStepStatus { const tabs = [ ['/settings', 'Profile'], ['/settings/security', 'Security'], - ['/settings/sessions', 'Sessions'], - ['/settings/tokens', 'Access tokens'], - ['/settings/api', 'API and webhooks'], + ['/settings/developer', 'Developer'], ['/settings/data', 'Your data'], ] as const; -export function SettingsSection({ action, children, title }: { action?: ReactNode; children: ReactNode; title: string }) { - return ( -
-
-

{title}

- {action} -
-
{children}
-
- ); -} - export function SettingsRows({ children }: { children: ReactNode }) { return
{children}
; } export function SettingsRow({ action, children, label }: { action?: ReactNode; children?: ReactNode; label: ReactNode }) { return ( -
+

{label}

{children &&
{children}
} @@ -59,10 +46,9 @@ export function SettingsRow({ action, children, label }: { action?: ReactNode; c export function SettingsLayout() { const { pathname } = useLocation(); return ( -
- + ({ active: pathname === href, href, label }))} label="Settings" /> -
+ ); } diff --git a/app/frontend/src/pages/settings/TokensSettings.tsx b/app/frontend/src/pages/settings/TokensSettings.tsx index 360657d..c57f610 100644 --- a/app/frontend/src/pages/settings/TokensSettings.tsx +++ b/app/frontend/src/pages/settings/TokensSettings.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ConfirmDialog, Dialog, EmptyState, FormSkeleton, Icon, TextField, timeAgo } from '@project-graphite/ui'; +import { ConfirmDialog, Dialog, EmptyState, FormSkeleton, Icon, Select, TextField, timeAgo } from '@project-graphite/ui'; import { useAuth } from '../../auth'; import { FormDialog } from '../../components/FormDialog'; import { LoadError } from '../../components/LoadError'; @@ -7,7 +7,8 @@ import { ProofFields, proofFrom } from '../../components/ProofFields'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { atMost, required, useFormErrors } from '../../validation'; -import { SettingsRow, SettingsRows, SettingsSection, type TwoStepStatus } from './SettingsLayout'; +import { Section } from '../../components/Page'; +import { SettingsRow, SettingsRows, type TwoStepStatus } from './SettingsLayout'; interface AccessToken { id: string; @@ -46,7 +47,7 @@ export function TokensSettings() { return (
- setOpen(true)} type="button"> @@ -81,7 +82,7 @@ export function TokensSettings() { ))} )} - + {open && created && ( @@ -155,15 +156,17 @@ export function TokensSettings() {

)} - + - {[7, 6, 5, 4, 3, 2, 1].map((days) => ( - - ))} - - + - - - - - -
- - - - + <> + {pending.error && } {pending.data && pending.data.length > 0 && ( -
-

Waiting for an answer

-
    +
    +
      {pending.data.map((invitation) => ( -
    • +
    • {invitation.invitee ? `${invitation.invitee.displayName} (@${invitation.invitee.handle})` : invitation.email}

      -

      +

      as {invitation.role} · sent {timeAgo(invitation.createdAt)}

      @@ -101,9 +47,64 @@ export function SpaceInvitations({ space }: { space: Space }) {
    • ))}
    -
+ )} - {pending.error && } - + {inviting && ( + { + pending.reload(); + onClose(); + }} + space={space} + /> + )} + + ); +} + +function InviteToSpaceDialog({ onClose, onInvited, space }: { onClose: () => void; onInvited: () => void; space: Space }) { + const auth = useAuth(); + const inviting = useAction(); + const form = useFormErrors(); + + return ( + { + if (!form.check(target, { who: [required('Enter an email address or a @handle.')], note: noteChecks })) return; + const values = new FormData(target); + const who = String(values.get('who')).trim(); + const note = String(values.get('note')).trim(); + void inviting.run(async () => { + const created = await auth.request<{ invitation: SentInvitation; emailed: boolean }>(`/spaces/${space.id}/invitations`, { + method: 'POST', + body: JSON.stringify({ + ...(who.includes('@') && !who.startsWith('@') ? { email: who } : { handle: who }), + role: values.get('role'), + ...(note ? { note } : {}), + }), + }); + onInvited(); + return created.emailed ? `Invited ${who}.` : `Invited ${who}, but the email could not be sent.`; + }, 'Could not send the invite'); + }} + submitLabel="Send invite" + title={`Invite people to ${space.name}`} + > + + changeRole(member, event.currentTarget.value as SpaceRole)} + onChange={(role) => changeRole(member, role)} + options={roleOptions} + size="small" value={member.role} - > - - - - + /> ) : ( - {member.role} + {roleOptions.find((option) => option.value === member.role)?.label} )} {space.kind === 'shared' && (manages || member.userId === self) && ( - - ({ label, onSelect: () => create({ template, title: label }) })), - ...templates.map((note, index) => ({ label: noteTitle(note), onSelect: () => create({ fromNoteId: note.id, title: note.title }), separated: index === 0 })), - ]} - label="Templates" - trigger="From a template" - triggerClassName="secondary-button px-3 py-2 text-sm" - triggerLabel="Start a page from a template" - /> -
- )} +
+ + ({ label, onSelect: () => create({ template, title: label }) })), + ...templates.map((note, index) => ({ label: noteTitle(note), onSelect: () => create({ fromNoteId: note.id, title: note.title }), separated: index === 0 })), + ]} + label="Templates" + trigger={ + <> + From a template + + + } + triggerClassName="secondary-button px-3 py-2 text-sm" + triggerLabel="Start a page from a template" + /> + create({ board: true })} /> + create({})} primary /> + + ) + } + > +

{pages.length === 1 ? '1 page' : `${pages.length} pages`}

+ {branches.length === 0 ? ( ) : ( -
    +
      {branches.map(({ note, depth }) => ( ))} @@ -83,7 +89,7 @@ export function SpaceNotes() { {templates.length > 0 && (

      Templates in this space

      -
        +
          {templates.map((note) => ( ))} diff --git a/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx b/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx index 29ba79f..436c6ea 100644 --- a/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx +++ b/app/frontend/src/pages/spaces/SpaceSubscriptions.tsx @@ -11,6 +11,7 @@ import { useResource } from '../../useResource'; import { SpaceBalances } from './SpaceBalances'; import { useSpace } from './SpaceLayout'; import { LoadError } from '../../components/LoadError'; +import { HeaderButton, Toolbar } from '../../components/Page'; export function SpaceSubscriptions() { const auth = useAuth(); @@ -47,17 +48,17 @@ export function SpaceSubscriptions() { if (!subscriptions.data) return ; return ( -
          -
          - - {cancelled ? 'Show active' : 'Show cancelled'} - - {editable && !cancelled && ( - - )} -
          +
          + setEditing('new')} primary />}> + + {shared && !cancelled && } {subscriptions.data.length === 0 ? ( diff --git a/app/frontend/src/pages/spaces/SpaceTags.tsx b/app/frontend/src/pages/spaces/SpaceTags.tsx index b8f4ec8..ce7592a 100644 --- a/app/frontend/src/pages/spaces/SpaceTags.tsx +++ b/app/frontend/src/pages/spaces/SpaceTags.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ConfirmDialog, Dialog, EmptyState, ListSkeleton, TagChip } from '@project-graphite/ui'; +import { ConfirmDialog, EmptyState, Icon, ListSkeleton, Menu, TagChip } from '@project-graphite/ui'; import { useAuth } from '../../auth'; import { NameAndColorFields, nameAndColorFrom, type Tag } from '../../spaces'; import { useAction } from '../../useAction'; @@ -7,7 +7,9 @@ import { useResource } from '../../useResource'; import { atMost, required, useFormErrors } from '../../validation'; import { useSpace } from './SpaceLayout'; import { TidyRules } from './TidyRules'; +import { FormDialog } from '../../components/FormDialog'; import { LoadError } from '../../components/LoadError'; +import { HeaderButton, Toolbar } from '../../components/Page'; const tagNameChecks = [required('Enter a tag name.'), atMost(40, 'Use at most 40 characters.')]; @@ -17,11 +19,9 @@ export function SpaceTags() { const auth = useAuth(); const space = useSpace(); const tags = useResource(`/spaces/${space.id}/tags`, true); - const creating = useAction(); const saving = useAction(); - const createForm = useFormErrors(); - const editForm = useFormErrors(); - const [editing, setEditing] = useState(); + const form = useFormErrors(); + const [editing, setEditing] = useState(); const [deleting, setDeleting] = useState(); const editable = space.role !== 'viewer'; @@ -29,102 +29,68 @@ export function SpaceTags() { if (!tags.data) return ; return ( -
          - {tags.data.length === 0 ? ( - -

          - Tags group items across every area of this space. -

          -
          - ) : ( -
            - {tags.data.map((tag) => ( -
          • - - {editable && ( -
            - - -
            - )} -
          • - ))} -
          - )} - - {editable && ( -
          -

          New tag

          -
          { - event.preventDefault(); - const target = event.currentTarget; - if (!createForm.check(target, { name: tagNameChecks })) return; - void creating - .run(async () => { - const created = await auth.request(`/spaces/${space.id}/tags`, { - method: 'POST', - body: JSON.stringify(nameAndColorFrom(target)), - }); - tags.mutate((current) => [...current, created].sort(byName)); - return `Added ${created.name}.`; - }, 'Could not add the tag') - .then((added) => added && target.reset()); - }} - > - - - -
          - )} +
          +
          + setEditing('new')} primary />}> +

          Tags group items across every area of this space.

          +
          + {tags.data.length === 0 ? ( + + ) : ( +
            + {tags.data.map((tag) => ( +
          • + + {editable && ( + setEditing(tag) }, + { label: 'Delete', onSelect: () => setDeleting(tag), separated: true }, + ]} + label={`${tag.name} actions`} + trigger={} + triggerClassName="icon-button" + triggerLabel={`Actions for ${tag.name}`} + /> + )} +
          • + ))} +
          + )} +
          {editing && ( - setEditing(undefined)} title={`Edit ${editing.name}`}> -
          { - event.preventDefault(); - const target = event.currentTarget; - if (!editForm.check(target, { name: tagNameChecks })) return; - void saving - .run(async () => { - const updated = await auth.request(`/tags/${editing.id}`, { - method: 'PATCH', - body: JSON.stringify(nameAndColorFrom(target)), - }); - tags.mutate((current) => current.map((tag) => (tag.id === updated.id ? updated : tag)).sort(byName)); - return ''; - }, 'Could not save the tag') - .then((saved) => saved && setEditing(undefined)); - }} - > - -
          - - -
          - -
          + setEditing(undefined)} + onSubmit={(target) => { + if (!form.check(target, { name: tagNameChecks })) return; + void saving + .run(async () => { + if (editing === 'new') { + const created = await auth.request(`/spaces/${space.id}/tags`, { method: 'POST', body: JSON.stringify(nameAndColorFrom(target)) }); + tags.mutate((current) => [...current, created].sort(byName)); + return `Added ${created.name}.`; + } + const updated = await auth.request(`/tags/${editing.id}`, { method: 'PATCH', body: JSON.stringify(nameAndColorFrom(target)) }); + tags.mutate((current) => current.map((tag) => (tag.id === updated.id ? updated : tag)).sort(byName)); + return ''; + }, editing === 'new' ? 'Could not add the tag' : 'Could not save the tag') + .then((saved) => saved && setEditing(undefined)); + }} + submitLabel={editing === 'new' ? 'Add tag' : 'Save'} + title={editing === 'new' ? 'New tag' : `Edit ${editing.name}`} + > + + )} {deleting && ( diff --git a/app/frontend/src/pages/spaces/SpaceTasks.tsx b/app/frontend/src/pages/spaces/SpaceTasks.tsx index 3bebb0d..0ec45d2 100644 --- a/app/frontend/src/pages/spaces/SpaceTasks.tsx +++ b/app/frontend/src/pages/spaces/SpaceTasks.tsx @@ -1,39 +1,37 @@ import { useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router'; -import { EmptyState, ListSkeleton, Pagination, TextField } from '@project-graphite/ui'; -import type { Page } from '../../api'; +import { EmptyState, Icon, ListSkeleton, Menu, Pagination, TextField } from '@project-graphite/ui'; +import type { Page as ResultPage } from '../../api'; import { useAuth } from '../../auth'; import { FormDialog } from '../../components/FormDialog'; +import { LoadError } from '../../components/LoadError'; +import { HeaderButton, Toolbar } from '../../components/Page'; import { TaskEditor } from '../../components/TaskEditor'; -import { describeQuickTask, parseQuickTask } from '../../quick-add'; import type { Tag } from '../../spaces'; import { TaskRow, type Task, type TaskList } from '../../tasks'; import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; +import { atMost, required, useFormErrors } from '../../validation'; import { useSpace } from './SpaceLayout'; -import { LoadError } from '../../components/LoadError'; export function SpaceTasks() { const auth = useAuth(); const space = useSpace(); + const navigate = useNavigate(); const [params] = useSearchParams(); const listId = params.get('list'); const completed = params.get('completed') === 'true'; const page = Number(params.get('page')) || 1; const base = `/spaces/${space.id}/tasks`; const filters = `${listId ? `&listId=${encodeURIComponent(listId)}` : ''}${completed ? '&completed=true' : ''}`; - const tasks = useResource>(`/spaces/${space.id}/tasks?page=${page}${filters}`, true); + const tasks = useResource>(`/spaces/${space.id}/tasks?page=${page}${filters}`, true); const lists = useResource(`/spaces/${space.id}/lists`, true); const members = useResource<{ userId: string; displayName: string }[]>(`/spaces/${space.id}/members`, true); const tags = useResource(`/spaces/${space.id}/tags`, true); - const adding = useAction(); const action = useAction(); - const [editing, setEditing] = useState(); - const [renaming, setRenaming] = useState(false); - const navigate = useNavigate(); - const [quickText, setQuickText] = useState(''); - const preview = parseQuickTask(quickText, (tags.data ?? []).map((tag) => tag.name)); - const understood = describeQuickTask(preview); + const listForm = useFormErrors(); + const [editing, setEditing] = useState(); + const [listDialog, setListDialog] = useState<'new' | 'rename'>(); const editable = space.role !== 'viewer'; const link = (next: { list?: string | null; completed?: boolean; page?: number }) => { const query = new URLSearchParams(); @@ -45,12 +43,19 @@ export function SpaceTasks() { }; function replace(task: Task) { - tasks.mutate((current) => ({ - ...current, - results: current.results.flatMap((shown) => - shown.id !== task.id ? [shown] : Boolean(task.completedAt) === completed ? [task] : [], - ), - })); + tasks.mutate((current) => { + const shown = current.results.some((entry) => entry.id === task.id); + const belongs = Boolean(task.completedAt) === completed && (!listId || task.listId === listId); + return { + ...current, + totalResults: current.totalResults + (shown ? (belongs ? 0 : -1) : belongs ? 1 : 0), + results: shown + ? current.results.flatMap((entry) => (entry.id !== task.id ? [entry] : belongs ? [task] : [])) + : belongs + ? [...current.results, task] + : current.results, + }; + }); lists.reload(); } @@ -59,105 +64,55 @@ export function SpaceTasks() { const shownList = lists.data.find((list) => list.id === listId); return ( -
          - + {editable && shownList && ( + setListDialog('rename') }, + { + label: 'Move list to trash', + separated: true, + onSelect: () => + void action.run(async () => { + await auth.request(`/items/${shownList.id}/trash`, { method: 'POST' }); + lists.mutate((current) => current.filter((list) => list.id !== shownList.id)); + navigate(link({ list: null })); + return `Moved the list ${shownList.title} to the trash. Its tasks stay in this space.`; + }, 'Could not move the list to the trash'), + }, + ]} + label={`${shownList.title} actions`} + trigger={} + triggerClassName="icon-button" + triggerLabel={`Actions for ${shownList.title}`} + /> + )} + {tasks.data.results.length === 0 ? ( - + + {!completed && editable && ( +

          Add one with New task. Give it a due date and it shows up on Today.

          + )} +
          ) : (
            {tasks.data.results.map((task) => ( @@ -167,9 +122,7 @@ export function SpaceTasks() { onEdit={() => setEditing(task)} onToggle={() => void action.run(async () => { - replace( - await auth.request(`/tasks/${task.id}/${task.completedAt ? 'reopen' : 'complete'}`, { method: 'POST' }), - ); + replace(await auth.request(`/tasks/${task.id}/${task.completedAt ? 'reopen' : 'complete'}`, { method: 'POST' })); return ''; }, 'Could not update the task') } @@ -180,76 +133,65 @@ export function SpaceTasks() {
          )} -
          - +
          + {completed ? 'Show open' : 'Show finished'} - {editable && ( -
          { - event.preventDefault(); - const target = event.currentTarget; - const title = String(new FormData(target).get('title')).trim(); - if (!title) return; - void adding - .run(async () => { - const created = await auth.request(`/spaces/${space.id}/lists`, { - method: 'POST', - body: JSON.stringify({ title }), - }); - lists.mutate((current) => [...current, created]); - return `Added the list ${created.title}.`; - }, 'Could not add the list') - .then((added) => added && target.reset()); - }} - > - - - - )} + link({ page: next })} totalPages={tasks.data.totalPages} />
          - link({ page: next })} totalPages={tasks.data.totalPages} /> - {editing && ( setEditing(undefined)} onSaved={replace} onTrashed={() => { - tasks.mutate((current) => ({ ...current, totalResults: current.totalResults - 1, results: current.results.filter((task) => task.id !== editing.id) })); + if (editing === 'new') return; + tasks.mutate((current) => ({ + ...current, + totalResults: current.totalResults - 1, + results: current.results.filter((task) => task.id !== editing.id), + })); lists.reload(); }} - task={editing} + spaceId={space.id} + tags={tags.data ?? []} + task={editing === 'new' ? undefined : editing} /> )} - {renaming && shownList && ( + {listDialog && ( setRenaming(false)} + onClose={() => setListDialog(undefined)} onSubmit={(target) => { + if (!listForm.check(target, { title: [required('Name the list.'), atMost(100, 'Use at most 100 characters.')] })) return; const title = String(new FormData(target).get('title')).trim(); - if (!title) return; void action - .run(async () => { - await auth.request(`/items/${shownList.id}`, { method: 'PATCH', body: JSON.stringify({ title }) }); - lists.mutate((current) => current.map((list) => (list.id === shownList.id ? { ...list, title } : list))); - return ''; - }, 'Could not rename the list') - .then((saved) => saved && setRenaming(false)); + .run( + async () => { + if (listDialog === 'rename' && shownList) { + await auth.request(`/items/${shownList.id}`, { method: 'PATCH', body: JSON.stringify({ title }) }); + lists.mutate((current) => current.map((list) => (list.id === shownList.id ? { ...list, title } : list))); + return ''; + } + const created = await auth.request(`/spaces/${space.id}/lists`, { method: 'POST', body: JSON.stringify({ title }) }); + lists.mutate((current) => [...current, created]); + navigate(link({ list: created.id })); + return `Added the list ${created.title}.`; + }, + listDialog === 'rename' ? 'Could not rename the list' : 'Could not add the list', + ) + .then((saved) => saved && setListDialog(undefined)); }} - submitLabel="Rename" - title="Rename list" + submitLabel={listDialog === 'rename' ? 'Rename' : 'Add list'} + title={listDialog === 'rename' ? 'Rename list' : 'New list'} > - + )}
          diff --git a/app/frontend/src/pages/spaces/SpacesPage.tsx b/app/frontend/src/pages/spaces/SpacesPage.tsx index cf3cbaa..307d3cd 100644 --- a/app/frontend/src/pages/spaces/SpacesPage.tsx +++ b/app/frontend/src/pages/spaces/SpacesPage.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { Link, useNavigate } from 'react-router'; -import { ConfirmDialog, Dialog, ListSkeleton, Menu, type MenuItem, PageHeader } from '@project-graphite/ui'; +import { ConfirmDialog, Icon, ListSkeleton, Menu, type MenuItem } from '@project-graphite/ui'; import { useAuth } from '../../auth'; -import { NameAndColorFields, nameAndColorFrom, SpaceDot, spaceNameChecks, useSpaces, type Space } from '../../spaces'; +import { FormDialog } from '../../components/FormDialog'; +import { LoadError } from '../../components/LoadError'; +import { HeaderButton, Page } from '../../components/Page'; +import { NameAndColorFields, nameAndColorFrom, SpaceMark, spaceNameChecks, useSpaces, type Space } from '../../spaces'; import { useAction } from '../../useAction'; import { useFormErrors } from '../../validation'; -import { LoadError } from '../../components/LoadError'; export function SpacesPage() { const auth = useAuth(); @@ -15,6 +17,7 @@ export function SpacesPage() { const renaming = useAction(); const createForm = useFormErrors(); const renameForm = useFormErrors(); + const [creatingSpace, setCreatingSpace] = useState(false); const [editing, setEditing] = useState(); const [deleting, setDeleting] = useState(); const [leaving, setLeaving] = useState(); @@ -33,31 +36,25 @@ export function SpacesPage() { } return ( -
          - - + setCreatingSpace(true)} primary />} title="Spaces"> {spaces.error ? ( ) : !spaces.data ? ( ) : ( -
            +
              {spaces.data.map((space) => ( -
            • -
              - - - {space.name} - -

              - {space.kind === 'personal' ? 'Personal' : `Shared · ${space.role}`} -

              -
              +
            • + + + {space.name} + + {space.kind === 'personal' ? 'Only you' : `Shared · ${space.role}`} } + triggerClassName="icon-button" triggerLabel={`Actions for ${space.name}`} />
            • @@ -65,70 +62,48 @@ export function SpacesPage() {
            )} -
            -

            New shared space

            -
            { - event.preventDefault(); - const target = event.currentTarget; + {creatingSpace && ( + setCreatingSpace(false)} + onSubmit={(target) => { if (!createForm.check(target, { name: spaceNameChecks })) return; void creating.run(async () => { - const created = await auth.request('/spaces', { - method: 'POST', - body: JSON.stringify(nameAndColorFrom(target)), - }); + const created = await auth.request('/spaces', { method: 'POST', body: JSON.stringify(nameAndColorFrom(target)) }); spaces.mutate((current) => [...current, created]); navigate(`/spaces/${created.id}`); return ''; }, 'Could not create the space'); }} + submitLabel="Create space" + title="New shared space" > +

            Invite people once it exists. Everything in it is shared with them.

            - - -
            + + )} {editing && ( - setEditing(undefined)} title={`Rename ${editing.name}`}> -
            { - event.preventDefault(); - const target = event.currentTarget; - if (!renameForm.check(target, { name: spaceNameChecks })) return; - void renaming - .run(async () => { - const updated = await auth.request(`/spaces/${editing.id}`, { - method: 'PATCH', - body: JSON.stringify(nameAndColorFrom(target)), - }); - spaces.mutate((current) => current.map((space) => (space.id === updated.id ? updated : space))); - return ''; - }, 'Could not rename the space') - .then((saved) => saved && setEditing(undefined)); - }} - > - -
            - - -
            - -
            + setEditing(undefined)} + onSubmit={(target) => { + if (!renameForm.check(target, { name: spaceNameChecks })) return; + void renaming + .run(async () => { + const updated = await auth.request(`/spaces/${editing.id}`, { method: 'PATCH', body: JSON.stringify(nameAndColorFrom(target)) }); + spaces.mutate((current) => current.map((space) => (space.id === updated.id ? updated : space))); + return ''; + }, 'Could not rename the space') + .then((saved) => saved && setEditing(undefined)); + }} + submitLabel="Save" + title={`Rename ${editing.name}`} + > + + )} {deleting && ( @@ -162,6 +137,6 @@ export function SpacesPage() { You lose access to everything in it until someone invites you back. )} -
          + ); } diff --git a/app/frontend/src/pages/spaces/TidyRules.tsx b/app/frontend/src/pages/spaces/TidyRules.tsx index bbb3105..ff7e654 100644 --- a/app/frontend/src/pages/spaces/TidyRules.tsx +++ b/app/frontend/src/pages/spaces/TidyRules.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { ConfirmDialog, ListSkeleton, TagChip, TextField } from '@project-graphite/ui'; +import { ConfirmDialog, Icon, ListSkeleton, Menu, Select, TagChip, TextField } from '@project-graphite/ui'; import { useAuth } from '../../auth'; import { batchSummary, TidyPreview, type TidyRequest } from '../../components/TidyPreview'; import { fileTypeNames } from '../../files'; @@ -8,7 +8,9 @@ import { useAction } from '../../useAction'; import { useResource } from '../../useResource'; import { atMost, required, useFormErrors } from '../../validation'; import { useSpace } from './SpaceLayout'; +import { FormDialog } from '../../components/FormDialog'; import { LoadError } from '../../components/LoadError'; +import { HeaderButton, Section } from '../../components/Page'; interface Rule { id: string; @@ -47,150 +49,119 @@ export function TidyRules({ tags }: { tags: Tag[] }) { const form = useFormErrors(); const [deleting, setDeleting] = useState(); const [request, setRequest] = useState(); + const [adding, setAdding] = useState(false); const editable = space.role !== 'viewer'; + function findMatches(rule: Rule) { + void changing.run(async () => { + const matches = await auth.request<{ total: number; results: { id: string }[] }>(`/tidy-rules/${rule.id}/matches`); + if (matches.total === 0) return 'No existing items match this rule.'; + setRequest({ action: 'tag', tagId: rule.tag.id, itemIds: matches.results.map((item) => item.id) }); + return matches.total > matches.results.length ? `${matches.total} items match. Showing the newest ${matches.results.length}.` : ''; + }, 'Could not find matching items'); + } + + function toggle(rule: Rule) { + void changing.run(async () => { + const updated = await auth.request(`/tidy-rules/${rule.id}`, { method: 'PATCH', body: JSON.stringify({ enabled: !rule.enabled }) }); + rules.mutate((current) => current.map((shown) => (shown.id === updated.id ? updated : shown))); + return updated.enabled ? 'The rule is on again. It tags items created from now on.' : 'The rule is paused.'; + }, 'Could not change the rule'); + } + return ( -
          -
          -

          Tagging rules

          -

          - Tag new items in this space by words in their title, by file type, or both. -

          -
          +
          0 && setAdding(true)} />} + description="Tag new items in this space by words in their title, by file type, or both." + title="Tagging rules" + > {rules.error ? ( ) : !rules.data ? ( + ) : rules.data.length === 0 ? ( +

          {editable && tags.length === 0 ? 'Add a tag first, then make a rule for it.' : 'No rules yet.'}

          ) : ( - rules.data.length > 0 && ( -
            - {rules.data.map((rule) => ( -
          • -
            -

            - {ruleText(rule)} → -

            -

            {rule.enabled ? 'On' : 'Paused'}

            +
              + {rules.data.map((rule) => ( +
            • +
              +

              + {ruleText(rule)} → +

              +

              {rule.enabled ? 'On' : 'Paused'}

              +
              + {editable && ( +
              + + toggle(rule) }, + { label: 'Delete', onSelect: () => setDeleting(rule), separated: true }, + ]} + label="Rule actions" + trigger={} + triggerClassName="icon-button" + triggerLabel={`Actions for ${ruleText(rule)}`} + />
              - {editable && ( -
              - - - -
              - )} -
            • - ))} -
            - ) + )} +
          • + ))} +
          )} - {editable && - (tags.length === 0 ? ( -

          Add a tag first, then make a rule for it.

          - ) : ( -
          { - event.preventDefault(); - const target = event.currentTarget; - const values = new FormData(target); - const fileType = values.get('fileType'); - const limit = atMost(100, 'Use at most 100 characters.'); - if (!form.check(target, { titleContains: fileType ? [limit] : [required('Enter words to look for, or choose a file type.'), limit] })) return; - void creating - .run(async () => { - const created = await auth.request(`/spaces/${space.id}/tidy-rules`, { - method: 'POST', - body: JSON.stringify({ - titleContains: String(values.get('titleContains')).trim(), - tagId: values.get('tagId'), - ...(fileType ? { fileType } : values.get('kind') ? { kind: values.get('kind') } : {}), - }), - }); - rules.mutate((current) => [...current, created]); - const words = created.titleContains && `“${created.titleContains}” in the title`; - return created.fileType - ? `${fileTypeNames[created.fileType]} uploaded from now on${words ? ` with ${words}` : ''} will be tagged ${created.tag.name}.` - : `New items with ${words} will be tagged ${created.tag.name}.`; - }, 'Could not add the rule') - .then((added) => added && target.reset()); - }} - > -

          New rule

          - -
          - - - -
          - - - ))} + {adding && ( + setAdding(false)} + onSubmit={(target) => { + const values = new FormData(target); + const fileType = values.get('fileType'); + const limit = atMost(100, 'Use at most 100 characters.'); + if (!form.check(target, { titleContains: fileType ? [limit] : [required('Enter words to look for, or choose a file type.'), limit] })) return; + void creating + .run(async () => { + const created = await auth.request(`/spaces/${space.id}/tidy-rules`, { + method: 'POST', + body: JSON.stringify({ + titleContains: String(values.get('titleContains')).trim(), + tagId: values.get('tagId'), + ...(fileType ? { fileType } : values.get('kind') ? { kind: values.get('kind') } : {}), + }), + }); + rules.mutate((current) => [...current, created]); + const words = created.titleContains && `“${created.titleContains}” in the title`; + return created.fileType + ? `${fileTypeNames[created.fileType]} uploaded from now on${words ? ` with ${words}` : ''} will be tagged ${created.tag.name}.` + : `New items with ${words} will be tagged ${created.tag.name}.`; + }, 'Could not add the rule') + .then((added) => added && setAdding(false)); + }} + submitLabel="Add rule" + title="New rule" + > + +
          + ({ value, label: label.charAt(0).toUpperCase() + label.slice(1) }))]} + /> +
          + - {tagColors.map((option) => ( - - ))} - - +
          + +
          ); } diff --git a/app/frontend/src/styles/global.css b/app/frontend/src/styles/global.css index d5f679d..61f4346 100644 --- a/app/frontend/src/styles/global.css +++ b/app/frontend/src/styles/global.css @@ -577,10 +577,25 @@ button.habit-day { } .page-tree-toggle { - width: 1rem; + display: grid; + width: 1.25rem; + height: 1.25rem; flex-shrink: 0; + place-items: center; + border-radius: var(--radius-field); + color: var(--color-faint); +} + +.page-tree-toggle:not(:disabled):hover { + color: var(--color-ink); +} + +.note-mark { + display: inline-grid; + width: 1.25rem; + flex-shrink: 0; + place-items: center; color: var(--color-faint); - font-size: 0.7rem; } .page-tree-link { @@ -600,12 +615,22 @@ button.habit-day { } .page-tree-add { + display: grid; visibility: hidden; width: 1.25rem; + height: 1.25rem; + place-items: center; border-radius: var(--radius-field); color: var(--color-faint); } +.page-tree-add:hover { + background: var(--color-line); + color: var(--color-ink); +} + +.page-tree-row:focus-within .page-tree-add, + .page-tree-row:hover .page-tree-add { visibility: visible; } @@ -664,3 +689,48 @@ button.habit-day { white-space: nowrap; } + +.tag-mark[data-initial]::before { + content: attr(data-initial); +} + +.space-mark-large { + width: 2.5rem; + height: 2.5rem; + flex: none; + border-radius: 0.65rem; + font-size: 1.05rem; +} + +.filter-chip { + display: inline-flex; + min-height: 2rem; + align-items: center; + gap: 0.4rem; + border: 1px solid var(--color-line); + border-radius: 999px; + background: transparent; + padding: 0.25rem 0.8rem; + color: var(--color-muted); + font-size: 0.85rem; + text-decoration: none; + transition: + border-color 140ms ease, + color 140ms ease; +} + +.filter-chip:hover { + border-color: var(--color-muted); + color: var(--color-ink); +} + +.filter-chip[aria-current='page'] { + border-color: var(--color-ink); + background: var(--color-ink); + color: var(--color-paper); +} + +.filter-chip[aria-current='page'] .text-faint { + color: inherit; + opacity: 0.6; +} diff --git a/app/frontend/src/subscriptions.ts b/app/frontend/src/subscriptions.ts index 21462b8..f34ad96 100644 --- a/app/frontend/src/subscriptions.ts +++ b/app/frontend/src/subscriptions.ts @@ -42,6 +42,8 @@ export const categories = [ 'other', ]; +export const categoryLabel = (category: string) => category.charAt(0).toUpperCase() + category.slice(1); + export const billingCycles = [ ['FREQ=MONTHLY', 'Monthly'], ['FREQ=YEARLY', 'Yearly'], diff --git a/app/frontend/src/tasks.tsx b/app/frontend/src/tasks.tsx index 05cd380..48da45c 100644 --- a/app/frontend/src/tasks.tsx +++ b/app/frontend/src/tasks.tsx @@ -44,6 +44,18 @@ export const reminderPresets = [ ['1440', '1 day before'], ] as const; +export function repeatOptions(current?: string | null) { + const options: { value: string; label: string }[] = repeatPresets.map(([value, label]) => ({ value, label })); + if (current && !options.some((option) => option.value === current)) options.push({ value: current, label: `Custom (${current})` }); + return options; +} + +export function reminderOptions(current?: number | null) { + const options: { value: string; label: string }[] = reminderPresets.map(([value, label]) => ({ value, label })); + if (current != null && !options.some((option) => option.value === String(current))) options.push({ value: String(current), label: `${current} minutes before` }); + return options; +} + const priorityLabels = ['', 'low', 'medium', 'high']; function localToday() { @@ -91,7 +103,7 @@ export function TaskRow({ priorityLabels[task.priority] && `${priorityLabels[task.priority]} priority`, ].filter(Boolean); return ( -
        • +
        • (path: string | null, authenticated = false) { const current = key && state.key === key ? state : undefined; const sameViewer = authenticated ? Boolean(userId && state.key?.startsWith(`${userId}|`)) : true; + const data = useSettled(current ? current.data : key && sameViewer ? state.data : undefined); return { - data: current ? current.data : key && sameViewer ? state.data : undefined, + data, error: current?.error ?? '', status: current?.status, loading: Boolean(key) && !current?.error && current?.data === undefined, diff --git a/app/frontend/test/activity.spec.tsx b/app/frontend/test/activity.spec.tsx index 9b0da03..9aeeead 100644 --- a/app/frontend/test/activity.spec.tsx +++ b/app/frontend/test/activity.spec.tsx @@ -140,6 +140,7 @@ describe('Activity, inbox and security log', () => { if (path === '/me/sessions') { return json([{ id: 's', deviceLabel: 'Firefox on Windows', signedInAt: at, lastUsedAt: at, current: true }]); } + if (path === '/me/two-step') return json({ enabled: false, recoveryCodesLeft: 0 }); if (path === '/me/audit?page=1') { return json( page([ @@ -150,7 +151,7 @@ describe('Activity, inbox and security log', () => { } return undefined; }); - await render('/settings/sessions'); + await render('/settings/security'); expect(container.textContent).toContain('Failed sign-in · wrong password · Safari on iOS'); expect(container.textContent).toContain('Two-step sign-in turned on'); diff --git a/app/frontend/test/agenda.spec.tsx b/app/frontend/test/agenda.spec.tsx index 8007444..e214cee 100644 --- a/app/frontend/test/agenda.spec.tsx +++ b/app/frontend/test/agenda.spec.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { dayKey } from '../src/agenda'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { pickDay } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -251,15 +252,8 @@ describe('Agenda and RSVP', () => { const button = (label: string) => [...document.querySelectorAll('button')].find((candidate) => candidate.textContent === label)!; await act(async () => button('Offer dates').click()); - const set = (label: string, value: string) => { - const input = container.querySelector(`input[aria-label="${label}"]`)!; - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); - input.dispatchEvent(new Event('input', { bubbles: true })); - }; - await act(async () => { - set('Date 1', '2026-11-06'); - set('Date 2', '2026-11-07'); - }); + await pickDay(container, 'Date 1', '2026-11-06'); + await pickDay(container, 'Date 2', '2026-11-07'); await act(async () => button('Save dates').click()); expect(sent[0]).toEqual({ path: '/events/party/poll', diff --git a/app/frontend/test/bookmarks.spec.tsx b/app/frontend/test/bookmarks.spec.tsx index 8f70a50..9cb2c49 100644 --- a/app/frontend/test/bookmarks.spec.tsx +++ b/app/frontend/test/bookmarks.spec.tsx @@ -106,6 +106,7 @@ describe('Bookmarks', () => { await render('/bookmarks'); expect(container.textContent).toContain('Nothing to read later'); + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Save a link')!.click()); const input = container.querySelector('input[name="url"]')!; await act(async () => { input.value = 'https://bread.example/slow'; diff --git a/app/frontend/test/calendars.spec.tsx b/app/frontend/test/calendars.spec.tsx index 7f1d664..170bf46 100644 --- a/app/frontend/test/calendars.spec.tsx +++ b/app/frontend/test/calendars.spec.tsx @@ -69,7 +69,8 @@ describe('Calendars', () => { }); await render('/agenda/calendars'); - const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('Follow a calendar'))!; + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Follow a calendar')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); diff --git a/app/frontend/test/controls.ts b/app/frontend/test/controls.ts new file mode 100644 index 0000000..7cdeb5e --- /dev/null +++ b/app/frontend/test/controls.ts @@ -0,0 +1,30 @@ +import { act } from 'react'; + +const named = (element: Element, name: string) => + element.getAttribute('aria-label') === name || + element.textContent === name || + document.getElementById(element.getAttribute('aria-labelledby') ?? '')?.textContent === name; + +export async function choose(root: ParentNode, name: string, option: string) { + const trigger = [...root.querySelectorAll('[role="combobox"]')].find((element) => named(element, name))!; + await act(async () => trigger.click()); + const choice = [...root.querySelectorAll('[role="option"]')].find((element) => element.textContent?.startsWith(option))!; + await act(async () => choice.click()); +} + +export async function chooseFromMenu(root: ParentNode, trigger: string, item: string) { + const button = [...root.querySelectorAll('button[aria-haspopup="menu"]')].find((element) => named(element, trigger))!; + await act(async () => button.click()); + const entry = [...root.querySelectorAll('[role="menuitem"]')].find((element) => element.textContent === item)!; + await act(async () => entry.click()); +} + +export async function pickDay(root: ParentNode, name: string, day: string) { + const trigger = [...root.querySelectorAll('button[aria-haspopup="dialog"]')].find((element) => named(element, name))!; + await act(async () => trigger.click()); + for (let step = 0; step < 24 && !root.querySelector(`[data-day="${day}"]`); step += 1) { + const later = day > root.querySelector('[data-day]')!.dataset.day!; + await act(async () => root.querySelector(`button[aria-label="${later ? 'Next month' : 'Previous month'}"]`)!.click()); + } + await act(async () => root.querySelector(`[data-day="${day}"]`)!.click()); +} diff --git a/app/frontend/test/dashboard.spec.tsx b/app/frontend/test/dashboard.spec.tsx index c6f226d..58d70cf 100644 --- a/app/frontend/test/dashboard.spec.tsx +++ b/app/frontend/test/dashboard.spec.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { automaticDashboard } from '../src/dashboard/Dashboard'; import { AuthProvider } from '../src/auth'; +import { choose, chooseFromMenu } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -70,15 +71,14 @@ describe('Home dashboard', () => { expect(titles()).toEqual(['Clock', 'Shortcuts', 'Inbox']); expect(container.querySelector('a[href="https://mail.example.com"]')?.textContent).toContain('Mail'); expect(container.textContent).toContain('The inbox is unavailable'); - expect(container.querySelector('button[aria-label="Move Clock later"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Actions for Clock"]')).toBeNull(); await act(async () => button('Edit').click()); - await act(async () => button('Move Clock later').click()); + await chooseFromMenu(container, 'Actions for Clock', 'Move later'); expect(titles()).toEqual(['Shortcuts', 'Clock', 'Inbox']); await act(async () => button('Undo').click()); expect(titles()).toEqual(['Clock', 'Shortcuts', 'Inbox']); - const removeInbox = [...container.querySelectorAll('article')].find((article) => article.getAttribute('aria-label') === 'Inbox')!; - await act(async () => [...removeInbox.querySelectorAll('button')].find((candidate) => candidate.textContent === 'Remove')!.click()); + await chooseFromMenu(container, 'Actions for Inbox', 'Remove'); expect(saved).toBeUndefined(); await act(async () => button('Done').click()); @@ -111,7 +111,7 @@ describe('Home dashboard', () => { ), ); - const wall = [...container.querySelectorAll('a')].find((link) => link.getAttribute('aria-label') === 'Wall display')!; + const wall = [...container.querySelectorAll('a')].find((link) => link.textContent === 'Wall display')!; expect(wall.getAttribute('href')).toBe('/?d=home&display=wall'); await act(async () => wall.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))); @@ -422,8 +422,9 @@ describe('Home dashboard', () => { const widget = container.querySelector('article[aria-label="Quick note"]')!; const note = widget.querySelector('textarea')!; - const space = widget.querySelector('select')!; - expect([...space.options].map((option) => option.textContent)).toEqual(['Mine', 'Flat']); + await act(async () => widget.querySelector('[role="combobox"]')!.click()); + expect([...widget.querySelectorAll('[role="option"]')].map((option) => option.textContent)).toEqual(['Mine', 'Flat']); + await act(async () => widget.querySelector('[role="combobox"]')!.click()); const type = async (text: string) => act(async () => { Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(note, text); @@ -438,10 +439,7 @@ describe('Home dashboard', () => { ); expect(note.value).toBe(''); - await act(async () => { - space.value = 'flat'; - space.dispatchEvent(new Event('change', { bubbles: true })); - }); + await choose(widget, 'Save in', 'Flat'); await type('Buy bin bags\nthe big ones'); await act(async () => button('Save as task').click()); expect(fetchMock).toHaveBeenCalledWith('/api/v1/spaces/flat/tasks', expect.objectContaining({ method: 'POST', body: JSON.stringify({ title: 'Buy bin bags' }) })); @@ -490,15 +488,13 @@ describe('Home dashboard', () => { expect(container.querySelector('h1')?.textContent).toBe('Late'); await act(async () => button('Edit').click()); - const time = (label: string) => [...container.querySelectorAll('label')].find((candidate) => candidate.textContent?.startsWith(label))!.querySelector('input')!; - expect(time('From').value).toBe('22:00'); - await act(async () => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(time('From'), '21:00'); - time('From').dispatchEvent(new Event('input', { bubbles: true })); - const device = [...container.querySelectorAll('label')].find((candidate) => candidate.textContent?.startsWith('On'))!.querySelector('select')!; - device.value = 'desktop'; - device.dispatchEvent(new Event('change', { bubbles: true })); - }); + await act(async () => button('Dashboard settings').click()); + const settings = container.querySelector('dialog')!; + const from = [...settings.querySelectorAll('[role="combobox"]')].find((trigger) => document.getElementById(trigger.getAttribute('aria-labelledby')!)?.textContent === 'From')!; + expect(from.textContent).toBe('22:00'); + await choose(settings, 'From', '21:00'); + await choose(settings, 'On', 'Computers'); + await act(async () => [...settings.querySelectorAll('button')].find((candidate) => candidate.textContent === 'Done')!.click()); await act(async () => button('Done').click()); expect(saved).toEqual({ layout: { widgets: [] }, name: 'Late', showFrom: 1260, showUntil: 360, showOn: 'desktop' }); diff --git a/app/frontend/test/habits.spec.tsx b/app/frontend/test/habits.spec.tsx index 9a97619..d7bb4ab 100644 --- a/app/frontend/test/habits.spec.tsx +++ b/app/frontend/test/habits.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { choose } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -92,9 +93,10 @@ describe('Habits', () => { expect(fetchMock).toHaveBeenCalledWith('/api/v1/habits/gym/check-ins/2026-10-04', expect.objectContaining({ method: 'PUT' })); expect(button('Gym yesterday').getAttribute('aria-pressed')).toBe('true'); - const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('New habit'))!; + await act(async () => button('New habit').click()); + const form = container.querySelector('dialog form') as HTMLFormElement; form.querySelector('input[name="name"]')!.value = 'Read'; - form.querySelector('select[name="perWeek"]')!.value = '3'; + await choose(form, 'Goal', '3 days a week'); await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); diff --git a/app/frontend/test/invitations.spec.tsx b/app/frontend/test/invitations.spec.tsx index 90454b1..eace428 100644 --- a/app/frontend/test/invitations.spec.tsx +++ b/app/frontend/test/invitations.spec.tsx @@ -137,9 +137,11 @@ describe('Invitations', () => { }); await render('/spaces/choir/members'); - const form = [...container.querySelectorAll('form')].at(-1)!; + await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'Invite people')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; fill(form, 'who', '@sam'); - form.querySelector('select[name="role"]')!.value = 'viewer'; + await act(async () => form.querySelector('[role="combobox"]')!.click()); + await act(async () => [...form.querySelectorAll('[role="option"]')].find((option) => option.textContent?.startsWith('Viewer'))!.click()); fill(form, 'note', 'Join us'); await submit(form); diff --git a/app/frontend/test/notes.spec.tsx b/app/frontend/test/notes.spec.tsx index 31f6f54..3c137b4 100644 --- a/app/frontend/test/notes.spec.tsx +++ b/app/frontend/test/notes.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { choose } from './controls'; import { internalPath, storedFileId } from '../src/components/note-extensions'; import { flattenTree, type Note } from '../src/notes'; @@ -139,7 +140,7 @@ describe('Notes', () => { await render('/spaces/home/notes'); const links = [...container.querySelectorAll('main a[href^="/notes/"]')]; - expect(links.map((link) => link.textContent)).toEqual(['·Trips', '·Lisbon', '·Food', '·Untitled']); + expect(links.map((link) => link.textContent)).toEqual(['Trips', 'Lisbon', 'Food', 'Untitled']); expect((links[2] as HTMLElement).style.paddingLeft).toBe('3.5rem'); await act(async () => [...container.querySelectorAll('button')].find((button) => button.textContent === 'New page')!.click()); @@ -160,7 +161,7 @@ describe('Notes', () => { }); await render('/spaces/home/notes'); - expect([...container.querySelectorAll('main a[href^="/notes/"]')].at(-1)?.textContent).toBe('·SeatingBoard'); + expect([...container.querySelectorAll('main a[href^="/notes/"]')].at(-1)?.textContent).toBe('SeatingBoard'); 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('seating'); @@ -209,9 +210,9 @@ describe('Notes', () => { const press = (label: string) => act(async () => tree().querySelector(`[aria-label="${label}"]`)!.click()); await press('Expand Home'); - expect([...tree().querySelectorAll('a')].map((link) => link.textContent)).toEqual(['·Trips', '·Food']); + 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']); + expect([...tree().querySelectorAll('a')].map((link) => link.textContent)).toEqual(['Trips', 'Lisbon', 'Food']); const rows = [...tree().querySelectorAll('.page-tree-row')]; const food = rows.find((row) => row.textContent?.includes('Food'))!; @@ -670,12 +671,8 @@ describe('Notes', () => { 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 })); - }); + await choose(panel, 'Status', 'Done'); 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'); + expect(panel.querySelector('[role="combobox"][aria-label="Status"]')!.textContent).toBe('Done'); }); }); diff --git a/app/frontend/test/share.spec.tsx b/app/frontend/test/share.spec.tsx index c2f76fe..5502335 100644 --- a/app/frontend/test/share.spec.tsx +++ b/app/frontend/test/share.spec.tsx @@ -86,7 +86,9 @@ describe('Save to Strata', () => { await render(); expect(container.querySelector('h1')?.textContent).toBe('Save what you shared'); - expect([...container.querySelectorAll('option')].map((option) => option.textContent)).toEqual(['Club']); + await act(async () => container.querySelector('[role="combobox"]')!.click()); + expect([...container.querySelectorAll('[role="option"]')].map((option) => option.textContent)).toEqual(['Club']); + await act(async () => container.querySelector('[role="combobox"]')!.click()); expect(container.querySelector('input[name="title"]')!.value).toBe('Tickets — Look https://example.com/tickets'); expect(container.textContent).toContain('ticket one.txt'); diff --git a/app/frontend/test/spaces.spec.tsx b/app/frontend/test/spaces.spec.tsx index 12ce542..886e0d3 100644 --- a/app/frontend/test/spaces.spec.tsx +++ b/app/frontend/test/spaces.spec.tsx @@ -74,9 +74,10 @@ describe('Spaces', () => { expect(sidebar.textContent).toContain('Personal'); expect(sidebar.textContent).toContain('Family'); - const form = [...container.querySelectorAll('form')].at(-1)!; + await act(async () => button('New space')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; form.querySelector('input[name="name"]')!.value = ' Book club '; - form.querySelector('select[name="color"]')!.value = 'purple'; + await act(async () => form.querySelector('input[name="color"][value="purple"]')!.click()); await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); diff --git a/app/frontend/test/subscriptions.spec.tsx b/app/frontend/test/subscriptions.spec.tsx index cc2c65c..2566f86 100644 --- a/app/frontend/test/subscriptions.spec.tsx +++ b/app/frontend/test/subscriptions.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { choose } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -109,8 +110,8 @@ describe('Recurring', () => { const form = document.querySelector('dialog form') as HTMLFormElement; fill(form, 'name', 'Music'); fill(form, 'amount', '9.99'); - fill(form, 'category', 'music'); - fill(form, 'reminderDays', '3'); + await choose(form, 'Category', 'Music'); + await choose(form, 'Remind me', '3 days before'); fill(form, 'paymentLabel', '4242 4242 4242 4242'); await submit(form); expect(document.body.textContent).toContain('never a card number'); @@ -152,14 +153,18 @@ describe('Recurring', () => { const add = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Add subscription')!; await act(async () => add.click()); const form = document.querySelector('dialog form') as HTMLFormElement; - expect([...form.querySelectorAll('datalist option')].map((option) => option.getAttribute('value'))).toContain('Netflix'); const name = form.querySelector('input[name="name"]')!; + name.value = 'netf'; + await act(async () => { + name.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect([...form.querySelectorAll('[role="option"]')].map((option) => option.textContent)).toContain('Netflix'); name.value = 'netflix'; await act(async () => { name.dispatchEvent(new Event('input', { bubbles: true })); }); - expect(form.querySelector('select[name="category"]')!.value).toBe('streaming'); + expect(form.querySelector('input[name="category"]')!.value).toBe('streaming'); expect(form.querySelector('input[name="cancelUrl"]')!.value).toBe('https://www.netflix.com/cancelplan'); }); diff --git a/app/frontend/test/tasks.spec.tsx b/app/frontend/test/tasks.spec.tsx index 869e6c2..4c645e5 100644 --- a/app/frontend/test/tasks.spec.tsx +++ b/app/frontend/test/tasks.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { chooseFromMenu } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -98,7 +99,7 @@ describe('Tasks', () => { expect(fetchMock).toHaveBeenCalledWith('/api/v1/items/bins/trash', expect.objectContaining({ method: 'POST' })); expect(container.textContent).toContain('Nothing to do'); - await act(async () => button('Rename list').click()); + await chooseFromMenu(container, 'Actions for Chores', 'Rename list'); const name = document.querySelector('dialog input[name="title"]')!; await act(async () => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(name, 'House'); @@ -108,7 +109,7 @@ describe('Tasks', () => { expect(fetchMock).toHaveBeenCalledWith('/api/v1/items/chores', expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ title: 'House' }) })); expect(container.querySelector('nav[aria-label="Lists"]')?.textContent).toContain('House'); - await act(async () => button('Move list to trash').click()); + await chooseFromMenu(container, 'Actions for House', 'Move list to trash'); expect(fetchMock).toHaveBeenCalledWith('/api/v1/items/chores/trash', expect.objectContaining({ method: 'POST' })); expect(container.querySelector('nav[aria-label="Lists"]')?.textContent).not.toContain('House'); }); @@ -127,8 +128,13 @@ describe('Tasks', () => { expect(container.textContent).toContain('Bins out'); expect(container.textContent).toContain('every week'); - const form = container.querySelector('form')!; - form.querySelector('input[name="title"]')!.value = 'Water plants'; + await act(async () => [...container.querySelectorAll('button')].find((candidate) => candidate.textContent === 'New task')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; + const title = form.querySelector('input[name="title"]')!; + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(title, 'Water plants'); + title.dispatchEvent(new Event('input', { bubbles: true })); + }); form.querySelector('input[name="dueDate"]')!.value = '2026-10-06'; await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); @@ -159,9 +165,10 @@ describe('Tasks', () => { }); await render('/spaces/home/tasks'); - const form = container.querySelector('form')!; + await act(async () => [...container.querySelectorAll('button')].find((candidate) => candidate.textContent === 'New task')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; const input = form.querySelector('input[name="title"]')!; - input.value = 'pay rent 2030-01-04 9am #home !2'; + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'pay rent 2030-01-04 9am #home !2'); await act(async () => { input.dispatchEvent(new Event('input', { bubbles: true })); }); @@ -173,7 +180,7 @@ describe('Tasks', () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); expect(fetchMock).toHaveBeenCalledWith('/api/v1/items/rent/tags', expect.objectContaining({ method: 'PUT' })); - expect(form.querySelector('[role="status"]')).toBeNull(); + expect(container.querySelector('dialog [role="status"]')).toBeNull(); }); it('lists what is due today across spaces and clears a finished task', async () => { diff --git a/app/frontend/test/tidy.spec.tsx b/app/frontend/test/tidy.spec.tsx index 5bc9c92..62684c8 100644 --- a/app/frontend/test/tidy.spec.tsx +++ b/app/frontend/test/tidy.spec.tsx @@ -4,6 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { App } from '../src/App'; import { AuthProvider } from '../src/auth'; +import { choose } from './controls'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -135,7 +136,8 @@ describe('Tidy', () => { }); await render('/spaces/club/tags'); - const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('New rule'))!; + await act(async () => [...container.querySelectorAll('button')].find((candidate) => candidate.textContent === 'New rule')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; form.querySelector('input[name="titleContains"]')!.value = 'receipt'; await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); @@ -164,15 +166,16 @@ describe('Tidy', () => { }); await render('/spaces/club/tags'); - const form = [...container.querySelectorAll('form')].find((candidate) => candidate.textContent?.includes('New rule'))!; + await act(async () => [...container.querySelectorAll('button')].find((candidate) => candidate.textContent === 'New rule')!.click()); + const form = container.querySelector('dialog form') as HTMLFormElement; await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); expect(container.textContent).toContain('Enter words to look for, or choose a file type.'); expect(sent).toEqual([]); - form.querySelector('select[name="fileType"]')!.value = 'pdf'; - form.querySelector('select[name="kind"]')!.value = 'note'; + await choose(form, 'File type', 'PDFs'); + await choose(form, 'Kind of item', 'Notes'); await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); }); diff --git a/app/package-lock.json b/app/package-lock.json index 26324e1..052aa31 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -24,7 +24,7 @@ "@excalidraw/excalidraw": "0.18.1", "@hocuspocus/provider": "4.7.0", "@mozilla/readability": "0.6.0", - "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.3.0/project-graphite-ui-0.3.0.tgz", + "@project-graphite/ui": "https://github.com/project-graphite/graphite-ui/releases/download/v0.4.0/project-graphite-ui-0.4.0.tgz", "@tiptap/core": "3.31.4", "@tiptap/extension-collaboration": "3.31.4", "@tiptap/extension-collaboration-caret": "3.31.4", @@ -2086,9 +2086,9 @@ } }, "node_modules/@project-graphite/ui": { - "version": "0.3.0", - "resolved": "https://github.com/project-graphite/graphite-ui/releases/download/v0.3.0/project-graphite-ui-0.3.0.tgz", - "integrity": "sha512-Lzpjzyla1y+LAgMtn3CBuL5Tn7x75iNyLn9KNP6o4CWvFsyJzfbIFwP0lso3mA8mpmVi65vmWrwWb5NVQU1e/A==", + "version": "0.4.0", + "resolved": "https://github.com/project-graphite/graphite-ui/releases/download/v0.4.0/project-graphite-ui-0.4.0.tgz", + "integrity": "sha512-Yr61VvTfOI8SLjDhpL/Vzm0FI5HVTTwFMETVHSG5kC3GjiAFOYFK2/53injnKPsd9NyPiFFmESJGlv7kIshX8A==", "license": "MIT", "peerDependencies": { "react": "^19.0.0", diff --git a/app/server/src/auth/auth.service.ts b/app/server/src/auth/auth.service.ts index 925d604..08796c8 100644 --- a/app/server/src/auth/auth.service.ts +++ b/app/server/src/auth/auth.service.ts @@ -518,7 +518,7 @@ export class AuthService implements OnModuleInit { 'If this was you, there is nothing to do. If not, reset your password now, then sign out the devices you do not recognise:', '', this.mail.link('/forgot-password'), - this.mail.link('/settings/sessions'), + this.mail.link('/settings/security'), ].join('\n'), }); }