fix(places): render markdown in the mobile place description

The mobile shell printed place descriptions and notes as plain text while
the desktop rows and inspector run the same field through react-markdown,
so a description written with the formatting bar read as `# headings` and
`**asterisks**` on the phone.

Put the renderer in one shared component instead of copying it next to
each mobile twin, with a clamped variant for list rows that keeps them one
line high the way the desktop sidebar does. A clamped caption sits inside
the tappable row, so its links keep their text and lose the anchor.

Fixes #2337
This commit is contained in:
Konstantinos Thermos
2026-09-16 15:42:07 +03:00
parent 2efa843306
commit 37163e9a41
8 changed files with 122 additions and 17 deletions
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import MarkdownText from './MarkdownText'
describe('MarkdownText', () => {
it('FE-MDTEXT-001: a description written in markdown reads as formatted text, not as syntax (#2337)', () => {
render(<MarkdownText>{'# Checkpoint Charlie\n\nThe **most famous** crossing.'}</MarkdownText>)
expect(screen.getByRole('heading', { name: 'Checkpoint Charlie' })).toBeInTheDocument()
expect(screen.getByText('most famous').tagName).toBe('STRONG')
expect(screen.queryByText(/#\s*Checkpoint/)).toBeNull()
})
it('FE-MDTEXT-002: a gfm table is a table', () => {
render(<MarkdownText>{'| Day | Stop |\n| --- | --- |\n| 1 | Gate |'}</MarkdownText>)
expect(screen.getByRole('table')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'Day' })).toBeInTheDocument()
})
it('FE-MDTEXT-003: a single newline stays a line break, the way the editors show it', () => {
const { container } = render(<MarkdownText>{'Opening hours\n9–17'}</MarkdownText>)
expect(container.querySelector('br')).not.toBeNull()
})
it('FE-MDTEXT-004: a link in a full view opens in its own tab', () => {
render(<MarkdownText>{'[tickets](https://example.com/tickets)'}</MarkdownText>)
const link = screen.getByRole('link', { name: 'tickets' })
expect(link).toHaveAttribute('target', '_blank')
expect(link.getAttribute('rel')).toContain('noopener')
})
it('FE-MDTEXT-005: a clamped row keeps the link text but drops the anchor, so the tap opens the row', () => {
render(<MarkdownText clamp>{'[tickets](https://example.com/tickets)'}</MarkdownText>)
expect(screen.queryByRole('link')).toBeNull()
expect(screen.getByText('tickets')).toBeInTheDocument()
})
it('FE-MDTEXT-006: a clamped row is held to one line so a long description cannot swallow the list', () => {
const { container } = render(<MarkdownText clamp className="text-m-muted">{'# Berlin Wall Memorial\n\nA long second paragraph.'}</MarkdownText>)
const wrapper = container.firstElementChild as HTMLElement
expect(wrapper.style.maxHeight).toBe('1.2em')
expect(wrapper.className).toContain('truncate')
expect(wrapper.className).toContain('text-m-muted')
})
})
@@ -0,0 +1,42 @@
import Markdown from 'react-markdown'
import type { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from './markdownLink'
/**
* A clamped caption lives inside a tappable row, so an anchor there would eat
* the tap that opens the row. The link text stays, the anchor goes.
*/
const clampedComponents: Components = {
a: ({ children }) => <>{children}</>,
}
/**
* A description or note, rendered (#2337). The field is Markdown wherever it is
* written — the editors ship a formatting bar — so printing it raw anywhere
* shows `# headings` and `**asterisks**` to the reader.
*
* `clamp` is the list-row variant: one line high, matching what the desktop
* sidebar rows do with the same field. The height cap is inline because the row
* itself sets a font size, and a Tailwind `leading-*` would race with it.
*/
export default function MarkdownText({ children, className = '', clamp = false }: {
children: string
className?: string
clamp?: boolean
}) {
return (
<div
className={`collab-note-md ${clamp ? 'truncate ' : ''}${className}`}
style={clamp ? { maxHeight: '1.2em', lineHeight: 1.2 } : undefined}
>
<Markdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={clamp ? clampedComponents : markdownLinkComponents}
>
{children}
</Markdown>
</div>
)
}
@@ -9,6 +9,7 @@ import { useAddonStore } from '../../../../store/addonStore'
import { useToast } from '../../../../components/shared/Toast'
import { collectionsApi } from '../../../../api/collections'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import MarkdownText from '../../../../components/shared/MarkdownText'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
import MConfirmSheet from '../../settings/MConfirmSheet'
@@ -350,7 +351,7 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
<button type="button" onClick={() => openRow(place)} className="flex min-w-0 flex-1 items-center gap-[11px] text-left">
{selectMode && <SquareCheck big checked={selectedIds.has(place.id)} />}
<PlaceAvatar place={place} category={cat} size={40} />
<span className="min-w-0 flex-1">
<div className="min-w-0 flex-1">
<span className="flex items-center gap-[6px]">
{/* Stroke in the track's colour — the mobile map is full-bleed
with no sidebar, so this is the only thing tying a line to
@@ -365,9 +366,9 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
<span className="truncate text-[0.8125rem] font-semibold text-m-ink">{place.name}</span>
</span>
{sub && (
<span className="mt-px block truncate font-geist text-[0.65625rem] text-m-muted">{sub}</span>
<MarkdownText clamp className="mt-px font-geist text-[0.65625rem] text-m-muted">{sub}</MarkdownText>
)}
</span>
</div>
</button>
{!selectMode && dayNumber != null && (
<span className="flex-none whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[9px] py-1 font-geist text-[0.59375rem] font-bold uppercase tracking-[.05em] text-m-muted">
@@ -6,6 +6,7 @@ import {
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useContextMenu, ContextMenu } from '../../../../components/shared/ContextMenu'
import MarkdownText from '../../../../components/shared/MarkdownText'
import { fmtTransitDuration } from '../../../../components/Planner/transitDisplay'
import { formatTime } from '../../../../utils/formatters'
import { useMPlanTimeline, type MPlanTimelineController } from './useMPlanTimeline'
@@ -323,7 +324,7 @@ function UpNextCard({ tl, t, onOpen }: {
)}
<span className="min-w-0 truncate text-[1.125rem] font-bold">{place?.name}</span>
</div>
{sub && <div className="mt-[2px] truncate font-geist text-[0.75rem] text-m-muted">{sub}</div>}
{sub && <MarkdownText clamp className="mt-[2px] font-geist text-[0.75rem] text-m-muted">{sub}</MarkdownText>}
</div>
<span className="ml-2 flex h-8 w-8 flex-none items-center justify-center rounded-full bg-m-act text-m-actfg">
<ChevronRight size={16} strokeWidth={2.4} />
@@ -2,12 +2,9 @@ import { BedDouble, Car, ChevronDown, ChevronUp, Clock, Footprints, Pencil, Rout
import type { ReactNode, MouseEvent, CSSProperties } from 'react'
import PlaceAvatar from '../../../../components/shared/PlaceAvatar'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import MarkdownText from '../../../../components/shared/MarkdownText'
import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants'
import { noteSurface } from '../../../../components/Planner/noteSurface'
import { markdownLinkComponents } from '../../../../components/shared/markdownLink'
import { getDisplayTimeForDay, getSpanPhase } from '../../../../utils/dayMerge'
import { formatTime, splitReservationDateTime } from '../../../../utils/formatters'
import { transportSubtitle, type TransitMeta, type TransportEntry } from './planTimelineModel'
@@ -193,7 +190,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
{(time || sub) && (
<div className="mt-[2px] flex min-w-0 items-center gap-1.5">
{time && <span className={TIME_CHIP}>{time}</span>}
{sub && <span className="min-w-0 truncate font-geist text-[0.71875rem] text-m-muted">{sub}</span>}
{sub && <MarkdownText clamp className="min-w-0 font-geist text-[0.71875rem] text-m-muted">{sub}</MarkdownText>}
</div>
)}
{bookingLines.slice(1).map(line => (
@@ -207,7 +204,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re
// without opening the place sheet.
<div className="mt-[2px] flex min-w-0 items-center gap-1">
<StickyNote size={10} strokeWidth={2.2} className="flex-none text-m-faint" />
<span className="min-w-0 truncate font-geist text-[0.65625rem] text-m-faint">{assignment.notes}</span>
<MarkdownText clamp className="min-w-0 font-geist text-[0.65625rem] text-m-faint">{assignment.notes}</MarkdownText>
</div>
)}
</div>
@@ -571,9 +568,7 @@ export function NoteRow({ note, chrome, reorder, drag, onEdit }: {
{detail && (
// Rendered, not raw: a note written with the formatting bar would
// otherwise read as `**asterisks**` on the phone.
<div className="collab-note-md mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{detail}</Markdown>
</div>
<MarkdownText className="mt-px font-geist text-[0.71875rem] leading-[1.45] text-m-muted [overflow-wrap:anywhere]">{detail}</MarkdownText>
)}
</div>
{chrome.editing && <span className="flex flex-none items-center gap-1.5">{reorder}</span>}
@@ -15,6 +15,7 @@ import { useSaveToCollectionStore } from '../../../../store/saveToCollectionStor
import { collectionTargetFromPlace } from '../lib/collectionTarget'
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import PlaceRating from '../../../../components/shared/StarRating'
import MarkdownText from '../../../../components/shared/MarkdownText'
import TrackColorPicker from '../../../../components/shared/TrackColorPicker'
import { resolveTrackColor, inheritedTrackColor } from '../../../../components/Map/trackColors'
import { avatarSrc } from '../../../../utils/avatarSrc'
@@ -342,7 +343,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
{place.description && (
<div className={`mt-[10px] rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
<div className="font-geist text-[0.75rem] leading-[1.5] text-m-muted">{place.description}</div>
<MarkdownText className="font-geist text-[0.75rem] leading-[1.5] text-m-muted [overflow-wrap:anywhere]">{place.description}</MarkdownText>
</div>
)}
@@ -350,7 +351,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) {
<>
<Eyebrow className="mb-[6px] mt-3">{t('mobileTrip.notes')}</Eyebrow>
<div className={`rounded-[14px] px-3 py-[10px] ${INNER_CLS}`}>
<div className="whitespace-pre-wrap font-geist text-[0.75rem] leading-[1.5] text-m-muted">{place.notes}</div>
<MarkdownText className="font-geist text-[0.75rem] leading-[1.5] text-m-muted [overflow-wrap:anywhere]">{place.notes}</MarkdownText>
</div>
</>
)}
@@ -10,7 +10,7 @@ import type { Assignment, Category, Day, Place, TripFile, TripMember } from '../
import { resetAllStores, seedStore } from '../../../helpers/store'
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-PLSH-001 to FE-MOB-PLSH-025
// FE-MOB-PLSH-001 to FE-MOB-PLSH-032
vi.mock('../../../../src/utils/fileDownload', () => ({ openFile: vi.fn() }))
@@ -506,6 +506,19 @@ describe('MPlaceSheet', () => {
expect(row.disabled).toBe(true)
})
it('FE-MOB-PLSH-032: the description and the note are rendered markdown, as on the desktop inspector (#2337)', () => {
const marked = {
...PLACE,
description: '# Kunsthistorisches\n\nThe **Habsburg** collections.',
notes: 'Book the *skip-the-line* ticket',
} as unknown as Place
renderSheet(makePlanner({ selectedPlace: marked }) as unknown as TripPlanner)
expect(screen.getByRole('heading', { name: 'Kunsthistorisches' })).toBeInTheDocument()
expect(screen.getByText('Habsburg').tagName).toBe('STRONG')
expect(screen.getByText('skip-the-line').tagName).toBe('EM')
})
it('FE-MOB-PLSH-031: a booking on another assignment is not shown here', () => {
renderSheet(bookingPlanner({ id: 9, assignment_id: 999, title: 'Someone elses ferry', status: 'pending', type: 'ferry' }))
@@ -10,7 +10,7 @@ import { resetAllStores, seedStore } from '../../../helpers/store'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { AssignmentsMap, Category, Day, Place } from '../../../../src/types'
// FE-MOB-PBROW-001 to FE-MOB-PBROW-030
// FE-MOB-PBROW-001 to FE-MOB-PBROW-031
const CATEGORIES = [
{ id: 1, name: 'Sights', color: '#123456', icon: 'landmark' },
@@ -382,6 +382,14 @@ describe('MPlacesBrowser', () => {
expect(screen.getByText('places.selectionCount:2')).toBeInTheDocument()
})
it('FE-MOB-PBROW-031: a markdown description in a row reads as formatted text, not as syntax (#2337)', () => {
const CHARLIE = place({ id: 4, name: 'Checkpoint Charlie', description: '# Checkpoint Charlie\n\nThe **most famous** crossing.' })
renderBrowser(makePlanner({ places: [CHARLIE], assignments: {} } as Partial<TripPlanner>))
expect(screen.getByRole('heading', { name: 'Checkpoint Charlie' })).toBeInTheDocument()
expect(screen.queryByText(/^# Checkpoint/)).toBeNull()
})
it('FE-MOB-PBROW-030: a selection that only matches the visible pool in size is not "all selected"', () => {
renderBrowser()
fireEvent.click(screen.getByRole('button', { name: 'common.select' }))