diff --git a/client/src/components/shared/MarkdownText.test.tsx b/client/src/components/shared/MarkdownText.test.tsx new file mode 100644 index 000000000..afd9ad015 --- /dev/null +++ b/client/src/components/shared/MarkdownText.test.tsx @@ -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({'# Checkpoint Charlie\n\nThe **most famous** crossing.'}) + 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({'| Day | Stop |\n| --- | --- |\n| 1 | Gate |'}) + 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({'Opening hours\n9–17'}) + expect(container.querySelector('br')).not.toBeNull() + }) + + it('FE-MDTEXT-004: a link in a full view opens in its own tab', () => { + render({'[tickets](https://example.com/tickets)'}) + 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({'[tickets](https://example.com/tickets)'}) + 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({'# Berlin Wall Memorial\n\nA long second paragraph.'}) + 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') + }) +}) diff --git a/client/src/components/shared/MarkdownText.tsx b/client/src/components/shared/MarkdownText.tsx new file mode 100644 index 000000000..f65be4069 --- /dev/null +++ b/client/src/components/shared/MarkdownText.tsx @@ -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 ( +
+ + {children} + +
+ ) +} diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx index a3880d089..6bd4a1d42 100644 --- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx +++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx @@ -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) {!selectMode && dayNumber != null && ( diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx index a3f1cb59e..b72c29f70 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx @@ -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 }: { )} {place?.name} - {sub &&
{sub}
} + {sub && {sub}} diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx index f02f136af..1ea35adc9 100644 --- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx +++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx @@ -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) && (
{time && {time}} - {sub && {sub}} + {sub && {sub}}
)} {bookingLines.slice(1).map(line => ( @@ -207,7 +204,7 @@ export function PlaceRow({ assignment, fullPlace, linkedReservations, chrome, re // without opening the place sheet.
- {assignment.notes} + {assignment.notes}
)} @@ -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. -
- {detail} -
+ {detail} )} {chrome.editing && {reorder}} diff --git a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx index 80457ebd8..eb29d346e 100644 --- a/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MPlaceSheet.tsx @@ -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 && (
-
{place.description}
+ {place.description}
)} @@ -350,7 +351,7 @@ export default function MPlaceSheet({ planner, shell }: MTripSheetsProps) { <> {t('mobileTrip.notes')}
-
{place.notes}
+ {place.notes}
)} diff --git a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx index f230f504c..3224b5d3f 100644 --- a/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MPlaceSheet.test.tsx @@ -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' })) diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index 8ecbaaf57..49a192689 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -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)) + + 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' }))