feat(journey): the journal settings list who a linked trip lets in

Under the contributors, the journey settings on desktop and phone get a section of their own, Through trips, with everyone a trip's journal link lets in, their role and the trip they come through. It is read only there, since the trip's settings manage it, and it leaves out whoever is a contributor already. getJourneyFull answers it as trip_access.
This commit is contained in:
Maurice
2026-10-18 22:04:41 +02:00
parent 451167bde3
commit 532010aa79
35 changed files with 248 additions and 12 deletions
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
await screen.findByRole('button', { name: /create share link/i })
})
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
const { unmount } = mountDialog()
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
unmount()
mountDialog(buildJourney({
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
}))
expect(screen.getByText('Through trips')).toBeInTheDocument()
expect(screen.getByText('lena')).toBeInTheDocument()
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
})
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
const user = userEvent.setup()
const { onSaved } = mountDialog()
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
import JourneyShareSection from './JourneyShareSection'
import { useJourneySettings } from './useJourneySettings'
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
import ToggleSwitch from '../Settings/ToggleSwitch'
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</div>
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
</DialogSection>
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
</div>
</div>
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
</>
)
}
/**
* Who a trip's linked journal lets in (#2683), under its own heading below the
* contributors: shown here, but managed in that trip's settings, so read only.
*/
function TripAccessSection({ access, roleLabel }: {
access: JourneyTripAccess[] | undefined
roleLabel: (role: JourneyContributor['role']) => string
}) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<DialogSection label={t('journey.contributors.viaTrips')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
<div className={ROWS}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
<div className="min-w-0 flex-1">
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
</div>
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
</div>
))}
</div>
</DialogSection>
)
}
@@ -1,8 +1,5 @@
import { useRef, useState } from 'react'
import {
X, Image, Plus, Trash2, UserPlus, Link as LinkIcon,
List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2,
} from 'lucide-react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2 } from 'lucide-react'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import MToggle from '../../components/MToggle'
@@ -237,6 +234,26 @@ export default function MJourneySettingsSheet({
{t('journey.contributors.invite')}
</button>
{/* Through a trip's linked journal (#2683): shown, but changed in that trip. */}
{!!journey.trip_access?.length && (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
{journey.trip_access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
<Link2 size={15} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1">
<span className="block truncate text-caption font-bold">{a.username}</span>
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
{a.role}
</span>
</div>
))}
</>
)}
{/* Public share */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
{!shareLink ? (
+12
View File
@@ -141,11 +141,23 @@ export interface JourneyContributor {
avatar?: string | null
}
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
export interface JourneyTripAccess {
user_id: number
role: 'editor' | 'viewer'
username: string
avatar?: string | null
trip_id: number
trip_title: string
}
export interface JourneyDetail extends Journey {
entries: JourneyEntry[]
gallery: GalleryPhoto[]
trips: JourneyTrip[]
contributors: JourneyContributor[]
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
trip_access?: JourneyTripAccess[]
stats: { entries: number; photos: number; places: number }
hide_skeletons?: boolean
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
@@ -89,6 +89,17 @@ describe('MJourneySettingsSheet', () => {
expect(screen.getByText('8 places')).toBeInTheDocument();
});
it('FE-MOB-JSET-2683: lists the people a linked trip lets in, and nothing without', async () => {
const { unmount } = setup();
expect(screen.queryByText('Through trips')).not.toBeInTheDocument();
unmount();
setup({ trip_access: [{ user_id: 7, role: 'editor', username: 'lena', avatar: null, trip_id: 3, trip_title: 'Tokyo trip' }] });
expect(await screen.findByText('Through trips')).toBeInTheDocument();
expect(screen.getByText('lena')).toBeInTheDocument();
expect(screen.getByText('via Tokyo trip')).toBeInTheDocument();
});
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
setup({ trips: [] });
expect(await screen.findByText('No trips linked')).toBeInTheDocument();