mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 17029d364a | |||
| f458278e88 | |||
| fca42d46d9 | |||
| 532010aa79 | |||
| 451167bde3 | |||
| 92181cdefb | |||
| ffa7333bb1 | |||
| 48d573c60d | |||
| 33c5c7decc | |||
| 3db20dd71c | |||
| 0751b3e8c9 | |||
| 4a074ec93c | |||
| a6da69ee36 | |||
| ce8d1e5a0a | |||
| 340d429415 | |||
| 89c96ed554 | |||
| 889e88c362 | |||
| fe05afec19 | |||
| 89f3055a29 | |||
| 80b6739362 |
@@ -257,6 +257,15 @@ jobs:
|
||||
# migration that changes a column regenerates it in the same change.
|
||||
run: cd server && npm run check:db-types
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Errors fail, and the warnings per rule may not pass
|
||||
# scripts/eslint-baseline.json (src, tests and eslint-disable'd
|
||||
@@ -264,6 +273,7 @@ jobs:
|
||||
# program, which outgrows Node's default ~4 GB heap on the runner.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd server && npm run lint:warnings
|
||||
|
||||
- name: Formatting check
|
||||
@@ -395,6 +405,15 @@ jobs:
|
||||
# one included, has to be clean (scripts/strict-lint.mjs).
|
||||
run: cd client && npm run lint:strict
|
||||
|
||||
- name: Fetch the base the warnings are compared with
|
||||
# lint:warnings names the file behind a grown count by linting it as
|
||||
# it is on the base: the pushed-over commit on a push, the target
|
||||
# branch on a pull request. A miss only drops that hint.
|
||||
run: >-
|
||||
git fetch --no-tags --depth=1 origin
|
||||
"+${{ github.event_name == 'push' && github.event.before || format('refs/heads/{0}', github.base_ref || 'dev') }}:refs/remotes/lint-base"
|
||||
|| true
|
||||
|
||||
- name: Lint
|
||||
# Fails on any ESLint error, like lint:check did, and on any rule
|
||||
# whose warning count rises above scripts/eslint-baseline.json.
|
||||
@@ -402,6 +421,7 @@ jobs:
|
||||
# whole TypeScript program, so it gets the heap the server lint gets.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
TREK_LINT_BASE: refs/remotes/lint-base
|
||||
run: cd client && npm run lint:warnings
|
||||
|
||||
- name: Page pattern check
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs).
|
||||
*/
|
||||
import { ESLint } from 'eslint';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -137,6 +138,78 @@ export async function lintClient(root) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
maxBuffer: 1 << 28,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
const now = ofRule(await lintText(readText(path), path), rule, area);
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? [] : ofRule(await lintText(before, path), rule, area);
|
||||
return now.length > was.length ? now.map((m) => `${key}:${m.line ?? 0} ${m.message}`) : [];
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) => newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText }))
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Lints one file's text with the client's own config, for newWarningsOf. */
|
||||
function lintTextClient(root) {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -148,6 +221,7 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint = lintClient,
|
||||
lintText = lint === lintClient ? lintTextClient : null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -160,13 +234,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -182,7 +256,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -191,5 +266,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 10,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 22,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
"components/Layout/NewVersionNotice.tsx": 3,
|
||||
"components/Layout/OfflineBanner.tsx": 2,
|
||||
"components/Layout/PageSidebar.tsx": 3,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
TripJournalCandidatesResponse,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
import apiClient from './client';
|
||||
|
||||
/**
|
||||
* `/api/trips/:tripId/journal`: the journal from the Journey addon a trip shows,
|
||||
* and who it is open to. In its own file rather than in the `client.ts` god
|
||||
* module; repo/tripJournalLinkRepo.ts is the one caller.
|
||||
*/
|
||||
export const tripJournalApi = {
|
||||
get: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
candidates: (tripId: number, journeyId: number): Promise<TripJournalCandidatesResponse> =>
|
||||
apiClient.get(`/trips/${tripId}/journal/candidates`, { params: { journey_id: journeyId } }).then((r) => r.data),
|
||||
link: (
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.put(`/trips/${tripId}/journal`, { journey_id: journeyId, members }).then((r) => r.data),
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.patch(`/trips/${tripId}/journal/members`, { members }).then((r) => r.data),
|
||||
unlink: (tripId: number): Promise<TripJournalLinkResponse> =>
|
||||
apiClient.delete(`/trips/${tripId}/journal`).then((r) => r.data),
|
||||
/** The journals this person can open; the dialog offers the ones they own. */
|
||||
journals: (): Promise<{ journeys: { id: number; user_id: number; title: string; subtitle?: string | null }[] }> =>
|
||||
apiClient.get('/journeys').then((r) => r.data),
|
||||
};
|
||||
@@ -93,6 +93,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
// Linked journal: components/Trips/useTripJournalLink asks again which journal
|
||||
// the trip shows and who it is open to. Content-free, because that answer is
|
||||
// per person (a member left off the link must not learn its title).
|
||||
'trip:journal:changed',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
|
||||
@@ -24,7 +24,7 @@ export const CATEGORIES = [
|
||||
{ id: 'members', keys: ['member_manage'] },
|
||||
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
|
||||
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
|
||||
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage', 'journal_link'] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -98,6 +98,19 @@ describe('JourneySettingsDialog', () => {
|
||||
await screen.findByRole('button', { name: /create share link/i })
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-2683: lists the people a linked trip lets in under their own heading, and nothing without', () => {
|
||||
const { unmount } = mountDialog()
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountDialog(buildJourney({
|
||||
trip_access: [{ user_id: 7, role: 'viewer', username: 'lena', avatar: null, trip_id: 5, trip_title: 'Italy Trip' }],
|
||||
}))
|
||||
expect(screen.getByText('Through trips')).toBeInTheDocument()
|
||||
expect(screen.getByText('lena')).toBeInTheDocument()
|
||||
expect(screen.getByText('via Italy Trip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-002: saves the edited title and subtitle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSaved } = mountDialog()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection,
|
||||
import { AddRowButton, EditorField, INPUT, Segmented } from '../shared/dialogParts'
|
||||
import JourneyShareSection from './JourneyShareSection'
|
||||
import { useJourneySettings } from './useJourneySettings'
|
||||
import type { JourneyContributor, JourneyDetail } from '../../store/journeyStore'
|
||||
import type { JourneyContributor, JourneyDetail, JourneyTripAccess } from '../../store/journeyStore'
|
||||
import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
@@ -277,6 +277,8 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</div>
|
||||
<AddRowButton onClick={onOpenInvite}>{t('journey.contributors.invite')}</AddRowButton>
|
||||
</DialogSection>
|
||||
|
||||
<TripAccessSection access={journey.trip_access} roleLabel={roleLabel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,3 +326,32 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a trip's linked journal lets in (#2683), under its own heading below the
|
||||
* contributors: shown here, but managed in that trip's settings, so read only.
|
||||
*/
|
||||
function TripAccessSection({ access, roleLabel }: {
|
||||
access: JourneyTripAccess[] | undefined
|
||||
roleLabel: (role: JourneyContributor['role']) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<DialogSection label={t('journey.contributors.viaTrips')}>
|
||||
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.contributors.viaTripsHint')}</p>
|
||||
<div className={ROWS}>
|
||||
{access.map(a => (
|
||||
<div key={`${a.user_id}-${a.trip_id}`} className={ROW}>
|
||||
<TripMemberAvatar username={a.username} avatarUrl={avatarSrc(a.avatar)} size={28} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13, 'body')}>{a.username}</div>
|
||||
<div className="truncate text-content-faint" style={fs(11)}>{t('journey.contributors.viaTrip', { trip: a.trip_title })}</div>
|
||||
</div>
|
||||
<span className={`${BADGE} bg-surface-tertiary text-content-muted`} style={fs(10.5)}>{roleLabel(a.role)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,23 +169,24 @@ describe('Navbar', () => {
|
||||
expect(adminLink).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
|
||||
render(<Navbar onShare={vi.fn()} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
expect(shareBtn).toBeInTheDocument();
|
||||
it('FE-COMP-NAVBAR-019: the trip settings button sits beside the back pill when onSettings is given', () => {
|
||||
render(<Navbar tripTitle="Japan" showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
const settings = screen.getByRole('button', { name: 'Trip settings' });
|
||||
expect(back.parentElement).toBe(settings.closest('div'));
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
|
||||
it('FE-COMP-NAVBAR-020: the trip settings button calls onSettings', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onShare = vi.fn();
|
||||
render(<Navbar onShare={onShare} />);
|
||||
const shareBtn = screen.getByRole('button', { name: /share/i });
|
||||
await user.click(shareBtn);
|
||||
expect(onShare).toHaveBeenCalled();
|
||||
const onSettings = vi.fn();
|
||||
render(<Navbar onSettings={onSettings} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Trip settings' }));
|
||||
expect(onSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
|
||||
it('FE-COMP-NAVBAR-021: no trip settings button without onSettings, and no share button any more', () => {
|
||||
render(<Navbar />);
|
||||
expect(screen.queryByRole('button', { name: 'Trip settings' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -453,8 +454,8 @@ describe('Navbar styling and menu details', () => {
|
||||
expect(screen.queryByText('3.5.0-rc1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5NAV-009: the back, share and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onShare={vi.fn()} />);
|
||||
it('FE-W5NAV-009: the back and theme buttons reset their hover background', () => {
|
||||
render(<Navbar showBack onBack={vi.fn()} onSettings={vi.fn()} />);
|
||||
|
||||
const back = screen.getByRole('button', { name: /back/i });
|
||||
fireEvent.mouseEnter(back);
|
||||
@@ -462,11 +463,6 @@ describe('Navbar styling and menu details', () => {
|
||||
fireEvent.mouseLeave(back);
|
||||
expect(back.style.background).toBe('transparent');
|
||||
|
||||
const share = screen.getByRole('button', { name: /share/i });
|
||||
fireEvent.mouseEnter(share);
|
||||
expect(share.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(share);
|
||||
expect(share.style.background).toBe('var(--bg-card)');
|
||||
|
||||
const theme = document.querySelector('button[title]:not([data-help-button])') as HTMLElement;
|
||||
fireEvent.mouseEnter(theme);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, ChevronLeft, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass, BookOpen, Bookmark } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import InAppNotificationBell from './InAppNotificationBell.tsx'
|
||||
import HelpButton from '../Help/HelpButton'
|
||||
@@ -21,7 +21,8 @@ interface NavbarProps {
|
||||
tripId?: number | string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
/** The trip's Settings: who is on it, its links and its journal. */
|
||||
onSettings?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -32,7 +33,7 @@ interface Addon {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
@@ -173,6 +174,21 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
<span className="truncate text-caption font-semibold text-content">{tripTitle}</span>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* The trip's Settings sit with the trip, beside its pill: they are about this
|
||||
trip, not about the app the right side of the bar belongs to. */}
|
||||
{onSettings && (
|
||||
// The same pill as the trip's own beside it, so the two read as one pair.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSettings}
|
||||
aria-label={t('trip.settings.title')}
|
||||
className="inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card py-1 ps-2 pe-3 text-content shadow-sm ring-1 ring-inset ring-edge-faint transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4 flex-none text-content-muted" aria-hidden="true" />
|
||||
<span className="text-caption font-semibold">{t('nav.settings')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Centred liquid-glass tab menu (design handoff).
|
||||
@@ -245,17 +261,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
|
||||
itself on top of them. */}
|
||||
<div className="flex items-center gap-4 flex-1 basis-0 min-w-max justify-end">
|
||||
|
||||
{/* Share button */}
|
||||
{onShare && (
|
||||
<button type="button" onClick={onShare}
|
||||
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0 border-edge text-content-secondary bg-surface-card"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('nav.share')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Contextual help for the current screen — same footprint as the theme toggle beside it */}
|
||||
{user && <HelpButton />}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BookOpen, Link2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* The trip's Journal tab: a book with a link on its corner, because the journal
|
||||
* is not the trip's own but one from the Journey addon tied to it. Takes the
|
||||
* props the tab bars hand a lucide icon.
|
||||
*
|
||||
* The book is masked out under the link rather than the link given a backing:
|
||||
* a backing has to match whatever the icon sits on (a card, the accent of a
|
||||
* picked tab), and the moment it does not, the link vanishes into it.
|
||||
*/
|
||||
export function LinkedJournalIcon({
|
||||
size = 16,
|
||||
strokeWidth = 2,
|
||||
className,
|
||||
}: {
|
||||
size?: number;
|
||||
strokeWidth?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const badge = Math.round(size * 0.6);
|
||||
// The hole: a circle round the link's centre, a little wider than the link.
|
||||
const hole = `radial-gradient(circle at calc(100% + 2px) calc(100% + 1px), transparent ${badge * 0.62}px, currentColor ${badge * 0.62 + 0.5}px)`;
|
||||
return (
|
||||
// dir="ltr": the hole is cut on the right, so the link has to stay there in Arabic too.
|
||||
<span
|
||||
dir="ltr"
|
||||
className={`relative inline-flex flex-none ${className ?? ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="inline-flex" style={{ maskImage: hole, WebkitMaskImage: hole }}>
|
||||
<BookOpen size={size} strokeWidth={strokeWidth} />
|
||||
</span>
|
||||
<Link2 size={badge} strokeWidth={strokeWidth + 0.6} className="absolute -bottom-[3px] -end-[4px]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// FE-TJL-SECTION-001 to FE-TJL-SECTION-007
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, rendered: what someone sees with no
|
||||
* journal linked, with one linked, while picking, and the warning that names
|
||||
* the people a save would let in for the first time.
|
||||
*/
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const TRIP = 4;
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: 'covers/j.jpg',
|
||||
owner_id: 1,
|
||||
linked_by: 1,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: true, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
let deletes = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
answer = EMPTY;
|
||||
deletes = 0;
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }) });
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }],
|
||||
});
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => HttpResponse.json(answer)),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () => HttpResponse.json({ journeys: [{ id: 9, user_id: 1, title: 'Japan' }] })),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
deletes += 1;
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('TripJournalLinkSection', () => {
|
||||
it('FE-TJL-SECTION-001: renders nothing with the Journey addon off', async () => {
|
||||
useAddonStore.setState({ addons: [] });
|
||||
const { container } = render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-002: with no journal linked it offers to link one, in its own card', async () => {
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-003: picking a journal lists the trip and warns about who gets in for the first time', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TripJournalLinkSection tripId={TRIP} framed={false} />);
|
||||
await user.click(await screen.findByText('Link a journal'));
|
||||
await user.click(await screen.findByText('Choose a journal'));
|
||||
// The select opens its list in a portal, one button per journal.
|
||||
await user.click(await screen.findByRole('button', { name: 'Japan' }));
|
||||
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Has access')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('tom will get access to this journal for the first time');
|
||||
|
||||
await user.click(screen.getAllByRole('radio', { name: 'No access' })[1]);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Everyone can read'));
|
||||
expect(screen.getByRole('note')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Nobody'));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.getByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-004: a linked journal shows its cover, my role and who it is open to', async () => {
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Japan')).toBeInTheDocument();
|
||||
expect(screen.getByText('Your journal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Linked')).toBeInTheDocument();
|
||||
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-005: unlinking asks first, then takes it off', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = LINKED;
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Unlink' }));
|
||||
expect(screen.getByText('Unlink this journal?')).toBeInTheDocument();
|
||||
const buttons = screen.getAllByRole('button', { name: 'Unlink' });
|
||||
await user.click(buttons[buttons.length - 1]);
|
||||
await waitFor(() => expect(deletes).toBe(1));
|
||||
expect(await screen.findByText('Link a journal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-006: editing access lists the trip with each one as the link holds them', async () => {
|
||||
const user = userEvent.setup();
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, members: [] } };
|
||||
render(<TripJournalLinkSection tripId={TRIP} />);
|
||||
expect(await screen.findByText('Nobody else from this trip has access yet.')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Edit access'));
|
||||
expect(await screen.findByText('tom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('radio', { name: 'No access' }).every((r) => r.getAttribute('aria-checked') === 'true')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-SECTION-007: the linked-journal icon draws a book with the link on its corner', () => {
|
||||
const { container } = render(<LinkedJournalIcon size={20} className="x" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.getAttribute('dir')).toBe('ltr');
|
||||
expect(root.style.width).toBe('20px');
|
||||
expect(container.querySelectorAll('svg')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkRole } from '@trek/shared';
|
||||
import { AlertTriangle, BookOpen, Link2, Unlink, Users } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import ConfirmDialog from '../shared/ConfirmDialog';
|
||||
import CustomSelect from '../shared/CustomSelect';
|
||||
import { DialogButton, fs } from '../shared/DialogShell';
|
||||
import { AddRowButton } from '../shared/dialogParts';
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon';
|
||||
import { TripMemberAvatar } from './TripMemberAvatar';
|
||||
import { LinkCard } from './TripShareDialog';
|
||||
import { useTripJournalLink, type JournalMemberChoice } from './useTripJournalLink';
|
||||
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5';
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5';
|
||||
const SEGMENT = 'rounded-full px-2.5 py-1 font-semibold';
|
||||
|
||||
const ROLES: { value: TripJournalLinkRole | null; labelKey: string }[] = [
|
||||
{ value: null, labelKey: 'trip.journal.roleNone' },
|
||||
{ value: 'viewer', labelKey: 'trip.journal.roleViewer' },
|
||||
{ value: 'editor', labelKey: 'trip.journal.roleEditor' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The Journal part of the trip's Settings, for the desktop dialog and the phone
|
||||
* sheet alike: the journal the trip shows, and who of the trip it is open to.
|
||||
* Hidden with the Journey addon off, and for someone who can neither link one
|
||||
* nor see one that is linked.
|
||||
*/
|
||||
export function TripJournalLinkSection({ tripId, framed = true }: { tripId: number; framed?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const j = useTripJournalLink(tripId);
|
||||
const [askUnlink, setAskUnlink] = useState(false);
|
||||
if (!j.visible) return null;
|
||||
|
||||
// The desktop dialog names the area above it already; the phone sheet gets the card with its own title.
|
||||
const Frame = framed ? JournalCard : Bare;
|
||||
return (
|
||||
<>
|
||||
<Frame>
|
||||
{j.mode === 'view' && j.link && <LinkedJournal j={j} />}
|
||||
{j.mode === 'view' && !j.link && j.canLink && (
|
||||
<AddRowButton onClick={j.startPick}>{t('trip.journal.link')}</AddRowButton>
|
||||
)}
|
||||
{j.mode === 'view' && j.link && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{j.canEditMembers && (
|
||||
<DialogButton onClick={j.editMembers} icon={<Users size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.editMembers')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canLink && (
|
||||
<DialogButton onClick={j.startPick} icon={<BookOpen size={14} strokeWidth={2.2} />}>
|
||||
{t('trip.journal.change')}
|
||||
</DialogButton>
|
||||
)}
|
||||
{j.canUnlink && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAskUnlink(true)}
|
||||
disabled={j.busy}
|
||||
className="inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:opacity-50"
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Unlink size={13} strokeWidth={2.2} />
|
||||
{t('trip.journal.unlink')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{j.mode === 'pick' && <JournalPicker j={j} />}
|
||||
{(j.mode === 'members' || (j.mode === 'pick' && j.pickedId != null)) && <MemberChooser j={j} />}
|
||||
{j.mode !== 'view' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogButton onClick={j.cancel} disabled={j.busy}>
|
||||
{t('common.cancel')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
onClick={() => void j.save()}
|
||||
disabled={!j.canSave}
|
||||
icon={<Link2 size={14} strokeWidth={2.2} />}
|
||||
>
|
||||
{j.mode === 'pick' ? t('trip.journal.linkSubmit') : t('common.save')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
)}
|
||||
</Frame>
|
||||
<ConfirmDialog
|
||||
isOpen={askUnlink}
|
||||
onClose={() => setAskUnlink(false)}
|
||||
onConfirm={() => {
|
||||
setAskUnlink(false);
|
||||
void j.unlink();
|
||||
}}
|
||||
title={t('trip.journal.unlinkTitle')}
|
||||
message={t('trip.journal.unlinkMessage')}
|
||||
confirmLabel={t('trip.journal.unlink')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalCard({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<LinkCard
|
||||
icon={<LinkedJournalIcon size={15} strokeWidth={2.2} />}
|
||||
title={t('trip.journal.title')}
|
||||
hint={t('trip.journal.hint')}
|
||||
>
|
||||
{children}
|
||||
</LinkCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Bare({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-col gap-4">{children}</div>;
|
||||
}
|
||||
|
||||
type JournalLink = ReturnType<typeof useTripJournalLink>;
|
||||
|
||||
/** The linked journal: cover, title, what the reader may do in it, and who it is open to. */
|
||||
function LinkedJournal({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
const link = j.link;
|
||||
if (!link) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center gap-3 rounded-[14px] border border-edge-faint bg-surface-card p-2.5">
|
||||
<span className="grid h-11 w-11 flex-none place-items-center overflow-hidden rounded-[10px] bg-surface-tertiary text-content-muted">
|
||||
{link.cover_image ? (
|
||||
<img src={`/uploads/${link.cover_image}`} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<BookOpen size={18} strokeWidth={2} />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{link.title}
|
||||
</div>
|
||||
{link.my_role && (
|
||||
<div className="truncate text-content-faint" style={fs(11.5)}>
|
||||
{t(`trip.journal.yourRole.${link.my_role}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
<Link2 size={10} strokeWidth={2.6} />
|
||||
{t('trip.journal.linkedBadge')}
|
||||
</span>
|
||||
</div>
|
||||
{link.members && link.members.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{link.members.map((m) => (
|
||||
<span
|
||||
key={m.user_id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-surface-card py-[3px] pe-2.5 ps-[3px] shadow-sm ring-1 ring-edge-faint"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
<TripMemberAvatar username={m.username} avatarUrl={m.avatar_url} size={20} />
|
||||
<span className="font-medium text-content">{m.username}</span>
|
||||
<span className="text-content-faint">
|
||||
{t(m.role === 'editor' ? 'trip.journal.roleEditor' : 'trip.journal.roleViewer')}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{link.members && link.members.length === 0 && (
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{t('trip.journal.onlyYou')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The journals of one's own to choose from; one only contributed to is not one's to give away. */
|
||||
function JournalPicker({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.journals == null) return <div className="h-10 animate-pulse rounded-[10px] bg-surface-tertiary" />;
|
||||
if (j.journals.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noJournals')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<CustomSelect
|
||||
value={j.pickedId == null ? '' : String(j.pickedId)}
|
||||
onChange={(value) => j.pickJournal(value === '' ? null : Number(value))}
|
||||
placeholder={t('trip.journal.pickPlaceholder')}
|
||||
options={[
|
||||
{ value: '', label: t('trip.journal.pickPlaceholder') },
|
||||
...j.journals.map((journal) => ({ value: String(journal.id), label: journal.title })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone of the trip, each with no access, read or edit. Before saving, the
|
||||
* people the link would let in for the first time are named: from then on they
|
||||
* see the journal in their own Journey list as well.
|
||||
*/
|
||||
function MemberChooser({ j }: { j: JournalLink }) {
|
||||
const { t } = useTranslation();
|
||||
if (j.candidates == null) return <div className="h-24 animate-pulse rounded-[14px] bg-surface-tertiary" />;
|
||||
if (j.candidates.length === 0)
|
||||
return (
|
||||
<p className="m-0 text-content-faint" style={fs(12)}>
|
||||
{t('trip.journal.noMembers')}
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-content-secondary" style={fs(12, 'body')}>
|
||||
{t('trip.journal.members')}
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll('viewer')}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allRead')}
|
||||
</button>
|
||||
<span className="text-content-faint" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => j.setAll(null)}
|
||||
className="text-content-muted hover:text-content"
|
||||
style={fs(11.5)}
|
||||
>
|
||||
{t('trip.journal.allNone')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={ROWS}>
|
||||
{j.candidates.map((c) => (
|
||||
<MemberRow key={c.user_id} c={c} choice={j.choice} onRole={j.setRole} showAccess={j.mode === 'pick'} />
|
||||
))}
|
||||
</div>
|
||||
{j.newlyGranted.length > 0 && (
|
||||
<div role="note" className="flex items-start gap-2 rounded-[12px] bg-warning-soft px-3 py-2.5 text-warning">
|
||||
<AlertTriangle size={15} strokeWidth={2.2} className="mt-px flex-none" />
|
||||
<p className="m-0 leading-normal" style={fs(12)}>
|
||||
{t('trip.journal.warnNew', {
|
||||
count: j.newlyGranted.length,
|
||||
names: j.newlyGranted.map((c) => c.username).join(', '),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({
|
||||
c,
|
||||
choice,
|
||||
onRole,
|
||||
showAccess,
|
||||
}: {
|
||||
c: TripJournalCandidate;
|
||||
choice: JournalMemberChoice;
|
||||
onRole: (userId: number, role: TripJournalLinkRole | null) => void;
|
||||
showAccess: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const current = choice[c.user_id] ?? null;
|
||||
return (
|
||||
<div className={ROW}>
|
||||
<TripMemberAvatar username={c.username} avatarUrl={c.avatar_url} />
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate font-semibold text-content" style={fs(13, 'body')}>
|
||||
{c.username}
|
||||
</span>
|
||||
{c.is_trip_owner && (
|
||||
<span
|
||||
className="rounded-full bg-surface-tertiary px-2 py-[2px] font-semibold text-content-muted"
|
||||
style={fs(10.5)}
|
||||
>
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
{showAccess && c.access && (
|
||||
<span className="rounded-full bg-success-soft px-2 py-[2px] font-semibold text-success" style={fs(10.5)}>
|
||||
{t('trip.journal.hasAccess')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={c.username}
|
||||
className="flex flex-none gap-0.5 rounded-full bg-surface-card p-0.5 ring-1 ring-edge-faint"
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.labelKey}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={current === r.value}
|
||||
onClick={() => onRole(c.user_id, r.value)}
|
||||
className={`${SEGMENT} ${current === r.value ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{t(r.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,10 +122,10 @@ describe('TripMembersModal', () => {
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-002: shows Share Trip title', () => {
|
||||
it('FE-COMP-MEMBERS-002: shows the Trip settings title', () => {
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
// members.shareTrip = "Share Trip"
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
// trip.settings.title = "Trip settings"
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MEMBERS-003: shows owner username after load', async () => {
|
||||
@@ -245,7 +245,7 @@ describe('TripMembersModal', () => {
|
||||
|
||||
it('FE-COMP-MEMBERS-015: modal renders when isOpen is true', () => {
|
||||
render(<TripMembersModal {...defaultProps} isOpen={true} />);
|
||||
expect(screen.getByText('Share Trip')).toBeInTheDocument();
|
||||
expect(screen.getByText('Trip settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Share Link Section (016-021) ───────────────────────────────────────────
|
||||
@@ -291,6 +291,7 @@ describe('TripMembersModal', () => {
|
||||
);
|
||||
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
const createBtn = await screen.findByText('Create link');
|
||||
await user.click(createBtn);
|
||||
|
||||
@@ -689,6 +690,7 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
const view = render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
const createBtn = await screen.findByRole('button', { name: /Create invite link/i });
|
||||
fireEvent.click(createBtn);
|
||||
@@ -719,6 +721,7 @@ describe('TripMembersModal', () => {
|
||||
asShareOwner();
|
||||
server.use(http.post('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Create invite link/i }));
|
||||
|
||||
@@ -734,6 +737,7 @@ describe('TripMembersModal', () => {
|
||||
http.delete('/api/trips/1/invite-link', () => HttpResponse.json({}, { status: 500 })),
|
||||
);
|
||||
render(<TripMembersModal {...defaultProps} />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Sharing' }));
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /Disable/i }));
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check, UserRound, Pencil, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -181,7 +182,7 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={t('trip.settings.title')} size="3xl">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: canManageShare ? '1fr 1fr' : '1fr', gap: 24, fontFamily: "var(--font-system)" }} className="share-modal-grid">
|
||||
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||
|
||||
@@ -402,6 +403,8 @@ function TripMembersSheet({ isOpen, onClose, tripId, tripTitle, onMembersChanged
|
||||
<TripInviteLinkSection tripId={tripId} t={t} />
|
||||
</div>}
|
||||
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
|
||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useId, useState, type ReactNode } from 'react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { Check, Copy, Crown, Link2, LogOut, Pencil, Plus, Settings, Trash2, UserMinus, UserPlus, UserRound, Users } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { DialogButton, DialogHeader, DialogSection, DialogShell, DialogTile, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell'
|
||||
import { AddRowButton, INPUT, LABEL, PANEL } from '../shared/dialogParts'
|
||||
import { LinkedJournalIcon } from './LinkedJournalIcon'
|
||||
import { TripJournalLinkSection } from './TripJournalLinkSection'
|
||||
import { TripMemberAvatar } from './TripMemberAvatar'
|
||||
import { useTripJournalSectionVisible } from './useTripJournalLink'
|
||||
import { SHARE_OPTIONS, SHARE_SECTIONS, useShareLink, useTripInviteLink, useTripMembers, type TripMembersState } from './useTripShare'
|
||||
|
||||
interface TripShareDialogProps {
|
||||
@@ -29,18 +32,38 @@ const COUNT = 'rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bo
|
||||
const ROWS = 'flex flex-col gap-0.5 rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5'
|
||||
const ROW = 'flex min-h-[48px] items-center gap-3 rounded-[10px] px-2.5 py-1.5 hover:bg-surface-card'
|
||||
const BADGE = 'inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-semibold'
|
||||
/** A fixed height on a wide screen, so the dialog does not jump when another area is picked. */
|
||||
const BODY = 'flex min-h-0 flex-1 max-md:flex-col md:h-[min(640px,calc(100dvh-240px))]'
|
||||
const NAV = 'flex flex-none gap-1 border-edge-faint bg-surface-secondary p-3 md:w-[236px] md:flex-col md:border-e max-md:overflow-x-auto max-md:border-b'
|
||||
const DANGER_BUTTON = 'inline-flex items-center gap-1.5 rounded-[10px] bg-surface-card px-3.5 py-2 font-medium text-danger shadow-sm ring-1 ring-edge-faint hover:bg-danger-soft disabled:cursor-default disabled:opacity-50'
|
||||
|
||||
/** The desktop dialog behind Share: who is on the trip, its guests, and the two links that let others in. */
|
||||
/**
|
||||
* The trip's Settings on the desktop: who is on the trip, its guests, the two
|
||||
* links that let others in, and the journal the trip shows.
|
||||
*/
|
||||
export default function TripShareDialog(props: TripShareDialogProps) {
|
||||
return props.isOpen ? <OpenDialog {...props} /> : null
|
||||
}
|
||||
|
||||
type Area = 'people' | 'sharing' | 'journal'
|
||||
|
||||
/** The areas of the dialog, each with its icon, its name and the line under the name. */
|
||||
const AREAS: { id: Area; icon: (on: boolean) => ReactNode; labelKey: string; hintKey: string }[] = [
|
||||
{ id: 'people', icon: () => <Users size={16} strokeWidth={2} />, labelKey: 'trip.settings.people', hintKey: 'trip.settings.peopleHint' },
|
||||
{ id: 'sharing', icon: () => <Link2 size={16} strokeWidth={2} />, labelKey: 'trip.settings.sharing', hintKey: 'trip.settings.sharingHint' },
|
||||
{ id: 'journal', icon: () => <LinkedJournalIcon size={16} />, labelKey: 'trip.journal.title', hintKey: 'trip.journal.hint' },
|
||||
]
|
||||
|
||||
function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const labelId = useId()
|
||||
const m = useTripMembers({ isOpen: true, tripId, onClose, onMembersChanged })
|
||||
const [ask, setAsk] = useState<Ask | null>(null)
|
||||
const [area, setArea] = useState<Area>('people')
|
||||
const journalSection = useTripJournalSectionVisible(tripId)
|
||||
const areas = AREAS.filter(a => a.id === 'people' || (a.id === 'sharing' ? m.canManageShare : journalSection))
|
||||
const current = areas.find(a => a.id === area) ?? areas[0]
|
||||
const panelOf = (id: Area) => areas.find(a => a.id === id)
|
||||
const persons = `${m.realMembers.length} ${t('members.persons', { count: m.realMembers.length })}`
|
||||
|
||||
return (
|
||||
@@ -48,15 +71,16 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
<DialogShell
|
||||
onClose={onClose}
|
||||
labelledBy={labelId}
|
||||
width={m.canManageShare ? 'wide' : 'detail'}
|
||||
width={areas.length > 1 ? 'wide' : 'detail'}
|
||||
blocked={!!ask}
|
||||
bodyClassName={BODY}
|
||||
header={(
|
||||
<DialogHeader
|
||||
tile={<DialogTile><Users size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tile={<DialogTile><Settings size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={onClose}
|
||||
eyebrow={t('members.shareTrip')}
|
||||
eyebrow={t('trip.settings.title')}
|
||||
title={tripTitle}
|
||||
pills={m.loading ? undefined : (
|
||||
<>
|
||||
@@ -67,17 +91,24 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className={m.canManageShare ? 'grid grid-cols-2 items-start gap-6 max-md:grid-cols-1' : ''}>
|
||||
<div className="flex flex-col gap-5">
|
||||
{areas.length > 1 && <AreaNav areas={areas} current={current.id} onPick={setArea} />}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-7 pb-7 pt-6 max-sm:px-5">
|
||||
{/* Every area stays mounted, so its links and lists are loaded by the time it is picked. */}
|
||||
<AreaPanel area={panelOf('people')} shown={current.id === 'people'} titled={areas.length > 1}>
|
||||
{m.canManageMembers && <InviteUser m={m} />}
|
||||
<MembersList m={m} onAsk={setAsk} />
|
||||
{(m.isCurrentOwner || m.guests.length > 0) && <GuestsList m={m} onAsk={setAsk} />}
|
||||
</div>
|
||||
</AreaPanel>
|
||||
{m.canManageShare && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<AreaPanel area={panelOf('sharing')} shown={current.id === 'sharing'} titled>
|
||||
<PublicLink tripId={tripId} />
|
||||
<InviteLink tripId={tripId} />
|
||||
</div>
|
||||
</AreaPanel>
|
||||
)}
|
||||
{journalSection && (
|
||||
<AreaPanel area={panelOf('journal')} shown={current.id === 'journal'} titled>
|
||||
<TripJournalLinkSection tripId={tripId} framed={false} />
|
||||
</AreaPanel>
|
||||
)}
|
||||
</div>
|
||||
</DialogShell>
|
||||
@@ -93,6 +124,45 @@ function OpenDialog({ onClose, tripId, tripTitle, onMembersChanged }: TripShareD
|
||||
)
|
||||
}
|
||||
|
||||
/** The areas down the side on a wide screen, a row of pills above the content on a narrow one. */
|
||||
function AreaNav({ areas, current, onPick }: { areas: typeof AREAS; current: Area; onPick: (area: Area) => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<nav aria-label={t('trip.settings.title')} className={NAV}>
|
||||
{areas.map(a => {
|
||||
const on = a.id === current
|
||||
return (
|
||||
<button key={a.id} type="button" aria-current={on ? 'page' : undefined} aria-label={t(a.labelKey)} onClick={() => onPick(a.id)}
|
||||
className={`flex flex-none items-center gap-2.5 rounded-[12px] px-2.5 py-2 text-start transition-colors ${on ? 'bg-surface-card text-content shadow-sm ring-1 ring-edge-faint' : 'text-content-muted hover:bg-surface-hover hover:text-content'}`}>
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-[9px] ${on ? 'bg-accent text-accent-text' : 'bg-surface-tertiary'}`}>{a.icon(on)}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold" style={fs(13, 'body')}>{t(a.labelKey)}</span>
|
||||
<span className="block truncate text-content-faint max-md:hidden" style={fs(11)}>{t(a.hintKey)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/** One area: its name and what it is for, then its parts. Hidden rather than unmounted while another one shows. */
|
||||
function AreaPanel({ area, shown, titled, children }: { area: (typeof AREAS)[number] | undefined; shown: boolean; titled: boolean; children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
// The class, not the hidden attribute alone: flex would win over the attribute's display:none.
|
||||
<section hidden={!shown} className={shown ? 'flex flex-col gap-5' : 'hidden'}>
|
||||
{titled && area && (
|
||||
<header>
|
||||
<h3 className="m-0 font-semibold text-content" style={fs(17, 'subtitle')}>{t(area.labelKey)}</h3>
|
||||
<p className="m-0 mt-1 max-w-[560px] leading-normal text-content-muted" style={fs(12.5, 'body')}>{t(area.hintKey)}</p>
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteUser({ m }: { m: TripMembersState }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -291,7 +361,7 @@ function LinkLine({ url, copied, onCopy }: { url: string; copied: boolean; onCop
|
||||
)
|
||||
}
|
||||
|
||||
function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
export function LinkCard({ icon, title, hint, children }: { icon: ReactNode; title: string; hint: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={PANEL}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// FE-TJL-HOOK-001 to FE-TJL-HOOK-014
|
||||
|
||||
/**
|
||||
* The trip's linked journal as the planner and the Settings dialog read it.
|
||||
*
|
||||
* `useTripJournal` decides whether the trip has a Journal tab at all: only with
|
||||
* the Journey addon on, only once the server answered, and live on every
|
||||
* `trip:journal:changed`. `useTripJournalLink` is the Settings section: pick one
|
||||
* of your own journals, everybody of the trip ticked as a reader by default,
|
||||
* the people who would get in for the first time named before the save.
|
||||
*/
|
||||
import type { TripJournalCandidate, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { act, renderHook, waitFor } from '../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../tests/helpers/store';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode';
|
||||
import { useTripJournal, useTripJournalLink, useTripJournalSectionVisible } from './useTripJournalLink';
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() };
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
const listeners = new Set<(e: Record<string, unknown>) => void>();
|
||||
vi.mock('../../api/websocket', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../api/websocket')>()),
|
||||
addListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.add(fn);
|
||||
},
|
||||
removeListener: (fn: (e: Record<string, unknown>) => void) => {
|
||||
listeners.delete(fn);
|
||||
},
|
||||
}));
|
||||
|
||||
const TRIP = 4;
|
||||
const ME = 1;
|
||||
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: null,
|
||||
owner_id: ME,
|
||||
linked_by: ME,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [{ user_id: 2, role: 'editor', username: 'lena', avatar_url: null }],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const CANDIDATES: TripJournalCandidate[] = [
|
||||
{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: 'editor' },
|
||||
{ user_id: 3, username: 'tom', avatar_url: null, is_trip_owner: false, access: null },
|
||||
];
|
||||
|
||||
let gets = 0;
|
||||
let answer: TripJournalLinkResponse = EMPTY;
|
||||
const writes: { method: string; body: unknown }[] = [];
|
||||
|
||||
function routes({ writeStatus = 200 } = {}) {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => {
|
||||
gets += 1;
|
||||
return HttpResponse.json(answer);
|
||||
}),
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ candidates: CANDIDATES })),
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({
|
||||
journeys: [
|
||||
{ id: 9, user_id: ME, title: 'Japan' },
|
||||
{ id: 10, user_id: 99, title: 'Not mine' },
|
||||
],
|
||||
})
|
||||
),
|
||||
http.put('/api/trips/:tripId/journal', async ({ request }) => {
|
||||
writes.push({ method: 'PUT', body: await request.json() });
|
||||
return writeStatus === 200
|
||||
? HttpResponse.json(LINKED)
|
||||
: HttpResponse.json({ error: 'no' }, { status: writeStatus });
|
||||
}),
|
||||
http.patch('/api/trips/:tripId/journal/members', async ({ request }) => {
|
||||
writes.push({ method: 'PATCH', body: await request.json() });
|
||||
return HttpResponse.json(LINKED);
|
||||
}),
|
||||
http.delete('/api/trips/:tripId/journal', () => {
|
||||
writes.push({ method: 'DELETE', body: null });
|
||||
return HttpResponse.json(EMPTY);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function journeyAddon(enabled: boolean): void {
|
||||
useAddonStore.setState({
|
||||
loaded: true,
|
||||
addons: enabled ? [{ id: 'journey', name: 'Journey', type: 'global', icon: 'BookOpen', enabled: true }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gets = 0;
|
||||
answer = EMPTY;
|
||||
writes.length = 0;
|
||||
listeners.clear();
|
||||
vi.clearAllMocks();
|
||||
useTripJournalLinkStore.setState({ byTrip: {} });
|
||||
seedStore(useAuthStore, { user: buildUser({ id: ME }) });
|
||||
journeyAddon(true);
|
||||
routes();
|
||||
});
|
||||
|
||||
afterEach(() => _resetNetworkMode());
|
||||
|
||||
describe('useTripJournal', () => {
|
||||
it('FE-TJL-HOOK-001: with the Journey addon off it asks nothing and there is no journal', async () => {
|
||||
journeyAddon(false);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
expect(result.current.settled).toBe(true);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-002: a linked journal open to this person is the one the tab opens', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(false);
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
expect(result.current.settled).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-003: a link that leaves this person out gives no tab', async () => {
|
||||
answer = { ...LINKED, link: LINKED.link && { ...LINKED.link, my_role: null } };
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
expect(result.current.openJournalId).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-004: trip:journal:changed asks again', async () => {
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
await waitFor(() => expect(gets).toBe(1));
|
||||
answer = LINKED;
|
||||
act(() => {
|
||||
for (const fn of [...listeners]) fn({ type: 'trip:journal:changed' });
|
||||
});
|
||||
await waitFor(() => expect(result.current.openJournalId).toBe(9));
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-005: offline it asks nothing and is settled', () => {
|
||||
setForcedOffline(true);
|
||||
const { result } = renderHook(() => useTripJournal(TRIP));
|
||||
expect(result.current.settled).toBe(true);
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-006: without a trip id it asks nothing', async () => {
|
||||
renderHook(() => useTripJournal(null));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(gets).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-007: the Settings section shows for someone who may link, and not for someone who may not', async () => {
|
||||
const { result, rerender } = renderHook(() => useTripJournalSectionVisible(TRIP));
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
act(() => useTripJournalLinkStore.setState({ byTrip: { [TRIP]: { ...EMPTY, can_link: false } } }));
|
||||
rerender();
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTripJournalLink', () => {
|
||||
it('FE-TJL-HOOK-008: picking offers only my journals and ticks everybody as a reader', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(result.current.journals).toEqual([{ id: 9, title: 'Japan', subtitle: null }]));
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'viewer', 3: 'viewer' });
|
||||
// lena is in already; tom would be let in for the first time.
|
||||
expect(result.current.newlyGranted.map((c) => c.username)).toEqual(['tom']);
|
||||
expect(result.current.canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-009: saving a pick links with the chosen roles and goes back to the view', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
act(() => {
|
||||
result.current.setRole(2, 'editor');
|
||||
result.current.setRole(3, null);
|
||||
});
|
||||
expect(result.current.newlyGranted).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'PUT', body: { journey_id: 9, members: [{ user_id: 2, role: 'editor' }] } }]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.linked');
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.link?.title).toBe('Japan');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-010: editing members starts from who the link holds and saves them', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.link).not.toBeNull());
|
||||
act(() => result.current.editMembers());
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
expect(result.current.choice).toEqual({ 2: 'editor', 3: null });
|
||||
|
||||
act(() => result.current.setAll('viewer'));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(writes).toEqual([
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
members: [
|
||||
{ user_id: 2, role: 'viewer' },
|
||||
{ user_id: 3, role: 'viewer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.membersSaved');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-011: unlinking takes the journal off the trip', async () => {
|
||||
answer = LINKED;
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.canUnlink).toBe(true));
|
||||
await act(async () => {
|
||||
await result.current.unlink();
|
||||
});
|
||||
expect(writes).toEqual([{ method: 'DELETE', body: null }]);
|
||||
expect(result.current.link).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith('trip.journal.unlinked');
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-012: a refused save says it was not allowed and keeps the form open', async () => {
|
||||
routes({ writeStatus: 403 });
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(2));
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith('trip.journal.errorForbidden');
|
||||
expect(result.current.mode).toBe('pick');
|
||||
expect(result.current.busy).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-013: offline, picking says a connection is needed', async () => {
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
setForcedOffline(true);
|
||||
act(() => result.current.startPick());
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorOffline'));
|
||||
expect(result.current.journals).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TJL-HOOK-014: a failing candidate list is reported and cancel goes back to the view', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal/candidates', () => HttpResponse.json({ error: 'x' }, { status: 500 }))
|
||||
);
|
||||
const { result } = renderHook(() => useTripJournalLink(TRIP));
|
||||
await waitFor(() => expect(result.current.visible).toBe(true));
|
||||
act(() => result.current.startPick());
|
||||
act(() => result.current.pickJournal(9));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('trip.journal.errorGeneric'));
|
||||
expect(result.current.candidates).toEqual([]);
|
||||
act(() => result.current.cancel());
|
||||
expect(result.current.mode).toBe('view');
|
||||
expect(result.current.canSave).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkMember, TripJournalLinkRole } from '@trek/shared';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { tripJournalLinkRepo, TripJournalOfflineError, type OwnJournal } from '../../repo/tripJournalLinkRepo';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripJournalLinkStore } from '../../store/tripJournalLinkStore';
|
||||
import { useServerPing } from '../../sync/useServerPing';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** Who a link lets in, as the member list edits it: a role, or left out. */
|
||||
export type JournalMemberChoice = Record<number, TripJournalLinkRole | null>;
|
||||
|
||||
/** One empty list for "the link holds nobody", so the member effect does not rerun on every render. */
|
||||
const NOBODY: TripJournalLinkMember[] = [];
|
||||
|
||||
/** The status of a failed request, when the server gave one. */
|
||||
function statusOf(err: unknown): number | undefined {
|
||||
return (err as { response?: { status?: number } })?.response?.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's linked journal as everybody on the trip reads it: answered once per
|
||||
* trip from the store, asked again on mount, when the connection returns and on
|
||||
* every `trip:journal:changed`. With the Journey addon off there is no journal.
|
||||
*/
|
||||
export function useTripJournal(tripId: number | null | undefined) {
|
||||
const addonsLoaded = useAddonStore((s) => s.loaded);
|
||||
const journeyOn = useAddonStore((s) => s.isEnabled('journey'));
|
||||
const answer = useTripJournalLinkStore((s) => (tripId != null ? s.byTrip[String(tripId)] : undefined));
|
||||
const refresh = useTripJournalLinkStore((s) => s.refresh);
|
||||
const { offline } = useNetworkMode();
|
||||
const active = journeyOn && tripId != null && !offline;
|
||||
const reload = useCallback(async () => {
|
||||
if (tripId != null) await refresh(tripId);
|
||||
}, [tripId, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void reload();
|
||||
}, [active, reload]);
|
||||
useServerPing('trip:journal:changed', active, reload);
|
||||
|
||||
const link = journeyOn ? (answer?.link ?? null) : null;
|
||||
return {
|
||||
journeyOn,
|
||||
answer: journeyOn ? answer : undefined,
|
||||
link,
|
||||
/** The journal the trip's Journal tab opens: linked, and open to this person. */
|
||||
openJournalId: link?.my_role ? link.journey_id : null,
|
||||
/** Whether the answer is in, or none is coming (addon off, offline): the planner may drop the tab then. */
|
||||
settled: addonsLoaded && (!journeyOn || offline || answer !== undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the trip's Settings carry a Journal section for this person, for laying the dialog out. */
|
||||
export function useTripJournalSectionVisible(tripId: number): boolean {
|
||||
const { journeyOn, answer } = useTripJournal(tripId);
|
||||
return journeyOn && !!answer && (answer.can_link || !!answer.link);
|
||||
}
|
||||
|
||||
/** The toast for a failed journal request: offline, refused, or anything else. */
|
||||
function useJournalFailure() {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
return useCallback(
|
||||
(err: unknown) => {
|
||||
if (err instanceof TripJournalOfflineError) toast.error(t('trip.journal.errorOffline'));
|
||||
else if (statusOf(err) === 403) toast.error(t('trip.journal.errorForbidden'));
|
||||
else toast.error(t('trip.journal.errorGeneric'));
|
||||
},
|
||||
[t, toast]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The people of the trip for one journal, each with the role the save would
|
||||
* give them. `current` is who the link holds now, to start from; without it
|
||||
* everybody starts as a reader, the default the section promises.
|
||||
*
|
||||
* What loaded is kept with the journal it belongs to, so picking another one
|
||||
* shows nothing until its own people are in, never the last journal's.
|
||||
*/
|
||||
function useJournalMemberChoice(
|
||||
tripId: number,
|
||||
journeyId: number | null,
|
||||
current: TripJournalLinkMember[] | null,
|
||||
fail: (err: unknown) => void
|
||||
) {
|
||||
const [loaded, setLoaded] = useState<{ journeyId: number; list: TripJournalCandidate[] } | null>(null);
|
||||
const [choice, setChoice] = useState<JournalMemberChoice>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (journeyId == null) return;
|
||||
let cancelled = false;
|
||||
tripJournalLinkRepo
|
||||
.candidates(tripId, journeyId)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
const held = new Map((current ?? []).map((m) => [m.user_id, m.role]));
|
||||
setLoaded({ journeyId, list });
|
||||
setChoice(Object.fromEntries(list.map((c) => [c.user_id, current ? (held.get(c.user_id) ?? null) : 'viewer'])));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
fail(err);
|
||||
setLoaded({ journeyId, list: [] });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [tripId, journeyId, current, fail]);
|
||||
|
||||
const candidates = journeyId != null && loaded?.journeyId === journeyId ? loaded.list : null;
|
||||
|
||||
const setRole = useCallback((memberId: number, role: TripJournalLinkRole | null) => {
|
||||
setChoice((prev) => ({ ...prev, [memberId]: role }));
|
||||
}, []);
|
||||
|
||||
const setAll = useCallback(
|
||||
(role: TripJournalLinkRole | null) =>
|
||||
setChoice(Object.fromEntries((candidates ?? []).map((c) => [c.user_id, role]))),
|
||||
[candidates]
|
||||
);
|
||||
|
||||
const chosenMembers = useMemo(
|
||||
() => Object.entries(choice).flatMap(([id, role]) => (role ? [{ user_id: Number(id), role }] : [])),
|
||||
[choice]
|
||||
);
|
||||
|
||||
/** The people the save would let into the journal for the first time; the warning names them. */
|
||||
const newlyGranted = useMemo(
|
||||
() => (candidates ?? []).filter((c) => c.access === null && choice[c.user_id]),
|
||||
[candidates, choice]
|
||||
);
|
||||
|
||||
return { candidates, choice, setRole, setAll, chosenMembers, newlyGranted };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Journal section of the trip's Settings: link one of your journals, choose
|
||||
* who it is open to and as what, change it, or take it off. One hook for the
|
||||
* desktop dialog and the phone sheet, so both offer and warn about the same thing.
|
||||
*
|
||||
* Picking a journal asks the server who of the trip already has access to it;
|
||||
* whoever would be let in for the first time is named before anything is saved,
|
||||
* because they will see the journal in their own Journey list from then on.
|
||||
*/
|
||||
export function useTripJournalLink(tripId: number) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const fail = useJournalFailure();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null);
|
||||
const { journeyOn, answer, link } = useTripJournal(tripId);
|
||||
const store = useTripJournalLinkStore.getState;
|
||||
|
||||
/** 'view' shows the link; 'pick' chooses a journal; 'members' changes who it is open to. */
|
||||
const [mode, setMode] = useState<'view' | 'pick' | 'members'>('view');
|
||||
const [journals, setJournals] = useState<OwnJournal[] | null>(null);
|
||||
const [pickedId, setPickedId] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const journeyId = mode === 'members' ? (link?.journey_id ?? null) : mode === 'pick' ? pickedId : null;
|
||||
// Who the link holds, to start from: when editing it, or when re-picking the
|
||||
// journal it already shows with people on it.
|
||||
const held = link?.members ?? NOBODY;
|
||||
const current = mode === 'members' || (link?.journey_id === pickedId && held.length > 0) ? held : null;
|
||||
const members = useJournalMemberChoice(tripId, journeyId, current, fail);
|
||||
const { chosenMembers } = members;
|
||||
|
||||
const startPick = useCallback(() => {
|
||||
setMode('pick');
|
||||
setPickedId(null);
|
||||
if (userId == null) return;
|
||||
tripJournalLinkRepo
|
||||
.ownJournals(userId)
|
||||
.then(setJournals)
|
||||
.catch((err: unknown) => {
|
||||
fail(err);
|
||||
setJournals([]);
|
||||
});
|
||||
}, [userId, fail]);
|
||||
|
||||
const editMembers = useCallback(() => setMode('members'), []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setMode('view');
|
||||
setPickedId(null);
|
||||
}, []);
|
||||
|
||||
const run = useCallback(
|
||||
async (write: () => Promise<void>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await write();
|
||||
toast.success(done);
|
||||
cancel();
|
||||
} catch (err: unknown) {
|
||||
fail(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cancel, fail, toast]
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (mode === 'pick' && pickedId != null) {
|
||||
await run(() => store().link(tripId, pickedId, chosenMembers), t('trip.journal.linked'));
|
||||
} else if (mode === 'members') {
|
||||
await run(() => store().setMembers(tripId, chosenMembers), t('trip.journal.membersSaved'));
|
||||
}
|
||||
}, [mode, pickedId, run, store, tripId, chosenMembers, t]);
|
||||
|
||||
const unlink = useCallback(async () => {
|
||||
await run(() => store().unlink(tripId), t('trip.journal.unlinked'));
|
||||
}, [run, store, tripId, t]);
|
||||
|
||||
return {
|
||||
/** The section shows at all: the addon is on and this person may link, or the trip has a journal they see. */
|
||||
visible: journeyOn && !!answer && (answer.can_link || !!answer.link),
|
||||
link,
|
||||
canLink: !!answer?.can_link,
|
||||
canUnlink: !!answer?.can_unlink,
|
||||
canEditMembers: !!answer?.can_edit_members,
|
||||
mode,
|
||||
startPick,
|
||||
cancel,
|
||||
editMembers,
|
||||
journals,
|
||||
pickedId,
|
||||
pickJournal: setPickedId,
|
||||
candidates: members.candidates,
|
||||
choice: members.choice,
|
||||
setRole: members.setRole,
|
||||
setAll: members.setAll,
|
||||
newlyGranted: members.newlyGranted,
|
||||
canSave: !busy && (mode === 'members' || (mode === 'pick' && pickedId != null)),
|
||||
busy,
|
||||
save,
|
||||
unlink,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const TRIP_TAB_IDS = [
|
||||
'finanzplan',
|
||||
'dateien',
|
||||
'collab',
|
||||
'journal',
|
||||
] as const
|
||||
|
||||
export type TripTabId = (typeof TRIP_TAB_IDS)[number]
|
||||
@@ -40,6 +41,7 @@ export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
|
||||
finanzplan: 'trip.tabs.budget',
|
||||
dateien: 'trip.tabs.files',
|
||||
collab: 'admin.addons.catalog.collab.name',
|
||||
journal: 'trip.tabs.journal',
|
||||
}
|
||||
|
||||
export const isTripTabId = (value: unknown): value is TripTabId =>
|
||||
|
||||
@@ -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,14 +1,11 @@
|
||||
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'
|
||||
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import type { JourneyDetail } from '../../../store/journeyStore'
|
||||
import type { JourneyDetail, JourneyTripAccess } from '../../../store/journeyStore'
|
||||
import { useJourneySettings, useJourneyTripLinking } from '../../../components/Journey/useJourneySettings'
|
||||
import { useJourneyShareLink } from '../../../components/Journey/useJourneyShareLink'
|
||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
@@ -237,6 +234,8 @@ export default function MJourneySettingsSheet({
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
|
||||
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
|
||||
|
||||
{/* Public share */}
|
||||
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
|
||||
{!shareLink ? (
|
||||
@@ -333,3 +332,27 @@ export default function MJourneySettingsSheet({
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
|
||||
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
|
||||
const { t } = useTranslation()
|
||||
if (!access?.length) return null
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: its bars measure from --m-safe-top.
|
||||
case 'journal':
|
||||
return planner.journalId == null ? null : (
|
||||
<div className="absolute inset-0 overflow-hidden [--m-safe-top:calc(env(safe-area-inset-top,0px)+62px)]">
|
||||
<MJourneyDetail journeyId={planner.journalId} embedded />
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return <TabScroller>{null}</TabScroller>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import JourneyDayDawarich from '../components/Journey/JourneyDayDawarich'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { useJourneyStore } from '../store/journeyStore'
|
||||
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
|
||||
import { useJourneyDetail } from './journeyDetail/useJourneyDetail'
|
||||
import { useJourneyDetail, type JourneyDetailOptions } from './journeyDetail/useJourneyDetail'
|
||||
import { createDraftJourneyEntry, pickGradient, groupByDate, formatDate, photoUrl, matchJourneyEntries } from './journeyDetail/JourneyDetailPage.helpers'
|
||||
import { EntryCard, SkeletonCard, CheckinCard } from '../components/Journey/JourneyDetailPageEntryCard'
|
||||
import { GalleryView } from '../components/Journey/JourneyDetailPageGalleryView'
|
||||
@@ -39,7 +39,12 @@ export default function JourneyDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function JourneyDetailPageDesktop() {
|
||||
/**
|
||||
* The journal screen. The journal page renders it for the route's journal; a trip
|
||||
* that links a journal renders the same screen inside its own tab (`embedded`),
|
||||
* so the trip shows the journal with nothing of it built twice.
|
||||
*/
|
||||
export function JourneyDetailPageDesktop({ journeyId, embedded = false }: JourneyDetailOptions = {}) {
|
||||
// Page = wiring container: load + live sync, view state, dialogs, the
|
||||
// scroll-synced map and the map/trip-date derivations live in the hook.
|
||||
const {
|
||||
@@ -62,13 +67,17 @@ function JourneyDetailPageDesktop() {
|
||||
feedEdge, scrollFeedTo,
|
||||
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
|
||||
addPickedProviderPhotos, addEntryProviderPhotos,
|
||||
} = useJourneyDetail()
|
||||
} = useJourneyDetail({ journeyId, embedded })
|
||||
// Inside a trip the trip draws the bar and owns the height; the journal fills
|
||||
// the box it is given and starts at its top.
|
||||
const frame = embedded ? 'h-full bg-zinc-50 dark:bg-zinc-950' : 'min-h-screen bg-zinc-50 dark:bg-zinc-950'
|
||||
const below = embedded ? undefined : { paddingTop: 'var(--nav-h, 0px)' }
|
||||
|
||||
if (loading || !current) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className="flex justify-center py-20">
|
||||
<div className={frame}>
|
||||
{!embedded && <Navbar />}
|
||||
<div style={below} className="flex justify-center py-20">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,8 +175,8 @@ function JourneyDetailPageDesktop() {
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
<Navbar />
|
||||
<div className={frame}>
|
||||
{!embedded && <Navbar />}
|
||||
|
||||
{/* Mobile combined map+timeline (Polarsteps-style) — renders as fullscreen overlay */}
|
||||
{showMobileCombined && (
|
||||
@@ -206,13 +215,15 @@ function JourneyDetailPageDesktop() {
|
||||
className="fixed inset-x-0 z-30 flex items-start justify-between gap-2 px-4"
|
||||
style={{ top: 'calc(var(--nav-h, 56px) + 12px)' }}
|
||||
>
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className={barButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className={barButton}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0 flex justify-center">
|
||||
<div className="flex bg-surface-elevated backdrop-blur-lg border border-edge rounded-lg overflow-hidden shadow-lg">
|
||||
@@ -270,7 +281,7 @@ function JourneyDetailPageDesktop() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h, 0px)' }} className={showMobileCombined ? 'hidden' : ''}>
|
||||
<div style={below} className={showMobileCombined ? 'hidden' : embedded ? 'h-full' : ''}>
|
||||
<div
|
||||
className={
|
||||
isMobile
|
||||
@@ -310,13 +321,15 @@ function JourneyDetailPageDesktop() {
|
||||
|
||||
<div className="relative z-[3] flex items-center justify-between mb-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
{!embedded && (
|
||||
<button type="button"
|
||||
onClick={() => navigate('/journey')}
|
||||
aria-label={t('journey.detail.backToJourney')}
|
||||
className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
)}
|
||||
{/* Status badge — keep completed/upcoming/draft/archived, but drop live + synced-with-trips per UX trim */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{lifecycle !== 'live' && lifecycle !== 'archived' && (
|
||||
@@ -821,7 +834,7 @@ function JourneyDetailPageDesktop() {
|
||||
|
||||
{/* TREK Studio. It portals itself over the page, so this journey keeps
|
||||
rendering underneath and shows through the panel's margin. */}
|
||||
<Outlet />
|
||||
{!embedded && <Outlet />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import { latestReservation } from './tripPlanner/transportEditorOpeners'
|
||||
import {
|
||||
ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
|
||||
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal,
|
||||
TourWorkspaceRail, TourPlannerToursRail, TransportModal,
|
||||
TourWorkspaceRail, TourPlannerToursRail, TransportModal, TripJournalPanel,
|
||||
} from './tripPlanner/plannerLazy'
|
||||
import { LazyPanel } from './tripPlanner/LazyPanel'
|
||||
import { ListsContainer } from './tripPlanner/ListsContainer'
|
||||
@@ -70,7 +70,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
enabledAddons, collabFeatures, journalId, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewActive, tripOverview, toggleOverview, overviewShown,
|
||||
dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates,
|
||||
@@ -242,7 +242,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
|
||||
<HelpAnchor id={helpId} />
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
|
||||
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onSettings={() => setShowMembersModal(true)} />
|
||||
|
||||
<div className="bg-surface-elevated border-b border-edge-faint" style={{
|
||||
position: 'fixed', top: 'var(--nav-h)', insetInline: 0, zIndex: 40,
|
||||
@@ -792,7 +792,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
</LazyPanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'journal' && <LazyPanel id="journal"><TripJournalPanel journeyId={journalId} /></LazyPanel>}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div style={{ position: 'absolute', top: 0, insetInline: 0, bottom: 'var(--bottom-nav-h)', overflow: 'hidden' }}>
|
||||
|
||||
@@ -657,14 +657,14 @@ describe('TripPlannerPage — shell', () => {
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / share', () => {
|
||||
it('FE-PAGE-TPW-005: the navbar gets the trip title and drives back / settings', () => {
|
||||
renderPage()
|
||||
|
||||
expect(props('navbar').tripTitle).toBe('Kyoto')
|
||||
act(() => { props('navbar').onBack() })
|
||||
expect(hookState.navigate).toHaveBeenCalledWith('/dashboard')
|
||||
|
||||
act(() => { props('navbar').onShare() })
|
||||
act(() => { props('navbar').onSettings() })
|
||||
expect(hookState.setShowMembersModal).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -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,20 @@
|
||||
import { JourneyDetailPageDesktop } from '../JourneyDetailPage';
|
||||
|
||||
/**
|
||||
* The trip's Journal tab on the desktop: the Journey addon's own journal view,
|
||||
* not a copy of it, so the journal looks and works the same in both places and
|
||||
* stays live through the journey's own sync.
|
||||
*
|
||||
* The view's fixed bars measure themselves from --nav-h, so the wrapper sets it
|
||||
* to the navbar plus the trip's 44 px tab bar, and they sit below both.
|
||||
*/
|
||||
const BELOW_TRIP_TABS = '[--nav-h:44px] md:[--nav-h:calc(64px+env(safe-area-inset-top,0px)+44px)]';
|
||||
|
||||
export default function TripJournalPanel({ journeyId }: { journeyId: number | null }) {
|
||||
if (journeyId == null) return null;
|
||||
return (
|
||||
<div className={`${BELOW_TRIP_TABS} absolute inset-0 overflow-y-auto`}>
|
||||
<JourneyDetailPageDesktop journeyId={journeyId} embedded />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ vi.mock('../../components/Tours/planner/TourPlannerPanels', () => ({
|
||||
TourPlannerToursRail: label('tour planner tours rail'),
|
||||
}))
|
||||
vi.mock('../../components/Planner/TransportModal', () => ({ TransportModal: label('transport modal') }))
|
||||
vi.mock('./TripJournalPanel', () => ({ default: label('trip journal panel') }))
|
||||
|
||||
const cases: Array<[keyof typeof lazy, string]> = [
|
||||
['ReservationsPanel', 'reservations panel'],
|
||||
@@ -43,6 +44,7 @@ const cases: Array<[keyof typeof lazy, string]> = [
|
||||
['TourWorkspaceRail', 'tour workspace rail'],
|
||||
['TourPlannerToursRail', 'tour planner tours rail'],
|
||||
['TransportModal', 'transport modal'],
|
||||
['TripJournalPanel', 'trip journal panel'],
|
||||
]
|
||||
|
||||
function renderLazy(name: keyof typeof lazy) {
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ExpenseModal = lazyWithRetry(() =>
|
||||
import('../../components/Budget/CostsPanel').then(m => ({ default: m.ExpenseModal }))
|
||||
)
|
||||
export const CollabPanel = lazyWithRetry(() => import('../../components/Collab/CollabPanel'))
|
||||
// The Journey addon's journal view, which only a trip with a linked journal opens.
|
||||
export const TripJournalPanel = lazyWithRetry(() => import('./TripJournalPanel'))
|
||||
export const RoadtripSidebar = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripSidebar'))
|
||||
export const RoadtripCorridorPanel = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripCorridorPanel'))
|
||||
export const RoadtripLimitsCard = lazyWithRetry(() => import('../../components/Roadtrip/RoadtripLimitsCard'))
|
||||
|
||||
@@ -24,6 +24,8 @@ import { isServiceStopType } from '../../components/Roadtrip/roadtripModel'
|
||||
import { inspectorStay } from '../../components/Roadtrip/stayReading'
|
||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||
import { useTourPlaceIds } from '../../hooks/useTourPlaceIds'
|
||||
import { LinkedJournalIcon } from '../../components/Trips/LinkedJournalIcon'
|
||||
import { useTripJournal } from '../../components/Trips/useTripJournalLink'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||
import { useIsTouch } from '../../hooks/useIsTouch'
|
||||
@@ -198,6 +200,7 @@ export function useTripPlanner() {
|
||||
|
||||
// A trip-page plugin may replace core tabs while it's active (its manifest names
|
||||
// them; 'plan' is never replaceable) and may pick where its own tab sits.
|
||||
const journal = useTripJournal(Number.isFinite(tripId) ? tripId : null)
|
||||
const replacedTabs = new Set(tripPagePlugins.flatMap(p => p.tripPage?.replaces ?? []))
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
|
||||
@@ -211,6 +214,8 @@ export function useTripPlanner() {
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: t(TRIP_TAB_LABEL_KEYS.collab), icon: Users }] : []),
|
||||
// A journal from the Journey addon tied to this trip, for whoever the link lets in.
|
||||
...(journal.openJournalId != null ? [{ id: 'journal', label: t('trip.tabs.journal'), icon: LinkedJournalIcon }] : []),
|
||||
].filter(tab => tab.id === 'plan' || !replacedTabs.has(tab.id))
|
||||
// Positioned plugin tabs splice in ascending order so two positions stay stable;
|
||||
// the rest append, exactly as before this capability existed.
|
||||
@@ -237,12 +242,14 @@ export function useTripPlanner() {
|
||||
// Same for the addon-owned tabs: until the feed answers, enabledAddons is a
|
||||
// guess, and evicting on a guess would drop a legitimately requested tab.
|
||||
if (!addonsLoaded) return
|
||||
// And for the Journal tab, until the link has been asked for.
|
||||
if (activeTab === 'journal' && !journal.settled) return
|
||||
const validTabIds = TRIP_TABS.map(t => t.id)
|
||||
if (!validTabIds.includes(activeTab)) {
|
||||
setActiveTab('plan')
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
|
||||
}
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile])
|
||||
}, [activeTab, enabledAddons, addonsLoaded, tripPluginIds, pluginsLoaded, isMobile, journal.settled, journal.openJournalId])
|
||||
|
||||
const handleTabChange = (rawTabId: string): void => {
|
||||
// A core tab a plugin replaced is gone from the bar, but a programmatic jump
|
||||
@@ -653,7 +660,7 @@ export function useTripPlanner() {
|
||||
trip, days, places, assignments, storedAssignments, packingItems, todoItems, categories, reservations, budgetItems, files,
|
||||
selectedDayId, isLoading, tripActions, can, canUploadFiles,
|
||||
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
|
||||
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
|
||||
enabledAddons, collabFeatures, journalId: journal.openJournalId, tripAccommodations, setTripAccommodations,
|
||||
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
|
||||
overviewShown, toggleOverview, overviewActive, tripOverview,
|
||||
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* FE-TJL-REPO-001 to FE-TJL-REPO-006
|
||||
*
|
||||
* `tripJournalLinkRepo` is online-only on purpose (who a journal is open to is
|
||||
* the server's answer, never a cached guess), so what is pinned is that it
|
||||
* refuses before a request while offline, that every call reaches its route
|
||||
* with the body the contract names, and that the picker only offers journals
|
||||
* the person owns.
|
||||
*/
|
||||
import type { TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
|
||||
import { tripJournalLinkRepo, TripJournalOfflineError } from './tripJournalLinkRepo';
|
||||
|
||||
const NONE: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
|
||||
afterEach(() => _resetNetworkMode());
|
||||
|
||||
describe('tripJournalLinkRepo', () => {
|
||||
it('FE-TJL-REPO-001: refuses every call while offline, before a request leaves', async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/:tripId/journal', () => {
|
||||
calls += 1;
|
||||
return HttpResponse.json(NONE);
|
||||
})
|
||||
);
|
||||
setForcedOffline(true);
|
||||
|
||||
await expect(tripJournalLinkRepo.get(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.candidates(4, 1)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.link(4, 1, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.setMembers(4, [])).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.unlink(4)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
await expect(tripJournalLinkRepo.ownJournals(1)).rejects.toBeInstanceOf(TripJournalOfflineError);
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-002: reads the trip link', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(NONE)));
|
||||
expect(await tripJournalLinkRepo.get(4)).toEqual(NONE);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-003: asks for the candidates of one journal and hands back the list', async () => {
|
||||
let asked: string | null = null;
|
||||
server.use(
|
||||
http.get('/api/trips/4/journal/candidates', ({ request }) => {
|
||||
asked = new URL(request.url).searchParams.get('journey_id');
|
||||
return HttpResponse.json({
|
||||
candidates: [{ user_id: 2, username: 'lena', avatar_url: null, is_trip_owner: false, access: null }],
|
||||
});
|
||||
})
|
||||
);
|
||||
const list = await tripJournalLinkRepo.candidates(4, 9);
|
||||
expect(asked).toBe('9');
|
||||
expect(list.map((c) => c.username)).toEqual(['lena']);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-004: links, sets members and unlinks with the bodies the contract names', async () => {
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
http.put('/api/trips/4/journal', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json(NONE);
|
||||
}),
|
||||
http.patch('/api/trips/4/journal/members', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return HttpResponse.json(NONE);
|
||||
}),
|
||||
http.delete('/api/trips/4/journal', () => HttpResponse.json(NONE))
|
||||
);
|
||||
await tripJournalLinkRepo.link(4, 9, [{ user_id: 2, role: 'viewer' }]);
|
||||
await tripJournalLinkRepo.setMembers(4, [{ user_id: 2, role: 'editor' }]);
|
||||
expect(await tripJournalLinkRepo.unlink(4)).toEqual(NONE);
|
||||
expect(bodies).toEqual([
|
||||
{ journey_id: 9, members: [{ user_id: 2, role: 'viewer' }] },
|
||||
{ members: [{ user_id: 2, role: 'editor' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-005: offers only the journals the person owns', async () => {
|
||||
server.use(
|
||||
http.get('/api/journeys', () =>
|
||||
HttpResponse.json({
|
||||
journeys: [
|
||||
{ id: 1, user_id: 7, title: 'Mine', subtitle: 'Japan' },
|
||||
{ id: 2, user_id: 8, title: 'Theirs' },
|
||||
{ id: 3, user_id: 7, title: 'Also mine' },
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(await tripJournalLinkRepo.ownJournals(7)).toEqual([
|
||||
{ id: 1, title: 'Mine', subtitle: 'Japan' },
|
||||
{ id: 3, title: 'Also mine', subtitle: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-TJL-REPO-006: the offline error says what it needs', () => {
|
||||
const err = new TripJournalOfflineError();
|
||||
expect(err.name).toBe('TripJournalOfflineError');
|
||||
expect(err.message).toMatch(/connection/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { TripJournalCandidate, TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { tripJournalApi } from '../api/tripJournal';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
|
||||
/** A journal the person owns, as the link picker lists it. */
|
||||
export interface OwnJournal {
|
||||
id: number;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journal a trip links, and who it is open to.
|
||||
*
|
||||
* Online-only with no Dexie table, for the reason docSyncRepo gives: every
|
||||
* answer is the server's (who may see the link, who would be let in), and the
|
||||
* journal it opens is online-only too, as the Journey addon is. Writing it
|
||||
* offline would mean granting access nobody could check. The store in front of
|
||||
* this keeps the last answer for the session.
|
||||
*/
|
||||
export class TripJournalOfflineError extends Error {
|
||||
constructor() {
|
||||
super('Linking a journal needs a connection');
|
||||
this.name = 'TripJournalOfflineError';
|
||||
}
|
||||
}
|
||||
|
||||
function requireOnline(): void {
|
||||
if (isEffectivelyOffline()) throw new TripJournalOfflineError();
|
||||
}
|
||||
|
||||
export const tripJournalLinkRepo = {
|
||||
async get(tripId: number): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.get(tripId);
|
||||
},
|
||||
|
||||
async candidates(tripId: number, journeyId: number): Promise<TripJournalCandidate[]> {
|
||||
requireOnline();
|
||||
return (await tripJournalApi.candidates(tripId, journeyId)).candidates;
|
||||
},
|
||||
|
||||
async link(
|
||||
tripId: number,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[]
|
||||
): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.link(tripId, journeyId, members);
|
||||
},
|
||||
|
||||
async setMembers(tripId: number, members: TripJournalLinkMemberRequest[]): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.setMembers(tripId, members);
|
||||
},
|
||||
|
||||
async unlink(tripId: number): Promise<TripJournalLinkResponse> {
|
||||
requireOnline();
|
||||
return tripJournalApi.unlink(tripId);
|
||||
},
|
||||
|
||||
/** The journals `userId` owns, newest first, for the picker. Those they only contribute to are not theirs to link. */
|
||||
async ownJournals(userId: number): Promise<OwnJournal[]> {
|
||||
requireOnline();
|
||||
const { journeys } = await tripJournalApi.journals();
|
||||
return journeys
|
||||
.filter((j) => j.user_id === userId)
|
||||
.map((j) => ({ id: j.id, title: j.title, subtitle: j.subtitle ?? null }));
|
||||
},
|
||||
};
|
||||
@@ -141,11 +141,23 @@ export interface JourneyContributor {
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
/** Someone a trip's linked journal lets in, with the trip they come through (#2683). */
|
||||
export interface JourneyTripAccess {
|
||||
user_id: number
|
||||
role: 'editor' | 'viewer'
|
||||
username: string
|
||||
avatar?: string | null
|
||||
trip_id: number
|
||||
trip_title: string
|
||||
}
|
||||
|
||||
export interface JourneyDetail extends Journey {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
contributors: JourneyContributor[]
|
||||
/** Who the trips' journal links let in besides the contributors, managed in those trips. */
|
||||
trip_access?: JourneyTripAccess[]
|
||||
stats: { entries: number; photos: number; places: number }
|
||||
hide_skeletons?: boolean
|
||||
/** How many suggestions were waved away one at a time, so the settings sheet can offer them back. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* FE-TJL-STORE-001 to FE-TJL-STORE-005
|
||||
*
|
||||
* The store keeps the server's answer per trip so the Journal tab and the
|
||||
* Settings dialog read the same one: a write replaces it with what the server
|
||||
* answered, a 404/403 means there is no journal for this person, and any other
|
||||
* failure leaves the last answer standing rather than flashing the tab away.
|
||||
*/
|
||||
import type { TripJournalLinkResponse } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { server } from '../../tests/helpers/msw/server';
|
||||
import { useTripJournalLinkStore } from './tripJournalLinkStore';
|
||||
|
||||
const LINKED: TripJournalLinkResponse = {
|
||||
link: {
|
||||
journey_id: 9,
|
||||
title: 'Japan',
|
||||
subtitle: null,
|
||||
cover_image: null,
|
||||
owner_id: 1,
|
||||
linked_by: 1,
|
||||
created_at: 1,
|
||||
my_role: 'owner',
|
||||
members: [],
|
||||
},
|
||||
can_link: true,
|
||||
can_unlink: true,
|
||||
can_edit_members: true,
|
||||
};
|
||||
const EMPTY: TripJournalLinkResponse = { link: null, can_link: true, can_unlink: false, can_edit_members: false };
|
||||
|
||||
beforeEach(() => useTripJournalLinkStore.setState({ byTrip: {} }));
|
||||
|
||||
describe('tripJournalLinkStore', () => {
|
||||
it('FE-TJL-STORE-001: refresh keeps the answer under the trip', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json(LINKED)));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-002: a 404 (addon off, trip out of reach) is no journal at all', async () => {
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'gone' }, { status: 404 })));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual({
|
||||
link: null,
|
||||
can_link: false,
|
||||
can_unlink: false,
|
||||
can_edit_members: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-003: any other failure leaves the last answer standing', async () => {
|
||||
useTripJournalLinkStore.setState({ byTrip: { 4: LINKED } });
|
||||
server.use(http.get('/api/trips/4/journal', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
|
||||
await useTripJournalLinkStore.getState().refresh(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-004: link, setMembers and unlink each keep what the server answered', async () => {
|
||||
server.use(
|
||||
http.put('/api/trips/4/journal', () => HttpResponse.json(LINKED)),
|
||||
http.patch('/api/trips/4/journal/members', () => HttpResponse.json({ ...LINKED, can_unlink: false })),
|
||||
http.delete('/api/trips/4/journal', () => HttpResponse.json(EMPTY))
|
||||
);
|
||||
const s = useTripJournalLinkStore.getState();
|
||||
await s.link(4, 9, []);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(LINKED);
|
||||
await s.setMembers(4, []);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']?.can_unlink).toBe(false);
|
||||
await s.unlink(4);
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
|
||||
});
|
||||
|
||||
it('FE-TJL-STORE-005: a failed write rejects for its caller and keeps the last answer', async () => {
|
||||
useTripJournalLinkStore.setState({ byTrip: { 4: EMPTY } });
|
||||
server.use(http.put('/api/trips/4/journal', () => HttpResponse.json({ error: 'no' }, { status: 403 })));
|
||||
await expect(useTripJournalLinkStore.getState().link(4, 9, [])).rejects.toBeTruthy();
|
||||
expect(useTripJournalLinkStore.getState().byTrip['4']).toEqual(EMPTY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TripJournalLinkMemberRequest, TripJournalLinkResponse } from '@trek/shared';
|
||||
import { create } from 'zustand';
|
||||
import { tripJournalLinkRepo } from '../repo/tripJournalLinkRepo';
|
||||
|
||||
/**
|
||||
* The journal each trip links, as this person may see it, remembered for the
|
||||
* session so the trip's Journal tab and its Settings dialog render the last
|
||||
* answer at once. Both read it from here, so the tab appears and goes the moment
|
||||
* the dialog links or unlinks, without a second request.
|
||||
*/
|
||||
interface TripJournalLinkState {
|
||||
/** Per trip id, the server's answer. Missing until asked. */
|
||||
byTrip: Record<string, TripJournalLinkResponse>;
|
||||
refresh: (tripId: number) => Promise<void>;
|
||||
link: (tripId: number, journeyId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
|
||||
setMembers: (tripId: number, members: TripJournalLinkMemberRequest[]) => Promise<void>;
|
||||
unlink: (tripId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The answer for a trip someone cannot reach, or with the Journey addon off: no journal. */
|
||||
const NONE: TripJournalLinkResponse = { link: null, can_link: false, can_unlink: false, can_edit_members: false };
|
||||
|
||||
export const useTripJournalLinkStore = create<TripJournalLinkState>((set) => {
|
||||
const keep = (tripId: number, answer: TripJournalLinkResponse) =>
|
||||
set((s) => ({ byTrip: { ...s.byTrip, [String(tripId)]: answer } }));
|
||||
|
||||
return {
|
||||
byTrip: {},
|
||||
|
||||
refresh: async (tripId) => {
|
||||
try {
|
||||
keep(tripId, await tripJournalLinkRepo.get(tripId));
|
||||
} catch (err) {
|
||||
// 404 is the Journey addon switched off or a trip this person no longer
|
||||
// reaches: both a real "no journal". Anything else, offline included, is
|
||||
// no answer at all, and the last one stands.
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 404 || status === 403) keep(tripId, NONE);
|
||||
}
|
||||
},
|
||||
|
||||
// The writes answer with the link as it now stands, so the tab and the
|
||||
// dialog move together; a failure is the caller's to report.
|
||||
link: async (tripId, journeyId, members) =>
|
||||
keep(tripId, await tripJournalLinkRepo.link(tripId, journeyId, members)),
|
||||
setMembers: async (tripId, members) => keep(tripId, await tripJournalLinkRepo.setMembers(tripId, members)),
|
||||
unlink: async (tripId) => keep(tripId, await tripJournalLinkRepo.unlink(tripId)),
|
||||
};
|
||||
});
|
||||
@@ -59,4 +59,5 @@ export const UNRESET_STORES: Record<string, string> = {
|
||||
useSystemNoticeStore: NOT_YET,
|
||||
useTourFerrataCrossingStore: 'set by the tour map stage; the tests that read it reset it in their own beforeEach',
|
||||
useTourPlaceToolStore: 'reset in the tour tests themselves to initialTourPlaceToolState()',
|
||||
useTripJournalLinkStore: 'the journal link per trip; the tests that read it reset it in their own beforeEach',
|
||||
};
|
||||
|
||||
@@ -89,6 +89,17 @@ describe('MJourneySettingsSheet', () => {
|
||||
expect(screen.getByText('8 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-2683: lists the people a linked trip lets in, and nothing without', async () => {
|
||||
const { unmount } = setup();
|
||||
expect(screen.queryByText('Through trips')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
setup({ trip_access: [{ user_id: 7, role: 'editor', username: 'lena', avatar: null, trip_id: 3, trip_title: 'Tokyo trip' }] });
|
||||
expect(await screen.findByText('Through trips')).toBeInTheDocument();
|
||||
expect(screen.getByText('lena')).toBeInTheDocument();
|
||||
expect(screen.getByText('via Tokyo trip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => {
|
||||
setup({ trips: [] });
|
||||
expect(await screen.findByText('No trips linked')).toBeInTheDocument();
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('MMehrSheet', () => {
|
||||
|
||||
it('FE-MOB-MEHR-009: the action rows open the share, export and edit sheets', () => {
|
||||
const { shell } = renderSheet()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Share Trip' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Trip settings' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Export' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit Trip' }))
|
||||
expect(shell.openSheet).toHaveBeenNthCalledWith(1, 'members')
|
||||
@@ -116,7 +116,7 @@ describe('MMehrSheet', () => {
|
||||
const { planner } = renderSheet({ can: vi.fn(() => false) })
|
||||
expect(planner.can).toHaveBeenCalledWith('trip_edit', planner.trip)
|
||||
expect(screen.queryByRole('button', { name: 'Edit Trip' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Trip settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-011: renders only the action rows when every section sits in the dock', () => {
|
||||
@@ -124,13 +124,13 @@ describe('MMehrSheet', () => {
|
||||
TRIP_TABS: TABS.filter(tab => tab.id !== 'dateien' && tab.id !== 'collab') as TripPlanner['TRIP_TABS'],
|
||||
})
|
||||
expect(screen.queryByText(/files$/)).not.toBeInTheDocument()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
|
||||
expect(rows).not.toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
it('FE-MOB-MEHR-012: separates the action rows from the tile grid when both are present', () => {
|
||||
renderSheet()
|
||||
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
|
||||
const rows = screen.getByRole('button', { name: 'Trip settings' }).parentElement
|
||||
expect(rows).toHaveClass('mt-2')
|
||||
})
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('MMehrSheet', () => {
|
||||
// Five sections still fit beside the More button, so nothing overflows and the
|
||||
// sheet is the action rows alone.
|
||||
expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2')
|
||||
expect(screen.getByRole('button', { name: 'Trip settings' }).parentElement).not.toHaveClass('mt-2')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { areaCounts, check, inlineConfigsOf, lintClient, tally } from '../../../scripts/lib/eslint-warnings.mjs';
|
||||
import {
|
||||
areaCounts,
|
||||
check,
|
||||
inlineConfigsOf,
|
||||
lintClient,
|
||||
newWarningsOf,
|
||||
tally,
|
||||
} from '../../../scripts/lib/eslint-warnings.mjs';
|
||||
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
|
||||
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
|
||||
|
||||
@@ -183,4 +191,33 @@ describe('lint:warnings', () => {
|
||||
expect(byKey['b.js'].messages.map((m) => m.ruleId)).toEqual(['no-empty']);
|
||||
expect(byKey['b.js'].inlineConfigs).toEqual([]);
|
||||
});
|
||||
|
||||
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'src'));
|
||||
writeFileSync(join(root, 'src/new.ts'), 'two');
|
||||
writeFileSync(join(root, 'src/old.ts'), 'one');
|
||||
// new.ts held none of the rule on the base, old.ts already held its one
|
||||
const git = (_root: string, args: string[]) =>
|
||||
args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1]?.endsWith('old.ts') ? 'one' : 'none';
|
||||
const counts: Record<string, number> = { two: 2, one: 1, none: 0 };
|
||||
const lintText = async (text: string) => [
|
||||
{
|
||||
messages: Array.from({ length: counts[text] ?? 0 }, (_, i) => ({
|
||||
ruleId: 'no-empty',
|
||||
severity: 1,
|
||||
message: `w${i}`,
|
||||
line: i + 1,
|
||||
})),
|
||||
},
|
||||
];
|
||||
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
|
||||
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
|
||||
expect(lines).toEqual(['src/new.ts:1 w0', 'src/new.ts:2 w1']);
|
||||
expect(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null })).toBeNull();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* own major and has to lint with the one its eslint.config.mjs was written
|
||||
* for. This file never imports eslint, so it resolves no version of its own.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import {
|
||||
@@ -145,6 +146,77 @@ export function lintWith(ESLint) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The ref new warnings are measured against: TREK_LINT_BASE (the CI sets it), else origin/dev. */
|
||||
const baseRef = () => process.env.TREK_LINT_BASE || 'origin/dev';
|
||||
|
||||
/** Git in the workspace: its output, or null when it fails (no repository, no such ref). */
|
||||
function gitIn(root, args) {
|
||||
try {
|
||||
return execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1 << 28 });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The messages of one rule in one area of a single file's lint result. */
|
||||
function ofRule(results, rule, area) {
|
||||
const result = results[0];
|
||||
if (!result) return [];
|
||||
const pool =
|
||||
area === 'suppressed' ? (result.suppressedMessages ?? []) : result.messages.filter((m) => m.severity === 1);
|
||||
return pool.filter((m) => ruleOf(m) === rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a grown rule's new warnings sit: every file among `keys` (the files
|
||||
* holding the rule now) that differs from `ref` and holds more of the rule
|
||||
* than it did there, each of its warnings of the rule as `file:line message`.
|
||||
* The count alone names no file, and the files holding the most are old ones;
|
||||
* this names the file that tipped it. Null when `ref` is not at hand (a clone
|
||||
* without it), and the report then keeps to the files holding the most.
|
||||
*/
|
||||
export async function newWarningsOf({ root, rule, area, keys, lintText, ref = baseRef(), git = gitIn }) {
|
||||
const changed = git(root, ['diff', '--name-only', '--relative', ref, '--', '.']);
|
||||
if (changed === null) return null;
|
||||
const differs = new Set(changed.split('\n').filter(Boolean));
|
||||
const found = await Promise.all(
|
||||
keys
|
||||
.filter((key) => differs.has(key))
|
||||
.map(async (key) => {
|
||||
const path = join(root, key);
|
||||
const now = ofRule(await lintText(readText(path), path), rule, area);
|
||||
const before = git(root, ['show', `${ref}:./${key}`]);
|
||||
const was = before === null ? [] : ofRule(await lintText(before, path), rule, area);
|
||||
return now.length > was.length ? now.map((m) => `${key}:${m.line ?? 0} ${m.message}`) : [];
|
||||
})
|
||||
);
|
||||
return found.flat();
|
||||
}
|
||||
|
||||
/** Prints, under the grown counts, the files and lines behind each one (see newWarningsOf). */
|
||||
async function reportNew(grown, { root, files, lintText, error }) {
|
||||
if (!grown.length || !lintText) return;
|
||||
const found = await Promise.all(
|
||||
grown.map(({ area, rule }) =>
|
||||
newWarningsOf({ root, rule, area, keys: Object.keys(files[area][rule]), lintText })
|
||||
)
|
||||
);
|
||||
grown.forEach(({ area, rule }, i) => {
|
||||
const lines = found[i];
|
||||
if (!lines?.length) return;
|
||||
error(`New ${rule} warning(s) in ${area}, against ${baseRef()}:`);
|
||||
for (const line of lines) error(` ${line}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** A one-file lint for newWarningsOf, with the ESLint the workspace imports (see lintWith). */
|
||||
export function lintTextWith(ESLint) {
|
||||
return (root) => {
|
||||
const eslint = new ESLint({ cwd: root });
|
||||
return (text, filePath) => eslint.lintText(text, { filePath });
|
||||
};
|
||||
}
|
||||
|
||||
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
|
||||
|
||||
/**
|
||||
@@ -156,6 +228,8 @@ export async function check({
|
||||
baselinePath = join(root, 'scripts/eslint-baseline.json'),
|
||||
update = false,
|
||||
lint,
|
||||
/** lintTextWith(ESLint): names the files behind a grown count; left out, only the counts are told. */
|
||||
lintText = null,
|
||||
log = console.log,
|
||||
error = console.error,
|
||||
}) {
|
||||
@@ -169,13 +243,13 @@ export async function check({
|
||||
}
|
||||
|
||||
for (const line of errors) error(`FAIL ${line}`);
|
||||
let grown = 0;
|
||||
const grown = [];
|
||||
let stale = 0;
|
||||
for (const area of AREAS) {
|
||||
for (const [rule, n] of Object.entries(counts[area])) {
|
||||
const allowed = baseline[area][rule] ?? 0;
|
||||
if (n <= allowed) continue;
|
||||
grown++;
|
||||
grown.push({ area, rule });
|
||||
const top = Object.entries(files[area][rule])
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
@@ -191,7 +265,8 @@ export async function check({
|
||||
});
|
||||
stale += fallen.length;
|
||||
}
|
||||
if (grown) {
|
||||
await reportNew(grown, { root, files, lintText: lintText && lintText(root), error });
|
||||
if (grown.length) {
|
||||
error(
|
||||
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
|
||||
'Run npx eslint <file> to see each one.'
|
||||
@@ -200,5 +275,5 @@ export async function check({
|
||||
log(
|
||||
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
|
||||
);
|
||||
return errors.length || grown || stale ? 1 : 0;
|
||||
return errors.length || grown.length || stale ? 1 : 0;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ESLint } from 'eslint';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, it } from 'node:test';
|
||||
import { areaCounts, check, inlineConfigsOf, lintWith, tally } from './eslint-warnings.mjs';
|
||||
import { areaCounts, check, inlineConfigsOf, lintWith, newWarningsOf, tally } from './eslint-warnings.mjs';
|
||||
import { RatchetError } from './ratchet.mjs';
|
||||
import { ratchetTree } from './tree-fixture.mjs';
|
||||
|
||||
@@ -92,6 +93,26 @@ describe('scripts/lib/eslint-warnings', () => {
|
||||
assert.match(out, /Fix the new warning rather than silencing it/);
|
||||
});
|
||||
|
||||
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
|
||||
try {
|
||||
mkdirSync(join(root, 'src'));
|
||||
writeFileSync(join(root, 'src/new.ts'), 'two');
|
||||
writeFileSync(join(root, 'src/old.ts'), 'one');
|
||||
// new.ts held none of the rule on the base, old.ts already held its one
|
||||
const git = (_root, args) => (args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1].endsWith('old.ts') ? 'one' : 'none');
|
||||
const lintText = async (text) => [
|
||||
{ messages: Array.from({ length: { two: 2, one: 1, none: 0 }[text] }, (_, i) => ({ ...warn('no-empty', `w${i}`), line: i + 1 })) },
|
||||
];
|
||||
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
|
||||
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
|
||||
assert.deepEqual(lines, ['src/new.ts:1 w0', 'src/new.ts:2 w1']);
|
||||
assert.equal(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null }), null);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('counts a message an eslint-disable comment silenced', async () => {
|
||||
assert.equal(await run(empty, (root) => [result(root, 'src/a.ts', [], [warn('no-console')])]), 1);
|
||||
assert.match(tree.error.join('\n'), /suppressed: 1 no-console warning/);
|
||||
|
||||
@@ -23,11 +23,13 @@
|
||||
* ESLint comes from this workspace, so the server lints with the major its
|
||||
* config was written for.
|
||||
*/
|
||||
import { check, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
|
||||
import { check, lintTextWith, lintWith } from '../../scripts/lib/eslint-warnings.mjs';
|
||||
import { runCli } from '../../scripts/lib/ratchet.mjs';
|
||||
|
||||
import { ESLint } from 'eslint';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update'), lint: lintWith(ESLint) }));
|
||||
await runCli('lint:warnings', (args) =>
|
||||
check({ root, update: args.includes('--update'), lint: lintWith(ESLint), lintText: lintTextWith(ESLint) }),
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"src/db/repositories/Days.repository.ts": 7,
|
||||
"src/db/repositories/InviteTokens.repository.ts": 3,
|
||||
"src/db/repositories/JourneyBooks.repository.ts": 3,
|
||||
"src/db/repositories/JourneyContributors.repository.ts": 3,
|
||||
"src/db/repositories/JourneyEntries.repository.ts": 9,
|
||||
"src/db/repositories/JourneyPhotos.repository.ts": 6,
|
||||
"src/db/repositories/Journeys.repository.ts": 4,
|
||||
|
||||
@@ -117,6 +117,8 @@
|
||||
"TripDocumentLinks": "doc-sync",
|
||||
"TripFiles": "files",
|
||||
"TripInviteTokens": "trip-invite",
|
||||
"TripJournalLinkMembers": "journey",
|
||||
"TripJournalLinks": "journey",
|
||||
"TripMembers": "trip-membership",
|
||||
"TripPhotos": "photos",
|
||||
"Trips": "trips",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { JourneyEntries } from './JourneyEntries.entity';
|
||||
import { JourneyPhotos } from './JourneyPhotos.entity';
|
||||
import { JourneyShareTokens } from './JourneyShareTokens.entity';
|
||||
import { JourneyTrips } from './JourneyTrips.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class Journeys {
|
||||
@@ -32,6 +33,7 @@ export class Journeys {
|
||||
journey_photos_collection = new Collection<JourneyPhotos>(this);
|
||||
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
|
||||
journey_trips_collection = new Collection<JourneyTrips>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
}
|
||||
|
||||
export const JourneysSchema = defineEntity({
|
||||
@@ -60,5 +62,6 @@ export const JourneysSchema = defineEntity({
|
||||
journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(),
|
||||
journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(),
|
||||
journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('journey').hidden(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TripJournalLinkMembersRepository } from '../repositories/TripJournalLinkMembers.repository';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class TripJournalLinkMembers {
|
||||
[EntityRepositoryType]?: TripJournalLinkMembersRepository;
|
||||
[PrimaryKeyProp]?: ['link', 'user'];
|
||||
link!: Ref<TripJournalLinks>;
|
||||
link_id!: number;
|
||||
user!: Ref<Users>;
|
||||
user_id!: number;
|
||||
role: TTripJournalLinkMembersRole & Opt = TripJournalLinkMembersRole.VIEWER;
|
||||
hide_skeletons: number & Opt = 0;
|
||||
added_at!: number;
|
||||
}
|
||||
|
||||
export const TripJournalLinkMembersRole = {
|
||||
VIEWER: 'viewer',
|
||||
EDITOR: 'editor',
|
||||
} as const;
|
||||
|
||||
export type TTripJournalLinkMembersRole = (typeof TripJournalLinkMembersRole)[keyof typeof TripJournalLinkMembersRole];
|
||||
|
||||
export const TripJournalLinkMembersSchema = defineEntity({
|
||||
class: TripJournalLinkMembers,
|
||||
repository: () => TripJournalLinkMembersRepository,
|
||||
uniques: [{ properties: ['link', 'user'] }],
|
||||
properties: {
|
||||
link: () => p.manyToOne(TripJournalLinks).primary().ref().hidden(),
|
||||
link_id: p.integer().persist(false),
|
||||
user: () => p.manyToOne(Users).primary().ref().hidden().index('idx_trip_journal_link_members_user'),
|
||||
user_id: p.integer().persist(false).index('idx_trip_journal_link_members_user'),
|
||||
role: p.enum(() => TripJournalLinkMembersRole).default('viewer'),
|
||||
hide_skeletons: p.integer().default(0),
|
||||
added_at: p.integer(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TripJournalLinksRepository } from '../repositories/TripJournalLinks.repository';
|
||||
import { Journeys } from './Journeys.entity';
|
||||
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
import { Users } from './Users.entity';
|
||||
|
||||
export class TripJournalLinks {
|
||||
[EntityRepositoryType]?: TripJournalLinksRepository;
|
||||
id!: number & Opt;
|
||||
trip!: Ref<Trips>;
|
||||
trip_id!: number;
|
||||
journey!: Ref<Journeys>;
|
||||
journey_id!: number;
|
||||
linked_by?: number | null;
|
||||
created_at!: number;
|
||||
linkedByRef?: Ref<Users> | null;
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
}
|
||||
|
||||
export const TripJournalLinksSchema = defineEntity({
|
||||
class: TripJournalLinks,
|
||||
repository: () => TripJournalLinksRepository,
|
||||
uniques: [{ properties: ['trip'] }],
|
||||
properties: {
|
||||
id: p.integer().primary(),
|
||||
trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(),
|
||||
trip_id: p.integer().persist(false),
|
||||
journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_trip_journal_links_journey'),
|
||||
journey_id: p.integer().persist(false).index('idx_trip_journal_links_journey'),
|
||||
linked_by: p.integer().nullable().persist(false).index('idx_trip_journal_links_linked_by'),
|
||||
created_at: p.integer(),
|
||||
linkedByRef: () => p.manyToOne(Users).ref().joinColumn('linked_by').nullable().hidden().index('idx_trip_journal_links_linked_by'),
|
||||
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('link').hidden(),
|
||||
},
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinks } from './TripDocumentLinks.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Users } from './Users.entity';
|
||||
@@ -84,6 +85,7 @@ export class Trips {
|
||||
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
}
|
||||
@@ -145,6 +147,7 @@ export const TripsSchema = defineEntity({
|
||||
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(),
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(),
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('trip').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(),
|
||||
},
|
||||
|
||||
@@ -57,6 +57,8 @@ import { TripAlbumLinks } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinks } from './TripDocumentLinks.entity';
|
||||
import { TripFiles } from './TripFiles.entity';
|
||||
import { TripInviteTokens } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembers } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinks } from './TripJournalLinks.entity';
|
||||
import { TripMembers } from './TripMembers.entity';
|
||||
import { TripPhotos } from './TripPhotos.entity';
|
||||
import { Trips } from './Trips.entity';
|
||||
@@ -182,6 +184,8 @@ export class Users {
|
||||
trip_document_links_collection = new Collection<TripDocumentLinks>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(this);
|
||||
trip_invite_tokens_collection = new Collection<TripInviteTokens>(this);
|
||||
trip_journal_link_members_collection = new Collection<TripJournalLinkMembers>(this);
|
||||
trip_journal_links_collection = new Collection<TripJournalLinks>(this);
|
||||
trip_members_collection = new Collection<TripMembers>(this);
|
||||
trip_members_collection1 = new Collection<TripMembers>(this);
|
||||
trip_photos_collection = new Collection<TripPhotos>(this);
|
||||
@@ -325,6 +329,8 @@ export const UsersSchema = defineEntity({
|
||||
trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(),
|
||||
trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(),
|
||||
trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(),
|
||||
trip_journal_link_members_collection: () => p.oneToMany(TripJournalLinkMembers).mappedBy('user').hidden(),
|
||||
trip_journal_links_collection: () => p.oneToMany(TripJournalLinks).mappedBy('linkedByRef').hidden(),
|
||||
trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(),
|
||||
trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(),
|
||||
trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(),
|
||||
|
||||
@@ -116,6 +116,8 @@ import { TripAlbumLinksSchema } from './TripAlbumLinks.entity';
|
||||
import { TripDocumentLinksSchema } from './TripDocumentLinks.entity';
|
||||
import { TripFilesSchema } from './TripFiles.entity';
|
||||
import { TripInviteTokensSchema } from './TripInviteTokens.entity';
|
||||
import { TripJournalLinkMembersSchema } from './TripJournalLinkMembers.entity';
|
||||
import { TripJournalLinksSchema } from './TripJournalLinks.entity';
|
||||
import { TripMembersSchema } from './TripMembers.entity';
|
||||
import { TripPhotosSchema } from './TripPhotos.entity';
|
||||
import { TripsSchema } from './Trips.entity';
|
||||
@@ -256,6 +258,8 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
TripDocumentLinksSchema,
|
||||
TripFilesSchema,
|
||||
TripInviteTokensSchema,
|
||||
TripJournalLinkMembersSchema,
|
||||
TripJournalLinksSchema,
|
||||
TripMembersSchema,
|
||||
TripPhotosSchema,
|
||||
TripsSchema,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TripJournalLinkMembersTable {
|
||||
link_id: number;
|
||||
user_id: number;
|
||||
role: InsertOptional<string>;
|
||||
hide_skeletons: InsertOptional<number>;
|
||||
added_at: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TripJournalLinksTable {
|
||||
id: InsertOptional<number>;
|
||||
trip_id: number;
|
||||
journey_id: number;
|
||||
linked_by: InsertOptional<number | null>;
|
||||
created_at: number;
|
||||
}
|
||||
@@ -122,6 +122,8 @@ import type { TripAlbumLinksTable } from './TripAlbumLinks.table';
|
||||
import type { TripDocumentLinksTable } from './TripDocumentLinks.table';
|
||||
import type { TripFilesTable } from './TripFiles.table';
|
||||
import type { TripInviteTokensTable } from './TripInviteTokens.table';
|
||||
import type { TripJournalLinkMembersTable } from './TripJournalLinkMembers.table';
|
||||
import type { TripJournalLinksTable } from './TripJournalLinks.table';
|
||||
import type { TripMembersTable } from './TripMembers.table';
|
||||
import type { TripPhotosTable } from './TripPhotos.table';
|
||||
import type { TripsTable } from './Trips.table';
|
||||
@@ -268,6 +270,8 @@ export interface DB {
|
||||
trip_document_links: TripDocumentLinksTable;
|
||||
trip_files: TripFilesTable;
|
||||
trip_invite_tokens: TripInviteTokensTable;
|
||||
trip_journal_link_members: TripJournalLinkMembersTable;
|
||||
trip_journal_links: TripJournalLinksTable;
|
||||
trip_members: TripMembersTable;
|
||||
trip_photos: TripPhotosTable;
|
||||
trips: TripsTable;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* A trip can show one journal from the Journey addon as its own. The link
|
||||
* belongs to the trip, not to the journal's own trips (`journey_trips`): any
|
||||
* journal its owner chooses, and one journal may hang off several trips.
|
||||
* `linked_by` names who linked it, kept when that account goes as unknown.
|
||||
*
|
||||
* `trip_journal_link_members` lists the trip members the link lets into the
|
||||
* journal, as viewer or editor, apart from `journey_contributors` so that
|
||||
* unlinking, deselecting someone or their leaving the trip takes away exactly
|
||||
* the access the link gave and nothing the journal's owner granted directly.
|
||||
* `hide_skeletons` is that member's own journal preference, which for a direct
|
||||
* contributor sits on the contributor row. A member's rows go with the link,
|
||||
* with the trip and with the account.
|
||||
*
|
||||
* One link per trip is `trip_id UNIQUE` beside its own id rather than the trip
|
||||
* id as key, as `trip_invite_tokens` holds one per trip: a key that is also a
|
||||
* foreign key maps to a one-to-one on both entities, and a second one of those
|
||||
* next to `tours` made the ORM's entity types recurse past what tsc expands.
|
||||
*/
|
||||
export class Migration20200101044300_a_trip_links_a_journal extends Migration {
|
||||
override name = 'Migration20200101044300_a_trip_links_a_journal';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS trip_journal_links (
|
||||
id INTEGER PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE,
|
||||
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
|
||||
linked_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_journey ON trip_journal_links(journey_id)');
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_links_linked_by ON trip_journal_links(linked_by)');
|
||||
this.addSql(`
|
||||
CREATE TABLE IF NOT EXISTS trip_journal_link_members (
|
||||
link_id INTEGER NOT NULL REFERENCES trip_journal_links(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('viewer', 'editor')),
|
||||
hide_skeletons INTEGER NOT NULL DEFAULT 0,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (link_id, user_id)
|
||||
)
|
||||
`);
|
||||
this.addSql('CREATE INDEX IF NOT EXISTS idx_trip_journal_link_members_user ON trip_journal_link_members(user_id)');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { JourneyContributors } from '../entities/JourneyContributors.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import {
|
||||
linkReach,
|
||||
readLinkHideSkeletons,
|
||||
readLinkRole,
|
||||
type TripJournalLinkKyselyDB,
|
||||
type TripJournalLinkRole,
|
||||
} from './TripJournalLinks.repository';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** JG18's contributor-list row (`getJourneyFull`) — the junction plus the joined username/avatar. */
|
||||
@@ -15,6 +22,19 @@ export interface JourneyContributorListRow {
|
||||
/** The `journey_contributors`/`users` tables {@link JourneyContributorsRepository.listForJourney} reads. */
|
||||
type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>;
|
||||
|
||||
/** Someone a trip's journal link lets in, with the trip they come through (#2683). */
|
||||
export interface JourneyTripAccessRow {
|
||||
user_id: number;
|
||||
role: TripJournalLinkRole;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
trip_id: number;
|
||||
trip_title: string;
|
||||
}
|
||||
|
||||
/** The table the access reads below take a person's own contributor row from. */
|
||||
type JourneyContributorRowKyselyDB = Pick<DB, 'journey_contributors'>;
|
||||
|
||||
/**
|
||||
* `journey_contributors` — a genuine TWO-column composite primary key
|
||||
* (`journey` + `user`, both `.primary()`, R3 — pinned against
|
||||
@@ -26,36 +46,71 @@ type JourneyContributorListKyselyDB = Pick<DB, 'journey_contributors' | 'users'>
|
||||
* pinned in `task-1-report.md`.
|
||||
*/
|
||||
export class JourneyContributorsRepository extends TrekRepository<JourneyContributors> {
|
||||
/** JG4 — `canAccessJourney`'s contributor-match primitive: `SELECT 1 FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
|
||||
async existsForUser(journeyId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.journey'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<unknown>('get', false);
|
||||
return !!row;
|
||||
/** The person's own contributor row on the journal, if they have one. */
|
||||
private async directRow(journeyId: number, userId: number) {
|
||||
return await this.kysely<JourneyContributorRowKyselyDB>()
|
||||
.selectFrom('journey_contributors')
|
||||
.select(['role', 'hide_skeletons'])
|
||||
.where('journey_id', '=', journeyId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** JG7/JG21 — `canEdit`'s edit-access primitive and `getJourneyFull`'s `myRole` resolution: `SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?`. */
|
||||
async findRole(journeyId: number, userId: number): Promise<string | undefined> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.role'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<{ role: string } | undefined>('get', false);
|
||||
return row?.role;
|
||||
/** JG4 — whether a person may open the journal: as a contributor, or through a trip's link. */
|
||||
async hasAccess(journeyId: number, userId: number): Promise<boolean> {
|
||||
return (await this.findAccessRole(journeyId, userId)) !== undefined;
|
||||
}
|
||||
|
||||
/** JG20/JG27 — `getJourneyFull`'s `hide_skeletons` preference read and `updateJourneyPreferences`'s re-read, one statement text. */
|
||||
/**
|
||||
* JG7/JG21 — the role a person acts with on the journal, for `canEdit` and
|
||||
* `my_role`: their contributor role, or the role a trip's link gives them
|
||||
* when that is the stronger one. Undefined with neither.
|
||||
*/
|
||||
async findAccessRole(journeyId: number, userId: number): Promise<string | undefined> {
|
||||
const direct = await this.directRow(journeyId, userId);
|
||||
if (direct?.role === 'owner' || direct?.role === 'editor') return direct.role;
|
||||
const linked = await readLinkRole(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId);
|
||||
return linked ?? direct?.role;
|
||||
}
|
||||
|
||||
/** JG20/JG27 — the person's "hide skeletons" choice, from their contributor row or else from their link. */
|
||||
async getHideSkeletons(journeyId: number, userId: number): Promise<boolean> {
|
||||
const row = await this.qb('jc')
|
||||
.select(['jc.hide_skeletons'])
|
||||
.where({ journey: journeyId, user: userId })
|
||||
.execute<{ hide_skeletons: number } | undefined>('get', false);
|
||||
return !!row?.hide_skeletons;
|
||||
const direct = await this.directRow(journeyId, userId);
|
||||
if (direct) return !!direct.hide_skeletons;
|
||||
return !!(await readLinkHideSkeletons(this.kysely<TripJournalLinkKyselyDB>(), journeyId, userId));
|
||||
}
|
||||
|
||||
/** JG26 — `updateJourneyPreferences`'s write: `UPDATE journey_contributors SET hide_skeletons = ? WHERE journey_id = ? AND user_id = ?`. */
|
||||
/** JG26 — stores that choice where `getHideSkeletons` reads it: the contributor row, or else the person's links. */
|
||||
async setHideSkeletons(journeyId: number, userId: number, value: number): Promise<void> {
|
||||
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
|
||||
if (await this.directRow(journeyId, userId)) {
|
||||
await this.nativeUpdate({ journey: journeyId, user: userId }, { hide_skeletons: value });
|
||||
return;
|
||||
}
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
await db
|
||||
.updateTable('trip_journal_link_members')
|
||||
.set({ hide_skeletons: value })
|
||||
.where('user_id', '=', userId)
|
||||
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('journey_id', '=', journeyId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* #2683 — the people the trips' journal links let into this journal, one row
|
||||
* per trip they come through, leaving out whoever is a contributor already:
|
||||
* the settings show them apart, since they are managed in the trip.
|
||||
*/
|
||||
async listTripAccessForJourney(journeyId: number): Promise<JourneyTripAccessRow[]> {
|
||||
const direct = new Set((await this.listForJourney(journeyId)).map((c) => c.user_id));
|
||||
const rows = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
|
||||
.innerJoin('users as u', 'u.id', 'm.user_id')
|
||||
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar', 't.id as trip_id', 't.title as trip_title'])
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
return rows
|
||||
.filter((r) => !direct.has(r.user_id))
|
||||
.map((r) => ({ ...r, role: r.role === 'editor' ? 'editor' : 'viewer' }));
|
||||
}
|
||||
|
||||
/** JG18 — `getJourneyFull`'s contributor list, joined to `users` for `username`/`avatar` (`avatar_url` is computed in the SERVICE, unchanged). */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Journeys } from '../entities/Journeys.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { linkReach, type TripJournalLinkKyselyDB } from './TripJournalLinks.repository';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
@@ -114,6 +115,12 @@ export class JourneysRepository extends TrekRepository<Journeys> {
|
||||
.where('journey_id', '=', id)
|
||||
.execute();
|
||||
const ids = new Set(contributors.map((c) => c.user_id));
|
||||
// The trip members a trip's link lets in follow the journal live as well.
|
||||
const linked = await linkReach(this.kysely<TripJournalLinkKyselyDB>())
|
||||
.select('m.user_id')
|
||||
.where('l.journey_id', '=', id)
|
||||
.execute();
|
||||
for (const l of linked) ids.add(l.user_id);
|
||||
const ownerId = await this.findOwnerId(id);
|
||||
if (ownerId !== undefined) ids.add(ownerId);
|
||||
return [...ids];
|
||||
@@ -169,7 +176,18 @@ export class JourneysRepository extends TrekRepository<Journeys> {
|
||||
.whereRef('jt2.journey_id', '=', 'j.id')
|
||||
.as('trip_date_max'),
|
||||
])
|
||||
.where((eb) => eb.or([eb('j.user_id', '=', user_id), eb('jc.user_id', '=', user_id)]))
|
||||
// A journal a trip's link lets this person into is theirs to open here too.
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('j.user_id', '=', user_id),
|
||||
eb('jc.user_id', '=', user_id),
|
||||
eb(
|
||||
'j.id',
|
||||
'in',
|
||||
linkReach(this.kysely<TripJournalLinkKyselyDB>()).select('l.journey_id').where('m.user_id', '=', user_id),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.orderBy('j.updated_at', 'desc')
|
||||
.execute();
|
||||
return rows as JourneyListRow[];
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { TripJournalLinkMembers } from '../entities/TripJournalLinkMembers.entity';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
export class TripJournalLinkMembersRepository extends TrekRepository<TripJournalLinkMembers> {}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
TripJournalLinkMembersRole,
|
||||
type TTripJournalLinkMembersRole,
|
||||
} from '../entities/TripJournalLinkMembers.entity';
|
||||
import type { TripJournalLinks } from '../entities/TripJournalLinks.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
import type { Kysely } from 'kysely';
|
||||
|
||||
/** The tables a link's reach is read from. */
|
||||
export type TripJournalLinkKyselyDB = Pick<
|
||||
DB,
|
||||
'trip_journal_links' | 'trip_journal_link_members' | 'trips' | 'trip_members' | 'journeys' | 'users'
|
||||
>;
|
||||
|
||||
/** The role a link gives, as the migration's CHECK and the generated entity spell it. */
|
||||
export type TripJournalLinkRole = TTripJournalLinkMembersRole;
|
||||
|
||||
const { EDITOR, VIEWER } = TripJournalLinkMembersRole;
|
||||
|
||||
/** A trip's link with the journal it shows, as the settings dialog reads it. */
|
||||
export interface TripJournalLinkRow {
|
||||
trip_id: number;
|
||||
journey_id: number;
|
||||
linked_by: number | null;
|
||||
created_at: number;
|
||||
journey_title: string;
|
||||
journey_subtitle: string | null;
|
||||
journey_cover_image: string | null;
|
||||
journey_owner_id: number;
|
||||
}
|
||||
|
||||
/** Someone on the trip, as the member picker lists them. */
|
||||
export interface TripPersonRow {
|
||||
user_id: number;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
is_guest: boolean;
|
||||
is_trip_owner: boolean;
|
||||
}
|
||||
|
||||
/** One person the link lets into its journal. */
|
||||
export interface TripJournalLinkMemberRow {
|
||||
user_id: number;
|
||||
role: TripJournalLinkRole;
|
||||
username: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The link members who still belong to the linking trip, as its owner or on its
|
||||
* roster, with the link they hold. Every read of link access starts here, so
|
||||
* leaving a trip ends what its link gave in the same instant, whether or not
|
||||
* anything cleaned up after it. A caller narrows it by journal or by person and
|
||||
* picks its own columns.
|
||||
*/
|
||||
export function linkReach(db: Kysely<TripJournalLinkKyselyDB>) {
|
||||
return db
|
||||
.selectFrom('trip_journal_links as l')
|
||||
.innerJoin('trip_journal_link_members as m', 'm.link_id', 'l.id')
|
||||
.innerJoin('trips as t', 't.id', 'l.trip_id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('t.user_id', '=', eb.ref('m.user_id')),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('trip_members as tm')
|
||||
.select('tm.id')
|
||||
.whereRef('tm.trip_id', '=', 'l.trip_id')
|
||||
.whereRef('tm.user_id', '=', 'm.user_id'),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/** The stronger of two link roles; a person linked through two trips gets the better one. */
|
||||
function strongest(roles: string[]): TripJournalLinkRole | null {
|
||||
if (roles.includes(EDITOR)) return EDITOR;
|
||||
return roles.length > 0 ? VIEWER : null;
|
||||
}
|
||||
|
||||
/** The role a person holds on a journal through any trip's link, or null when none lets them in. */
|
||||
export async function readLinkRole(
|
||||
db: Kysely<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<TripJournalLinkRole | null> {
|
||||
const rows = await linkReach(db)
|
||||
.select('m.role')
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.where('m.user_id', '=', userId)
|
||||
.execute();
|
||||
return strongest(rows.map((r) => r.role));
|
||||
}
|
||||
|
||||
/** A link member's own "hide skeletons" choice on the journal, or null when no link lets them in. */
|
||||
export async function readLinkHideSkeletons(
|
||||
db: Kysely<TripJournalLinkKyselyDB>,
|
||||
journeyId: number,
|
||||
userId: number,
|
||||
): Promise<number | null> {
|
||||
const row = await linkReach(db)
|
||||
.select('m.hide_skeletons')
|
||||
.where('l.journey_id', '=', journeyId)
|
||||
.where('m.user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row?.hide_skeletons ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `trip_journal_links` and its members: which journal a trip shows, and who
|
||||
* of the trip may read or write it through that link. The access this table
|
||||
* gives is read through {@link linkReach} everywhere, never copied into
|
||||
* `journey_contributors`, so a direct contributor's role is never overwritten
|
||||
* and undoing a link takes away exactly what it gave.
|
||||
*/
|
||||
export class TripJournalLinksRepository extends TrekRepository<TripJournalLinks> {
|
||||
/** The trip's link with its journal's title and cover, or null without one. */
|
||||
async findForTrip(tripId: number): Promise<TripJournalLinkRow | null> {
|
||||
const row = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_links as l')
|
||||
.innerJoin('journeys as j', 'j.id', 'l.journey_id')
|
||||
.select([
|
||||
'l.trip_id',
|
||||
'l.journey_id',
|
||||
'l.linked_by',
|
||||
'l.created_at',
|
||||
'j.title as journey_title',
|
||||
'j.subtitle as journey_subtitle',
|
||||
'j.cover_image as journey_cover_image',
|
||||
'j.user_id as journey_owner_id',
|
||||
])
|
||||
.where('l.trip_id', '=', tripId)
|
||||
.executeTakeFirst();
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
/** The people the trip's link lets in, still on the trip or not, with their name for the list. */
|
||||
async listMembers(tripId: number): Promise<TripJournalLinkMemberRow[]> {
|
||||
const rows = await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_link_members as m')
|
||||
.innerJoin('trip_journal_links as l', 'l.id', 'm.link_id')
|
||||
.innerJoin('users as u', 'u.id', 'm.user_id')
|
||||
.select(['m.user_id', 'm.role', 'u.username', 'u.avatar'])
|
||||
.where('l.trip_id', '=', tripId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
return rows.map((r) => ({ ...r, role: r.role === EDITOR ? EDITOR : VIEWER }));
|
||||
}
|
||||
|
||||
/** Everyone on the trip, its owner first, with what the member picker shows of them. */
|
||||
async listTripPeople(tripId: number): Promise<TripPersonRow[]> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
const owner = await db
|
||||
.selectFrom('trips as t')
|
||||
.innerJoin('users as u', 'u.id', 't.user_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
|
||||
.where('t.id', '=', tripId)
|
||||
.executeTakeFirst();
|
||||
const members = await db
|
||||
.selectFrom('trip_members as tm')
|
||||
.innerJoin('users as u', 'u.id', 'tm.user_id')
|
||||
.select(['u.id as user_id', 'u.username', 'u.avatar', 'u.is_guest'])
|
||||
.where('tm.trip_id', '=', tripId)
|
||||
.orderBy('u.username', 'asc')
|
||||
.execute();
|
||||
const people = owner ? [{ ...owner, is_trip_owner: true }] : [];
|
||||
for (const m of members) if (m.user_id !== owner?.user_id) people.push({ ...m, is_trip_owner: false });
|
||||
return people.map((p) => ({ ...p, is_guest: p.is_guest === 1 }));
|
||||
}
|
||||
|
||||
/** Points the trip at a journal, replacing the one it showed; the link keeps its id. */
|
||||
async upsertLink(tripId: number, journeyId: number, linkedBy: number, now: number): Promise<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.insertInto('trip_journal_links')
|
||||
.values({ trip_id: tripId, journey_id: journeyId, linked_by: linkedBy, created_at: now })
|
||||
.onConflict((oc) =>
|
||||
oc.column('trip_id').doUpdateSet({ journey_id: journeyId, linked_by: linkedBy, created_at: now }),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The id of the trip's link, as a subquery the member writes narrow by. */
|
||||
private linkIdOf(tripId: number) {
|
||||
return this.kysely<TripJournalLinkKyselyDB>()
|
||||
.selectFrom('trip_journal_links')
|
||||
.select('id')
|
||||
.where('trip_id', '=', tripId);
|
||||
}
|
||||
|
||||
/** Sets who the trip's link lets in, replacing the list it had. */
|
||||
async replaceMembers(
|
||||
tripId: number,
|
||||
members: { user_id: number; role: TripJournalLinkRole }[],
|
||||
now: number,
|
||||
): Promise<void> {
|
||||
const db = this.kysely<TripJournalLinkKyselyDB>();
|
||||
const link = await this.linkIdOf(tripId).executeTakeFirst();
|
||||
if (!link) return;
|
||||
await db.deleteFrom('trip_journal_link_members').where('link_id', '=', link.id).execute();
|
||||
if (members.length === 0) return;
|
||||
await db
|
||||
.insertInto('trip_journal_link_members')
|
||||
.values(members.map((m) => ({ link_id: link.id, user_id: m.user_id, role: m.role, added_at: now })))
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Removes the trip's link; its members go with it. */
|
||||
async deleteForTrip(tripId: number): Promise<void> {
|
||||
await this.kysely<TripJournalLinkKyselyDB>()
|
||||
.deleteFrom('trip_journal_links')
|
||||
.where('trip_id', '=', tripId)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { caseWhenEquals, coalesce } from '../dialect/sql-functions';
|
||||
import type { TripMembers } from '../entities/TripMembers.entity';
|
||||
import { Trips } from '../entities/Trips.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/**
|
||||
@@ -266,6 +267,22 @@ export class TripMembersRepository extends TrekRepository<TripMembers> {
|
||||
await this.qb('m').delete().where({ trip: trip_id }).andWhere('m.user_id = ?', [user_id]).execute('run');
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes someone who left the trip off its journal link, so coming back later
|
||||
* does not quietly reopen the journal. Their access already ended with the
|
||||
* membership (the link's reach requires it); this tidies the row. Here rather
|
||||
* than in the journey domain because leaving is the roster's to finish, and
|
||||
* reaching into journey from the roster closed a cycle between the two.
|
||||
*/
|
||||
async leaveJournalLink(tripId: number, userId: number): Promise<void> {
|
||||
const db = this.kysely<Pick<DB, 'trip_journal_links' | 'trip_journal_link_members'>>();
|
||||
await db
|
||||
.deleteFrom('trip_journal_link_members')
|
||||
.where('user_id', '=', userId)
|
||||
.where('link_id', 'in', db.selectFrom('trip_journal_links').select('id').where('trip_id', '=', tripId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository
|
||||
// is rooted at `trip_members` joined to `users`, and RP3's shape is
|
||||
|
||||
@@ -52,6 +52,7 @@ import { AirtrailModule } from './integrations/airtrail.module';
|
||||
import { DawarichModule } from './integrations/dawarich.module';
|
||||
import { JourneyAccessModule } from './journey/journey-access.module';
|
||||
import { JourneyModule } from './journey/journey.module';
|
||||
import { TripJournalLinkModule } from './journey/trip-journal-link.module';
|
||||
import { LlmParseModule } from './llm-parse/llm-parse.module';
|
||||
import { ManagedExtModule } from './managed/managed-ext.module';
|
||||
import { MapsModule } from './maps/maps.module';
|
||||
@@ -166,6 +167,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
|
||||
AirtrailModule,
|
||||
DawarichModule,
|
||||
JourneyModule,
|
||||
TripJournalLinkModule,
|
||||
CollectionsModule,
|
||||
ShareModule,
|
||||
TripInviteModule,
|
||||
|
||||
@@ -178,7 +178,7 @@ export class JourneyDomainService {
|
||||
// `SELECT *` read this repository now serves.
|
||||
const own = await this.journeysRepo.findOwnedByUser(journeyId, userId);
|
||||
if (own) return own as Journey;
|
||||
const isContributor = await this.contributorsRepo.existsForUser(journeyId, userId);
|
||||
const isContributor = await this.contributorsRepo.hasAccess(journeyId, userId);
|
||||
if (isContributor) return ((await this.journeysRepo.findById(journeyId)) as Journey) || null;
|
||||
return null;
|
||||
}
|
||||
@@ -189,7 +189,7 @@ export class JourneyDomainService {
|
||||
|
||||
async canEdit(journeyId: number, userId: number): Promise<boolean> {
|
||||
if (await this.isOwner(journeyId, userId)) return true;
|
||||
const role = await this.contributorsRepo.findRole(journeyId, userId);
|
||||
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
|
||||
return role === 'editor' || role === 'owner';
|
||||
}
|
||||
|
||||
@@ -270,12 +270,10 @@ export class JourneyDomainService {
|
||||
// linked trips (JG17)
|
||||
const trips = await this.journeyTripsRepo.listForJourney(journeyId);
|
||||
|
||||
// contributors (JG18)
|
||||
const contributorsRaw = await this.contributorsRepo.listForJourney(journeyId);
|
||||
const contributors = contributorsRaw.map((c) => ({
|
||||
...c,
|
||||
avatar_url: avatarUrl(c),
|
||||
}));
|
||||
const withAvatar = <R extends { avatar: string | null }>(rows: R[]) => rows.map((r) => ({ ...r, avatar_url: avatarUrl(r) }));
|
||||
const contributors = withAvatar(await this.contributorsRepo.listForJourney(journeyId)); // JG18
|
||||
// Who a trip's journal link lets in besides them (#2683), shown apart in the settings.
|
||||
const tripAccess = withAvatar(await this.contributorsRepo.listTripAccessForJourney(journeyId));
|
||||
|
||||
// stats
|
||||
const entryCount = entries.filter((e) => e.type === 'entry').length;
|
||||
@@ -293,7 +291,7 @@ export class JourneyDomainService {
|
||||
myRole = 'owner';
|
||||
} else {
|
||||
// JG21 — same text as JG7 (`findRole`).
|
||||
const role = await this.contributorsRepo.findRole(journeyId, userId);
|
||||
const role = await this.contributorsRepo.findAccessRole(journeyId, userId);
|
||||
myRole = (role as 'editor' | 'viewer' | undefined) ?? null;
|
||||
}
|
||||
|
||||
@@ -306,6 +304,7 @@ export class JourneyDomainService {
|
||||
gallery,
|
||||
trips,
|
||||
contributors,
|
||||
trip_access: tripAccess,
|
||||
stats: { entries: entryCount, photos: photoCount, places: places.length },
|
||||
hide_skeletons: hideSkeletons,
|
||||
// What the eye toggle cannot say: how much was waved away one at a time, and
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import type { User } from '../../types';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TripJournalCandidatesQueryDto, TripJournalLinkDto, TripJournalLinkMembersDto } from './trip-journal-link.dto';
|
||||
import { TripJournalLinkService } from './trip-journal-link.service';
|
||||
import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Patch, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { tripJournalCandidatesResponseSchema, tripJournalLinkResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The journal a trip shows, at `/api/trips/:tripId/journal`. Every member of
|
||||
* the trip may read the link as it concerns them; changing it is decided in the
|
||||
* service, because it turns on who owns the journal as well as on the trip
|
||||
* permission. Behind the Journey addon: with it off the routes answer 404.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/journal')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.JOURNEY, 'Journey')
|
||||
export class TripJournalLinkController {
|
||||
constructor(private readonly links: TripJournalLinkService) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async get(@Param('tripId') tripId: string, @CurrentUser() user: User) {
|
||||
return this.links.get(Number(tripId), user);
|
||||
}
|
||||
|
||||
@Get('candidates')
|
||||
@ResponseContract(tripJournalCandidatesResponseSchema)
|
||||
async candidates(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Query() query: TripJournalCandidatesQueryDto,
|
||||
) {
|
||||
return { candidates: await this.links.candidates(Number(tripId), user, query.journey_id) };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async link(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: TripJournalLinkDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.links.link(Number(tripId), user, body.journey_id, body.members, socketId);
|
||||
}
|
||||
|
||||
@Patch('members')
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async setMembers(
|
||||
@Param('tripId') tripId: string,
|
||||
@CurrentUser() user: User,
|
||||
@Body() body: TripJournalLinkMembersDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
return this.links.setMembers(Number(tripId), user, body.members, socketId);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@HttpCode(200)
|
||||
@ResponseContract(tripJournalLinkResponseSchema)
|
||||
async unlink(@Param('tripId') tripId: string, @CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
|
||||
return this.links.unlink(Number(tripId), user, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
tripJournalCandidatesQuerySchema,
|
||||
tripJournalLinkMembersRequestSchema,
|
||||
tripJournalLinkRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TripJournalLinkDto extends createZodDto(tripJournalLinkRequestSchema) {}
|
||||
export class TripJournalLinkMembersDto extends createZodDto(tripJournalLinkMembersRequestSchema) {}
|
||||
export class TripJournalCandidatesQueryDto extends createZodDto(tripJournalCandidatesQuerySchema) {}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
McpController,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
Tool,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripJournalLinkService, type TripJournalActor } from './trip-journal-link.service';
|
||||
import { idSchema, tripJournalLinkRequestSchema } from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/**
|
||||
* The trip's linked journal over MCP: the same service, the same permission and
|
||||
* journal-owner rules as `/api/trips/:tripId/journal`, behind the Journey addon.
|
||||
*/
|
||||
@McpController()
|
||||
export class TripJournalLinkMcp {
|
||||
constructor(
|
||||
private readonly links: TripJournalLinkService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
private async actor(tripId: number, userId: number): Promise<TripJournalActor> {
|
||||
const { role } = await this.guards.tripWriter(tripId, userId);
|
||||
return { id: userId, role };
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_trip_journal',
|
||||
description:
|
||||
'The journal from the Journey addon a trip shows as its own, if any: its id and title, your access to it, and whether you may link, unlink or change who it is open to. Open the journal itself with get_journey.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
return ok(await this.links.get(tripId, await this.actor(tripId, ctx.userId)));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'link_trip_journal',
|
||||
description:
|
||||
'Show one of your own journals in a trip, replacing the journal it showed. The trip members listed get into the journal as viewers or editors and see it in their own journal list; members left out do not. Needs the journal to be yours and the trip permission to link journals.',
|
||||
inputSchema: { tripId: idSchema, journey_id: idSchema, members: tripJournalLinkRequestSchema.shape.members },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async linkTripJournal(
|
||||
{
|
||||
tripId,
|
||||
journey_id,
|
||||
members,
|
||||
}: { tripId: number; journey_id: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return ok(await this.links.link(tripId, await this.actor(tripId, ctx.userId), journey_id, members));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_trip_journal_members',
|
||||
description:
|
||||
"Change which trip members the trip's linked journal is open to, and as viewer or editor. The list replaces the one before; anyone left out loses the access the link gave them. Only the journal's owner can do this.",
|
||||
inputSchema: { tripId: idSchema, members: tripJournalLinkRequestSchema.shape.members },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async setTripJournalMembers(
|
||||
{ tripId, members }: { tripId: number; members: { user_id: number; role: 'viewer' | 'editor' }[] },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return ok(await this.links.setMembers(tripId, await this.actor(tripId, ctx.userId), members));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'unlink_trip_journal',
|
||||
description:
|
||||
'Take the linked journal off a trip. The journal itself stays; the members the link let in lose that access, while its owner and direct contributors keep theirs.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'write' },
|
||||
})
|
||||
async unlinkTripJournal({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
return ok(await this.links.unlink(tripId, await this.actor(tripId, ctx.userId)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
|
||||
import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { JourneyDomainModule } from './journey-domain.module';
|
||||
import { TripJournalLinkController } from './trip-journal-link.controller';
|
||||
import { TripJournalLinkMcp } from './trip-journal-link.mcp';
|
||||
import { TripJournalLinkService } from './trip-journal-link.service';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* A trip showing one journal from the Journey addon: the link, its routes and
|
||||
* its MCP tools.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
JourneyDomainModule,
|
||||
TripMembershipModule,
|
||||
PermissionsModule,
|
||||
RealtimeModule,
|
||||
AddonsModule,
|
||||
McpSharedModule,
|
||||
MikroOrmModule.forFeature([TripJournalLinks, JourneyContributors]),
|
||||
],
|
||||
controllers: [TripJournalLinkController],
|
||||
providers: [TripJournalLinkService, TripJournalLinkMcp],
|
||||
exports: [TripJournalLinkService],
|
||||
})
|
||||
export class TripJournalLinkModule {}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { JourneyContributors } from '../../db/entities/JourneyContributors.entity';
|
||||
import { TripJournalLinks } from '../../db/entities/TripJournalLinks.entity';
|
||||
import type { JourneyContributorsRepository } from '../../db/repositories/JourneyContributors.repository';
|
||||
import type { TripJournalLinksRepository, TripPersonRow } from '../../db/repositories/TripJournalLinks.repository';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
import { forbidden, notFound } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
TripJournalAccessRole,
|
||||
TripJournalCandidate,
|
||||
TripJournalLinkMemberRequest,
|
||||
TripJournalLinkResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Who is acting: their id, and their account role for the admin bypass of trip permissions. */
|
||||
export interface TripJournalActor {
|
||||
id: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const NOT_LINKED = 'No journal is linked to this trip';
|
||||
|
||||
/**
|
||||
* A trip that shows one journal from the Journey addon as its own.
|
||||
*
|
||||
* Linking opens the journal to the trip members chosen for it, as viewers or
|
||||
* editors, for as long as the link holds them and they stay on the trip. That
|
||||
* access is read wherever journal access is read (`JourneyContributorsRepository`
|
||||
* folds it in), so the journal's own screens, its live updates and its MCP tools
|
||||
* serve linked members without knowing about trips.
|
||||
*
|
||||
* Linking a journal, or changing who it is open to, hands someone else's access
|
||||
* to it, so it takes the journal's owner, and on the trip the `journal_link`
|
||||
* permission. Taking the link down only takes access away, so the permission
|
||||
* alone or the journal's owner alone is enough.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripJournalLinkService {
|
||||
constructor(
|
||||
@InjectRepository(TripJournalLinks) private readonly links: TripJournalLinksRepository,
|
||||
@InjectRepository(JourneyContributors) private readonly contributors: JourneyContributorsRepository,
|
||||
private readonly journeys: JourneyDomainService,
|
||||
private readonly tripAccess: TripAccessService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly realtime: RealtimeService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/** The trip, or a 404 for someone who is not on it. */
|
||||
private async requireTrip(tripId: number, actor: TripJournalActor) {
|
||||
const trip = await this.tripAccess.findAccessible(tripId, actor.id);
|
||||
if (!trip) throw notFound('Trip not found');
|
||||
return trip;
|
||||
}
|
||||
|
||||
private async mayLink(trip: { user_id: number }, actor: TripJournalActor): Promise<boolean> {
|
||||
return this.permissions.checkPermission(
|
||||
'journal_link',
|
||||
actor.role,
|
||||
trip.user_id,
|
||||
actor.id,
|
||||
trip.user_id !== actor.id,
|
||||
);
|
||||
}
|
||||
|
||||
/** The access someone holds on a journal today, as owner, contributor or through a link. */
|
||||
private async accessOf(journeyId: number, ownerId: number, userId: number): Promise<TripJournalAccessRole | null> {
|
||||
if (userId === ownerId) return 'owner';
|
||||
const role = await this.contributors.findAccessRole(journeyId, userId);
|
||||
return role === 'owner' || role === 'editor' || role === 'viewer' ? role : null;
|
||||
}
|
||||
|
||||
/** The trip's journal as this person sees it, and what they may do with it. */
|
||||
async get(tripId: number, actor: TripJournalActor): Promise<TripJournalLinkResponse> {
|
||||
const trip = await this.requireTrip(tripId, actor);
|
||||
const canLink = await this.mayLink(trip, actor);
|
||||
const row = await this.links.findForTrip(tripId);
|
||||
if (!row) return { link: null, can_link: canLink, can_unlink: false, can_edit_members: false };
|
||||
|
||||
const isJournalOwner = row.journey_owner_id === actor.id;
|
||||
const myRole = await this.accessOf(row.journey_id, row.journey_owner_id, actor.id);
|
||||
const canEditMembers = canLink && isJournalOwner;
|
||||
const canUnlink = canLink || isJournalOwner;
|
||||
// Someone the journal is closed to, and who can do nothing about the link,
|
||||
// does not see that the trip has one.
|
||||
if (!myRole && !canLink) return { link: null, can_link: false, can_unlink: false, can_edit_members: false };
|
||||
|
||||
const members = canLink || isJournalOwner ? await this.links.listMembers(tripId) : null;
|
||||
return {
|
||||
link: {
|
||||
journey_id: row.journey_id,
|
||||
title: row.journey_title,
|
||||
subtitle: row.journey_subtitle,
|
||||
cover_image: row.journey_cover_image,
|
||||
owner_id: row.journey_owner_id,
|
||||
linked_by: row.linked_by,
|
||||
created_at: row.created_at,
|
||||
my_role: myRole,
|
||||
members:
|
||||
members?.map((m) => ({ user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), role: m.role })) ??
|
||||
null,
|
||||
},
|
||||
can_link: canLink,
|
||||
can_unlink: canUnlink,
|
||||
can_edit_members: canEditMembers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The people of the trip who could be let into a journal, with the access they
|
||||
* hold there today. A null `access` is someone the link would let in for the
|
||||
* first time, the person the dialog warns about. Only for the journal's owner.
|
||||
*/
|
||||
async candidates(tripId: number, actor: TripJournalActor, journeyId: number): Promise<TripJournalCandidate[]> {
|
||||
await this.requireTrip(tripId, actor);
|
||||
if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it');
|
||||
const people = (await this.links.listTripPeople(tripId)).filter((p) => !p.is_guest && p.user_id !== actor.id);
|
||||
return await Promise.all(
|
||||
people.map(async (p) => ({
|
||||
user_id: p.user_id,
|
||||
username: p.username,
|
||||
avatar_url: avatarUrl(p),
|
||||
is_trip_owner: p.is_trip_owner,
|
||||
access: await this.accessOf(journeyId, actor.id, p.user_id),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The members a link may hold: people on the trip, never a guest (who cannot
|
||||
* sign in) and never the journal's owner (who has it anyway). A repeated id
|
||||
* keeps its last role.
|
||||
*/
|
||||
private allowedMembers(
|
||||
people: TripPersonRow[],
|
||||
ownerId: number,
|
||||
members: TripJournalLinkMemberRequest[],
|
||||
): TripJournalLinkMemberRequest[] {
|
||||
const onTrip = new Set(people.filter((p) => !p.is_guest).map((p) => p.user_id));
|
||||
const byUser = new Map<number, TripJournalLinkMemberRequest>();
|
||||
for (const m of members) {
|
||||
if (m.user_id === ownerId) continue;
|
||||
if (!onTrip.has(m.user_id)) throw forbidden('Only members of this trip can be given the journal');
|
||||
byUser.set(m.user_id, m);
|
||||
}
|
||||
return [...byUser.values()];
|
||||
}
|
||||
|
||||
/** Shows a journal in the trip, to these members, replacing the one it showed. */
|
||||
async link(
|
||||
tripId: number,
|
||||
actor: TripJournalActor,
|
||||
journeyId: number,
|
||||
members: TripJournalLinkMemberRequest[],
|
||||
socketId?: string,
|
||||
): Promise<TripJournalLinkResponse> {
|
||||
const trip = await this.requireTrip(tripId, actor);
|
||||
if (!(await this.mayLink(trip, actor))) throw forbidden('No permission to link a journal');
|
||||
if (!(await this.journeys.isOwner(journeyId, actor.id))) throw forbidden('Only the journal owner can link it');
|
||||
const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), actor.id, members);
|
||||
const now = Date.now();
|
||||
await this.uow.transactional(async () => {
|
||||
await this.links.upsertLink(tripId, journeyId, actor.id, now);
|
||||
await this.links.replaceMembers(tripId, allowed, now);
|
||||
});
|
||||
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
|
||||
return this.get(tripId, actor);
|
||||
}
|
||||
|
||||
/** Changes who the linked journal is open to. */
|
||||
async setMembers(
|
||||
tripId: number,
|
||||
actor: TripJournalActor,
|
||||
members: TripJournalLinkMemberRequest[],
|
||||
socketId?: string,
|
||||
): Promise<TripJournalLinkResponse> {
|
||||
const trip = await this.requireTrip(tripId, actor);
|
||||
const row = await this.links.findForTrip(tripId);
|
||||
if (!row) throw notFound(NOT_LINKED);
|
||||
if (!(await this.mayLink(trip, actor)) || row.journey_owner_id !== actor.id) {
|
||||
throw forbidden('Only the journal owner can change who it is open to');
|
||||
}
|
||||
const allowed = this.allowedMembers(await this.links.listTripPeople(tripId), row.journey_owner_id, members);
|
||||
await this.uow.transactional(async () => {
|
||||
await this.links.replaceMembers(tripId, allowed, Date.now());
|
||||
});
|
||||
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
|
||||
return this.get(tripId, actor);
|
||||
}
|
||||
|
||||
/** Takes the journal off the trip; everyone it let in loses that access. */
|
||||
async unlink(tripId: number, actor: TripJournalActor, socketId?: string): Promise<TripJournalLinkResponse> {
|
||||
const trip = await this.requireTrip(tripId, actor);
|
||||
const row = await this.links.findForTrip(tripId);
|
||||
if (!row) throw notFound(NOT_LINKED);
|
||||
if (!(await this.mayLink(trip, actor)) && row.journey_owner_id !== actor.id) {
|
||||
throw forbidden('No permission to unlink the journal');
|
||||
}
|
||||
await this.links.deleteForTrip(tripId);
|
||||
this.realtime.broadcast(tripId, 'trip:journal:changed', {}, socketId);
|
||||
return this.get(tripId, actor);
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class MemoriesAccessService {
|
||||
/** MA2's owner-OR-contributor check, shared by MA2 and MA6's per-journey loop — the same two-branch `journeys`/`journey_contributors` logic `canAccessJourney` uses, re-implemented here (see the class docstring on why it isn't a call to `JourneyDomainService`). */
|
||||
private async ownerOrContributor(journeyId: number, userId: number): Promise<boolean> {
|
||||
if (await this.journeys.isOwnedByUser(journeyId, userId)) return true;
|
||||
return await this.journeyContributors.existsForUser(journeyId, userId);
|
||||
return await this.journeyContributors.hasAccess(journeyId, userId);
|
||||
}
|
||||
|
||||
async canAccessUserPhoto(
|
||||
|
||||
@@ -197,7 +197,12 @@ export class TripMembersService {
|
||||
* the route's one remaining raw-string caller).
|
||||
*/
|
||||
async removeMember(tripId: number, targetUserId: number): Promise<void> {
|
||||
await this.tripMembersRepo.remove(tripId, targetUserId);
|
||||
// A linked journal stops being open to someone who left; coming back later
|
||||
// must not quietly reopen it.
|
||||
await this.uow.transactional(async () => {
|
||||
await this.tripMembersRepo.remove(tripId, targetUserId);
|
||||
await this.tripMembersRepo.leaveJournalLink(tripId, targetUserId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,13 +29,12 @@ export async function makeDayAssignment(
|
||||
overrides: EntityData<DayAssignments> = {},
|
||||
): Promise<DayAssignmentRow> {
|
||||
const orderIndex = overrides.order_index ?? (await nextOrderIndex(orm, dayId));
|
||||
return createRow(orm, DayAssignments, {
|
||||
day: dayId,
|
||||
place: placeId,
|
||||
notes: null,
|
||||
...overrides,
|
||||
order_index: orderIndex,
|
||||
});
|
||||
return createRow(
|
||||
orm,
|
||||
DayAssignments,
|
||||
{ day: dayId, place: placeId, notes: null },
|
||||
{ ...overrides, order_index: orderIndex },
|
||||
);
|
||||
}
|
||||
|
||||
/** A note on the day, sorted last unless `sort_order` says otherwise. */
|
||||
@@ -45,15 +44,19 @@ export function makeDayNote(
|
||||
tripId: number,
|
||||
overrides: EntityData<DayNotes> = {},
|
||||
): Promise<DayNoteRow> {
|
||||
return createRow(orm, DayNotes, {
|
||||
day: dayId,
|
||||
trip: tripId,
|
||||
text: 'Test note',
|
||||
time: null,
|
||||
icon: '📝',
|
||||
sort_order: 9999,
|
||||
...overrides,
|
||||
});
|
||||
return createRow(
|
||||
orm,
|
||||
DayNotes,
|
||||
{
|
||||
day: dayId,
|
||||
trip: tripId,
|
||||
text: 'Test note',
|
||||
time: null,
|
||||
icon: '📝',
|
||||
sort_order: 9999,
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
/** A stay at `placeId` from the start day to the end day. */
|
||||
@@ -65,14 +68,18 @@ export function makeDayAccommodation(
|
||||
endDayId: number,
|
||||
overrides: EntityData<DayAccommodations> = {},
|
||||
): Promise<DayAccommodationRow> {
|
||||
return createRow(orm, DayAccommodations, {
|
||||
trip: tripId,
|
||||
place: placeId,
|
||||
startDay: startDayId,
|
||||
endDay: endDayId,
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
confirmation: null,
|
||||
...overrides,
|
||||
});
|
||||
return createRow(
|
||||
orm,
|
||||
DayAccommodations,
|
||||
{
|
||||
trip: tripId,
|
||||
place: placeId,
|
||||
startDay: startDayId,
|
||||
endDay: endDayId,
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
confirmation: null,
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,17 +28,26 @@ import type {
|
||||
* `SELECT *` returned.
|
||||
*/
|
||||
|
||||
/** Inserts one row and returns its primary key (the autoincrement id for most tables). */
|
||||
/**
|
||||
* Inserts one row and returns its primary key (the autoincrement id for most tables).
|
||||
*
|
||||
* A factory hands its caller's `overrides` in as their own argument rather than
|
||||
* spread into `data`: checking a spread `EntityData` against `RequiredEntityData`
|
||||
* expands every relation the entity reaches, and for the itinerary and tour rows
|
||||
* (day to trip, place to tour) that took tsc past its instantiation depth (TS2589).
|
||||
*/
|
||||
export function insertRow<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: RequiredEntityData<T>,
|
||||
overrides?: EntityData<T>,
|
||||
): Promise<Primary<T>> {
|
||||
const row = (overrides ? { ...data, ...overrides } : data) as RequiredEntityData<T>;
|
||||
// async, so a refused column rejects the returned promise rather than
|
||||
// throwing before the caller has one to await.
|
||||
return inContext(orm, async (em) => {
|
||||
assertWritable(em, entity, data);
|
||||
return em.insert(entity, data);
|
||||
assertWritable(em, entity, row);
|
||||
return em.insert(entity, row);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,14 +74,15 @@ export function insertRows<T extends object>(
|
||||
/**
|
||||
* Inserts one row into a table keyed by a single autoincrement `id` and reads
|
||||
* it back, defaults and all. For a table with a composite or natural key use
|
||||
* `insertRow` and then `findRow` with the key columns.
|
||||
* `insertRow` and then `findRow` with the key columns. `overrides` as for `insertRow`.
|
||||
*/
|
||||
export async function createRow<T extends { id: number }>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: RequiredEntityData<T>,
|
||||
overrides?: EntityData<T>,
|
||||
): Promise<EntityDTO<T>> {
|
||||
const id = await insertRow(orm, entity, data);
|
||||
const id = await insertRow(orm, entity, data, overrides);
|
||||
const row = await findRow(orm, entity, { id } as FilterQuery<T>);
|
||||
if (!row) throw new Error(`createRow: ${entity.name} ${String(id)} is not there after its insert`);
|
||||
return row;
|
||||
|
||||
@@ -11,7 +11,7 @@ export type TourRow = EntityDTO<Tours>;
|
||||
* of its own; its type defaults to 'hike', the one the tours migration seeds.
|
||||
*/
|
||||
export async function makeTour(orm: FactoryOrm, placeId: number, overrides: EntityData<Tours> = {}): Promise<TourRow> {
|
||||
await insertRow(orm, Tours, { place: placeId, tourTypeRef: 'hike', ...overrides });
|
||||
await insertRow(orm, Tours, { place: placeId, tourTypeRef: 'hike' }, overrides);
|
||||
const tour = await findRow(orm, Tours, { place: placeId });
|
||||
if (!tour) throw new Error(`makeTour: no tour for place ${placeId} after its insert`);
|
||||
return tour;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { TripJournalLinkMcp } from '../../src/nest/journey/trip-journal-link.mcp';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { addTripMember, makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* A trip that shows one journal from the Journey addon (TJL-*).
|
||||
*
|
||||
* The link opens the journal to the trip members chosen for it, as viewers or
|
||||
* editors, and that access is read wherever journal access is read. So these
|
||||
* cases go through the real routes end to end: the link's own routes, and the
|
||||
* journal's existing ones a linked member then reaches.
|
||||
*/
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: FactoryOrm;
|
||||
let broadcast: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
await app.init();
|
||||
orm = app.get(MikroORM);
|
||||
broadcast = vi.spyOn(app.get(RealtimeService), 'broadcast');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await setAddonEnabled(orm, 'journey', true);
|
||||
broadcast.mockClear();
|
||||
});
|
||||
|
||||
const http = () => request(app.getHttpServer());
|
||||
|
||||
let scenes = 0;
|
||||
|
||||
/**
|
||||
* A trip owned by `owner` with three members, and a journal of the owner's.
|
||||
* The names carry the scene's number, since usernames are unique and every
|
||||
* case builds its own scene on the one database.
|
||||
*/
|
||||
async function scene() {
|
||||
const n = ++scenes;
|
||||
const { user: owner } = await makeUser(orm, { username: `Owner ${n}` });
|
||||
const { user: lena } = await makeUser(orm, { username: `Lena ${n}` });
|
||||
const { user: tom } = await makeUser(orm, { username: `Tom ${n}` });
|
||||
const { user: mia } = await makeUser(orm, { username: `Mia ${n}` });
|
||||
const { user: stranger } = await makeUser(orm, { username: `Stranger ${n}` });
|
||||
const trip = await makeTrip(orm, owner.id);
|
||||
await Promise.all([lena, tom, mia].map((m) => addTripMember(orm, trip.id, m.id)));
|
||||
const journal = await makeJourney(orm, owner.id, { title: 'New York, the long weekend' });
|
||||
return { owner, lena, tom, mia, stranger, trip, journal };
|
||||
}
|
||||
|
||||
async function link(s: Awaited<ReturnType<typeof scene>>, members: { user_id: number; role: 'viewer' | 'editor' }[]) {
|
||||
return http()
|
||||
.put(`/api/trips/${s.trip.id}/journal`)
|
||||
.set('Cookie', authCookie(s.owner.id))
|
||||
.send({ journey_id: s.journal.id, members });
|
||||
}
|
||||
|
||||
describe('linking a journal to a trip', () => {
|
||||
it('TJL-001: a trip without a link says so, and its owner may link one', async () => {
|
||||
const s = await scene();
|
||||
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ link: null, can_link: true, can_unlink: false, can_edit_members: false });
|
||||
});
|
||||
|
||||
it('TJL-002: links the journal, opens it to the chosen members and tells the trip', async () => {
|
||||
const s = await scene();
|
||||
const res = await link(s, [
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.tom.id, role: 'editor' },
|
||||
]);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.link).toMatchObject({
|
||||
journey_id: s.journal.id,
|
||||
title: 'New York, the long weekend',
|
||||
my_role: 'owner',
|
||||
});
|
||||
expect(res.body.link.members.map((m: { username: string; role: string }) => [m.username, m.role])).toEqual([
|
||||
[s.lena.username, 'viewer'],
|
||||
[s.tom.username, 'editor'],
|
||||
]);
|
||||
expect(res.body).toMatchObject({ can_link: true, can_unlink: true, can_edit_members: true });
|
||||
expect(broadcast).toHaveBeenCalledWith(s.trip.id, 'trip:journal:changed', {}, undefined);
|
||||
});
|
||||
|
||||
it('TJL-003: a viewer reads the journal, finds it in their list, and cannot write to it', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
|
||||
const cookie = authCookie(s.lena.id);
|
||||
|
||||
const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie);
|
||||
expect(seen.body.link).toMatchObject({ journey_id: s.journal.id, my_role: 'viewer', members: null });
|
||||
expect(seen.body).toMatchObject({ can_link: false, can_unlink: false, can_edit_members: false });
|
||||
|
||||
const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie);
|
||||
expect(journal.status).toBe(200);
|
||||
expect(journal.body.my_role).toBe('viewer');
|
||||
|
||||
const list = await http().get('/api/journeys').set('Cookie', cookie);
|
||||
expect(list.body.journeys.map((j: { id: number }) => j.id)).toContain(s.journal.id);
|
||||
|
||||
const write = await http()
|
||||
.post(`/api/journeys/${s.journal.id}/entries`)
|
||||
.set('Cookie', cookie)
|
||||
.send({ title: 'not mine to write', entry_date: '2026-05-01' });
|
||||
expect(write.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TJL-004: an editor writes to the journal', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [{ user_id: s.tom.id, role: 'editor' }]);
|
||||
const res = await http()
|
||||
.post(`/api/journeys/${s.journal.id}/entries`)
|
||||
.set('Cookie', authCookie(s.tom.id))
|
||||
.send({ title: 'Brooklyn Bridge at dawn', entry_date: '2026-05-01' });
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it('TJL-005: a member left out sees no journal in the trip and cannot open it', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
|
||||
const cookie = authCookie(s.mia.id);
|
||||
const seen = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie);
|
||||
expect(seen.body).toEqual({ link: null, can_link: false, can_unlink: false, can_edit_members: false });
|
||||
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('TJL-006: someone outside the trip gets a 404 for its journal', async () => {
|
||||
const s = await scene();
|
||||
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.stranger.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('TJL-007: refuses a member without the permission, a journal not theirs, and people off the trip', async () => {
|
||||
const s = await scene();
|
||||
const asLena = await http()
|
||||
.put(`/api/trips/${s.trip.id}/journal`)
|
||||
.set('Cookie', authCookie(s.lena.id))
|
||||
.send({ journey_id: s.journal.id, members: [] });
|
||||
expect(asLena.status).toBe(403);
|
||||
|
||||
const other = await makeJourney(orm, s.lena.id);
|
||||
const notHers = await http()
|
||||
.put(`/api/trips/${s.trip.id}/journal`)
|
||||
.set('Cookie', authCookie(s.owner.id))
|
||||
.send({ journey_id: other.id, members: [] });
|
||||
expect(notHers.status).toBe(403);
|
||||
expect(notHers.body.error).toBe('Only the journal owner can link it');
|
||||
|
||||
const offTrip = await link(s, [{ user_id: s.stranger.id, role: 'viewer' }]);
|
||||
expect(offTrip.status).toBe(403);
|
||||
expect(offTrip.body.error).toBe('Only members of this trip can be given the journal');
|
||||
});
|
||||
|
||||
it('TJL-008: never lets a guest in, and leaves the journal owner off the list', async () => {
|
||||
const s = await scene();
|
||||
const { user: guest } = await makeUser(orm, { is_guest: 1 });
|
||||
await addTripMember(orm, s.trip.id, guest.id);
|
||||
const withGuest = await link(s, [{ user_id: guest.id, role: 'viewer' }]);
|
||||
expect(withGuest.status).toBe(403);
|
||||
|
||||
const withOwner = await link(s, [
|
||||
{ user_id: s.owner.id, role: 'viewer' },
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.lena.id, role: 'editor' },
|
||||
]);
|
||||
expect(withOwner.body.link.members.map((m: { user_id: number; role: string }) => [m.user_id, m.role])).toEqual([
|
||||
[s.lena.id, 'editor'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TJL-009: the owner may only link a journal they own, even with the admin permission set wide', async () => {
|
||||
const s = await scene();
|
||||
const lenaJournal = await makeJourney(orm, s.lena.id);
|
||||
const res = await http()
|
||||
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${lenaJournal.id}`)
|
||||
.set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('who a journal would be opened to', () => {
|
||||
it('TJL-010: lists the trip people but the caller and guests, with the access they hold today', async () => {
|
||||
const s = await scene();
|
||||
await addJourneyContributor(orm, s.journal.id, s.tom.id, 'editor');
|
||||
const res = await http()
|
||||
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=${s.journal.id}`)
|
||||
.set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(200);
|
||||
const byName = Object.fromEntries(
|
||||
res.body.candidates.map((c: { username: string; access: string | null }) => [c.username, c.access]),
|
||||
);
|
||||
expect(byName).toEqual({ [s.lena.username]: null, [s.mia.username]: null, [s.tom.username]: 'editor' });
|
||||
});
|
||||
|
||||
it('TJL-011: refuses a journey id that is not a number', async () => {
|
||||
const s = await scene();
|
||||
const res = await http()
|
||||
.get(`/api/trips/${s.trip.id}/journal/candidates?journey_id=abc`)
|
||||
.set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing and ending a link', () => {
|
||||
it('TJL-012: changing the members takes the access of whoever was left out', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.tom.id, role: 'editor' },
|
||||
]);
|
||||
const res = await http()
|
||||
.patch(`/api/trips/${s.trip.id}/journal/members`)
|
||||
.set('Cookie', authCookie(s.owner.id))
|
||||
.send({ members: [{ user_id: s.tom.id, role: 'viewer' }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
|
||||
const tom = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.tom.id));
|
||||
expect(tom.body.my_role).toBe('viewer');
|
||||
});
|
||||
|
||||
it('TJL-013: only the journal owner may change who it is open to', async () => {
|
||||
const s = await scene();
|
||||
await link(s, []);
|
||||
const res = await http()
|
||||
.patch(`/api/trips/${s.trip.id}/journal/members`)
|
||||
.set('Cookie', authCookie(s.lena.id))
|
||||
.send({ members: [{ user_id: s.mia.id, role: 'editor' }] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('TJL-014: changing members or unlinking a trip without a link answers 404', async () => {
|
||||
const s = await scene();
|
||||
const cookie = authCookie(s.owner.id);
|
||||
expect(
|
||||
(await http().patch(`/api/trips/${s.trip.id}/journal/members`).set('Cookie', cookie).send({ members: [] }))
|
||||
.status,
|
||||
).toBe(404);
|
||||
expect((await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', cookie)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('TJL-015: unlinking takes what the link gave and keeps what was given directly', async () => {
|
||||
const s = await scene();
|
||||
await addJourneyContributor(orm, s.journal.id, s.mia.id, 'viewer');
|
||||
await link(s, [
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.mia.id, role: 'editor' },
|
||||
]);
|
||||
const mia = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id));
|
||||
expect(mia.body.my_role).toBe('editor');
|
||||
|
||||
const res = await http().delete(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.link).toBeNull();
|
||||
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
|
||||
const after = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.mia.id));
|
||||
expect(after.body.my_role).toBe('viewer');
|
||||
});
|
||||
|
||||
it('TJL-016: a member who leaves the trip loses the journal, and coming back does not reopen it', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
|
||||
const left = await http()
|
||||
.delete(`/api/trips/${s.trip.id}/members/${s.lena.id}`)
|
||||
.set('Cookie', authCookie(s.lena.id));
|
||||
expect(left.status).toBe(200);
|
||||
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
|
||||
|
||||
await addTripMember(orm, s.trip.id, s.lena.id);
|
||||
expect((await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.lena.id))).status).toBe(404);
|
||||
});
|
||||
|
||||
it('TJL-017: a link member keeps a "hide skeletons" choice of their own', async () => {
|
||||
const s = await scene();
|
||||
await link(s, [{ user_id: s.lena.id, role: 'viewer' }]);
|
||||
const cookie = authCookie(s.lena.id);
|
||||
const set = await http()
|
||||
.patch(`/api/journeys/${s.journal.id}/preferences`)
|
||||
.set('Cookie', cookie)
|
||||
.send({ hide_skeletons: true });
|
||||
expect(set.status).toBe(200);
|
||||
const journal = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', cookie);
|
||||
expect(journal.body.hide_skeletons).toBe(true);
|
||||
const owner = await http().get(`/api/journeys/${s.journal.id}`).set('Cookie', authCookie(s.owner.id));
|
||||
expect(owner.body.hide_skeletons).toBe(false);
|
||||
});
|
||||
|
||||
it('TJL-018: with the Journey addon off the routes answer 404', async () => {
|
||||
const s = await scene();
|
||||
await setAddonEnabled(orm, 'journey', false);
|
||||
const res = await http().get(`/api/trips/${s.trip.id}/journal`).set('Cookie', authCookie(s.owner.id));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same over MCP', () => {
|
||||
it('TJL-019: links, reads, changes and unlinks with the rules of the routes', async () => {
|
||||
const s = await scene();
|
||||
const mcp = app.get(TripJournalLinkMcp);
|
||||
const ctx = (userId: number) => ({ userId }) as Parameters<TripJournalLinkMcp['getTripJournal']>[1];
|
||||
const json = (r: { content: { text: string }[] }) => JSON.parse(r.content[0].text);
|
||||
const run = <T>(fn: () => Promise<T>) => withRequestContext(orm as MikroORM, fn);
|
||||
|
||||
const linked = json(
|
||||
await run(() =>
|
||||
mcp.linkTripJournal(
|
||||
{ tripId: s.trip.id, journey_id: s.journal.id, members: [{ user_id: s.lena.id, role: 'viewer' }] },
|
||||
ctx(s.owner.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(linked.link.members).toHaveLength(1);
|
||||
expect(json(await run(() => mcp.getTripJournal({ tripId: s.trip.id }, ctx(s.lena.id)))).link.my_role).toBe(
|
||||
'viewer',
|
||||
);
|
||||
|
||||
await expect(
|
||||
run(() => mcp.setTripJournalMembers({ tripId: s.trip.id, members: [] }, ctx(s.lena.id))),
|
||||
).rejects.toMatchObject({ status: 403 });
|
||||
const changed = json(
|
||||
await run(() =>
|
||||
mcp.setTripJournalMembers(
|
||||
{ tripId: s.trip.id, members: [{ user_id: s.tom.id, role: 'editor' }] },
|
||||
ctx(s.owner.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(changed.link.members.map((m: { user_id: number }) => m.user_id)).toEqual([s.tom.id]);
|
||||
expect(json(await run(() => mcp.unlinkTripJournal({ tripId: s.trip.id }, ctx(s.owner.id)))).link).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -37,14 +37,15 @@ describe('ALL_ENTITIES', () => {
|
||||
* + scheduler_leases from the cron lease migration
|
||||
* + user_sessions, the session rows behind revocable session tokens
|
||||
* + ferrata_tiles, the stored via ferrata grid cells
|
||||
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions)
|
||||
* + paddle_regions, paddle_features and tour_obstacle_reviews, the paddle obstacle data and decisions
|
||||
* + trip_journal_links and trip_journal_link_members, the journal a trip shows and who it is open to)
|
||||
* is registered, and no two share a class name (a silent duplicate would
|
||||
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
|
||||
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
|
||||
* which de-duplicates by class name).
|
||||
*/
|
||||
it('ENT-003: exactly 138 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(138);
|
||||
it('ENT-003: exactly 140 entities, every class name unique', () => {
|
||||
expect(ALL_ENTITIES.length).toBe(140);
|
||||
const names = ALL_ENTITIES.map((s) => s.meta.className);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,9 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
// + paddle_regions: id, name; paddle_features: id, region_id, osm_ref, kind, geometry, tags, tag_hash and
|
||||
// its bounding box (a feature is only ever written whole, from one read of its region).
|
||||
// + tour_obstacle_reviews: place_id, obstacle_key, decision, both fingerprints, obstacle_snapshot, decided_at.
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 475;
|
||||
// + trip_journal_links: id, trip_id, journey_id, created_at; trip_journal_link_members: link_id, user_id,
|
||||
// added_at (a link and its members are always written whole).
|
||||
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 482;
|
||||
|
||||
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
|
||||
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => {
|
||||
|
||||
@@ -54,6 +54,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101044000_a_paddle_tour_plans_its_portages_and_locks',
|
||||
'Migration20200101044100_paddle_obstacles_from_openstreetmap',
|
||||
'Migration20200101044200_a_paddle_tour_records_its_obstacle_decisions',
|
||||
'Migration20200101044300_a_trip_links_a_journal',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* `TripJournalLinksRepository` and the link's reach (TJLREPO-*): one journal
|
||||
* per trip, the members it lets in, and the rule every read of link access
|
||||
* starts from: a member counts only while they are still on the linking trip.
|
||||
* Also the roster's side, `TripMembersRepository.leaveJournalLink`.
|
||||
*/
|
||||
import { JourneyContributors } from '../../../../src/db/entities/JourneyContributors.entity';
|
||||
import { TripJournalLinks } from '../../../../src/db/entities/TripJournalLinks.entity';
|
||||
import { TripMembers } from '../../../../src/db/entities/TripMembers.entity';
|
||||
import type { JourneyContributorsRepository } from '../../../../src/db/repositories/JourneyContributors.repository';
|
||||
import type { TripJournalLinksRepository } from '../../../../src/db/repositories/TripJournalLinks.repository';
|
||||
import type { TripMembersRepository } from '../../../../src/db/repositories/TripMembers.repository';
|
||||
import { createSnapshotTestDb } from '../../../helpers/db-mock';
|
||||
import { addJourneyContributor, makeJourney } from '../../../helpers/factories/journeys';
|
||||
import { countRows } from '../../../helpers/factories/rows';
|
||||
import { addTripMember, makeTrip } from '../../../helpers/factories/trips';
|
||||
import { makeUser } from '../../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../../helpers/test-db';
|
||||
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
let t: TestOrm;
|
||||
let links: TripJournalLinksRepository;
|
||||
let contributors: JourneyContributorsRepository;
|
||||
let roster: TripMembersRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestOrm(testDb);
|
||||
links = t.repo(TripJournalLinks);
|
||||
contributors = t.repo(JourneyContributors);
|
||||
roster = t.repo(TripMembers);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
t.clear();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
/** A trip owned by `owner` with Lena and Tom on it, a guest, and a journal of the owner's. */
|
||||
async function scene() {
|
||||
const { user: owner } = await makeUser(t, { username: 'Owner' });
|
||||
const { user: lena } = await makeUser(t, { username: 'Lena' });
|
||||
const { user: tom } = await makeUser(t, { username: 'Tom' });
|
||||
const { user: guest } = await makeUser(t, { username: 'Guest', is_guest: 1 });
|
||||
const trip = await makeTrip(t, owner.id);
|
||||
await Promise.all([lena, tom, guest].map((m) => addTripMember(t, trip.id, m.id)));
|
||||
const journal = await makeJourney(t, owner.id, { title: 'Japan' });
|
||||
return { owner, lena, tom, guest, trip, journal };
|
||||
}
|
||||
|
||||
describe('TripJournalLinksRepository', () => {
|
||||
it('TJLREPO-001: a trip without a link reads as none, and its people with the owner first', async () => {
|
||||
const s = await scene();
|
||||
expect(await links.findForTrip(s.trip.id)).toBeNull();
|
||||
expect(await links.listMembers(s.trip.id)).toEqual([]);
|
||||
const people = await links.listTripPeople(s.trip.id);
|
||||
expect(people.map((p) => [p.username, p.is_trip_owner, p.is_guest])).toEqual([
|
||||
['Owner', true, false],
|
||||
['Guest', false, true],
|
||||
['Lena', false, false],
|
||||
['Tom', false, false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TJLREPO-002: linking reads back with the journal, and relinking keeps one row per trip', async () => {
|
||||
const s = await scene();
|
||||
const other = await makeJourney(t, s.owner.id, { title: 'Iceland' });
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.upsertLink(s.trip.id, other.id, s.owner.id, 2);
|
||||
const row = await links.findForTrip(s.trip.id);
|
||||
expect(row).toMatchObject({ trip_id: s.trip.id, journey_id: other.id, journey_title: 'Iceland', created_at: 2 });
|
||||
expect(await countRows(t, TripJournalLinks, {})).toBe(1);
|
||||
});
|
||||
|
||||
it('TJLREPO-003: members are replaced as a list, and a trip without a link takes none', async () => {
|
||||
const s = await scene();
|
||||
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
|
||||
expect(await links.listMembers(s.trip.id)).toEqual([]);
|
||||
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(
|
||||
s.trip.id,
|
||||
[
|
||||
{ user_id: s.tom.id, role: 'editor' },
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
expect((await links.listMembers(s.trip.id)).map((m) => [m.username, m.role])).toEqual([
|
||||
['Lena', 'viewer'],
|
||||
['Tom', 'editor'],
|
||||
]);
|
||||
await links.replaceMembers(s.trip.id, [], 2);
|
||||
expect(await links.listMembers(s.trip.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('TJLREPO-004: the reach gives the stronger role across trips and ends with the membership', async () => {
|
||||
const s = await scene();
|
||||
const second = await makeTrip(t, s.owner.id);
|
||||
await addTripMember(t, second.id, s.lena.id);
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.upsertLink(second.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
|
||||
await links.replaceMembers(second.id, [{ user_id: s.lena.id, role: 'editor' }], 1);
|
||||
|
||||
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
|
||||
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBeUndefined();
|
||||
|
||||
await roster.remove(second.id, s.lena.id);
|
||||
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('viewer');
|
||||
await roster.remove(s.trip.id, s.lena.id);
|
||||
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('TJLREPO-005: a link member keeps a hide-skeletons choice of their own, apart from contributors', async () => {
|
||||
const s = await scene();
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(s.trip.id, [{ user_id: s.lena.id, role: 'viewer' }], 1);
|
||||
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(false);
|
||||
|
||||
await contributors.setHideSkeletons(s.journal.id, s.lena.id, 1);
|
||||
expect(await contributors.getHideSkeletons(s.journal.id, s.lena.id)).toBe(true);
|
||||
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(false);
|
||||
|
||||
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
|
||||
await contributors.setHideSkeletons(s.journal.id, s.tom.id, 1);
|
||||
expect(await contributors.getHideSkeletons(s.journal.id, s.tom.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('TJLREPO-006: a direct editor stays one, and a link editor outranks a direct viewer', async () => {
|
||||
const s = await scene();
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(
|
||||
s.trip.id,
|
||||
[
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.tom.id, role: 'editor' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
await addJourneyContributor(t, s.journal.id, s.lena.id, 'editor');
|
||||
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
|
||||
|
||||
expect(await contributors.findAccessRole(s.journal.id, s.lena.id)).toBe('editor');
|
||||
expect(await contributors.findAccessRole(s.journal.id, s.tom.id)).toBe('editor');
|
||||
expect(await contributors.hasAccess(s.journal.id, s.lena.id)).toBe(true);
|
||||
expect(await contributors.hasAccess(s.journal.id, s.guest.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('TJLREPO-007: unlinking takes the members along, and leaving the trip takes the leaver off', async () => {
|
||||
const s = await scene();
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(
|
||||
s.trip.id,
|
||||
[
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
{ user_id: s.tom.id, role: 'viewer' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
await roster.leaveJournalLink(s.trip.id, s.lena.id);
|
||||
expect((await links.listMembers(s.trip.id)).map((m) => m.username)).toEqual(['Tom']);
|
||||
|
||||
await links.deleteForTrip(s.trip.id);
|
||||
expect(await links.findForTrip(s.trip.id)).toBeNull();
|
||||
expect(await links.listMembers(s.trip.id)).toEqual([]);
|
||||
});
|
||||
it('TJLREPO-008: the journal lists who the links let in, per trip, apart from its contributors', async () => {
|
||||
const s = await scene();
|
||||
await links.upsertLink(s.trip.id, s.journal.id, s.owner.id, 1);
|
||||
await links.replaceMembers(
|
||||
s.trip.id,
|
||||
[
|
||||
{ user_id: s.tom.id, role: 'editor' },
|
||||
{ user_id: s.lena.id, role: 'viewer' },
|
||||
],
|
||||
1,
|
||||
);
|
||||
await addJourneyContributor(t, s.journal.id, s.tom.id, 'viewer');
|
||||
|
||||
const access = await contributors.listTripAccessForJourney(s.journal.id);
|
||||
expect(access.map((a) => [a.username, a.role, a.trip_id])).toEqual([['Lena', 'viewer', s.trip.id]]);
|
||||
expect(access[0]?.trip_title).toBe(s.trip.title);
|
||||
});
|
||||
});
|
||||
@@ -655,7 +655,7 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr
|
||||
// ---------------------------------------------------------------------------
|
||||
// MA1/MA2/MA6 — the journey half of the cross-domain photo-access checks
|
||||
// (Plan 3g Task 4), onto `JourneysRepository.isOwnedByUser`/
|
||||
// `JourneyContributorsRepository.existsForUser` via the private
|
||||
// `JourneyContributorsRepository.hasAccess` via the private
|
||||
// `ownerOrContributor` helper. MEM-ACCESS-006/006b/006c/014/014b above
|
||||
// already cover owner (006b), editor (006/014) and viewer (006c/014b)
|
||||
// contributors passing, and MEM-ACCESS-007/008/016 cover a stranger/no-link
|
||||
@@ -665,9 +665,9 @@ describe('canAccessTrekPhoto — MA5 parity (TripsRepository.findAccessible rewr
|
||||
// Mutation proof (performed once by hand, not run in CI): editing
|
||||
// `MemoriesAccessService.ownerOrContributor` from
|
||||
// `if (await this.journeys.isOwnedByUser(...)) return true; return await
|
||||
// this.journeyContributors.existsForUser(...);`
|
||||
// this.journeyContributors.hasAccess(...);`
|
||||
// to an AND (`return (await this.journeys.isOwnedByUser(...)) &&
|
||||
// (await this.journeyContributors.existsForUser(...));`) — collapsing the
|
||||
// (await this.journeyContributors.hasAccess(...));`) — collapsing the
|
||||
// owner-OR-contributor union into an owner-AND-contributor intersection —
|
||||
// turns MEM-ACCESS-006 (an editor who is NOT also the owner) and
|
||||
// MEM-ACCESS-006c (a viewer who is NOT also the owner) red: both contributors
|
||||
|
||||
@@ -241,6 +241,9 @@ const journey: TranslationStrings = {
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
|
||||
'journey.contributors.removed': 'Contributor removed', // en-fallback
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
|
||||
'journey.contributors.viaTrips': 'عبر الرحلات',
|
||||
'journey.contributors.viaTripsHint': 'يمكنهم فتح هذه اليوميات لأن رحلة ترتبط بها. غيّر ذلك من إعدادات الرحلة.',
|
||||
'journey.contributors.viaTrip': 'عبر {trip}',
|
||||
'journey.share.publicShare': 'Public Share', // en-fallback
|
||||
'journey.share.createLink': 'Create share link', // en-fallback
|
||||
'journey.share.linkCreated': 'Share link created', // en-fallback
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'مشاركة الرحلة',
|
||||
'members.inviteUser': 'دعوة مستخدم',
|
||||
'members.selectUser': 'اختر مستخدمًا…',
|
||||
'members.invite': 'دعوة',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.trip': 'الرحلة',
|
||||
'nav.share': 'مشاركة',
|
||||
'nav.settings': 'الإعدادات',
|
||||
'nav.admin': 'الإدارة',
|
||||
'nav.logout': 'تسجيل الخروج',
|
||||
|
||||
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
|
||||
'perm.action.collab_edit': 'التعاون (ملاحظات، استطلاعات، دردشة)',
|
||||
'perm.action.share_manage': 'إدارة روابط المشاركة',
|
||||
'perm.actionHint.trip_create': 'من يمكنه إنشاء رحلات جديدة',
|
||||
'perm.action.journal_link': 'ربط يوميات',
|
||||
'perm.actionHint.trip_edit': 'من يمكنه تغيير اسم الرحلة والتواريخ والوصف والعملة',
|
||||
'perm.actionHint.trip_delete': 'من يمكنه حذف رحلة نهائياً',
|
||||
'perm.actionHint.trip_archive': 'من يمكنه أرشفة أو إلغاء أرشفة رحلة',
|
||||
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
|
||||
'perm.actionHint.packing_edit': 'من يمكنه إدارة عناصر التعبئة والحقائب',
|
||||
'perm.actionHint.collab_edit': 'من يمكنه إنشاء ملاحظات واستطلاعات وإرسال رسائل',
|
||||
'perm.actionHint.share_manage': 'من يمكنه إنشاء أو حذف روابط المشاركة العامة',
|
||||
'perm.actionHint.journal_link': 'من يمكنه ربط يومية من Journey بالرحلة وفتحها لأعضائها',
|
||||
};
|
||||
export default perm;
|
||||
|
||||
@@ -119,5 +119,44 @@ const trip: TranslationStrings = {
|
||||
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" والحجز "{booking}" وأي مصروف مرتبط به.',
|
||||
'trip.confirm.deletePlaceBookedSame':
|
||||
'سيؤدي ذلك أيضًا إلى حذف الإقامة المحجوزة في "{name}" وحجزها وأي مصروف مرتبط به.',
|
||||
'trip.settings.title': 'إعدادات الرحلة',
|
||||
'trip.settings.people': 'الأشخاص',
|
||||
'trip.settings.peopleHint': 'من المشاركون في هذه الرحلة، والضيوف بلا حساب.',
|
||||
'trip.settings.sharing': 'المشاركة',
|
||||
'trip.settings.sharingHint': 'رابط للقراءة فقط لأي شخص، ورابط دعوة للانضمام.',
|
||||
'trip.tabs.journal': 'اليوميات',
|
||||
'trip.journal.title': 'اليوميات',
|
||||
'trip.journal.hint': 'اربط إحدى يومياتك بهذه الرحلة. من تمنحه الوصول يفتحها هنا مباشرة في تبويب اليوميات، بشكل حي.',
|
||||
'trip.journal.link': 'ربط يوميات',
|
||||
'trip.journal.linkSubmit': 'ربط اليوميات',
|
||||
'trip.journal.change': 'تغيير اليوميات',
|
||||
'trip.journal.editMembers': 'تعديل الوصول',
|
||||
'trip.journal.unlink': 'فك الربط',
|
||||
'trip.journal.unlinkTitle': 'فك ربط هذه اليوميات؟',
|
||||
'trip.journal.unlinkMessage':
|
||||
'يختفي تبويب اليوميات من هذه الرحلة. يفقد الأعضاء الذين كانوا يفتحون اليوميات عبر هذه الرحلة فقط وصولهم؛ أما من أضيفوا في اليوميات نفسها فيحتفظون به.',
|
||||
'trip.journal.linkedBadge': 'مرتبطة',
|
||||
'trip.journal.yourRole.owner': 'يومياتك',
|
||||
'trip.journal.yourRole.editor': 'يمكنك التعديل',
|
||||
'trip.journal.yourRole.viewer': 'يمكنك القراءة',
|
||||
'trip.journal.onlyYou': 'لا يملك أحد غيرك من هذه الرحلة وصولًا بعد.',
|
||||
'trip.journal.noJournals': 'ليست لديك يوميات بعد. أنشئ واحدة أولًا في Journey.',
|
||||
'trip.journal.pickPlaceholder': 'اختر يوميات',
|
||||
'trip.journal.noMembers': 'لا يوجد أحد غيرك في هذه الرحلة بعد.',
|
||||
'trip.journal.members': 'وصول أعضاء الرحلة',
|
||||
'trip.journal.allRead': 'الجميع يقرأ',
|
||||
'trip.journal.allNone': 'لا أحد',
|
||||
'trip.journal.roleNone': 'بلا وصول',
|
||||
'trip.journal.roleViewer': 'قراءة',
|
||||
'trip.journal.roleEditor': 'تعديل',
|
||||
'trip.journal.hasAccess': 'لديه وصول',
|
||||
'trip.journal.warnNew':
|
||||
'سيحصل {names} على وصول إلى هذه اليوميات لأول مرة، ومن ثم سيراها أيضًا في قائمة يومياته الخاصة.',
|
||||
'trip.journal.errorOffline': 'يتطلب ربط اليوميات اتصالًا بالإنترنت.',
|
||||
'trip.journal.errorForbidden': 'غير مسموح لك بذلك.',
|
||||
'trip.journal.errorGeneric': 'لم ينجح ذلك. حاول مرة أخرى.',
|
||||
'trip.journal.linked': 'تم ربط اليوميات',
|
||||
'trip.journal.membersSaved': 'تم حفظ الوصول',
|
||||
'trip.journal.unlinked': 'تم فك ربط اليوميات',
|
||||
};
|
||||
export default trip;
|
||||
|
||||
@@ -206,6 +206,10 @@ const journey: TranslationStrings = {
|
||||
'journey.contributors.removeConfirm': '{username} bu səyahət gündəliyindən çıxarılsın?',
|
||||
'journey.contributors.removed': 'İştirakçı çıxarıldı',
|
||||
'journey.contributors.removeFailed': 'İştirakçını çıxarmaq mümkün olmadı',
|
||||
'journey.contributors.viaTrips': 'Səyahətlər vasitəsilə',
|
||||
'journey.contributors.viaTripsHint':
|
||||
'Bu gündəliyi aça bilirlər, çünki bir səyahət onu bağlayır. Bunu səyahətin parametrlərində dəyişin.',
|
||||
'journey.contributors.viaTrip': '{trip} vasitəsilə',
|
||||
|
||||
'journey.share.publicShare': 'İctimai paylaşım',
|
||||
'journey.share.createLink': 'Paylaşım linki yarat',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'Səyahəti paylaş',
|
||||
'members.inviteUser': 'İstifadəçi dəvət et',
|
||||
'members.selectUser': 'İstifadəçi seçin…',
|
||||
'members.invite': 'Dəvət et',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.trip': 'Səyahət',
|
||||
'nav.share': 'Paylaş',
|
||||
'nav.settings': 'Tənzimləmələr',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.logout': 'Çıxış et',
|
||||
|
||||
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
|
||||
'perm.action.collab_edit': 'Əməkdaşlıq (qeydlər, sorğular və çat)',
|
||||
'perm.action.share_manage': 'Paylaşma keçidlərini idarə etmək',
|
||||
'perm.actionHint.trip_create': 'Yeni səyahətləri kimin yarada biləcəyi',
|
||||
'perm.action.journal_link': 'Jurnal bağlamaq',
|
||||
'perm.actionHint.trip_edit': 'Səyahətin adını, tarixlərini, təsvirini və valyutasını kimin dəyişə biləcəyi',
|
||||
'perm.actionHint.trip_delete': 'Səyahəti kimin həmişəlik silə biləcəyi',
|
||||
'perm.actionHint.trip_archive': 'Səyahəti kimin arxivləşdirə və ya arxivdən çıxara biləcəyi',
|
||||
@@ -47,6 +48,7 @@ const perm: TranslationStrings = {
|
||||
'perm.actionHint.packing_edit': 'Baqaj elementlərini və çantaları kimin idarə edə biləcəyi',
|
||||
'perm.actionHint.collab_edit': 'Qeydləri və sorğuları kimin yarada və mesajları kimin göndərə biləcəyi',
|
||||
'perm.actionHint.share_manage': 'Açıq paylaşma keçidlərini kimin yarada və ya silə biləcəyi',
|
||||
'perm.actionHint.journal_link': 'Kim Journey-dən jurnalı səfərə bağlaya və onu üzvlərinə aça bilər',
|
||||
};
|
||||
|
||||
export default perm;
|
||||
|
||||
@@ -110,6 +110,46 @@ const trip: TranslationStrings = {
|
||||
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, “{booking}” rezervasiyasını və onunla əlaqəli bütün xərcləri silir.',
|
||||
'trip.confirm.deletePlaceBookedSame':
|
||||
'Bu, həmçinin “{name}” məkanında rezervasiya edilmiş qalmanı, onun rezervasiyasını və əlaqəli bütün xərcləri silir.',
|
||||
'trip.settings.title': 'Səyahət parametrləri',
|
||||
'trip.settings.people': 'İnsanlar',
|
||||
'trip.settings.peopleHint': 'Bu səyahətdə kimlərin olduğu və hesabı olmayan qonaqlar.',
|
||||
'trip.settings.sharing': 'Paylaşma',
|
||||
'trip.settings.sharingHint': 'Hər kəs üçün yalnız oxunan keçid və qoşulmaq üçün dəvət keçidi.',
|
||||
'trip.tabs.journal': 'Gündəlik',
|
||||
'trip.journal.title': 'Gündəlik',
|
||||
'trip.journal.hint':
|
||||
'Gündəliklərinizdən birini bu səyahətə bağlayın. Giriş verdiyiniz şəxslər onu burada, Gündəlik bölməsində canlı açır.',
|
||||
'trip.journal.link': 'Gündəlik bağla',
|
||||
'trip.journal.linkSubmit': 'Gündəliyi bağla',
|
||||
'trip.journal.change': 'Gündəliyi dəyiş',
|
||||
'trip.journal.editMembers': 'Girişi redaktə et',
|
||||
'trip.journal.unlink': 'Ayır',
|
||||
'trip.journal.unlinkTitle': 'Bu gündəlik ayrılsın?',
|
||||
'trip.journal.unlinkMessage':
|
||||
'Gündəlik bölməsi bu səyahətdən yox olur. Gündəliyi yalnız bu səyahət vasitəsilə aça bilən üzvlər girişi itirir; gündəliyin özündə əlavə olunanlar onu saxlayır.',
|
||||
'trip.journal.linkedBadge': 'Bağlıdır',
|
||||
'trip.journal.yourRole.owner': 'Sizin gündəliyiniz',
|
||||
'trip.journal.yourRole.editor': 'Redaktə edə bilərsiniz',
|
||||
'trip.journal.yourRole.viewer': 'Oxuya bilərsiniz',
|
||||
'trip.journal.onlyYou': 'Bu səyahətdən hələ heç kimin girişi yoxdur.',
|
||||
'trip.journal.noJournals': 'Hələ gündəliyiniz yoxdur. Əvvəlcə Səyahət gündəliyində yaradın.',
|
||||
'trip.journal.pickPlaceholder': 'Gündəlik seçin',
|
||||
'trip.journal.noMembers': 'Bu səyahətdə hələ başqa heç kim yoxdur.',
|
||||
'trip.journal.members': 'Səyahət üzvləri üçün giriş',
|
||||
'trip.journal.allRead': 'Hamı oxuya bilər',
|
||||
'trip.journal.allNone': 'Heç kim',
|
||||
'trip.journal.roleNone': 'Giriş yoxdur',
|
||||
'trip.journal.roleViewer': 'Oxumaq',
|
||||
'trip.journal.roleEditor': 'Redaktə',
|
||||
'trip.journal.hasAccess': 'Girişi var',
|
||||
'trip.journal.warnNew':
|
||||
'{names} bu gündəliyə ilk dəfə giriş əldə edəcək və bundan sonra onu öz gündəlik siyahısında da görəcək.',
|
||||
'trip.journal.errorOffline': 'Gündəliyi bağlamaq üçün bağlantı lazımdır.',
|
||||
'trip.journal.errorForbidden': 'Bunu etməyə icazəniz yoxdur.',
|
||||
'trip.journal.errorGeneric': 'Alınmadı. Yenidən cəhd edin.',
|
||||
'trip.journal.linked': 'Gündəlik bağlandı',
|
||||
'trip.journal.membersSaved': 'Giriş yadda saxlanıldı',
|
||||
'trip.journal.unlinked': 'Gündəlik ayrıldı',
|
||||
};
|
||||
|
||||
export default trip;
|
||||
|
||||
@@ -265,6 +265,10 @@ const journey: TranslationStrings = {
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
|
||||
'journey.contributors.removed': 'Contributor removed', // en-fallback
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
|
||||
'journey.contributors.viaTrips': 'Por meio de viagens',
|
||||
'journey.contributors.viaTripsHint':
|
||||
'Podem abrir este diário porque uma viagem o vincula. Altere isso nas configurações da viagem.',
|
||||
'journey.contributors.viaTrip': 'por {trip}',
|
||||
'journey.editor.externalPhotos': 'External photos', // en-fallback
|
||||
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
|
||||
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'Compartilhar viagem',
|
||||
'members.inviteUser': 'Convidar usuário',
|
||||
'members.selectUser': 'Selecionar usuário…',
|
||||
'members.invite': 'Convidar',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.trip': 'Viagem',
|
||||
'nav.share': 'Compartilhar',
|
||||
'nav.settings': 'Configurações',
|
||||
'nav.admin': 'Admin',
|
||||
'nav.logout': 'Sair',
|
||||
|
||||
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
|
||||
'perm.action.collab_edit': 'Colaboração (notas, enquetes, chat)',
|
||||
'perm.action.share_manage': 'Gerenciar links de compartilhamento',
|
||||
'perm.actionHint.trip_create': 'Quem pode criar novas viagens',
|
||||
'perm.action.journal_link': 'Vincular diário',
|
||||
'perm.actionHint.trip_edit': 'Quem pode alterar nome, datas, descrição e moeda da viagem',
|
||||
'perm.actionHint.trip_delete': 'Quem pode excluir permanentemente uma viagem',
|
||||
'perm.actionHint.trip_archive': 'Quem pode arquivar ou desarquivar uma viagem',
|
||||
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
|
||||
'perm.actionHint.packing_edit': 'Quem pode gerenciar itens de bagagem e malas',
|
||||
'perm.actionHint.collab_edit': 'Quem pode criar notas, enquetes e enviar mensagens',
|
||||
'perm.actionHint.share_manage': 'Quem pode criar ou excluir links de compartilhamento públicos',
|
||||
'perm.actionHint.journal_link': 'Quem pode vincular um diário do Journey à viagem e abri-lo aos membros',
|
||||
};
|
||||
export default perm;
|
||||
|
||||
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
|
||||
'Isso também exclui a estadia reservada em “{name}”, a reserva “{booking}” e qualquer despesa vinculada a ela.',
|
||||
'trip.confirm.deletePlaceBookedSame':
|
||||
'Isso também exclui a estadia reservada em “{name}”, a reserva dela e qualquer despesa vinculada.',
|
||||
'trip.settings.title': 'Configurações da viagem',
|
||||
'trip.settings.people': 'Pessoas',
|
||||
'trip.settings.peopleHint': 'Quem está nesta viagem e os convidados sem conta.',
|
||||
'trip.settings.sharing': 'Compartilhar',
|
||||
'trip.settings.sharingHint': 'Um link somente leitura para qualquer pessoa e um link de convite para entrar.',
|
||||
'trip.tabs.journal': 'Diário',
|
||||
'trip.journal.title': 'Diário',
|
||||
'trip.journal.hint':
|
||||
'Vincule um dos seus diários a esta viagem. Quem tiver acesso abre ele aqui mesmo na aba Diário, ao vivo.',
|
||||
'trip.journal.link': 'Vincular um diário',
|
||||
'trip.journal.linkSubmit': 'Vincular diário',
|
||||
'trip.journal.change': 'Trocar diário',
|
||||
'trip.journal.editMembers': 'Editar acesso',
|
||||
'trip.journal.unlink': 'Desvincular',
|
||||
'trip.journal.unlinkTitle': 'Desvincular este diário?',
|
||||
'trip.journal.unlinkMessage':
|
||||
'A aba Diário some desta viagem. Membros que só conseguiam abrir o diário por esta viagem perdem o acesso; quem foi adicionado no próprio diário continua com ele.',
|
||||
'trip.journal.linkedBadge': 'Vinculado',
|
||||
'trip.journal.yourRole.owner': 'Seu diário',
|
||||
'trip.journal.yourRole.editor': 'Você pode editar',
|
||||
'trip.journal.yourRole.viewer': 'Você pode ler',
|
||||
'trip.journal.onlyYou': 'Ninguém mais desta viagem tem acesso ainda.',
|
||||
'trip.journal.noJournals': 'Você ainda não tem nenhum diário. Crie um primeiro em Jornada.',
|
||||
'trip.journal.pickPlaceholder': 'Escolha um diário',
|
||||
'trip.journal.noMembers': 'Ainda não há mais ninguém nesta viagem.',
|
||||
'trip.journal.members': 'Acesso para membros da viagem',
|
||||
'trip.journal.allRead': 'Todos podem ler',
|
||||
'trip.journal.allNone': 'Ninguém',
|
||||
'trip.journal.roleNone': 'Sem acesso',
|
||||
'trip.journal.roleViewer': 'Ler',
|
||||
'trip.journal.roleEditor': 'Editar',
|
||||
'trip.journal.hasAccess': 'Tem acesso',
|
||||
'trip.journal.warnNew':
|
||||
'{names} vai ganhar acesso a este diário pela primeira vez e a partir de então também vê-lo na própria lista de diários.',
|
||||
'trip.journal.errorOffline': 'Para vincular um diário é preciso conexão.',
|
||||
'trip.journal.errorForbidden': 'Você não tem permissão para isso.',
|
||||
'trip.journal.errorGeneric': 'Não deu certo. Tente novamente.',
|
||||
'trip.journal.linked': 'Diário vinculado',
|
||||
'trip.journal.membersSaved': 'Acesso salvo',
|
||||
'trip.journal.unlinked': 'Diário desvinculado',
|
||||
};
|
||||
export default trip;
|
||||
|
||||
@@ -265,6 +265,10 @@ const journey: TranslationStrings = {
|
||||
'journey.contributors.removeConfirm': "Vols eliminar {username} d'aquesta travesia?",
|
||||
'journey.contributors.removed': 'Col·laborador eliminat',
|
||||
'journey.contributors.removeFailed': "No s'ha pogut eliminar el col·laborador",
|
||||
'journey.contributors.viaTrips': 'A través de viatges',
|
||||
'journey.contributors.viaTripsHint':
|
||||
'Poden obrir aquest diari perquè un viatge el vincula. Canvia-ho a la configuració del viatge.',
|
||||
'journey.contributors.viaTrip': 'a través de {trip}',
|
||||
'journey.editor.externalPhotos': 'External photos', // en-fallback
|
||||
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
|
||||
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'Comparteix el viatge',
|
||||
'members.inviteUser': 'Convida un usuari',
|
||||
'members.selectUser': 'Selecciona un usuari…',
|
||||
'members.invite': 'Convida',
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { TranslationStrings } from '../types';
|
||||
|
||||
const nav: TranslationStrings = {
|
||||
'nav.trip': 'Viatge',
|
||||
'nav.share': 'Comparteix',
|
||||
'nav.settings': 'Configuració',
|
||||
'nav.admin': 'Administració',
|
||||
'nav.logout': 'Tanca la sessió',
|
||||
|
||||
@@ -32,6 +32,7 @@ const perm: TranslationStrings = {
|
||||
'perm.action.collab_edit': 'Col·laboració (notes, enquestes, xat)',
|
||||
'perm.action.share_manage': 'Gestiona els enllaços compartits',
|
||||
'perm.actionHint.trip_create': 'Permet crear viatges nous',
|
||||
'perm.action.journal_link': 'Enllaçar un diari',
|
||||
'perm.actionHint.trip_edit': 'Permet canviar el nom, les dates, la descripció i la moneda del viatge',
|
||||
'perm.actionHint.trip_delete': 'Permet eliminar permanentment un viatge',
|
||||
'perm.actionHint.trip_archive': "Permet arxivar o treure de l'arxiu un viatge",
|
||||
@@ -47,5 +48,6 @@ const perm: TranslationStrings = {
|
||||
'perm.actionHint.packing_edit': "Permet gestionar els elements d'equipatge i les bosses",
|
||||
'perm.actionHint.collab_edit': 'Permet crear notes, enquestes i enviar missatges',
|
||||
'perm.actionHint.share_manage': 'Permet crear o eliminar enllaços públics compartits',
|
||||
'perm.actionHint.journal_link': 'Qui pot enllaçar un diari de Journey al viatge i obrir-lo als seus membres',
|
||||
};
|
||||
export default perm;
|
||||
|
||||
@@ -99,5 +99,45 @@ const trip: TranslationStrings = {
|
||||
'També s’eliminen l’estada reservada a «{name}», la reserva «{booking}» i qualsevol despesa vinculada.',
|
||||
'trip.confirm.deletePlaceBookedSame':
|
||||
'També s’eliminen l’estada reservada a «{name}», la seva reserva i qualsevol despesa vinculada.',
|
||||
'trip.settings.title': 'Configuració del viatge',
|
||||
'trip.settings.people': 'Persones',
|
||||
'trip.settings.peopleHint': 'Qui és en aquest viatge i els convidats sense compte.',
|
||||
'trip.settings.sharing': 'Compartir',
|
||||
'trip.settings.sharingHint': "Un enllaç de només lectura per a tothom i un enllaç d'invitació per unir-se.",
|
||||
'trip.tabs.journal': 'Diari',
|
||||
'trip.journal.title': 'Diari',
|
||||
'trip.journal.hint':
|
||||
"Vincula un dels teus diaris a aquest viatge. Qui hi tingui accés l'obre aquí mateix a la pestanya Diari, en directe.",
|
||||
'trip.journal.link': 'Vincula un diari',
|
||||
'trip.journal.linkSubmit': 'Vincula el diari',
|
||||
'trip.journal.change': 'Canvia el diari',
|
||||
'trip.journal.editMembers': "Edita l'accés",
|
||||
'trip.journal.unlink': 'Desvincula',
|
||||
'trip.journal.unlinkTitle': 'Vols desvincular aquest diari?',
|
||||
'trip.journal.unlinkMessage':
|
||||
"La pestanya Diari desapareix d'aquest viatge. Els membres que només podien obrir el diari a través d'aquest viatge en perden l'accés; les persones afegides al mateix diari el conserven.",
|
||||
'trip.journal.linkedBadge': 'Vinculat',
|
||||
'trip.journal.yourRole.owner': 'El teu diari',
|
||||
'trip.journal.yourRole.editor': 'Pots editar',
|
||||
'trip.journal.yourRole.viewer': 'Pots llegir',
|
||||
'trip.journal.onlyYou': "Ningú més d'aquest viatge hi té accés encara.",
|
||||
'trip.journal.noJournals': 'Encara no tens cap diari. Crea-ne un primer a Travesia.',
|
||||
'trip.journal.pickPlaceholder': 'Tria un diari',
|
||||
'trip.journal.noMembers': 'Encara no hi ha ningú més en aquest viatge.',
|
||||
'trip.journal.members': 'Accés per als membres del viatge',
|
||||
'trip.journal.allRead': 'Tothom pot llegir',
|
||||
'trip.journal.allNone': 'Ningú',
|
||||
'trip.journal.roleNone': 'Sense accés',
|
||||
'trip.journal.roleViewer': 'Llegir',
|
||||
'trip.journal.roleEditor': 'Editar',
|
||||
'trip.journal.hasAccess': 'Té accés',
|
||||
'trip.journal.warnNew':
|
||||
"{names} tindrà accés a aquest diari per primera vegada i a partir d'ara també el veurà a la seva pròpia llista de diaris.",
|
||||
'trip.journal.errorOffline': 'Per vincular un diari cal connexió.',
|
||||
'trip.journal.errorForbidden': 'No tens permís per fer això.',
|
||||
'trip.journal.errorGeneric': 'No ha funcionat. Torna-ho a provar.',
|
||||
'trip.journal.linked': 'Diari vinculat',
|
||||
'trip.journal.membersSaved': 'Accés desat',
|
||||
'trip.journal.unlinked': 'Diari desvinculat',
|
||||
};
|
||||
export default trip;
|
||||
|
||||
@@ -270,6 +270,10 @@ const journey: TranslationStrings = {
|
||||
'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback
|
||||
'journey.contributors.removed': 'Contributor removed', // en-fallback
|
||||
'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback
|
||||
'journey.contributors.viaTrips': 'Přes cesty',
|
||||
'journey.contributors.viaTripsHint':
|
||||
'Mohou tento deník otevřít, protože ho cesta propojuje. Změňte to v nastavení cesty.',
|
||||
'journey.contributors.viaTrip': 'přes {trip}',
|
||||
'journey.editor.externalPhotos': 'External photos', // en-fallback
|
||||
'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback
|
||||
'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const members: TranslationStrings = {
|
||||
'members.shareTrip': 'Sdílet cestu',
|
||||
'members.inviteUser': 'Pozvat uživatele',
|
||||
'members.selectUser': 'Vyberte uživatele…',
|
||||
'members.invite': 'Pozvat',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user