Files
TREK/client/tests/unit/mobile/trip/MNoteSheet.test.tsx
T
Maurice ce37e8e97c Rich formatting and colours for notes (#1889)
* Rich formatting and colours for notes

Asked for in discussion #1629: notes should carry formatting, links and
colour, so a warning reads differently from a recommendation.

Notes have rendered Markdown for a long time. The problem was that the
only way to write it was a bare textarea, so unless you already knew the
syntax there was nothing to tell you it existed. Both note fields get a
formatting bar - bold, italic, strikethrough, code, link, lists, quote -
which edits Markdown rather than replacing it with a rich-text model, so
the stored value stays plain text that the PDF, the plugin API and every
existing note keep reading unchanged.

Links now open in their own tab. A bare anchor navigates the planner
away, which loses the open day and the map, and without rel=noopener the
opened page can navigate this one - notes are content other trip members
wrote.

Day notes get a colour from a short shared palette. It tints the card,
the border and the icon circle, and it reaches the PDF as the spine and
rule rather than a fill. The dialog is two columns now, with a live
preview of the card beside the picker, because a row of swatches says
nothing about what a tinted note looks like next to a place.

On the phone the thirty-two icon grid was most of the screen, so it is
folded behind the chosen icon, which doubles as the colour preview. The
detail field there got the same formatting bar and the same 2000
character ceiling as the desktop one - they write the same column.

* Fix the tests that pinned the old note caps, and rework the note actions

The e2e suite builds its own day_notes table by hand rather than going
through schema.ts, so it never got the new column and every note insert
came back 500. Three more tests wrote down the 250 character body cap
and the old create() arity.

While in there: the edit and delete buttons on a note card were a 10px
icon in a 14px box, side by side, and they took width away from the text
on every note whether you were hovering or not. They are stacked in a
capsule now, 30px targets with their own hover states, floating over the
right edge of the card only while the pointer is on it. The text keeps
the full width the rest of the time.

* Centre the day badge when there is no weather to stack under it

The badge is pinned to the top of the row because with weather it is a
tall stack that has to start there. Without a location there is no
weather block, so all that is left is a 26px circle floating above the
day title.

* Render the place subtitle in the places list, and drop the form hint

The row in the places sidebar printed the description raw while the same
line in the day plan renders it, so a place written with the formatting
bar read as _underscores_ in one list and italics in the other. Still
clamped to one line - it is a list entry, not the inspector.

The "Markdown supported" line under the notes field goes: the bar above
it already says so.
2026-08-11 22:14:12 +02:00

322 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import MNoteSheet, { type MNoteSheetPayload } from '../../../../src/mobile/screens/trip/sheets/MNoteSheet'
import { useTripStore } from '../../../../src/store/tripStore'
import type { Assignment, DayNote } from '../../../../src/types'
import { buildPlanner } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-NOTESH-001 to FE-MOB-NOTESH-018
// planner.t echoes the key, so every assertion here is against keys.
const NOTE = {
id: 88,
day_id: 7,
text: 'Buy museum tickets',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
sort_order: 2,
} as unknown as DayNote
function renderSheet(overrides: {
planner?: ReturnType<typeof buildPlanner>
open?: boolean
payload?: MNoteSheetPayload
} = {}) {
const planner = overrides.planner ?? buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload = 'payload' in overrides ? overrides.payload : { dayId: 7 }
const view = render(
<MNoteSheet planner={planner} open={overrides.open ?? true} payload={payload} onClose={onClose} />,
)
return { ...view, planner, onClose, payload }
}
function typeTitle(value: string) {
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value } })
}
// The icon grid is folded away behind the chosen icon (#1629) — a phone cannot
// spare 290px for 32 icons — so reaching one means opening the grid first.
const openIconGrid = () => fireEvent.click(screen.getByRole('button', { name: 'dayplan.noteIcon' }))
const pickIcon = (id: string) => {
openIconGrid()
fireEvent.click(screen.getByRole('button', { name: id }))
}
describe('MNoteSheet', () => {
beforeEach(() => {
resetAllStores()
})
it('FE-MOB-NOTESH-001: renders nothing while the sheet is closed', () => {
renderSheet({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-MOB-NOTESH-002: opens in create mode with the default icon and no delete action', () => {
renderSheet()
expect(screen.getByRole('dialog', { name: 'dayplan.noteAdd' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument()
openIconGrid()
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Train' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-003: picking an icon moves the pressed state', () => {
renderSheet()
pickIcon('Coffee')
// Choosing closes the grid, so the assertion reopens it.
openIconGrid()
expect(screen.getByRole('button', { name: 'Coffee' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'FileText' })).toHaveAttribute('aria-pressed', 'false')
})
it('FE-MOB-NOTESH-004: keeps the submit disabled until a non-blank title is typed', () => {
renderSheet()
const submit = screen.getByRole('button', { name: 'common.add' })
expect(submit).toBeDisabled()
typeTitle(' ')
expect(submit).toBeDisabled()
typeTitle('Ferry tickets')
expect(submit).toBeEnabled()
})
it('FE-MOB-NOTESH-005: appends the new note behind the last assignment and note of the day', async () => {
seedStore(useTripStore, {
assignments: { '7': [{ order_index: 3 }, { order_index: 1 }] as unknown as Assignment[] },
dayNotes: { '7': [{ sort_order: 5 }] as unknown as DayNote[] },
})
const { planner, onClose } = renderSheet()
pickIcon('Plane')
typeTitle(' Check in online ')
fireEvent.change(screen.getByPlaceholderText('notes.bodyPlaceholder'), { target: { value: '18:00 at the gate' } })
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Check in online',
time: '18:00 at the gate',
icon: 'Plane',
color: null,
sort_order: 6,
})
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-006: starts at sort order 0 on an empty day and stores an empty detail as null', async () => {
const { planner } = renderSheet()
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, {
text: 'Sunrise walk',
time: null,
icon: 'FileText',
color: null,
sort_order: 0,
})
})
it('FE-MOB-NOTESH-007: falls back to the selected day when the payload carries no dayId', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 12 })
renderSheet({ planner, payload: {} })
typeTitle('Laundry')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 12, expect.objectContaining({ text: 'Laundry' }))
})
it('FE-MOB-NOTESH-008: does not save without any day to attach the note to', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: null })
renderSheet({ planner, payload: undefined })
typeTitle('Orphan note')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-009: prefills icon, title and detail when editing an existing note', () => {
renderSheet({ payload: { dayId: 7, note: NOTE } })
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('09:30 at the kiosk')
openIconGrid()
expect(screen.getByRole('button', { name: 'Ticket' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'common.save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-010: updates the existing note instead of creating a second one', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
typeTitle('Buy museum tickets online')
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledTimes(1))
expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, {
text: 'Buy museum tickets online',
time: '09:30 at the kiosk',
icon: 'Ticket',
color: null,
})
expect(planner.tripActions.addDayNote).not.toHaveBeenCalled()
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-011: deletes the note and closes the sheet', async () => {
const { planner, onClose } = renderSheet({ payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.tripActions.deleteDayNote).toHaveBeenCalledWith(3, 7, 88))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-MOB-NOTESH-012: surfaces the save error and keeps the sheet open', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce(new Error('day is locked'))
const { onClose } = renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('day is locked'))
expect(onClose).not.toHaveBeenCalled()
// isSaving is released again, so a retry is possible.
expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled()
})
it('FE-MOB-NOTESH-013: falls back to the generic message when the rejection is not an Error', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.addDayNote).mockRejectedValueOnce('nope')
renderSheet({ planner })
typeTitle('Sunrise walk')
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError'))
})
it('FE-MOB-NOTESH-014: surfaces a failing delete without closing', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
vi.mocked(planner.tripActions.deleteDayNote).mockRejectedValueOnce(new Error('gone already'))
const { onClose } = renderSheet({ planner, payload: { dayId: 7, note: NOTE } })
fireEvent.click(screen.getByRole('button', { name: 'common.delete' }))
await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('gone already'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-MOB-NOTESH-020: the icon grid stays folded until asked for (#1629)', () => {
renderSheet()
// 32 icons in a six-wide grid is most of a phone screen.
expect(screen.queryByRole('button', { name: 'Train' })).toBeNull()
openIconGrid()
expect(screen.getByRole('button', { name: 'Train' })).toBeInTheDocument()
// Choosing one closes it again.
fireEvent.click(screen.getByRole('button', { name: 'Train' }))
expect(screen.queryByRole('button', { name: 'Coffee' })).toBeNull()
})
it('FE-MOB-NOTESH-021: a colour is saved with the note (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
render(<MNoteSheet planner={planner} open payload={{ dayId: 7 }} onClose={vi.fn()} />)
fireEvent.change(screen.getByPlaceholderText('dayplan.noteTitle *'), { target: { value: 'Passport' } })
fireEvent.click(screen.getByRole('button', { name: '#dc2626' }))
fireEvent.click(screen.getByRole('button', { name: 'common.add' }))
await waitFor(() => expect(planner.tripActions.addDayNote).toHaveBeenCalledWith(3, 7, expect.objectContaining({
color: '#dc2626',
})))
})
it('FE-MOB-NOTESH-022: an existing colour is loaded and can be cleared again (#1629)', async () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const note = { id: 88, day_id: 7, text: 'Ferry', time: '', icon: 'FileText', color: '#16a34a' } as unknown as DayNote
render(<MNoteSheet planner={planner} open payload={{ dayId: 7, note }} onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: '#16a34a' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'notes.color.none' }))
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
await waitFor(() => expect(planner.tripActions.updateDayNote).toHaveBeenCalledWith(3, 7, 88, expect.objectContaining({
color: null,
})))
})
it('FE-MOB-NOTESH-015: counts the detail characters and warns near the limit', () => {
renderSheet()
const detail = screen.getByPlaceholderText('notes.bodyPlaceholder')
// The ceiling matches the desktop dialog since #1629 — a formatted note needs
// the room, and the two fields write the same column.
expect(screen.getByText('0/2000')).toBeInTheDocument()
fireEvent.change(detail, { target: { value: 'a'.repeat(100) } })
const relaxed = screen.getByText('100/2000')
expect(relaxed.className).toContain('text-m-faint')
fireEvent.change(detail, { target: { value: 'a'.repeat(1950) } })
const warned = screen.getByText('1950/2000')
expect(warned.className).toContain('--m-st-pending')
})
it('FE-MOB-NOTESH-016: cancel and the header close both report the close', () => {
const { onClose } = renderSheet()
fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-MOB-NOTESH-017: keeps the opened payload while the sheet animates out', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const payload: MNoteSheetPayload = { dayId: 7, note: NOTE }
const { rerender } = render(
<MNoteSheet planner={planner} open payload={payload} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open={false} payload={undefined} onClose={onClose} />)
// The parent drops the payload with the sheet id, but the exit animation
// still shows the edit chrome.
expect(screen.getByRole('button', { name: 'common.delete' })).toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'dayplan.noteEdit' })).toBeInTheDocument()
})
it('FE-MOB-NOTESH-018: reopening on another note replaces the form state', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const first: MNoteSheetPayload = { dayId: 7, note: NOTE }
const second: MNoteSheetPayload = {
dayId: 9,
note: { id: 90, day_id: 9, text: 'Return the car', time: '', icon: 'Car' } as unknown as DayNote,
}
const { rerender } = render(
<MNoteSheet planner={planner} open payload={first} onClose={onClose} />,
)
rerender(<MNoteSheet planner={planner} open payload={second} onClose={onClose} />)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Return the car')
expect(screen.getByPlaceholderText('notes.bodyPlaceholder')).toHaveValue('')
openIconGrid()
expect(screen.getByRole('button', { name: 'Car' })).toHaveAttribute('aria-pressed', 'true')
})
it('FE-MOB-NOTESH-019: a fresh payload object for the same note keeps the typed draft', () => {
const planner = buildPlanner({ tripId: 3, selectedDayId: 7 })
const onClose = vi.fn()
const { rerender } = render(
<MNoteSheet planner={planner} open payload={{ dayId: 7, note: NOTE }} onClose={onClose} />,
)
typeTitle('Buy museum tickets for four')
// Same day, same note — only the wrapper object is new (inline payload or a
// store refresh), so the draft must survive.
rerender(
<MNoteSheet planner={planner} open payload={{ dayId: 7, note: { ...NOTE } }} onClose={onClose} />,
)
expect(screen.getByPlaceholderText('dayplan.noteTitle *')).toHaveValue('Buy museum tickets for four')
})
})