mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 | |||
| e725d4f0a9 | |||
| 36f1b5b7d9 | |||
| acce063dbf | |||
| d229613e0e | |||
| 5622113348 | |||
| cd2ecf897b | |||
| f5af9d1259 | |||
| cecfdf2dcc | |||
| 23b28a1f3f | |||
| f7f6b83d33 | |||
| 097c1da0af | |||
| a74a2cf9b5 | |||
| dc1ff06573 | |||
| fc631d36b6 | |||
| 6463060f67 | |||
| 593f839592 | |||
| a9c5cf6d9a | |||
| f901982903 | |||
| 8f64431ff1 | |||
| c378722d63 | |||
| bfec874d04 | |||
| a36019b7e2 |
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
@@ -269,7 +269,7 @@
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
"mobile/screens/trip/tabs/MCollabChat.tsx": 22,
|
||||
"mobile/screens/trip/tabs/MCollabNotes.tsx": 19,
|
||||
"mobile/screens/trip/tabs/MCollabPolls.tsx": 18,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
TripJournalCandidatesResponse,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||
*/
|
||||
export const tripJournalApi = {
|
||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||
link: (
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
/** The journals this person can open; the dialog offers the ones they own. */
|
||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||
apiClient.get('/journeys').then((r) => r.data),
|
||||
};
|
||||
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
// Linked journal: components/Trips/useTripJournalLink asks again which journal
|
||||
// the trip shows and who it is open to. Content-free, because that answer is
|
||||
// per person (a member left off the link must not learn its title).
|
||||
'trip:journal:changed',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { AlertTriangle, Check, Loader2, Plug, ShieldAlert } from 'lucide-react'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../../shared/DialogShell'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useConnectForm } from './useConnectForm'
|
||||
import { Badge } from './DocSyncBits'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
|
||||
import type { DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* Connecting a store, as a dialog rather than a panel that unfolds in place.
|
||||
@@ -32,7 +33,7 @@ export default function DocSyncConnectModal({
|
||||
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
|
||||
|
||||
const form = useConnectForm(provider, existing, sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
const test = async () => setVerdict(await form.probe())
|
||||
const labelId = useId()
|
||||
@@ -134,7 +135,7 @@ function Verdict({
|
||||
busy,
|
||||
failure,
|
||||
}: {
|
||||
verdict: { connected: boolean; account?: string; error?: string } | null
|
||||
verdict: DocSyncVerdict | null
|
||||
busy: boolean
|
||||
/** A call that failed outside the connection test (saving, most of all). */
|
||||
failure: string | null
|
||||
@@ -172,10 +173,16 @@ function Verdict({
|
||||
)
|
||||
}
|
||||
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
|
||||
const message = t(`docsync.error.${verdict.error || 'unknown'}`)
|
||||
// The footer shares its row with the buttons, and a refusal cut to "The
|
||||
// credentials were re..." with no way to read the rest left nothing to go on
|
||||
// (#2666). Two lines, and the whole of it with the store's reason on hover.
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
|
||||
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
|
||||
</span>
|
||||
<Tooltip label={verdict.detail ? `${message} ${verdict.detail}` : message} placement="top">
|
||||
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
|
||||
{blocked ? <ShieldAlert size={14} className="flex-none" /> : <AlertTriangle size={14} className="flex-none" />}
|
||||
<span className="line-clamp-2 [overflow-wrap:anywhere]">{message}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import type { DocSyncConnection, DocSyncProvider, useDocSync } from './useDocSync'
|
||||
import type { DocSyncConnection, DocSyncProvider, DocSyncVerdict, useDocSync } from './useDocSync'
|
||||
|
||||
/**
|
||||
* The credential form's rules, for both shells.
|
||||
@@ -39,7 +39,7 @@ export interface ConnectForm {
|
||||
setInsecureTls: (on: boolean) => void
|
||||
/** Every required field is either filled in or already stored. */
|
||||
complete: boolean
|
||||
probe: () => Promise<{ connected: boolean; account?: string; error?: string }>
|
||||
probe: () => Promise<DocSyncVerdict>
|
||||
save: () => Promise<boolean>
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,14 @@ import { useServerPing } from '../../../sync/useServerPing'
|
||||
* reason for the same problem.
|
||||
*/
|
||||
|
||||
/** What a connection test answers; `detail` is the store's own reason, for the person fixing it. */
|
||||
export interface DocSyncVerdict {
|
||||
connected: boolean
|
||||
account?: string
|
||||
error?: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface DocSyncField {
|
||||
field_key: string
|
||||
label: string
|
||||
@@ -264,11 +272,9 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
|
||||
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
|
||||
setBusy('test')
|
||||
try {
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
|
||||
connected: boolean; account?: string; error?: string; detail?: string
|
||||
}
|
||||
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as DocSyncVerdict
|
||||
} catch (e: unknown) {
|
||||
return { connected: false, error: readError(e) }
|
||||
return { connected: false, error: readError(e) } satisfies DocSyncVerdict
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
|
||||
await screen.findByRole('button', { name: /create share link/i })
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
|
||||
const { unmount } = mountDialog()
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountDialog(buildJourney({
|
||||
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
|
||||
}))
|
||||
expect(screen.getByText('Through trips')).toBeInTheDocument()
|
||||
expect(screen.getByText('lena')).toBeInTheDocument()
|
||||
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSaved } = mountDialog()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import { useJourneySettings } from './useJourneySettings'
|
||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||
</DialogSection>
|
||||
|
||||
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||
*/
|
||||
function TripAccessSection({ access, roleLabel }: {
|
||||
access: JourneyTripAccess[] | undefined
|
||||
roleLabel: (role: JourneyContributor['role']) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||
<div className={ROWS}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||
</div>
|
||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,23 +169,24 @@ describe('Navbar', () => {
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||
expect(back.parentElement).toBe(settings.closest('div'));
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
const onSettings = vi.fn();
|
||||
render(<Navbar onSettings={onSettings} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||
expect(onSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
|
||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.mouseEnter(back);
|
||||
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(back);
|
||||
expect(back.style.background).toBe('transparent');
|
||||
|
||||
const share = screen.getByRole('button', { name: /share/i });
|
||||
fireEvent.mouseEnter(share);
|
||||
expect(share.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(share);
|
||||
expect(share.style.background).toBe('var(--bg-card)');
|
||||
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
fireEvent.mouseEnter(theme);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
@@ -21,7 +21,8 @@ interface NavbarProps {
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
/** The trip's Settings: who is on it, its links and its journal. */
|
||||
onSettings?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -32,7 +33,7 @@ interface Addon {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* The trip's Settings sit with the trip, beside its pill: they are about this
|
||||
trip, not about the app the right side of the bar belongs to. */}
|
||||
{onSettings && (
|
||||
// The same pill as the trip's own beside it, so the two read as one pair.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
aria-label={t('trip.settings.title')}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="text-caption font-semibold">{t('nav.settings')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button type="button" onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
import { calculateRouteWithRetries, ROUTE_RETRY_DELAYS_MS } from './routeRetry';
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>();
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() };
|
||||
});
|
||||
|
||||
const WAYPOINTS = [
|
||||
{ lat: 47.5, lng: 19.04 },
|
||||
{ lat: 47.49, lng: 19.05 },
|
||||
];
|
||||
const ROUTED = {
|
||||
coordinates: [
|
||||
[47.5, 19.04],
|
||||
[47.49, 19.05],
|
||||
],
|
||||
legs: [],
|
||||
} as unknown as Awaited<ReturnType<typeof calculateRouteWithLegs>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(calculateRouteWithLegs).mockReset();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('calculateRouteWithRetries', () => {
|
||||
it('waits out a rate limit and answers with the road (Discord, 16.10.2026)', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { profile: 'driving' });
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('waits as long as the host asks when that is longer', async () => {
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(503, 3000))
|
||||
.mockResolvedValueOnce(ROUTED);
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
await vi.advanceTimersByTimeAsync(ROUTE_RETRY_DELAYS_MS[0]);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(3000 - ROUTE_RETRY_DELAYS_MS[0]);
|
||||
|
||||
await expect(answer).resolves.toBe(ROUTED);
|
||||
});
|
||||
|
||||
it('gives up after the last delay and throws the refusal', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(429, null));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, {});
|
||||
const settled = expect(answer).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
for (const ms of ROUTE_RETRY_DELAYS_MS) await vi.advanceTimersByTimeAsync(ms);
|
||||
|
||||
await settled;
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(ROUTE_RETRY_DELAYS_MS.length + 1);
|
||||
});
|
||||
|
||||
it('throws a refusal the host meant at once', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new RoutingRefusedError(400, null));
|
||||
|
||||
await expect(calculateRouteWithRetries(WAYPOINTS, {})).rejects.toBeInstanceOf(RoutingRefusedError);
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting when the day changes', async () => {
|
||||
const controller = new AbortController();
|
||||
vi.mocked(calculateRouteWithLegs)
|
||||
.mockRejectedValueOnce(new RoutingRefusedError(429, null))
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
const answer = calculateRouteWithRetries(WAYPOINTS, { signal: controller.signal });
|
||||
const settled = expect(answer).rejects.toMatchObject({ name: 'AbortError' });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
controller.abort();
|
||||
|
||||
await settled;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError } from './RouteCalculator';
|
||||
|
||||
// The profile a caller hands in, so a hook routing through here needs nothing else from Map/.
|
||||
export type { RouteProfileKey } from './RouteCalculator';
|
||||
|
||||
/** How often a rate-limited route is asked for again, and how long after. */
|
||||
export const ROUTE_RETRY_DELAYS_MS = [1500, 4000];
|
||||
|
||||
/** A pause that ends early, without throwing, when the signal aborts. */
|
||||
export const sleepUnlessAborted = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
if (signal?.aborted || ms <= 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(done, ms);
|
||||
function done(): void {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', done);
|
||||
resolve();
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `calculateRouteWithLegs`, asked again while the host says it is busy.
|
||||
*
|
||||
* The public OSRM hosts allow about one request a second and answer 429 above that. A
|
||||
* day with a change of mode, or a quick switch between days, asks more often than that,
|
||||
* and the day route used to give up on the first refusal and keep its straight lines
|
||||
* until the next edit (reported on Discord, 16.10.2026). A rate limit is waited out, for
|
||||
* as long as the host asks when it says; a refusal the host meant is thrown at once,
|
||||
* because the same request earns the same answer. An abort ends the wait, and the next
|
||||
* attempt then throws the AbortError the caller already handles.
|
||||
*/
|
||||
export async function calculateRouteWithRetries(
|
||||
...[waypoints, opts]: Parameters<typeof calculateRouteWithLegs>
|
||||
): ReturnType<typeof calculateRouteWithLegs> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await calculateRouteWithLegs(waypoints, opts);
|
||||
} catch (err) {
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null;
|
||||
if (!rateLimit || attempt >= ROUTE_RETRY_DELAYS_MS.length || opts?.signal?.aborted) throw err;
|
||||
await sleepUnlessAborted(Math.max(ROUTE_RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), opts?.signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { calculateRouteWithLegs, RoutingRefusedError, type RouteProfileKey } from './RouteCalculator'
|
||||
import type { RouteProfileKey } from './RouteCalculator'
|
||||
import { calculateRouteWithRetries, sleepUnlessAborted as sleep } from './routeRetry'
|
||||
import { buildDayRouteRuns, type DayRouteInputs, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viasOnDayLeg, type DayViaLookup } from '../../utils/dayRouteVias'
|
||||
import { projectDayItinerary } from './dayTourProjection'
|
||||
@@ -185,20 +186,6 @@ export function summariseTripRoute(days: TripOverviewDay[]): TripRouteSummary {
|
||||
const REQUEST_SPACING_MS = 1100
|
||||
/** Anything answered faster than this came out of RouteCalculator's cache, not the network. */
|
||||
const CACHE_HIT_MS = 60
|
||||
/** How often a rate-limited leg is tried again, and how long after. */
|
||||
const RETRY_DELAYS_MS = [1500, 4000]
|
||||
|
||||
const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal?.aborted || ms <= 0) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal?.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* Ask the router for every leg of every day, one at a time.
|
||||
@@ -233,23 +220,16 @@ export async function routeTripLegs(
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
return
|
||||
} catch (err) {
|
||||
if (signal?.aborted) return
|
||||
const rateLimit = err instanceof RoutingRefusedError && err.isRateLimit ? err : null
|
||||
if (!rateLimit || attempt === RETRY_DELAYS_MS.length) return
|
||||
// When the host says how long to wait, waiting less is just a second refusal.
|
||||
await sleep(Math.max(RETRY_DELAYS_MS[attempt], rateLimit.retryAfterMs ?? 0), signal)
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const answer = await calculateRouteWithRetries(
|
||||
chunk.waypoints,
|
||||
{ signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: foldViaLegs(answer.legs, chunk.stopAt, unit) }
|
||||
onAnswer?.(routed)
|
||||
} catch {
|
||||
// Refused for good, or aborted: the leg keeps its straight line.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -271,7 +271,9 @@ describe('PackingListPanel', () => {
|
||||
];
|
||||
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||
expect(screen.getByRole('button', { name: 'A to Z' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(container.querySelector('[draggable="true"]')).toBeNull();
|
||||
// Only the category grip stays: the order of the categories is not the A to Z of their items.
|
||||
const draggable = [...container.querySelectorAll('[draggable="true"]')];
|
||||
expect(draggable.map((el) => el.getAttribute('aria-label'))).toEqual(['Drag to reorder the category']);
|
||||
});
|
||||
|
||||
it('FE-COMP-PACKING-023: inline edit item name via pencil icon calls PUT', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Trash2, Plus, ChevronDown, ChevronRight,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus,
|
||||
X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, UserPlus, GripVertical,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { katColor } from './packingListPanel.helpers'
|
||||
@@ -38,6 +38,16 @@ interface KategorieGruppeProps {
|
||||
// Off while the list is shown A-Z: a drop there would move an item in an order
|
||||
// the reader cannot see.
|
||||
reorderable?: boolean
|
||||
// Moving the whole category among the others, wired by the list, which sees every
|
||||
// card. `onDrop` gets the category that was dragged onto this one.
|
||||
categoryDrag?: {
|
||||
isDragging: boolean
|
||||
isOver: boolean
|
||||
onStart: () => void
|
||||
onOver: () => void
|
||||
onEnd: () => void
|
||||
onDrop: (dragged: string) => void
|
||||
}
|
||||
// Three-tier sharing (#858) — threaded down to each item's share control.
|
||||
currentUserId?: number
|
||||
onSetSharing?: (id: number, visibility: 'common' | 'personal' | 'shared', recipientIds: number[]) => void
|
||||
@@ -46,7 +56,7 @@ interface KategorieGruppeProps {
|
||||
onLeave?: (id: number, userId: number) => void
|
||||
}
|
||||
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true, allItems, onReorder, reorderable = true, categoryDrag, currentUserId, onSetSharing, onClone, onJoin, onLeave }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
@@ -120,8 +130,9 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
return (
|
||||
// Shaped after the phone's category card: a tinted head band carrying the
|
||||
// name as written, the count, and the fold arrow on the far right.
|
||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
||||
<CategoryDropFrame drag={categoryDrag}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, paddingBlock: 11, paddingInlineEnd: 12, paddingInlineStart: 14, background: 'var(--bg-tertiary)', borderRadius: offen ? '15px 15px 0 0' : 15 }}>
|
||||
<CategoryGrip drag={categoryDrag} kategorie={kategorie} label={t('packing.dragCategory')} />
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
||||
|
||||
{editingName && canEdit ? (
|
||||
@@ -344,6 +355,65 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CategoryDropFrame>
|
||||
)
|
||||
}
|
||||
|
||||
type CategoryDrag = NonNullable<KategorieGruppeProps['categoryDrag']>
|
||||
|
||||
/** The data type a dragged category carries, its name as the value. */
|
||||
const CATEGORY_DRAG_TYPE = 'application/x-trek-packing-category'
|
||||
|
||||
/**
|
||||
* The card, and while a category is dragged its drop target. Captured, because
|
||||
* the item rows stop their own drops from bubbling.
|
||||
*/
|
||||
function CategoryDropFrame({ drag, children }: { drag?: CategoryDrag; children: React.ReactNode }) {
|
||||
// Told apart by the drag's own data type, which is there from the first event on:
|
||||
// a flag set in state on dragstart can still be unrendered when a quick drag
|
||||
// arrives, and a dragged item must pass through to its rows untouched.
|
||||
const isCategoryDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(CATEGORY_DRAG_TYPE)
|
||||
return (
|
||||
<div
|
||||
onDragOverCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.dataTransfer.dropEffect = 'move'; drag.onOver()
|
||||
}) : undefined}
|
||||
onDropCapture={drag ? (e => {
|
||||
if (!isCategoryDrag(e)) return
|
||||
e.preventDefault(); e.stopPropagation(); drag.onDrop(e.dataTransfer.getData(CATEGORY_DRAG_TYPE))
|
||||
}) : undefined}
|
||||
style={{
|
||||
marginBottom: 6, background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-secondary)', overflow: 'visible',
|
||||
opacity: drag?.isDragging ? 0.4 : 1,
|
||||
boxShadow: drag?.isOver ? '0 0 0 2px var(--accent)' : 'none',
|
||||
transition: 'opacity 0.15s, box-shadow 0.15s',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The handle a category is dragged by, in the head band before its colour dot. */
|
||||
function CategoryGrip({ drag, kategorie, label }: { drag?: CategoryDrag; kategorie: string; label: string }) {
|
||||
if (!drag) return null
|
||||
return (
|
||||
<span
|
||||
draggable
|
||||
role="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData(CATEGORY_DRAG_TYPE, kategorie)
|
||||
e.dataTransfer.setData('text/plain', kategorie)
|
||||
drag.onStart()
|
||||
}}
|
||||
onDragEnd={drag.onEnd}
|
||||
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0, marginInlineStart: -6 }}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { KategorieGruppe } from './PackingListPanelCategoryGroup'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -7,8 +8,13 @@ export function PackingList(S: PackingState) {
|
||||
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
|
||||
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
|
||||
bagTrackingEnabled, bags, handleCreateBagByName, canEdit, reorderPackingItems,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort,
|
||||
currentUserId, handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem, sort, moveCategory,
|
||||
} = S
|
||||
// Which category is being dragged and which card it is over. Held here, above
|
||||
// the cards, because the drag starts in one card and ends in another.
|
||||
const [draggedCategory, setDraggedCategory] = useState<string | null>(null)
|
||||
const [overCategory, setOverCategory] = useState<string | null>(null)
|
||||
const endCategoryDrag = () => { setDraggedCategory(null); setOverCategory(null) }
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 0 16px' }}>
|
||||
{items.length === 0 ? (
|
||||
@@ -40,6 +46,14 @@ export function PackingList(S: PackingState) {
|
||||
allItems={items}
|
||||
onReorder={(orderedIds) => reorderPackingItems(tripId, orderedIds)}
|
||||
reorderable={sort === 'manual'}
|
||||
categoryDrag={canEdit ? {
|
||||
isDragging: draggedCategory === kat,
|
||||
isOver: overCategory === kat && draggedCategory !== null && draggedCategory !== kat,
|
||||
onStart: () => { setDraggedCategory(kat); setOverCategory(null) },
|
||||
onOver: () => setOverCategory(kat),
|
||||
onEnd: endCategoryDrag,
|
||||
onDrop: (dragged) => { moveCategory(dragged, kat); endCategoryDrag() },
|
||||
} : undefined}
|
||||
currentUserId={currentUserId}
|
||||
onSetSharing={handleSetSharing}
|
||||
onClone={handleCloneItem}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
groupPackingItems,
|
||||
isLastCustomItemInCategory,
|
||||
isPackingPlaceholder,
|
||||
itemOrderWithCategoryMoved,
|
||||
packingCategoryOrder,
|
||||
packingItemWeight,
|
||||
packingProgress,
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from './packingListModel';
|
||||
import { PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants';
|
||||
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-008
|
||||
// FE-PACK-MODEL-001 to FE-PACK-MODEL-009
|
||||
|
||||
const shared = buildPackingItem({ id: 1, name: 'Tent', category: 'Camp', checked: 1 });
|
||||
const mine = buildPackingItem({ id: 2, name: 'Socks', category: 'Clothes', is_private: 1 });
|
||||
@@ -76,4 +77,28 @@ describe('packingListModel', () => {
|
||||
expect(isPackingPlaceholder({ name: PACKING_PLACEHOLDER_NAME })).toBe(true);
|
||||
expect(isPackingPlaceholder({ name: 'Tent' })).toBe(false);
|
||||
});
|
||||
it('FE-PACK-MODEL-009: moving a category moves its items as a block, the other view stays as it was', () => {
|
||||
const day1 = buildPackingItem({ id: 11, name: 'Shirt', category: 'Day 1' });
|
||||
const day2a = buildPackingItem({ id: 12, name: 'Shorts', category: 'Day 2' });
|
||||
const own = buildPackingItem({ id: 13, name: 'Pills', category: 'Day 2', is_private: 1 });
|
||||
const day3 = buildPackingItem({ id: 14, name: 'Jacket', category: 'Day 3' });
|
||||
const day2b = buildPackingItem({ id: 15, name: 'Hat', category: 'Day 2' });
|
||||
const items = [day1, day2a, own, day3, day2b];
|
||||
const common = packingViewItems(items, 'common');
|
||||
|
||||
const order = itemOrderWithCategoryMoved(items, common, 'Day 3', 'Day 1', 'Other');
|
||||
expect(order).toEqual([14, 11, 12, 15, 13]);
|
||||
const moved = (order ?? []).flatMap((id) => items.filter((i) => i.id === id));
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'common'), 'Other')).toEqual(['Day 3', 'Day 1', 'Day 2']);
|
||||
expect(packingCategoryOrder(packingViewItems(moved, 'personal'), 'Other')).toEqual(['Day 2']);
|
||||
|
||||
expect(itemOrderWithCategoryMoved(items, common, 'Day 1', 'Day 3', 'Other')).toEqual([12, 15, 14, 11, 13]);
|
||||
});
|
||||
|
||||
it('FE-PACK-MODEL-009b: nothing moves for the same category or one the view does not show', () => {
|
||||
const items = [shared, stove, loose];
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Camp', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Clothes', 'Camp', 'Other')).toBeNull();
|
||||
expect(itemOrderWithCategoryMoved(items, items, 'Other', 'Camp', 'Other')).toEqual([4, 1, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,34 @@ export function packingCategoryOrder(viewItems: PackingItem[], defaultCategory:
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole list's item order with category `from` moved to where `to` stands.
|
||||
*
|
||||
* A category has no position of its own: it shows where its first item is. So a
|
||||
* category is moved by moving its items, as one block, through the item reorder
|
||||
* the list already has; nothing new is stored, and the order reaches every device
|
||||
* the way a dragged item does. The view's items come first, grouped in the new
|
||||
* order with each group in its own order; the other view's items follow in theirs,
|
||||
* which leaves that view's categories as they were. Null when nothing would move.
|
||||
*/
|
||||
export function itemOrderWithCategoryMoved(
|
||||
items: PackingItem[],
|
||||
viewItems: PackingItem[],
|
||||
from: string,
|
||||
to: string,
|
||||
defaultCategory: string
|
||||
): number[] | null {
|
||||
const order = packingCategoryOrder(viewItems, defaultCategory);
|
||||
const fromIndex = order.indexOf(from);
|
||||
const toIndex = order.indexOf(to);
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) return null;
|
||||
order.splice(fromIndex, 1);
|
||||
order.splice(toIndex, 0, from);
|
||||
const inView = new Set(viewItems.map((i) => i.id));
|
||||
const grouped = order.flatMap((cat) => viewItems.filter((i) => (i.category || defaultCategory) === cat));
|
||||
return [...grouped, ...items.filter((i) => !inView.has(i.id))].map((i) => i.id);
|
||||
}
|
||||
|
||||
export function packingStatusFiltered(items: PackingItem[], status: PackingStatusFilter): PackingItem[] {
|
||||
if (status === 'open') return items.filter((i) => !i.checked);
|
||||
if (status === 'done') return items.filter((i) => !!i.checked);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { PackingItem } from '../../types'
|
||||
import { parseImportLines, sortItemsByName } from './packingListPanel.helpers'
|
||||
import { groupPackingItems, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { groupPackingItems, itemOrderWithCategoryMoved, packingCategoryOrder, packingProgress, packingViewItems } from './packingListModel'
|
||||
import { usePackingBags } from './usePackingBags'
|
||||
import { usePackingCategoryAssignees } from './usePackingCategoryAssignees'
|
||||
import { usePackingImport } from './usePackingImport'
|
||||
@@ -106,6 +106,13 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
|
||||
const { checked: abgehakt, pct: fortschritt } = packingProgress(viewItems)
|
||||
|
||||
// A category dropped onto another takes its place; the slice reorders
|
||||
// optimistically and rolls back with a toast of its own.
|
||||
const moveCategory = (from: string, to: string) => {
|
||||
const orderedIds = itemOrderWithCategoryMoved(items, viewItems, from, to, t('packing.defaultCategory'))
|
||||
if (orderedIds) void reorderPackingItems(tripId, orderedIds)
|
||||
}
|
||||
|
||||
const {
|
||||
addingCategory, setAddingCategory, newCategoryName: newCatName, setNewCategoryName: setNewCatName,
|
||||
addItemToCategory: handleAddItemToCategory, deleteItem: handleDeleteItem, addNewCategory: handleAddNewCategory,
|
||||
@@ -213,7 +220,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, addCategor
|
||||
handleSetSharing, handleCloneItem, handleJoinItem, handleLeaveItem,
|
||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font, reorderPackingItems,
|
||||
filter, setFilter, sort, setSort, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt, moveCategory,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
|
||||
@@ -308,9 +308,10 @@ export default function AccountTab(): React.ReactElement {
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={e => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
aria-label={t('settings.mfa.codePlaceholder')}
|
||||
className={`${INPUT} font-geist tabular-nums`}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BookOpen, Link2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The trip's Journal tab: a book with a link on its corner, because the journal
|
||||
* is not the trip's own but one from the Journey addon tied to it. Takes the
|
||||
* props the tab bars hand a lucide icon.
|
||||
*
|
||||
* The book is masked out under the link rather than the link given a backing:
|
||||
* a backing has to match whatever the icon sits on (a card, the accent of a
|
||||
* picked tab), and the moment it does not, the link vanishes into it.
|
||||
*/
|
||||
export function LinkedJournalIcon({
|
||||
size = 16,
|
||||
strokeWidth = 2,
|
||||
className,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const badge = Math.round(size * 0.6);
|
||||
// The hole: a circle round the link's centre, a little wider than the link.
|
||||
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
|
||||
return (
|
||||
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`relative inline-flex flex-none ${className ?? ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
|
||||
<BookOpen size={size} strokeWidth={strokeWidth} />
|
||||
</span>
|
||||
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, rendered: what someone sees with no
|
||||
* journal linked, with one linked, while picking, and the warning that names
|
||||
* the people a save would let in for the first time.
|
||||
*/
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const TRIP = 4;
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: 'covers/j.jpg',
|
||||
owner_id: 1,
|
||||
linked_by: 1,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
let deletes = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
answer = EMPTY;
|
||||
deletes = 0;
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
deletes += 1;
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('TripJournalLinkSection', () => {
|
||||
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
|
||||
useAddonStore.setState({ addons: [] });
|
||||
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
|
||||
await user.click(await screen.findByText('Link a journal'));
|
||||
await user.click(await screen.findByText('Choose a journal'));
|
||||
// The select opens its list in a portal, one button per journal.
|
||||
await user.click(await screen.findByRole('button', { name: 'Japan' }));
|
||||
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Has access')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
|
||||
|
||||
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Everyone can read'));
|
||||
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Nobody'));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.getByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Linked')).toBeInTheDocument();
|
||||
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Edit access'));
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
|
||||
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.getAttribute('dir')).toBe('ltr');
|
||||
expect(root.style.width).toBe('20px');
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
|
||||
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { DialogButton, fs } from '../shared/DialogShell';
|
||||
import { AddRowButton } from '../shared/dialogParts';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripMemberAvatar } from './TripMemberAvatar';
|
||||
import { LinkCard } from './TripShareDialog';
|
||||
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
|
||||
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
|
||||
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
|
||||
|
||||
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
|
||||
{ value: null, labelKey: 'trip.journal.roleNone' },
|
||||
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
|
||||
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, for the desktop dialog and the phone
|
||||
* sheet alike: the journal the trip shows, and who of the trip it is open to.
|
||||
* Hidden with the Journey addon off, and for someone who can neither link one
|
||||
* nor see one that is linked.
|
||||
*/
|
||||
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const j = useTripJournalLink(tripId);
|
||||
const [askUnlink, setAskUnlink] = useState(false);
|
||||
if (!j.visible) return null;
|
||||
|
||||
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
|
||||
const Frame = framed ? JournalCard : Bare;
|
||||
return (
|
||||
<>
|
||||
<Frame>
|
||||
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
|
||||
{j.mode === 'view' && !j.link && j.canLink && (
|
||||
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
|
||||
)}
|
||||
{j.mode === 'view' && j.link && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{j.canEditMembers && (
|
||||
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.editMembers')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canLink && (
|
||||
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.change')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canUnlink && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAskUnlink(true)}
|
||||
disabled={j.busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Unlink size={13} strokeWidth={2.2} />
|
||||
{t('trip.journal.unlink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{j.mode === 'pick' && <JournalPicker j={j} />}
|
||||
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
|
||||
{j.mode !== 'view' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogButton onClick={j.cancel} disabled={j.busy}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={() => void j.save()}
|
||||
disabled={!j.canSave}
|
||||
icon={<Link2 size={14} strokeWidth={2.2} />}
|
||||
>
|
||||
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
)}
|
||||
</Frame>
|
||||
<ConfirmDialog
|
||||
isOpen={askUnlink}
|
||||
onClose={() => setAskUnlink(false)}
|
||||
onConfirm={() => {
|
||||
setAskUnlink(false);
|
||||
void j.unlink();
|
||||
}}
|
||||
title={t('trip.journal.unlinkTitle')}
|
||||
message={t('trip.journal.unlinkMessage')}
|
||||
confirmLabel={t('trip.journal.unlink')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalCard({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LinkCard
|
||||
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
|
||||
title={t('trip.journal.title')}
|
||||
hint={t('trip.journal.hint')}
|
||||
>
|
||||
{children}
|
||||
</LinkCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Bare({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-col gap-4">{children}</div>;
|
||||
}
|
||||
|
||||
type JournalLink = ReturnType<typeof useTripJournalLink>;
|
||||
|
||||
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
|
||||
function LinkedJournal({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
const link = j.link;
|
||||
if (!link) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
|
||||
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{link.cover_image ? (
|
||||
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<BookOpen size={18} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{link.title}
|
||||
</div>
|
||||
{link.my_role && (
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>
|
||||
{t(`trip.journal.yourRole.${link.my_role}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Link2 size={10} strokeWidth={2.6} />
|
||||
{t('trip.journal.linkedBadge')}
|
||||
</span>
|
||||
</div>
|
||||
{link.members && link.members.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{link.members.map((m) => (
|
||||
<span
|
||||
key={m.user_id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
|
||||
<span className="font-medium text-content">{m.username}</span>
|
||||
<span className="text-content-faint">
|
||||
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{link.members && link.members.length === 0 && (
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{t('trip.journal.onlyYou')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
|
||||
function JournalPicker({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
|
||||
if (j.journals.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noJournals')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<CustomSelect
|
||||
value={j.pickedId == null ? '' : String(j.pickedId)}
|
||||
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
|
||||
placeholder={t('trip.journal.pickPlaceholder')}
|
||||
options={[
|
||||
{ value: '', label: t('trip.journal.pickPlaceholder') },
|
||||
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone of the trip, each with no access, read or edit. Before saving, the
|
||||
* people the link would let in for the first time are named: from then on they
|
||||
* see the journal in their own Journey list as well.
|
||||
*/
|
||||
function MemberChooser({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
|
||||
if (j.candidates.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noMembers')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
|
||||
{t('trip.journal.members')}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll('viewer')}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allRead')}
|
||||
</button>
|
||||
<span className="text-content-faint" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll(null)}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allNone')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={ROWS}>
|
||||
{j.candidates.map((c) => (
|
||||
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
|
||||
))}
|
||||
</div>
|
||||
{j.newlyGranted.length > 0 && (
|
||||
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
|
||||
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
|
||||
<p className="m-0 leading-normal" style={fs(12)}>
|
||||
{t('trip.journal.warnNew', {
|
||||
count: j.newlyGranted.length,
|
||||
names: j.newlyGranted.map((c) => c.username).join(', '),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
c,
|
||||
choice,
|
||||
onRole,
|
||||
showAccess,
|
||||
}: {
|
||||
c: TripJournalCandidate;
|
||||
choice: JournalMemberChoice;
|
||||
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
|
||||
showAccess: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = choice[c.user_id] ?? null;
|
||||
return (
|
||||
<div className={ROW}>
|
||||
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{c.username}
|
||||
</span>
|
||||
{c.is_trip_owner && (
|
||||
<span
|
||||
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
{showAccess && c.access && (
|
||||
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
|
||||
{t('trip.journal.hasAccess')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={c.username}
|
||||
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.labelKey}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={current === r.value}
|
||||
onClick={() => onRole(c.user_id, r.value)}
|
||||
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
|
||||
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
// members.shareTrip = "Share Trip"
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
// trip.settings.title = "Trip settings"
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
||||
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
|
||||
|
||||
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
||||
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
||||
@@ -291,6 +291,7 @@ describe('TripMembersModal', () => {
|
||||
);
|
||||
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
const createBtn = await screen.findByText('Create link');
|
||||
await user.click(createBtn);
|
||||
|
||||
@@ -689,6 +690,7 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
const view = render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
||||
fireEvent.click(createBtn);
|
||||
@@ -719,6 +721,7 @@ describe('TripMembersModal', () => {
|
||||
asShareOwner();
|
||||
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
||||
|
||||
@@ -734,6 +737,7 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon'
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import { TripMemberAvatar } from './TripMemberAvatar'
|
||||
import { useTripJournalSectionVisible } from './useTripJournalLink'
|
||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
||||
|
||||
interface TripShareDialogProps {
|
||||
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
||||
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
||||
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
|
||||
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
|
||||
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
|
||||
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
|
||||
/**
|
||||
* The trip's Settings on the desktop: who is on the trip, its guests, the two
|
||||
* links that let others in, and the journal the trip shows.
|
||||
*/
|
||||
export default function TripShareDialog(props: TripShareDialogProps) {
|
||||
return props.isOpen ? <OpenDialog {...props} /> : null
|
||||
}
|
||||
|
||||
type Area = 'people' | 'sharing' | 'journal'
|
||||
|
||||
/** The areas of the dialog, each with its icon, its name and the line under the name. */
|
||||
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
|
||||
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
|
||||
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
|
||||
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
|
||||
]
|
||||
|
||||
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
||||
const [ask, setAsk] = useState<Ask | null>(null)
|
||||
const [area, setArea] = useState<Area>('people')
|
||||
const journalSection = useTripJournalSectionVisible(tripId)
|
||||
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
|
||||
const current = areas.find(a => a.id === area) ?? areas[0]
|
||||
const panelOf = (id: Area) => areas.find(a => a.id === id)
|
||||
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
||||
|
||||
return (
|
||||
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width={m.canManageShare ? 'wide' : 'detail'}
|
||||
width={areas.length > 1 ? 'wide' : 'detail'}
|
||||
blocked={!!ask}
|
||||
bodyClassName={BODY}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={t('members.shareTrip')}
|
||||
eyebrow={t('trip.settings.title')}
|
||||
title={tripTitle}
|
||||
pills={m.loading ? undefined : (
|
||||
<>
|
||||
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
|
||||
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
|
||||
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
|
||||
{m.canManageMembers && <InviteUser m={m} />}
|
||||
<MembersList m={m} onAsk={setAsk} />
|
||||
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
||||
</div>
|
||||
</AreaPanel>
|
||||
{m.canManageShare && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
|
||||
<PublicLink tripId={tripId} />
|
||||
<InviteLink tripId={tripId} />
|
||||
</div>
|
||||
</AreaPanel>
|
||||
)}
|
||||
{journalSection && (
|
||||
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
|
||||
<TripJournalLinkSection tripId={tripId} framed={false} />
|
||||
</AreaPanel>
|
||||
)}
|
||||
</div>
|
||||
</DialogShell>
|
||||
@@ -93,6 +124,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
)
|
||||
}
|
||||
|
||||
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
|
||||
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<nav aria-label={t('trip.settings.title')} className={NAV}>
|
||||
{areas.map(a => {
|
||||
const on = a.id === current
|
||||
return (
|
||||
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
|
||||
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
|
||||
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
|
||||
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
|
||||
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
|
||||
{titled && area && (
|
||||
<header>
|
||||
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
|
||||
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteUser({ m }: { m: TripMembersState }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
|
||||
)
|
||||
}
|
||||
|
||||
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
|
||||
|
||||
/**
|
||||
* The trip's linked journal as the planner and the Settings dialog read it.
|
||||
*
|
||||
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
|
||||
* the Journey addon on, only once the server answered, and live on every
|
||||
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
|
||||
* of your own journals, everybody of the trip ticked as a reader by default,
|
||||
* the people who would get in for the first time named before the save.
|
||||
*/
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const listeners = new Set<(e: Record<string, unknown>) => void>();
|
||||
vi.mock('../../api/websocket', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../api/websocket')>()),
|
||||
addListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.add(fn);
|
||||
},
|
||||
removeListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const ME = 1;
|
||||
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: null,
|
||||
owner_id: ME,
|
||||
linked_by: ME,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let gets = 0;
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
const writes: { method: string; body: unknown }[] = [];
|
||||
|
||||
function routes({ writeStatus = 200 } = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => {
|
||||
gets += 1;
|
||||
return HttpResponse.json(answer);
|
||||
}),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({
|
||||
journeys: [
|
||||
{ id: 9, user_id: ME, title: 'Japan' },
|
||||
{ id: 10, user_id: 99, title: 'Not mine' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/trips/:tripId/journal', async ({ request }) => {
|
||||
writes.push({ method: 'PUT', body: await request.json() });
|
||||
return writeStatus === 200
|
||||
? HttpResponse.json(LINKED)
|
||||
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
|
||||
}),
|
||||
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
|
||||
writes.push({ method: 'PATCH', body: await request.json() });
|
||||
return HttpResponse.json(LINKED);
|
||||
}),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
writes.push({ method: 'DELETE', body: null });
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function journeyAddon(enabled: boolean): void {
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gets = 0;
|
||||
answer = EMPTY;
|
||||
writes.length = 0;
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
|
||||
journeyAddon(true);
|
||||
routes();
|
||||
});
|
||||
|
||||
afterEach(() => _resetNetworkMode());
|
||||
|
||||
describe('useTripJournal', () => {
|
||||
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
|
||||
journeyAddon(false);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
expect(result.current.settled).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(false);
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
expect(result.current.settled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(gets).toBe(1));
|
||||
answer = LINKED;
|
||||
act(() => {
|
||||
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
|
||||
setForcedOffline(true);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(true);
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
|
||||
renderHook(() => useTripJournal(null));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
|
||||
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
|
||||
rerender();
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTripJournalLink', () => {
|
||||
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
|
||||
// lena is in already; tom would be let in for the first time.
|
||||
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
|
||||
expect(result.current.canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
act(() => {
|
||||
result.current.setRole(2, 'editor');
|
||||
result.current.setRole(3, null);
|
||||
});
|
||||
expect(result.current.newlyGranted).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.link?.title).toBe('Japan');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
act(() => result.current.editMembers());
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
|
||||
|
||||
act(() => result.current.setAll('viewer'));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
members: [
|
||||
{ user_id: 2, role: 'viewer' },
|
||||
{ user_id: 3, role: 'viewer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.canUnlink).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.unlink();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
|
||||
expect(result.current.link).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
|
||||
routes({ writeStatus: 403 });
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
|
||||
expect(result.current.mode).toBe('pick');
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
setForcedOffline(true);
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
|
||||
expect(result.current.journals).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
|
||||
);
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
|
||||
expect(result.current.candidates).toEqual([]);
|
||||
act(() => result.current.cancel());
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** Who a link lets in, as the member list edits it: a role, or left out. */
|
||||
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
|
||||
|
||||
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
|
||||
const NOBODY: TripJournalLinkMember[] = [];
|
||||
|
||||
/** The status of a failed request, when the server gave one. */
|
||||
function statusOf(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } })?.response?.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's linked journal as everybody on the trip reads it: answered once per
|
||||
* trip from the store, asked again on mount, when the connection returns and on
|
||||
* every `trip:journal:changed`. With the Journey addon off there is no journal.
|
||||
*/
|
||||
export function useTripJournal(tripId: number | null | undefined) {
|
||||
const addonsLoaded = useAddonStore((s) => s.loaded);
|
||||
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
|
||||
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
|
||||
const refresh = useTripJournalLinkStore((s) => s.refresh);
|
||||
const { offline } = useNetworkMode();
|
||||
const active = journeyOn && tripId != null && !offline;
|
||||
const reload = useCallback(async () => {
|
||||
if (tripId != null) await refresh(tripId);
|
||||
}, [tripId, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void reload();
|
||||
}, [active, reload]);
|
||||
useServerPing('trip:journal:changed', active, reload);
|
||||
|
||||
const link = journeyOn ? (answer?.link ?? null) : null;
|
||||
return {
|
||||
journeyOn,
|
||||
answer: journeyOn ? answer : undefined,
|
||||
link,
|
||||
/** The journal the trip's Journal tab opens: linked, and open to this person. */
|
||||
openJournalId: link?.my_role ? link.journey_id : null,
|
||||
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
|
||||
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
|
||||
export function useTripJournalSectionVisible(tripId: number): boolean {
|
||||
const { journeyOn, answer } = useTripJournal(tripId);
|
||||
return journeyOn && !!answer && (answer.can_link || !!answer.link);
|
||||
}
|
||||
|
||||
/** The toast for a failed journal request: offline, refused, or anything else. */
|
||||
function useJournalFailure() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
return useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
|
||||
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
|
||||
else toast.error(t('trip.journal.errorGeneric'));
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people of the trip for one journal, each with the role the save would
|
||||
* give them. `current` is who the link holds now, to start from; without it
|
||||
* everybody starts as a reader, the default the section promises.
|
||||
*
|
||||
* What loaded is kept with the journal it belongs to, so picking another one
|
||||
* shows nothing until its own people are in, never the last journal's.
|
||||
*/
|
||||
function useJournalMemberChoice(
|
||||
tripId: number,
|
||||
journeyId: number | null,
|
||||
current: TripJournalLinkMember[] | null,
|
||||
fail: (err: unknown) => void
|
||||
) {
|
||||
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
|
||||
const [choice, setChoice] = useState<JournalMemberChoice>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (journeyId == null) return;
|
||||
let cancelled = false;
|
||||
tripJournalLinkRepo
|
||||
.candidates(tripId, journeyId)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
|
||||
setLoaded({ journeyId, list });
|
||||
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
fail(err);
|
||||
setLoaded({ journeyId, list: [] });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tripId, journeyId, current, fail]);
|
||||
|
||||
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
|
||||
|
||||
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
|
||||
setChoice((prev) => ({ ...prev, [memberId]: role }));
|
||||
}, []);
|
||||
|
||||
const setAll = useCallback(
|
||||
(role: TripJournalLinkRole | null) =>
|
||||
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
|
||||
[candidates]
|
||||
);
|
||||
|
||||
const chosenMembers = useMemo(
|
||||
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
|
||||
[choice]
|
||||
);
|
||||
|
||||
/** The people the save would let into the journal for the first time; the warning names them. */
|
||||
const newlyGranted = useMemo(
|
||||
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
|
||||
[candidates, choice]
|
||||
);
|
||||
|
||||
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Journal section of the trip's Settings: link one of your journals, choose
|
||||
* who it is open to and as what, change it, or take it off. One hook for the
|
||||
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
|
||||
*
|
||||
* Picking a journal asks the server who of the trip already has access to it;
|
||||
* whoever would be let in for the first time is named before anything is saved,
|
||||
* because they will see the journal in their own Journey list from then on.
|
||||
*/
|
||||
export function useTripJournalLink(tripId: number) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const fail = useJournalFailure();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { journeyOn, answer, link } = useTripJournal(tripId);
|
||||
const store = useTripJournalLinkStore.getState;
|
||||
|
||||
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
|
||||
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
|
||||
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
|
||||
const [pickedId, setPickedId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
|
||||
// Who the link holds, to start from: when editing it, or when re-picking the
|
||||
// journal it already shows with people on it.
|
||||
const held = link?.members ?? NOBODY;
|
||||
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
|
||||
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
|
||||
const { chosenMembers } = members;
|
||||
|
||||
const startPick = useCallback(() => {
|
||||
setMode('pick');
|
||||
setPickedId(null);
|
||||
if (userId == null) return;
|
||||
tripJournalLinkRepo
|
||||
.ownJournals(userId)
|
||||
.then(setJournals)
|
||||
.catch((err: unknown) => {
|
||||
fail(err);
|
||||
setJournals([]);
|
||||
});
|
||||
}, [userId, fail]);
|
||||
|
||||
const editMembers = useCallback(() => setMode('members'), []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setMode('view');
|
||||
setPickedId(null);
|
||||
}, []);
|
||||
|
||||
const run = useCallback(
|
||||
async (write: () => Promise<void>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await write();
|
||||
toast.success(done);
|
||||
cancel();
|
||||
} catch (err: unknown) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cancel, fail, toast]
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (mode === 'pick' && pickedId != null) {
|
||||
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
|
||||
} else if (mode === 'members') {
|
||||
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
|
||||
}
|
||||
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
|
||||
}, [run, store, tripId, t]);
|
||||
|
||||
return {
|
||||
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
|
||||
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
|
||||
link,
|
||||
canLink: !!answer?.can_link,
|
||||
canUnlink: !!answer?.can_unlink,
|
||||
canEditMembers: !!answer?.can_edit_members,
|
||||
mode,
|
||||
startPick,
|
||||
cancel,
|
||||
editMembers,
|
||||
journals,
|
||||
pickedId,
|
||||
pickJournal: setPickedId,
|
||||
candidates: members.candidates,
|
||||
choice: members.choice,
|
||||
setRole: members.setRole,
|
||||
setAll: members.setAll,
|
||||
newlyGranted: members.newlyGranted,
|
||||
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
|
||||
busy,
|
||||
save,
|
||||
unlink,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
|
||||
'finanzplan',
|
||||
'dateien',
|
||||
'collab',
|
||||
'journal',
|
||||
] as const
|
||||
|
||||
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
||||
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
|
||||
finanzplan: 'trip.tabs.budget',
|
||||
dateien: 'trip.tabs.files',
|
||||
collab: 'admin.addons.catalog.collab.name',
|
||||
journal: 'trip.tabs.journal',
|
||||
}
|
||||
|
||||
export const isTripTabId = (value: unknown): value is TripTabId =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { calculateRouteWithRetries, type RouteProfileKey } from '../components/Map/routeRetry'
|
||||
import { buildDayRouteRuns, hotelBookendOf, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan'
|
||||
import { dayViaLookup, foldViaLegs, threadVias, viaKeyOf, viasOnDayLeg } from '../utils/dayRouteVias'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
@@ -131,7 +131,8 @@ export function useRouteCalculation(tripStore: Pick<TripStoreState, 'assignments
|
||||
const straight = (): [number, number][] => chunk.map(p => [p.lat, p.lng] as [number, number])
|
||||
const { waypoints, stopAt } = threadVias(chunk, (from, to) => viasOnDayLeg(viaLookup, dayId, mode, from, to))
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
// A busy router is waited out rather than left as straight lines until the next edit.
|
||||
const r = await calculateRouteWithRetries(waypoints, { signal: controller.signal, profile: mode, tripId, dayId })
|
||||
pushCoords(r.coordinates.length >= 2 ? r.coordinates : straight(), mode === 'walking')
|
||||
// Leg k runs from chunk[k] to chunk[k + 1]; a hotel bookend says so on the
|
||||
// segment, which is how the phone timeline finds it (#2501).
|
||||
|
||||
@@ -7,7 +7,7 @@ import ContributorInviteDialog from '../../../components/Journey/ContributorInvi
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { ProviderPicker } from '../../../components/Journey/JourneyDetailPageProviderPicker'
|
||||
import { photoUrl, posterlessVideo } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyDetail, type JourneyDetailOptions } from '../../../pages/journeyDetail/useJourneyDetail'
|
||||
import { useJourneyStore } from '../../../store/journeyStore'
|
||||
import { useAddonStore } from '../../../store/addonStore'
|
||||
import DawarichIcon from '../../../components/shared/DawarichIcon'
|
||||
@@ -32,8 +32,12 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
|
||||
* Journey detail — integrated map with the horizontal 280px card timeline
|
||||
* (Journey tab) and the 2-column photo gallery (Gallery tab). Cards and map
|
||||
* markers stay in sync; tapping the centered card opens the entry sheet.
|
||||
*
|
||||
* A trip that links the journal shows this same screen in its own tab
|
||||
* (`embedded`): the trip's back button and controls sit on top, so the screen
|
||||
* drops its own back button and leaves the trip's URL alone.
|
||||
*/
|
||||
export default function MJourneyDetail() {
|
||||
export default function MJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
const {
|
||||
id, navigate, toast, t,
|
||||
current, loading,
|
||||
@@ -48,7 +52,7 @@ export default function MJourneyDetail() {
|
||||
dismissSuggestion, restoreSuggestions, openAtEntryId,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail()
|
||||
} = useJourneyDetail({ journeyId, embedded })
|
||||
|
||||
// The dock's FAB is a sibling of this screen: on the Gallery it becomes the
|
||||
// upload button, so it has to know which tab is open — and it stops knowing
|
||||
@@ -175,7 +179,7 @@ export default function MJourneyDetail() {
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [pendingUpload, setPendingUpload] = useState(false)
|
||||
useEffect(() => {
|
||||
if (params.get('create') !== 'photo') return
|
||||
if (embedded || params.get('create') !== 'photo') return
|
||||
setParams(prev => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('create')
|
||||
@@ -184,7 +188,7 @@ export default function MJourneyDetail() {
|
||||
if (!canEditEntries) return
|
||||
setView('gallery')
|
||||
setPendingUpload(true)
|
||||
}, [params, setParams, canEditEntries, setView])
|
||||
}, [params, setParams, canEditEntries, setView, embedded])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingUpload || !providersReady) return
|
||||
@@ -331,14 +335,18 @@ export default function MJourneyDetail() {
|
||||
|
||||
{/* Header: back — segment — upload / overflow menu */}
|
||||
<div className="absolute inset-x-4 top-[var(--m-safe-top,12px)] z-10 flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
{embedded ? (
|
||||
<span className="w-[38px] flex-none" aria-hidden />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]"
|
||||
>
|
||||
<ChevronLeft size={18} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute left-1/2 top-0 flex -translate-x-1/2 rounded-full bg-[color:var(--m-sheet)] p-[3px] shadow-[0_5px_14px_-6px_rgba(0,0,0,.3)]">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
|
||||
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
|
||||
} from 'lucide-react'
|
||||
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -237,6 +234,26 @@ export default function MJourneySettingsSheet({
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
|
||||
{/* Through a trip's linked journal (#2683): shown, but changed in that trip. */}
|
||||
{!!journey.trip_access?.length && (
|
||||
<>
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
|
||||
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
|
||||
{journey.trip_access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
|
||||
<Link2 size={15} className="flex-none text-m-muted" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-caption font-bold">{a.username}</span>
|
||||
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
|
||||
</span>
|
||||
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
|
||||
{a.role}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Public share */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||
{!shareLink ? (
|
||||
|
||||
@@ -244,9 +244,10 @@ export default function MSettingsAccount() {
|
||||
/>
|
||||
<MSetInput
|
||||
className="mt-2"
|
||||
inputMode="numeric"
|
||||
inputMode="text"
|
||||
autoComplete="one-time-code"
|
||||
value={mfaDisableCode}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
onChange={(e) => setMfaDisableCode(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').slice(0, 12))}
|
||||
placeholder={t('settings.mfa.codePlaceholder')}
|
||||
/>
|
||||
<MSetButton
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
|
||||
import { ChevronRight, FileDown, Settings, Settings2 } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
@@ -14,6 +14,7 @@ const TILE_COLORS: Record<string, string> = {
|
||||
finanzplan: '#E8A33D',
|
||||
dateien: '#68686F',
|
||||
collab: '#EC4899',
|
||||
journal: '#C2410C',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +49,7 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
|
||||
}
|
||||
|
||||
const actions: { id: string; icon: LucideIcon; label: string; go: () => void }[] = [
|
||||
{ id: 'members', icon: Share2, label: t('members.shareTrip'), go: () => shell.openSheet('members') },
|
||||
{ id: 'members', icon: Settings2, label: t('trip.settings.title'), go: () => shell.openSheet('members') },
|
||||
{ id: 'export', icon: FileDown, label: t('mobileTrip.export'), go: () => shell.openSheet('export') },
|
||||
...(canEditTrip
|
||||
? [{ id: 'tripedit', icon: Settings, label: t('dashboard.editTrip'), go: () => shell.openSheet('tripedit') }]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatTime, formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -173,11 +173,7 @@ function BookingCard({ res, planner, canEdit, compact }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{res.notes && (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-[0.6875rem] leading-[1.5] text-m-muted">{res.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useTranslation } from '../../../../i18n'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
|
||||
import TrekIcon from '../../../../components/shared/TrekIcon'
|
||||
import {
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider,
|
||||
bindingNotice, needsReauth, storeName, useDocSync, type DocSyncLink, type DocSyncProvider, type DocSyncVerdict,
|
||||
} from '../../../../components/Files/docsync/useDocSync'
|
||||
import { useConnectForm } from '../../../../components/Files/docsync/useConnectForm'
|
||||
import { conflictPolicyKey, nextConflictPolicy } from '../../../../components/Files/docsync/DocSyncBits'
|
||||
@@ -450,7 +450,7 @@ function ConnectView({
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const form = useConnectForm(provider, sync.connectionFor(provider.id), sync)
|
||||
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
|
||||
const [verdict, setVerdict] = useState<DocSyncVerdict | null>(null)
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
@@ -503,6 +503,10 @@ function ConnectView({
|
||||
? t('docsync.connect.okAs', { account: verdict.account })
|
||||
: t('docsync.connected')
|
||||
: t(`docsync.error.${verdict.error ?? 'unknown'}`)}
|
||||
{/* The store's own reason, so a refusal can be told apart from a typo (#2666). */}
|
||||
{!verdict.connected && verdict.detail && (
|
||||
<span className="mt-0.5 block text-m-muted [overflow-wrap:anywhere]">{verdict.detail}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-009
|
||||
// FE-COMP-MRESCARD-001 to FE-COMP-MRESCARD-010
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Hotel, Plane } from 'lucide-react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard';
|
||||
import type { Reservation } from '../../../../types';
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts';
|
||||
import {
|
||||
CardActions,
|
||||
CardDeleteSheet,
|
||||
CardFiles,
|
||||
CardNotes,
|
||||
CardTitleButton,
|
||||
CardWhenAndCode,
|
||||
} from './MReservationCardParts';
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) =>
|
||||
params ? `${key}:${Object.values(params).join(',')}` : key;
|
||||
@@ -149,4 +156,15 @@ describe('MReservationCardParts', () => {
|
||||
expect(title.contains(date)).toBe(false);
|
||||
expect(container.querySelector('button > div')).toHaveClass('flex-col');
|
||||
});
|
||||
|
||||
it('FE-COMP-MRESCARD-010: shows the notes of a booking, and nothing for empty ones (Discord, 15.10.2026)', () => {
|
||||
const notes = 'Gate closes 40 min early\nBag drop at counter C';
|
||||
const { container, rerender } = render(<CardNotes notes={notes} />);
|
||||
expect(container.querySelector('p')?.textContent).toBe(notes);
|
||||
|
||||
rerender(<CardNotes notes=" " />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
rerender(<CardNotes notes={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,22 @@ export function CardWhenAndCode({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The booking's notes, inside the card body button. A transport card on the phone
|
||||
* never showed them, so notes typed into a flight were nowhere to be read
|
||||
* (reported on Discord, 15.10.2026); both cards now take them from here.
|
||||
*/
|
||||
export function CardNotes({ notes }: { notes: string | null | undefined }) {
|
||||
if (!notes?.trim()) return null;
|
||||
return (
|
||||
<div className="mt-2 rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-2">
|
||||
<p className="whitespace-pre-wrap font-geist text-caption leading-[1.5] text-m-muted [overflow-wrap:anywhere]">
|
||||
{notes}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Attached files, listed inside the card body button. */
|
||||
export function CardFiles({ files, card, t }: { files: ReturnType<typeof filesFor>; card: CardState; t: T }) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RES_ICONS } from '../../../../components/Planner/DayPlanSidebar.constan
|
||||
import { formatPriceText } from '../../../../utils/formatters'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Reservation } from '../../../../types'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, CardWhenAndCode } from './MReservationCardParts'
|
||||
import { Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
|
||||
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
|
||||
import { cardWhen, parseTransportMeta } from './transportsModel'
|
||||
@@ -189,6 +189,8 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
|
||||
|
||||
<TravelerAvatars travelers={res.travelers || []} label={t('reservations.travelers.label')} />
|
||||
|
||||
<CardNotes notes={res.notes} />
|
||||
|
||||
<CardFiles files={files} card={card} t={t} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import MFilesTab from './MFilesTab'
|
||||
import MCollabTab from './MCollabTab'
|
||||
import MListsTab from './MListsTab'
|
||||
import MRoadtripTab from '../roadtrip/MRoadtripTab'
|
||||
import MJourneyDetail from '../../journey/MJourneyDetail'
|
||||
import PluginFrame from '../../../../components/Plugins/PluginFrame'
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,14 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
|
||||
// switch between them must not lose it.
|
||||
case 'roadtrip':
|
||||
return <MRoadtripTab planner={planner} shell={shell} tab={tab} />
|
||||
// The journal the trip links: the Journey addon's own phone screen, moved
|
||||
// below the trip's floating top controls: its bars measure from --m-safe-top.
|
||||
case 'journal':
|
||||
return planner.journalId == null ? null : (
|
||||
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
|
||||
<MJourneyDetail journeyId={planner.journalId} embedded />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return <TabScroller>{null}</TabScroller>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
|
||||
import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
|
||||
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
|
||||
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
|
||||
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
|
||||
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyDetailPageDesktop() {
|
||||
/**
|
||||
* The journal screen. The journal page renders it for the route's journal; a trip
|
||||
* that links a journal renders the same screen inside its own tab (`embedded`),
|
||||
* so the trip shows the journal with nothing of it built twice.
|
||||
*/
|
||||
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
// Page = wiring container: load + live sync, view state, dialogs, the
|
||||
// scroll-synced map and the map/trip-date derivations live in the hook.
|
||||
const {
|
||||
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail()
|
||||
} = useJourneyDetail({ journeyId, embedded })
|
||||
// Inside a trip the trip draws the bar and owns the height; the journal fills
|
||||
// the box it is given and starts at its top.
|
||||
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
|
||||
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
|
||||
|
||||
if (loading || !current) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
|
||||
<div className={frame}>
|
||||
{!embedded && <Navbar />}
|
||||
<div style={below} className="flex justify-center py-20">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div className={frame}>
|
||||
{!embedded && <Navbar />}
|
||||
|
||||
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||
{showMobileCombined && (
|
||||
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
|
||||
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className={barButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className={barButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
|
||||
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
|
||||
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
|
||||
|
||||
{/* TREK Studio. It portals itself over the page, so this journey keeps
|
||||
rendering underneath and shows through the panel's margin. */}
|
||||
<Outlet />
|
||||
{!embedded && <Outlet />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useAddonStore } from '../store/addonStore';
|
||||
import { usePermissionsStore } from '../store/permissionsStore';
|
||||
import JourneyPage from './JourneyPage';
|
||||
|
||||
const { mockToastError } = vi.hoisted(() => ({ mockToastError: vi.fn() }));
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
@@ -24,6 +26,10 @@ vi.mock('../components/Layout/Navbar', () => ({
|
||||
default: () => <nav data-testid="navbar" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/shared/Toast', () => ({
|
||||
useToast: () => ({ error: mockToastError }),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _seq = 500;
|
||||
@@ -78,6 +84,7 @@ function setupDefaultHandlers(journeys: ReturnType<typeof buildJourneyListItem>[
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mockNavigate.mockReset();
|
||||
resetAllStores();
|
||||
seedDefaults();
|
||||
@@ -193,6 +200,75 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007c: keeps a dismissed suggestion hidden after remount', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestions = [
|
||||
{ id: 991, title: 'Kyoto Weekend', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 },
|
||||
{ id: 993, title: 'Lisbon Loop', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 3 },
|
||||
];
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: suggestions })),
|
||||
);
|
||||
|
||||
const firstRender = render(<JourneyPage />);
|
||||
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
const dismissedRecord = JSON.parse(window.localStorage.getItem(`trek:journey:dismissed-suggestions:v1:${useAuthStore.getState().user?.id}`) || '[]');
|
||||
expect(dismissedRecord).toContain(991);
|
||||
firstRender.unmount();
|
||||
|
||||
render(<JourneyPage />);
|
||||
expect(await screen.findByText(/Lisbon Loop/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Kyoto Weekend/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007d: keeps the suggestion visible and reports a storage failure', async () => {
|
||||
const user = userEvent.setup();
|
||||
const suggestion = { id: 992, title: 'Coast Trip', start_date: '2026-03-01', end_date: '2026-03-02', place_count: 2 };
|
||||
mockToastError.mockClear();
|
||||
const setItem = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new DOMException('Storage is full', 'QuotaExceededError');
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({ trips: [suggestion] })),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Could not dismiss this suggestion');
|
||||
expect(screen.getByText(/Trip just ended/i)).toBeInTheDocument();
|
||||
setItem.mockRestore();
|
||||
});
|
||||
|
||||
it('FE-PAGE-JOURNEY-007e: deletes dismissed IDs absent from the suggestions response', async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const absentTripId = 994;
|
||||
const stillDismissedTripId = 995;
|
||||
const returnedTripId = 996;
|
||||
const key = `trek:journey:dismissed-suggestions:v1:${userId}`;
|
||||
window.localStorage.setItem(key, JSON.stringify([absentTripId, stillDismissedTripId]));
|
||||
server.use(
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [] })),
|
||||
http.get('/api/journeys/suggestions', () => HttpResponse.json({
|
||||
trips: [
|
||||
{ id: returnedTripId, title: 'Returned Trip', start_date: '2026-04-01', end_date: '2026-04-02', place_count: 2 },
|
||||
{ id: stillDismissedTripId, title: 'Still Dismissed', start_date: '2026-05-01', end_date: '2026-05-02', place_count: 2 },
|
||||
],
|
||||
})),
|
||||
);
|
||||
|
||||
render(<JourneyPage />);
|
||||
|
||||
expect(await screen.findByText(/Returned Trip/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Still Dismissed/)).not.toBeInTheDocument();
|
||||
expect(JSON.parse(window.localStorage.getItem(key) || '[]')).toEqual([stillDismissedTripId]);
|
||||
});
|
||||
|
||||
// FE-PAGE-JOURNEY-007b — XSS regression: the suggestion banner interpolates
|
||||
// a user-controlled trip title into an HTML template that is later passed to
|
||||
// dangerouslySetInnerHTML. The sanitiser in @trek/shared must drop any script
|
||||
@@ -266,7 +342,7 @@ describe('JourneyPage', () => {
|
||||
});
|
||||
|
||||
// Click dismiss
|
||||
await user.click(screen.getByText(/Dismiss/i));
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Trip just ended/i)).not.toBeInTheDocument();
|
||||
|
||||
@@ -45,7 +45,7 @@ function JourneyPageDesktop() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
} = useJourney()
|
||||
@@ -112,7 +112,7 @@ function JourneyPageDesktop() {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => setDismissedSuggestions(prev => new Set([...prev, activeSuggestion.id]))}
|
||||
onClick={() => dismissSuggestion(activeSuggestion.id)}
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 border border-white/20 text-[12px] font-medium text-white hover:bg-white/20"
|
||||
>
|
||||
{t('journey.frontpage.dismiss')}
|
||||
|
||||
@@ -38,7 +38,7 @@ function buildHook(over: Record<string, unknown> = {}): Record<string, unknown>
|
||||
availableTrips: [], selectedTripIds: new Set<number>(), setSelectedTripIds: vi.fn(),
|
||||
searchOpen: false, setSearchOpen: vi.fn(), searchQuery: '', setSearchQuery: vi.fn(),
|
||||
searchInputRef: { current: null },
|
||||
activeSuggestion: undefined, setDismissedSuggestions: vi.fn(),
|
||||
activeSuggestion: undefined, dismissSuggestion: vi.fn(),
|
||||
activeJourney: null, activeJourneyIsLive: false,
|
||||
filteredJourneys: journeys,
|
||||
openCreateModal: vi.fn(), handleCreate: vi.fn(), totalPlaces: 0,
|
||||
@@ -116,9 +116,7 @@ describe('JourneyPage wiring', () => {
|
||||
expect(screen.getByText('Trip just ended')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||
const updater = (hook.setDismissedSuggestions as ReturnType<typeof vi.fn>).mock.calls[0][0] as
|
||||
(prev: Set<number>) => Set<number>;
|
||||
expect([...updater(new Set<number>())]).toEqual([42]);
|
||||
expect(hook.dismissSuggestion).toHaveBeenCalledWith(42);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Create Journey' })[1]);
|
||||
expect(hook.openCreateModal).toHaveBeenCalledWith(42);
|
||||
|
||||
@@ -1586,8 +1586,9 @@ describe('SharedTripPage', () => {
|
||||
id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches',
|
||||
place: {
|
||||
id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris',
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null,
|
||||
duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
// The stay comes from the window; the place default of an hour is not a plan (#2676).
|
||||
lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: '11:30',
|
||||
duration_minutes: 60, notes: 'Skip the pyramid queue', website: 'https://louvre.fr',
|
||||
phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
},
|
||||
@@ -1639,6 +1640,23 @@ describe('SharedTripPage', () => {
|
||||
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
|
||||
expect(screen.queryByText(/min$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no stay for a stop with only a start, whatever the place default says (#2676)', async () => {
|
||||
const startOnly = {
|
||||
21: [{
|
||||
id: 303, day_id: 21, order_index: 0, notes: null,
|
||||
place: {
|
||||
id: 503, name: 'Café', description: null, address: 'Somewhere 1', lat: null, lng: null,
|
||||
duration_minutes: 60, notes: null, website: null, phone: null,
|
||||
category_id: null, price: null, place_time: '15:00', end_time: null, image_url: null,
|
||||
transport_mode: 'walking', category: null, tags: [],
|
||||
},
|
||||
}],
|
||||
};
|
||||
await open('start-only-token', payload({ days, assignments: startOnly }));
|
||||
await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument());
|
||||
expect(screen.queryByText('1 h')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
|
||||
import {
|
||||
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
|
||||
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
|
||||
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
|
||||
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
|
||||
} from './tripPlanner/plannerLazy'
|
||||
import { LazyPanel } from './tripPlanner/LazyPanel'
|
||||
import { ListsContainer } from './tripPlanner/ListsContainer'
|
||||
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
|
||||
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<HelpAnchor id={helpId} />
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
|
||||
|
||||
<div className="bg-surface-elevated border-b border-edge-faint" style={{
|
||||
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
|
||||
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
|
||||
@@ -657,14 +657,14 @@ describe('TripPlannerPage — shell', () => {
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / share', () => {
|
||||
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / settings', () => {
|
||||
renderPage()
|
||||
|
||||
expect(props('navbar').tripTitle).toBe('Kyoto')
|
||||
act(() => { props('navbar').onBack() })
|
||||
expect(hookState.navigate).toHaveBeenCalledWith('/dashboard')
|
||||
|
||||
act(() => { props('navbar').onShare() })
|
||||
act(() => { props('navbar').onSettings() })
|
||||
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { computeJourneyLifecycle } from '../../utils/journeyLifecycle'
|
||||
|
||||
function dismissedSuggestionsKey(userId: number | undefined): string {
|
||||
return `trek:journey:dismissed-suggestions:v1:${userId ?? 'anonymous'}`
|
||||
}
|
||||
|
||||
/** The trips this user put away, as stored here; anything unreadable reads as none. */
|
||||
function readDismissedSuggestions(userId: number | undefined): Set<number> {
|
||||
try {
|
||||
const value: unknown = JSON.parse(window.localStorage.getItem(dismissedSuggestionsKey(userId)) || '[]')
|
||||
return new Set(Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [])
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedSuggestions(userId: number | undefined, dismissed: Set<number>): void {
|
||||
const key = dismissedSuggestionsKey(userId)
|
||||
if (dismissed.size === 0) {
|
||||
window.localStorage.removeItem(key)
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(key, JSON.stringify([...dismissed]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Journey list page logic — owns the journey store load, the create-journey
|
||||
* modal (title, available trips, selection), the search box, the trip
|
||||
@@ -18,6 +42,7 @@ export function useJourney() {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { journeys, loading, loadJourneys, createJourney } = useJourneyStore()
|
||||
const userId = useAuthStore(s => s.user?.id)
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
@@ -30,15 +55,35 @@ export function useJourney() {
|
||||
|
||||
// suggestion
|
||||
const [suggestions, setSuggestions] = useState<any[]>([])
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(new Set())
|
||||
const [dismissedSuggestions, setDismissedSuggestions] = useState<Set<number>>(() => readDismissedSuggestions(userId))
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
loadJourneys()
|
||||
journeyApi.suggestions().then(d => setSuggestions(d.trips || [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
journeyApi.suggestions().then(d => {
|
||||
if (cancelled) return
|
||||
const trips = d.trips || []
|
||||
const suggestionIds = new Set<number>(trips.map((trip: { id: number }) => trip.id))
|
||||
const stored = readDismissedSuggestions(userId)
|
||||
const retained = new Set([...stored].filter(id => suggestionIds.has(id)))
|
||||
setSuggestions(trips)
|
||||
setDismissedSuggestions(retained)
|
||||
if (retained.size !== stored.size) {
|
||||
try {
|
||||
saveDismissedSuggestions(userId, retained)
|
||||
} catch {
|
||||
// Pruning is best effort; a failed write leaves the old IDs to retry later.
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [userId])
|
||||
|
||||
// The bottom-nav "+" opens the new-journey modal via ?create=1.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === '1') {
|
||||
@@ -49,6 +94,17 @@ export function useJourney() {
|
||||
|
||||
const activeSuggestion = suggestions.find(s => !dismissedSuggestions.has(s.id))
|
||||
|
||||
const dismissSuggestion = (tripId: number) => {
|
||||
const next = new Set(dismissedSuggestions)
|
||||
next.add(tripId)
|
||||
try {
|
||||
saveDismissedSuggestions(userId, next)
|
||||
setDismissedSuggestions(next)
|
||||
} catch {
|
||||
toast.error(t('journey.frontpage.dismissError'))
|
||||
}
|
||||
}
|
||||
|
||||
// The frontpage always features one journey as the hero. A journey tied to a
|
||||
// currently-running trip wins; otherwise we fall back to the most recent one
|
||||
// (the list arrives sorted by updated_at DESC, so journeys[0] is the newest).
|
||||
@@ -118,7 +174,7 @@ export function useJourney() {
|
||||
showCreate, setShowCreate, newTitle, setNewTitle, newSubtitle, setNewSubtitle,
|
||||
availableTrips, selectedTripIds, setSelectedTripIds,
|
||||
searchOpen, setSearchOpen, searchQuery, setSearchQuery, searchInputRef,
|
||||
activeSuggestion, setDismissedSuggestions,
|
||||
activeSuggestion, dismissSuggestion,
|
||||
activeJourney, activeJourneyIsLive, filteredJourneys,
|
||||
openCreateModal, handleCreate, totalPlaces,
|
||||
}
|
||||
|
||||
@@ -29,9 +29,23 @@ const NO_TRACKS: JourneyTrack[] = []
|
||||
* map/trip-date derivations. JourneyDetailPage stays a wiring container around
|
||||
* its large two-pane JSX and many presentational sub-components.
|
||||
* Behaviour is identical to the previous in-component logic.
|
||||
*
|
||||
* The journal can also show inside a trip that links it, as the same screen with
|
||||
* the same dialogs. There the trip names the journal (`journeyId`) instead of the
|
||||
* route, and `embedded` keeps the screen to its own box: no scroll lock on the
|
||||
* page, no `?create=` handling on the trip's URL, and no trip out to the journal
|
||||
* list when the journal goes away; the trip decides what shows then.
|
||||
*/
|
||||
export function useJourneyDetail() {
|
||||
const { id } = useParams()
|
||||
export interface JourneyDetailOptions {
|
||||
/** The journal to show; the route's `:id` when left out. */
|
||||
journeyId?: number
|
||||
/** Shown inside another screen (a trip) rather than as the journal page. */
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
const params = useParams()
|
||||
const id = journeyId != null ? String(journeyId) : params.id
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -67,9 +81,10 @@ export function useJourneyDetail() {
|
||||
const [showAddTrip, setShowAddTrip] = useState(false)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// The bottom-nav "+" starts a new entry via ?create=entry.
|
||||
// The bottom-nav "+" starts a new entry via ?create=entry. Not inside a trip:
|
||||
// the trip's own `create` values live on that same URL.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('create') === 'entry' && current && canEditEntries) {
|
||||
if (!embedded && searchParams.get('create') === 'entry' && current && canEditEntries) {
|
||||
setEditingEntry(createDraftJourneyEntry(current.id))
|
||||
setSearchParams(p => { p.delete('create'); return p }, { replace: true })
|
||||
}
|
||||
@@ -119,11 +134,11 @@ export function useJourneyDetail() {
|
||||
}, [current?.hide_skeletons])
|
||||
|
||||
useEffect(() => {
|
||||
if (notFound) {
|
||||
if (notFound && !embedded) {
|
||||
toast.error(t('journey.notFound'))
|
||||
navigate('/journey')
|
||||
}
|
||||
}, [notFound])
|
||||
}, [notFound, embedded])
|
||||
|
||||
// WebSocket real-time updates
|
||||
useEffect(() => {
|
||||
@@ -374,9 +389,9 @@ export function useJourneyDetail() {
|
||||
// the body must not scroll underneath it. Goes through the shared counter so a
|
||||
// modal opening on top of it cannot hand the page back early (#1809).
|
||||
useEffect(() => {
|
||||
if (isMobile) return
|
||||
if (isMobile || embedded) return
|
||||
return lockBodyScroll()
|
||||
}, [isMobile])
|
||||
}, [isMobile, embedded])
|
||||
|
||||
// Map only shows real journal entries — skeletons are trip-derived
|
||||
// suggestions, not something the user actually journaled at that spot.
|
||||
@@ -549,7 +564,7 @@ export function useJourneyDetail() {
|
||||
}, [])
|
||||
|
||||
return {
|
||||
id, navigate, toast, t, locale,
|
||||
id, navigate, toast, t, locale, embedded, notFound,
|
||||
openStudio, prefetchStudio,
|
||||
current, loading,
|
||||
canEditEntries, canEditJourney, myRole,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fs } from '../../components/shared/DialogShell'
|
||||
import { Tooltip } from '../../components/shared/Tooltip'
|
||||
import { WhiteBadge } from '../../components/Planner/planParts'
|
||||
import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps'
|
||||
import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel'
|
||||
|
||||
/**
|
||||
* The read-only detail of one stop on a shared plan (#2320).
|
||||
@@ -14,7 +14,8 @@ import { formatDurationMinutes, isHttpUrl } from './sharedTripModel'
|
||||
* usually also left a note on the place and another on the day it was
|
||||
* planned for, and a link would show none of that. This shows what the owner
|
||||
* chose to share, laid out the way the planner lays it out: address, then
|
||||
* description, then the two notes, then how long they meant to stay, then
|
||||
* description, then the two notes, then how long they meant to stay (from the
|
||||
* stop's start and end time), then
|
||||
* the ways to reach the place.
|
||||
*
|
||||
* Nothing here is editable and nothing here is a route into the app: every
|
||||
@@ -46,7 +47,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl
|
||||
name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null,
|
||||
google_place_id: null, google_ftid: null,
|
||||
})
|
||||
const duration = formatDurationMinutes(place.duration_minutes)
|
||||
const duration = formatDurationMinutes(plannedStayMinutes(place.place_time, place.end_time))
|
||||
const dayNote = assignmentNotes?.trim() || null
|
||||
const placeNote = place.notes?.trim() || null
|
||||
const hasLinks = !!(website || phone || mapsUrl)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { AxiosError, AxiosHeaders } from 'axios'
|
||||
import {
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost,
|
||||
coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, plannedStayMinutes,
|
||||
sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces,
|
||||
} from './sharedTripModel'
|
||||
|
||||
@@ -25,6 +25,15 @@ describe('sharedTripModel (#2320)', () => {
|
||||
expect(formatDurationMinutes(89.6)).toBe('1 h 30 min')
|
||||
})
|
||||
|
||||
it('reads the planned stay off the time window, never off the place default (#2676)', () => {
|
||||
expect(plannedStayMinutes('10:00', '12:30')).toBe(150)
|
||||
expect(plannedStayMinutes('22:30', '01:00')).toBe(150)
|
||||
expect(plannedStayMinutes('10:00', null)).toBeNull()
|
||||
expect(plannedStayMinutes(null, '12:00')).toBeNull()
|
||||
expect(plannedStayMinutes('10:00', '10:00')).toBeNull()
|
||||
expect(plannedStayMinutes(undefined, undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('writes nothing for a missing, zero or nonsense figure', () => {
|
||||
expect(formatDurationMinutes(null)).toBeNull()
|
||||
expect(formatDurationMinutes(undefined)).toBeNull()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* page and its detail blocks can share them and a test can drive them
|
||||
* without rendering.
|
||||
*/
|
||||
import { parseTimeToMinutes } from '../../utils/dayMerge'
|
||||
|
||||
/**
|
||||
* Whether a string is a link the page may render as one.
|
||||
@@ -22,6 +23,22 @@ export function isHttpUrl(value: unknown): value is string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the owner planned to stay, read off the stop's time window.
|
||||
*
|
||||
* Not `places.duration_minutes`: every place carries one, 60 unless someone
|
||||
* set a road-trip stay, so a link showed "1 h" under every stop (#2676). The
|
||||
* window is what the owner actually planned, and it can differ per visit.
|
||||
* Without an end there is no stay to show. An end before the start runs past
|
||||
* midnight.
|
||||
*/
|
||||
export function plannedStayMinutes(start: string | null | undefined, end: string | null | undefined): number | null {
|
||||
const from = parseTimeToMinutes(start)
|
||||
const to = parseTimeToMinutes(end)
|
||||
if (from == null || to == null || from === to) return null
|
||||
return to > from ? to - from : to + 24 * 60 - from
|
||||
}
|
||||
|
||||
/**
|
||||
* "45 min", "2 h", "1 h 30 min" — the planned time at a place.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { JourneyDetailPageDesktop } from '../JourneyDetailPage';
|
||||
|
||||
/**
|
||||
* The trip's Journal tab on the desktop: the Journey addon's own journal view,
|
||||
* not a copy of it, so the journal looks and works the same in both places and
|
||||
* stays live through the journey's own sync.
|
||||
*
|
||||
* The view's fixed bars measure themselves from --nav-h, so the wrapper sets it
|
||||
* to the navbar plus the trip's 44 px tab bar, and they sit below both.
|
||||
*/
|
||||
const BELOW_TRIP_TABS = '[--nav-h:44px] md:[--nav-h:calc(64px+env(safe-area-inset-top,0px)+44px)]';
|
||||
|
||||
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
|
||||
if (journeyId == null) return null;
|
||||
return (
|
||||
<div className={`${BELOW_TRIP_TABS} absolute inset-0 overflow-y-auto`}>
|
||||
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({
|
||||
TourPlannerToursRail: label('tour planner tours rail'),
|
||||
}))
|
||||
vi.mock('../../components/Planner/TransportModal', () => ({ TransportModal: label('transport modal') }))
|
||||
vi.mock('./TripJournalPanel', () => ({ default: label('trip journal panel') }))
|
||||
|
||||
const cases: Array<[keyof typeof lazy, string]> = [
|
||||
['ReservationsPanel', 'reservations panel'],
|
||||
@@ -43,6 +44,7 @@ const cases: Array<[keyof typeof lazy, string]> = [
|
||||
['TourWorkspaceRail', 'tour workspace rail'],
|
||||
['TourPlannerToursRail', 'tour planner tours rail'],
|
||||
['TransportModal', 'transport modal'],
|
||||
['TripJournalPanel', 'trip journal panel'],
|
||||
]
|
||||
|
||||
function renderLazy(name: keyof typeof lazy) {
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
|
||||
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
|
||||
)
|
||||
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
|
||||
// The Journey addon's journal view, which only a trip with a linked journal opens.
|
||||
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
|
||||
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
|
||||
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
|
||||
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
|
||||
|
||||
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
|
||||
import { inspectorStay } from '../../components/Roadtrip/stayReading'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
|
||||
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
|
||||
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
@@ -198,6 +200,7 @@ export function useTripPlanner() {
|
||||
|
||||
// A trip-page plugin may replace core tabs while it's active (its manifest names
|
||||
// them; 'plan' is never replaceable) and may pick where its own tab sits.
|
||||
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
|
||||
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
|
||||
@@ -211,6 +214,8 @@ export function useTripPlanner() {
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
|
||||
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
|
||||
...(journal.openJournalId != null ? [{ id: 'journal', label: t('trip.tabs.journal'), icon: LinkedJournalIcon }] : []),
|
||||
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
|
||||
// Positioned plugin tabs splice in ascending order so two positions stay stable;
|
||||
// the rest append, exactly as before this capability existed.
|
||||
@@ -237,12 +242,14 @@ export function useTripPlanner() {
|
||||
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a
|
||||
// guess, and evicting on a guess would drop a legitimately requested tab.
|
||||
if (!addonsLoaded) return
|
||||
// And for the Journal tab, until the link has been asked for.
|
||||
if (activeTab === 'journal' && !journal.settled) return
|
||||
const validTabIds = TRIP_TABS.map(t => t.id)
|
||||
if (!validTabIds.includes(activeTab)) {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
|
||||
|
||||
const handleTabChange = (rawTabId: string): void => {
|
||||
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
|
||||
@@ -653,7 +660,7 @@ export function useTripPlanner() {
|
||||
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
enabledAddons, collabFeatures, journalId: journal.openJournalId, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* FE-TJL-REPO-001 to FE-TJL-REPO-006
|
||||
*
|
||||
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
|
||||
* the server's answer, never a cached guess), so what is pinned is that it
|
||||
* refuses before a request while offline, that every call reaches its route
|
||||
* with the body the contract names, and that the picker only offers journals
|
||||
* the person owns.
|
||||
*/
|
||||
import type { TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
|
||||
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
|
||||
|
||||
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
|
||||
afterEach(() => _resetNetworkMode());
|
||||
|
||||
describe('tripJournalLinkRepo', () => {
|
||||
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json(NONE);
|
||||
})
|
||||
);
|
||||
setForcedOffline(true);
|
||||
|
||||
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-002: reads the trip link', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
|
||||
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
|
||||
let asked: string | null = null;
|
||||
server.use(
|
||||
http.get('/api/trips/4/journal/candidates', ({ request }) => {
|
||||
asked = new URL(request.url).searchParams.get('journey_id');
|
||||
return HttpResponse.json({
|
||||
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
|
||||
});
|
||||
})
|
||||
);
|
||||
const list = await tripJournalLinkRepo.candidates(4, 9);
|
||||
expect(asked).toBe('9');
|
||||
expect(list.map((c) => c.username)).toEqual(['lena']);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/4/journal', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json(NONE);
|
||||
}),
|
||||
http.patch('/api/trips/4/journal/members', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json(NONE);
|
||||
}),
|
||||
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
|
||||
);
|
||||
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
|
||||
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
|
||||
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
|
||||
expect(bodies).toEqual([
|
||||
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
|
||||
{ members: [{ user_id: 2, role: 'editor' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
|
||||
server.use(
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({
|
||||
journeys: [
|
||||
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
|
||||
{ id: 2, user_id: 8, title: 'Theirs' },
|
||||
{ id: 3, user_id: 7, title: 'Also mine' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
|
||||
{ id: 1, title: 'Mine', subtitle: 'Japan' },
|
||||
{ id: 3, title: 'Also mine', subtitle: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
|
||||
const err = new TripJournalOfflineError();
|
||||
expect(err.name).toBe('TripJournalOfflineError');
|
||||
expect(err.message).toMatch(/connection/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { tripJournalApi } from '../api/tripJournal';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** A journal the person owns, as the link picker lists it. */
|
||||
export interface OwnJournal {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal a trip links, and who it is open to.
|
||||
*
|
||||
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
|
||||
* answer is the server's (who may see the link, who would be let in), and the
|
||||
* journal it opens is online-only too, as the Journey addon is. Writing it
|
||||
* offline would mean granting access nobody could check. The store in front of
|
||||
* this keeps the last answer for the session.
|
||||
*/
|
||||
export class TripJournalOfflineError extends Error {
|
||||
constructor() {
|
||||
super('Linking a journal needs a connection');
|
||||
this.name = 'TripJournalOfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
function requireOnline(): void {
|
||||
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
|
||||
}
|
||||
|
||||
export const tripJournalLinkRepo = {
|
||||
async get(tripId: number): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.get(tripId);
|
||||
},
|
||||
|
||||
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
|
||||
requireOnline();
|
||||
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
|
||||
},
|
||||
|
||||
async link(
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.link(tripId, journeyId, members);
|
||||
},
|
||||
|
||||
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.setMembers(tripId, members);
|
||||
},
|
||||
|
||||
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.unlink(tripId);
|
||||
},
|
||||
|
||||
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
|
||||
async ownJournals(userId: number): Promise<OwnJournal[]> {
|
||||
requireOnline();
|
||||
const { journeys } = await tripJournalApi.journals();
|
||||
return journeys
|
||||
.filter((j) => j.user_id === userId)
|
||||
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
|
||||
},
|
||||
};
|
||||
@@ -141,11 +141,23 @@ export interface JourneyContributor {
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
|
||||
export interface JourneyTripAccess {
|
||||
user_id: number
|
||||
role: 'editor' | 'viewer'
|
||||
username: string
|
||||
avatar?: string | null
|
||||
trip_id: number
|
||||
trip_title: string
|
||||
}
|
||||
|
||||
export interface JourneyDetail extends Journey {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
|
||||
trip_access?: JourneyTripAccess[]
|
||||
stats: { entries: number; photos: number; places: number }
|
||||
hide_skeletons?: boolean
|
||||
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FE-TJL-STORE-001 to FE-TJL-STORE-005
|
||||
*
|
||||
* The store keeps the server's answer per trip so the Journal tab and the
|
||||
* Settings dialog read the same one: a write replaces it with what the server
|
||||
* answered, a 404/403 means there is no journal for this person, and any other
|
||||
* failure leaves the last answer standing rather than flashing the tab away.
|
||||
*/
|
||||
import type { TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { useTripJournalLinkStore } from './tripJournalLinkStore';
|
||||
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: null,
|
||||
owner_id: 1,
|
||||
linked_by: 1,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
|
||||
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
|
||||
|
||||
describe('tripJournalLinkStore', () => {
|
||||
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
|
||||
link: null,
|
||||
can_link: false,
|
||||
can_unlink: false,
|
||||
can_edit_members: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
|
||||
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
|
||||
server.use(
|
||||
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
|
||||
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
|
||||
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
|
||||
);
|
||||
const s = useTripJournalLinkStore.getState();
|
||||
await s.link(4, 9, []);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
await s.setMembers(4, []);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
|
||||
await s.unlink(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
|
||||
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
|
||||
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
|
||||
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { create } from 'zustand';
|
||||
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
|
||||
|
||||
/**
|
||||
* The journal each trip links, as this person may see it, remembered for the
|
||||
* session so the trip's Journal tab and its Settings dialog render the last
|
||||
* answer at once. Both read it from here, so the tab appears and goes the moment
|
||||
* the dialog links or unlinks, without a second request.
|
||||
*/
|
||||
interface TripJournalLinkState {
|
||||
/** Per trip id, the server's answer. Missing until asked. */
|
||||
byTrip: Record<string, TripJournalLinkResponse>;
|
||||
refresh: (tripId: number) => Promise<void>;
|
||||
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
|
||||
unlink: (tripId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
|
||||
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
|
||||
|
||||
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
|
||||
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
|
||||
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
|
||||
|
||||
return {
|
||||
byTrip: {},
|
||||
|
||||
refresh: async (tripId) => {
|
||||
try {
|
||||
keep(tripId, await tripJournalLinkRepo.get(tripId));
|
||||
} catch (err) {
|
||||
// 404 is the Journey addon switched off or a trip this person no longer
|
||||
// reaches: both a real "no journal". Anything else, offline included, is
|
||||
// no answer at all, and the last one stands.
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404 || status === 403) keep(tripId, NONE);
|
||||
}
|
||||
},
|
||||
|
||||
// The writes answer with the link as it now stands, so the tab and the
|
||||
// dialog move together; a failure is the caller's to report.
|
||||
link: async (tripId, journeyId, members) =>
|
||||
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
|
||||
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
|
||||
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
|
||||
};
|
||||
});
|
||||
@@ -59,4 +59,5 @@ export const UNRESET_STORES: Record<string, string> = {
|
||||
useSystemNoticeStore: NOT_YET,
|
||||
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
|
||||
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
|
||||
useTripJournalLinkStore: 'the journal link per trip; the tests that read it reset it in their own beforeEach',
|
||||
};
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('MJourneySettingsSheet', () => {
|
||||
expect(screen.getByText('8 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-2683: lists the people a linked trip lets in, and nothing without', async () => {
|
||||
const { unmount } = setup();
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
setup({ trip_access: [{ user_id: 7, role: 'editor', username: 'lena', avatar: null, trip_id: 3, trip_title: 'Tokyo trip' }] });
|
||||
expect(await screen.findByText('Through trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||
expect(screen.getByText('via Tokyo trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
|
||||
setup({ trips: [] });
|
||||
expect(await screen.findByText('No trips linked')).toBeInTheDocument();
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('MMehrSheet', () => {
|
||||
|
||||
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trip settings' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
|
||||
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
|
||||
@@ -116,7 +116,7 @@ describe('MMehrSheet', () => {
|
||||
const { planner } = renderSheet({ can: vi.fn(() => false) })
|
||||
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
|
||||
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Trip settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
|
||||
@@ -124,13 +124,13 @@ describe('MMehrSheet', () => {
|
||||
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
|
||||
})
|
||||
expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
|
||||
expect(rows).not.toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
|
||||
renderSheet()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
|
||||
expect(rows).toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('MMehrSheet', () => {
|
||||
// Five sections still fit beside the More button, so nothing overflows and the
|
||||
// sheet is the action rows alone.
|
||||
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2')
|
||||
expect(screen.getByRole('button', { name: 'Trip settings' }).parentElement).not.toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
# - KITINERARY_EXTRACTOR_PATH= # Optional. Full path to kitinerary-extractor binary. Auto-detected from PATH and /usr/lib/*/libexec/kf6/ when unset.
|
||||
# - LLM_TIMEOUT_MS=900000 # Optional. Ceiling (ms) for one AI-parsing call before it is abandoned, applied to every provider and to the HTTP client underneath. Generous by default so heavier documents fit; lower it on a cloud provider to fail fast. Must be a whole number of milliseconds between 1 and 2147483647 — anything else stops the server at startup with the reason. (default: 900000)
|
||||
# - LLM_MAX_OUTPUT_TOKENS=8192 # Optional. Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. Whole number from 256 to 1000000. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
|
||||
# - TREK_PLUGINS_IGNORE_TREK_RANGE=true # Optional. Lets plugins whose declared TREK version range predates this build, and plugins that declare none, be installed and activated anyway. Every gate warns instead of refusing; the plugin-API version gate and the Discover compatibility verdict stay hard. Off unless set. (default: off)
|
||||
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
|
||||
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
|
||||
|
||||
Generated
+8
-8
@@ -13,7 +13,7 @@
|
||||
"shared"
|
||||
],
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.6",
|
||||
"knip": "^6.38.0",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
@@ -11082,15 +11082,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz",
|
||||
"integrity": "sha512-trZql+7l/0+WRAsAnEdctr4+iiOS6ZrViI6H8QWcCF9MFS/LT0dKpe8vluB1to6it+OxSI4VospFTIFMW8DJRw==",
|
||||
"version": "10.0.6",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.6.tgz",
|
||||
"integrity": "sha512-qgICXXFp2/3nAebRLmxcINKEXM1mLK3ij/y31P02VTs/KIkddIeC05n73DxOXsXMB9LFSqNU1/na3lDlOhcbIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "5.6.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.9.0",
|
||||
"shell-quote": "1.12.0",
|
||||
"supports-color": "10.2.2",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "18.0.0"
|
||||
@@ -19748,9 +19748,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
|
||||
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
||||
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
"concurrently": "^10.0.6",
|
||||
"knip": "^6.38.0",
|
||||
"unrun": "^0.3.1"
|
||||
},
|
||||
|
||||
Generated
+3
-3
@@ -1906,9 +1906,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@@ -84,6 +84,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
|
||||
# KITINERARY_EXTRACTOR_PATH= # Full path to the kitinerary-extractor binary. Unset, TREK searches /usr/lib/*/libexec/kf6/ and then PATH; the official image pins it. Set it to run a newer extractor than your distribution packages — the version in use is in the startup log and in GET /api/admin/system-info. (default: auto-detected)
|
||||
|
||||
# LLM_TIMEOUT_MS=900000 # Ceiling (ms) for one AI-parsing call before it is abandoned. One value for every provider, and the underlying HTTP client is held to it too. Generous by default so heavier parsing work fits without a code change; lower it on a cloud provider to fail fast. (default: 900000)
|
||||
# LLM_MAX_OUTPUT_TOKENS=8192 # Longest answer (tokens) one AI-parsing call may ask for. Raise it when a document with many bookings comes back cut off; keep prompt plus cap inside your model's context. (default: 4096 for OpenAI-compatible, 8192 for Anthropic)
|
||||
|
||||
# VAPID_PUBLIC_KEY= # Web Push: the public half of the key pair browsers subscribe with (base64url, 65 bytes). Leave both halves unset and TREK generates a pair on first start and keeps it in the database, so it travels with every backup. Set both to use a pair of your own, e.g. printed by `npx web-push generate-vapid-keys`. Changing the pair later means every device has to turn push on again.
|
||||
# VAPID_PRIVATE_KEY= # Web Push: the private half (base64url, 32 bytes). Only used together with VAPID_PUBLIC_KEY; a pair whose halves do not belong together, or only one half, turns push off with an error in the log until the pair is fixed. A pair stored in the database is not used in its place, and every device stays registered.
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"src/db/repositories/Days.repository.ts": 7,
|
||||
"src/db/repositories/InviteTokens.repository.ts": 3,
|
||||
"src/db/repositories/JourneyBooks.repository.ts": 3,
|
||||
"src/db/repositories/JourneyContributors.repository.ts": 3,
|
||||
"src/db/repositories/JourneyEntries.repository.ts": 9,
|
||||
"src/db/repositories/JourneyPhotos.repository.ts": 6,
|
||||
"src/db/repositories/Journeys.repository.ts": 4,
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"TripDocumentLinks": "doc-sync",
|
||||
"TripFiles": "files",
|
||||
"TripInviteTokens": "trip-invite",
|
||||
"TripJournalLinkMembers": "journey",
|
||||
"TripJournalLinks": "journey",
|
||||
"TripMembers": "trip-membership",
|
||||
"TripPhotos": "photos",
|
||||
"Trips": "trips",
|
||||
|
||||
@@ -165,7 +165,7 @@ ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
TREK_DB_PRE_MIGRATE_SNAPSHOT(_KEEP) (read when a migration run starts; the
|
||||
legacy-upgrade suites set it per file),
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check),
|
||||
LLM_TIMEOUT_MS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
LLM_TIMEOUT_MS, LLM_MAX_OUTPUT_TOKENS, NOMINATIM_URL, VAPID_* (resolved per use by the Web Push key
|
||||
service, which the notification suites build without the container).
|
||||
|
||||
## Exemptions — raw `process.env` stays
|
||||
|
||||
@@ -295,6 +295,13 @@ export function deriveIntegrations(raw: RawEnv) {
|
||||
* rejects a fractional value.
|
||||
*/
|
||||
llmTimeoutMs: Math.floor(positiveNumberOr(raw.LLM_TIMEOUT_MS, 900_000)),
|
||||
/**
|
||||
* How long one AI-parsing answer may get. Unset leaves each client the cap it
|
||||
* has always sent (4096 to OpenAI-compatible servers, 8192 to Anthropic): a
|
||||
* bigger default would make the server of a small local model refuse every
|
||||
* request whose prompt plus cap overruns its context (#2677).
|
||||
*/
|
||||
llmMaxOutputTokens: positiveIntOr(raw.LLM_MAX_OUTPUT_TOKENS, 0) || undefined,
|
||||
// TRANSIT_API_URL, KITINERARY_EXTRACTOR_PATH and PATH are boot-stable and
|
||||
// read only by Nest providers, so the transitConfig and kitineraryConfig
|
||||
// tokens own them (boot-derive.ts).
|
||||
|
||||
@@ -144,6 +144,8 @@ export const envSchema = z.object({
|
||||
// 2^31-1 makes Node clamp the delay to 1 ms and do the same. Both refuse at
|
||||
// boot rather than degrading into a timeout of zero.
|
||||
LLM_TIMEOUT_MS: integer(1, 2_147_483_647, 'must be a whole number of milliseconds between 1 and 2147483647'),
|
||||
// Below 256 not even one reservation fits, so a value that small is a typo, not a choice.
|
||||
LLM_MAX_OUTPUT_TOKENS: integer(256, 1_000_000, 'must be a whole number of tokens between 256 and 1000000'),
|
||||
KITINERARY_EXTRACTOR_PATH: anyString,
|
||||
// The OS search path. Not configuration anybody sets for TREK — it is here so
|
||||
// the kitinerary probe can resolve its binary to an absolute path itself
|
||||
|
||||
@@ -6,6 +6,7 @@ import { JourneyEntries } from './JourneyEntries.entity';
|
||||
import { JourneyPhotos } from './JourneyPhotos.entity';
|
||||
import { JourneyShareTokens } from './JourneyShareTokens.entity';
|
||||
import { JourneyTrips } from './JourneyTrips.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class Journeys {
|
||||
@@ -32,6 +33,7 @@ export class Journeys {
|
||||
journey_photos_collection = new Collection<JourneyPhotos>(this);
|
||||
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
|
||||
journey_trips_collection = new Collection<JourneyTrips>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
}
|
||||
|
||||
export const JourneysSchema = defineEntity({
|
||||
@@ -60,5 +62,6 @@ export const JourneysSchema = defineEntity({
|
||||
journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(),
|
||||
journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(),
|
||||
journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TripJournalLinkMembersRepository } from '../repositories/TripJournalLinkMembers.repository';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class TripJournalLinkMembers {
|
||||
[EntityRepositoryType]?: TripJournalLinkMembersRepository;
|
||||
[PrimaryKeyProp]?: ['link', 'user'];
|
||||
link!: Ref<TripJournalLinks>;
|
||||
link_id!: number;
|
||||
user!: Ref<Users>;
|
||||
user_id!: number;
|
||||
role: TTripJournalLinkMembersRole & Opt = TripJournalLinkMembersRole.VIEWER;
|
||||
hide_skeletons: number & Opt = 0;
|
||||
added_at!: number;
|
||||
}
|
||||
|
||||
export const TripJournalLinkMembersRole = {
|
||||
VIEWER: 'viewer',
|
||||
EDITOR: 'editor',
|
||||
} as const;
|
||||
|
||||
export type TTripJournalLinkMembersRole = (typeof TripJournalLinkMembersRole)[keyof typeof TripJournalLinkMembersRole];
|
||||
|
||||
export const TripJournalLinkMembersSchema = defineEntity({
|
||||
class: TripJournalLinkMembers,
|
||||
repository: () => TripJournalLinkMembersRepository,
|
||||
uniques: [{ properties: ['link', 'user'] }],
|
||||
properties: {
|
||||
link: () => p.manyToOne(TripJournalLinks).primary().ref().hidden(),
|
||||
link_id: p.integer().persist(false),
|
||||
user: () => p.manyToOne(Users).primary().ref().hidden().index('idx_trip_journal_link_members_user'),
|
||||
user_id: p.integer().persist(false).index('idx_trip_journal_link_members_user'),
|
||||
role: p.enum(() => TripJournalLinkMembersRole).default('viewer'),
|
||||
hide_skeletons: p.integer().default(0),
|
||||
added_at: p.integer(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TripJournalLinksRepository } from '../repositories/TripJournalLinks.repository';
|
||||
import { Journeys } from './Journeys.entity';
|
||||
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class TripJournalLinks {
|
||||
[EntityRepositoryType]?: TripJournalLinksRepository;
|
||||
id!: number & Opt;
|
||||
trip!: Ref<Trips>;
|
||||
trip_id!: number;
|
||||
journey!: Ref<Journeys>;
|
||||
journey_id!: number;
|
||||
linked_by?: number | null;
|
||||
created_at!: number;
|
||||
linkedByRef?: Ref<Users> | null;
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
}
|
||||
|
||||
export const TripJournalLinksSchema = defineEntity({
|
||||
class: TripJournalLinks,
|
||||
repository: () => TripJournalLinksRepository,
|
||||
uniques: [{ properties: ['trip'] }],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(),
|
||||
trip_id: p.integer().persist(false),
|
||||
journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_trip_journal_links_journey'),
|
||||
journey_id: p.integer().persist(false).index('idx_trip_journal_links_journey'),
|
||||
linked_by: p.integer().nullable().persist(false).index('idx_trip_journal_links_linked_by'),
|
||||
created_at: p.integer(),
|
||||
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_trip_journal_links_linked_by'),
|
||||
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('link').hidden(),
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinks } from './TripDocumentLinks.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Users } from './Users.entity';
|
||||
@@ -84,6 +85,7 @@ export class Trips {
|
||||
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
}
|
||||
@@ -145,6 +147,7 @@ export const TripsSchema = defineEntity({
|
||||
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(),
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(),
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('trip').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(),
|
||||
},
|
||||
|
||||
@@ -57,6 +57,8 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinks } from './TripDocumentLinks.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
@@ -182,6 +184,8 @@ export class Users {
|
||||
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_members_collection1 = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
@@ -325,6 +329,8 @@ export const UsersSchema = defineEntity({
|
||||
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(),
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(),
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(),
|
||||
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(),
|
||||
trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
|
||||
|
||||
@@ -116,6 +116,8 @@ import { TripAlbumLinksSchema } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinksSchema } from './TripDocumentLinks.entity';
|
||||
import { TripFilesSchema } from './TripFiles.entity';
|
||||
import { TripInviteTokensSchema } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinksSchema } from './TripJournalLinks.entity';
|
||||
import { TripMembersSchema } from './TripMembers.entity';
|
||||
import { TripPhotosSchema } from './TripPhotos.entity';
|
||||
import { TripsSchema } from './Trips.entity';
|
||||
@@ -256,6 +258,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
TripDocumentLinksSchema,
|
||||
TripFilesSchema,
|
||||
TripInviteTokensSchema,
|
||||
TripJournalLinkMembersSchema,
|
||||
TripJournalLinksSchema,
|
||||
TripMembersSchema,
|
||||
TripPhotosSchema,
|
||||
TripsSchema,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TripJournalLinkMembersTable {
|
||||
link_id: number;
|
||||
user_id: number;
|
||||
role: InsertOptional<string>;
|
||||
hide_skeletons: InsertOptional<number>;
|
||||
added_at: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TripJournalLinksTable {
|
||||
id: InsertOptional<number>;
|
||||
trip_id: number;
|
||||
journey_id: number;
|
||||
linked_by: InsertOptional<number | null>;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -122,6 +122,8 @@ import type { TripAlbumLinksTable } from './TripAlbumLinks.table';
|
||||
import type { TripDocumentLinksTable } from './TripDocumentLinks.table';
|
||||
import type { TripFilesTable } from './TripFiles.table';
|
||||
import type { TripInviteTokensTable } from './TripInviteTokens.table';
|
||||
import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table';
|
||||
import type { TripJournalLinksTable } from './TripJournalLinks.table';
|
||||
import type { TripMembersTable } from './TripMembers.table';
|
||||
import type { TripPhotosTable } from './TripPhotos.table';
|
||||
import type { TripsTable } from './Trips.table';
|
||||
@@ -268,6 +270,8 @@ export interface DB {
|
||||
trip_document_links: TripDocumentLinksTable;
|
||||
trip_files: TripFilesTable;
|
||||
trip_invite_tokens: TripInviteTokensTable;
|
||||
trip_journal_link_members: TripJournalLinkMembersTable;
|
||||
trip_journal_links: TripJournalLinksTable;
|
||||
trip_members: TripMembersTable;
|
||||
trip_photos: TripPhotosTable;
|
||||
trips: TripsTable;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* A trip can show one journal from the Journey addon as its own. The link
|
||||
* belongs to the trip, not to the journal's own trips (`journey_trips`): any
|
||||
* journal its owner chooses, and one journal may hang off several trips.
|
||||
* `linked_by` names who linked it, kept when that account goes as unknown.
|
||||
*
|
||||
* `trip_journal_link_members` lists the trip members the link lets into the
|
||||
* journal, as viewer or editor, apart from `journey_contributors` so that
|
||||
* unlinking, deselecting someone or their leaving the trip takes away exactly
|
||||
* the access the link gave and nothing the journal's owner granted directly.
|
||||
* `hide_skeletons` is that member's own journal preference, which for a direct
|
||||
* contributor sits on the contributor row. A member's rows go with the link,
|
||||
* with the trip and with the account.
|
||||
*
|
||||
* One link per trip is `trip_id UNIQUE` beside its own id rather than the trip
|
||||
* id as key, as `trip_invite_tokens` holds one per trip: a key that is also a
|
||||
* foreign key maps to a one-to-one on both entities, and a second one of those
|
||||
* next to `tours` made the ORM's entity types recurse past what tsc expands.
|
||||
*/
|
||||
export class Migration20200101044300_a_trip_links_a_journal extends Migration {
|
||||
override name = 'Migration20200101044300_a_trip_links_a_journal';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS trip_journal_links (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE,
|
||||
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_journey ON trip_journal_links(journey_id)');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_linked_by ON trip_journal_links(linked_by)');
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS trip_journal_link_members (
|
||||
link_id INTEGER NOT NULL REFERENCES trip_journal_links(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
|
||||
hide_skeletons INTEGER NOT NULL DEFAULT 0,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (link_id, user_id)
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_link_members_user ON trip_journal_link_members(user_id)');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { JourneyContributors } from '../entities/JourneyContributors.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import {
|
||||
linkReach,
|
||||
readLinkHideSkeletons,
|
||||
readLinkRole,
|
||||
type TripJournalLinkKyselyDB,
|
||||
type TripJournalLinkRole,
|
||||
} from './TripJournalLinks.repository';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */
|
||||
@@ -15,6 +22,19 @@ export interface JourneyContributorListRow {
|
||||
/** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */
|
||||
type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>;
|
||||
|
||||
/** Someone a trip's journal link lets in, with the trip they come through (#2683). */
|
||||
export interface JourneyTripAccessRow {
|
||||
user_id: number;
|
||||
role: TripJournalLinkRole;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
trip_id: number;
|
||||
trip_title: string;
|
||||
}
|
||||
|
||||
/** The table the access reads below take a person's own contributor row from. */
|
||||
type JourneyContributorRowKyselyDB = Pick<DB, 'journey_contributors'>;
|
||||
|
||||
/**
|
||||
* `journey_contributors` — a genuine TWO-column composite primary key
|
||||
* (`journey` + `user`, both `.primary()`, R3 — pinned against
|
||||
@@ -26,36 +46,71 @@ type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>
|
||||
* pinned in `task-1-report.md`.
|
||||
*/
|
||||
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {
|
||||
/** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
|
||||
async existsForUser(journeyId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.journey'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<unknown>('get', false);
|
||||
return !!row;
|
||||
/** The person's own contributor row on the journal, if they have one. */
|
||||
private async directRow(journeyId: number, userId: number) {
|
||||
return await this.kysely<JourneyContributorRowKyselyDB>()
|
||||
.selectFrom('journey_contributors')
|
||||
.select(['role', 'hide_skeletons'])
|
||||
.where('journey_id', '=', journeyId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** JG7/JG21 — `canEdit`'s edit-access primitive and `getJourneyFull`'s `myRole` resolution: `SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
|
||||
async findRole(journeyId: number, userId: number): Promise<string | undefined> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.role'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<{ role: string } | undefined>('get', false);
|
||||
return row?.role;
|
||||
/** JG4 — whether a person may open the journal: as a contributor, or through a trip's link. */
|
||||
async hasAccess(journeyId: number, userId: number): Promise<boolean> {
|
||||
return (await this.findAccessRole(journeyId, userId)) !== undefined;
|
||||
}
|
||||
|
||||
/** JG20/JG27 — `getJourneyFull`'s `hide_skeletons` preference read and `updateJourneyPreferences`'s re-read, one statement text. */
|
||||
/**
|
||||
* JG7/JG21 — the role a person acts with on the journal, for `canEdit` and
|
||||
* `my_role`: their contributor role, or the role a trip's link gives them
|
||||
* when that is the stronger one. Undefined with neither.
|
||||
*/
|
||||
async findAccessRole(journeyId: number, userId: number): Promise<string | undefined> {
|
||||
const direct = await this.directRow(journeyId, userId);
|
||||
if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role;
|
||||
const linked = await readLinkRole(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId);
|
||||
return linked ?? direct?.role;
|
||||
}
|
||||
|
||||
/** JG20/JG27 — the person's "hide skeletons" choice, from their contributor row or else from their link. */
|
||||
async getHideSkeletons(journeyId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.hide_skeletons'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<{ hide_skeletons: number } | undefined>('get', false);
|
||||
return !!row?.hide_skeletons;
|
||||
const direct = await this.directRow(journeyId, userId);
|
||||
if (direct) return !!direct.hide_skeletons;
|
||||
return !!(await readLinkHideSkeletons(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId));
|
||||
}
|
||||
|
||||
/** JG26 — `updateJourneyPreferences`'s write: `UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?`. */
|
||||
/** JG26 — stores that choice where `getHideSkeletons` reads it: the contributor row, or else the person's links. */
|
||||
async setHideSkeletons(journeyId: number, userId: number, value: number): Promise<void> {
|
||||
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
|
||||
if (await this.directRow(journeyId, userId)) {
|
||||
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
|
||||
return;
|
||||
}
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
await db
|
||||
.updateTable('trip_journal_link_members')
|
||||
.set({ hide_skeletons: value })
|
||||
.where('user_id', '=', userId)
|
||||
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('journey_id', '=', journeyId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* #2683 — the people the trips' journal links let into this journal, one row
|
||||
* per trip they come through, leaving out whoever is a contributor already:
|
||||
* the settings show them apart, since they are managed in the trip.
|
||||
*/
|
||||
async listTripAccessForJourney(journeyId: number): Promise<JourneyTripAccessRow[]> {
|
||||
const direct = new Set((await this.listForJourney(journeyId)).map((c) => c.user_id));
|
||||
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
|
||||
.innerJoin('users as u', 'u.id', 'm.user_id')
|
||||
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar', 't.id as trip_id', 't.title as trip_title'])
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
return rows
|
||||
.filter((r) => !direct.has(r.user_id))
|
||||
.map((r) => ({ ...r, role: r.role === 'editor' ? 'editor' : 'viewer' }));
|
||||
}
|
||||
|
||||
/** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */
|
||||
|
||||
@@ -241,6 +241,21 @@ export class JourneyEntriesRepository extends TrekRepository<JourneyEntries> {
|
||||
return rows as { entry_date: string; m: number }[];
|
||||
}
|
||||
|
||||
/** One day's entries in timeline order, with what placing a timed entry among them reads (#2679). */
|
||||
async listDayOrder(
|
||||
journeyId: number,
|
||||
entryDate: string,
|
||||
): Promise<{ id: number; entry_time: string | null; sort_order: number | null }[]> {
|
||||
return await this.kysely<JourneyEntriesDateSortKyselyDB>()
|
||||
.selectFrom('journey_entries')
|
||||
.select(['id', 'entry_time', 'sort_order'])
|
||||
.where('journey_id', '=', journeyId)
|
||||
.where('entry_date', '=', entryDate)
|
||||
.orderBy('sort_order', 'asc')
|
||||
.orderBy('id', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** JG42 — `onPlaceCreated`'s next-order lookup: `SELECT MAX(sort_order) AS m FROM journey_entries WHERE journey_id = ? AND entry_date = ?`. The `?? -1` fallback stays the SERVICE's, unchanged (same text as Task 2's own JG74). */
|
||||
async maxSortOrderForDate(journeyId: number, entryDate: string): Promise<number | null> {
|
||||
const row = await this.kysely<JourneyEntriesDateSortKyselyDB>()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Journeys } from '../entities/Journeys.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { linkReach, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
@@ -114,6 +115,12 @@ export class JourneysRepository extends TrekRepository<Journeys> {
|
||||
.where('journey_id', '=', id)
|
||||
.execute();
|
||||
const ids = new Set(contributors.map((c) => c.user_id));
|
||||
// The trip members a trip's link lets in follow the journal live as well.
|
||||
const linked = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
|
||||
.select('m.user_id')
|
||||
.where('l.journey_id', '=', id)
|
||||
.execute();
|
||||
for (const l of linked) ids.add(l.user_id);
|
||||
const ownerId = await this.findOwnerId(id);
|
||||
if (ownerId !== undefined) ids.add(ownerId);
|
||||
return [...ids];
|
||||
@@ -169,7 +176,18 @@ export class JourneysRepository extends TrekRepository<Journeys> {
|
||||
.whereRef('jt2.journey_id', '=', 'j.id')
|
||||
.as('trip_date_max'),
|
||||
])
|
||||
.where((eb) => eb.or([eb('j.user_id', '=', user_id), eb('jc.user_id', '=', user_id)]))
|
||||
// A journal a trip's link lets this person into is theirs to open here too.
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('j.user_id', '=', user_id),
|
||||
eb('jc.user_id', '=', user_id),
|
||||
eb(
|
||||
'j.id',
|
||||
'in',
|
||||
linkReach(this.kysely<TripJournalLinkKyselyDB>()).select('l.journey_id').where('m.user_id', '=', user_id),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.orderBy('j.updated_at', 'desc')
|
||||
.execute();
|
||||
return rows as JourneyListRow[];
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripJournalLinkMembersRepository extends TrekRepository<TripJournalLinkMembers> {}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
TripJournalLinkMembersRole,
|
||||
type TTripJournalLinkMembersRole,
|
||||
} from '../entities/TripJournalLinkMembers.entity';
|
||||
import type { TripJournalLinks } from '../entities/TripJournalLinks.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
import type { Kysely } from 'kysely';
|
||||
|
||||
/** The tables a link's reach is read from. */
|
||||
export type TripJournalLinkKyselyDB = Pick<
|
||||
DB,
|
||||
'trip_journal_links' | 'trip_journal_link_members' | 'trips' | 'trip_members' | 'journeys' | 'users'
|
||||
>;
|
||||
|
||||
/** The role a link gives, as the migration's CHECK and the generated entity spell it. */
|
||||
export type TripJournalLinkRole = TTripJournalLinkMembersRole;
|
||||
|
||||
const { EDITOR, VIEWER } = TripJournalLinkMembersRole;
|
||||
|
||||
/** A trip's link with the journal it shows, as the settings dialog reads it. */
|
||||
export interface TripJournalLinkRow {
|
||||
trip_id: number;
|
||||
journey_id: number;
|
||||
linked_by: number | null;
|
||||
created_at: number;
|
||||
journey_title: string;
|
||||
journey_subtitle: string | null;
|
||||
journey_cover_image: string | null;
|
||||
journey_owner_id: number;
|
||||
}
|
||||
|
||||
/** Someone on the trip, as the member picker lists them. */
|
||||
export interface TripPersonRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
is_guest: boolean;
|
||||
is_trip_owner: boolean;
|
||||
}
|
||||
|
||||
/** One person the link lets into its journal. */
|
||||
export interface TripJournalLinkMemberRow {
|
||||
user_id: number;
|
||||
role: TripJournalLinkRole;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link members who still belong to the linking trip, as its owner or on its
|
||||
* roster, with the link they hold. Every read of link access starts here, so
|
||||
* leaving a trip ends what its link gave in the same instant, whether or not
|
||||
* anything cleaned up after it. A caller narrows it by journal or by person and
|
||||
* picks its own columns.
|
||||
*/
|
||||
export function linkReach(db: Kysely<TripJournalLinkKyselyDB>) {
|
||||
return db
|
||||
.selectFrom('trip_journal_links as l')
|
||||
.innerJoin('trip_journal_link_members as m', 'm.link_id', 'l.id')
|
||||
.innerJoin('trips as t', 't.id', 'l.trip_id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('t.user_id', '=', eb.ref('m.user_id')),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('trip_members as tm')
|
||||
.select('tm.id')
|
||||
.whereRef('tm.trip_id', '=', 'l.trip_id')
|
||||
.whereRef('tm.user_id', '=', 'm.user_id'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** The stronger of two link roles; a person linked through two trips gets the better one. */
|
||||
function strongest(roles: string[]): TripJournalLinkRole | null {
|
||||
if (roles.includes(EDITOR)) return EDITOR;
|
||||
return roles.length > 0 ? VIEWER : null;
|
||||
}
|
||||
|
||||
/** The role a person holds on a journal through any trip's link, or null when none lets them in. */
|
||||
export async function readLinkRole(
|
||||
db: Kysely<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<TripJournalLinkRole | null> {
|
||||
const rows = await linkReach(db)
|
||||
.select('m.role')
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.where('m.user_id', '=', userId)
|
||||
.execute();
|
||||
return strongest(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/** A link member's own "hide skeletons" choice on the journal, or null when no link lets them in. */
|
||||
export async function readLinkHideSkeletons(
|
||||
db: Kysely<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<number | null> {
|
||||
const row = await linkReach(db)
|
||||
.select('m.hide_skeletons')
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.where('m.user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row?.hide_skeletons ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `trip_journal_links` and its members: which journal a trip shows, and who
|
||||
* of the trip may read or write it through that link. The access this table
|
||||
* gives is read through {@link linkReach} everywhere, never copied into
|
||||
* `journey_contributors`, so a direct contributor's role is never overwritten
|
||||
* and undoing a link takes away exactly what it gave.
|
||||
*/
|
||||
export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks> {
|
||||
/** The trip's link with its journal's title and cover, or null without one. */
|
||||
async findForTrip(tripId: number): Promise<TripJournalLinkRow | null> {
|
||||
const row = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_links as l')
|
||||
.innerJoin('journeys as j', 'j.id', 'l.journey_id')
|
||||
.select([
|
||||
'l.trip_id',
|
||||
'l.journey_id',
|
||||
'l.linked_by',
|
||||
'l.created_at',
|
||||
'j.title as journey_title',
|
||||
'j.subtitle as journey_subtitle',
|
||||
'j.cover_image as journey_cover_image',
|
||||
'j.user_id as journey_owner_id',
|
||||
])
|
||||
.where('l.trip_id', '=', tripId)
|
||||
.executeTakeFirst();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The people the trip's link lets in, still on the trip or not, with their name for the list. */
|
||||
async listMembers(tripId: number): Promise<TripJournalLinkMemberRow[]> {
|
||||
const rows = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_link_members as m')
|
||||
.innerJoin('trip_journal_links as l', 'l.id', 'm.link_id')
|
||||
.innerJoin('users as u', 'u.id', 'm.user_id')
|
||||
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar'])
|
||||
.where('l.trip_id', '=', tripId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
return rows.map((r) => ({ ...r, role: r.role === EDITOR ? EDITOR : VIEWER }));
|
||||
}
|
||||
|
||||
/** Everyone on the trip, its owner first, with what the member picker shows of them. */
|
||||
async listTripPeople(tripId: number): Promise<TripPersonRow[]> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
const owner = await db
|
||||
.selectFrom('trips as t')
|
||||
.innerJoin('users as u', 'u.id', 't.user_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
|
||||
.where('t.id', '=', tripId)
|
||||
.executeTakeFirst();
|
||||
const members = await db
|
||||
.selectFrom('trip_members as tm')
|
||||
.innerJoin('users as u', 'u.id', 'tm.user_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
|
||||
.where('tm.trip_id', '=', tripId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
const people = owner ? [{ ...owner, is_trip_owner: true }] : [];
|
||||
for (const m of members) if (m.user_id !== owner?.user_id) people.push({ ...m, is_trip_owner: false });
|
||||
return people.map((p) => ({ ...p, is_guest: p.is_guest === 1 }));
|
||||
}
|
||||
|
||||
/** Points the trip at a journal, replacing the one it showed; the link keeps its id. */
|
||||
async upsertLink(tripId: number, journeyId: number, linkedBy: number, now: number): Promise<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.insertInto('trip_journal_links')
|
||||
.values({ trip_id: tripId, journey_id: journeyId, linked_by: linkedBy, created_at: now })
|
||||
.onConflict((oc) =>
|
||||
oc.column('trip_id').doUpdateSet({ journey_id: journeyId, linked_by: linkedBy, created_at: now }),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The id of the trip's link, as a subquery the member writes narrow by. */
|
||||
private linkIdOf(tripId: number) {
|
||||
return this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_links')
|
||||
.select('id')
|
||||
.where('trip_id', '=', tripId);
|
||||
}
|
||||
|
||||
/** Sets who the trip's link lets in, replacing the list it had. */
|
||||
async replaceMembers(
|
||||
tripId: number,
|
||||
members: { user_id: number; role: TripJournalLinkRole }[],
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
const link = await this.linkIdOf(tripId).executeTakeFirst();
|
||||
if (!link) return;
|
||||
await db.deleteFrom('trip_journal_link_members').where('link_id', '=', link.id).execute();
|
||||
if (members.length === 0) return;
|
||||
await db
|
||||
.insertInto('trip_journal_link_members')
|
||||
.values(members.map((m) => ({ link_id: link.id, user_id: m.user_id, role: m.role, added_at: now })))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Removes the trip's link; its members go with it. */
|
||||
async deleteForTrip(tripId: number): Promise<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.deleteFrom('trip_journal_links')
|
||||
.where('trip_id', '=', tripId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { caseWhenEquals, coalesce } from '../dialect/sql-functions';
|
||||
import type { TripMembers } from '../entities/TripMembers.entity';
|
||||
import { Trips } from '../entities/Trips.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
@@ -266,6 +267,22 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
|
||||
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes someone who left the trip off its journal link, so coming back later
|
||||
* does not quietly reopen the journal. Their access already ended with the
|
||||
* membership (the link's reach requires it); this tidies the row. Here rather
|
||||
* than in the journey domain because leaving is the roster's to finish, and
|
||||
* reaching into journey from the roster closed a cycle between the two.
|
||||
*/
|
||||
async leaveJournalLink(tripId: number, userId: number): Promise<void> {
|
||||
const db = this.kysely<Pick<DB, 'trip_journal_links' | 'trip_journal_link_members'>>();
|
||||
await db
|
||||
.deleteFrom('trip_journal_link_members')
|
||||
.where('user_id', '=', userId)
|
||||
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('trip_id', '=', tripId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
|
||||
// is rooted at `trip_members` joined to `users`, and RP3's shape is
|
||||
|
||||
@@ -52,6 +52,7 @@ import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { DawarichModule } from './integrations/dawarich.module';
|
||||
import { JourneyAccessModule } from './journey/journey-access.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { TripJournalLinkModule } from './journey/trip-journal-link.module';
|
||||
import { LlmParseModule } from './llm-parse/llm-parse.module';
|
||||
import { ManagedExtModule } from './managed/managed-ext.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
@@ -166,6 +167,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
AirtrailModule,
|
||||
DawarichModule,
|
||||
JourneyModule,
|
||||
TripJournalLinkModule,
|
||||
CollectionsModule,
|
||||
ShareModule,
|
||||
TripInviteModule,
|
||||
|
||||
@@ -135,3 +135,8 @@ export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
|
||||
export function parseBackupCodeHashes(raw: string | null | undefined): string[] {
|
||||
return decodeJson(USER_MFA_BACKUP_CODES, raw).filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
/** Where `plaintext` sits among a user's stored backup-code hashes, or -1 when it is none of them. */
|
||||
export function backupCodeIndex(plaintext: string, raw: string | null | undefined): number {
|
||||
return parseBackupCodeHashes(raw).findIndex((hash) => matchBackupCode(plaintext, hash));
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
generateBackupCodes,
|
||||
hashBackupCodeBcrypt,
|
||||
mask_stored_api_key,
|
||||
backupCodeIndex,
|
||||
matchBackupCode,
|
||||
parseBackupCodeHashes,
|
||||
stripUserForClient,
|
||||
@@ -871,9 +872,10 @@ export class AuthService {
|
||||
throw new DomainError(401, 'Incorrect password');
|
||||
}
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const tokenStr = String(code).replace(/\s/g, '');
|
||||
const ok = authenticator.verify({ token: tokenStr, secret });
|
||||
if (!ok) {
|
||||
const tokenStr = String(code).trim();
|
||||
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
|
||||
// A backup code proves the second factor as at login; disabling drops them all (#2665).
|
||||
if (!okTotp && backupCodeIndex(tokenStr, user.mfa_backup_codes) === -1) {
|
||||
throw new DomainError(401, 'Invalid verification code');
|
||||
}
|
||||
// Every other session ends with the second factor; this one just proved both.
|
||||
@@ -1053,10 +1055,8 @@ export class AuthService {
|
||||
const secret = decryptMfaSecret(user.mfa_secret);
|
||||
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||
if (idx === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||
backupCodeConsumedIndex = idx;
|
||||
backupCodeConsumedIndex = backupCodeIndex(supplied, user.mfa_backup_codes);
|
||||
if (backupCodeConsumedIndex === -1) throw new DomainError(401, 'Invalid MFA code');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -780,8 +780,8 @@ export class SynologyDriveClient {
|
||||
api: 'SYNO.API.Auth',
|
||||
version: '6',
|
||||
method: 'login',
|
||||
account: JSON.stringify(creds.username),
|
||||
passwd: JSON.stringify(creds.password),
|
||||
// Plain: auth.cgi reads no JSON, a quoted account was refused as `"trek"` (#2666).
|
||||
...{ account: creds.username, passwd: creds.password },
|
||||
session: SESSION_NAME,
|
||||
format: 'sid',
|
||||
client: 'browser',
|
||||
|
||||
@@ -10,6 +10,7 @@ import { todayUtc } from '@trek/shared';
|
||||
import { TrekPhotoRegistrationService } from '../photos/trek-photo-registration.service';
|
||||
import { getCountryFromCoords } from '../atlas/atlas-geo';
|
||||
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
|
||||
import { newEntrySortOrder, placeMovedEntry } from './journey-entry-order';
|
||||
import { Journeys } from '../../db/entities/Journeys.entity';
|
||||
import type { JourneysRepository } from '../../db/repositories/Journeys.repository';
|
||||
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
|
||||
@@ -177,7 +178,7 @@ export class JourneyDomainService {
|
||||
// `SELECT *` read this repository now serves.
|
||||
const own = await this.journeysRepo.findOwnedByUser(journeyId, userId);
|
||||
if (own) return own as Journey;
|
||||
const isContributor = await this.contributorsRepo.existsForUser(journeyId, userId);
|
||||
const isContributor = await this.contributorsRepo.hasAccess(journeyId, userId);
|
||||
if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null;
|
||||
return null;
|
||||
}
|
||||
@@ -188,7 +189,7 @@ export class JourneyDomainService {
|
||||
|
||||
async canEdit(journeyId: number, userId: number): Promise<boolean> {
|
||||
if (await this.isOwner(journeyId, userId)) return true;
|
||||
const role = await this.contributorsRepo.findRole(journeyId, userId);
|
||||
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
|
||||
return role === 'editor' || role === 'owner';
|
||||
}
|
||||
|
||||
@@ -269,12 +270,10 @@ export class JourneyDomainService {
|
||||
// linked trips (JG17)
|
||||
const trips = await this.journeyTripsRepo.listForJourney(journeyId);
|
||||
|
||||
// contributors (JG18)
|
||||
const contributorsRaw = await this.contributorsRepo.listForJourney(journeyId);
|
||||
const contributors = contributorsRaw.map((c) => ({
|
||||
...c,
|
||||
avatar_url: avatarUrl(c),
|
||||
}));
|
||||
const withAvatar = <R extends { avatar: string | null }>(rows: R[]) => rows.map((r) => ({ ...r, avatar_url: avatarUrl(r) }));
|
||||
const contributors = withAvatar(await this.contributorsRepo.listForJourney(journeyId)); // JG18
|
||||
// Who a trip's journal link lets in besides them (#2683), shown apart in the settings.
|
||||
const tripAccess = withAvatar(await this.contributorsRepo.listTripAccessForJourney(journeyId));
|
||||
|
||||
// stats
|
||||
const entryCount = entries.filter((e) => e.type === 'entry').length;
|
||||
@@ -292,7 +291,7 @@ export class JourneyDomainService {
|
||||
myRole = 'owner';
|
||||
} else {
|
||||
// JG21 — same text as JG7 (`findRole`).
|
||||
const role = await this.contributorsRepo.findRole(journeyId, userId);
|
||||
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
|
||||
myRole = (role as 'editor' | 'viewer' | undefined) ?? null;
|
||||
}
|
||||
|
||||
@@ -305,6 +304,7 @@ export class JourneyDomainService {
|
||||
gallery,
|
||||
trips,
|
||||
contributors,
|
||||
trip_access: tripAccess,
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: hideSkeletons,
|
||||
// What the eye toggle cannot say: how much was waved away one at a time, and
|
||||
@@ -1209,10 +1209,8 @@ export class JourneyDomainService {
|
||||
? JSON.stringify(data.pros_cons)
|
||||
: null;
|
||||
|
||||
// The next position and the row that takes it in one transaction, so two
|
||||
// entries added at once cannot both read the same MAX (JG74).
|
||||
// Position and row in one transaction, so two adds cannot read the same MAX (JG74, #2679).
|
||||
const insertedId = await this.uow.transactional(async () => {
|
||||
const maxOrder = await this.entriesRepo.maxSortOrderForDate(journeyId, data.entry_date);
|
||||
return await this.entriesRepo.insertEntry({
|
||||
journey_id: journeyId,
|
||||
author_id: userId,
|
||||
@@ -1230,7 +1228,7 @@ export class JourneyDomainService {
|
||||
tags: data.tags?.length ? JSON.stringify(data.tags) : null,
|
||||
pros_cons: prosConsJson,
|
||||
visibility: data.visibility || 'private',
|
||||
sort_order: (maxOrder ?? -1) + 1,
|
||||
sort_order: await newEntrySortOrder(this.entriesRepo, journeyId, data.entry_date, data.entry_time, now),
|
||||
is_draft: data.is_draft ? 1 : 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -1356,6 +1354,7 @@ export class JourneyDomainService {
|
||||
if (Object.keys(patch).length === 1) return decodeEntryRow(entry);
|
||||
|
||||
await this.uow.transactional(async () => {
|
||||
await placeMovedEntry(this.entriesRepo, entry, patch, this.ts());
|
||||
await this.entriesRepo.updateFields(entryId, patch);
|
||||
await this.journeysRepo.updateFields(entry.journey_id, { updated_at: this.ts() }); // touch the journey
|
||||
});
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { JourneyEntriesRepository } from '../../db/repositories/JourneyEntries.repository';
|
||||
|
||||
/** "HH:MM" (seconds ignored) as minutes past midnight, or null for anything else. */
|
||||
function minutesOf(time: string | null | undefined): number | null {
|
||||
const match = /^(\d{1,2}):(\d{2})/.exec(time?.trim() ?? '');
|
||||
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an entry at `time` belongs among its day's entries, given in their
|
||||
* current order: right before the first one that starts later. Entries
|
||||
* without a time are no anchor either way, and an unreadable `time` goes last.
|
||||
*/
|
||||
export function chronologicalSlot(day: readonly { entry_time: string | null }[], time: string): number {
|
||||
const at = minutesOf(time);
|
||||
if (at == null) return day.length;
|
||||
const later = day.findIndex((e) => {
|
||||
const other = minutesOf(e.entry_time);
|
||||
return other != null && other > at;
|
||||
});
|
||||
return later === -1 ? day.length : later;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes room for a timed entry in its day and returns the sort_order it takes.
|
||||
*
|
||||
* A new entry used to go to the end of its day whatever its time, so writing
|
||||
* up the morning after the evening left the morning last (#2679). It now lands
|
||||
* before the first entry that starts later; the order of everything else,
|
||||
* dragged by hand or not, stays as it was. Run it inside the write's
|
||||
* transaction, so the room and the entry that takes it land together.
|
||||
*/
|
||||
export async function makeRoomAtTime(
|
||||
entries: JourneyEntriesRepository,
|
||||
journeyId: number,
|
||||
date: string,
|
||||
time: string,
|
||||
now: number,
|
||||
exceptId?: number,
|
||||
): Promise<number> {
|
||||
const day = (await entries.listDayOrder(journeyId, date)).filter((e) => e.id !== exceptId);
|
||||
const slot = chronologicalSlot(day, time);
|
||||
const moves = day
|
||||
.map((e, index) => ({ id: e.id, from: e.sort_order, to: index < slot ? index : index + 1 }))
|
||||
.filter((m) => m.from !== m.to);
|
||||
await Promise.all(moves.map((m) => entries.updateSortOrder(m.id, m.to, now)));
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** The sort_order a new entry takes: where its time puts it in its day, last when it has none. */
|
||||
export async function newEntrySortOrder(
|
||||
entries: JourneyEntriesRepository,
|
||||
journeyId: number,
|
||||
date: string,
|
||||
time: string | null | undefined,
|
||||
now: number,
|
||||
): Promise<number> {
|
||||
if (time) return await makeRoomAtTime(entries, journeyId, date, time, now);
|
||||
return ((await entries.maxSortOrderForDate(journeyId, date)) ?? -1) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an edited entry to where its new time or day puts it, by giving the patch
|
||||
* the sort_order it takes. Left alone when neither changed, so a save keeps an
|
||||
* entry where it was dragged, and when the caller sent a sort_order of its own.
|
||||
*/
|
||||
export async function placeMovedEntry(
|
||||
entries: JourneyEntriesRepository,
|
||||
entry: { id: number; journey_id: number; entry_date: string; entry_time?: string | null },
|
||||
patch: { entry_date?: string; entry_time?: string | null; sort_order?: number },
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
if (patch.sort_order !== undefined) return;
|
||||
const date = patch.entry_date ?? entry.entry_date;
|
||||
const time = patch.entry_time !== undefined ? patch.entry_time : entry.entry_time;
|
||||
if (!time || (date === entry.entry_date && time === entry.entry_time)) return;
|
||||
patch.sort_order = await makeRoomAtTime(entries, entry.journey_id, date, time, now, entry.id);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TripJournalCandidatesQueryDto, TripJournalLinkDto, TripJournalLinkMembersDto } from './trip-journal-link.dto';
|
||||
import { TripJournalLinkService } from './trip-journal-link.service';
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Patch, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { tripJournalCandidatesResponseSchema, tripJournalLinkResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The journal a trip shows, at `/api/trips/:tripId/journal`. Every member of
|
||||
* the trip may read the link as it concerns them; changing it is decided in the
|
||||
* service, because it turns on who owns the journal as well as on the trip
|
||||
* permission. Behind the Journey addon: with it off the routes answer 404.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/journal')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.JOURNEY, 'Journey')
|
||||
export class TripJournalLinkController {
|
||||
constructor(private readonly links: TripJournalLinkService) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async get(@Param('tripId') tripId: string, @CurrentUser() user: User) {
|
||||
return this.links.get(Number(tripId), user);
|
||||
}
|
||||
|
||||
@Get('candidates')
|
||||
@ResponseContract(tripJournalCandidatesResponseSchema)
|
||||
async candidates(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Query() query: TripJournalCandidatesQueryDto,
|
||||
) {
|
||||
return { candidates: await this.links.candidates(Number(tripId), user, query.journey_id) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async link(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: TripJournalLinkDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.links.link(Number(tripId), user, body.journey_id, body.members, socketId);
|
||||
}
|
||||
|
||||
@Patch('members')
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async setMembers(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: TripJournalLinkMembersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.links.setMembers(Number(tripId), user, body.members, socketId);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@HttpCode(200)
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async unlink(@Param('tripId') tripId: string, @CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.links.unlink(Number(tripId), user, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
tripJournalCandidatesQuerySchema,
|
||||
tripJournalLinkMembersRequestSchema,
|
||||
tripJournalLinkRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TripJournalLinkDto extends createZodDto(tripJournalLinkRequestSchema) {}
|
||||
export class TripJournalLinkMembersDto extends createZodDto(tripJournalLinkMembersRequestSchema) {}
|
||||
export class TripJournalCandidatesQueryDto extends createZodDto(tripJournalCandidatesQuerySchema) {}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
McpController,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
Tool,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripJournalLinkService, type TripJournalActor } from './trip-journal-link.service';
|
||||
import { idSchema, tripJournalLinkRequestSchema } from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/**
|
||||
* The trip's linked journal over MCP: the same service, the same permission and
|
||||
* journal-owner rules as `/api/trips/:tripId/journal`, behind the Journey addon.
|
||||
*/
|
||||
@McpController()
|
||||
export class TripJournalLinkMcp {
|
||||
constructor(
|
||||
private readonly links: TripJournalLinkService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private async actor(tripId: number, userId: number): Promise<TripJournalActor> {
|
||||
const { role } = await this.guards.tripWriter(tripId, userId);
|
||||
return { id: userId, role };
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_trip_journal',
|
||||
description:
|
||||
'The journal from the Journey addon a trip shows as its own, if any: its id and title, your access to it, and whether you may link, unlink or change who it is open to. Open the journal itself with get_journey.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
return ok(await this.links.get(tripId, await this.actor(tripId, ctx.userId)));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_trip_journal',
|
||||
description:
|
||||
'Show one of your own journals in a trip, replacing the journal it showed. The trip members listed get into the journal as viewers or editors and see it in their own journal list; members left out do not. Needs the journal to be yours and the trip permission to link journals.',
|
||||
inputSchema: { tripId: idSchema, journey_id: idSchema, members: tripJournalLinkRequestSchema.shape.members },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async linkTripJournal(
|
||||
{
|
||||
tripId,
|
||||
journey_id,
|
||||
members,
|
||||
}: { tripId: number; journey_id: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return ok(await this.links.link(tripId, await this.actor(tripId, ctx.userId), journey_id, members));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_trip_journal_members',
|
||||
description:
|
||||
"Change which trip members the trip's linked journal is open to, and as viewer or editor. The list replaces the one before; anyone left out loses the access the link gave them. Only the journal's owner can do this.",
|
||||
inputSchema: { tripId: idSchema, members: tripJournalLinkRequestSchema.shape.members },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async setTripJournalMembers(
|
||||
{ tripId, members }: { tripId: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return ok(await this.links.setMembers(tripId, await this.actor(tripId, ctx.userId), members));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'unlink_trip_journal',
|
||||
description:
|
||||
'Take the linked journal off a trip. The journal itself stays; the members the link let in lose that access, while its owner and direct contributors keep theirs.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async unlinkTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
return ok(await this.links.unlink(tripId, await this.actor(tripId, ctx.userId)));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user