diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json index d0de5a06d..96591f898 100644 --- a/client/scripts/eslint-baseline.json +++ b/client/scripts/eslint-baseline.json @@ -1,9 +1,9 @@ { "src": { "@typescript-eslint/no-non-null-assertion": 404, - "@typescript-eslint/no-misused-promises": 874, + "@typescript-eslint/no-misused-promises": 866, "no-console": 88, - "complexity": 230, + "complexity": 229, "@typescript-eslint/no-explicit-any": 147, "trek/no-swallowed-catch": 133, "max-params": 35, diff --git a/client/src/components/Admin/paddle/paddleRegionsModel.ts b/client/src/components/Admin/paddle/paddleRegionsModel.ts index 83a512f92..ecdc0a4f2 100644 --- a/client/src/components/Admin/paddle/paddleRegionsModel.ts +++ b/client/src/components/Admin/paddle/paddleRegionsModel.ts @@ -3,7 +3,7 @@ import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared'; /** How TREK loads a paddler's region by itself, in the order the switch shows them. */ export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off']; /** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */ -export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const; +const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const; /** The size choices, with a stored size outside the presets kept among them in its place. */ export function autoLoadSizes(current: number): number[] { diff --git a/client/src/components/Tours/toursPermissions.test.tsx b/client/src/components/Tours/toursPermissions.test.tsx index 9fd95d062..b3f3ebb29 100644 --- a/client/src/components/Tours/toursPermissions.test.tsx +++ b/client/src/components/Tours/toursPermissions.test.tsx @@ -148,12 +148,13 @@ describe('Tours permissions without database setup', () => { expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0) const view = vi.fn() const openSheet = vi.fn() - rerender( key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />) - fireEvent.click(screen.getByRole('option')) + rerender( key, tours: [tour], places: [], days: [], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />) + fireEvent.click(screen.getByRole('button', { name: new RegExp(tour.name) })) expect(view).toHaveBeenCalledWith(42) - const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement - expect(attach.disabled).toBe(!canAssign) - fireEvent.click(attach) + // Without the permission the row offers no plus at all, as an unplanned place's row does. + const attach = screen.queryByRole('button', { name: 'tours.addToDay' }) + expect(Boolean(attach)).toBe(canAssign) + if (attach) fireEvent.click(attach) expect(openSheet).toHaveBeenCalledTimes(canAssign ? 1 : 0) }) it.each([[false, false], [true, false], [false, true], [true, true]])('rails edit=%s assign=%s separate save and existing-tour attach', (canEdit, canAssign) => { diff --git a/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx index cc0632fe2..d42a5ed87 100644 --- a/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPaddleRegionsPanel.tsx @@ -266,9 +266,9 @@ function CatalogSearch({ admin }: { admin: Admin }) { ); } -function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { +/** What a region's read stands at: its status, where it came from, its objects, its date and its water. */ +function RegionBadges({ region }: { region: PaddleRegion }) { const { t, language } = useTranslation(); - const busy = admin.busyId === region.id; const percent = progressPercent(region); const status = region.status === 'importing' && region.progress @@ -280,6 +280,38 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { const km = region.network ? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km) : null; + return ( +
+ : undefined} + > + {status} + {percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`} + + {region.origin === 'auto' && ( + }> + {t('admin.paddleRegions.origin.auto')} + + )} + {region.imported_at && ( + {t('admin.paddleRegions.objects', { count: region.feature_count })} + )} + {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} + {km !== null && {t('admin.paddleRegions.network.km', { km })}} + {!region.network && region.imported_at && region.status !== 'importing' && ( + + {t('admin.paddleRegions.network.missing')} + + )} +
+ ); +} + +function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) { + const { t } = useTranslation(); + const busy = admin.busyId === region.id; return (
{region.name}
-
- : undefined} - > - {status} - {percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`} - - {region.origin === 'auto' && ( - }> - {t('admin.paddleRegions.origin.auto')} - - )} - {region.imported_at && ( - {t('admin.paddleRegions.objects', { count: region.feature_count })} - )} - {region.imported_at && date && {t('admin.paddleRegions.asOf', { date })}} - {km !== null && {t('admin.paddleRegions.network.km', { km })}} - {!region.network && region.imported_at && region.status !== 'importing' && ( - - {t('admin.paddleRegions.network.missing')} - - )} -
+ {region.status === 'failed' && region.error && (

{region.error} diff --git a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx index e603b9a73..997b8b28b 100644 --- a/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx +++ b/client/src/mobile/screens/admin/MAdminSchoolHolidayCatalog.tsx @@ -219,6 +219,188 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr ); } +type Catalog = ReturnType; +type Removal = { label: string; action: () => Promise }; + +/** Offline, loading and a failed read, each said where the card starts. */ +function CatalogNotices({ state, disabled }: { state: Catalog; disabled: boolean }) { + const { t } = useTranslation(); + return ( + <> + {state.offline && ( +

+ + {t('schoolCatalog.offline')} +

+ )} + {state.loading && ( +

+ + {t('common.loading')} +

+ )} + {state.error && !state.editor && ( +
+ + {state.error} + + void state.refresh()}> + {t('schoolCatalog.retry')} + +
+ )} + + ); +} + +/** The country shown, with adding one and deleting the shown one (only while it has no regions). */ +function CountryPicker({ + state, + selected, + regionCount, + disabled, + onSelect, + onAdd, + onRemove, +}: { + state: Catalog; + selected: string; + regionCount: number; + disabled: boolean; + onSelect: (code: string) => void; + onAdd: () => void; + onRemove: (removal: Removal) => void; +}) { + const { t } = useTranslation(); + return ( + <> + {state.catalog.countries.length > 0 ? ( +
+ {t('schoolCatalog.country')} +
+ onSelect(String(value))} + searchable + disabled={disabled} + size="sm" + options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))} + placeholder={t('schoolCatalog.country')} + style={{ flex: 1, minWidth: 0 }} + /> + + +
+ {regionCount > 0 && ( +

+ {t('schoolCatalog.deleteHint')} +

+ )} +
+ ) : ( + !state.loading && ( + + + {t('schoolCatalog.addCountry')} + + ) + )} + + ); +} + +/** The regions of a country, each opened or deleted, and the button that adds one. */ +function RegionList({ + state, + country, + regions, + disabled, + onRemove, +}: { + state: Catalog; + country: string; + regions: Catalog['catalog']['regions']; + disabled: boolean; + onRemove: (removal: Removal) => void; +}) { + const { t } = useTranslation(); + return ( + <> + {regions.length === 0 ? ( +

+ {t('schoolCatalog.empty')} +

+ ) : ( +
+ {regions.map((region) => ( +
+ + +
+ ))} +
+ )} + + + ); +} + /** * The school holiday catalog on the phone: the shared holiday regions per * country that everyone can pick in their vacation settings. The state is @@ -229,7 +411,7 @@ export default function MAdminSchoolHolidayCatalog() { const state = useSchoolHolidayCatalog(); const [country, setCountry] = useState(''); const [adding, setAdding] = useState(false); - const [remove, setRemove] = useState<{ label: string; action: () => Promise } | null>(null); + const [remove, setRemove] = useState(null); const selectedCountry = state.catalog.countries.find((item) => item.code === country)?.code || state.catalog.countries[0]?.code || ''; const regions = state.catalog.regions.filter((region) => region.country === selectedCountry); @@ -257,139 +439,26 @@ export default function MAdminSchoolHolidayCatalog() {
- {state.offline && ( -

- - {t('schoolCatalog.offline')} -

- )} - {state.loading && ( -

- - {t('common.loading')} -

- )} - {state.error && !state.editor && ( -
- - {state.error} - - void state.refresh()}> - {t('schoolCatalog.retry')} - -
- )} + - {state.catalog.countries.length > 0 ? ( -
- {t('schoolCatalog.country')} -
- setCountry(String(value))} - searchable - disabled={disabled} - size="sm" - options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))} - placeholder={t('schoolCatalog.country')} - style={{ flex: 1, minWidth: 0 }} - /> - - -
- {regions.length > 0 && ( -

- {t('schoolCatalog.deleteHint')} -

- )} -
- ) : ( - !state.loading && ( - setAdding(true)} className="self-start"> - - {t('schoolCatalog.addCountry')} - - ) - )} + setAdding(true)} + onRemove={setRemove} + /> {selectedCountry && ( - <> - {regions.length === 0 ? ( -

- {t('schoolCatalog.empty')} -

- ) : ( -
- {regions.map((region) => ( -
- - -
- ))} -
- )} - - + )}
diff --git a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx index 5aa655afb..066b4624a 100644 --- a/client/src/mobile/screens/dashboard/MNewTripSheet.tsx +++ b/client/src/mobile/screens/dashboard/MNewTripSheet.tsx @@ -22,8 +22,8 @@ import { endDateForNewStart, tripFormError } from '../../../components/Trips/tri interface MNewTripSheetProps { open: boolean - /** null = create, otherwise edit */ - trip: DashboardTrip | null + /** null or undefined = create, otherwise edit (the planner may not have its trip yet) */ + trip: DashboardTrip | null | undefined onClose: () => void onSave: (data: TripCreateRequest) => Promise<{ trip?: Trip } | void> | void onCoverUpdate?: (tripId: number, coverUrl: string | null) => void @@ -31,6 +31,9 @@ interface MNewTripSheetProps { onArchive?: () => void } +const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]' +const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint' + /** The reminder presets the desktop form offers; anything else is a custom number of days. */ const REMINDER_PRESETS = [0, 1, 3, 9] @@ -47,12 +50,12 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle * opens it for editing too. Archiving * lives here in edit mode, as decided for the grid cards. */ -export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement { +export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement { + const trip = tripProp ?? null const isEditing = !!trip const { t } = useTranslation() const can = useCanDo() const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR' - const fileRef = useRef(null) const canEditTrip = !isEditing || can('trip_edit', trip) const canUploadCover = !isEditing || can('trip_cover_upload', trip) const currentUser = useAuthStore(s => s.user) @@ -71,7 +74,6 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda const [error, setError] = useState('') const [isSaving, setIsSaving] = useState(false) const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true }) - const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover // A save whose new dates remove days with something on them, held until the // traveller confirms. The phone has no shift step, so bookings keep their dates. const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null) @@ -149,13 +151,8 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda } } - const handleCoverFile = cover.selectFile - const handleSearch = cover.search - const selectUnsplash = cover.selectPhoto - const removeCover = cover.removeCover - - const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]' - const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint' + const boxCls = BOX_CLS + const inputCls = INPUT_CLS const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null @@ -261,135 +258,17 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda {showReminder && ( -
- - {t('trips.reminder')} - - {!remindersEnabled ? ( -

{t('trips.reminderDisabledHint')}

- ) : ( - <> -
- {REMINDER_PRESETS.map(days => ( - { setError(''); setReminderDays(days); setCustomReminder(false) }}> - {days === 0 ? t('trips.reminderNone') : `${days} ${t(days === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`} - - ))} - { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}> - {t('trips.reminderCustom')} - -
- {customReminder && ( -
- setReminderDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))} - aria-label={t('trips.reminderDaysBefore')} - className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none" - /> - {t('trips.reminderDaysBefore')} -
- )} - - )} -
+ { setError(''); setReminderDays(days); setCustomReminder(false) }} + onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }} + onDays={setReminderDays} + /> )} - {canUploadCover && ( -
- { void handleCoverFile(e.target.files?.[0]); e.target.value = '' }} - /> - {coverPreview ? ( -
- -
- - -
-
- ) : ( - - )} - -
-
- setSearchQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleSearch() } }} - placeholder={t('dashboard.unsplashSearchPlaceholder')} - className={`${inputCls} pt-0 text-[0.8125rem] font-medium`} - /> -
- -
- {searchError &&

{searchError}

} - {searchResults.length > 0 && ( -
- {searchResults.map(photo => ( - - ))} -
- )} -
- )} + {canUploadCover && } {isEditing && onArchive && (
@@ -449,3 +328,149 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda ) } + +/** The trip's reminder: the presets, or a number of days of one's own; a hint while reminders are off. */ +function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: { + enabled: boolean + days: number + custom: boolean + onPreset: (days: number) => void + onCustom: () => void + onDays: (days: number) => void +}): React.ReactElement { + const { t } = useTranslation() + return ( +
+ + {t('trips.reminder')} + + {!enabled ? ( +

{t('trips.reminderDisabledHint')}

+ ) : ( + <> +
+ {REMINDER_PRESETS.map(preset => ( + onPreset(preset)}> + {preset === 0 ? t('trips.reminderNone') : `${preset} ${t(preset === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`} + + ))} + + {t('trips.reminderCustom')} + +
+ {custom && ( +
+ onDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))} + aria-label={t('trips.reminderDaysBefore')} + className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none" + /> + {t('trips.reminderDaysBefore')} +
+ )} + + )} +
+ ) +} + +/** The cover: a photo from the device or from Unsplash, with its preview. */ +function CoverField({ cover, title }: { cover: ReturnType; title: string }): React.ReactElement { + const { t } = useTranslation() + const fileRef = useRef(null) + const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover + return ( +
+ { void cover.selectFile(e.target.files?.[0]); e.target.value = '' }} + /> + {coverPreview ? ( +
+ +
+ + +
+
+ ) : ( + + )} + +
+
+ setSearchQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }} + placeholder={t('dashboard.unsplashSearchPlaceholder')} + className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`} + /> +
+ +
+ {searchError &&

{searchError}

} + {searchResults.length > 0 && ( +
+ {searchResults.map(photo => ( + + ))} +
+ )} +
+ ) +} diff --git a/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx index e3deb2aa9..bcf5330cb 100644 --- a/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx +++ b/client/src/mobile/screens/trip/places/MToursSelectionList.test.tsx @@ -41,7 +41,9 @@ const openSheet = vi.fn() function translate(table: Record) { return (key: string, params?: Record) => { - const value = table[key] ?? en[key] + // The one form is all a count of 1 needs here; the real t() picks it by Intl.PluralRules. + const one = params?.count === 1 ? table[`${key}.one`] : undefined + const value = one ?? table[key] ?? en[key] if (typeof value !== 'string') return key return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? '')) } @@ -115,9 +117,10 @@ describe('MToursSelectionList', () => { expect(screen.getByLabelText(en['tours.badge.gpx'] as string)).toHaveTextContent('GPX') }) - it('says once that tours are planned on a bigger screen, in German too', () => { + it('says once that tours are planned on a bigger screen, and counts in German', () => { render() - expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + // The badge reads the app's language, English in this suite. + expect(screen.getByText('Planned on desktop')).toBeInTheDocument() expect(screen.getByText('1 Tour')).toBeInTheDocument() expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/) }) @@ -126,17 +129,17 @@ describe('MToursSelectionList', () => { render() expect(screen.getByText('Noch keine Touren')).toBeInTheDocument() expect(screen.getByText(de['mobileTrip.toursEmptyBody'] as string)).toBeInTheDocument() - expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument() + expect(screen.getByText('Planned on desktop')).toBeInTheDocument() }) it('narrows the list by a facet and widens it again', () => { const other = { ...tour, place_id: 43, name: 'Other', max_hiking_difficulty: 4 } render() - fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ })) expect(screen.getByText('Other')).toBeInTheDocument() expect(screen.queryByText(tour.name)).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /^T4/ })) + fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ })) expect(screen.getByText(tour.name)).toBeInTheDocument() }) }) diff --git a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx index d270f88fd..c69d2d2f7 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx @@ -35,7 +35,7 @@ interface MTripSettingsSheetProps { open: boolean; onClose: () => void; tripId: number; - tripTitle: string; + tripTitle?: string; onMembersChanged?: () => void; } @@ -214,6 +214,215 @@ function InviteLinkSection({ tripId }: { tripId: number }) { ); } +type Members = ReturnType; +type Translate = ReturnType['t']; +type Ask = (question: Question) => void; + +/** What a question says and what its button does, by the kind of question. */ +function questionWords(question: Question | null, t: Translate): { text: string; action: string } { + if (!question) return { text: '', action: '' }; + if (question.kind === 'transfer') + return { text: t('members.confirmTransfer', { name: question.name }), action: t('members.makeOwner') }; + if (question.kind === 'guest') return { text: t('members.confirmRemoveGuest'), action: t('members.removeAccess') }; + return question.self + ? { text: t('members.confirmLeave'), action: t('members.leaveTrip') } + : { text: t('members.confirmRemove'), action: t('members.removeAccess') }; +} + +/** Make owner and remove (or leave) beside a member, as far as the reader may. */ +function MemberActions({ member, m, ask }: { member: ListedMember; m: Members; ask: Ask }) { + const { t } = useTranslation(); + const self = member.id === m.user?.id; + const canRemove = self || (m.canManageMembers && member.role !== 'owner'); + return ( + <> + {m.isCurrentOwner && member.role !== 'owner' && ( + + )} + {canRemove && ( + + )} + + ); +} + +/** The badges beside a member's name: you, and the owner's crown. */ +function MemberBadges({ member, m }: { member: ListedMember; m: Members }) { + const { t } = useTranslation(); + return ( + <> + {member.id === m.user?.id && ( + {t('members.you')} + )} + {member.role === 'owner' && ( + + + {t('members.owner')} + + )} + + ); +} + +/** Who has access: the invite picker for those who may add people, then everyone with their badges. */ +function AccessSection({ m, ask }: { m: Members; ask: Ask }) { + const { t } = useTranslation(); + return ( +
+ {m.canManageMembers && ( +
+ m.setSelectedUserId(String(value))} + placeholder={t('members.selectUser')} + options={[ + { value: '', label: t('members.selectUser') }, + ...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })), + ]} + searchable + size="sm" + style={{ flex: 1, minWidth: 0 }} + /> + +
+ )} + {m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && ( +

+ {t('members.allHaveAccess')} +

+ )} + {m.loading && m.realMembers.length === 0 ? ( +
+ ) : ( + m.realMembers.map((member) => ( + } + > + + + )) + )} +
+ ); +} + +/** A guest's row, or the field its new name is typed into. */ +function GuestRow({ guest, m, ask }: { guest: Members['guests'][number]; m: Members; ask: Ask }) { + const { t } = useTranslation(); + if (m.renamingGuestId === guest.id) { + return ( + m.setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.commitRename(guest.id); + if (e.key === 'Escape') m.cancelRename(); + }} + onBlur={() => void m.commitRename(guest.id)} + className={FIELD} + /> + ); + } + return ( + + + {t('members.guest')} + + } + > + {m.isCurrentOwner && ( + <> + + + + )} + + ); +} + +/** The guests without an account, and for the owner the field that adds one. */ +function GuestsSection({ m, ask }: { m: Members; ask: Ask }) { + const { t } = useTranslation(); + if (!m.isCurrentOwner && m.guests.length === 0) return null; + return ( +
0 ? `${t('members.guests')} ${m.guests.length}` : t('members.guests')} + hint={t('members.guestsHint')} + > + {m.guests.map((guest) => ( + + ))} + {m.isCurrentOwner && ( +
+ m.setNewGuestName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void m.addGuest(); + }} + className={FIELD} + /> + +
+ )} +
+ ); +} + /** * The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has * access, the guests without an account, the public link, the invite link and @@ -230,6 +439,7 @@ export default function MTripSettingsSheet({ const { t } = useTranslation(); const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged }); const [question, setQuestion] = useState(null); + const words = questionWords(question, t); const answer = () => { if (!question) return; @@ -238,52 +448,6 @@ export default function MTripSettingsSheet({ else void m.removeGuest(question.id); setQuestion(null); }; - const questionText = !question - ? '' - : question.kind === 'transfer' - ? t('members.confirmTransfer', { name: question.name }) - : question.kind === 'guest' - ? t('members.confirmRemoveGuest') - : question.self - ? t('members.confirmLeave') - : t('members.confirmRemove'); - const questionAction = - question?.kind === 'transfer' - ? t('members.makeOwner') - : question?.kind === 'remove' && question.self - ? t('members.leaveTrip') - : t('members.removeAccess'); - - const memberActions = (member: ListedMember) => { - const self = member.id === m.user?.id; - const canRemove = self || (m.canManageMembers && member.role !== 'owner'); - return ( - <> - {m.isCurrentOwner && member.role !== 'owner' && ( - - )} - {canRemove && ( - - )} - - ); - }; return ( <> @@ -299,148 +463,8 @@ export default function MTripSettingsSheet({
-
- {m.canManageMembers && ( -
- m.setSelectedUserId(String(value))} - placeholder={t('members.selectUser')} - options={[ - { value: '', label: t('members.selectUser') }, - ...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })), - ]} - searchable - size="sm" - style={{ flex: 1, minWidth: 0 }} - /> - -
- )} - {m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && ( -

- {t('members.allHaveAccess')} -

- )} - {m.loading && m.realMembers.length === 0 ? ( -
- ) : ( - m.realMembers.map((member) => ( - - {member.id === m.user?.id && ( - {t('members.you')} - )} - {member.role === 'owner' && ( - - - {t('members.owner')} - - )} - - } - > - {memberActions(member)} - - )) - )} -
- - {(m.isCurrentOwner || m.guests.length > 0) && ( -
0 ? ` ${m.guests.length}` : ''}`} - hint={t('members.guestsHint')} - > - {m.guests.map((guest) => - m.renamingGuestId === guest.id ? ( - m.setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void m.commitRename(guest.id); - if (e.key === 'Escape') m.cancelRename(); - }} - onBlur={() => void m.commitRename(guest.id)} - className={FIELD} - /> - ) : ( - - - {t('members.guest')} - - } - > - {m.isCurrentOwner && ( - <> - - - - )} - - ) - )} - {m.isCurrentOwner && ( -
- m.setNewGuestName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') void m.addGuest(); - }} - className={FIELD} - /> - -
- )} -
- )} - + + {m.canManageShare && } {m.canManageShare && } @@ -450,9 +474,9 @@ export default function MTripSettingsSheet({ setQuestion(null)} - title={questionAction} - message={questionText} - confirmLabel={questionAction} + title={words.action} + message={words.text} + confirmLabel={words.action} cancelLabel={t('common.cancel')} danger={question?.kind !== 'transfer'} onConfirm={answer} diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index 8cc0c02f3..74166fdba 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -200,7 +200,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { {/* Trip edit + share/members, opened from the Mehr sheet. */} { await tripActions.updateTrip(tripId, data) @@ -212,7 +212,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { open={sheet?.id === 'members'} onClose={shell.closeSheet} tripId={tripId} - tripTitle={trip?.title ?? ''} + tripTitle={trip?.title} onMembersChanged={planner.refreshMembers} /> diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts index 47414e6be..c577a1e8c 100644 --- a/client/src/pages/journeyDetail/useJourneyDetail.ts +++ b/client/src/pages/journeyDetail/useJourneyDetail.ts @@ -511,7 +511,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO dayImport, dayImportDates, } = useJourneyDetailLayers({ journey: current, loaded: journeyLoaded, canEditEntries, loadJourney, dawarich, dawarichByDate, dawarichTrail, - tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, allLayers: isMobile, + tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, }) /** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */ diff --git a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts index a925f7335..fee79b031 100644 --- a/client/src/pages/journeyDetail/useJourneyDetailLayers.ts +++ b/client/src/pages/journeyDetail/useJourneyDetailLayers.ts @@ -5,6 +5,7 @@ import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayer import { useJourneyRecordingPrep } from '../../components/Journey/useJourneyRecordingPrep'; import type { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'; import type { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'; +import { useIsMobile } from '../../hooks/useIsMobile'; import { useAddonStore } from '../../store/addonStore'; import type { JourneyDetail } from '../../store/journeyStore'; import { useJourneyDetailDayImport } from './useJourneyDetailDayImport'; @@ -30,7 +31,6 @@ export function useJourneyDetailLayers({ sidebarMapItems, photoCount, showTripTracks, - allLayers = false, }: { journey: JourneyDetail | null; /** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */ @@ -44,8 +44,6 @@ export function useJourneyDetailLayers({ sidebarMapItems: ReadonlyArray<{ id: string; entry_date: string; dayColor: string }>; photoCount: number; showTripTracks: boolean; - /** The phone: every map layer on, as it offers no switches (see useJourneyMapLayers). */ - allLayers?: boolean; }) { // The editor's own status, the owner's switch, and where each day of the trips stands. const isAddonEnabled = useAddonStore((s) => s.isEnabled); @@ -114,7 +112,7 @@ export function useJourneyDetailLayers({ recordedRead, colorOfDay, dateOfEntry, - allLayers, + allLayers: useIsMobile(), }); // The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list. diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx index 43340656f..a25a1e8cb 100644 --- a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -282,7 +282,8 @@ describe('MAdminAddonManager', () => { expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true'); // Journey + three providers, and providers are not shown as top-level rows. expect(screen.getAllByRole('switch')).toHaveLength(4); - expect(screen.queryByText('Global')).toBeInTheDocument(); + // The band and the addon's type badge both say Global. + expect(screen.getAllByText('Global')).toHaveLength(2); }); it('FE-MOB-AADD-015: toggling a provider persists it and rolls back on failure', async () => { @@ -419,7 +420,8 @@ describe('MAdminAddonManager', () => { expect(screen.getByRole('switch', { name: 'Nextcloud' })).toHaveAttribute('aria-checked', 'true'); // Documents plus two providers, and neither provider carries a type badge of its own. expect(screen.getAllByRole('switch')).toHaveLength(3); - expect(screen.getAllByText('Trip')).toHaveLength(1); + // The band and the addon's type badge. + expect(screen.getAllByText('Trip')).toHaveLength(2); }); it('FE-MOB-AADD-029: the shelf stays hidden while Documents is off', async () => { diff --git a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx index f85ef1535..381a83f4b 100644 --- a/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx +++ b/client/tests/unit/mobile/trip/MPlacesBrowser.test.tsx @@ -383,7 +383,7 @@ describe('MPlacesBrowser', () => { expect(list).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: /^Tours$/i })) - expect(screen.getByRole('option', { name: /Ridge walk/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Ridge walk/ })).toBeInTheDocument() expect(screen.queryByText('Louvre')).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'mobileTrip.importPlaces' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument() diff --git a/scripts/ci/knip-baseline.json b/scripts/ci/knip-baseline.json index d77421e48..a33c09736 100644 --- a/scripts/ci/knip-baseline.json +++ b/scripts/ci/knip-baseline.json @@ -151,7 +151,7 @@ "client/src/components/Studio/grids.ts": 1, "client/src/components/Studio/mapTiles.ts": 1, "client/src/components/Studio/templates.ts": 1, - "client/src/components/Trips/useTripShare.ts": 3, + "client/src/components/Trips/useTripShare.ts": 2, "client/src/components/shared/DialogShell.tsx": 1, "client/src/components/shared/SlidingTabs.tsx": 1, "client/src/components/shared/elevationProfileModel.ts": 1,