mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(trips): trip settings with a linked journal tab
The Share button becomes the trip's Settings, a round button beside the back pill. The dialog is rebuilt around three areas (People, Sharing, Journal) with a side navigation, a fixed height and 960 px width on wide screens. Journal links one of your journals from the Journey addon to the trip. Every trip member is ticked as a reader by default, each can be raised to editor or left out, and the people a save would let in for the first time are named before it saves. The link can be changed or removed. With a link the trip gets a Journal tab on desktop and phone that shows the Journey addon's own view, live through its sync and trip:journal:changed. Nothing shows with the addon off. members.shareTrip and nav.share are gone with the button that used them.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,8 @@ 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 { Tooltip } from '../shared/Tooltip'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
@@ -21,7 +22,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 +34,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 +175,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 && (
|
||||
<Tooltip label={t('trip.settings.title')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
aria-label={t('trip.settings.title')}
|
||||
className="inline-flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-surface-card text-content-muted shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -245,17 +262,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,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) ───────────────────────────────────────────
|
||||
|
||||
@@ -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,17 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width={m.canManageShare ? 'wide' : 'detail'}
|
||||
width={areas.length > 1 ? 'wide' : 'detail'}
|
||||
align="top"
|
||||
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 +92,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 +125,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} 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 +362,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('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('Access saved');
|
||||
});
|
||||
|
||||
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('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('You are not allowed to do that.');
|
||||
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('Linking a journal needs a connection.'));
|
||||
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('That did not work. Please try again.'));
|
||||
expect(result.current.candidates).toEqual([]);
|
||||
act(() => result.current.cancel());
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { TripJournalCandidate, 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>;
|
||||
|
||||
/** 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 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 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 [candidates, setCandidates] = useState<TripJournalCandidate[] | null>(null);
|
||||
const [choice, setChoice] = useState<JournalMemberChoice>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const fail = 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 the chosen journal, each ticked as they stand:
|
||||
// on the link with their role, or (for a journal not linked yet) everybody as
|
||||
// a reader, the default this section promises.
|
||||
useEffect(() => {
|
||||
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
|
||||
if (journeyId == null) return;
|
||||
let cancelled = false;
|
||||
tripJournalLinkRepo
|
||||
.candidates(tripId, journeyId)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setCandidates(list);
|
||||
const onLink = new Map(
|
||||
(link?.journey_id === journeyId ? (link.members ?? []) : []).map((m) => [m.user_id, m.role])
|
||||
);
|
||||
const keepCurrent = mode === 'members' || onLink.size > 0;
|
||||
setChoice(
|
||||
Object.fromEntries(list.map((c) => [c.user_id, keepCurrent ? (onLink.get(c.user_id) ?? null) : 'viewer']))
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
fail(err);
|
||||
setCandidates([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mode, pickedId, link, tripId, fail]);
|
||||
|
||||
const startPick = useCallback(() => {
|
||||
setCandidates(null);
|
||||
setMode('pick');
|
||||
setPickedId(null);
|
||||
if (userId == null) return;
|
||||
tripJournalLinkRepo
|
||||
.ownJournals(userId)
|
||||
.then(setJournals)
|
||||
.catch((err: unknown) => {
|
||||
fail(err);
|
||||
setJournals([]);
|
||||
});
|
||||
}, [userId, fail]);
|
||||
|
||||
// A new pick shows its own people only: the last journal's stay out until they load.
|
||||
const pickJournal = useCallback((journeyId: number | null) => {
|
||||
setCandidates(null);
|
||||
setPickedId(journeyId);
|
||||
}, []);
|
||||
|
||||
const editMembers = useCallback(() => {
|
||||
setCandidates(null);
|
||||
setMode('members');
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setMode('view');
|
||||
setPickedId(null);
|
||||
setCandidates(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]
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (write: () => Promise<void>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await write();
|
||||
toast.success(t(done));
|
||||
cancel();
|
||||
} catch (err: unknown) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cancel, fail, t, toast]
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (mode === 'pick' && pickedId != null) {
|
||||
await run(() => store().link(tripId, pickedId, chosenMembers), 'trip.journal.linked');
|
||||
} else if (mode === 'members') {
|
||||
await run(() => store().setMembers(tripId, chosenMembers), 'trip.journal.membersSaved');
|
||||
}
|
||||
}, [mode, pickedId, run, store, tripId, chosenMembers]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
await run(() => store().unlink(tripId), 'trip.journal.unlinked');
|
||||
}, [run, store, tripId]);
|
||||
|
||||
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,
|
||||
candidates,
|
||||
choice,
|
||||
setRole,
|
||||
setAll,
|
||||
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 =>
|
||||
|
||||
@@ -824,6 +824,14 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
:root { --nav-h: calc(64px + env(safe-area-inset-top, 0px)); }
|
||||
}
|
||||
|
||||
/* A journal shown inside a trip (its Journal tab): the journal's own fixed bars
|
||||
measure themselves from --nav-h, so below the trip's navbar they also have to
|
||||
clear the trip's tab bar. */
|
||||
.trip-embedded-journal { --nav-h: 44px; }
|
||||
@media (min-width: 768px) {
|
||||
.trip-embedded-journal { --nav-h: calc(64px + env(safe-area-inset-top, 0px) + 44px); }
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg-primary: #121215;
|
||||
--bg-secondary: #1a1a1e;
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
to { opacity: 0; transform: translateY(6px); }
|
||||
}
|
||||
|
||||
/* A journal inside a trip's Journal tab starts below the trip's own floating
|
||||
top controls rather than under them. */
|
||||
.m-embedded-journal { --m-safe-top: calc(env(safe-area-inset-top, 0px) + 62px); }
|
||||
|
||||
.m-sheet-in { animation: m-sheet-in 280ms var(--ease-drawer) both; }
|
||||
.m-sheet-out { animation: m-sheet-out 280ms var(--ease-drawer) both; }
|
||||
.m-drawer-in { animation: m-drawer-in 280ms var(--ease-drawer) both; }
|
||||
|
||||
@@ -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,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') }]
|
||||
|
||||
@@ -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 (see .m-embedded-journal).
|
||||
case 'journal':
|
||||
return planner.journalId == null ? null : (
|
||||
<div className="m-embedded-journal absolute inset-0 overflow-hidden">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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 wrapper moves the view's fixed
|
||||
* bars below the trip's tab bar (see .trip-embedded-journal).
|
||||
*/
|
||||
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
|
||||
if (journeyId == null) return null;
|
||||
return (
|
||||
<div className="trip-embedded-journal absolute inset-0 overflow-y-auto">
|
||||
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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_TAB_LABEL_KEYS.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 }));
|
||||
},
|
||||
};
|
||||
@@ -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)),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user