mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
Merge pull request #2383 from liketrek/fix/mobile-markdown-place-description
fix(places): render markdown in the mobile place description
This commit is contained in:
@@ -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' }))
|
||||
|
||||
Reference in New Issue
Block a user