mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cde099c7f | |||
| e798eb712e | |||
| 0dae908e05 | |||
| 01c7bc9de2 | |||
| 2e683f8609 | |||
| f8e51cb83d | |||
| eedfb08597 | |||
| 0aee0a019e | |||
| 91bc1944dc | |||
| f99b40cd81 | |||
| 59e68d81b2 | |||
| 86513fda0d |
@@ -34,7 +34,7 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1915,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
|
||||
@@ -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,6 +980,7 @@
|
||||
}
|
||||
],
|
||||
"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/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/PDF/TripPDF.tsx": 1039,
|
||||
"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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
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'
|
||||
|
||||
@@ -69,7 +69,7 @@ function errorText(err: unknown, fallback: string): string {
|
||||
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()) })
|
||||
@@ -217,7 +217,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
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"
|
||||
@@ -283,7 +283,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') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl';
|
||||
import {
|
||||
@@ -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';
|
||||
|
||||
@@ -53,6 +60,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 +163,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
defaults,
|
||||
loaded,
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, operatorMapboxToken),
|
||||
isSet,
|
||||
save,
|
||||
reset,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,7 +7,15 @@ import type { Place } from '../../types';
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders';
|
||||
import { useWebGL2 } from '../Map/glSupport';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
|
||||
import {
|
||||
type MapProvider,
|
||||
mapboxOffered,
|
||||
mapPresetsFor,
|
||||
normalizeProvider,
|
||||
previewPlace,
|
||||
slotStyle,
|
||||
styleForProvider,
|
||||
} from './mapSettingsModel';
|
||||
|
||||
export interface MapSettingsFormOptions {
|
||||
/**
|
||||
@@ -32,6 +40,7 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
const [provider, setProvider] = useState<MapProvider>(initialProvider);
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '');
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '');
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(
|
||||
@@ -94,6 +103,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
|
||||
return {
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, settings.mapbox_access_token),
|
||||
saving,
|
||||
provider,
|
||||
changeProvider,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
|
||||
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
||||
// imported as one, and when a track was recorded shown where it carried times.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TourGpxPreviewTrack } from '@trek/shared';
|
||||
import type { TourFileLeftOut, TourGpxPreviewTrack } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
@@ -27,10 +27,10 @@ function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrac
|
||||
};
|
||||
}
|
||||
|
||||
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
|
||||
function gpxImport(tracks: TourGpxPreviewTrack[], leftOut?: TourFileLeftOut): TourGpxImport {
|
||||
return {
|
||||
importing: false,
|
||||
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
|
||||
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks, leftOut }],
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
@@ -63,19 +63,54 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
|
||||
expect(current.confirm).toHaveBeenCalledWith(
|
||||
{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' },
|
||||
{ points: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
|
||||
const walk = gpxImport([track()]);
|
||||
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
|
||||
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' }, { points: false });
|
||||
unmount();
|
||||
|
||||
const hike = gpxImport([track()]);
|
||||
render(<TourGpxPreviewDialog gpxImport={hike} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
|
||||
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
|
||||
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
|
||||
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
|
||||
// Added as places, they are not listed as left out.
|
||||
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
|
||||
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
|
||||
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
|
||||
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
const button = screen.getByTestId('gpx-preview-import');
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
|
||||
expect(screen.queryByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { MapViewAuto } from '../Map/MapViewAuto';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
@@ -98,8 +99,8 @@ function PreviewTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
|
||||
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
|
||||
/** What all the files held besides their lines, summed by kind. */
|
||||
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
|
||||
const sum = { points: 0, areas: 0, other: 0 };
|
||||
for (const { leftOut } of files) {
|
||||
if (!leftOut) continue;
|
||||
@@ -107,18 +108,46 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
|
||||
sum.areas += leftOut.areas;
|
||||
sum.other += leftOut.other;
|
||||
}
|
||||
return sum.points + sum.areas + sum.other > 0 ? sum : null;
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* IO-02: what a KML or KMZ file held besides its lines, named before the
|
||||
* import so nothing goes missing unseen: a dashed card with each kind as a
|
||||
* counted badge, and why in the info dot.
|
||||
* #2969: the single points of the files (GPX waypoints, KML pins), which no
|
||||
* tour is made of, offered as places of the trip. On by default: a file with
|
||||
* a route and its stops is usually meant as both.
|
||||
*/
|
||||
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
|
||||
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const leftOut = leftOutOf(files);
|
||||
if (!leftOut) return null;
|
||||
const hintId = useId();
|
||||
return (
|
||||
<div
|
||||
data-testid="gpx-preview-points"
|
||||
className="mt-2 flex items-center gap-2.5 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2"
|
||||
>
|
||||
<MapPin size={14} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex flex-wrap items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.import.pointsAsPlaces')}
|
||||
<SoftPill>{t('tours.import.leftOutPoints', { count })}</SoftPill>
|
||||
</span>
|
||||
<span id={hintId} className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.import.pointsAsPlacesHint')}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={onToggle} label={t('tours.import.pointsAsPlaces')} describedBy={hintId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* IO-02: what a file held besides its lines and does not take in, named
|
||||
* before the import so nothing goes missing unseen: a dashed card with each
|
||||
* kind as a counted badge, and why in the info dot. Its points only while
|
||||
* they are not added as places.
|
||||
*/
|
||||
function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
|
||||
const { t } = useTranslation();
|
||||
if (leftOut.points + leftOut.areas + leftOut.other === 0) return null;
|
||||
const kinds = [
|
||||
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
|
||||
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
|
||||
@@ -170,6 +199,29 @@ export default function TourGpxPreviewDialog({
|
||||
|
||||
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
|
||||
|
||||
/** A fresh line needs its type complete; a file of known lines can still be confirmed for its points alone. */
|
||||
function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean): boolean {
|
||||
return fresh > 0 ? typeComplete : addsPoints;
|
||||
}
|
||||
|
||||
/** The points offered as places, and below them whatever else stays out. */
|
||||
function PreviewExtras({
|
||||
leftOut,
|
||||
withPoints,
|
||||
onTogglePoints,
|
||||
}: {
|
||||
leftOut: TourFileLeftOut;
|
||||
withPoints: boolean;
|
||||
onTogglePoints: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
|
||||
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDialog({
|
||||
gpxImport,
|
||||
files,
|
||||
@@ -183,6 +235,9 @@ function PreviewDialog({
|
||||
const labelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const choices = useGpxImportChoices(initial);
|
||||
const leftOut = useMemo(() => leftOutOf(files), [files]);
|
||||
const [withPoints, setWithPoints] = useState(true);
|
||||
const addsPoints = withPoints && leftOut.points > 0;
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
@@ -194,7 +249,8 @@ function PreviewDialog({
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
|
||||
const canImport = canConfirm(fresh, addsPoints, importMissing(choices.chosen) === null) && !gpxImport.importing;
|
||||
const confirmLabel = fresh > 0 ? t('tours.import.previewConfirm', { count: fresh }) : t('tours.import.pointsConfirm');
|
||||
const fields = importFields(choices.chosen);
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
@@ -231,9 +287,9 @@ function PreviewDialog({
|
||||
data-testid="gpx-preview-import"
|
||||
icon={<FileDown size={14} strokeWidth={2} />}
|
||||
disabled={!canImport}
|
||||
onClick={() => void gpxImport.confirm(fields)}
|
||||
onClick={() => void gpxImport.confirm(fields, { points: addsPoints })}
|
||||
>
|
||||
{t('tours.import.previewConfirm', { count: fresh })}
|
||||
{confirmLabel}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
@@ -258,8 +314,8 @@ function PreviewDialog({
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
<PreviewLeftOut files={files} />
|
||||
{fresh === 0 && (
|
||||
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
|
||||
{fresh === 0 && !addsPoints && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.import.previewAllDuplicates')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { tourGpxApi } from '../../api/tourGpx'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
import { pickFiles } from '../../../tests/helpers/filePicker'
|
||||
@@ -457,6 +458,7 @@ describe('ToursSidebar', () => {
|
||||
left_out: { points: 3, areas: 1, other: 0 },
|
||||
})
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
@@ -469,9 +471,11 @@ describe('ToursSidebar', () => {
|
||||
const rows = screen.getAllByTestId('gpx-preview-track')
|
||||
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
|
||||
expect(rows[1]).toHaveTextContent('No heights in the file')
|
||||
// The pins are offered as places (#2969); only the area stays out.
|
||||
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
|
||||
const leftOut = screen.getByTestId('gpx-preview-left-out')
|
||||
expect(leftOut).toHaveTextContent('Not imported')
|
||||
expect(leftOut).toHaveTextContent('3 pins')
|
||||
expect(leftOut).not.toHaveTextContent('pins')
|
||||
expect(leftOut).toHaveTextContent('1 area')
|
||||
expect(leftOut).not.toHaveTextContent('other item')
|
||||
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
|
||||
@@ -479,6 +483,8 @@ describe('ToursSidebar', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
|
||||
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
|
||||
expect(toast.success).toHaveBeenCalledWith('3 places added')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('a ride taken from a GPX file (HOR-02)', () => {
|
||||
expect(gpxImport.confirm).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
|
||||
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' }, { points: false });
|
||||
});
|
||||
|
||||
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { placesApi } from '../../api/client';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useTourGpxImport } from './useTourGpxImport';
|
||||
|
||||
// The toasts are caught at useToast, so no window global has to be set for them.
|
||||
@@ -67,3 +69,66 @@ describe('useTourGpxImport without a line in the file', () => {
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTourGpxImport with points beside the lines (#2969)', () => {
|
||||
const route = new File(['<gpx/>'], 'sygic.gpx');
|
||||
const pins = new File(['<kml/>'], 'pins.kml');
|
||||
const line = {
|
||||
name: 'Route',
|
||||
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
|
||||
point_count: 2,
|
||||
distance_km: 1,
|
||||
elevation_gain_m: null,
|
||||
elevation_loss_m: null,
|
||||
duplicate: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
|
||||
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
|
||||
});
|
||||
|
||||
const previewed = async (preview: (picked: File) => object) => {
|
||||
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
|
||||
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
|
||||
await act(() => hook.result.current.importFiles([route, pins]));
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('adds the points of each file as places through the places import, waypoints only', async () => {
|
||||
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
|
||||
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
|
||||
const { result } = await previewed((picked) => ({
|
||||
tracks: [line],
|
||||
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
|
||||
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
|
||||
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
|
||||
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
|
||||
});
|
||||
|
||||
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
|
||||
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
|
||||
const { result } = await previewed(() => ({
|
||||
tracks: [{ ...line, duplicate: true }],
|
||||
left_out: { points: 3, areas: 0, other: 0 },
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }));
|
||||
expect(importGpx).not.toHaveBeenCalled();
|
||||
expect(tourRepo.importGpx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names a file whose points could not be added, and still takes in its tour', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
|
||||
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
|
||||
const { result } = await previewed((picked) => ({
|
||||
tracks: [line],
|
||||
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
|
||||
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,10 +40,62 @@ function fileErrorMessage(error: unknown, name: string, t: Translate): string {
|
||||
export interface GpxPreviewFile {
|
||||
file: File;
|
||||
tracks: TourGpxPreviewTrack[];
|
||||
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
|
||||
/** What the file held besides its lines, which no tour is made of: GPX waypoints, KML pins, areas and the rest. */
|
||||
leftOut?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
/** What to do with the previewed files besides their lines. */
|
||||
export interface TourImportExtras {
|
||||
/** Add each file's points (GPX waypoints, KML pins) as places of the trip (#2969). */
|
||||
points?: boolean;
|
||||
}
|
||||
|
||||
interface ToursImported {
|
||||
tours: TourListItem[];
|
||||
skipped: number;
|
||||
caution: boolean;
|
||||
}
|
||||
|
||||
/** Each file with a line the trip does not hold yet, imported as tours; a file that fails is handed to `onError`. */
|
||||
async function importTours(
|
||||
tripId: number,
|
||||
files: readonly GpxPreviewFile[],
|
||||
fields: TourGpxImportFields,
|
||||
onError: (error: unknown, file: File) => void
|
||||
): Promise<ToursImported> {
|
||||
const sum: ToursImported = { tours: [], skipped: 0, caution: false };
|
||||
for (const { file, tracks } of files) {
|
||||
if (tracks.every((track) => track.duplicate)) continue;
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file, fields);
|
||||
sum.tours.push(...result.tours);
|
||||
sum.skipped += result.skipped;
|
||||
sum.caution ||= result.caution;
|
||||
} catch (error: unknown) {
|
||||
onError(error, file);
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
|
||||
async function importPoints(
|
||||
tripId: number,
|
||||
files: readonly GpxPreviewFile[],
|
||||
onError: (file: File) => void
|
||||
): Promise<number> {
|
||||
let places = 0;
|
||||
for (const { file, leftOut } of files) {
|
||||
if (!leftOut?.points) continue;
|
||||
try {
|
||||
places += await tourRepo.importFilePoints(tripId, file);
|
||||
} catch {
|
||||
onError(file);
|
||||
}
|
||||
}
|
||||
return places;
|
||||
}
|
||||
|
||||
interface TourGpxImportOptions {
|
||||
tripId: number;
|
||||
canEdit: boolean;
|
||||
@@ -115,26 +167,29 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
|
||||
[canEdit, fileError, importing, onNoTrack, preview, t, toast, tripId]
|
||||
);
|
||||
|
||||
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
|
||||
/**
|
||||
* The previewed files imported as the chosen type, summed up in one set of
|
||||
* toasts. With `points`, what each file holds as single points comes in as
|
||||
* places as well, so a file with a route and its stops loses neither (#2969).
|
||||
* A file whose every line the trip already holds is not sent to the Tours
|
||||
* import at all, only for its points.
|
||||
*/
|
||||
const confirm = useCallback(
|
||||
async (fields: TourGpxImportFields) => {
|
||||
async (fields: TourGpxImportFields, extras: TourImportExtras = {}) => {
|
||||
if (!canEdit || !preview || importing) return;
|
||||
setImporting(true);
|
||||
const imported: TourListItem[] = [];
|
||||
let skipped = 0;
|
||||
let caution = false;
|
||||
try {
|
||||
for (const { file } of preview) {
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file, fields);
|
||||
imported.push(...result.tours);
|
||||
skipped += result.skipped;
|
||||
caution ||= result.caution;
|
||||
} catch (error: unknown) {
|
||||
fileError(error, file);
|
||||
}
|
||||
}
|
||||
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
|
||||
const places = extras.points
|
||||
? await importPoints(tripId, preview, (file) =>
|
||||
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
|
||||
)
|
||||
: 0;
|
||||
setPreview(null);
|
||||
if (places > 0) {
|
||||
if (imported.length === 0) await refreshPlaces(tripId);
|
||||
toast.success(t('tours.import.pointsImported', { count: places }));
|
||||
}
|
||||
if (imported.length > 0) {
|
||||
// The server's place:created skips this socket, so the places are read
|
||||
// again here for the new ones and their lines. Only the places: a whole
|
||||
|
||||
@@ -232,3 +232,30 @@ describe('help registry: helpers', () => {
|
||||
expect(docsRoute({ slug: 'Creating-a-Trip', anchor: 'fields' })).toBe('/help/Creating-a-Trip#fields')
|
||||
})
|
||||
})
|
||||
|
||||
describe('help on a managed install', () => {
|
||||
it('leaves out the guides and screens about what the operator runs', async () => {
|
||||
const { useAuthStore } = await import('../store/authStore')
|
||||
const { useAddonStore } = await import('../store/addonStore')
|
||||
const reg = await import('./registry')
|
||||
useAuthStore.setState({ managed: true })
|
||||
try {
|
||||
expect(reg.getHelpGuide('create-backup')).toBeNull()
|
||||
expect(reg.getHelpGuide('instance-keys')).toBeNull()
|
||||
expect(reg.getHelpContext('admin-backup')).toBeNull()
|
||||
expect(reg.allHelpGuides().some(g => g.context === 'admin-storage')).toBe(false)
|
||||
expect(reg.getHelpGuide('files-sync')).toBeNull()
|
||||
// Integrations follow whether the instance has them on.
|
||||
useAddonStore.setState({ addons: [] })
|
||||
expect(reg.getHelpGuide('connect-dawarich')).toBeNull()
|
||||
useAddonStore.setState({ addons: [{ id: 'dawarich', enabled: true } as never] })
|
||||
expect(reg.getHelpGuide('connect-dawarich')).not.toBeNull()
|
||||
// What a hosted admin can still do keeps its guide.
|
||||
expect(reg.getHelpGuide('create-user')).not.toBeNull()
|
||||
} finally {
|
||||
useAuthStore.setState({ managed: false })
|
||||
useAddonStore.setState({ addons: [] })
|
||||
}
|
||||
expect(reg.getHelpGuide('create-backup')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { HelpContext, HelpGuide } from './types'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { dashboardContext, dashboardGuides } from './contexts/dashboard'
|
||||
import { vacayContext, vacayGuides } from './contexts/vacay'
|
||||
import { atlasContext, atlasGuides } from './contexts/atlas'
|
||||
@@ -74,36 +76,87 @@ const GUIDE_LIST: HelpGuide[] = [
|
||||
export const HELP_CONTEXTS: ReadonlyMap<string, HelpContext> = new Map(CONTEXT_LIST.map(c => [c.id, c]))
|
||||
export const HELP_GUIDES: ReadonlyMap<string, HelpGuide> = new Map(GUIDE_LIST.map(g => [g.id, g]))
|
||||
|
||||
/**
|
||||
* What a managed install leaves out: the admin screens it hides (AdminPage) and
|
||||
* the guides about what the operator runs there, such as keys, OIDC, place
|
||||
* search, routing, backups, storage, releases and plugins. A guide that walks
|
||||
* somebody through a panel they cannot open is worse than none.
|
||||
*/
|
||||
const MANAGED_HIDDEN_CONTEXTS: ReadonlySet<string> = new Set([
|
||||
'admin-plugins', 'admin-storage', 'admin-github', 'admin-backup', 'settings-plugins',
|
||||
])
|
||||
const MANAGED_HIDDEN_GUIDES: ReadonlySet<string> = new Set([
|
||||
'oidc', 'instance-keys', 'places-transit', 'rotate-jwt-secret', 'own-routing-engine',
|
||||
'install-plugin', 'storage-backends', 'release-history', 'create-backup', 'auto-backup',
|
||||
'plugin-settings', 'files-sync', 'files-sync-settings', 'document-providers',
|
||||
])
|
||||
|
||||
/**
|
||||
* Guides about an integration, shown on a managed install only while it is on
|
||||
* there: the operator decides which integrations the instance has, and a guide
|
||||
* to connect one it does not offer leads nowhere.
|
||||
*/
|
||||
const addonOn = (id: string) => (): boolean => useAddonStore.getState().isEnabled(id)
|
||||
const MANAGED_FEATURE_GUIDES: Readonly<Record<string, () => boolean>> = {
|
||||
'connect-dawarich': addonOn('dawarich'),
|
||||
'dawarich-countries': addonOn('dawarich'),
|
||||
'dawarich-wishes': addonOn('dawarich'),
|
||||
'dawarich-stays': addonOn('dawarich'),
|
||||
'journey-day-stays': addonOn('dawarich'),
|
||||
'map-dawarich-trail': addonOn('dawarich'),
|
||||
'connect-airtrail': addonOn('airtrail'),
|
||||
'airtrail-import': addonOn('airtrail'),
|
||||
'photo-providers': addonOn('memories'),
|
||||
'mcp-oauth': addonOn('mcp'),
|
||||
'api-keys': addonOn('mcp'),
|
||||
'mcp-tokens-admin': addonOn('mcp'),
|
||||
'tour-connect-wanderer': () => useAddonStore.getState().toursWanderer,
|
||||
'tour-wanderer-watch': () => useAddonStore.getState().toursWanderer,
|
||||
'tour-import-wanderer': () => useAddonStore.getState().toursWanderer,
|
||||
}
|
||||
|
||||
/** Read at call time: `managed` arrives with app-config, after the registry loaded. */
|
||||
const isManaged = (): boolean => useAuthStore.getState().managed
|
||||
const shownContext = (c: HelpContext): boolean => !(isManaged() && MANAGED_HIDDEN_CONTEXTS.has(c.id))
|
||||
const shownGuide = (g: HelpGuide): boolean => {
|
||||
if (!isManaged()) return true
|
||||
if (MANAGED_HIDDEN_GUIDES.has(g.id) || MANAGED_HIDDEN_CONTEXTS.has(g.context)) return false
|
||||
const feature = MANAGED_FEATURE_GUIDES[g.id]
|
||||
return feature ? feature() : true
|
||||
}
|
||||
|
||||
export function getHelpContext(id: string | null | undefined): HelpContext | null {
|
||||
return id ? (HELP_CONTEXTS.get(id) ?? null) : null
|
||||
const context = id ? HELP_CONTEXTS.get(id) : undefined
|
||||
return context && shownContext(context) ? context : null
|
||||
}
|
||||
|
||||
export function getHelpGuide(id: string | null | undefined): HelpGuide | null {
|
||||
return id ? (HELP_GUIDES.get(id) ?? null) : null
|
||||
const guide = id ? HELP_GUIDES.get(id) : undefined
|
||||
return guide && shownGuide(guide) ? guide : null
|
||||
}
|
||||
|
||||
/** The guides of a context, in the order the context lists them. */
|
||||
export function guidesFor(context: HelpContext): HelpGuide[] {
|
||||
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined)
|
||||
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined && shownGuide(g))
|
||||
}
|
||||
|
||||
export function allHelpGuides(): HelpGuide[] {
|
||||
return GUIDE_LIST
|
||||
return isManaged() ? GUIDE_LIST.filter(shownGuide) : GUIDE_LIST
|
||||
}
|
||||
|
||||
/** Every screen that has help, in registration order, for the screen switcher. */
|
||||
export function allHelpContexts(): HelpContext[] {
|
||||
return CONTEXT_LIST
|
||||
return isManaged() ? CONTEXT_LIST.filter(shownContext) : CONTEXT_LIST
|
||||
}
|
||||
|
||||
/** The screens that stand on their own; their sub-screens hang under them in the switcher. */
|
||||
export function topLevelHelpContexts(): HelpContext[] {
|
||||
return CONTEXT_LIST.filter(c => !c.parent)
|
||||
return CONTEXT_LIST.filter(c => !c.parent && shownContext(c))
|
||||
}
|
||||
|
||||
/** The sub-screens of a screen, in registration order. */
|
||||
export function childHelpContexts(parentId: string): HelpContext[] {
|
||||
return CONTEXT_LIST.filter(c => c.parent === parentId)
|
||||
return CONTEXT_LIST.filter(c => c.parent === parentId && shownContext(c))
|
||||
}
|
||||
|
||||
/** The screen and the screens above it, outermost first, for a breadcrumb. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
|
||||
* once, the way Safari lands after the iOS Home Screen app handed the PDF over.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useExportDeepLink } from './useExportDeepLink';
|
||||
|
||||
function setup(entry: string) {
|
||||
const openExport = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[entry]}>{children}</MemoryRouter>
|
||||
);
|
||||
const { result, rerender } = renderHook(
|
||||
() => {
|
||||
useExportDeepLink(openExport);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper }
|
||||
);
|
||||
return { openExport, result, rerender };
|
||||
}
|
||||
|
||||
describe('useExportDeepLink', () => {
|
||||
it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
|
||||
const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.pathname).toBe('/trips/7');
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
|
||||
rerender();
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
|
||||
const { openExport, result } = setup('/trips/7?tab=plan');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
|
||||
const { openExport, result } = setup('/trips/7?export=gpx');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?export=gpx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useEffectEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
|
||||
|
||||
/**
|
||||
* Opens the trip's export when the address asks for it (`?export=pdf`), the way
|
||||
* the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
|
||||
* once read, so a reload or a copied link does not open the dialog again.
|
||||
*/
|
||||
export function useExportDeepLink(openExport: () => void): void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
|
||||
// The shells pass a fresh closure on every render; the request is answered once.
|
||||
const open = useEffectEvent(openExport);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requested) return;
|
||||
open();
|
||||
setSearchParams(
|
||||
(p) => {
|
||||
p.delete(EXPORT_PARAM);
|
||||
return p;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [requested, setSearchParams]);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export default function MAdmin() {
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Blocks },
|
||||
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
|
||||
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle }]),
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive }]),
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
@@ -115,13 +116,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{addon.id === 'documents' && addon.enabled && <MProviderShelf options={documentProviderOptions} />}
|
||||
{addon.id === 'tours' && addon.enabled && (
|
||||
<>
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
{wandererOffered && (
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
)}
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<MSubRow
|
||||
key={key}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../
|
||||
import { weekStartOptions } from '../../../utils/calendarWeek'
|
||||
import { withTileApiKey } from '../../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { useDefaultUserSettings, type Defaults } from '../../../components/Admin/useDefaultUserSettings'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
@@ -22,7 +22,7 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
|
||||
export default function MAdminDefaultUserSettings(): 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: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
|
||||
@@ -80,14 +80,14 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
]
|
||||
const providerOptions: { value: string; label: React.ReactNode }[] = [
|
||||
{ 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') },
|
||||
]
|
||||
|
||||
const currencyLabel = defaults.default_currency
|
||||
? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
|
||||
: t('settings.currency')
|
||||
const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
|
||||
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
|
||||
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
|
||||
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
title={t('settings.mapTemplate')}
|
||||
value={mapTileUrl}
|
||||
onSelect={pickTilePreset}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
|
||||
<MSetPickerSheet
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin';
|
||||
import type { TranslationFn } from '../../../types';
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput } from './MAdminUi';
|
||||
|
||||
interface MAdminOidcCardProps {
|
||||
admin: ReturnType<typeof useAdmin>;
|
||||
t: TranslationFn;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** OIDC / SSO, as on the desktop settings tab. The settings section leaves it out on a managed install. */
|
||||
export default function MAdminOidcCard({ admin, t, onSave }: MAdminOidcCardProps) {
|
||||
const { oidcConfig, setOidcConfig, savingOidc } = admin;
|
||||
return (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.oidcDisplayName')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.display_name}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
|
||||
placeholder={t('admin.oidcDisplayNamePlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.issuer}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField
|
||||
label={t('admin.oidcDiscoveryUrl')}
|
||||
hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}
|
||||
>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientId')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientSecret')}>
|
||||
<MAdminInput
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
|
||||
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminButton busy={savingOidc} onClick={onSave}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
|
||||
import MAdminOidcCard from './MAdminOidcCard'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import {
|
||||
MAdminButton,
|
||||
@@ -31,7 +32,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
placeShadowEnabled,
|
||||
oidcConfig, setOidcConfig, savingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
||||
@@ -134,6 +134,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
{t('admin.passkey.notConfigured')}
|
||||
</p>
|
||||
)}
|
||||
{/* The relying party is the instance's address, which the operator owns on a
|
||||
managed install (server managed.ts), same as the desktop tab. */}
|
||||
{!managed && (
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.passkey.rpId')} hint={t('admin.passkey.rpIdHint')}>
|
||||
<MAdminInput
|
||||
@@ -155,6 +158,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
</MAdminCard>
|
||||
|
||||
{/* Require 2FA */}
|
||||
@@ -187,7 +191,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
</div>
|
||||
</MAdminCard>
|
||||
|
||||
{/* API keys */}
|
||||
{/* API keys, place search and routing: the operator's on a managed install,
|
||||
same as the desktop tab. Share place details has its own section there. */}
|
||||
{!managed && (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
|
||||
<div className="space-y-3">
|
||||
@@ -379,55 +385,10 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
)}
|
||||
</div>
|
||||
</MAdminCard>
|
||||
)}
|
||||
|
||||
{/* OIDC / SSO */}
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.oidcDisplayName')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.display_name}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
|
||||
placeholder={t('admin.oidcDisplayNamePlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.issuer}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcDiscoveryUrl')} hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientId')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientSecret')}>
|
||||
<MAdminInput
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
|
||||
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminButton busy={savingOidc} onClick={saveOidc}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
{/* OIDC / SSO: the operator's on a managed install, as on the desktop tab. */}
|
||||
{!managed && <MAdminOidcCard admin={admin} t={t} onSave={saveOidc} />}
|
||||
|
||||
{/* Danger zone */}
|
||||
<MAdminCard className="border-[color:color-mix(in_srgb,var(--m-st-danger)_28%,transparent)]">
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function MSettings() {
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []),
|
||||
...(hasPlugins ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
|
||||
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User },
|
||||
// Same call as the desktop page: About is about the project — what TREK is,
|
||||
@@ -116,7 +116,7 @@ export default function MSettings() {
|
||||
{active.id === 'notifications' && <MSettingsNotifications />}
|
||||
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
|
||||
{/* Per-plugin settings still reuse the existing responsive tab. */}
|
||||
{active.id === 'plugins' && hasPlugins && <MSettingsPlugins />}
|
||||
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
|
||||
{active.id === 'offline' && <MSettingsOffline />}
|
||||
{active.id === 'account' && <MSettingsAccount />}
|
||||
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MapView } from '../../../components/Map/MapView'
|
||||
import ErrorBoundary from '../../../components/shared/ErrorBoundary'
|
||||
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy'
|
||||
import { defaultStyleForProvider, getStylePresets } from '../../../components/Map/glProviders'
|
||||
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { useMapSettingsForm } from '../../../components/Settings/useMapSettingsForm'
|
||||
import { withTileApiKey } from '../../../utils/tileUrl'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -20,7 +20,7 @@ import MSetPickerSheet from './MSetPickerSheet'
|
||||
export default function MSettingsMap() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
managed, presets: tilePresets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
|
||||
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
|
||||
@@ -29,7 +29,7 @@ export default function MSettingsMap() {
|
||||
|
||||
const providers: { id: MapProvider; name: string; sub: string; icon: typeof Layers }[] = [
|
||||
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
|
||||
{ id: 'mapbox-gl', name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box },
|
||||
...(canMapbox ? [{ id: 'mapbox-gl' as const, name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box }] : []),
|
||||
{ id: 'maplibre-gl', name: 'MapLibre GL', sub: t('settings.mapMapLibreSubtitle'), icon: Globe2 },
|
||||
]
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MSettingsMap() {
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapTemplate')}</MSetEyebrow>
|
||||
<MSetSelectRow
|
||||
label={MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
|
||||
label={tilePresets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
|
||||
trailing={chevron}
|
||||
onClick={() => setPresetOpen(true)}
|
||||
/>
|
||||
@@ -221,7 +221,7 @@ export default function MSettingsMap() {
|
||||
title={t('settings.mapTemplate')}
|
||||
value={mapTileUrl}
|
||||
onSelect={setMapTileUrl}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
options={tilePresets.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
|
||||
<MSetPickerSheet
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useTripExport } from '../../../../components/Planner/useTripExport'
|
||||
import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const open = shell.sheet?.id === 'export'
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => shell.openSheet('export'))
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
|
||||
// applies the day plan's filter itself, so both shells print the same. The PDF stays
|
||||
// open here; a calendar or GPX file closes the sheet.
|
||||
|
||||
@@ -1837,4 +1837,12 @@ describe('AdminPage', () => {
|
||||
await screen.findByRole('button', { name: /^users$/i });
|
||||
expect(screen.queryByRole('button', { name: /^storage$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-ADMIN-STOR-003: managed mode hides the Plugins tab, and ?tab=plugins falls back to Users', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
render(<AdminPage />, { initialEntries: ['/admin?tab=plugins'] });
|
||||
await screen.findByRole('button', { name: /^users$/i });
|
||||
expect(screen.queryByRole('button', { name: /^plugins$/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: /plugins/i })).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,9 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
|
||||
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
|
||||
// Plugins run third-party code that can send data anywhere; a hosted instance
|
||||
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
|
||||
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
|
||||
// Storage backends and their credentials are hoster-level configuration —
|
||||
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
|
||||
|
||||
@@ -4,10 +4,9 @@ import { render, screen, waitFor, cleanup, within } from '../../tests/helpers/re
|
||||
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 { resetAllStores } from '../../tests/helpers/store';
|
||||
import { seedJourneyOwner } from '../../tests/helpers/journeyDetail';
|
||||
import { buildUser } from '../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePermissionsStore } from '../store/permissionsStore';
|
||||
import { useJourneyStore } from '../store/journeyStore';
|
||||
import JourneyDetailPage from './JourneyDetailPage';
|
||||
|
||||
@@ -238,8 +237,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
useJourneyStore.setState(useJourneyStore.getState(), true);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: ownerUser });
|
||||
seedStore(usePermissionsStore, { level: 'owner' } as any);
|
||||
seedJourneyOwner(ownerUser);
|
||||
setupDefaultHandlers();
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
// Always there: API keys sit in this tab and need no addon. The addon backed
|
||||
// sections inside it gate themselves.
|
||||
{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug },
|
||||
...(hasPlugins
|
||||
...(hasPlugins && !managed
|
||||
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Blocks }]
|
||||
: []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
@@ -87,7 +87,7 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && <IntegrationsTab />}
|
||||
{activeTab === 'plugins' && hasPlugins && <PluginSettingsTab />}
|
||||
{activeTab === 'plugins' && hasPlugins && !managed && <PluginSettingsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
|
||||
@@ -78,6 +78,21 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
</EditorField>
|
||||
)
|
||||
|
||||
const tripRemindersCard = (
|
||||
<SettingsCard
|
||||
icon={CalendarClock}
|
||||
title={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
return (<>
|
||||
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
|
||||
cards are a title with a switch on the far right, so on a wide screen the
|
||||
@@ -168,19 +183,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
action={<LockedSwitch />}
|
||||
/>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<SettingsCard
|
||||
icon={CalendarClock}
|
||||
title={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!managed && tripRemindersCard}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
@@ -284,6 +287,9 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
over, so it belongs under the targets it routes to rather than across the
|
||||
full width below both columns. */}
|
||||
<AdminNotificationsPanel t={t} toast={toast} />
|
||||
{/* Without the admin alert cards the right column is short, so the
|
||||
reminder switch moves over on a managed install. */}
|
||||
{managed && tripRemindersCard}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -320,24 +320,24 @@ describe('AdminSettingsTab', () => {
|
||||
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022e: a managed install hides the keys but keeps the provider choice', () => {
|
||||
// The operator owns the credentials; which of them answers place search is
|
||||
// still the admin's call (server managed.ts lists places_provider as theirs).
|
||||
it('FE-ADMSET-022e: a managed install hides place search, routing and the keys', () => {
|
||||
// All of it is the operator's there (server managed.ts locks places_provider);
|
||||
// Share place details has a tab of its own on AdminPage.
|
||||
renderTab({ managed: true, placesProvider: 'auto' });
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
|
||||
expect(within(card(SERVICES)).getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: SERVICES })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
|
||||
// The key fields are not on the page here, and an operator key set through
|
||||
// the environment would not show up in them anyway. app-config knows.
|
||||
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
|
||||
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
|
||||
// With place search and the keys gone the right column would stand empty.
|
||||
renderTab({ managed: true });
|
||||
|
||||
expect(
|
||||
within(card(SERVICES)).queryByText(/falls back to OpenStreetMap/i),
|
||||
).not.toBeInTheDocument();
|
||||
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
|
||||
expect(right).not.toBeNull();
|
||||
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions'
|
||||
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
|
||||
import ProviderBlock from './ProviderBlock'
|
||||
import TrekApiCard from './TrekApiCard'
|
||||
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
|
||||
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
|
||||
import { placesGoogleOnlyHint } from '../../utils/placeSource'
|
||||
import type { TranslationFn } from '../../types'
|
||||
@@ -140,6 +141,29 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
const passwordLoginLocked = !passwordLogin && !oidcLogin
|
||||
const oidcLoginLocked = !passwordLogin && oidcLogin
|
||||
|
||||
// On a managed install the right column loses place search, routing and the
|
||||
// keys to the operator, so file types move over and the columns stay even.
|
||||
const fileTypesCard = (
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('admin.fileTypes')}
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
|
||||
</div>
|
||||
<SaveBar
|
||||
label={t('common.save')}
|
||||
saving={savingFileTypes}
|
||||
onClick={saveFileTypes}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Two columns from xl up. Nearly every card here is "label left,
|
||||
@@ -351,33 +375,24 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Allowed File Types */}
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('admin.fileTypes')}
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
|
||||
</div>
|
||||
<SaveBar
|
||||
label={t('common.save')}
|
||||
saving={savingFileTypes}
|
||||
onClick={saveFileTypes}
|
||||
/>
|
||||
</SettingsCard>
|
||||
{!managed && fileTypesCard}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{managed && fileTypesCard}
|
||||
{/* Inside the place search card elsewhere; that card is the operator's here,
|
||||
sharing place details is still the admin's decision. */}
|
||||
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
|
||||
|
||||
{/* Place search and routing: TREK's own services first, then which provider
|
||||
answers search and transit, and the search log. Apart from the keys, so a
|
||||
keyless install reads its whole setup in one card. Everything here saves
|
||||
as it is changed. */}
|
||||
{/* On a managed install place search and routing are the operator's: the
|
||||
services, their allowance and the providers behind them come with the
|
||||
instance. Share place details stays the admin's call and has a tab of
|
||||
its own there (AdminPage). */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
|
||||
<TrekApiCard t={t} managed={managed} />
|
||||
{!managed && (<>
|
||||
@@ -486,6 +501,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingRows>
|
||||
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
|
||||
the operator's on a managed install, so the card is not shown there;
|
||||
|
||||
@@ -33,7 +33,7 @@ const ADMIN_TAB_IDS = [
|
||||
* hosted instance. Checked in an effect rather than in the initial state
|
||||
* because `managed` arrives with /app-config, after the first render.
|
||||
*/
|
||||
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
|
||||
const MANAGED_HIDDEN = ['storage', 'github', 'backup', 'plugins']
|
||||
|
||||
/** How often the open users list asks again who is online and active. */
|
||||
const USERS_REFRESH_MS = 30_000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourActivityAssignRequest, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourSkiClassificationRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
import { placesApi, toursApi } from '../api/client'
|
||||
import { tourGpxApi } from '../api/tourGpx'
|
||||
import { recordedTrackApi } from '../api/recordedTrack'
|
||||
import { tourActivitiesApi } from '../api/tourActivities'
|
||||
@@ -499,6 +499,19 @@ export const tourRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* The single points of a file (GPX waypoints, KML pins) added as places of
|
||||
* the trip through the places import, which reads nothing else of it here
|
||||
* (#2969). Online only, like the Tours import beside it. Answers how many
|
||||
* places came in.
|
||||
*/
|
||||
async importFilePoints(tripId: number | string, file: File): Promise<number> {
|
||||
const result = file.name.toLowerCase().endsWith('.gpx')
|
||||
? await placesApi.importGpx(tripId, file, { waypoints: true, routes: false, tracks: false })
|
||||
: await placesApi.importMapFile(tripId, file, { points: true, paths: false })
|
||||
return result.count ?? 0
|
||||
},
|
||||
|
||||
/** Reads a GPX, KML or KMZ file for its preview; online only, like the import, and nothing is cached. */
|
||||
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
|
||||
return tourGpxApi.preview(tripId, file)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* FE-PDFSAFARI-001: the link the iOS Home Screen app hands to Safari.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { EXPORT_PARAM, EXPORT_PDF, pdfExportUrl } from './pdfSafariHandoff';
|
||||
|
||||
describe('pdfExportUrl', () => {
|
||||
it('FE-PDFSAFARI-001: is the absolute trip address with the export asked for', () => {
|
||||
const url = new URL(pdfExportUrl(42));
|
||||
expect(url.origin).toBe(window.location.origin);
|
||||
expect(url.pathname).toBe('/trips/42');
|
||||
expect(url.searchParams.get(EXPORT_PARAM)).toBe(EXPORT_PDF);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* An iPhone or iPad ignores print() in an app added to the Home Screen, in the
|
||||
* page and in an iframe alike, so the trip PDF cannot be saved from there. Safari
|
||||
* can print it. This is the link that hands the export over: it opens the trip in
|
||||
* Safari with the export dialog already up.
|
||||
*/
|
||||
|
||||
export const EXPORT_PARAM = 'export';
|
||||
export const EXPORT_PDF = 'pdf';
|
||||
|
||||
/** The trip, opened with its export dialog showing. Absolute, since it leaves the installed app. */
|
||||
export function pdfExportUrl(tripId: number): string {
|
||||
return `${window.location.origin}/trips/${tripId}?${EXPORT_PARAM}=${EXPORT_PDF}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useAddonStore } from '../../src/store/addonStore';
|
||||
import { useAuthStore } from '../../src/store/authStore';
|
||||
import { usePermissionsStore } from '../../src/store/permissionsStore';
|
||||
import type { User } from '../../src/types';
|
||||
import { seedStore } from './store';
|
||||
|
||||
/**
|
||||
* The install the journey page suite runs on: the owner is signed in with full
|
||||
* rights, and a photo provider is on, so the entry editor offers External photos.
|
||||
*/
|
||||
export function seedJourneyOwner(user: User): void {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user });
|
||||
seedStore(usePermissionsStore, { level: 'owner' } as never);
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never],
|
||||
});
|
||||
}
|
||||
@@ -281,16 +281,16 @@ describe('MAdminSettingsSection', () => {
|
||||
expect(admin.handleSaveApiKeys).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-016b: a managed install hides the keys but keeps the provider choice', () => {
|
||||
// The operator owns the credentials; which of them answers place search is
|
||||
// still the admin's call, the same split the desktop tab makes.
|
||||
it('FE-MOB-ASET-016b: a managed install hides the keys, place search, OIDC and the relying party', () => {
|
||||
// All of it is the operator's there, the same split the desktop tab makes.
|
||||
renderSettings({ managed: true });
|
||||
|
||||
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Amap/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Unsplash API Key')).not.toBeInTheDocument();
|
||||
// The native select became TREK's own picker: a button carrying the choice.
|
||||
expect(screen.getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText('trek.example.org')).not.toBeInTheDocument();
|
||||
expect(screen.queryByPlaceholderText(/Authentik/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-ASET-016c: the missing-key notice follows app-config, not the fields', () => {
|
||||
@@ -301,7 +301,7 @@ describe('MAdminSettingsSection', () => {
|
||||
expect(screen.getByText(/TREK index and OpenStreetMap alone/i)).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderSettings({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
|
||||
renderSettings({ placesProvider: 'google', mapsKey: '', hasMapsKey: true });
|
||||
expect(screen.queryByText(/TREK index and OpenStreetMap alone/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-015
|
||||
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-016
|
||||
// This sheet reads its copy from useTranslation(), so the assertions are English.
|
||||
|
||||
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
|
||||
@@ -248,4 +248,10 @@ describe('MExportSheet', () => {
|
||||
expect(route?.signal?.aborted).toBe(true)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-016: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the sheet', () => {
|
||||
const shell = buildShell({ sheet: null })
|
||||
render(<MExportSheet planner={buildPlanner({ days: DAYS })} shell={shell} />, { initialEntries: ['/trips/1?export=pdf'] })
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('export')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -114,6 +114,12 @@ export class AddonsService {
|
||||
return this.writeFlag('ski_routing_enabled', enabled);
|
||||
}
|
||||
|
||||
/** The photo providers users can connect: none while the journey addon is off. */
|
||||
async enabledPhotoProviderIds(): Promise<string[]> {
|
||||
if (!(await this.isAddonEnabled(ADDON_IDS.JOURNEY))) return [];
|
||||
return this.photoProviders.listEnabledIds();
|
||||
}
|
||||
|
||||
/** wanderer may be used: Tours is on and the admin allowed it under Tours. */
|
||||
async isWandererAvailable(): Promise<boolean> {
|
||||
return (await this.isAddonEnabled(ADDON_IDS.TOURS)) && (await this.getToursWanderer()).enabled;
|
||||
|
||||
@@ -293,6 +293,7 @@ export class AdminController {
|
||||
|
||||
@Put('tours-wanderer')
|
||||
@ResponseContract(adminFeatureToggleResponseSchema)
|
||||
@ManagedForbidden('wanderer sends searches to a server the user picks, which the operator has not agreed to')
|
||||
async updateToursWanderer(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateToursWanderer(body.enabled);
|
||||
await this.audit.writeAudit({
|
||||
@@ -418,6 +419,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Put('place-shadow')
|
||||
@ManagedForbidden('place search belongs to the operator on a managed install')
|
||||
async updatePlaceShadow(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) {
|
||||
const result = await this.addons.updatePlaceShadow(body.enabled);
|
||||
await this.audit.writeAudit({
|
||||
@@ -491,6 +493,7 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Put('transit-provider')
|
||||
@ManagedForbidden('routing belongs to the operator on a managed install')
|
||||
async updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) {
|
||||
const result = await this.addons.updateTransitProvider(body.provider, user.id);
|
||||
await this.audit.writeAudit({
|
||||
|
||||
@@ -266,6 +266,9 @@ export class AssistantToolSession {
|
||||
/** The groups this turn loaded; the core is always there. */
|
||||
private readonly loaded = new Set<string>();
|
||||
|
||||
/** Tools the model called before their group was loaded in this turn, by name, with that group. */
|
||||
private readonly calledBlind = new Map<string, string>();
|
||||
|
||||
/** Whether the session offers its tools by group: a trip's turn and a home turn do. */
|
||||
private get grouped(): boolean {
|
||||
return this.mode === 'turn' || this.mode === 'home';
|
||||
@@ -307,6 +310,13 @@ export class AssistantToolSession {
|
||||
return tool?.offered ? tool.kind : null;
|
||||
}
|
||||
|
||||
/** The group of a tool this grouped session holds but has not loaded in this turn; null otherwise. */
|
||||
private unloadedGroupOf(name: string): string | null {
|
||||
const group = this.tools.get(name)?.hint.group;
|
||||
if (!this.grouped || !group || this.loaded.has(group)) return null;
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The home chat offers a journal's page while the user's journals can be listed (the journal addon is on). */
|
||||
private get offersJournalChat(): boolean {
|
||||
return this.mode === 'home' && this.tools.has(OPEN_JOURNEY_CHAT_READ);
|
||||
@@ -323,6 +333,15 @@ export class AssistantToolSession {
|
||||
* conversation's is refused.
|
||||
*/
|
||||
pinArgs(name: string, args: ToolArgs): { args: ToolArgs } | { error: string } {
|
||||
const unloaded = this.unloadedGroupOf(name);
|
||||
if (unloaded) {
|
||||
// The model called a tool it was not offered in this turn (it knows the
|
||||
// name from an earlier turn), so it wrote the arguments without the
|
||||
// schema (#2967). Its group is loaded here, so the next step offers the
|
||||
// schema, and a refusal of these arguments says so (see call()).
|
||||
this.loaded.add(unloaded);
|
||||
this.calledBlind.set(name, unloaded);
|
||||
}
|
||||
const given = pinNamesOf(this.pinArg).filter((key) => args[key] !== undefined);
|
||||
if (given.some((key) => Number(args[key]) !== this.pinnedId)) {
|
||||
const about = this.pinArg === PINNED_ARG ? 'trip' : 'journey';
|
||||
@@ -342,6 +361,15 @@ export class AssistantToolSession {
|
||||
const final = name === TRANSIT_CREATE_TOOL ? await this.withFoundItinerary(args) : args;
|
||||
const run = await this.invoke(name, final);
|
||||
this.audit(name);
|
||||
const blind = this.calledBlind.get(name);
|
||||
if (!run.ok && blind) {
|
||||
const hint = `${name} was called before the "${blind}" tools were loaded in this turn, so its arguments were written without its schema. They are loaded now and the schema is in your tools: send the call again with the arguments exactly as it describes them.`;
|
||||
return {
|
||||
ok: false,
|
||||
value: `${typeof run.value === 'string' ? run.value : JSON.stringify(run.value)}
|
||||
${hint}`,
|
||||
};
|
||||
}
|
||||
if (name === TRANSIT_SEARCH_TOOL && run.ok) this.itineraries.keep(run.value);
|
||||
return run;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const APP_SETTINGS = {
|
||||
webauthn_origins: { schema: text, default: null, adminForm: true, managedLocked: true },
|
||||
// 'auto' (what every install had before Amap existed), 'google', 'amap' or
|
||||
// 'openstreetmap'; the maps domain validates it (isPlacesProviderChoice).
|
||||
places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true },
|
||||
places_provider: { schema: text, default: 'auto', adminForm: true, publicConfig: true, managedLocked: true },
|
||||
|
||||
// Read by name elsewhere.
|
||||
oidc_issuer: { schema: text, default: null, env: (e) => e.oidc.issuer, publicConfig: true },
|
||||
|
||||
@@ -91,6 +91,9 @@ export const MANAGED_LOCKED_SETTING_KEYS = [
|
||||
'oidc_login',
|
||||
'oidc_registration',
|
||||
'openweather_api_key',
|
||||
// Place search and routing are the operator's on a managed install: the
|
||||
// services, their allowance and who answers come with the instance.
|
||||
'places_provider',
|
||||
'routing_base_url',
|
||||
'routing_valhalla_provider',
|
||||
// The operator issues a managed install its key through TREK_SERVICES_KEY.
|
||||
@@ -149,10 +152,6 @@ export const MANAGED_CUSTOMER_KEYS = [
|
||||
'passkey_login',
|
||||
'password_login',
|
||||
'password_registration',
|
||||
// WHICH provider answers place search stays the admin's call even when the
|
||||
// operator owns both credentials: it decides what their users see, not what
|
||||
// the operator is billed for — the keys above cover that.
|
||||
'places_provider',
|
||||
'require_mfa',
|
||||
'temperature_unit',
|
||||
'time_format',
|
||||
|
||||
@@ -64,6 +64,9 @@ export function collectManagedRoutes(app: INestApplication): ManagedRouteEntry[]
|
||||
*/
|
||||
export const MANAGED_ROUTE_ALLOW_LIST: string[] = [
|
||||
'AdminController.rotateJwtSecret',
|
||||
'AdminController.updatePlaceShadow',
|
||||
'AdminController.updateToursWanderer',
|
||||
'AdminController.updateTransitProvider',
|
||||
'AdminOidcController.get',
|
||||
'AdminOidcController.update',
|
||||
'AiAdminController.connection',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { readEnv } from '../../app-config';
|
||||
import { AppSettings } from '../../db/entities/AppSettings.entity';
|
||||
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
|
||||
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
|
||||
import type { NotificationChannelPreferencesRepository } from '../../db/repositories/NotificationChannelPreferences.repository';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { readAppSetting } from '../common/app-settings.registry';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { listChannels } from './channel-registry';
|
||||
@@ -60,6 +63,21 @@ const DEFAULT_KEY = (event: string, channel: string) => `notif_default_${event}_
|
||||
* 3-column composite primary key (`user`, `event_type`, `channel`) via
|
||||
* `em.upsert({ onConflictFields: [...] })`, per Task 0's R5 pin.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The events this install can actually raise, for the preference matrices.
|
||||
* Plugin notifications need the plugin runtime; on a managed install the
|
||||
* release check and the storage replicas are the operator's, so their alerts
|
||||
* would be rows nobody there can act on.
|
||||
*/
|
||||
export function offeredEventTypes(): NotifEventType[] {
|
||||
const env = readEnv();
|
||||
return ALL_EVENT_TYPES.filter((e) => {
|
||||
if (e === 'plugin_notification') return env.plugins.enabled;
|
||||
if (e === 'version_available' || e === 'replica_failure') return !env.managed.enabled;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@Injectable()
|
||||
export class NotificationPreferencesService {
|
||||
constructor(
|
||||
@@ -68,8 +86,56 @@ export class NotificationPreferencesService {
|
||||
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
|
||||
@InjectRepository(NotificationChannelPreferences)
|
||||
private readonly channelPrefs: NotificationChannelPreferencesRepository,
|
||||
private readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The events the matrices list: what the install can raise at all
|
||||
* (offeredEventTypes), narrowed to the features that are switched on. A row
|
||||
* for vacation sharing on an install without Vacay, or for Synology without
|
||||
* the provider, is a switch that never does anything.
|
||||
*/
|
||||
private async listedEventTypes(): Promise<NotifEventType[]> {
|
||||
const photoProviders = await this.addons.enabledPhotoProviderIds();
|
||||
// One read per addon, shared by the events that hang off it.
|
||||
const addonOn = new Map<string, Promise<boolean>>();
|
||||
const on = (id: string): Promise<boolean> => {
|
||||
if (!addonOn.has(id)) addonOn.set(id, this.addons.isAddonEnabled(id));
|
||||
return addonOn.get(id) as Promise<boolean>;
|
||||
};
|
||||
const events = offeredEventTypes();
|
||||
const shown = await Promise.all(events.map((event) => this.featureOn(event, on, photoProviders)));
|
||||
return events.filter((_, i) => shown[i]);
|
||||
}
|
||||
|
||||
private async featureOn(
|
||||
event: NotifEventType,
|
||||
on: (addonId: string) => Promise<boolean>,
|
||||
photoProviders: string[],
|
||||
): Promise<boolean> {
|
||||
switch (event) {
|
||||
case 'vacay_invite':
|
||||
case 'vacay_share':
|
||||
return on(ADDON_IDS.VACAY);
|
||||
case 'collection_invite':
|
||||
return on(ADDON_IDS.COLLECTIONS);
|
||||
case 'collab_message':
|
||||
return on(ADDON_IDS.COLLAB);
|
||||
case 'packing_tagged':
|
||||
return on(ADDON_IDS.PACKING);
|
||||
case 'paddle_region_requested':
|
||||
return on(ADDON_IDS.TOURS);
|
||||
case 'photos_shared':
|
||||
return photoProviders.length > 0;
|
||||
case 'synology_session_cleared':
|
||||
return photoProviders.includes('synologyphotos');
|
||||
case 'wanderer_source_hint':
|
||||
return this.addons.isWandererAvailable();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels implemented for an event. In-app takes everything; external channels
|
||||
* decide for themselves (today: everything except `synology_session_cleared`).
|
||||
@@ -151,12 +217,12 @@ export class NotificationPreferencesService {
|
||||
return (NOTIFICATION_DEFAULTS as readonly string[]).includes(raw ?? '') ? (raw as NotificationDefault) : 'on';
|
||||
}
|
||||
|
||||
private userEventTypes(): NotifEventType[] {
|
||||
return ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
private async userEventTypes(): Promise<NotifEventType[]> {
|
||||
return (await this.listedEventTypes()).filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
}
|
||||
|
||||
async getInstanceDefaults(adminId: number): Promise<InstanceDefaultsMatrix> {
|
||||
const event_types = this.userEventTypes();
|
||||
const event_types = await this.userEventTypes();
|
||||
const implemented_combos = await this.allCombos();
|
||||
const defaults: InstanceDefaultsMatrix['defaults'] = {};
|
||||
for (const event of event_types) {
|
||||
@@ -171,7 +237,9 @@ export class NotificationPreferencesService {
|
||||
async setInstanceDefaults(
|
||||
defaults: Partial<Record<string, Partial<Record<string, NotificationDefault>>>>,
|
||||
): Promise<void> {
|
||||
const userEvents = new Set<string>(this.userEventTypes());
|
||||
// Every user event, not only the listed ones: a default set while an addon is
|
||||
// off is still the right default once it is switched on.
|
||||
const userEvents = new Set<string>(ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e)));
|
||||
// The channel set is read before the transaction opens: listChannels() may
|
||||
// ask the plugin runtime, which is no work to hold the connection for.
|
||||
const combos = await this.allCombos();
|
||||
@@ -298,8 +366,8 @@ export class NotificationPreferencesService {
|
||||
// Filter event types by scope
|
||||
const event_types =
|
||||
scope === 'admin'
|
||||
? ALL_EVENT_TYPES.filter((e) => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: ALL_EVENT_TYPES.filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
? (await this.listedEventTypes()).filter((e) => ADMIN_SCOPED_EVENTS.has(e))
|
||||
: (await this.listedEventTypes()).filter((e) => !ADMIN_SCOPED_EVENTS.has(e));
|
||||
|
||||
return {
|
||||
preferences,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PushSubscriptions } from '../../db/entities/PushSubscriptions.entity';
|
||||
import { Settings } from '../../db/entities/Settings.entity';
|
||||
import { TodoItems } from '../../db/entities/TodoItems.entity';
|
||||
import { Trips } from '../../db/entities/Trips.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { StorageModule } from '../storage/storage.module';
|
||||
import { MailerModule } from './mailer/mailer.module';
|
||||
@@ -63,6 +64,7 @@ import { Module } from '@nestjs/common';
|
||||
PushSubscriptions,
|
||||
]),
|
||||
|
||||
AddonsModule,
|
||||
MailerModule,
|
||||
SchedulingModule,
|
||||
StorageModule,
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* A GPX file gives its tracks and routes, a KML or KMZ file its lines; both
|
||||
* come back as the same rows, so the preview, the duplicate rule and the one
|
||||
* transaction that stores them are the GPX import's own. Loose points are
|
||||
* never a tour: a GPX file's waypoints are skipped as they always were, and a
|
||||
* KML file's pins, areas and the rest are counted for the preview to name.
|
||||
* never a tour: a GPX file's waypoints and a KML file's pins, areas and the
|
||||
* rest are counted for the preview, which offers to add the points as places
|
||||
* of the trip through the places import (#2969).
|
||||
*/
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import type { PlaceImportService } from '../place-import/place-import.service';
|
||||
@@ -13,7 +14,7 @@ import type { TourFileLeftOut } from '@trek/shared';
|
||||
|
||||
type TourFileFormat = 'gpx' | 'kml' | 'kmz';
|
||||
|
||||
/** A file read for a Tours import: the rows it would store, and for a KML or KMZ file what it holds besides. */
|
||||
/** A file read for a Tours import: the rows it would store, and what the file holds besides. */
|
||||
export interface TourFileRead {
|
||||
rows: PreparedGpxPlace[];
|
||||
leftOut?: TourFileLeftOut;
|
||||
@@ -56,7 +57,14 @@ export async function readTourFile(
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
return { rows };
|
||||
if (rows.length === 0) return { rows };
|
||||
const points = placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: true,
|
||||
importRoutes: false,
|
||||
importTracks: false,
|
||||
defaultName: fileName,
|
||||
}).length;
|
||||
return points > 0 ? { rows, leftOut: { points, areas: 0, other: 0 } } : { rows };
|
||||
}
|
||||
try {
|
||||
const { tracks, leftOut } = await placeImport.readMapFileTracks(fileBuffer, fileName ?? '');
|
||||
|
||||
@@ -943,8 +943,8 @@ export class ToursService {
|
||||
/**
|
||||
* PAD-02: what a file would import, read with the import's own parser and
|
||||
* duplicate rule and written nowhere. Null when the file holds no track or
|
||||
* route, the import's own refusal. A KML or KMZ file also says what it holds
|
||||
* besides its lines (IO-02).
|
||||
* route, the import's own refusal. The preview also says what the file holds
|
||||
* besides its lines (IO-02), a GPX file its waypoints (#2969).
|
||||
*/
|
||||
async previewGpx(tripId: string, fileBuffer: Buffer, fileName?: string): Promise<TourGpxPreviewResponse | null> {
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
|
||||
@@ -1049,6 +1049,18 @@ describe('Tours e2e (real guards + temp SQLite)', () => {
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts the waypoints beside the track, which no tour takes in (#2969)', async () => {
|
||||
expect((await previewGpx(OWNER, TRACK_GPX)).body.left_out).toBeUndefined();
|
||||
const withPoints = TRACK_GPX.replace(
|
||||
'<trk>',
|
||||
'<wpt lat="49.1" lon="12.1"><name>Hut</name></wpt><wpt lat="49.2" lon="12.2"><name>Lake</name></wpt><trk>',
|
||||
);
|
||||
const res = await previewGpx(OWNER, withPoints);
|
||||
expect(res.body.tracks).toHaveLength(1);
|
||||
expect(res.body.left_out).toEqual({ points: 2, areas: 0, other: 0 });
|
||||
expect(await countRows(orm, Places)).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves heights it does not have unknown, and names a track the trip already holds', async () => {
|
||||
const flat = await previewGpx(OWNER, FLAT_GPX);
|
||||
expect(flat.body.tracks[0]).toMatchObject({ elevation_gain_m: null, elevation_loss_m: null });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AddonsService } from '../../src/nest/addons/addons.service';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { NotificationPreferencesService } from '../../src/nest/notifications/notification-preferences.service';
|
||||
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
|
||||
@@ -62,7 +63,7 @@ export async function makeNotificationsService(
|
||||
mailer,
|
||||
new WebhookService(settingsRepo, appSettings),
|
||||
new NtfyService(settingsRepo, appSettings),
|
||||
new NotificationPreferencesService(mailer, uow, appSettings, channelPrefsRepo),
|
||||
new NotificationPreferencesService(mailer, uow, appSettings, channelPrefsRepo, everyFeatureOn()),
|
||||
uow,
|
||||
notificationsRepo,
|
||||
await makeWebPushService(db),
|
||||
@@ -98,9 +99,25 @@ export async function makeNotificationPreferencesService(
|
||||
await createTestUnitOfWork(db),
|
||||
appSettings,
|
||||
channelPrefsRepo,
|
||||
everyFeatureOn(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The addon answers for the preference matrices with every feature on, so a
|
||||
* suite sees every event row unless it is about the narrowing itself.
|
||||
*/
|
||||
export function everyFeatureOn(
|
||||
overrides: Partial<Pick<AddonsService, 'isAddonEnabled' | 'isWandererAvailable' | 'enabledPhotoProviderIds'>> = {},
|
||||
): AddonsService {
|
||||
return {
|
||||
isAddonEnabled: async () => true,
|
||||
isWandererAvailable: async () => true,
|
||||
enabledPhotoProviderIds: async () => ['immich', 'synologyphotos'],
|
||||
...overrides,
|
||||
} as unknown as AddonsService;
|
||||
}
|
||||
|
||||
/**
|
||||
* A send that records instead of delivering.
|
||||
*
|
||||
|
||||
@@ -375,6 +375,15 @@ describe('AddonsService.list', () => {
|
||||
expect(listSpy).not.toHaveBeenCalled();
|
||||
listSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('names the photo providers users can connect: the enabled ones, none while the journey addon is off', async () => {
|
||||
await insertProvider({ id: 'immich', name: 'Immich', icon: 'image', enabled: 1 });
|
||||
await insertProvider({ id: 'off-provider', name: 'Off', icon: 'image', enabled: 0 });
|
||||
expect(await svc.enabledPhotoProviderIds()).toEqual([]);
|
||||
|
||||
stubJourneyEnabled();
|
||||
expect(await svc.enabledPhotoProviderIds()).toEqual(['immich']);
|
||||
});
|
||||
});
|
||||
|
||||
// Direct coverage of the enablement reads/writers relocated from
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { AuditService } from '../../../src/nest/audit/audit.service';
|
||||
import type { RegistrationInvitesService } from '../../../src/nest/auth/registration-invites.service';
|
||||
import { KitineraryExtractorService } from '../../../src/nest/booking-import/kitinerary-extractor.service';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
import { MANAGED_FORBIDDEN } from '../../../src/nest/common/managed';
|
||||
import type { NotificationsService } from '../../../src/nest/notifications/notifications.service';
|
||||
import type { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
@@ -607,3 +608,17 @@ describe('AdminController feature toggles', () => {
|
||||
expect(noopInvalidate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController on a managed install', () => {
|
||||
it('ADMIN-CTRL-MANAGED-001: the transit provider and the place search log are operator-owned', () => {
|
||||
// Place search and routing come with a managed instance; the admin UI hides
|
||||
// both, and the routes refuse them so a direct call cannot set them either.
|
||||
for (const name of ['updateTransitProvider', 'updatePlaceShadow', 'updateToursWanderer'] as const) {
|
||||
const handler = AdminController.prototype[name];
|
||||
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, handler)?.reason, name).toBeTruthy();
|
||||
}
|
||||
// Reading stays open: the search still needs to know what it runs on.
|
||||
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getTransitProvider)).toBeUndefined();
|
||||
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AdminController.prototype.getPlaceShadow)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -403,6 +403,28 @@ describe('an assistant tool session', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTTOOL-024: a tool called before its group was loaded loads the group, and a refusal of its arguments says why (#2967)', async () => {
|
||||
const { userId, tripId } = await ownerWithTrips();
|
||||
const session = await tools.open(userId, tripId);
|
||||
try {
|
||||
expect(session.specs().map((spec) => spec.name)).not.toContain('list_tours');
|
||||
// Called blind, as from an earlier turn: it still runs, and its group comes with it.
|
||||
const pinned = session.pinArgs('get_tour_plan', { placeId: '530' });
|
||||
expect(pinned).toEqual({ args: { placeId: '530' } });
|
||||
expect(session.specs().map((spec) => spec.name)).toContain('list_tours');
|
||||
// The MCP check refuses the string id; the answer tells the model to send it again.
|
||||
const run = await session.call('get_tour_plan', { placeId: '530' });
|
||||
expect(run.ok).toBe(false);
|
||||
expect(String(run.value)).toContain('"tours" tools were loaded in this turn');
|
||||
// A core tool's refusal carries no such hint.
|
||||
const core = await session.call('create_place', {});
|
||||
expect(core.ok).toBe(false);
|
||||
expect(String(core.value)).not.toContain('were loaded in this turn');
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('ASTTOOL-014: an undo session holds its target and nothing else', async () => {
|
||||
const { userId, tripId } = await ownerWithTrips();
|
||||
const session = await tools.open(userId, tripId, new Set(['delete_place']));
|
||||
|
||||
@@ -27,7 +27,7 @@ import { NtfyService } from '../../../src/nest/notifications/transports/ntfy.ser
|
||||
import { WebhookService } from '../../../src/nest/notifications/transports/webhook.service';
|
||||
import { createUser, createAdmin, setAppSetting, setNotificationChannels } from '../../helpers/factories';
|
||||
import { makePlugin } from '../../helpers/factories/plugins';
|
||||
import { makeNotificationsService } from '../../helpers/notifications';
|
||||
import { everyFeatureOn, makeNotificationsService } from '../../helpers/notifications';
|
||||
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
|
||||
import { createPluginRuntime } from '../../helpers/plugin-host';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
@@ -114,6 +114,7 @@ beforeAll(async () => {
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
await createTestNotificationChannelPreferencesRepo(testDb),
|
||||
everyFeatureOn(),
|
||||
);
|
||||
notifications = await makeNotificationsService(testDb);
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('managed key assignment', () => {
|
||||
'oidc_login',
|
||||
'oidc_registration',
|
||||
'openweather_api_key',
|
||||
'places_provider',
|
||||
'routing_base_url',
|
||||
'routing_valhalla_provider',
|
||||
'service_key',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Which events the preference matrices offer. A row for an event the install
|
||||
* can never raise is a switch that does nothing: plugin notifications without
|
||||
* the plugin runtime, and on a managed install the release check and the
|
||||
* storage replicas, which belong to the operator there.
|
||||
*/
|
||||
import { ALL_EVENT_TYPES } from '../../../src/nest/notifications/notification-events';
|
||||
import { offeredEventTypes } from '../../../src/nest/notifications/notification-preferences.service';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const saved = { managed: process.env.TREK_MANAGED, plugins: process.env.TREK_PLUGINS_ENABLED };
|
||||
|
||||
afterEach(() => {
|
||||
if (saved.managed === undefined) delete process.env.TREK_MANAGED;
|
||||
else process.env.TREK_MANAGED = saved.managed;
|
||||
if (saved.plugins === undefined) delete process.env.TREK_PLUGINS_ENABLED;
|
||||
else process.env.TREK_PLUGINS_ENABLED = saved.plugins;
|
||||
});
|
||||
|
||||
describe('offeredEventTypes', () => {
|
||||
it('NOTIF-OFFERED-001: a self-hosted install with plugins offers every event', () => {
|
||||
delete process.env.TREK_MANAGED;
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
expect(offeredEventTypes()).toEqual(ALL_EVENT_TYPES);
|
||||
});
|
||||
|
||||
it('NOTIF-OFFERED-002: without the plugin runtime there is no plugin row', () => {
|
||||
delete process.env.TREK_MANAGED;
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(offeredEventTypes()).not.toContain('plugin_notification');
|
||||
expect(offeredEventTypes()).toContain('version_available');
|
||||
});
|
||||
|
||||
it('NOTIF-OFFERED-003: a managed install drops the operator alerts and keeps the rest in order', () => {
|
||||
process.env.TREK_MANAGED = '1';
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const offered = offeredEventTypes();
|
||||
expect(offered).not.toContain('version_available');
|
||||
expect(offered).not.toContain('replica_failure');
|
||||
expect(offered).toContain('paddle_region_requested');
|
||||
expect(offered).toEqual(
|
||||
ALL_EVENT_TYPES.filter((e) => !['version_available', 'replica_failure', 'plugin_notification'].includes(e)),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
disableNotificationPref,
|
||||
} from '../../helpers/factories';
|
||||
import { countRows, findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { makeWebPushService } from '../../helpers/notifications';
|
||||
import { everyFeatureOn, makeWebPushService } from '../../helpers/notifications';
|
||||
import { createTestNotificationChannelPreferencesRepo } from '../../helpers/notifications-repos';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import {
|
||||
@@ -86,6 +86,9 @@ const getActiveChannels = (...a: Parameters<Svc['getActiveChannels']>) => svc.ge
|
||||
const isSmtpConfigured = (...a: Parameters<Svc['isSmtpConfigured']>) => svc.isSmtpConfigured(...a);
|
||||
const isWebhookConfigured = (...a: Parameters<Svc['isWebhookConfigured']>) => svc.isWebhookConfigured(...a);
|
||||
|
||||
/** The addon answers the service sees; a test narrows them and the next one gets them back. */
|
||||
const features = everyFeatureOn();
|
||||
|
||||
beforeAll(async () => {
|
||||
const usersRepo = await createTestUsersRepo(testDb);
|
||||
const settingsRepo = await createTestSettingsRepo(testDb);
|
||||
@@ -102,6 +105,7 @@ beforeAll(async () => {
|
||||
await createTestUnitOfWork(testDb),
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
await createTestNotificationChannelPreferencesRepo(testDb),
|
||||
features,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -450,3 +454,31 @@ describe('instance defaults', () => {
|
||||
expect(await countRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'notif_default_%' } })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listed events follow the features that are on', () => {
|
||||
it('NPREF-LIST-001: rows for switched-off addons and providers are not listed', async () => {
|
||||
const all = { ...features };
|
||||
Object.assign(features, {
|
||||
isAddonEnabled: async (id: string) => id !== 'vacay' && id !== 'collections',
|
||||
isWandererAvailable: async () => false,
|
||||
enabledPhotoProviderIds: async () => ['immich'],
|
||||
});
|
||||
try {
|
||||
const { user } = createUser(testDb);
|
||||
const { event_types } = await svc.getPreferencesMatrix(user.id, 'user');
|
||||
for (const hidden of [
|
||||
'vacay_invite',
|
||||
'vacay_share',
|
||||
'collection_invite',
|
||||
'wanderer_source_hint',
|
||||
'synology_session_cleared',
|
||||
]) {
|
||||
expect(event_types).not.toContain(hidden);
|
||||
}
|
||||
expect(event_types).toContain('photos_shared');
|
||||
expect(event_types).toContain('trip_invite');
|
||||
} finally {
|
||||
Object.assign(features, all);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,10 @@ const KML = Buffer.from(`<kml><Placemark><name>Coast</name>
|
||||
<Placemark><Polygon/></Placemark></kml>`);
|
||||
|
||||
describe('readTourFile', () => {
|
||||
it('TOUR-FILE-001: reads a GPX file for its tracks, never its waypoints, and counts nothing left out', async () => {
|
||||
it('TOUR-FILE-001: reads a GPX file for its tracks, never its waypoints, and counts them as left out (#2969)', async () => {
|
||||
const read = await readTourFile(buildPlaceImportService(), GPX, 'ridge.gpx');
|
||||
expect(read.rows.map((row) => row.name)).toEqual(['ridge']);
|
||||
expect(read.leftOut).toBeUndefined();
|
||||
expect(read.leftOut).toEqual({ points: 1, areas: 0, other: 0 });
|
||||
});
|
||||
|
||||
it('TOUR-FILE-002: reads a file without a known extension, or without a name, as GPX as before', async () => {
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'التكلفة',
|
||||
'pdf.preview': 'معاينة PDF',
|
||||
'pdf.saveAsPdf': 'حفظ كـ PDF',
|
||||
'pdf.openInSafari': 'فتح في Safari',
|
||||
'pdf.safariHint':
|
||||
'لا يمكن لنظام iOS الطباعة من التطبيق على الشاشة الرئيسية. سيفتح Safari هذه الرحلة مع تجهيز التصدير، ويمكنك حفظ ملف PDF هناك.',
|
||||
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
|
||||
'pdf.transportNotes': 'ملاحظات التنقل',
|
||||
'pdf.mapTitle': 'نظرة عامة على المسار',
|
||||
|
||||
@@ -967,6 +967,16 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutOther.two': 'عنصران آخران',
|
||||
'tours.import.leftOutOther.few': '{count} عناصر أخرى',
|
||||
'tours.import.leftOutOther.many': '{count} عنصرًا آخر',
|
||||
'tours.import.pointsAsPlaces': 'أضف النقاط كأماكن',
|
||||
'tours.import.pointsAsPlacesHint': 'النقاط المفردة ليست جزءًا من الجولة، بل تُضاف إلى أماكن الرحلة.',
|
||||
'tours.import.pointsConfirm': 'أضف الأماكن',
|
||||
'tours.import.pointsFailedIn': 'تعذّرت إضافة النقاط في {name} كأماكن.',
|
||||
'tours.import.pointsImported': 'أُضيف {count} مكان',
|
||||
'tours.import.pointsImported.zero': 'أُضيف {count} مكان',
|
||||
'tours.import.pointsImported.one': 'أُضيف مكان واحد',
|
||||
'tours.import.pointsImported.two': 'أُضيف مكانان',
|
||||
'tours.import.pointsImported.few': 'أُضيفت {count} أماكن',
|
||||
'tours.import.pointsImported.many': 'أُضيف {count} مكانًا',
|
||||
'tours.import.unreadableIn': 'تعذّرت قراءة {name}: الملف تالف أو ليس GPX أو KML أو KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'لم يُعثر على ملف KML في {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} كبير جدًا بعد فك الضغط',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Xərc',
|
||||
'pdf.preview': 'PDF önizləməsi',
|
||||
'pdf.saveAsPdf': 'PDF kimi yadda saxla',
|
||||
'pdf.openInSafari': 'Safari-də aç',
|
||||
'pdf.safariHint':
|
||||
'iOS Əsas ekrandakı tətbiqdən çap edə bilmir. Safari bu səfəri ixrac hazır vəziyyətdə açır, PDF-i orada saxlaya bilərsən.',
|
||||
'pdf.pageBreakPerDay': 'Hər gün üçün yeni səhifə',
|
||||
'pdf.transportNotes': 'Nəqliyyat qeydləri',
|
||||
'pdf.mapTitle': 'Marşrutun icmalı',
|
||||
|
||||
@@ -956,6 +956,13 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} sahə',
|
||||
'tours.import.leftOutOther': '{count} digər element',
|
||||
'tours.import.leftOutOther.one': '{count} digər element',
|
||||
'tours.import.pointsAsPlaces': 'Nöqtələri məkan kimi əlavə et',
|
||||
'tours.import.pointsAsPlacesHint':
|
||||
'Tək nöqtələr turun bir hissəsi deyil. Onlar əvəzinə səyahətin məkanlarına əlavə olunur.',
|
||||
'tours.import.pointsConfirm': 'Məkanları əlavə et',
|
||||
'tours.import.pointsFailedIn': '{name} faylındakı nöqtələr məkan kimi əlavə edilə bilmədi.',
|
||||
'tours.import.pointsImported': '{count} məkan əlavə edildi',
|
||||
'tours.import.pointsImported.one': '{count} məkan əlavə edildi',
|
||||
'tours.import.unreadableIn': '{name} oxunmadı: fayl zədələnib və ya GPX, KML, KMZ deyil',
|
||||
'tours.import.archiveNoKmlIn': '{name} içində KML faylı tapılmadı',
|
||||
'tours.import.archiveTooLargeIn': '{name} açıldıqdan sonra çox böyükdür',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Custo',
|
||||
'pdf.preview': 'Pré-visualização do PDF',
|
||||
'pdf.saveAsPdf': 'Salvar como PDF',
|
||||
'pdf.openInSafari': 'Abrir no Safari',
|
||||
'pdf.safariHint':
|
||||
'O iOS não consegue imprimir pelo app na Tela de Início. O Safari abre esta viagem com a exportação pronta, e lá você pode salvar o PDF.',
|
||||
'pdf.pageBreakPerDay': 'Quebra de página por dia',
|
||||
'pdf.transportNotes': 'Notas de transporte',
|
||||
'pdf.mapTitle': 'Visão geral da rota',
|
||||
|
||||
@@ -962,6 +962,12 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} área',
|
||||
'tours.import.leftOutOther': '{count} outros itens',
|
||||
'tours.import.leftOutOther.one': '{count} outro item',
|
||||
'tours.import.pointsAsPlaces': 'Adicionar os pontos como lugares',
|
||||
'tours.import.pointsAsPlacesHint': 'Pontos avulsos não fazem parte de um passeio. Eles entram nos lugares da viagem.',
|
||||
'tours.import.pointsConfirm': 'Adicionar lugares',
|
||||
'tours.import.pointsFailedIn': 'Não foi possível adicionar os pontos de {name} como lugares.',
|
||||
'tours.import.pointsImported': '{count} lugares adicionados',
|
||||
'tours.import.pointsImported.one': '{count} lugar adicionado',
|
||||
'tours.import.unreadableIn': 'Não foi possível ler {name}: o arquivo está danificado ou não é GPX, KML nem KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Nenhum arquivo KML encontrado em {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} é grande demais depois de descompactado',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost EUR',
|
||||
'pdf.preview': 'Vista prèvia del PDF',
|
||||
'pdf.saveAsPdf': 'Desa com a PDF',
|
||||
'pdf.openInSafari': 'Obre a Safari',
|
||||
'pdf.safariHint':
|
||||
"iOS no pot imprimir des de l'app de la pantalla d'inici. Safari obre aquest viatge amb l'exportació a punt i allà pots desar el PDF.",
|
||||
'pdf.pageBreakPerDay': 'Salt de pàgina per dia',
|
||||
'pdf.transportNotes': 'Notes del transport',
|
||||
'pdf.mapTitle': 'Resum de la ruta',
|
||||
|
||||
@@ -964,6 +964,12 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} àrea',
|
||||
'tours.import.leftOutOther': '{count} elements més',
|
||||
'tours.import.leftOutOther.one': '{count} element més',
|
||||
'tours.import.pointsAsPlaces': 'Afegeix els punts com a llocs',
|
||||
'tours.import.pointsAsPlacesHint': 'Els punts solts no formen part de cap ruta. S’afegeixen als llocs del viatge.',
|
||||
'tours.import.pointsConfirm': 'Afegeix llocs',
|
||||
'tours.import.pointsFailedIn': 'No s’han pogut afegir els punts de {name} com a llocs.',
|
||||
'tours.import.pointsImported': '{count} llocs afegits',
|
||||
'tours.import.pointsImported.one': '{count} lloc afegit',
|
||||
'tours.import.unreadableIn': "No s'ha pogut llegir {name}: el fitxer està malmès o no és GPX, KML ni KMZ",
|
||||
'tours.import.archiveNoKmlIn': "No s'ha trobat cap fitxer KML a {name}",
|
||||
'tours.import.archiveTooLargeIn': '{name} és massa gran un cop descomprimit',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Náklady',
|
||||
'pdf.preview': 'Náhled PDF',
|
||||
'pdf.saveAsPdf': 'Uložit jako PDF',
|
||||
'pdf.openInSafari': 'Otevřít v Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS neumí tisknout z aplikace na ploše. Safari otevře tento výlet s připraveným exportem a tam si PDF uložíte.',
|
||||
'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den',
|
||||
'pdf.transportNotes': 'Poznámky k dopravě',
|
||||
'pdf.mapTitle': 'Přehled trasy',
|
||||
|
||||
@@ -950,6 +950,13 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutOther': '{count} dalších položek',
|
||||
'tours.import.leftOutOther.one': '{count} další položka',
|
||||
'tours.import.leftOutOther.few': '{count} další položky',
|
||||
'tours.import.pointsAsPlaces': 'Přidat body jako místa',
|
||||
'tours.import.pointsAsPlacesHint': 'Samostatné body nejsou součástí trasy. Přidají se místo toho k místům cesty.',
|
||||
'tours.import.pointsConfirm': 'Přidat místa',
|
||||
'tours.import.pointsFailedIn': 'Body ze souboru {name} se nepodařilo přidat jako místa.',
|
||||
'tours.import.pointsImported': '{count} míst přidáno',
|
||||
'tours.import.pointsImported.one': '{count} místo přidáno',
|
||||
'tours.import.pointsImported.few': '{count} místa přidána',
|
||||
'tours.import.unreadableIn': 'Soubor {name} nelze přečíst: je poškozený nebo to není GPX, KML ani KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'V souboru {name} není žádný soubor KML',
|
||||
'tours.import.archiveTooLargeIn': 'Soubor {name} je po rozbalení příliš velký',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kosten',
|
||||
'pdf.preview': 'PDF Vorschau',
|
||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||
'pdf.openInSafari': 'In Safari öffnen',
|
||||
'pdf.safariHint':
|
||||
'iOS kann aus der App auf dem Home-Bildschirm nicht drucken. Safari öffnet diese Reise mit dem Export, dort kannst du das PDF speichern.',
|
||||
'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag',
|
||||
'pdf.transportNotes': 'Notizen zu Transporten',
|
||||
'pdf.mapTitle': 'Routenübersicht',
|
||||
|
||||
@@ -972,6 +972,13 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} Fläche',
|
||||
'tours.import.leftOutOther': '{count} weitere Elemente',
|
||||
'tours.import.leftOutOther.one': '{count} weiteres Element',
|
||||
'tours.import.pointsAsPlaces': 'Punkte als Orte hinzufügen',
|
||||
'tours.import.pointsAsPlacesHint':
|
||||
'Einzelne Punkte gehören zu keiner Tour. Sie kommen stattdessen zu den Orten der Reise.',
|
||||
'tours.import.pointsConfirm': 'Orte hinzufügen',
|
||||
'tours.import.pointsFailedIn': 'Die Punkte aus {name} konnten nicht als Orte hinzugefügt werden.',
|
||||
'tours.import.pointsImported': '{count} Orte hinzugefügt',
|
||||
'tours.import.pointsImported.one': '{count} Ort hinzugefügt',
|
||||
'tours.import.unreadableIn':
|
||||
'{name} konnte nicht gelesen werden: Die Datei ist beschädigt oder kein GPX, KML oder KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'Keine KML-Datei in {name} gefunden',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost',
|
||||
'pdf.preview': 'PDF Preview',
|
||||
'pdf.saveAsPdf': 'Save as PDF',
|
||||
'pdf.openInSafari': 'Open in Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS cannot print from the app on your Home Screen. Safari opens this trip with the export ready, and there you can save the PDF.',
|
||||
'pdf.pageBreakPerDay': 'Page break per day',
|
||||
'pdf.transportNotes': 'Transport notes',
|
||||
'pdf.mapTitle': 'Route overview',
|
||||
|
||||
@@ -944,6 +944,12 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} area',
|
||||
'tours.import.leftOutOther': '{count} other items',
|
||||
'tours.import.leftOutOther.one': '{count} other item',
|
||||
'tours.import.pointsAsPlaces': 'Add the points as places',
|
||||
'tours.import.pointsAsPlacesHint': 'Single points are not part of a tour. They join the places of the trip instead.',
|
||||
'tours.import.pointsConfirm': 'Add places',
|
||||
'tours.import.pointsFailedIn': 'The points in {name} could not be added as places.',
|
||||
'tours.import.pointsImported': '{count} places added',
|
||||
'tours.import.pointsImported.one': '{count} place added',
|
||||
'tours.import.unreadableIn': 'Could not read {name}: the file is damaged or not GPX, KML or KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'No KML file found in {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} is too large once unpacked',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Coste',
|
||||
'pdf.preview': 'Vista previa PDF',
|
||||
'pdf.saveAsPdf': 'Guardar como PDF',
|
||||
'pdf.openInSafari': 'Abrir en Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS no puede imprimir desde la app de la pantalla de inicio. Safari abre este viaje con la exportación lista y allí puedes guardar el PDF.',
|
||||
'pdf.pageBreakPerDay': 'Salto de página por día',
|
||||
'pdf.transportNotes': 'Notas de transporte',
|
||||
'pdf.mapTitle': 'Resumen de la ruta',
|
||||
|
||||
@@ -961,6 +961,13 @@ const tours: TranslationStrings = {
|
||||
'tours.import.leftOutAreas.one': '{count} área',
|
||||
'tours.import.leftOutOther': '{count} elementos más',
|
||||
'tours.import.leftOutOther.one': '{count} elemento más',
|
||||
'tours.import.pointsAsPlaces': 'Añadir los puntos como lugares',
|
||||
'tours.import.pointsAsPlacesHint':
|
||||
'Los puntos sueltos no forman parte de una ruta. Se añaden a los lugares del viaje.',
|
||||
'tours.import.pointsConfirm': 'Añadir lugares',
|
||||
'tours.import.pointsFailedIn': 'No se pudieron añadir los puntos de {name} como lugares.',
|
||||
'tours.import.pointsImported': '{count} lugares añadidos',
|
||||
'tours.import.pointsImported.one': '{count} lugar añadido',
|
||||
'tours.import.unreadableIn': 'No se ha podido leer {name}: el archivo está dañado o no es GPX, KML ni KMZ',
|
||||
'tours.import.archiveNoKmlIn': 'No hay ningún archivo KML en {name}',
|
||||
'tours.import.archiveTooLargeIn': '{name} es demasiado grande una vez descomprimido',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Maksumus',
|
||||
'pdf.preview': 'PDF-i eelvaade',
|
||||
'pdf.saveAsPdf': 'Salvesta PDF-ina',
|
||||
'pdf.openInSafari': 'Ava Safaris',
|
||||
'pdf.safariHint':
|
||||
'iOS ei saa avakuval olevast rakendusest printida. Safari avab selle reisi valmis ekspordiga ja seal saad PDF-i salvestada.',
|
||||
'pdf.pageBreakPerDay': 'Iga päev uuel lehel',
|
||||
'pdf.transportNotes': 'Transpordi märkmed',
|
||||
'pdf.mapTitle': 'Marsruudi ülevaade',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user