diff --git a/client/e2e/collection-row-names-2981.spec.ts b/client/e2e/collection-row-names-2981.spec.ts new file mode 100644 index 000000000..4c5857193 --- /dev/null +++ b/client/e2e/collection-row-names-2981.spec.ts @@ -0,0 +1,92 @@ +import { expect, test, type Locator } from '@playwright/test'; + +// #2981: labels, category and status took the whole row, so a place with two or +// three long labels showed one letter of its name or none, and the status pill +// ran out of the card. Measured in a real browser because the layout is the bug. +const PLACES = [ + { name: 'Oceanario de Lisboa', labels: ['Core shortlist', 'Heritage specialist', 'Pair with another stop'] }, + { name: 'Cove of Ursa', labels: ['Heritage specialist', 'Pair with another stop'] }, + { name: 'A Vida Portuguesa', labels: ['Flagship', 'Market', 'Core shortlist', 'Pair with another stop'] }, + { name: 'Torre de Belem', labels: [] as string[] }, +]; + +async function expectInside(row: Locator) { + const escapes = await row.evaluate((el) => { + const box = el.getBoundingClientRect(); + return Array.from(el.querySelectorAll('*')).filter((child) => { + const r = child.getBoundingClientRect(); + return r.width > 0 && (r.left < box.left - 0.5 || r.right > box.right + 0.5); + }).length; + }); + expect(escapes).toBe(0); +} + +for (const viewport of [ + { width: 1400, height: 900 }, + { width: 1024, height: 800 }, +]) { + test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport); + const addonsResponse = await page.request.get('/api/admin/addons'); + expect(addonsResponse.ok()).toBeTruthy(); + const { addons } = await addonsResponse.json(); + const original = addons.find((addon: { id: string }) => addon.id === 'collections'); + expect(original).toBeDefined(); + let collectionId: number | undefined; + + try { + expect((await page.request.put('/api/admin/addons/collections', { data: { enabled: true } })).ok()).toBeTruthy(); + const created = await page.request.post('/api/addons/collections', { data: { name: `Rows ${viewport.width}` } }); + expect(created.ok()).toBeTruthy(); + const body = await created.json(); + collectionId = (body.collection ?? body).id as number; + + const labelIds = new Map(); + for (const name of new Set(PLACES.flatMap((p) => p.labels))) { + const res = await page.request.post('/api/addons/collections/labels', { + data: { collection_id: collectionId, name, color: '#2563eb' }, + }); + expect(res.ok(), `label ${name}`).toBeTruthy(); + const label = await res.json(); + labelIds.set(name, (label.label ?? label).id); + } + for (const place of PLACES) { + const res = await page.request.post('/api/addons/collections/places', { + data: { + collection_id: collectionId, + name: place.name, + address: 'Rua Anchieta 11, 1200-023 Lisboa, Portugal', + lat: 38.71, + lng: -9.14, + }, + }); + expect(res.ok(), `place ${place.name}`).toBeTruthy(); + const saved = await res.json(); + if (!place.labels.length) continue; + const assign = await page.request.post('/api/addons/collections/labels/assign', { + data: { label_ids: place.labels.map((l) => labelIds.get(l)), place_ids: [(saved.place ?? saved).id] }, + }); + expect(assign.ok()).toBeTruthy(); + } + + await page.goto(`/collections/${collectionId}`); + const rows = page.locator('.col-lrow'); + await expect(rows).toHaveCount(PLACES.length); + for (const place of PLACES) { + const row = rows.filter({ hasText: place.name }); + const name = row.locator('.li .t'); + await expect(name).toHaveText(place.name); + // The whole name fits: it is not cut to an ellipsis. + expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + await expectInside(row); + } + } finally { + if (collectionId !== undefined) { + expect((await page.request.delete(`/api/addons/collections/${collectionId}`)).ok()).toBeTruthy(); + } + expect( + (await page.request.put('/api/admin/addons/collections', { data: { enabled: !!original.enabled } })).ok() + ).toBeTruthy(); + } + }); +} diff --git a/client/src/components/Collections/CollectionList.tsx b/client/src/components/Collections/CollectionList.tsx index 1ba3f50b0..d7dfc3019 100644 --- a/client/src/components/Collections/CollectionList.tsx +++ b/client/src/components/Collections/CollectionList.tsx @@ -70,7 +70,7 @@ export default function CollectionList({
{placeLabels.slice(0, 2).map(l => ( - {l.name} + {l.name} ))} {placeLabels.length > 2 && l.name).join(', ')}>+{placeLabels.length - 2}} @@ -79,7 +79,7 @@ export default function CollectionList({ return ( <> - {place.category.name} + {place.category.name} diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 9533458b7..391be0f35 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -254,8 +254,12 @@ /* ----------------- list view ----------------- */ .trek-dash .col-listview { display: flex; flex-direction: column; gap: 7px; } +/* A row is the place's tile, its name and address, and an end group of labels, category + and status. The name keeps at least 11rem; when the end group does not fit beside it, + the group goes to a line of its own and wraps there, so a place with long labels still + shows which place it is and its status stays inside the card (#2981). */ .trek-dash .col-lrow { - display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 14px; align-items: center; + display: flex; flex-wrap: wrap; column-gap: 14px; row-gap: 8px; align-items: center; padding: 10px 14px; border-radius: var(--r-md); background: var(--glass-bg); border: 1px solid var(--glass-border); box-shadow: var(--sh-xs); cursor: pointer; transition: transform .12s, box-shadow .15s, border-color .15s; @@ -265,15 +269,19 @@ .trek-dash .col-lrow:hover { transform: translateX(3px); box-shadow: var(--sh-sm); } /* Inset ring so the highlight is never clipped by the column's overflow. */ .trek-dash .col-lrow.sel { border-color: var(--accent); box-shadow: inset 0 0 0 1.5px var(--accent); } -.trek-dash .col-lrow .li { min-width: 0; } +.trek-dash .col-lrow > :first-child { flex: none; } +/* The select box is narrower than a tile; the margin keeps the names lined up. */ +.trek-dash .col-lrow > .col-lcheck { margin-inline-end: 18px; } +.trek-dash .col-lrow .li { flex: 1 1 11rem; min-width: 0; } .trek-dash .col-lrow .li .t { font-size: 14px; font-weight: 600; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .trek-dash .col-lrow .li .s { font-size: 12px; color: var(--ink-3); margin-top: 2px; display: flex; align-items: center; gap: 5px; overflow: hidden; } .trek-dash .col-lrow .li .s span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } /* The pin before the address must not be squeezed by a long address. */ .trek-dash .col-lrow .li .s svg { flex-shrink: 0; } -.trek-dash .col-lrow-end { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } +.trek-dash .col-lrow-end { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px 8px; flex: 0 1 auto; max-width: 100%; min-width: 0; margin-inline-start: auto; } .trek-dash .col-lrow-cat { display: inline-flex; align-items: center; gap: 5px; max-width: 130px; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--cat, var(--accent)); background: color-mix(in oklch, var(--cat, var(--accent)) 14%, transparent); } .trek-dash .col-lrow-cat svg { flex-shrink: 0; } +.trek-dash .col-lrow-txt { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); } /* A kept tour (LIB-03) is a row of the same list: its grade tile stands where