mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 15:21:08 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f6430762 | |||
| 6ee540d5e4 | |||
| 99cdcfcd09 | |||
| fd11944ed6 | |||
| 82c6a3cd5f | |||
| fbafa270c4 | |||
| 1c54d7d6c1 | |||
| d2c7d83256 | |||
| 91630dd811 | |||
| 14c767a675 | |||
| da324121bd | |||
| b41f6e3542 | |||
| 9f5d01ee09 | |||
| 67ca58f2a5 | |||
| 9eab19ea6c | |||
| 50ea9a74c9 | |||
| 82295a1d3f | |||
| 3c78452c14 | |||
| 74864f2543 | |||
| 178df0fbe3 | |||
| 51af575c9a | |||
| 3e90ece087 | |||
| 65e5d4ab72 | |||
| 154620b99a | |||
| adbe3a40f6 | |||
| 44c6fbf372 | |||
| 45690ecd14 | |||
| c6149cd64d | |||
| 4587cf2099 | |||
| 4585988dfd | |||
| a37a9c0fa8 | |||
| 0cde099c7f | |||
| e798eb712e | |||
| 0dae908e05 | |||
| 01c7bc9de2 | |||
| 2e683f8609 | |||
| f8e51cb83d | |||
| eedfb08597 | |||
| 0aee0a019e | |||
| 91bc1944dc | |||
| f99b40cd81 | |||
| 59e68d81b2 | |||
| 86513fda0d |
@@ -0,0 +1,104 @@
|
||||
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) => {
|
||||
// The strip's hidden measuring copy is meant to run past the bar.
|
||||
if (child.closest('[aria-hidden="true"]')) return false;
|
||||
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: 1920, height: 1000 },
|
||||
{ width: 1400, height: 900 },
|
||||
{ width: 1024, height: 800 },
|
||||
]) {
|
||||
test(`place names stay readable next to long labels at ${viewport.width}px`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
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<string, number>();
|
||||
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);
|
||||
// Every label is either on the row or counted by its "+N" chip.
|
||||
const shown = await row.locator('.col-lrow-label:not(.is-over)').count();
|
||||
// Read without waiting: a row whose labels all fit has no "+N".
|
||||
const [more] = await row.locator('.col-lrow-more').allTextContents();
|
||||
expect(shown + (more ? Number(more.replace('+', '')) : 0)).toBe(place.labels.length);
|
||||
}
|
||||
// The map's label strip stays inside the map: whole, or folded into its button.
|
||||
const topbar = page.locator('.col-map-topbar');
|
||||
if (await topbar.count()) await expectInside(topbar);
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 380,
|
||||
"@typescript-eslint/no-non-null-assertion": 377,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
@@ -8,14 +8,14 @@
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/refs": 554,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/static-components": 50,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
@@ -25,7 +25,7 @@
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
"react-hooks/purity": 2,
|
||||
"react-hooks/purity": 1,
|
||||
"preserve-caught-error": 53,
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
@@ -34,8 +34,8 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1915,
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1914,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -742,11 +742,8 @@
|
||||
{
|
||||
"feature": "Settings / Map: mapSettingsModel",
|
||||
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
|
||||
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMap.tsx"
|
||||
]
|
||||
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Map: useMapSettingsForm",
|
||||
@@ -983,8 +980,10 @@
|
||||
}
|
||||
],
|
||||
"mobileOnly": {
|
||||
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
|
||||
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
|
||||
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
|
||||
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
|
||||
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
|
||||
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
|
||||
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1873,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapView.tsx": 1337,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/PDF/TripPDF.tsx": 1039,
|
||||
"src/components/Map/MapViewGL.tsx": 2198,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
@@ -18,7 +17,7 @@
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
"components/Packing/PackingListPanelHeader.tsx": 5,
|
||||
@@ -284,7 +284,7 @@
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
|
||||
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
|
||||
// A page opened while the server restarts got a 502 here and kept every
|
||||
// default for the session, which brought back the operator's settings.
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => {
|
||||
calls += 1
|
||||
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
|
||||
})
|
||||
)
|
||||
const setManaged = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setManaged,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
|
||||
+25
-3
@@ -323,7 +323,21 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
// Asked again until it answers: a page opened while the server restarts got a
|
||||
// 502 here and kept the defaults for the whole session, which on a managed
|
||||
// install put back every setting the operator owns.
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
|
||||
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
|
||||
if (cancelled) throw err
|
||||
return new Promise<AppConfigAnswer>((resolve, reject) => {
|
||||
retryTimer = setTimeout(() => {
|
||||
fetchAppConfig(attempt + 1).then(resolve, reject)
|
||||
}, Math.min(30_000, 1_000 * 2 ** attempt))
|
||||
})
|
||||
})
|
||||
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -347,6 +361,10 @@ export default function App() {
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version)
|
||||
}).catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
@@ -402,6 +420,10 @@ export default function App() {
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
// The Help & Docs page is the wiki, written for somebody who runs TREK
|
||||
// themselves: keys, backups, updates, the server behind it. A managed install
|
||||
// does not offer it; the in-app help center stays.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -456,7 +478,7 @@ export default function App() {
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -464,7 +486,7 @@ export default function App() {
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
|
||||
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
|
||||
seedStore(useAuthStore, { managed: true });
|
||||
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
await unfoldShelves();
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...(wandererOffered
|
||||
? [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
|
||||
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
|
||||
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
|
||||
} = useMcpTokensAdmin()
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
const date = (iso: string) => formatDate(new Date(iso))
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
|
||||
import { deriveCaps } from './pluginCaps'
|
||||
import PluginPoiCategoryList from './PluginPoiCategoryList'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||
import VersionLine from './plugins/VersionLine'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const { detail, failed } = usePluginDetail(item.id)
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const permissions = manifest?.permissions ?? []
|
||||
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
|
||||
)}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
|
||||
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
|
||||
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
|
||||
)}
|
||||
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
) : (
|
||||
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}>
|
||||
<VersionLine v={v} locale={locale} />
|
||||
<VersionLine v={v} />
|
||||
</DialogListItem>
|
||||
))}
|
||||
</DialogList>
|
||||
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)
|
||||
}
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
t: T; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
|
||||
{current ? (
|
||||
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
|
||||
) : v.compatible ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
const choices = await screen.findAllByRole('button', { name: option });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
|
||||
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultUserSettingsTab date format (#2971)', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
// The choices are labelled by how they write 31 December.
|
||||
await pickFromSelect(user, '2026-12-31', '31.12.2026');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { dateFormatOptions } from '../../utils/dateFormat'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
|
||||
import type { MapProvider } from '../Settings/mapSettingsModel'
|
||||
import RoutingInstanceFields from './RoutingInstanceFields'
|
||||
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
|
||||
|
||||
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback
|
||||
}
|
||||
|
||||
interface ResetProps {
|
||||
shown: boolean
|
||||
onReset: () => void
|
||||
label: string
|
||||
}
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
|
||||
if (!shown) return null
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onReset}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const {
|
||||
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
|
||||
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
|
||||
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
const resetOf = (field: keyof Defaults): ResetProps => ({
|
||||
shown: isSet(field),
|
||||
onReset: () => reset(field),
|
||||
label: t('admin.defaultSettings.resetToBuiltIn'),
|
||||
})
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Date format: every date field and numeric date (#2971) */}
|
||||
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.date_format ?? ''}
|
||||
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
|
||||
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={pickTilePreset}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
<Segmented<MapProvider>
|
||||
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
|
||||
const asked = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
asked();
|
||||
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
|
||||
})
|
||||
);
|
||||
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
await waitFor(() => expect(asked).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
|
||||
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
|
||||
render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
|
||||
* way to take everything back. Turning it on for the first time opens the same
|
||||
* prompt an admin gets on the first visit, so an agreement always comes from it.
|
||||
*/
|
||||
export default function PlaceContribPanel() {
|
||||
/**
|
||||
* Standing alone (managed settings), a card that can only say "not available"
|
||||
* because the operator switched sharing off is a dead end.
|
||||
*/
|
||||
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
|
||||
return alone && state.ineligibleReason === 'env_off';
|
||||
}
|
||||
|
||||
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contrib = usePlaceContrib();
|
||||
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
|
||||
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
|
||||
|
||||
if (!state) return null;
|
||||
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
|
||||
const status = contribStatus(state);
|
||||
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
|
||||
// The prompt opens by itself once per visit while the server asks for it.
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
@@ -36,6 +37,9 @@ export function useAddonManager() {
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
// wanderer sends a user's searches to a server of their choosing, which a
|
||||
// managed install's terms do not cover; the operator decides, not the admin.
|
||||
const wandererOffered = !useAuthStore((s) => s.managed);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||
@@ -160,6 +164,7 @@ export function useAddonManager() {
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { VersionInfo } from './pluginModel';
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
export default function VersionLine({ v }: { v: VersionInfo }) {
|
||||
const { formatDate } = useFormatDate();
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span
|
||||
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
|
||||
style={fs(12.5)}
|
||||
>
|
||||
v{v.version}
|
||||
</span>
|
||||
{v.publishedAt && (
|
||||
<span className="text-content-faint" style={fs(11.5)}>
|
||||
{formatDate(new Date(v.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
|
||||
* cards render their own markup over it.
|
||||
*/
|
||||
export function useAuditLog(serverTimezone?: string) {
|
||||
const { locale } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
|
||||
import { backupApi } from '../../api/client';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
|
||||
const loadBackups = async () => {
|
||||
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (serverTimezone) opts.timeZone = serverTimezone;
|
||||
return new Date(dateStr).toLocaleString(locale, opts);
|
||||
return formatDateTime(new Date(dateStr), opts);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl';
|
||||
import {
|
||||
defaultStyleForProvider,
|
||||
@@ -11,7 +12,13 @@ import {
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
} from '../Map/glProviders';
|
||||
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
|
||||
import {
|
||||
type MapProvider,
|
||||
mapboxOffered,
|
||||
mapPresetsFor,
|
||||
normalizeProvider,
|
||||
styleForProvider,
|
||||
} from '../Settings/mapSettingsModel';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import type { RoutingDefaults } from './RoutingInstanceFields';
|
||||
|
||||
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
|
||||
dark_mode?: string | boolean;
|
||||
time_format?: string;
|
||||
week_start?: WeekStart;
|
||||
date_format?: DateFormat;
|
||||
default_currency?: string;
|
||||
blur_booking_codes?: boolean;
|
||||
map_tile_url?: string;
|
||||
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mapTileUrl, setMapTileUrl] = useState('');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
|
||||
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
|
||||
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
|
||||
const [mapboxToken, setMapboxToken] = useState('');
|
||||
const [cartoKey, setCartoKey] = useState('');
|
||||
const [mapboxStyle, setMapboxStyle] = useState('');
|
||||
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
defaults,
|
||||
loaded,
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, operatorMapboxToken),
|
||||
isSet,
|
||||
save,
|
||||
reset,
|
||||
|
||||
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
// A bill in the display currency counts as typed (#2965), as the balances net it:
|
||||
// row total and payer chip read the dollars entered, nothing to explain under it,
|
||||
// and what I lent is half of them, which settle-up offers too.
|
||||
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
|
||||
expect(screen.getAllByText('$801.76')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
|
||||
// $921.76, on the day header and on the total spend card.
|
||||
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
|
||||
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
expect(card.textContent).toContain('$921.76')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
expect(await screen.findByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
expect(outsideChips('$400.88')).toHaveLength(1)
|
||||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||||
// The list reads the dollars typed, so there is no conversion to preview.
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
|
||||
}
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
|
||||
|
||||
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
|
||||
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
|
||||
// Entered in the display currency, it reads as entered (#2965).
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
|
||||
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import { readDateFormat } from '../../hooks/useDateFormat';
|
||||
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { formatDisplayDate } from '../../utils/dateFormat';
|
||||
import { currencyDecimals } from '../../utils/formatters';
|
||||
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
|
||||
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
|
||||
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
|
||||
return (e.currency || ctx.tripCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
|
||||
/**
|
||||
* An amount of this expense in the display currency, at the rate the expense was booked at;
|
||||
* as typed when it was entered in the display currency (#2965).
|
||||
*/
|
||||
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
|
||||
return (s.currency || ctx.displayCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
|
||||
/**
|
||||
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
|
||||
* as entered when it was made in the display currency (#2965).
|
||||
*/
|
||||
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
|
||||
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
const currency = paymentCurrency(s, ctx);
|
||||
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
|
||||
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
|
||||
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
const fmtDate = (iso: string) => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return formatDisplayDate(
|
||||
new Date(iso + 'T00:00:00Z'),
|
||||
opts.locale,
|
||||
{
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
readDateFormat()
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
|
||||
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
|
||||
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
|
||||
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
|
||||
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
@@ -33,8 +33,8 @@ export interface ExpenseFx {
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
|
||||
* the display currency, which counts as typed (#2965).
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
if (total === 0 || currency === base) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function Dropdown({ current, options, onSelect, lead }: {
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="col-filter" ref={ref}>
|
||||
<button type="button" className={`col-filter-btn${open ? ' open' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}>
|
||||
<button type="button" className={`col-filter-btn${open ? ' open' : ''}${cur.key === options[0]?.key ? ' is-default' : ''}`} onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open} title={cur.label}>
|
||||
{cur.icon ?? lead}
|
||||
<span className="col-filter-lbl">{cur.label}</span>
|
||||
<ChevronDown size={14} className="col-filter-chev" />
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// FE-COMP-COLLABELS-001 to FE-COMP-COLLABELS-006
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import CollectionLabelFilter, { type LabelOption } from './CollectionLabelFilter';
|
||||
|
||||
const t = ((key: string) => key) as unknown as TranslationFn;
|
||||
const LABELS = [
|
||||
{ id: 1, name: 'Core shortlist', color: '#2563eb', count: 2 },
|
||||
{ id: 2, name: 'Heritage', color: null, count: 0 },
|
||||
] as unknown as LabelOption[];
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** The labels need `need` px against a slot of `room` px beside the map's controls. */
|
||||
function room(need: number, room: number) {
|
||||
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
|
||||
return this.classList.contains('col-labelfilter-measure') ? need : 0;
|
||||
});
|
||||
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
|
||||
return this.classList.contains('col-labelfilter-slot') ? room : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function setup(props: Partial<Parameters<typeof CollectionLabelFilter>[0]> = {}) {
|
||||
const onLabelFilter = vi.fn();
|
||||
const onManageLabels = vi.fn();
|
||||
const view = render(
|
||||
<CollectionLabelFilter
|
||||
labelOptions={LABELS}
|
||||
labelFilter={[]}
|
||||
onLabelFilter={onLabelFilter}
|
||||
canManageLabels
|
||||
onManageLabels={onManageLabels}
|
||||
t={t}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return { ...view, onLabelFilter, onManageLabels };
|
||||
}
|
||||
|
||||
describe('CollectionLabelFilter', () => {
|
||||
it('FE-COMP-COLLABELS-001: the filter row toggles a label on and off', () => {
|
||||
const on = setup();
|
||||
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
|
||||
expect(on.onLabelFilter).toHaveBeenCalledWith([1]);
|
||||
on.unmount();
|
||||
|
||||
const off = setup({ labelFilter: [1, 2] });
|
||||
expect(screen.getByRole('button', { name: /Core shortlist/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
|
||||
expect(off.onLabelFilter).toHaveBeenCalledWith([2]);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLABELS-002: offers to add a label when there is none, and to manage them otherwise', () => {
|
||||
const empty = setup({ labelOptions: [] });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'collections.labels.add' }));
|
||||
expect(empty.onManageLabels).toHaveBeenCalled();
|
||||
empty.unmount();
|
||||
|
||||
setup();
|
||||
expect(screen.getByRole('button', { name: 'collections.labels.manage' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLABELS-003: on the map the strip shows its labels while they fit', () => {
|
||||
room(300, 500);
|
||||
setup({ variant: 'map' });
|
||||
expect(screen.getByRole('button', { name: /Heritage/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /collections.labels.title/ })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLABELS-004: without room it folds into one button that lists the labels', () => {
|
||||
room(600, 200);
|
||||
const { onLabelFilter } = setup({ variant: 'map', labelFilter: [2] });
|
||||
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
|
||||
expect(fold).toHaveTextContent('1');
|
||||
expect(fold).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(fold);
|
||||
const list = screen.getByRole('group', { name: 'collections.labels.title' });
|
||||
expect(list).toHaveTextContent('Core shortlist');
|
||||
expect(screen.getByRole('button', { name: /Heritage/ })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Core shortlist/ }));
|
||||
expect(onLabelFilter).toHaveBeenCalledWith([2, 1]);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLABELS-005: the folded list manages labels and closes on Escape or a click beside it', () => {
|
||||
room(600, 200);
|
||||
const { onManageLabels } = setup({ variant: 'map' });
|
||||
const fold = screen.getByRole('button', { name: /collections.labels.title/ });
|
||||
|
||||
fireEvent.click(fold);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
|
||||
|
||||
fireEvent.click(fold);
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
|
||||
|
||||
fireEvent.click(fold);
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.labels.manage/ }));
|
||||
expect(onManageLabels).toHaveBeenCalled();
|
||||
expect(screen.queryByRole('group', { name: 'collections.labels.title' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLABELS-006: a click inside the folded list keeps it open', () => {
|
||||
room(600, 200);
|
||||
setup({ variant: 'map' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /collections.labels.title/ }));
|
||||
fireEvent.mouseDown(screen.getByRole('group', { name: 'collections.labels.title' }));
|
||||
expect(screen.getByRole('group', { name: 'collections.labels.title' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Plus, Settings2, Tags } from 'lucide-react'
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, Plus, Settings2, Tags } from 'lucide-react'
|
||||
import type { LabelOption } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
@@ -19,54 +19,154 @@ interface CollectionLabelFilterProps {
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
interface ChipsProps {
|
||||
labelOptions: LabelOption[]
|
||||
labelFilter: number[]
|
||||
onToggle?: (id: number) => void
|
||||
}
|
||||
|
||||
/** The label chips, as buttons, or as plain spans for the copy the map strip measures. */
|
||||
function LabelChips({ labelOptions, labelFilter, onToggle }: ChipsProps): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
const body = (
|
||||
<>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
</>
|
||||
)
|
||||
const style = { ['--label' as string]: l.color ?? 'var(--accent)' }
|
||||
if (!onToggle) return <span key={l.id} className={`col-labelchip${on ? ' on' : ''}`} style={style}>{body}</span>
|
||||
return (
|
||||
<button key={l.id} type="button" className={`col-labelchip${on ? ' on' : ''}`} style={style} onClick={() => onToggle(l.id)} aria-pressed={on}>
|
||||
{body}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the map strip has to fold into its button: true once its chips need more room
|
||||
* than the slot beside the map's other controls. Measured on a hidden copy, so the strip
|
||||
* unfolds again as soon as the room comes back.
|
||||
*/
|
||||
function useFolds(enabled: boolean) {
|
||||
const slotRef = useRef<HTMLDivElement>(null)
|
||||
const measureRef = useRef<HTMLDivElement>(null)
|
||||
const [folded, setFolded] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const slot = slotRef.current
|
||||
const measure = measureRef.current
|
||||
if (!enabled || !slot || !measure) return
|
||||
const check = () => setFolded(measure.scrollWidth > slot.clientWidth + 1)
|
||||
check()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(check)
|
||||
observer.observe(slot)
|
||||
observer.observe(measure)
|
||||
return () => observer.disconnect()
|
||||
}, [enabled])
|
||||
return { slotRef, measureRef, folded }
|
||||
}
|
||||
|
||||
/** Open state of the folded strip's list: closed by a click beside it or Escape, like the filter row's dropdowns. */
|
||||
function useFoldList(ref: React.RefObject<HTMLElement | null>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) }
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey) }
|
||||
}, [open, ref])
|
||||
return { open, setOpen }
|
||||
}
|
||||
|
||||
/**
|
||||
* The label filter, in one place because it has two homes.
|
||||
*
|
||||
* Labels are an axis of their own next to status, category and rating, so they
|
||||
* are grouped into a tray instead of joining the row as more loose buttons. On
|
||||
* a desktop it rides in the map's top bar, where there is room to spare; the
|
||||
* filter row keeps it whenever no map is on screen, so it never becomes
|
||||
* unreachable.
|
||||
* a desktop it rides in the map's top bar; when the bar has no room for every
|
||||
* label it folds into one button that opens them as a list, instead of hiding
|
||||
* the last ones behind a scroll nobody sees. The filter row keeps it whenever
|
||||
* no map is on screen, so it never becomes unreachable.
|
||||
*/
|
||||
export default function CollectionLabelFilter({
|
||||
labelOptions, labelFilter, onLabelFilter, canManageLabels = false, onManageLabels,
|
||||
variant = 'bar', t,
|
||||
}: CollectionLabelFilterProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`col-labelfilter${variant === 'map' ? ' on-map' : ''}`}
|
||||
role="group"
|
||||
aria-label={t('collections.labels.manage')}
|
||||
>
|
||||
const onMap = variant === 'map'
|
||||
const { slotRef, measureRef, folded } = useFolds(onMap)
|
||||
const fold = useFoldList(slotRef)
|
||||
const toggle = (id: number) => onLabelFilter(labelFilter.includes(id) ? labelFilter.filter(x => x !== id) : [...labelFilter, id])
|
||||
const manageLabel = labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')
|
||||
const manage = canManageLabels && onManageLabels && (
|
||||
<button type="button" className="col-filter-addlabel" onClick={onManageLabels} title={manageLabel} aria-label={manageLabel}>
|
||||
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
|
||||
</button>
|
||||
)
|
||||
|
||||
const tray = (
|
||||
<div className={`col-labelfilter${onMap ? ' on-map' : ''}`} role="group" aria-label={t('collections.labels.manage')}>
|
||||
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} onToggle={toggle} />
|
||||
{manage}
|
||||
</div>
|
||||
)
|
||||
if (!onMap) return tray
|
||||
|
||||
const active = labelFilter.filter(id => labelOptions.some(l => l.id === id)).length
|
||||
return (
|
||||
<div className="col-labelfilter-slot" ref={slotRef}>
|
||||
{!folded ? tray : (
|
||||
<>
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`col-labelchip${on ? ' on' : ''}`}
|
||||
style={{ ['--label' as string]: l.color ?? 'var(--accent)' }}
|
||||
onClick={() => onLabelFilter(on ? labelFilter.filter(id => id !== l.id) : [...labelFilter, l.id])}
|
||||
aria-pressed={on}
|
||||
onClick={() => fold.setOpen(o => !o)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={fold.open}
|
||||
className={`col-labelfilter on-map col-labelfilter-fold${fold.open ? ' open' : ''}`}
|
||||
>
|
||||
<span className="col-labelchip-dot" />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
<Tags size={13} className="col-labelfilter-lead" aria-hidden="true" />
|
||||
<span className="col-filter-lbl">{t('collections.labels.title')}</span>
|
||||
{active > 0 && <span className="col-labelfilter-active">{active}</span>}
|
||||
<ChevronDown size={14} className="col-filter-chev" aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && onManageLabels && (
|
||||
<button
|
||||
type="button"
|
||||
className="col-filter-addlabel"
|
||||
onClick={onManageLabels}
|
||||
title={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
|
||||
aria-label={labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}
|
||||
>
|
||||
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
|
||||
</button>
|
||||
{fold.open && (
|
||||
<div className="col-filter-pop col-labelfilter-pop" role="group" aria-label={t('collections.labels.title')}>
|
||||
{labelOptions.map(l => {
|
||||
const on = labelFilter.includes(l.id)
|
||||
return (
|
||||
<button key={l.id} type="button" aria-pressed={on} className={`col-filter-opt${on ? ' on' : ''}`} onClick={() => toggle(l.id)}>
|
||||
<span className="col-filter-dot" style={{ background: l.color ?? 'var(--accent)' }} />
|
||||
<span className="col-filter-lbl">{l.name}</span>
|
||||
{l.count > 0 && <span className="col-filter-count">{l.count}</span>}
|
||||
{on && <Check size={13} className="col-filter-check" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{canManageLabels && onManageLabels && (
|
||||
<button type="button" className="col-filter-opt col-labelfilter-manage" onClick={() => { fold.setOpen(false); onManageLabels() }}>
|
||||
{labelOptions.length ? <Settings2 size={13} /> : <Plus size={13} />}
|
||||
<span className="col-filter-lbl">{manageLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="col-labelfilter on-map col-labelfilter-measure" ref={measureRef} aria-hidden="true">
|
||||
<Tags size={13} className="col-labelfilter-lead" />
|
||||
<LabelChips labelOptions={labelOptions} labelFilter={labelFilter} />
|
||||
{canManageLabels && onManageLabels && <span className="col-filter-addlabel"><Settings2 size={13} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Check, MapPin } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLabel } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import CollectionPlaceTags from './CollectionPlaceTags'
|
||||
|
||||
interface CollectionListProps {
|
||||
places: CollectionPlace[]
|
||||
@@ -51,7 +51,7 @@ export default function CollectionList({
|
||||
tabIndex={0}
|
||||
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
|
||||
className={`col-lrow${active || selected ? ' sel' : ''}`}
|
||||
className={`col-lrow col-lrow-place${active || selected ? ' sel' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<span className={`col-lcheck${selected ? ' on' : ''}`}>{selected && <Check size={14} strokeWidth={3} />}</span>
|
||||
@@ -67,24 +67,8 @@ export default function CollectionList({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
{placeLabels.slice(0, 2).map(l => (
|
||||
<span key={l.id} className="col-lrow-label" style={{ ['--label' as string]: l.color || 'var(--accent)' }} title={l.name}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{placeLabels.length > 2 && <span className="col-lrow-label more" title={placeLabels.map(l => l.name).join(', ')}>+{placeLabels.length - 2}</span>}
|
||||
{place.category?.name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category.icon ?? undefined)
|
||||
return (
|
||||
<>
|
||||
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category.color || '#6366f1' }}>
|
||||
<CatIcon size={11} /> {place.category.name}
|
||||
</span>
|
||||
<span className="col-lrow-div" aria-hidden />
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
<CollectionPlaceTags place={place} labels={placeLabels} />
|
||||
<div className="col-lrow-status">
|
||||
<StatusBadge status={place.status} onChange={selectMode || !onStatusChange ? undefined : next => onStatusChange(place.id, next)} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// FE-COMP-COLTAGS-001 to FE-COMP-COLTAGS-005
|
||||
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import CollectionPlaceTags from './CollectionPlaceTags';
|
||||
|
||||
// jsdom lays nothing out: the observer reports each element once it is observed, and
|
||||
// the widths below stand in for the browser's.
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(private readonly cb: ResizeObserverCallback) {}
|
||||
observe(target: Element) {
|
||||
this.cb([{ target } as ResizeObserverEntry], this as unknown as ResizeObserver);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
/** Row and chip widths in px: the row holds the category, one label and the "+N" chip. */
|
||||
function layout({ row = 250, cutName }: { row?: number; cutName?: string } = {}) {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.classList.contains('col-lrow-cat')) return 50;
|
||||
if (this.classList.contains('col-lrow-label')) return 100;
|
||||
return 0;
|
||||
});
|
||||
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockImplementation(function (this: Element) {
|
||||
if (this.classList.contains('col-lrow-tags')) return row;
|
||||
if (this.classList.contains('col-lrow-txt')) return 60;
|
||||
return 0;
|
||||
});
|
||||
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) {
|
||||
if (this.classList.contains('col-lrow-txt')) return this.textContent === cutName ? 90 : 60;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const label = (id: number, name: string): CollectionLabel =>
|
||||
({ id, collection_id: 1, name, color: '#2563eb' }) as unknown as CollectionLabel;
|
||||
const place = (category: { name: string; color?: string; icon?: string } | null = { name: 'Attraction' }) =>
|
||||
({ id: 7, name: 'Torre', category }) as unknown as CollectionPlace;
|
||||
const LABELS = [label(1, 'Core shortlist'), label(2, 'Heritage'), label(3, 'Pair with another stop')];
|
||||
|
||||
describe('CollectionPlaceTags', () => {
|
||||
it('FE-COMP-COLTAGS-001: draws nothing for a place without labels or a category', () => {
|
||||
const { container } = render(<CollectionPlaceTags place={place(null)} labels={[]} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLTAGS-002: shows the category first, then the labels that fit, then a "+N" for the rest', () => {
|
||||
layout();
|
||||
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
|
||||
const chips = Array.from(container.querySelectorAll('.col-lrow-tags > span')).map((el) => el.className);
|
||||
expect(chips[0]).toBe('col-lrow-cat');
|
||||
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(1);
|
||||
expect(container.querySelectorAll('.col-lrow-label.is-over')).toHaveLength(2);
|
||||
expect(container.querySelector('.col-lrow-label.is-over')).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(screen.getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLTAGS-003: a row wide enough shows every label and no "+N"', () => {
|
||||
layout({ row: 800 });
|
||||
const { container } = render(<CollectionPlaceTags place={place()} labels={LABELS} />);
|
||||
expect(container.querySelectorAll('.col-lrow-label:not(.is-over)')).toHaveLength(3);
|
||||
expect(container.querySelector('.col-lrow-more')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLTAGS-004: the "+N" names the hidden labels in its tooltip', async () => {
|
||||
layout();
|
||||
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
|
||||
fireEvent.mouseEnter(screen.getByText('+2'));
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage, Pair with another stop');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLTAGS-005: a chip says its name in a tooltip only while it cuts the name short', async () => {
|
||||
layout({ row: 800, cutName: 'Heritage' });
|
||||
render(<CollectionPlaceTags place={place()} labels={LABELS} />);
|
||||
fireEvent.mouseEnter(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
|
||||
await new Promise((r) => setTimeout(r, 350));
|
||||
expect(screen.queryByRole('tooltip')).toBeNull();
|
||||
fireEvent.mouseLeave(screen.getByText('Core shortlist').closest('.col-lrow-label') as HTMLElement);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText('Heritage').closest('.col-lrow-label') as HTMLElement);
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent('Heritage');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
|
||||
import React, { createElement, useEffect, useRef, useState } from 'react';
|
||||
import { getCategoryIcon } from '../shared/categoryIcons';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
|
||||
/**
|
||||
* One observer for every row and chip of a list, so a list of hundreds of places does
|
||||
* not hold one per element. An element starts with one notification once observed.
|
||||
*/
|
||||
const sizeWatch = new Map<Element, () => void>();
|
||||
let sizeObserver: ResizeObserver | null = null;
|
||||
function watchSize(el: Element, onResize: () => void): () => void {
|
||||
if (typeof ResizeObserver === 'undefined') return () => {};
|
||||
sizeObserver ??= new ResizeObserver((entries) => {
|
||||
for (const entry of entries) sizeWatch.get(entry.target)?.();
|
||||
});
|
||||
sizeWatch.set(el, onResize);
|
||||
sizeObserver.observe(el);
|
||||
return () => {
|
||||
sizeWatch.delete(el);
|
||||
sizeObserver?.unobserve(el);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tooltip of a label or category chip: only while the chip cuts its name short,
|
||||
* since one repeating the words on screen says nothing.
|
||||
*/
|
||||
function ChipTooltip({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactElement<React.HTMLAttributes<HTMLElement> & React.RefAttributes<HTMLElement>>;
|
||||
}): React.ReactElement {
|
||||
const [cut, setCut] = useState(false);
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const text = ref.current?.querySelector('.col-lrow-txt');
|
||||
if (!text) return undefined;
|
||||
return watchSize(text, () => setCut(text.scrollWidth > text.clientWidth + 1));
|
||||
}, []);
|
||||
return (
|
||||
<Tooltip label={label} placement="top" disabled={!cut}>
|
||||
{React.cloneElement(children, { ref })}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** The room a "+N" chip takes, held free while the labels are fitted. */
|
||||
const MORE_CHIP = 34;
|
||||
|
||||
/**
|
||||
* A chip's own width, also while the row squeezes it: the part its name is cut short by
|
||||
* is the name's scroll width over its box, up to the widest the chip may grow.
|
||||
*/
|
||||
const naturalWidth = (el: HTMLElement): number => {
|
||||
const text = el.querySelector<HTMLElement>('.col-lrow-txt');
|
||||
const full = el.offsetWidth + (text ? text.scrollWidth - text.clientWidth : 0);
|
||||
const cap = parseFloat(getComputedStyle(el).maxWidth);
|
||||
return Number.isFinite(cap) ? Math.min(full, cap) : full;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many labels fit beside the category in `row`: as many as it holds with room left
|
||||
* for the "+N" chip, and at least one, which then shortens its name. Every label is in
|
||||
* the row, the ones that do not fit kept out of sight, so each can be measured.
|
||||
*/
|
||||
function labelsThatFit(row: HTMLElement): number {
|
||||
const gap = parseFloat(getComputedStyle(row).columnGap) || 0;
|
||||
const labels = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-label'));
|
||||
const fixed = Array.from(row.querySelectorAll<HTMLElement>('.col-lrow-cat, .col-lrow-div'))
|
||||
.filter((el) => el.offsetWidth > 0)
|
||||
.reduce((sum, el) => sum + naturalWidth(el) + gap, 0);
|
||||
let used = fixed;
|
||||
let fit = 0;
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const left = labels.length - i - 1;
|
||||
const need = used + naturalWidth(labels[i]) + (left > 0 ? gap + MORE_CHIP : 0);
|
||||
if (need > row.clientWidth && i > 0) break;
|
||||
used += naturalWidth(labels[i]) + gap;
|
||||
fit = i + 1;
|
||||
}
|
||||
return fit;
|
||||
}
|
||||
|
||||
/**
|
||||
* A place row's category and labels, on one line that never wraps (#2981). The category
|
||||
* comes first, then as many labels as the row has room for, then a "+N" chip naming the
|
||||
* rest in its tooltip, so a wide list shows every label and a narrow one the first.
|
||||
*/
|
||||
export default function CollectionPlaceTags({
|
||||
place,
|
||||
labels,
|
||||
}: {
|
||||
place: CollectionPlace;
|
||||
labels: CollectionLabel[];
|
||||
}): React.ReactElement | null {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [fit, setFit] = useState(labels.length);
|
||||
const labelKey = labels.map((l) => `${l.id}:${l.name}`).join('|');
|
||||
const categoryName = place.category?.name ?? null;
|
||||
|
||||
// Fitted on every change of the row's width and once more whenever its labels change:
|
||||
// observing anew hands the observer's first notification to the new labels.
|
||||
useEffect(() => {
|
||||
const row = ref.current;
|
||||
if (!row) return undefined;
|
||||
return watchSize(row, () => setFit(labelsThatFit(row)));
|
||||
}, [labelKey, categoryName]);
|
||||
|
||||
if (!labels.length && !categoryName) return null;
|
||||
const shown = Math.min(labels.length, Math.max(1, fit));
|
||||
const hidden = labels.slice(shown);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="col-lrow-tags">
|
||||
{categoryName && (
|
||||
<ChipTooltip label={categoryName}>
|
||||
<span className="col-lrow-cat" style={{ ['--cat' as string]: place.category?.color || '#6366f1' }}>
|
||||
{createElement(getCategoryIcon(place.category?.icon ?? undefined), { size: 11 })}{' '}
|
||||
<span className="col-lrow-txt">{categoryName}</span>
|
||||
</span>
|
||||
</ChipTooltip>
|
||||
)}
|
||||
{labels.map((l, i) => (
|
||||
<ChipTooltip key={l.id} label={l.name}>
|
||||
<span
|
||||
className={i < shown ? 'col-lrow-label' : 'col-lrow-label is-over'}
|
||||
style={{ ['--label' as string]: l.color || 'var(--accent)' }}
|
||||
aria-hidden={i < shown ? undefined : true}
|
||||
>
|
||||
<span className="col-labelchip-dot" /> <span className="col-lrow-txt">{l.name}</span>
|
||||
</span>
|
||||
</ChipTooltip>
|
||||
))}
|
||||
{hidden.length > 0 && (
|
||||
<Tooltip label={hidden.map((l) => l.name).join(', ')} placement="top">
|
||||
<span className="col-lrow-more">+{hidden.length}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="col-lrow-div" aria-hidden />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
const phone = useIsPhone()
|
||||
const labelId = useId()
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
from: formatDateTime(new Date(suggestion.startedAt)),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useMemo, useState } from 'react';
|
||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { frameLine, frameLng } from '../../utils/lngFrame';
|
||||
import { useLngFrame } from '../Map/lngFrameContext';
|
||||
import { type FerrataGroup, ferrataLabel } from './ferrataGroups';
|
||||
import { FERRATA_ICON_MIN_ZOOM, FERRATA_ICON_SIZE, ferrataIconSvg, placeFerrataIcons } from './ferrataIcon';
|
||||
import { ferrataPositions } from './ferrataModel';
|
||||
@@ -81,6 +83,8 @@ export default function FerrataLayerLeaflet({
|
||||
selectedId?: string | null;
|
||||
}) {
|
||||
const paint = useFerrataPaint();
|
||||
// Drawn in the map's longitude frame, with the trip (#2975).
|
||||
const frame = useLngFrame();
|
||||
const iconGroups = useIconGroups(groups);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
// Rebuilt per set of ferratas and per theme: pathOptions only reach Leaflet when their reference changes.
|
||||
@@ -110,7 +114,7 @@ export default function FerrataLayerLeaflet({
|
||||
group.segments.map((segment) => ({
|
||||
group,
|
||||
segment,
|
||||
positions: ferrataPositions(segment),
|
||||
positions: frameLine(frame, ferrataPositions(segment)),
|
||||
casing,
|
||||
litCasing,
|
||||
line,
|
||||
@@ -118,7 +122,7 @@ export default function FerrataLayerLeaflet({
|
||||
hit,
|
||||
}))
|
||||
);
|
||||
}, [groups, paint]);
|
||||
}, [groups, paint, frame]);
|
||||
|
||||
if (lines.length === 0) return null;
|
||||
const lit = (group: FerrataGroup) => group.id === hovered || group.id === selectedId;
|
||||
@@ -163,7 +167,7 @@ export default function FerrataLayerLeaflet({
|
||||
{iconGroups.map((group) => (
|
||||
<Marker
|
||||
key={`${group.id}-icon`}
|
||||
position={group.midpoint}
|
||||
position={frame ? [group.midpoint[0], frameLng(frame, group.midpoint[1])] : group.midpoint}
|
||||
icon={ICON}
|
||||
keyboard={false}
|
||||
eventHandlers={{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
import { readDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
if (!mimeType) return false
|
||||
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
|
||||
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
|
||||
|
||||
/** "2h ago", or "never" when a binding has not run yet. */
|
||||
export function LastRun({ at }: { at: string | null }) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
|
||||
const ms = Date.parse(at)
|
||||
if (Number.isNaN(ms)) return null
|
||||
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
|
||||
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
const openLightbox = useHelpStore(s => s.openLightbox)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
// No Help & Docs page on a managed install (App.tsx), so no link into it.
|
||||
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const title = t(guideKey(guide.id, 'title'))
|
||||
const screen = getHelpContext(guide.context)
|
||||
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
)}
|
||||
|
||||
{/* Go there / docs / related */}
|
||||
{(guide.link || guide.docs || related.length > 0) && (
|
||||
{(guide.link || docs || related.length > 0) && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(guide.link || guide.docs) && (
|
||||
{(guide.link || docs) && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
{guide.link && (
|
||||
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{guide.docs && (
|
||||
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
|
||||
{docs && (
|
||||
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
|
||||
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
// No Help & Docs page on a managed install (App.tsx), so no links into it.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const screenRoute = useScreenRoute(context?.route)
|
||||
|
||||
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
|
||||
const guides = guidesFor(context)
|
||||
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
|
||||
const children = childHelpContexts(context.id)
|
||||
const docs = onePerPage(context.docs)
|
||||
const docs = managed ? [] : onePerPage(context.docs)
|
||||
const trail = helpContextTrail(context).slice(0, -1)
|
||||
const Icon = helpIcon(context.icon)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { GuideCard, SectionLabel } from './HelpHome'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
/** Guides shown for a query, at most. The docs list is capped by the server. */
|
||||
const MAX_GUIDES = 8
|
||||
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const status = useHelpStore(s => s.searchStatus)
|
||||
// No Help & Docs page on a managed install (App.tsx): guides only.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
const docHits = useHelpStore(s => s.docHits)
|
||||
const q = query.trim()
|
||||
|
||||
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
.map(x => x.g)
|
||||
}, [q, t])
|
||||
|
||||
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
|
||||
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-8">
|
||||
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!managed && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
|
||||
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{nothing && (
|
||||
<div className="flex flex-col items-center text-center gap-2 py-10">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
|
||||
import { seedStore } from '../../../tests/helpers/store'
|
||||
import { buildSettings } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
|
||||
@@ -102,6 +103,8 @@ beforeEach(() => {
|
||||
// The time field reads time_format, so pin it: without this the cases that
|
||||
// assert a 24h string depend on whichever test ran before them (#2067).
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
|
||||
// A photo provider switched on for the install, so the External photos source is offered.
|
||||
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
|
||||
window.__addToast = toastSpy
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
|
||||
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
|
||||
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
|
||||
server.use(http.get('/api/addons', () => HttpResponse.json({})))
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
|
||||
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
|
||||
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
|
||||
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
|
||||
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
|
||||
const {
|
||||
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
|
||||
locationName,
|
||||
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
aria-pressed={photoTab === 'external'}
|
||||
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
|
||||
</button>
|
||||
{/* Only where the install has a photo provider (Immich, Synology) switched
|
||||
on, as on the phone: a source that can only say "nothing connected"
|
||||
is not worth its width, and the other two share the row instead. */}
|
||||
{photoProvidersOn && (
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
aria-pressed={photoTab === 'external'}
|
||||
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The gallery picker, directly below the buttons. Safari collapses
|
||||
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{photoTab === 'external' && (
|
||||
{photoTab === 'external' && photoProvidersOn && (
|
||||
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { journeyOpenAtEntryId } from './journeyOpenAt';
|
||||
|
||||
const entries = [
|
||||
{ id: 3, entry_date: '2026-05-03' },
|
||||
{ id: 1, entry_date: '2026-05-01' },
|
||||
{ id: 2, entry_date: '2026-05-01' },
|
||||
{ id: 5, entry_date: '2026-05-05' },
|
||||
];
|
||||
|
||||
describe('journeyOpenAtEntryId', () => {
|
||||
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
|
||||
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
interface DatedEntry {
|
||||
id: number | string;
|
||||
entry_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a journey opens on, or null for its first one.
|
||||
*
|
||||
* Every load used to start at the first entry, so keeping a journal on a trip
|
||||
* meant scrolling past everything already written before you could add to it
|
||||
* (discussion #2299). "Today" means the last day at or before now: a rest day
|
||||
* with nothing written still lands you at yesterday rather than at the start.
|
||||
*
|
||||
* Only while the journey is running, though. A journey that ended last spring
|
||||
* is something you read, and reading starts at the beginning; opening it at the
|
||||
* last page would be the worse of the two mistakes.
|
||||
*
|
||||
* A link the owner published newest-first reads like a blog (#1614), so it
|
||||
* opens on the latest day whether or not the trip is still running (#2851).
|
||||
*
|
||||
* Always the first entry OF the chosen day, not the last one before now: a day
|
||||
* is read from its beginning.
|
||||
*/
|
||||
export function journeyOpenAtEntryId(
|
||||
entries: readonly DatedEntry[],
|
||||
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
|
||||
): string | null {
|
||||
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
|
||||
const first = dated[0];
|
||||
const last = dated[dated.length - 1];
|
||||
if (!first || !last) return null;
|
||||
let day = last.entry_date;
|
||||
if (!newestFirst) {
|
||||
if (today < first.entry_date || today > last.entry_date) return null;
|
||||
const reached = dated.filter((e) => e.entry_date <= today);
|
||||
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
|
||||
}
|
||||
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
|
||||
return String(dayStart.id);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
|
||||
act(() => result.current.openEntryById('99'));
|
||||
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
|
||||
const carousel = document.createElement('div');
|
||||
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
|
||||
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
|
||||
const hook = renderHook(
|
||||
(p: Options) => {
|
||||
const sync = useJourneyCarouselSync(p);
|
||||
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
|
||||
return sync;
|
||||
},
|
||||
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
|
||||
);
|
||||
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
|
||||
// Card 1 sits at the centre (150) in both positions: only the ends decide.
|
||||
[0, 100, 200].forEach((left, i) => {
|
||||
const node = document.createElement('div');
|
||||
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
|
||||
hook.result.current.cardRefs.current.set(i, node);
|
||||
});
|
||||
const settleAt = (scrollLeft: number) => {
|
||||
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
};
|
||||
|
||||
settleAt(300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(150);
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
// Right to left: the scroll position runs negative from the start.
|
||||
settleAt(-300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(0);
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
// The first and the last card can never reach the centre, so a carousel scrolled
|
||||
// as far as it goes means the card at that end: without this, opening on the
|
||||
// latest day settled on the one before it (#2851). Measured from the start in
|
||||
// either direction: in a right-to-left layout scrollLeft runs negative.
|
||||
const travel = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
|
||||
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== bestIdx) syncMapToCard(bestIdx);
|
||||
return bestIdx;
|
||||
|
||||
@@ -87,6 +87,16 @@ describe('Navbar', () => {
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
|
||||
render(<Navbar />);
|
||||
expect(document.querySelector('[data-help-button]')).not.toBeNull();
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.queryByText('Help')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
|
||||
@@ -35,7 +35,7 @@ interface Addon {
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
|
||||
{!managed && (
|
||||
<Link to="/help" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('nav.help')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import L from 'leaflet';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import { MAX_ZOOM_RASTER, type ViewportPadding } from '../../utils/mapViewport';
|
||||
import { ASSISTANT_CAMERA_MS, centreOf, coverPadding, useCameraRequest, useUserMoveClock } from './assistantCamera';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import type { AssistantCamera } from './mapViewContract';
|
||||
|
||||
interface AssistantCameraControllerProps {
|
||||
@@ -18,12 +20,17 @@ interface AssistantCameraControllerProps {
|
||||
*/
|
||||
export function AssistantCameraController({ camera, padding }: AssistantCameraControllerProps) {
|
||||
const map = useMap();
|
||||
// The points are framed the way the map draws them (#2975).
|
||||
const frame = useLngFrame();
|
||||
const getContainer = useCallback(() => map.getContainer(), [map]);
|
||||
const userMovedAt = useUserMoveClock(getContainer, true);
|
||||
const request = useCameraRequest(camera, userMovedAt);
|
||||
|
||||
useEffect(() => {
|
||||
request((next) => {
|
||||
request((camera) => {
|
||||
const next = frame
|
||||
? { ...camera, points: camera.points.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) }
|
||||
: camera;
|
||||
const duration = ASSISTANT_CAMERA_MS / 1000;
|
||||
if (next.mode === 'pan') {
|
||||
const centre = centreOf(next.points);
|
||||
@@ -39,7 +46,7 @@ export function AssistantCameraController({ camera, padding }: AssistantCameraCo
|
||||
duration,
|
||||
});
|
||||
});
|
||||
}, [map, padding, request]);
|
||||
}, [map, padding, request, frame]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import { GHOST_SIZE, mountGhosts, setGhostHot, type GhostHandlers } from './assistantGhostMarker';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import type { AssistantGhost } from './mapViewContract';
|
||||
|
||||
interface AssistantGhostLayerProps {
|
||||
@@ -22,6 +24,8 @@ const GHOST_Z_OFFSET = 900;
|
||||
*/
|
||||
export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: AssistantGhostLayerProps) {
|
||||
const map = useMap();
|
||||
// Pinned where the map draws the trip, in its longitude frame (#2975).
|
||||
const frame = useLngFrame();
|
||||
const handlers = useRef<GhostHandlers>({ onHover, onClick });
|
||||
useLayoutEffect(() => {
|
||||
handlers.current = { onHover, onClick };
|
||||
@@ -36,7 +40,11 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
|
||||
const icon = L.divIcon({ html: el, className: 'trek-ghost-pin', iconSize: [GHOST_SIZE, GHOST_SIZE] });
|
||||
const label = document.createElement('span');
|
||||
label.textContent = ghost.name;
|
||||
const marker = L.marker([ghost.lat, ghost.lng], { icon, keyboard: false, zIndexOffset: GHOST_Z_OFFSET })
|
||||
const marker = L.marker([ghost.lat, frameLng(frame, ghost.lng)], {
|
||||
icon,
|
||||
keyboard: false,
|
||||
zIndexOffset: GHOST_Z_OFFSET,
|
||||
})
|
||||
.bindTooltip(label, { direction: 'top', offset: [0, -GHOST_SIZE / 2], className: 'map-tooltip' })
|
||||
.addTo(map);
|
||||
return { remove: () => marker.remove() };
|
||||
@@ -48,7 +56,7 @@ export function AssistantGhostLayer({ ghosts, hoveredId, onHover, onClick }: Ass
|
||||
mounted.dispose();
|
||||
elements.current = new Map();
|
||||
};
|
||||
}, [ghosts, map]);
|
||||
}, [ghosts, map, frame]);
|
||||
|
||||
useEffect(() => {
|
||||
setGhostHot(elements.current, hoveredId);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
|
||||
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
|
||||
const CASING_WEIGHT = 7;
|
||||
@@ -38,7 +41,10 @@ function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & Li
|
||||
if (line.hot) group.current?.bringToFront();
|
||||
}, [line.hot]);
|
||||
const opacity = line.dim ? DIM_OPACITY : 1;
|
||||
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
|
||||
const point = (event: LeafletMouseEvent) => {
|
||||
const at = onRealWorld(event.latlng);
|
||||
onPoint?.(line.ref, at.lat, at.lng);
|
||||
};
|
||||
return (
|
||||
<FeatureGroup ref={group}>
|
||||
<Polyline
|
||||
@@ -122,11 +128,25 @@ interface AssistantGhostLineLayerProps extends LineHandlers {
|
||||
* under its pins. The twin of
|
||||
* useAssistantGhostLinesGL.
|
||||
*/
|
||||
/** A line drawn in the map's longitude frame, with the trip it proposes changes to (#2975). */
|
||||
function inFrame(frame: LngFrame, line: AssistantRouteLine): AssistantRouteLine {
|
||||
const at = ([lat, lng]: [number, number]): [number, number] => [lat, frameLng(frame, lng)];
|
||||
return {
|
||||
...line,
|
||||
lines: line.lines.map((part) => frameLine(frame, part)),
|
||||
ends: line.ends && { start: at(line.ends.start), end: line.ends.end && at(line.ends.end) },
|
||||
stops: line.stops?.map((stop) => ({ ...stop, lng: frameLng(frame, stop.lng) })),
|
||||
tints: line.tints?.map((tint) => ({ ...tint, line: frameLine(frame, tint.line) })),
|
||||
};
|
||||
}
|
||||
|
||||
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
|
||||
if (!lines?.length) return null;
|
||||
const frame = useLngFrame();
|
||||
const drawn = useMemo(() => (frame && lines ? lines.map((line) => inFrame(frame, line)) : lines), [frame, lines]);
|
||||
if (!drawn?.length) return null;
|
||||
return (
|
||||
<>
|
||||
{lines.map((line) => (
|
||||
{drawn.map((line) => (
|
||||
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -4,11 +4,14 @@ import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { Poi } from './poiCategories'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
|
||||
export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
pois: Poi[]; enabled: boolean; onPoiClick?: (poi: Poi) => void; children: ReactNode[]
|
||||
}) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
const { t } = useTranslation()
|
||||
const [zoom, setZoom] = useState(() => map.getZoom())
|
||||
const popup = useRef<L.Popup | null>(null)
|
||||
@@ -18,20 +21,21 @@ export default function ClusteredPois({ pois, enabled, onPoiClick, children }: {
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, poi.lng]), zoom) : [], [pois, enabled, map, zoom])
|
||||
// Measured and placed where the map draws them, in its longitude frame (#2975).
|
||||
const groups = useMemo(() => enabled ? clusterPois(pois, poi => map.latLngToContainerPoint([poi.lat, frameLng(frame, poi.lng)]), zoom) : [], [pois, enabled, map, zoom, frame])
|
||||
const markers = new Map(pois.map((poi, index) => [poi.osm_id, children[index]]))
|
||||
if (!enabled) return <>{children}</>
|
||||
return <>{groups.map(group => {
|
||||
if (group.pois.length === 1) return markers.get(group.pois[0].osm_id)
|
||||
const label = t('roadtrip.poi.found', { count: group.pois.length })
|
||||
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, group.lng]} alt={label}
|
||||
return <Marker key={`poi-cluster-${group.pois[0].osm_id}`} position={[group.lat, frameLng(frame, group.lng)]} alt={label}
|
||||
icon={L.divIcon({ className: '', html: poiClusterMarkup(group.pois.length), iconSize: [38, 38], iconAnchor: [19, 19] })}
|
||||
zIndexOffset={600} eventHandlers={{ click: () => {
|
||||
if (map.getZoom() >= POI_CLUSTER_DETAIL_ZOOM) {
|
||||
popup.current?.remove()
|
||||
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, group.lng])
|
||||
popup.current = L.popup({ className: 'map-tooltip', maxWidth: 300 }).setLatLng([group.lat, frameLng(frame, group.lng)])
|
||||
.setContent(poiClusterList(group.pois, poi => { popup.current?.remove(); onPoiClick?.(poi) })).openOn(map)
|
||||
} else map.fitBounds(group.pois.map(poi => [poi.lat, poi.lng] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
} else map.fitBounds(group.pois.map(poi => [poi.lat, frameLng(frame, poi.lng)] as [number, number]), { padding: [70, 70], maxZoom: POI_CLUSTER_DETAIL_ZOOM })
|
||||
} }} />
|
||||
})}</>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Polyline } from 'react-leaflet'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLine } from '../../utils/lngFrame'
|
||||
import type { DawarichTrack } from '@trek/shared'
|
||||
import {
|
||||
DAWARICH_TRAIL_CASING,
|
||||
@@ -37,7 +39,10 @@ export default function DawarichTrailLayer({
|
||||
/** The `trek-track-casing` pane, when the renderer supports panes. */
|
||||
casingPane?: string
|
||||
}) {
|
||||
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates), [track, selectedDate, hiddenDates])
|
||||
const frame = useLngFrame()
|
||||
// Drawn in the map's longitude frame, with the trip it was recorded on (#2975).
|
||||
const segments = useMemo(() => trailSegments(track, selectedDate, hiddenDates)
|
||||
.map(segment => (frame ? { ...segment, points: frameLine(frame, segment.points) } : segment)), [track, selectedDate, hiddenDates, frame])
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,10 +4,14 @@ import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
import { hazardPopup, hazardFeature } from './hazardPopup'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
|
||||
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
|
||||
const { t } = useTranslation()
|
||||
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
|
||||
const frame = useLngFrame()
|
||||
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}-${frame?.seam ?? ''}`} data={hazardFeature(hazard)}
|
||||
coordsToLatLng={coords => L.latLng(coords[1], frameLng(frame, coords[0]))}
|
||||
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
|
||||
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
|
||||
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip trek-hazard-popup', maxWidth: 320 })}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Circle, Polygon, Polyline, Tooltip, useMap } from 'react-leaflet'
|
||||
import { pluginsApi, type PluginMapLayer, type PluginMapLayerFeature } from '../../api/client'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLine, frameLng } from '../../utils/lngFrame'
|
||||
|
||||
/**
|
||||
* Host-rendered overlay for the `mapLayerProvider` plugin hook. A plugin returns
|
||||
@@ -47,6 +49,7 @@ function pathOptions(f: PluginMapLayerFeature, pane: string | undefined) {
|
||||
|
||||
export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
const [layers, setLayers] = useState<PluginMapLayer[]>([])
|
||||
// Whether our dedicated below-overlay pane exists. Pane support is a Leaflet
|
||||
// optimization — if the map has no pane API (e.g. a minimal renderer), the
|
||||
@@ -73,6 +76,7 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
|
||||
|
||||
if (layers.length === 0) return null
|
||||
const pane = hasPane ? PANE : undefined
|
||||
// Drawn in the map's longitude frame, with the trip (#2975).
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -80,13 +84,13 @@ export function PluginMapLayers({ tripId }: { tripId?: number | string }) {
|
||||
const key = `${layer.pluginId}:${layer.id}:${i}`
|
||||
const tooltip = f.label ? <Tooltip sticky>{f.label}</Tooltip> : null
|
||||
if (f.type === 'polyline' && f.points) {
|
||||
return <Polyline key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
|
||||
return <Polyline key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polyline>
|
||||
}
|
||||
if (f.type === 'polygon' && f.points) {
|
||||
return <Polygon key={key} positions={f.points} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
|
||||
return <Polygon key={key} positions={frameLine(frame, f.points)} pathOptions={pathOptions(f, pane)}>{tooltip}</Polygon>
|
||||
}
|
||||
if (f.type === 'circle' && f.center && f.radiusM) {
|
||||
return <Circle key={key} center={f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
|
||||
return <Circle key={key} center={frame ? [f.center[0], frameLng(frame, f.center[1])] : f.center} radius={f.radiusM} pathOptions={pathOptions(f, pane)}>{tooltip}</Circle>
|
||||
}
|
||||
return null
|
||||
}))}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { pluginsApi, type PluginMapMarker } from '../../api/client'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
|
||||
/**
|
||||
* Host-rendered overlay for the `mapMarkerProvider` plugin hook (#587). A plugin
|
||||
@@ -33,6 +35,8 @@ function markerIcon(tone: PluginMapMarker['tone']): L.DivIcon {
|
||||
|
||||
export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
|
||||
const [markers, setMarkers] = useState<PluginMapMarker[]>([])
|
||||
// Drawn in the map's longitude frame, with the trip (#2975).
|
||||
const frame = useLngFrame()
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId == null) { setMarkers([]); return }
|
||||
@@ -54,7 +58,7 @@ export function PluginMapMarkers({ tripId }: { tripId?: number | string }) {
|
||||
return (
|
||||
<>
|
||||
{markers.map(mk => (
|
||||
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, mk.lng]} icon={icons.get(mk.tone)!}>
|
||||
<Marker key={`${mk.pluginId}:${mk.id}`} position={[mk.lat, frameLng(frame, mk.lng)]} icon={icons.get(mk.tone)!}>
|
||||
{(mk.label || mk.popupText || mk.url) && (
|
||||
<Popup>
|
||||
<div style={{ minWidth: 120, fontSize: 13 }}>
|
||||
|
||||
@@ -1185,9 +1185,14 @@ describe('MapView map event wiring', () => {
|
||||
it('FE-COMP-MAPVIEW-046: a map-click handler is attached and detached again', () => {
|
||||
const onMapClick = vi.fn()
|
||||
const { unmount } = render(<MapView onMapClick={onMapClick} />)
|
||||
expect(mapMock.on).toHaveBeenCalledWith('click', onMapClick)
|
||||
const bound = mapMock.on.mock.calls.find(c => c[0] === 'click')?.[1]
|
||||
expect(bound).toEqual(expect.any(Function))
|
||||
// A click on the real world reaches the caller as the very event the map fired.
|
||||
const event = { latlng: { lat: 48, lng: 2 } }
|
||||
bound(event)
|
||||
expect(onMapClick).toHaveBeenCalledWith(event)
|
||||
unmount()
|
||||
expect(mapMock.off).toHaveBeenCalledWith('click', onMapClick)
|
||||
expect(mapMock.off).toHaveBeenCalledWith('click', bound)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-047: without a click handler nothing is bound', () => {
|
||||
@@ -1198,9 +1203,13 @@ describe('MapView map event wiring', () => {
|
||||
it('FE-COMP-MAPVIEW-048: a context-menu handler is attached and detached again', () => {
|
||||
const onMapContextMenu = vi.fn()
|
||||
const { unmount } = render(<MapView onMapContextMenu={onMapContextMenu} />)
|
||||
expect(mapMock.on).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
|
||||
const bound = mapMock.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1]
|
||||
expect(bound).toEqual(expect.any(Function))
|
||||
const event = { latlng: { lat: 48, lng: 2 } }
|
||||
bound(event)
|
||||
expect(onMapContextMenu).toHaveBeenCalledWith(event)
|
||||
unmount()
|
||||
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', onMapContextMenu)
|
||||
expect(mapMock.off).toHaveBeenCalledWith('contextmenu', bound)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-049: changing the center prop moves the camera there', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import DOM from 'react-dom'
|
||||
import { categoryIconSvg } from './categoryIconSvg'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
|
||||
import { CLUSTER_OPTIONS, createClusterIcon, revealInCluster, type ClusterGroupLike } from './markerCluster'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import PlannerWaypointMarker from './PlannerWaypointMarker'
|
||||
@@ -121,7 +121,8 @@ function RouteViaMarker({ via, controls, eventHandlers, children }: {
|
||||
via: RouteVia; controls?: DayBoundaryControls; eventHandlers?: L.LeafletEventHandlerFnMap; children?: React.ReactNode
|
||||
}) {
|
||||
const [marker, setMarker] = useState<L.Marker | null>(null)
|
||||
return <Marker ref={setMarker} position={[via.lat, via.lng]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
|
||||
const frame = useLngFrame()
|
||||
return <Marker ref={setMarker} position={[via.lat, frameLng(frame, via.lng)]} icon={via.nightPause ? nightPauseIcon(via) : routeViaIcon(via.tone)}
|
||||
alt={via.nightPause ? via.label : undefined} zIndexOffset={800} eventHandlers={eventHandlers}>
|
||||
{children}
|
||||
{via.nightPause && <NightPauseDrag marker={marker} via={via} controls={controls} />}
|
||||
@@ -298,59 +299,6 @@ function CameraHoverGuard({ movingRef, onMoveStart, onZoom }: { movingRef: { cur
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onViewportChange) return
|
||||
const emit = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }, map.getZoom())
|
||||
}
|
||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||
map.on('moveend', emit)
|
||||
map.on('zoomend', emit)
|
||||
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
|
||||
}, [map, onViewportChange])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapCameraSnapshot({ onChange }: { onChange: (center: [number, number], zoom: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
@@ -377,6 +325,7 @@ interface SelectionControllerProps {
|
||||
|
||||
function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selectedPlace, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
const prev = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -387,7 +336,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
|
||||
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
||||
const selected = selectedPlaceTarget(selectedPlaceId, places, dayPlaces, selectedPlace)
|
||||
if (selected?.lat != null && selected?.lng != null) {
|
||||
const latlng: [number, number] = [selected.lat, selected.lng]
|
||||
const latlng: [number, number] = [selected.lat, frameLng(frame, selected.lng)]
|
||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
||||
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
||||
@@ -399,7 +348,7 @@ function SelectionController({ follow, places, selectedPlaceId, dayPlaces, selec
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow])
|
||||
}, [selectedPlaceId, places, dayPlaces, selectedPlace, map, follow, frame])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -411,14 +360,15 @@ interface MapControllerProps {
|
||||
|
||||
function MapController({ center, zoom }: MapControllerProps) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
const prevCenter = useRef(center)
|
||||
|
||||
useEffect(() => {
|
||||
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
|
||||
map.setView(center, zoom)
|
||||
map.setView([center[0], frameLng(frame, center[1])], zoom)
|
||||
prevCenter.current = center
|
||||
}
|
||||
}, [center, zoom, map])
|
||||
}, [center, zoom, map, frame])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -456,10 +406,12 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
const prevFocusKey = useRef(focusKey)
|
||||
const built = useRef(false)
|
||||
|
||||
const frame = useLngFrame()
|
||||
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
|
||||
if (coords.length === 0) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(coords)
|
||||
// In the map's longitude frame, so a trip across the Pacific is framed across the Pacific (#2975).
|
||||
const bounds = L.latLngBounds(frame ? coords.map(([lat, lng]) => [lat, frameLng(frame, lng)] as [number, number]) : coords)
|
||||
if (bounds.isValid()) {
|
||||
/*
|
||||
* The padding already reserves the day panel's height at the bottom
|
||||
@@ -478,7 +430,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
|
||||
}
|
||||
} catch {}
|
||||
}, [map, paddingOpts])
|
||||
}, [map, paddingOpts, frame])
|
||||
|
||||
// New fitKey (initial trip fit or a day selection): fit to the destinations now
|
||||
// and arm a one-shot re-fit for when the route arrives.
|
||||
@@ -535,10 +487,6 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
return null
|
||||
}
|
||||
|
||||
interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
const TRACK_CASING_PANE = 'trek-track-casing'
|
||||
|
||||
/**
|
||||
@@ -561,26 +509,6 @@ function TrackCasingPane({ onReady }: { onReady: (ready: boolean) => void }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
map.on('click', onClick)
|
||||
return () => { map.off('click', onClick) }
|
||||
}, [map, onClick])
|
||||
return null
|
||||
}
|
||||
|
||||
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onContextMenu) return
|
||||
map.on('contextmenu', onContextMenu)
|
||||
return () => { map.off('contextmenu', onContextMenu) }
|
||||
}, [map, onContextMenu])
|
||||
return null
|
||||
}
|
||||
|
||||
// Travel times are shown in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
import { useMarkerThumbs } from './useMarkerThumbs'
|
||||
@@ -594,9 +522,15 @@ import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
// identically. Heading is shown as a rotated conic SVG when available.
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import { LngFrameProvider, useLngFrame, useTripLngFrame } from './lngFrameContext'
|
||||
import { frameLine, frameLng } from '../../utils/lngFrame'
|
||||
import { LngFrameFollower, MapClickHandler, MapContextMenuHandler, PoiDropTarget, ViewportController } from './leafletMapBridges'
|
||||
|
||||
function LeafletLocationLayer({ position, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||
function LeafletLocationLayer({ position: fix, mode }: { position: GeoPosition | null; mode: TrackingMode }) {
|
||||
const map = useMap()
|
||||
const frame = useLngFrame()
|
||||
// Drawn on the trip's world copy (#2975); every field but the longitude is the fix itself.
|
||||
const position = useMemo(() => (fix && frame ? { ...fix, lng: frameLng(frame, fix.lng) } : fix), [fix, frame])
|
||||
|
||||
// When the user is in follow mode, keep the map centred on the dot.
|
||||
// setView (no animation) is what Google Maps does during navigation —
|
||||
@@ -711,7 +645,8 @@ const MemoMarker = memo(function MemoMarker({
|
||||
// differs, and the cluster group answers a moved child by taking it out and putting
|
||||
// it back — which collapses an open fan. A fresh array literal here did that on
|
||||
// every render the marker took part in.
|
||||
const position = useMemo<[number, number]>(() => [place.lat, place.lng], [place.lat, place.lng])
|
||||
const frame = useLngFrame()
|
||||
const position = useMemo<[number, number]>(() => [place.lat, frameLng(frame, place.lng)], [place.lat, place.lng, frame])
|
||||
const register = useCallback((marker: unknown) => { onRegister(place.id, marker) }, [onRegister, place.id])
|
||||
return (
|
||||
<Marker
|
||||
@@ -835,11 +770,15 @@ export const MapView = memo(function MapView({
|
||||
*/
|
||||
const isGcjBasemap = !tourRaster && basemap.kind === 'raster' && isGcj02Basemap(basemap.url)
|
||||
const gcjCrs = useMemo(() => crsForBasemap(isGcjBasemap), [isGcjBasemap])
|
||||
// Leaflet draws a layer on one world copy only, so a trip across the antimeridian is
|
||||
// drawn in a longitude frame that keeps it on one copy (#2975). Null for every other
|
||||
// trip, and then each layer below draws exactly what it always drew.
|
||||
const lngFrame = useTripLngFrame(places, dayPlaces, reservations)
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
alt={poi.name}
|
||||
position={[poi.lat, poi.lng]}
|
||||
position={[poi.lat, frameLng(lngFrame, poi.lng)]}
|
||||
icon={createPoiIcon(poi, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{
|
||||
@@ -858,7 +797,7 @@ export const MapView = memo(function MapView({
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip"><PoiTooltipBody poi={poi} /></Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute, lngFrame])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days, scopeConnectionsToDay])
|
||||
@@ -892,8 +831,11 @@ export const MapView = memo(function MapView({
|
||||
const framed = computeMapViewport(dayPlaces.length > 0 ? dayPlaces : places, {
|
||||
tileSize: TILE_SIZE_RASTER,
|
||||
padding: paddingBox,
|
||||
// With a longitude frame the trip lies on one copy, so it can be framed the short way.
|
||||
wrapsWorld: lngFrame !== null,
|
||||
})
|
||||
return { center: framed?.center ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
|
||||
const opening: [number, number] | undefined = framed ? [framed.center[0], frameLng(lngFrame, framed.center[1])] : undefined
|
||||
return { center: opening ?? center, zoom: framed?.zoom ?? zoom, framed: framed !== null }
|
||||
})
|
||||
const cameraSnapshotRef = useRef<{ center: [number, number]; zoom: number } | null>(null)
|
||||
const saveCameraSnapshot = useCallback((cameraCenter: [number, number], cameraZoom: number) => {
|
||||
@@ -947,7 +889,8 @@ export const MapView = memo(function MapView({
|
||||
|
||||
// Flattened [lat,lng] points of the selected day's route, so the bounds fit can
|
||||
// include the full polyline once it has been computed.
|
||||
const routeCoords = useMemo<[number, number][]>(() => (route || []).flat() as [number, number][], [route])
|
||||
const framedRoute = useMemo(() => (lngFrame && route ? route.map(seg => frameLine(lngFrame, seg)) : route), [route, lngFrame])
|
||||
const routeCoords = useMemo<[number, number][]>(() => (framedRoute || []).flat() as [number, number][], [framedRoute])
|
||||
const photoUrls = useMarkerThumbs(places)
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
@@ -1015,9 +958,9 @@ export const MapView = memo(function MapView({
|
||||
const gpxTracks = useMemo(() => places.flatMap(place => {
|
||||
const geometry = parseRenderableRouteGeometry(place.route_geometry)
|
||||
if (!geometry) return []
|
||||
const coords: [number, number][] = geometry.map(([lat, lng]) => [lat, lng])
|
||||
const coords: [number, number][] = frameLine(lngFrame, geometry.map(([lat, lng]) => [lat, lng] as [number, number]))
|
||||
return [{ place, coords, cased: hasManualTrackColor(place), color: resolveTrackColor(place) }]
|
||||
}), [places])
|
||||
}), [places, lngFrame])
|
||||
|
||||
// Keeps the click handler out of the track lines' memo (TrackLines): `handleMarkerClick`
|
||||
// changes on every selection, and depending on it would redraw all tracks.
|
||||
@@ -1053,6 +996,7 @@ export const MapView = memo(function MapView({
|
||||
return (
|
||||
<>
|
||||
<div className="w-full h-full relative">
|
||||
<LngFrameProvider value={lngFrame}>
|
||||
<MapContainer
|
||||
// The datum is in the element's identity, because react-leaflet builds the
|
||||
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
|
||||
@@ -1125,6 +1069,8 @@ export const MapView = memo(function MapView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* First, so a fit or a camera request in the same commit starts from the trip's world copy. */}
|
||||
<LngFrameFollower />
|
||||
<MapController center={center} zoom={zoom} />
|
||||
{hasViewBaseLayer && <MapCameraSnapshot onChange={saveCameraSnapshot} />}
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} focusKey={focusKey} fitPadding={fitPadding} follow={followSelection} />
|
||||
@@ -1142,7 +1088,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
When the route can be reshaped an invisible band wider than both takes the click. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
{framedRoute && framedRoute.length > 0 && framedRoute.flatMap((seg, i) => seg.length < 2 ? [] : routeWalking?.[i] ? [
|
||||
// Walked, so dashed and without the casing: the drive is the solid line (#2532).
|
||||
<Polyline
|
||||
key={`${i}-walk`}
|
||||
@@ -1174,7 +1120,7 @@ export const MapView = memo(function MapView({
|
||||
{(accessLines ?? []).map((spur, i) => (
|
||||
<Polyline
|
||||
key={`access-${i}`}
|
||||
positions={spur.line}
|
||||
positions={frameLine(lngFrame, spur.line)}
|
||||
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
@@ -1190,13 +1136,13 @@ export const MapView = memo(function MapView({
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
positions={frameLine(lngFrame, alt.coordinates)}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
positions={frameLine(lngFrame, alt.coordinates)}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
@@ -1213,7 +1159,7 @@ export const MapView = memo(function MapView({
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
position={[alt.at.lat, frameLng(lngFrame, alt.at.lng)]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
@@ -1249,7 +1195,7 @@ export const MapView = memo(function MapView({
|
||||
|
||||
{routeProfileFocus && (
|
||||
<CircleMarker
|
||||
center={[routeProfileFocus.lat, routeProfileFocus.lng]}
|
||||
center={[routeProfileFocus.lat, frameLng(lngFrame, routeProfileFocus.lng)]}
|
||||
radius={8}
|
||||
pathOptions={{ className: 'tour-profile-focus-marker', color: 'var(--bg-card)', weight: 3, fillColor: 'var(--text-muted)', fillOpacity: 1 }}
|
||||
interactive={false}
|
||||
@@ -1301,6 +1247,7 @@ export const MapView = memo(function MapView({
|
||||
<AssistantMapLeaflet assistant={assistant} padding={paddingBox} markerEl={assistantMarkerEl} />
|
||||
<PluginMapLayers tripId={tripId} />
|
||||
</MapContainer>
|
||||
</LngFrameProvider>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable } from './markerDrag'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { usePlannerWaypointMarkersGL } from './usePlannerWaypointMarkersGL'
|
||||
import { carryAssistantMarks, useAssistantMapGL } from './useAssistantMapGL'
|
||||
@@ -49,6 +49,8 @@ import { buildPoiPopupHtml } from './placePopup'
|
||||
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
|
||||
import { onRealWorld } from '../../utils/worldCopy'
|
||||
import { bindPoiDropTarget } from './poiDropTarget'
|
||||
import { selectedPlaceTarget } from './selectedPlaceTarget'
|
||||
import {
|
||||
PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID, PLACE_CLUSTER_SOURCE_ID,
|
||||
@@ -725,7 +727,7 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
const at = onRealWorld(map.unproject([e.clientX - rect.left, e.clientY - rect.top]))
|
||||
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
}
|
||||
// The browser fires this when the captured element is torn out of the DOM,
|
||||
@@ -925,7 +927,8 @@ export function MapViewGL({
|
||||
// trip reloads for a gesture the user made once. The picker is a modal
|
||||
// choice about that leg; dropping a via mid-choice is not offered anywhere.
|
||||
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
|
||||
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
||||
const at = onRealWorld(e.lngLat)
|
||||
onRouteClick(at.lat, at.lng)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
@@ -944,35 +947,12 @@ export function MapViewGL({
|
||||
}
|
||||
}, [mapReady, onRouteClick])
|
||||
|
||||
/**
|
||||
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
||||
* much once it is projected onto the routed geometry, and it works the same in all
|
||||
* three renderers.
|
||||
*/
|
||||
// A POI dropped on the map lands on the drive (see poiDropTarget.ts).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const container = map?.getContainer?.()
|
||||
if (!container || !mapReady || !onPoiDropOnRoute) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
return bindPoiDropTarget(container, (x, y) => map.unproject([x, y]), onPoiDropOnRoute)
|
||||
}, [mapReady, onPoiDropOnRoute])
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
@@ -1343,7 +1323,7 @@ export function MapViewGL({
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
if (isTourLayerHit(map, e.point)) return // a picked via ferrata or paddle obstacle opens its card instead
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
onClickRefs.current.map?.({ latlng: onRealWorld({ lat: e.lngLat.lat, lng: e.lngLat.lng }) })
|
||||
})
|
||||
// Emit the viewport (pan/zoom + once on first idle) for the POI-explore pill.
|
||||
const emitViewport = () => reportGlViewport(map, onViewportChangeRef.current)
|
||||
@@ -1376,7 +1356,7 @@ export function MapViewGL({
|
||||
// timer — dedupe so the form doesn't open twice.
|
||||
if (Date.now() - lastContextFire < 700) return false
|
||||
lastContextFire = Date.now()
|
||||
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
|
||||
onClickRefs.current.context?.({ latlng: onRealWorld({ lat: lngLat.lat, lng: lngLat.lng }), originalEvent })
|
||||
return true
|
||||
}
|
||||
// MapLibre swallows the map contextmenu at the end of a right-button
|
||||
@@ -1982,7 +1962,7 @@ export function MapViewGL({
|
||||
const fitPoints = routeCoords.length > 0 ? [...routeCoords, ...markerPoints] : markerPoints
|
||||
if (fitPoints.length === 0) return
|
||||
const bounds = new gl.LngLatBounds()
|
||||
fitPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
alongShortestArc(fitPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
let fitted = false
|
||||
const run = () => {
|
||||
try {
|
||||
|
||||
@@ -3,25 +3,29 @@ import { useMap } from 'react-leaflet'
|
||||
import type L from 'leaflet'
|
||||
import type { RouteVia } from '../../types'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
|
||||
export function NightPauseDrag({ marker, via, controls }: { marker: L.Marker | null; via: RouteVia; controls?: DayBoundaryControls }) {
|
||||
const map = useMap()
|
||||
// The route is drawn in the map's longitude frame (#2975), so its points are measured there too.
|
||||
const frame = useLngFrame()
|
||||
useEffect(() => {
|
||||
const el = marker?.getElement()
|
||||
if (!marker || !el || !controls) return
|
||||
return bindDayBoundaryDrag(el, via, controls, {
|
||||
project: (lat, lng) => {
|
||||
const point = map.latLngToContainerPoint([lat, lng])
|
||||
const point = map.latLngToContainerPoint([lat, frameLng(frame, lng)])
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
return { x: point.x + rect.left, y: point.y + rect.top }
|
||||
},
|
||||
setPosition: (lat, lng) => { marker.setLatLng([lat, lng]) },
|
||||
setPosition: (lat, lng) => { marker.setLatLng([lat, frameLng(frame, lng)]) },
|
||||
lock: () => {
|
||||
const enabled = map.dragging.enabled()
|
||||
map.dragging.disable()
|
||||
return () => { if (enabled) map.dragging.enable() }
|
||||
},
|
||||
})
|
||||
}, [map, marker, via, controls])
|
||||
}, [map, marker, via, controls, frame])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marker, Tooltip, useMap } from 'react-leaflet';
|
||||
import { frameLng } from '../../utils/lngFrame';
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import { MAP_LABEL_BADGE_CLASS } from './mapHoverLabel';
|
||||
import { plannerPinBadge, plannerPinShape, type PlannerPinPoint } from './plannerPinShape';
|
||||
import { wirePlannerWaypointDrag } from './plannerWaypointDrag';
|
||||
@@ -45,7 +48,11 @@ export default function PlannerWaypointMarker({
|
||||
useLayoutEffect(() => {
|
||||
handlersRef.current = { onClick, onMove };
|
||||
}, [onClick, onMove]);
|
||||
const position = useMemo(() => [point.lat, point.lng] as [number, number], [point.lat, point.lng]);
|
||||
const frame = useLngFrame();
|
||||
const position = useMemo(
|
||||
() => [point.lat, frameLng(frame, point.lng)] as [number, number],
|
||||
[point.lat, point.lng, frame]
|
||||
);
|
||||
const { label, shaping, stageEnd } = point;
|
||||
const icon = useMemo(
|
||||
() => plannerWaypointIcon(pinCss, { label, shaping, stageEnd }, index, selected, !!onMove),
|
||||
@@ -67,7 +74,7 @@ export default function PlannerWaypointMarker({
|
||||
if (!element) return;
|
||||
return wirePlannerWaypointDrag({
|
||||
element,
|
||||
initial: { lat: point.lat, lng: point.lng },
|
||||
initial: { lat: position[0], lng: position[1] },
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.containerPointToLatLng([clientX - rect.left, clientY - rect.top]);
|
||||
@@ -76,9 +83,13 @@ export default function PlannerWaypointMarker({
|
||||
setPosition: ({ lat, lng }) => {
|
||||
marker.setLatLng([lat, lng]);
|
||||
},
|
||||
onCommit: ({ lat, lng }) => handlersRef.current.onMove?.(point.id, lat, lng) ?? false,
|
||||
// Saved on the real world, whichever copy of it the pin was dragged on (#2976).
|
||||
onCommit: (at) => {
|
||||
const { lat, lng } = onRealWorld(at);
|
||||
return handlersRef.current.onMove?.(point.id, lat, lng) ?? false;
|
||||
},
|
||||
});
|
||||
}, [map, marker, onMove, point.id, point.lat, point.lng]);
|
||||
}, [map, marker, onMove, point.id, position]);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import { TRANSPORT_COLOR, TRANSPORT_META, transportHop, type TransportHop, type TransportType } from './transportHops'
|
||||
import type { Reservation } from '../../types'
|
||||
import { frameLine } from '../../utils/lngFrame'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
|
||||
@@ -48,11 +50,13 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
return road && road.length >= 2 ? [frameLine(item.frame, road)] : item.arcs
|
||||
}
|
||||
|
||||
interface TransportItem extends TransportHop {
|
||||
transitSegs: TransitMapSegment[]
|
||||
/** The map's longitude frame the hop was drawn in (#2975), for the road route that arrives later. */
|
||||
frame: ReturnType<typeof useLngFrame>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -75,16 +79,19 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
zoomend: () => setZoom(map.getZoom()),
|
||||
})
|
||||
const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) === true
|
||||
const frame = useLngFrame()
|
||||
|
||||
const items = useMemo<TransportItem[]>(() => {
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
const hop = transportHop(r, true)
|
||||
const hop = transportHop(r, true, frame)
|
||||
if (!hop) continue
|
||||
out.push({ ...hop, transitSegs: hop.type === 'transit' ? getTransitMapSegments(r) : [] })
|
||||
const segs = hop.type === 'transit' ? getTransitMapSegments(r) : []
|
||||
const transitSegs = frame ? segs.map(seg => ({ ...seg, coords: frameLine(frame, seg.coords) })) : segs
|
||||
out.push({ ...hop, transitSegs, frame })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
}, [reservations, frame])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
|
||||
|
||||
@@ -3,6 +3,9 @@ import { Marker, Tooltip, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { useLngFrame } from './lngFrameContext'
|
||||
import { frameLng } from '../../utils/lngFrame'
|
||||
import { onRealWorld } from '../../utils/worldCopy'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
@@ -64,12 +67,14 @@ export default function RoadtripViaMarkers({
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
|
||||
const frame = useLngFrame()
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
position: [via.lat, via.lng] as [number, number],
|
||||
position: [via.lat, frameLng(frame, via.lng)] as [number, number],
|
||||
handlers: {
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
// Saved on the real world, whichever copy of it the handle was dragged on (#2976).
|
||||
const at = onRealWorld(e.target.getLatLng())
|
||||
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
@@ -79,7 +84,7 @@ export default function RoadtripViaMarkers({
|
||||
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
},
|
||||
})), [vias])
|
||||
})), [vias, frame])
|
||||
|
||||
if (!markers.length || !zoomedIn) return null
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Polyline } from 'react-leaflet';
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
|
||||
/**
|
||||
* P2A-03: an invisible band along a route segment on the Leaflet map, wider
|
||||
@@ -31,7 +32,9 @@ export function RouteHitBand({
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu underneath the new via.
|
||||
e.originalEvent.stopPropagation();
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng);
|
||||
// The via is saved on the real world, whichever copy of it the route was clicked on (#2976).
|
||||
const at = onRealWorld(e.latlng);
|
||||
onRouteClick(at.lat, at.lng);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMap } from 'react-leaflet';
|
||||
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { bboxOnRealWorld } from '../../utils/worldCopy';
|
||||
|
||||
/**
|
||||
* The part of the planner's map on screen, told to the assistant after every
|
||||
@@ -54,7 +55,11 @@ export function useReportViewLeaflet(): void {
|
||||
if (tripId === null) return;
|
||||
const read = () => {
|
||||
const b = map.getBounds();
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
|
||||
report(
|
||||
tripId,
|
||||
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
);
|
||||
};
|
||||
read();
|
||||
map.on('moveend', read);
|
||||
@@ -87,7 +92,11 @@ export function useReportViewGL(mapRef: { readonly current: unknown }, mapReady:
|
||||
const read = () => {
|
||||
const b = getBounds();
|
||||
if (typeof b?.getSouth !== 'function') return;
|
||||
report(tripId, { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() });
|
||||
// On the real world, whichever world copy (or longitude frame, #2975) the map shows.
|
||||
report(
|
||||
tripId,
|
||||
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
);
|
||||
};
|
||||
read();
|
||||
on('moveend', read);
|
||||
|
||||
@@ -169,6 +169,45 @@ describe('buildDayRouteRuns', () => {
|
||||
expect(hotelBookendOf(run[0], run[1])).toBe('morning')
|
||||
})
|
||||
|
||||
it("FE-MAP-DRP-017: the morning after a night away starts at the side trip's hotel, not the room kept at home", () => {
|
||||
// The first hotel stays booked over the whole trip and comes first in the list; the
|
||||
// side trip's hotel holds the night before. The day used to start with a 716 km drive
|
||||
// from the kept room to the side trip's first stop.
|
||||
const days = [1, 2, 3, 4, 5].map(n => buildDay({ id: n, day_number: n }))
|
||||
const kept = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 5 } as unknown as Accommodation
|
||||
const away = { id: 2, trip_id: 1, place_lat: 18.7883, place_lng: 98.9853, start_day_id: 2, end_day_id: 3 } as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(3, inputs({
|
||||
days,
|
||||
assignments: { '3': [buildAssignment({ day_id: 3, order_index: 0, place: buildPlace({ lat: 18.8048, lng: 98.9216, place_time: '10:00' }) })] },
|
||||
accommodations: [kept, away],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
expect(runs[0][0]).toMatchObject({ lat: 18.7883, lng: 98.9853, hotel: 'morning' })
|
||||
expect(runs.flat().some(p => p.hotel === 'morning' && p.lat === 13.7466)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-018: a taxi that leaves from the hotel adds no road from the hotel to itself', () => {
|
||||
const days = [1, 2, 3].map(n => buildDay({ id: n, day_number: n }))
|
||||
const hotel = { id: 1, trip_id: 1, place_lat: 13.7466, place_lng: 100.5393, start_day_id: 1, end_day_id: 3 } as unknown as Accommodation
|
||||
const taxi = {
|
||||
id: 9, trip_id: 1, type: 'taxi', title: 'Taxi', status: 'confirmed', day_id: 2, end_day_id: 2,
|
||||
reservation_time: '2026-12-02T09:00', reservation_end_time: '2026-12-02T09:40',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Hotel', lat: 13.7466, lng: 100.5393 },
|
||||
{ role: 'to', sequence: 1, name: 'Museum', lat: 13.7516, lng: 100.4927 },
|
||||
],
|
||||
} as unknown as Reservation
|
||||
const runs = buildDayRouteRuns(2, inputs({
|
||||
days,
|
||||
assignments: { '2': [buildAssignment({ day_id: 2, order_index: 0, place: buildPlace({ lat: 13.7563, lng: 100.5018, place_time: '12:00' }) })] },
|
||||
reservations: [taxi],
|
||||
accommodations: [hotel],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
const zeroLength = runs.filter(run => run.length === 2 && run[0].lat === run[1].lat && run[0].lng === run[1].lng)
|
||||
expect(zeroLength).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
@@ -196,7 +196,10 @@ export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRout
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
// The booking's own point at this edge of the day, as the sidebar passes it: a taxi
|
||||
// that starts or ends at the hotel then adds no zero-length leg to it.
|
||||
const point = side === 'first' ? (e.from ?? e.to) : (e.to ?? e.from)
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null, lat: point?.lat, lng: point?.lng }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { greatCircle, unwrapLngs, geodesicArcs } from './flightGeodesy'
|
||||
import { tripLngFrame } from '../../utils/lngFrame'
|
||||
|
||||
const YVR: [number, number] = [49.19, -123.18]
|
||||
const ICN: [number, number] = [37.46, 126.44]
|
||||
@@ -61,3 +62,29 @@ describe('flightGeodesy (#1411)', () => {
|
||||
expect(unwrapLngs(pts)).toEqual(pts)
|
||||
})
|
||||
})
|
||||
|
||||
describe('flightGeodesy in a longitude frame (#2975)', () => {
|
||||
const LAX: [number, number] = [33.94, -118.41]
|
||||
const AKL: [number, number] = [-37.01, 174.79]
|
||||
const frame = tripLngFrame([LAX[1], AKL[1]])
|
||||
|
||||
it('draws LAX to Auckland as one arc on one world copy, between the two pins as the frame places them', () => {
|
||||
expect(frame).not.toBeNull()
|
||||
const arcs = geodesicArcs([LAX[0], LAX[1] + 360], AKL, true, frame)
|
||||
expect(arcs).toHaveLength(1)
|
||||
const [arc] = arcs
|
||||
expect(maxConsecutiveDeltaLng(arc)).toBeLessThan(180)
|
||||
expect(arc[0][1]).toBeCloseTo(LAX[1] + 360, 5)
|
||||
expect(arc[arc.length - 1][1]).toBeCloseTo(AKL[1], 5)
|
||||
})
|
||||
|
||||
it('still copies an arc that leaves the frame, so both of its ends keep a line', () => {
|
||||
const arcs = geodesicArcs(FRA, JFK, true, { seam: -30 })
|
||||
expect(arcs).toHaveLength(2)
|
||||
expect(arcs[1][0][1]).toBeCloseTo(arcs[0][0][1] + 360, 10)
|
||||
})
|
||||
|
||||
it('without a frame draws what it drew before', () => {
|
||||
expect(geodesicArcs(YVR, ICN, true, null)).toEqual(geodesicArcs(YVR, ICN, true))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Great-circle geometry for transport routes (flights, cruises, ferries),
|
||||
// shared by the Leaflet and Mapbox/MapLibre renderers (#1411).
|
||||
|
||||
import { frameLine, type LngFrame } from '../../utils/lngFrame'
|
||||
|
||||
const toRad = (d: number) => d * Math.PI / 180
|
||||
const toDeg = (r: number) => r * 180 / Math.PI
|
||||
|
||||
@@ -51,11 +53,17 @@ export function unwrapLngs(points: [number, number][]): [number, number][] {
|
||||
* copies), a ±360-shifted duplicate keeps both halves visible in the
|
||||
* standard [-180, 180] view; GL maps repeat features themselves
|
||||
* (renderWorldCopies), so the duplicate would just double the line opacity.
|
||||
*
|
||||
* Given a longitude frame (Leaflet, a trip across the antimeridian, #2975), the
|
||||
* arc starts at `a` as the frame draws it, and the duplicate is only needed
|
||||
* when the arc leaves the frame rather than the plain world.
|
||||
*/
|
||||
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean): [number, number][][] {
|
||||
const arc = unwrapLngs(greatCircle(a, b))
|
||||
const crosses = arc.some(([, lng]) => lng < -180 || lng > 180)
|
||||
export function geodesicArcs(a: [number, number], b: [number, number], wrapCopies: boolean, frame: LngFrame | null = null): [number, number][][] {
|
||||
const arc = frame ? frameLine(frame, greatCircle(a, b)) : unwrapLngs(greatCircle(a, b))
|
||||
const west = frame ? frame.seam : -180
|
||||
const east = west + 360
|
||||
const crosses = arc.some(([, lng]) => lng < west || lng > east)
|
||||
if (!crosses || !wrapCopies) return [arc]
|
||||
const shift = arc.some(([, lng]) => lng < -180) ? 360 : -360
|
||||
const shift = arc.some(([, lng]) => lng < west) ? 360 : -360
|
||||
return [arc, arc.map(([lat, lng]) => [lat, lng + shift] as [number, number])]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bboxOnRealWorld } from '../../utils/worldCopy';
|
||||
import { rasterTileZoom } from './glRasterLayers';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
|
||||
@@ -16,6 +17,11 @@ interface ViewportSource {
|
||||
export function reportGlViewport(map: ViewportSource, report: MapViewProps['onViewportChange']): void {
|
||||
const bounds = map.getBounds();
|
||||
if (!report || !bounds) return;
|
||||
const bbox = { south: bounds.getSouth(), west: bounds.getWest(), north: bounds.getNorth(), east: bounds.getEast() };
|
||||
const bbox = bboxOnRealWorld({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
});
|
||||
report(bbox, rasterTileZoom(map.getZoom()));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// The Leaflet map's bridges to the page: the events it hands out, with their
|
||||
// points on the real world (#2976), and the view kept on the world copy the
|
||||
// trip is drawn on (#2975).
|
||||
|
||||
import { wrapLongitude } from '@trek/shared';
|
||||
import L from 'leaflet';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { frameLng, type LngFrame } from '../../utils/lngFrame';
|
||||
import { bboxOnRealWorld } from '../../utils/worldCopy';
|
||||
import { useLngFrame } from './lngFrameContext';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { bindPoiDropTarget } from './poiDropTarget';
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
export function PoiDropTarget({
|
||||
onPoiDropOnRoute,
|
||||
}: {
|
||||
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return;
|
||||
return bindPoiDropTarget(map.getContainer(), (x, y) => map.containerPointToLatLng([x, y]), onPoiDropOnRoute);
|
||||
}, [map, onPoiDropOnRoute]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area, with the zoom an overlay's minimum is read against.
|
||||
export function ViewportController({ onViewportChange }: { onViewportChange?: MapViewProps['onViewportChange'] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onViewportChange) return;
|
||||
const emit = () => {
|
||||
const b = map.getBounds();
|
||||
// A view of a world copy (or of a trip drawn past the antimeridian) asks for the real places there.
|
||||
onViewportChange(
|
||||
bboxOnRealWorld({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }),
|
||||
map.getZoom()
|
||||
);
|
||||
};
|
||||
map.whenReady(emit); // ensure the first bbox is captured once the map is laid out
|
||||
map.on('moveend', emit);
|
||||
map.on('zoomend', emit);
|
||||
return () => {
|
||||
map.off('moveend', emit);
|
||||
map.off('zoomend', emit);
|
||||
};
|
||||
}, [map, onViewportChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the view along when the longitude frame changes under it (#2975): the trip
|
||||
* is then drawn one world further east or west, and a view left where it was would
|
||||
* look at an empty copy. One jump of a whole world, so nothing on screen changes.
|
||||
*/
|
||||
export function LngFrameFollower() {
|
||||
const map = useMap();
|
||||
const frame = useLngFrame();
|
||||
const prev = useRef<LngFrame | null>(frame);
|
||||
useEffect(() => {
|
||||
const before = prev.current;
|
||||
prev.current = frame;
|
||||
if (before === frame || typeof map.getCenter !== 'function') return;
|
||||
const center = map.getCenter();
|
||||
// Without a frame the trip is back on the real world, and so is the view.
|
||||
const target = frame ? frameLng(frame, center.lng) : wrapLongitude(center.lng);
|
||||
const shift = target - center.lng;
|
||||
if (shift !== 0) map.setView([center.lat, target], map.getZoom(), { animate: false });
|
||||
}, [frame, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The event as the map reports it, with its point moved from a repeated world copy
|
||||
* back onto the real world: a right-click on the copy east of it said 274.9 for
|
||||
* -85.1, and the place was saved there (#2976).
|
||||
*/
|
||||
function onRealWorldEvent(e: L.LeafletMouseEvent): L.LeafletMouseEvent {
|
||||
const lng = wrapLongitude(e.latlng.lng);
|
||||
return lng === e.latlng.lng ? e : { ...e, latlng: L.latLng(e.latlng.lat, lng) };
|
||||
}
|
||||
|
||||
export function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onClick) return;
|
||||
const handle = (e: L.LeafletMouseEvent) => onClick(onRealWorldEvent(e));
|
||||
map.on('click', handle);
|
||||
return () => {
|
||||
map.off('click', handle);
|
||||
};
|
||||
}, [map, onClick]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!onContextMenu) return;
|
||||
const handle = (e: L.LeafletMouseEvent) => onContextMenu(onRealWorldEvent(e));
|
||||
map.on('contextmenu', handle);
|
||||
return () => {
|
||||
map.off('contextmenu', handle);
|
||||
};
|
||||
}, [map, onContextMenu]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// The longitude frame of the Leaflet map, for every layer that draws in it (#2975).
|
||||
// See utils/lngFrame.ts for what the frame is and why only Leaflet needs it.
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
import type { Place, Reservation } from '../../types';
|
||||
import { tripLngFrame, type LngFrame } from '../../utils/lngFrame';
|
||||
|
||||
const LngFrameContext = createContext<LngFrame | null>(null);
|
||||
|
||||
export const LngFrameProvider = LngFrameContext.Provider;
|
||||
|
||||
/** The frame the map draws in, or null when it draws the plain world. */
|
||||
export function useLngFrame(): LngFrame | null {
|
||||
return useContext(LngFrameContext);
|
||||
}
|
||||
|
||||
type Located = Pick<Place, 'lng'>;
|
||||
|
||||
function tripLngs(
|
||||
places: readonly Located[],
|
||||
dayPlaces: readonly Located[],
|
||||
reservations: readonly Reservation[]
|
||||
): number[] {
|
||||
const lngs: number[] = [];
|
||||
for (const p of places) if (typeof p.lng === 'number') lngs.push(p.lng);
|
||||
for (const p of dayPlaces) if (typeof p.lng === 'number') lngs.push(p.lng);
|
||||
for (const r of reservations) for (const e of r.endpoints ?? []) if (typeof e.lng === 'number') lngs.push(e.lng);
|
||||
return lngs;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame for a trip: its places, the day's places and every endpoint of every
|
||||
* booking, shown or not, so switching a day or a booking on and off does not move
|
||||
* the seam. The frame in use is handed back in, which keeps it while it still fits.
|
||||
*/
|
||||
export function useTripLngFrame(
|
||||
places: readonly Located[],
|
||||
dayPlaces: readonly Located[],
|
||||
reservations: readonly Reservation[]
|
||||
): LngFrame | null {
|
||||
const [current, setCurrent] = useState<LngFrame | null>(() =>
|
||||
tripLngFrame(tripLngs(places, dayPlaces, reservations))
|
||||
);
|
||||
const next = useMemo(
|
||||
() => tripLngFrame(tripLngs(places, dayPlaces, reservations), current),
|
||||
[places, dayPlaces, reservations, current]
|
||||
);
|
||||
// Derived state, adjusted during render: the next render already draws in the new frame.
|
||||
if (next !== current) setCurrent(next);
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// A corridor hit dropped anywhere on the map, for every renderer.
|
||||
//
|
||||
// The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
// canvas and has no element to aim at, and the coordinate under the pointer says just
|
||||
// as much once the caller projects it onto the routed geometry. The point goes out on
|
||||
// the real world, whichever world copy it was dropped on (#2976).
|
||||
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
import { draggedPoiId } from './markerDrag';
|
||||
|
||||
/** Where a point of the container lies, in the renderer's own coordinates. */
|
||||
type PointAt = (x: number, y: number) => { lat: number; lng: number };
|
||||
|
||||
/** Listens for POI drops on `container` and reports each one; returns the cleanup. */
|
||||
export function bindPoiDropTarget(
|
||||
container: HTMLElement,
|
||||
pointAt: PointAt,
|
||||
onPoiDropOnRoute: (osmId: string, lat: number, lng: number) => void
|
||||
): () => void {
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e);
|
||||
if (!osmId) return;
|
||||
e.preventDefault();
|
||||
const rect = container.getBoundingClientRect();
|
||||
const at = onRealWorld(pointAt(e.clientX - rect.left, e.clientY - rect.top));
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng);
|
||||
};
|
||||
container.addEventListener('dragover', onDragOver);
|
||||
container.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver);
|
||||
container.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}
|
||||
@@ -88,6 +88,20 @@ describe('transportHop', () => {
|
||||
expect(transportHop(r, false)!.arcs[0].length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("draws the waypoints and arcs in the map's longitude frame, so the pins sit on the line (#2975)", () => {
|
||||
const r = booking('flight', [
|
||||
endpoint({ role: 'from', lat: 33.94, lng: -118.41 }),
|
||||
endpoint({ role: 'to', sequence: 1, lat: -37.01, lng: 174.79 }),
|
||||
]);
|
||||
const hop = transportHop(r, true, { seam: 28 });
|
||||
if (!hop) throw new Error('a flight with two located ends draws a hop');
|
||||
expect(hop.from.lng).toBeCloseTo(241.59, 5);
|
||||
expect(hop.to.lng).toBeCloseTo(174.79, 5);
|
||||
expect(hop.arcs).toHaveLength(1);
|
||||
expect(hop.arcs[0][0][1]).toBeCloseTo(241.59, 5);
|
||||
expect(hop.arcs[0][hop.arcs[0].length - 1][1]).toBeCloseTo(174.79, 5);
|
||||
});
|
||||
|
||||
it('draws ferries and cruises as arcs but a cable car as a straight line', () => {
|
||||
expect(TRANSPORT_META.ferry.geodesic).toBe(true);
|
||||
expect(TRANSPORT_META.cruise.geodesic).toBe(true);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { Bike, Bus, CableCar, Car, CarTaxiFront, Plane, Route, Sailboat, Ship, Train, TramFront } from 'lucide-react';
|
||||
import type { Reservation, ReservationEndpoint } from '../../types';
|
||||
import { haversineKm } from '../../utils/geo';
|
||||
import { frameLine, frameLng, type LngFrame } from '../../utils/lngFrame';
|
||||
import { geodesicArcs } from './flightGeodesy';
|
||||
|
||||
export type TransportType =
|
||||
@@ -144,14 +145,17 @@ export interface TransportHop {
|
||||
* antimeridian also gets a copy shifted by 360 degrees: Leaflet needs that, while a
|
||||
* GL map repeats features across world copies itself, so there one continuous arc
|
||||
* is enough (a shifted duplicate would sit on the wrapped copy and double the opacity).
|
||||
* `frame` is Leaflet's longitude frame (#2975): the arcs and the waypoints come back
|
||||
* drawn in it, so a pin and the line that ends at it are on the same world copy.
|
||||
*/
|
||||
export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop | null {
|
||||
export function transportHop(r: Reservation, wrapArcs: boolean, frame: LngFrame | null = null): TransportHop | null {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) return null;
|
||||
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two.
|
||||
const waypoints = (r.endpoints || [])
|
||||
const sorted = (r.endpoints || [])
|
||||
.filter((e) => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
const waypoints = frame ? sorted.map((e) => ({ ...e, lng: frameLng(frame, e.lng) })) : sorted;
|
||||
if (waypoints.length < 2) return null;
|
||||
const from = waypoints[0];
|
||||
const to = waypoints[waypoints.length - 1];
|
||||
@@ -164,12 +168,12 @@ export function transportHop(r: Reservation, wrapArcs: boolean): TransportHop |
|
||||
const a = waypoints[i];
|
||||
const b = waypoints[i + 1];
|
||||
const segArcs = isGeo
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs)
|
||||
? geodesicArcs([a.lat, a.lng], [b.lat, b.lng], wrapArcs, frame)
|
||||
: [
|
||||
[
|
||||
frameLine(frame, [
|
||||
[a.lat, a.lng],
|
||||
[b.lat, b.lng],
|
||||
] as [number, number][],
|
||||
] as [number, number][]),
|
||||
];
|
||||
arcs.push(...segArcs);
|
||||
distanceKm += haversineKm(a, b);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import type { AssistantRouteLine } from '../../types/assistantMap';
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
import { mapHoverLabel } from './mapHoverLabel';
|
||||
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
|
||||
|
||||
@@ -245,12 +246,15 @@ export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onP
|
||||
return;
|
||||
}
|
||||
handlers.current.onHover?.(ref);
|
||||
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
const at = onRealWorld(event.lngLat);
|
||||
handlers.current.onPoint?.(ref, at.lat, at.lng);
|
||||
};
|
||||
const leave = () => handlers.current.onHover?.(null);
|
||||
const click = (event: LayerEvent) => {
|
||||
const ref = refOf(event);
|
||||
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
|
||||
if (ref === null || onPlanPin(map, event)) return;
|
||||
const at = onRealWorld(event.lngLat);
|
||||
handlers.current.onPoint?.(ref, at.lat, at.lng);
|
||||
};
|
||||
const stop = (event: LayerEvent) => {
|
||||
const name = event.features?.[0]?.properties?.label;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
import { onRealWorld } from '../../utils/worldCopy';
|
||||
import { bindPinHoverLabel, mapHoverLabel } from './mapHoverLabel';
|
||||
import type { MapViewProps } from './mapViewContract';
|
||||
import { plannerPinBadge, plannerPinShape } from './plannerPinShape';
|
||||
@@ -102,7 +103,7 @@ export function usePlannerWaypointMarkersGL<Pin extends PlannerWaypointPin>({
|
||||
coordinateAt: (clientX, clientY) => {
|
||||
const rect = map.getContainer().getBoundingClientRect();
|
||||
const at = map.unproject([clientX - rect.left, clientY - rect.top]);
|
||||
return { lat: at.lat, lng: at.lng };
|
||||
return onRealWorld({ lat: at.lat, lng: at.lng });
|
||||
},
|
||||
setPosition: ({ lat, lng }) => pin.setLngLat([lng, lat]),
|
||||
onCommit: ({ lat, lng }) =>
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
|
||||
import { pdfSwitchOn } from '../../utils/pdfSwitch'
|
||||
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
|
||||
import { onlyMyPlan, planAssignments } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
|
||||
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
return renderIconMarkup(
|
||||
createElement(icon, props)
|
||||
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
|
||||
markdown.dispose()
|
||||
if (signal?.aborted) return
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'pdf-preview-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove()
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
|
||||
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
|
||||
})
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
|
||||
})
|
||||
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
|
||||
}
|
||||
|
||||
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
|
||||
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
|
||||
return classes.length ? ` class="${classes.join(' ')}"` : ''
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
|
||||
</button>`
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
|
||||
if (!button) return
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true'
|
||||
button.setAttribute('aria-checked', String(on))
|
||||
button.style.background = trackColour(on)
|
||||
const knob = button.firstElementChild as HTMLElement | null
|
||||
if (knob) knob.style.left = knobOffset(on)
|
||||
onChange(on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
|
||||
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
|
||||
* there the preview hands the export to Safari instead of offering a button that
|
||||
* does nothing.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
|
||||
|
||||
const tr = (key: string) => key;
|
||||
|
||||
function open(overrides: Partial<PdfPreviewOptions> = {}) {
|
||||
openPdfPreview({
|
||||
html: '<html><body>plan</body></html>',
|
||||
title: 'Lisbon',
|
||||
tripId: 9,
|
||||
tr,
|
||||
breaksPerDay: true,
|
||||
showTransportNotes: true,
|
||||
hasTransportNotes: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function standalone(value: boolean) {
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('pdf-preview-overlay')?.remove();
|
||||
delete (navigator as unknown as Record<string, unknown>).standalone;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('openPdfPreview', () => {
|
||||
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
|
||||
open();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
expect(document.getElementById('pdf-safari-hint')).toBeNull();
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
|
||||
const print = vi.fn();
|
||||
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
|
||||
const button = document.getElementById('pdf-print-btn');
|
||||
expect(button?.textContent).toBe('pdf.saveAsPdf');
|
||||
button?.click();
|
||||
expect(print).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
|
||||
standalone(true);
|
||||
open();
|
||||
expect(document.getElementById('pdf-print-btn')).toBeNull();
|
||||
|
||||
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
|
||||
expect(link.textContent).toBe('pdf.openInSafari');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener');
|
||||
const url = new URL(link.href);
|
||||
expect(url.pathname).toBe('/trips/9');
|
||||
expect(url.searchParams.get('export')).toBe('pdf');
|
||||
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
|
||||
standalone(true);
|
||||
open({ tripId: undefined });
|
||||
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
|
||||
open({ title: '<b>Lisbon</b>' });
|
||||
const overlay = document.getElementById('pdf-preview-overlay');
|
||||
expect(overlay?.querySelector('b')).toBeNull();
|
||||
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
|
||||
document.getElementById('pdf-close-btn')?.click();
|
||||
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
|
||||
open();
|
||||
const toggle = document.getElementById('pdf-daybreak-toggle');
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('true');
|
||||
toggle?.click();
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('false');
|
||||
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { escapeHtml } from '@trek/shared';
|
||||
|
||||
import { isIosStandalone } from '../../utils/iosDevice';
|
||||
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
|
||||
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK =
|
||||
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
|
||||
const TOGGLE_KNOB =
|
||||
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
|
||||
|
||||
const HEADER_ACTION =
|
||||
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface PdfPreviewOptions {
|
||||
/** The finished document, shown in a sandboxed srcdoc frame. */
|
||||
html: string;
|
||||
title: string;
|
||||
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
|
||||
tripId?: number;
|
||||
tr: Translate;
|
||||
breaksPerDay: boolean;
|
||||
showTransportNotes: boolean;
|
||||
/** The transport-notes switch only shows when there is a note to hide. */
|
||||
hasTransportNotes: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that saves the document. A browser prints the frame and offers
|
||||
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
|
||||
* Screen ignores print() altogether, so there the control opens the trip in
|
||||
* Safari with the export dialog up, and a line under the header says why.
|
||||
*/
|
||||
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
|
||||
if (tripId == null || !isIosStandalone()) {
|
||||
return {
|
||||
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
|
||||
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
|
||||
hint: tr('pdf.safariHint'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
|
||||
export function openPdfPreview({
|
||||
html,
|
||||
title,
|
||||
tripId,
|
||||
tr,
|
||||
breaksPerDay,
|
||||
showTransportNotes,
|
||||
hasTransportNotes,
|
||||
}: PdfPreviewOptions): void {
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pdf-preview-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);display:flex;align-items:center;justify-content:center;padding:8px;';
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText =
|
||||
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
|
||||
|
||||
const save = saveControl(tripId, tr);
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText =
|
||||
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
${save.html}
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;';
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals';
|
||||
iframe.srcdoc = html;
|
||||
|
||||
card.appendChild(header);
|
||||
if (save.hint) {
|
||||
const hint = document.createElement('p');
|
||||
hint.id = 'pdf-safari-hint';
|
||||
hint.style.cssText =
|
||||
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
hint.textContent = save.hint;
|
||||
card.appendChild(hint);
|
||||
}
|
||||
card.appendChild(iframe);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove();
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
|
||||
if (printBtn)
|
||||
printBtn.onclick = () => {
|
||||
iframe.contentWindow?.print();
|
||||
};
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
|
||||
});
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
|
||||
});
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
|
||||
if (!button) return;
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true';
|
||||
button.setAttribute('aria-checked', String(on));
|
||||
button.style.background = trackColour(on);
|
||||
const knob = button.firstElementChild as HTMLElement | null;
|
||||
if (knob) knob.style.insetInlineStart = knobOffset(on);
|
||||
onChange(on);
|
||||
};
|
||||
}
|
||||
@@ -419,8 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
// The bringer's initial on an avatar, the co-bringers as a count.
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
|
||||
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
|
||||
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { packingBringerNames } from './packingBringers'
|
||||
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
@@ -226,7 +227,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
|
||||
{ownerAvatar
|
||||
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// FE-PACK-BRINGERS-001 to FE-PACK-BRINGERS-003
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { packingBringerNames } from './packingBringers';
|
||||
|
||||
describe('packingBringerNames', () => {
|
||||
it('FE-PACK-BRINGERS-001: names the bringer first, then everyone who also brings it', () => {
|
||||
const item = {
|
||||
owner_username: 'anna',
|
||||
contributors: [{ username: 'ben' }, { username: 'carla' }],
|
||||
};
|
||||
expect(packingBringerNames(item)).toBe('anna, ben, carla');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-002: names a person once, even when listed twice', () => {
|
||||
expect(packingBringerNames({ owner_username: 'anna', contributors: [{ username: 'anna' }] })).toBe('anna');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-003: copes with an item without a bringer or contributors', () => {
|
||||
expect(packingBringerNames({ owner_username: null, contributors: [{ username: 'ben' }] })).toBe('ben');
|
||||
expect(packingBringerNames({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
interface BroughtItem {
|
||||
owner_username?: string | null;
|
||||
contributors?: { username: string }[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone bringing a common item, as one readable list: its bringer first, then
|
||||
* whoever also brings it. The row only has room for one avatar and a "+n", so this
|
||||
* is what its tooltip and the item sheet name (#2782).
|
||||
*/
|
||||
export function packingBringerNames(item: BroughtItem): string {
|
||||
const names = [item.owner_username, ...(item.contributors ?? []).map((c) => c.username)];
|
||||
return [...new Set(names.filter((n): n is string => !!n))].join(', ');
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
interface AirTrailImportModalProps {
|
||||
isOpen: boolean
|
||||
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
|
||||
}
|
||||
|
||||
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
||||
function fmtDate(d: string | null, locale: string): string {
|
||||
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
|
||||
if (!d) return ''
|
||||
try {
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}, dateFormat)
|
||||
} catch {
|
||||
return d
|
||||
}
|
||||
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
|
||||
|
||||
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const dateFormat = useDateFormat()
|
||||
const toast = useToast()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const reservations = useTripStore(s => s.reservations)
|
||||
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{already && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { BarButton, PANEL_BAR } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => setExportOpen(true))
|
||||
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
export function MobileDayPickerSheet(S: SidebarState) {
|
||||
const {
|
||||
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
|
||||
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
|
||||
} = S
|
||||
const dateFormat = useDateFormat()
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
|
||||
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
|
||||
@@ -133,7 +133,7 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByRole('button', { name: /^Accommodation$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Restaurant$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Event$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Guided tour$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Parking$/i })).toBeInTheDocument();
|
||||
// The pill reads "Booking Type: Other", so only its option is named plain "Other".
|
||||
expect(screen.getAllByRole('button', { name: /^Other$/i })).toHaveLength(1);
|
||||
@@ -708,7 +708,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await pickType(/^Tour$/i);
|
||||
await pickType(/^Guided tour$/i);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('BookingsTimeline: trip view', () => {
|
||||
expect(screen.getByText('No date')).toBeInTheDocument();
|
||||
const metro = screen.getByRole('button', { name: /Metro ride/ });
|
||||
expect(metro).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Bonus tour/ })).toHaveTextContent('Guided tour, Pending');
|
||||
expect(screen.getByRole('button', { name: /Farewell dinner/ })).toHaveTextContent('Restaurant, Confirmed');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Farewell dinner/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(early);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
|
||||
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import { chargingRepo } from '../../repo/chargingRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
@@ -76,6 +77,7 @@ export default function ChargingInfo({
|
||||
}: ChargingTarget & { compact?: boolean }) {
|
||||
const tripId = useTripStore((s) => s.trip?.id);
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const [info, setInfo] = useState<Info | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
@@ -133,7 +135,7 @@ export default function ChargingInfo({
|
||||
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
|
||||
const price = (value: number, currency: string) =>
|
||||
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
|
||||
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
|
||||
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
|
||||
if (compact)
|
||||
return (
|
||||
<ChargingBadges
|
||||
@@ -211,7 +213,7 @@ export default function ChargingInfo({
|
||||
</a>
|
||||
<br />
|
||||
{info.license}
|
||||
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
|
||||
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
|
||||
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi } from '../../api/client'
|
||||
|
||||
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
|
||||
}
|
||||
|
||||
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
|
||||
function formatStamp(ts: string, locale: string): string {
|
||||
function formatStamp(ts: string, locale: string, dateFormat: string): string {
|
||||
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
|
||||
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
|
||||
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
|
||||
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
|
||||
|
||||
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const dateFormat = useDateFormat()
|
||||
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
|
||||
return (
|
||||
<li className="flex items-start gap-3 px-3.5 py-3">
|
||||
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
|
||||
</code>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
|
||||
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
|
||||
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
|
||||
{', '}
|
||||
{apiKey.last_used_at
|
||||
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
|
||||
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
|
||||
: t('settings.apiKeys.neverUsed')}
|
||||
</p>
|
||||
{/* What the key may read. Shown on the row rather than behind a detail
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Section from './Section'
|
||||
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
|
||||
* no reason is the version of this card that generates support questions.
|
||||
*/
|
||||
export default function DawarichConnectionSection(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const S = useDawarichConnection()
|
||||
|
||||
return (
|
||||
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DawarichConnectionStatus state={S} locale={locale} />
|
||||
<DawarichConnectionStatus state={S} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
*/
|
||||
function DawarichConnectionStatus({
|
||||
state,
|
||||
locale,
|
||||
}: {
|
||||
state: ReturnType<typeof useDawarichConnection>
|
||||
locale: string
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
if (!state.connected && !state.probeMessage) return null
|
||||
|
||||
const missing: string[] = []
|
||||
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
|
||||
{state.connected && (
|
||||
<p className="m-0 text-content-secondary">
|
||||
{state.lastSyncAt
|
||||
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
|
||||
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
|
||||
: t('dawarich.neverSynced')}
|
||||
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
|
||||
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Date format (056, 057) ────────────────────────────────────────────────────
|
||||
|
||||
describe('DisplaySettingsTab date format (#2971)', () => {
|
||||
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
|
||||
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
|
||||
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
|
||||
await user.click(await screen.findByText('31.12.2026'));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
|
||||
await user.click(await screen.findByText('31/12/2026'));
|
||||
|
||||
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisplaySettingsTab – the demo account', () => {
|
||||
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
|
||||
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
|
||||
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { dateFormatOptions } from '../../utils/dateFormat'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Date format: every date field and every date written in numbers (#2971) */}
|
||||
<SettingRow
|
||||
label={t('settings.dateFormat')}
|
||||
hint={t('settings.dateFormatHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.date_format || DEFAULT_DATE_FORMAT}
|
||||
onChange={v => {
|
||||
void save('date_format', String(v))
|
||||
}}
|
||||
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Section from './Section'
|
||||
import React, { useId } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
|
||||
function useIntegrations() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { formatDate } = useFormatDate()
|
||||
const toast = useToast()
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
|
||||
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
|
||||
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
|
||||
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
|
||||
wandererEnabled,
|
||||
managed, ...mcp,
|
||||
}
|
||||
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
|
||||
|
||||
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
|
||||
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
|
||||
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
|
||||
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
|
||||
const expanded = !!oauthScopesExpanded[client.id]
|
||||
return (
|
||||
<div className={`${ROW} items-start`}>
|
||||
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
|
||||
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
|
||||
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
|
||||
function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
const {
|
||||
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
} = S
|
||||
return (
|
||||
<>
|
||||
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
|
||||
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
|
||||
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOauthRevokeId(session.id)}
|
||||
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
|
||||
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
|
||||
function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
return (
|
||||
<>
|
||||
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
|
||||
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
|
||||
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="font-geist">{token.token_prefix}...</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -386,6 +386,27 @@ describe('MapSettingsTab – CARTO key', () => {
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032b: a managed instance without an operator token offers no Mapbox and no Amap tiles', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true, hasAmapKey: false });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: '', map_tile_url: '' }) });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText('Mapbox GL')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Select template...'));
|
||||
expect(screen.queryByText('高德地图 (Amap)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032c: the operator token brings Mapbox back on a managed instance', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: 'pk.operator' }) });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -15,7 +15,7 @@ import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
|
||||
import { PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
|
||||
import { useMapSettingsForm } from './useMapSettingsForm'
|
||||
|
||||
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
|
||||
@@ -130,7 +130,7 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
|
||||
} = useMapSettingsForm()
|
||||
@@ -149,7 +149,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{/* Provider picker — big tiles so the choice is obvious */}
|
||||
<SettingRows>
|
||||
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className={`grid grid-cols-1 gap-2 ${canMapbox ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
|
||||
<ProviderTile
|
||||
active={provider === 'leaflet'}
|
||||
onClick={() => changeProvider('leaflet')}
|
||||
@@ -157,15 +157,17 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
name="Leaflet"
|
||||
subtitle={t('settings.mapLeafletSubtitle')}
|
||||
/>
|
||||
<ProviderTile
|
||||
active={provider === 'mapbox-gl'}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
icon={Box}
|
||||
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
|
||||
subtitle={t('settings.mapMapboxSubtitle')}
|
||||
// Only on ≥sm; on a narrow window there's no room next to the title.
|
||||
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
|
||||
/>
|
||||
{canMapbox && (
|
||||
<ProviderTile
|
||||
active={provider === 'mapbox-gl'}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
icon={Box}
|
||||
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
|
||||
subtitle={t('settings.mapMapboxSubtitle')}
|
||||
// Only on ≥sm; on a narrow window there's no room next to the title.
|
||||
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
|
||||
/>
|
||||
)}
|
||||
<ProviderTile
|
||||
active={provider === 'maplibre-gl'}
|
||||
onClick={() => changeProvider('maplibre-gl')}
|
||||
@@ -185,7 +187,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-tile`}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// FE-MAP-MODEL-001 to FE-MAP-MODEL-004
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults';
|
||||
import { MAP_PRESETS, mapboxOffered, mapPresetsFor } from './mapSettingsModel';
|
||||
|
||||
const urls = (list: { url: string }[]) => list.map((p) => p.url);
|
||||
|
||||
describe('mapPresetsFor', () => {
|
||||
it('FE-MAP-MODEL-001: a self-hosted install offers every preset, Amap included', () => {
|
||||
expect(mapPresetsFor(false, false)).toEqual(MAP_PRESETS);
|
||||
});
|
||||
|
||||
it('FE-MAP-MODEL-002: a managed install offers Amap only where the operator set it up', () => {
|
||||
const without = urls(mapPresetsFor(true, false));
|
||||
expect(without).not.toContain(AMAP_ROAD);
|
||||
expect(without).not.toContain(AMAP_SATELLITE);
|
||||
expect(without).toHaveLength(MAP_PRESETS.length - 2);
|
||||
expect(mapPresetsFor(true, true)).toEqual(MAP_PRESETS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapboxOffered', () => {
|
||||
it('FE-MAP-MODEL-003: a self-hosted install always offers Mapbox, the token field follows', () => {
|
||||
expect(mapboxOffered(false, null)).toBe(true);
|
||||
expect(mapboxOffered(false, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-MODEL-004: a managed install offers Mapbox only with the operator token', () => {
|
||||
expect(mapboxOffered(true, undefined)).toBe(false);
|
||||
expect(mapboxOffered(true, '')).toBe(false);
|
||||
expect(mapboxOffered(true, 'pk.operator')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,26 @@ export const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
|
||||
];
|
||||
|
||||
/**
|
||||
* The presets this install offers. On a managed install Amap is the operator's
|
||||
* service like every other keyed one: its tiles are offered only where the
|
||||
* operator set Amap up (has_amap_key), so a hosted user is never pointed at a
|
||||
* provider the instance does not use.
|
||||
*/
|
||||
export function mapPresetsFor(managed: boolean, hasAmapKey: boolean): MapPreset[] {
|
||||
if (!managed || hasAmapKey) return MAP_PRESETS;
|
||||
return MAP_PRESETS.filter((p) => p.url !== AMAP_ROAD && p.url !== AMAP_SATELLITE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Mapbox GL can be picked. A managed install brings the token with the
|
||||
* instance and offers no field for one, so without an operator token the
|
||||
* choice would only lead to a map that never loads.
|
||||
*/
|
||||
export function mapboxOffered(managed: boolean, token: string | null | undefined): boolean {
|
||||
return !managed || !!token;
|
||||
}
|
||||
|
||||
export type MapProvider = 'leaflet' | GlMapProvider;
|
||||
|
||||
export function normalizeProvider(value: unknown): MapProvider {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user