mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ce5cb733 | |||
| 74c47a6158 | |||
| 8b291a9a7e | |||
| 07e4a558e6 | |||
| 9228ef2bce | |||
| 15f22058e9 | |||
| b383230928 | |||
| 6cbefd23e8 | |||
| 23401fcdb9 | |||
| a2cb6a12d1 | |||
| ffb6546e51 | |||
| b82962f95b | |||
| b1ce7c303d | |||
| c5d10dfd6f | |||
| ed3216f65d | |||
| 31fa8908fc | |||
| 1428637694 | |||
| 3f49ef8d21 | |||
| 348d584a02 | |||
| 4cd25d3047 | |||
| 293dc9e1f1 | |||
| c026c5b1c9 | |||
| a5d87d295a | |||
| 7c857173cf | |||
| c907e0d5e5 | |||
| 9cc427d156 | |||
| 088eef6c6b | |||
| e1af2049f9 | |||
| 5feced54d2 | |||
| 2ab2dcc3dc | |||
| fc6843528c | |||
| faa27e3608 | |||
| 5d849ec292 | |||
| b6a86275f6 | |||
| 1e6ab069cd | |||
| 17668e5ff2 | |||
| 700918d533 | |||
| 98b97edb00 | |||
| 639c40f8de | |||
| b7f9c1e8ae | |||
| 5e5fe288d8 | |||
| 2d460eadfb | |||
| 33e737e453 | |||
| 46a89e7a90 | |||
| cfe2b80a5d | |||
| da2c881259 | |||
| 5decdcfa13 | |||
| 1e6f1fe95c | |||
| 674dededd3 | |||
| 5f63736b5d | |||
| 2d6454976e | |||
| c4fbf03928 | |||
| 03bd5c77af | |||
| c21e86e3bd | |||
| d1aa573167 | |||
| 49b0ce28d5 | |||
| 2e0c496a93 | |||
| 2e00c998c0 | |||
| b9e8821114 | |||
| a96bebc8bb | |||
| 9f6cd3dc6d | |||
| 37d7365707 | |||
| 354a89f25b | |||
| 0a3ba7a4d7 | |||
| c59425947a | |||
| 4704bf5903 | |||
| d5a3427cbb | |||
| a0c089baa4 | |||
| fef5c39581 | |||
| 2834967919 | |||
| 3472d06e62 | |||
| 9bfdc19e68 | |||
| 595a76321f | |||
| 537c450653 | |||
| 92bb834f2d | |||
| 394201cd32 | |||
| 54b0e4eed4 | |||
| e615011e73 | |||
| e70d7397dd | |||
| f341efccc9 | |||
| 2cac1ccd4f | |||
| 9d33bd71da | |||
| a4f8fabde0 | |||
| 1984e151c1 | |||
| dce348b947 | |||
| 78548ca2ba | |||
| 8a0932df72 |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.1.1
|
||||
version: 4.2.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.1.1"
|
||||
appVersion: "4.2.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.1.1",
|
||||
"version": "4.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,4 +26,25 @@ for (const { name, size } of sizes) {
|
||||
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||
}
|
||||
|
||||
// Maskable variants: Android lays a maskable icon edge to edge under the round
|
||||
// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80%
|
||||
// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and
|
||||
// recenter it, keeping the brand gradient as the full background.
|
||||
const maskableSvg = svgBuffer
|
||||
.toString()
|
||||
.replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)');
|
||||
|
||||
const maskableSizes = [
|
||||
{ name: 'icon-maskable-192x192.png', size: 192 },
|
||||
{ name: 'icon-maskable-512x512.png', size: 512 },
|
||||
];
|
||||
|
||||
for (const { name, size } of maskableSizes) {
|
||||
await sharp(Buffer.from(maskableSvg), { density: 300 })
|
||||
.resize(size, size)
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(join(iconsDir, name));
|
||||
console.log(` \u2713 ${name} (${size}x${size}, maskable)`);
|
||||
}
|
||||
|
||||
console.log('PWA icons generated.');
|
||||
|
||||
@@ -301,6 +301,9 @@ describe('client > endpoint wiring', () => {
|
||||
{ n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' },
|
||||
{ n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' },
|
||||
{ n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' },
|
||||
{ n: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' },
|
||||
{ n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' },
|
||||
{ n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' },
|
||||
{ n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
|
||||
@@ -862,4 +865,21 @@ describe('client > multipart uploads', () => {
|
||||
expect(post.mock.calls[1][0]).toBe('/backup/upload-restore')
|
||||
expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip')
|
||||
})
|
||||
|
||||
it('FE-APISURF-053: every channel test outlives the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
|
||||
// The server budgets up to 20s for an SMTP dial and 10s for a webhook or
|
||||
// ntfy ping; aborting at 8s threw away the reason and left the admin with a
|
||||
// bare "failed" (#2196).
|
||||
await notificationsApi.testSmtp('a@b.c')
|
||||
await notificationsApi.testWebhook('https://hook')
|
||||
await notificationsApi.testNtfy({ topic: 't' })
|
||||
await notificationsApi.testChannel('plugin/ch')
|
||||
|
||||
expect(post.mock.calls).toHaveLength(4)
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: 40000 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+42
-19
@@ -30,7 +30,7 @@ import {
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
@@ -53,6 +53,12 @@ import {
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
type PluginSettingsField,
|
||||
type PluginInstanceConfigResponse,
|
||||
type PluginInstanceConfigUpdated,
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -122,7 +128,7 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
function translateRateLimit(): string {
|
||||
const fallback = RATE_LIMIT_MESSAGES['en']!
|
||||
try {
|
||||
const lang = localStorage.getItem('app_language') || 'en'
|
||||
const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en'
|
||||
return RATE_LIMIT_MESSAGES[lang] ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
@@ -467,6 +473,8 @@ export const assignmentsApi = {
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
// Day-specific note on an assignment (#2163) — null clears it.
|
||||
updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data),
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
|
||||
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
|
||||
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
|
||||
@@ -535,8 +543,8 @@ export const adminApi = {
|
||||
plugins: () => apiClient.get('/admin/plugins').then(r => r.data),
|
||||
pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data),
|
||||
pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginInstall: (id: string, opts?: Pick<PluginInstallRequest, 'version' | 'constraint' | 'withDependencies'>) =>
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
@@ -556,6 +564,17 @@ export const adminApi = {
|
||||
// Dev-link (dev-only): register a plugin from a local built dir + hot-reload it.
|
||||
pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data),
|
||||
pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data),
|
||||
// The admin-owned `scope:'instance'` settings: the declared fields plus the stored
|
||||
// values (secrets masked). Saving may RESTART a running plugin — the child gets its
|
||||
// config once at init — and `restarted` reports whether that happened.
|
||||
pluginConfig: (id: string): Promise<PluginInstanceConfigResponse> =>
|
||||
apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data),
|
||||
pluginSaveConfig: (id: string, config: Record<string, unknown>): Promise<PluginInstanceConfigUpdated> =>
|
||||
apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data),
|
||||
// Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the
|
||||
// clicking admin. 404 when the plugin is inactive (no child process to run it).
|
||||
runPluginAction: (id: string, key: string): Promise<PluginActionResult> =>
|
||||
apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data),
|
||||
// Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name
|
||||
// the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns
|
||||
// the plugin with the widened allow-list.
|
||||
@@ -782,16 +801,13 @@ export interface PluginAtlasLayer {
|
||||
countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }>
|
||||
}
|
||||
|
||||
export interface PluginUserSettingField {
|
||||
key: string; label?: string | null; input_type?: string; placeholder?: string | null;
|
||||
hint?: string | null; required?: boolean; secret?: boolean;
|
||||
options?: Array<{ value: string; label: string }>
|
||||
}
|
||||
/** The settings-field descriptor is the SHARED contract (both scopes emit the same
|
||||
* shape) — aliased so existing per-user settings consumers keep their import path. */
|
||||
export type PluginUserSettingField = PluginSettingsField
|
||||
|
||||
/** A button a plugin contributes to its own settings page ("Test connection"). */
|
||||
export interface PluginAction {
|
||||
key: string; label: string; hint?: string; danger: boolean
|
||||
}
|
||||
/** A button a plugin contributes to a settings form ("Test connection", "Purge cache").
|
||||
* `scope` says which form: the user tab or the admin instance-settings dialog. */
|
||||
export type PluginAction = PluginActionDescriptor
|
||||
|
||||
export const pluginsApi = {
|
||||
// Active plugins the client renders (page nav entries, dashboard widgets).
|
||||
@@ -862,7 +878,7 @@ export const pluginsApi = {
|
||||
// caller, so it reads the caller's own settings.
|
||||
runAction: (id: string, key: string) =>
|
||||
apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`)
|
||||
.then(r => r.data as { ok: boolean; message?: string }),
|
||||
.then(r => r.data as PluginActionResult),
|
||||
saveUserSettings: (id: string, config: Record<string, unknown>) =>
|
||||
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
||||
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
|
||||
@@ -1108,7 +1124,8 @@ export const healthApi = {
|
||||
|
||||
export const weatherApi = {
|
||||
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
|
||||
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
// `lang` localizes the description; when omitted the server keeps its own default (#2167).
|
||||
get: (lat: number, lng: number, date: string, lang?: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, lang, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
@@ -1220,15 +1237,21 @@ export const tripInviteApi = {
|
||||
accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data),
|
||||
}
|
||||
|
||||
// A channel test dials a third party the admin just typed in, and the server
|
||||
// budgets for it: up to 20s for a wrong SMTP port, 10s for a webhook or ntfy
|
||||
// endpoint. The 8s instance timeout aborted those before the reason came back,
|
||||
// so the toast could only ever say "failed" (#2196).
|
||||
const CHANNEL_TEST_TIMEOUT = 40000
|
||||
|
||||
export const notificationsApi = {
|
||||
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')),
|
||||
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')),
|
||||
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')),
|
||||
// Generic channel test — this is how a PLUGIN channel's "Send test" button works.
|
||||
testChannel: (channelId: string) =>
|
||||
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`)
|
||||
apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`, undefined, { timeout: CHANNEL_TEST_TIMEOUT })
|
||||
.then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')),
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
|
||||
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
|
||||
// no state to apply, which is the point: the totals are summed server-side across
|
||||
// members whose items this client may not see.
|
||||
'packing:bag-totals',
|
||||
// Collections — pages/collections/useCollections ('collections:' prefix listener)
|
||||
'collections:updated',
|
||||
'collections:accepted',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -44,20 +44,16 @@ function modelsRoute(names: string[], seen?: (string | null)[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/** The pill toggle of a top-level addon row. */
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
* row alike, so one lookup covers both. */
|
||||
function addonToggle(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
return screen.getByRole('button', { name });
|
||||
}
|
||||
|
||||
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
|
||||
function subToggle(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
const subToggle = addonToggle;
|
||||
|
||||
function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.style.background === 'var(--text-primary)';
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -82,6 +78,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
describe('AddonManager', () => {
|
||||
@@ -144,13 +141,10 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
const toggleBtn = addonToggle('Todo List');
|
||||
expect(isOn(toggleBtn)).toBe(false);
|
||||
|
||||
// Before click - disabled state (border-primary bg)
|
||||
await user.click(toggleBtn!);
|
||||
await user.click(toggleBtn);
|
||||
|
||||
// After click - success toast
|
||||
await screen.findByText('Addon updated');
|
||||
@@ -169,17 +163,14 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
await user.click(toggleBtn!);
|
||||
await user.click(addonToggle('Todo List'));
|
||||
|
||||
// Error toast appears
|
||||
await screen.findByText('Failed to update addon');
|
||||
|
||||
// The disabled text should be back after rollback
|
||||
// The switch is back off after rollback
|
||||
await waitFor(() => {
|
||||
const disabledTexts = screen.getAllByText('Disabled');
|
||||
expect(disabledTexts.length).toBeGreaterThan(0);
|
||||
expect(isOn(addonToggle('Todo List'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,13 +186,7 @@ describe('AddonManager', () => {
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
);
|
||||
await screen.findByText('Bag Tracking');
|
||||
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||
await user.click(allBtns[allBtns.length - 1]);
|
||||
await user.click(subToggle('Bag Tracking'));
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -251,9 +236,10 @@ describe('AddonManager', () => {
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
// Journey's own switch plus one per provider, each labelled with what it controls
|
||||
expect(isOn(addonToggle('Journey'))).toBe(true);
|
||||
expect(isOn(subToggle('Unsplash'))).toBe(true);
|
||||
expect(isOn(subToggle('Pexels'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
@@ -278,7 +264,8 @@ describe('AddonManager', () => {
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
|
||||
// The wordmark reads the .dark class, the same source applyAppearance() writes.
|
||||
document.documentElement.classList.add('dark');
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('No addons available');
|
||||
@@ -309,11 +296,11 @@ describe('AddonManager', () => {
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
|
||||
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
|
||||
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement;
|
||||
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
|
||||
|
||||
expect(isOn(subToggle('Immich'))).toBe(true);
|
||||
@@ -361,6 +348,76 @@ describe('AddonManager', () => {
|
||||
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
|
||||
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Journey');
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
let journeyOn = true;
|
||||
let immichOn = true;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/journey', async ({ request }) => {
|
||||
journeyOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
// Journey off takes its providers with it, see admin.service.ts.
|
||||
if (!journeyOn) immichOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
// The toast closes the whole toggle, re-read included.
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(2);
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(isOn(subToggle('Immich'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
|
||||
}),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(1);
|
||||
expect(isOn(addonToggle('Todo List'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleCollabFeature = vi.fn();
|
||||
@@ -396,8 +453,21 @@ describe('AddonManager', () => {
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
@@ -615,7 +685,7 @@ describe('AddonManager', () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Connection');
|
||||
await screen.findByText('Provider');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -60,6 +66,14 @@ function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
/** What each type means, shown on the tile itself now that the sections are gone. The
|
||||
* hint rides along as the tooltip, so "Global" still explains itself. */
|
||||
const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelKey: string; hintKey: string }> = {
|
||||
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
|
||||
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
@@ -71,42 +85,43 @@ const COLLAB_SUB_FEATURES = [
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const dark = useIsDark()
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons()
|
||||
loadAddons().finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const loadAddons = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
|
||||
* snapshot back instead would undo a toggle the user hit in parallel. */
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
return
|
||||
}
|
||||
refreshGlobalAddons()
|
||||
// Journey off disables every photo provider with it, and the response carries
|
||||
// only Journey. Without re-reading, switching Journey back on brings the shelf
|
||||
// up with providers the database has long since turned off.
|
||||
if (addon.id === 'journey') await loadAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
@@ -118,24 +133,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
@@ -144,173 +142,160 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
toggle: () => handleToggle(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||
<div className="grid place-items-center py-16">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bag tracking, the collab features and the photo providers all hang off their
|
||||
* parent as shelf rows, and each shelf only shows while its parent is on —
|
||||
* a provider toggled under a disabled Journey would hit the server's 409. */
|
||||
const shelfFor = (addon: Addon) => {
|
||||
if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) {
|
||||
return (
|
||||
<AddonSubRow
|
||||
icon={<Luggage size={14} />}
|
||||
title={t('admin.bagTracking.title')}
|
||||
description={t('admin.bagTracking.subtitle')}
|
||||
enabled={!!bagTrackingEnabled}
|
||||
onToggle={onToggleBagTracking}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
|
||||
return COLLAB_SUB_FEATURES.map(feat => {
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={feat.key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(feat.titleKey)}
|
||||
description={t(feat.subtitleKey)}
|
||||
enabled={collabFeatures[feat.key]}
|
||||
onToggle={() => onToggleCollabFeature(feat.key)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
|
||||
return providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={provider.key}
|
||||
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
|
||||
title={provider.label}
|
||||
description={provider.description}
|
||||
enabled={provider.enabled}
|
||||
onToggle={provider.toggle}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tile = (addon: Addon) => {
|
||||
const label = getAddonLabel(t, addon)
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => handleToggle(addon)}
|
||||
>
|
||||
{shelfFor(addon)}
|
||||
</AddonTile>
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab 261px, Journey
|
||||
185px) set a section's height while its neighbours ran out early. Side by side the
|
||||
column heading carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<div>
|
||||
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button" onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button type="button"
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'llm_parsing' && addon.enabled && (
|
||||
<LlmParsingConfig addon={addon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-caption tabular-nums text-content-faint"
|
||||
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
|
||||
>
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key}>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="space-y-3">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
|
||||
title={t('admin.addons.group.count', { enabled, total: addons.length })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="shrink-0 text-content-muted" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
|
||||
{enabled}/{addons.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Says what the type means — the only place that still explains it. */}
|
||||
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -408,9 +393,8 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary'
|
||||
const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint'
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
@@ -418,131 +402,113 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
|
||||
sized and single-column — the band this used to be had a whole page width. */
|
||||
return (
|
||||
<div className="border-b border-edge-secondary bg-surface-secondary py-5 pr-6 pl-[70px]">
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<p className="text-xs text-content-faint">
|
||||
Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
<li className="space-y-2.5 py-1.5">
|
||||
<p className="text-caption text-content-faint">
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Connection</div>
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-xs text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Model */}
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</div>
|
||||
{modelsErr && <p className="text-xs text-rose-600">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-xs text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2.5 py-1 text-xs transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-3">
|
||||
<div className="mb-2 text-xs font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-lg border px-3 py-2 transition-colors ${active ? 'border-edge-secondary bg-surface-secondary' : 'border-transparent'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="rounded-md bg-[rgba(16,185,129,0.15)] px-1.5 py-px text-[10px] font-semibold text-emerald-600">Recommended</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-content-faint">{m.note}</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1.5">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<div className="border-t border-edge-secondary pt-2">
|
||||
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
|
||||
)}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
@@ -555,56 +521,3 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button type="button"
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
* Lists, a collab feature under Collab, a photo provider under Journey.
|
||||
*
|
||||
* A photo provider without a vendor mark has to stay free of `<svg>`
|
||||
* (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer
|
||||
* rather than a placeholder glyph. Nothing else belongs in this row either: no
|
||||
* chevron, no status dot, no lucide fallback.
|
||||
*
|
||||
* The row carries its title only; the description rides along as the `title`
|
||||
* tooltip, because a second line of text is what makes a child weigh as much as
|
||||
* an addon.
|
||||
*/
|
||||
export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[36px] items-center gap-3">
|
||||
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One addon in the admin grid: a card whose fill carries the on/off state, with
|
||||
* an optional shelf at the foot holding its sub-features.
|
||||
*
|
||||
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
|
||||
* sits on) — that pair reads in both themes, because secondary is *darker* than
|
||||
* the card in light and *lighter* in dark. The obvious alternative, on =
|
||||
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
|
||||
* ones.
|
||||
*
|
||||
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
|
||||
* is only declared in `:root`, so the `.dark` palette never overrides it and a
|
||||
* shadow-based affordance would be invisible there.
|
||||
*
|
||||
* The card is not a click target — only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
|
||||
enabled
|
||||
? 'border-edge bg-surface-secondary'
|
||||
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* The name stays full-strength when off: it is the tile's identity, not its
|
||||
state. Greying it out reads as "broken" rather than "not enabled". */}
|
||||
<h4 className="truncate text-body font-semibold text-content" title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it — no rail, no indent guides. */}
|
||||
<ul className="space-y-0.5">{children}</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => {
|
||||
* the UI. It must escape every overflow ancestor, and flip up when the bottom is tight.
|
||||
*/
|
||||
describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => {
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false })
|
||||
// operatorEgress so the menu shows every action, Allowed hosts included (it is
|
||||
// gated on the declaration, like the row chip).
|
||||
const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true })
|
||||
const realRect = HTMLButtonElement.prototype.getBoundingClientRect
|
||||
afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect })
|
||||
|
||||
@@ -281,6 +283,10 @@ describe('AdminPluginsPanel — a refused update', () => {
|
||||
expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument()
|
||||
// The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159).
|
||||
const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i }))
|
||||
})
|
||||
|
||||
// D2, at the UI. An invalid signature means the bytes are not what the author signed.
|
||||
@@ -843,6 +849,30 @@ describe('AdminPluginsPanel — row actions', () => {
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => {
|
||||
// An admin must never be invited to widen egress for a plugin that didn't ask for it
|
||||
// — same rule the row chip already follows.
|
||||
await openRowMenu(plugin({ operatorEgress: false }))
|
||||
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => {
|
||||
server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () =>
|
||||
HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] })))
|
||||
await openRowMenu()
|
||||
|
||||
fireEvent.click(screen.getByText('Allowed hosts'))
|
||||
|
||||
// The card is capped and its body scrolls, so the input, the Add button and the
|
||||
// restart note below the list stay reachable however many hosts the admin entered.
|
||||
const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com'))
|
||||
expect(body!.parentElement!.className).toContain('max-h-[88vh]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
@@ -947,7 +977,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
fireEvent.click(rowToggle('Gotify'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i }))
|
||||
// The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159).
|
||||
const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument())
|
||||
expect(installs).toBe(0)
|
||||
@@ -967,6 +1001,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => {
|
||||
// An unknown permission is still shown — as its raw code
|
||||
expect(screen.getByText('weird:perm')).toBeInTheDocument()
|
||||
expect(screen.getByText('api.acme.io')).toBeInTheDocument()
|
||||
// A long permission list scrolls inside the dialog; the decision buttons sit outside
|
||||
// the scroll container, so they never leave the viewport (#2159).
|
||||
const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto')
|
||||
expect(body).not.toBeNull()
|
||||
expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /keep off for now/i }))
|
||||
expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument()
|
||||
@@ -1439,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => {
|
||||
expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the card shifted the Install button out from under the pointer, so the click
|
||||
// retargeted onto the card and opened the detail modal instead of installing.
|
||||
// The data-no-press attribute is the pin.
|
||||
discoverWith({})
|
||||
render(<AdminPluginsPanel />)
|
||||
await clickDiscover()
|
||||
|
||||
expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => {
|
||||
discoverWith(
|
||||
{ downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' },
|
||||
@@ -1763,7 +1814,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel([row()])
|
||||
// Declares operatorEgress (so the menu item shows), but the runtime lookup
|
||||
// disagrees — the dialog's unsupported notice is the truth-teller.
|
||||
mockPanel([row({ operatorEgress: true })])
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })),
|
||||
)
|
||||
@@ -1778,7 +1831,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => {
|
||||
|
||||
it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockPanel([row()])
|
||||
mockPanel([row({ operatorEgress: true })])
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
)
|
||||
@@ -2134,3 +2187,275 @@ describe('AdminPluginsPanel — update all', () => {
|
||||
await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget']))
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The admin-owned `scope:'instance'` settings form. The fields are declared manifest
|
||||
* data (validated at install), the values live in the plugin's instance config, and a
|
||||
* save may RESTART a running plugin — the child reads config once, at init — so the
|
||||
* admin must be told when that happened rather than wondering why the plugin blinked.
|
||||
*/
|
||||
describe('AdminPluginsPanel — instance settings', () => {
|
||||
const FIELDS = [
|
||||
{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false },
|
||||
{ key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true },
|
||||
]
|
||||
|
||||
async function openRowMenu(p: Record<string, unknown>) {
|
||||
mockList(p)
|
||||
render(<><ToastContainer /><AdminPluginsPanel /></>)
|
||||
fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify'))
|
||||
}
|
||||
|
||||
async function openSettings(config: Record<string, unknown> = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => {
|
||||
await openSettings()
|
||||
// Both declared fields render, the secret as a masked password input.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => {
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 }))
|
||||
expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open
|
||||
expect(screen.queryByText('Instance settings')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 }))
|
||||
expect(screen.getByText('Instance settings')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ config: body, restarted: false })
|
||||
}),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
// The mask is a display artifact, not a value — sending it would overwrite the stored secret.
|
||||
expect(body).toEqual({ apiUrl: 'https://new.example' })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => {
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => {
|
||||
const RICH = [
|
||||
{ key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' },
|
||||
{ key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in
|
||||
{ key: 'retries', label: 'Retries', input_type: 'number', required: true },
|
||||
]
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })),
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ config: body, restarted: false })
|
||||
}),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 3 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
// Stored values land in the right controls; an unset value renders empty.
|
||||
const select = await screen.findByRole('combobox', {}, { timeout: 5000 })
|
||||
expect(select).toHaveValue('slow')
|
||||
expect(screen.getByRole('checkbox')).toBeChecked()
|
||||
expect(screen.getByRole('spinbutton')).toHaveValue(null)
|
||||
expect(screen.getByText('Pick one')).toBeInTheDocument() // hint
|
||||
expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key
|
||||
|
||||
fireEvent.change(select, { target: { value: 'fast' } })
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' })
|
||||
})
|
||||
|
||||
const ACTIONS = [
|
||||
{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' },
|
||||
{ key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' },
|
||||
]
|
||||
|
||||
async function openWithActions(p: Record<string, unknown> = plugin({ instanceSettingsCount: 2 })) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })),
|
||||
)
|
||||
await openRowMenu(p)
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 })
|
||||
}
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: true, message: 'pong' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('ping')
|
||||
// The dialog stays open — the admin may run another one.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => {
|
||||
let posted = ''
|
||||
server.use(
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => {
|
||||
posted = String(params.key)
|
||||
return HttpResponse.json({ ok: false, message: 'cache locked' })
|
||||
}),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge cache' }))
|
||||
expect(await screen.findByText('Run this action?')).toBeInTheDocument()
|
||||
expect(posted).toBe('')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^confirm$/i }))
|
||||
expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(posted).toBe('purge')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => {
|
||||
await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 }))
|
||||
expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled()
|
||||
expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => {
|
||||
const calls: string[] = []
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }),
|
||||
http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }),
|
||||
)
|
||||
await openWithActions()
|
||||
fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Ping server' }))
|
||||
await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 2 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => {
|
||||
server.use(
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () =>
|
||||
HttpResponse.json({ error: 'config refused' }, { status: 400 })),
|
||||
)
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
// Still open — the admin's edits are not thrown away on a refusal.
|
||||
expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => {
|
||||
await openSettings()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
|
||||
|
||||
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: {},
|
||||
})),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: { oauth_authorize_url: 'https://mine.example' },
|
||||
})),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
|
||||
expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => {
|
||||
let called = false
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({
|
||||
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }],
|
||||
config: {},
|
||||
})),
|
||||
http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }),
|
||||
)
|
||||
await openRowMenu(plugin({ instanceSettingsCount: 1 }))
|
||||
fireEvent.click(screen.getByText('Instance settings'))
|
||||
await screen.findByText('Client ID', {}, { timeout: 5000 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument()
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => {
|
||||
await openSettings()
|
||||
|
||||
// `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on
|
||||
// the near-white accent the dark theme uses by default.
|
||||
const save = screen.getByRole('button', { name: /^save$/i })
|
||||
expect(save.className).toContain('text-accent-text')
|
||||
expect(save.className).not.toContain('text-white')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -49,6 +51,12 @@ interface PluginRow {
|
||||
operatorEgress?: boolean
|
||||
/** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */
|
||||
egressHostCount?: number
|
||||
/** How many `scope:'instance'` settings fields the plugin declares — gates the
|
||||
* "Instance settings" menu item without a per-plugin fetch. */
|
||||
instanceSettingsCount?: number
|
||||
/** How many `scope:'instance'` actions the plugin declares — a plugin with actions
|
||||
* but no settings fields still needs the menu item to run them. */
|
||||
instanceActionsCount?: number
|
||||
dependencies?: PluginDependencies
|
||||
dependencyStatus?: DependencyStatus
|
||||
dependencyIssues?: DependencyIssues
|
||||
@@ -408,6 +416,8 @@ export default function AdminPluginsPanel() {
|
||||
const [detailFor, setDetailFor] = useState<RegistryItem | null>(null)
|
||||
const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null)
|
||||
const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null)
|
||||
// The admin-owned scope:'instance' settings form — shared logic with the phone shell.
|
||||
const settings = useInstanceSettings()
|
||||
const [egressDraft, setEgressDraft] = useState('')
|
||||
const [egressSaving, setEgressSaving] = useState(false)
|
||||
const [egressError, setEgressError] = useState('')
|
||||
@@ -573,6 +583,11 @@ export default function AdminPluginsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const openInstanceSettings = (p: { id: string; status: string }) => {
|
||||
setMenu(null)
|
||||
settings.open(p.id, p.status === 'active')
|
||||
}
|
||||
|
||||
const openErrors = (id: string) => {
|
||||
setMenu(null)
|
||||
adminApi.pluginErrors(id)
|
||||
@@ -962,6 +977,7 @@ export default function AdminPluginsPanel() {
|
||||
code: p.updateBlock!.code, detail: p.updateBlock!.detail,
|
||||
})}
|
||||
onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)}
|
||||
onSettings={() => openInstanceSettings(p)}
|
||||
onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} />
|
||||
))}
|
||||
</div>
|
||||
@@ -1001,12 +1017,12 @@ export default function AdminPluginsPanel() {
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setEgressFor(null)}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[88vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>
|
||||
<button type="button" onClick={() => setEgressFor(null)} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="p-5 space-y-3 min-h-0 flex-1 overflow-y-auto">
|
||||
{!egressFor.supported ? (
|
||||
<p className="text-sm text-content-faint">{t('admin.plugins.allowedHosts.unsupported')}</p>
|
||||
) : (
|
||||
@@ -1048,6 +1064,110 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
|
||||
{settings.form && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={settings.close}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-lg max-h-[80vh] flex flex-col shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-3.5 border-b border-edge-secondary flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-content flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>
|
||||
<button type="button" aria-label={t('common.close')} onClick={settings.close} className="text-content-faint hover:text-content"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 overflow-y-auto">
|
||||
{settings.form.fields.map(f => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="block text-sm font-medium text-content-secondary mb-1">
|
||||
{f.label || f.key}{f.required && <span className="text-danger"> *</span>}
|
||||
</span>
|
||||
{f.input_type === 'checkbox' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.form.values[f.key] === true}
|
||||
onChange={e => settings.setValue(f.key, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-edge"
|
||||
/>
|
||||
) : f.input_type === 'select' && f.options ? (
|
||||
<select
|
||||
value={String(settings.form.values[f.key] ?? '')}
|
||||
onChange={e => settings.setValue(f.key, e.target.value)}
|
||||
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{f.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.secret ? 'password' : (f.input_type === 'number' ? 'number' : 'text')}
|
||||
value={String(settings.form.values[f.key] ?? '')}
|
||||
placeholder={f.placeholder || ''}
|
||||
autoComplete={f.secret ? 'new-password' : 'off'}
|
||||
onChange={e => settings.setValue(f.key, e.target.value)}
|
||||
className="w-full rounded-lg border border-edge bg-surface px-3 py-2 text-sm text-content"
|
||||
/>
|
||||
)}
|
||||
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{(() => {
|
||||
const form = settings.form
|
||||
if (!form || form.actions.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-edge-secondary pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
{t('admin.plugins.actions')}
|
||||
</span>
|
||||
{!form.active && (
|
||||
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.actions.map(a => {
|
||||
const res = settings.actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => settings.runAction(a)}
|
||||
disabled={!form.active || settings.runningAction !== null || settings.saving}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
|
||||
}`}
|
||||
>
|
||||
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{res && (
|
||||
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
|
||||
<button type="button"
|
||||
disabled={settings.saving || settings.runningAction !== null}
|
||||
onClick={() => void settings.save()}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
|
||||
>{t('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={settings.pendingAction !== null}
|
||||
onClose={settings.cancelPendingAction}
|
||||
onConfirm={settings.confirmPendingAction}
|
||||
title={settings.pendingAction?.label ?? ''}
|
||||
message={t('admin.plugins.actions.confirm')}
|
||||
confirmLabel={t('common.confirm')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => setConfirmUninstall(null)}
|
||||
@@ -1185,11 +1305,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: {
|
||||
function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: {
|
||||
p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void
|
||||
hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean
|
||||
onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void
|
||||
onErrors: () => void; onEgress: () => void; onUninstall: () => void
|
||||
onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void
|
||||
}) {
|
||||
const caps = deriveCaps(parseJson<string[]>(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t)
|
||||
const deps = deriveDeps(p, t)
|
||||
@@ -1334,8 +1454,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} label={t('admin.plugins.restart')} onClick={onRestart} />
|
||||
)}
|
||||
{/* A plugin gets the item if it declares scope:'instance' fields OR actions —
|
||||
an action-only plugin still needs the dialog to run its buttons. */}
|
||||
{((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
{/* Only a plugin that DECLARED operatorEgress gets the item — an admin must
|
||||
never be invited to widen egress for a plugin that didn't ask for it
|
||||
(same rule the row's egress chip follows). */}
|
||||
{p.operatorEgress && (
|
||||
<MenuItem icon={<Globe size={14} />} label={t('admin.plugins.allowedHosts')} onClick={onEgress} />
|
||||
)}
|
||||
{/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */}
|
||||
{isRegistrySourced(p.source_repo) && (
|
||||
<MenuItem icon={<History size={14} />} label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} />
|
||||
@@ -1428,6 +1558,10 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
const offer = installOffer(item, t)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// No press-scale on the card: shrinking it mid-click slides the Install
|
||||
// button out from under the pointer, so the click retargets onto the card
|
||||
// and opens the detail modal instead of installing (#2158).
|
||||
data-no-press
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }}
|
||||
className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
|
||||
<div className="relative">
|
||||
@@ -1793,15 +1927,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldAlert size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.sig.title', { name: data.subject.name })}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t(bodyKey as never)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the
|
||||
new one back to the author over the phone. The full key travels in the request. */}
|
||||
{canRetrust && (
|
||||
@@ -1815,7 +1949,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
<p className="text-xs text-content-faint font-mono break-all bg-surface-tertiary rounded-lg px-3 py-2">{data.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={onClose}
|
||||
className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">
|
||||
{offerRetrust ? t('admin.plugins.sig.cancel') : t('common.close')}
|
||||
@@ -1857,15 +1991,15 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
}) {
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onLater}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><ShieldCheck size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.updateConsentTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.updateConsentBody', { name: data.plugin.name, version: data.version })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="p-5 space-y-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{/* The admin is about to widen what this code may do — so say, right here, that
|
||||
nothing ties this code to its author. One line, no checkbox, no extra click:
|
||||
this informs, it does not block. */}
|
||||
@@ -1894,7 +2028,7 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={onLater} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('admin.plugins.updateLater')}</button>
|
||||
<button type="button" onClick={onApprove} className="text-xs font-semibold px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">{t('admin.plugins.updateApprove')}</button>
|
||||
</div>
|
||||
@@ -1917,15 +2051,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
]
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div role="presentation" className="bg-surface-card border border-edge rounded-2xl w-full max-w-md max-h-[88vh] flex flex-col shadow-modal overflow-hidden" onClick={e => e.stopPropagation()}>
|
||||
<div className="shrink-0 px-5 py-4 border-b border-edge-secondary flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-warning-soft grid place-items-center shrink-0"><Puzzle size={18} className="text-warning" /></div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-content">{t('admin.plugins.dep.resolveTitle')}</h3>
|
||||
<p className="text-xs text-content-muted mt-1">{t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-2.5">
|
||||
<div className="p-5 space-y-2.5 min-h-0 flex-1 overflow-y-auto">
|
||||
{rows.map(r => (
|
||||
<div key={r.id} className="flex items-center gap-3 p-3 rounded-xl border border-edge-secondary bg-surface-tertiary">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1946,7 +2080,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl
|
||||
<p className="text-[11.5px] text-content-faint pt-1">{t('admin.plugins.dep.resolveHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<div className="shrink-0 px-5 py-3.5 border-t border-edge-secondary bg-surface-secondary flex items-center justify-end">
|
||||
<button type="button" onClick={onClose} className="text-xs font-medium px-3.5 py-2 rounded-lg border border-edge text-content-muted hover:text-content hover:bg-surface-tertiary transition-colors">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }))
|
||||
|
||||
const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }]
|
||||
const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const }
|
||||
const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] })
|
||||
vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false })
|
||||
vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
async function opened(active = true) {
|
||||
const hook = renderHook(() => useInstanceSettings())
|
||||
act(() => hook.result.current.open('p', active))
|
||||
await waitFor(() => expect(hook.result.current.form).not.toBeNull())
|
||||
return hook
|
||||
}
|
||||
|
||||
/** A promise this test controls the settling of, to pin down state mid-flight. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>(r => { resolve = r })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useInstanceSettings — actions', () => {
|
||||
it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => {
|
||||
const { result } = await opened()
|
||||
expect(result.current.form?.actions).toEqual([PING, PURGE])
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled()
|
||||
expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')
|
||||
expect(result.current.form).not.toBeNull()
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://b.example'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toBeDefined())
|
||||
expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' })
|
||||
expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0])
|
||||
expect(result.current.form?.dirty).toBe(false)
|
||||
expect(result.current.form).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => {
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } })
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'x'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.error).toBe('nope'))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => {
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
expect(result.current.pendingAction).toEqual(PURGE)
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
act(() => result.current.cancelPendingAction())
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
act(() => result.current.runAction(PURGE))
|
||||
act(() => result.current.confirmPendingAction())
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge'))
|
||||
expect(result.current.pendingAction).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => {
|
||||
const { result } = await opened(false)
|
||||
act(() => result.current.runAction(PING))
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(adminApi.runPluginAction).not.toHaveBeenCalled()
|
||||
|
||||
vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' })
|
||||
const active = await opened(true)
|
||||
act(() => active.result.current.runAction(PING))
|
||||
await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' }))
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => {
|
||||
const savePromise = deferred<{ config: Record<string, unknown>; restarted: boolean }>()
|
||||
vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.setValue('apiUrl', 'https://a-edit.example'))
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' }))
|
||||
|
||||
// A concurrent edit lands while the save is still pending.
|
||||
act(() => result.current.setValue('apiUrl', 'https://b-edit.example'))
|
||||
|
||||
await act(async () => {
|
||||
savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false })
|
||||
await savePromise.promise
|
||||
})
|
||||
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
expect(result.current.form?.dirty).toBe(true)
|
||||
expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example')
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => {
|
||||
const runPromise = deferred<{ ok: boolean; message?: string }>()
|
||||
vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise)
|
||||
const { result } = await opened()
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping'))
|
||||
|
||||
act(() => result.current.close())
|
||||
act(() => result.current.open('q', true))
|
||||
await waitFor(() => expect(result.current.form?.id).toBe('q'))
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping'))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }))
|
||||
|
||||
// The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result.
|
||||
await act(async () => {
|
||||
runPromise.resolve({ ok: true, message: 'stale pong' })
|
||||
await runPromise.promise
|
||||
})
|
||||
expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })
|
||||
})
|
||||
|
||||
it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => {
|
||||
vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } })
|
||||
const { result } = await opened(true)
|
||||
expect(result.current.form?.active).toBe(true)
|
||||
act(() => result.current.runAction(PING))
|
||||
await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' }))
|
||||
expect(result.current.form?.active).toBe(false)
|
||||
expect(result.current.runningAction).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client'
|
||||
import type { PluginActionResult } from '@trek/shared'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
export interface InstanceSettingsForm {
|
||||
id: string
|
||||
fields: PluginUserSettingField[]
|
||||
values: Record<string, string | boolean>
|
||||
/** The plugin's `scope:'instance'` action buttons. */
|
||||
actions: PluginAction[]
|
||||
/** Whether the plugin is active: an action needs a running child to execute. */
|
||||
active: boolean
|
||||
/** An edit since open/last save. An action saves a dirty form before it runs. */
|
||||
dirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin-owned `scope:'instance'` settings form — the ONE logic path behind
|
||||
* both admin shells (the desktop modal and the phone sheet render their own
|
||||
* markup over this state). Open fetches the declared fields plus the stored
|
||||
* (masked) values and the instance-scope actions; save skips an untouched secret
|
||||
* mask so it never overwrites the stored ciphertext, and reports a restart when
|
||||
* the server re-spawned a running plugin (the child reads its config once, at init).
|
||||
*
|
||||
* Actions run AS the clicking admin. A dirty form is saved first (so the action
|
||||
* sees the config the admin is looking at), the dialog stays open, and the result
|
||||
* shows beside the button. A `danger` action parks in `pendingAction` until the
|
||||
* shell's confirm UI calls `confirmPendingAction`.
|
||||
*/
|
||||
export function useInstanceSettings() {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState<InstanceSettingsForm | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [actionResult, setActionResult] = useState<Record<string, PluginActionResult>>({})
|
||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||
const [pendingAction, setPendingAction] = useState<PluginAction | null>(null)
|
||||
// Bumped on every open()/close() so an in-flight action from a closed/replaced
|
||||
// dialog can tell it's stale and bail without touching the new dialog's state.
|
||||
const openSeq = useRef(0)
|
||||
|
||||
const open = (id: string, active: boolean) => {
|
||||
openSeq.current++
|
||||
setError('')
|
||||
setActionResult({})
|
||||
setPendingAction(null)
|
||||
setRunningAction(null)
|
||||
adminApi.pluginConfig(id)
|
||||
.then(d => {
|
||||
setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false })
|
||||
})
|
||||
.catch(() => toast.error(t('common.error')))
|
||||
}
|
||||
|
||||
const setValue = (key: string, value: string | boolean) =>
|
||||
setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true }))
|
||||
|
||||
const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) }
|
||||
|
||||
/** Persist the form. Returns false when the save was refused (error already shown). */
|
||||
const persist = async (f: InstanceSettingsForm): Promise<boolean> => {
|
||||
const missing = findMissingRequired(f.fields, f.values)
|
||||
if (missing) {
|
||||
setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key }))
|
||||
return false
|
||||
}
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values))
|
||||
toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved'))
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } } }
|
||||
setError(err.response?.data?.error || t('common.error'))
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (!form) return
|
||||
if (await persist(form)) setForm(null)
|
||||
}
|
||||
|
||||
const perform = async (a: PluginAction) => {
|
||||
if (!form || !form.active || runningAction) return
|
||||
const seq = openSeq.current
|
||||
// Set BEFORE the dirty-save window, not just around the invoke call, so a second
|
||||
// click (or the shells' Save button, see M7) is blocked for the whole run — not just
|
||||
// once the pre-run save has already gone out.
|
||||
setRunningAction(a.key)
|
||||
try {
|
||||
if (form.dirty) {
|
||||
if (!(await persist(form))) return
|
||||
if (openSeq.current !== seq) return
|
||||
// Only clear dirty if the values are still the snapshot we just persisted —
|
||||
// a reference check, since setValue always creates a new values object. A
|
||||
// concurrent edit during the save produced a new object, so dirty stays true
|
||||
// and the edit isn't silently dropped from the next save/action.
|
||||
setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s)
|
||||
}
|
||||
if (openSeq.current !== seq) return
|
||||
const res = await adminApi.runPluginAction(form.id, a.key)
|
||||
if (openSeq.current !== seq) return
|
||||
setActionResult(r => ({ ...r, [a.key]: res }))
|
||||
} catch (e) {
|
||||
if (openSeq.current !== seq) return
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } }
|
||||
if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') {
|
||||
// The DB `status` column said active when the dialog opened, but the server's
|
||||
// live-process check (the supervisor's actual child map) disagrees — a stale
|
||||
// row with no running child. Believe the server: show the real reason instead
|
||||
// of a bare "Error", and disable the buttons instead of leaving them clickable.
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } }))
|
||||
setForm(s => s && { ...s, active: false })
|
||||
} else {
|
||||
setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } }))
|
||||
}
|
||||
} finally {
|
||||
if (openSeq.current === seq) setRunningAction(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runAction = (a: PluginAction) => {
|
||||
if (!form || !form.active) return
|
||||
if (a.danger) { setPendingAction(a); return }
|
||||
void perform(a)
|
||||
}
|
||||
|
||||
const confirmPendingAction = () => {
|
||||
const a = pendingAction
|
||||
setPendingAction(null)
|
||||
if (a) void perform(a)
|
||||
}
|
||||
|
||||
const cancelPendingAction = () => setPendingAction(null)
|
||||
|
||||
return {
|
||||
form, saving, error, open, setValue, close, save,
|
||||
actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -16,8 +16,12 @@ describe('splitCents', () => {
|
||||
expect(splitCents(50, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('floors a negative amount at zero rather than inventing debt', () => {
|
||||
expect(splitCents(-10, 2)).toEqual([0, 0])
|
||||
it('splits a negative amount exactly instead of clamping it to zero (#2176)', () => {
|
||||
// A refund spread across payers has to sum back too — the old Math.max(0, …)
|
||||
// clamp silently swallowed the sign and broke rebalancePayers for refunds.
|
||||
expect(splitCents(-10, 2)).toEqual([-5, -5])
|
||||
const parts = splitCents(-100.01, 3)
|
||||
expect(parts.reduce((a, b) => a + Math.round(b * 100), 0)).toBe(-10001)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +79,57 @@ describe('rebalancePayers', () => {
|
||||
const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100)
|
||||
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
|
||||
})
|
||||
|
||||
// ── Refunds: negative totals rebalance like any other (#2176) ──────────────
|
||||
|
||||
it('spreads a negative total across payers so the parts stay typeable', () => {
|
||||
const next = rebalancePayers({}, new Set(), new Set([1, 2]), -90)
|
||||
expect(next).toEqual({ 1: '-45.00', 2: '-45.00' })
|
||||
expect(payersBalanced(next, new Set([1, 2]), -90)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets a free payer absorb the rest of a negative total past a pinned amount', () => {
|
||||
const next = rebalancePayers({ 1: '-70' }, new Set([1]), new Set([1, 2]), -100)
|
||||
expect(next[1]).toBe('-70')
|
||||
expect(next[2]).toBe('-30.00')
|
||||
})
|
||||
|
||||
it('handles a pinned positive payer against a negative total', () => {
|
||||
// Alice fronted 50 but the entry nets to -100 — the free payer carries -150.
|
||||
const next = rebalancePayers({ 1: '50' }, new Set([1]), new Set([1, 2]), -100)
|
||||
expect(next[2]).toBe('-150.00')
|
||||
expect(payersBalanced(next, new Set([1, 2]), -100)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Client/server share parity (#2176) ───────────────────────────────────────
|
||||
//
|
||||
// splitEqualShares exists twice: here (previewing the split in euros) and on the
|
||||
// server (netting the settlement in cents — BudgetService.splitEqualShares in
|
||||
// server/src/nest/budget/budget.service.ts). The fixture below is duplicated
|
||||
// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either
|
||||
// implementation drifts — sign handling included — its copy of this table fails.
|
||||
const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record<number, number> }[] = [
|
||||
{ totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } },
|
||||
{ totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } },
|
||||
{ totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } },
|
||||
{ totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } },
|
||||
{ totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } },
|
||||
{ totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } },
|
||||
{ totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } },
|
||||
]
|
||||
|
||||
describe('splitEqualShares — server parity (#2176)', () => {
|
||||
it.each(SHARE_PARITY_FIXTURE)(
|
||||
'splits $totalCents cents across $users.length members (item $itemId) exactly like the server',
|
||||
({ totalCents, users, itemId, expected }) => {
|
||||
const shares = splitEqualShares(totalCents / 100, users.map(user_id => ({ user_id })), itemId)
|
||||
const inCents = Object.fromEntries(Object.entries(shares).map(([id, v]) => [id, Math.round(v * 100)]))
|
||||
expect(inCents).toEqual(expected)
|
||||
// The invariant behind the fixture: the shares always sum back to the total.
|
||||
expect(Object.values(inCents).reduce((a, b) => a + b, 0)).toBe(totalCents)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── Notes vs. itemized receipts (#1658) ──────────────────────────────────────
|
||||
|
||||
@@ -11,10 +11,24 @@
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
|
||||
// places, and the old fixed two rejected every keystroke after that, leaving the field
|
||||
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
|
||||
// value a field accepts today can become uneditable.
|
||||
export const amountPattern = (currency: string, signed: boolean) =>
|
||||
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
|
||||
|
||||
/**
|
||||
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
|
||||
* Floor-based, so a negative total (a refund, #2176) splits just as exactly:
|
||||
* the remainder is always in [0, n) and the parts still sum to the input.
|
||||
*/
|
||||
export function splitCents(amount: number, n: number): number[] {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const cents = Math.round(amount * 100)
|
||||
const base = Math.floor(cents / n)
|
||||
const rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
@@ -58,6 +72,13 @@ export function rebalancePayers(
|
||||
* The remainder cent rotates with the item id rather than always landing on the
|
||||
* first member, so across several expenses the rounding evens out instead of
|
||||
* always favouring the same person.
|
||||
*
|
||||
* Must stay share-for-share identical to the server's
|
||||
* BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted
|
||||
* there, this copy only previews it. The remainder is `totalCents - baseCents*n`
|
||||
* rather than `%` so a negative total (a refund, #2176) still yields a remainder
|
||||
* in [0, n) and the shares sum back to the total exactly, like the server's do.
|
||||
* The parity fixture in CostsPanel.helpers.test.ts pins both sides.
|
||||
*/
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length
|
||||
@@ -65,7 +86,7 @@ export function splitEqualShares(total: number, members: { user_id: number }[],
|
||||
|
||||
const totalCents = Math.round(total * 100)
|
||||
const baseCents = Math.floor(totalCents / n)
|
||||
const remainder = totalCents % n
|
||||
const remainder = totalCents - baseCents * n
|
||||
|
||||
const shares: Record<number, number> = {}
|
||||
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
|
||||
|
||||
@@ -518,8 +518,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
// Seeded with two decimals since #2175, localized for EUR.
|
||||
const amounts = await screen.findAllByTestId('payer-amount')
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45,00', '45,00'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -705,8 +706,9 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
await screen.findByText('Payment')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// The stored USD amount comes back as-is, not silently reread as euros.
|
||||
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30')
|
||||
// The stored USD amount comes back as-is (padded to two decimals, #2175),
|
||||
// not silently reread as euros.
|
||||
expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30.00')
|
||||
expect(screen.getByText(/^USD/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1300,8 +1302,9 @@ describe('CostsPanel — expense modal', () => {
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT')
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
// Custom shares reopen padded to two decimals (#2175), localized for EUR.
|
||||
expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT')
|
||||
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
|
||||
|
||||
// Excluding Bob drops his amount; the split no longer matches the total.
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
@@ -1430,7 +1433,8 @@ describe('CostsPanel — expense modal', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('240')).toBeInTheDocument()
|
||||
// The prefilled amount is padded to the base currency's decimals (#2175).
|
||||
expect(screen.getByDisplayValue('240,00')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Add expense' }))
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
@@ -1640,7 +1644,8 @@ describe('CostsPanel — remaining paths', () => {
|
||||
await screen.findByText('Mining rig')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
expect(await screen.findByDisplayValue('2')).toBeInTheDocument()
|
||||
// XBT is unknown to the locale map → dot separator, two decimals (#2175).
|
||||
expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
@@ -1862,6 +1867,173 @@ describe('CostsPanel — mobile extras', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2175: edit inputs are padded to the currency's decimals ────────────────
|
||||
describe('CostsPanel — decimal padding on edit (#2175)', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-066: reopens 4,90 as "4,90" and 5,00 as "5,00", not "4,9" and "5"', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([
|
||||
expense({ id: 301, name: 'Coffee', category: 'food', total_price: 4.9, payers: [{ user_id: 1, amount: 4.9 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 302, name: 'Toll', category: 'transport', total_price: 5, payers: [{ user_id: 1, amount: 5 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Coffee')
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await user.click(screen.getAllByTitle('Edit')[1])
|
||||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="KWD" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3,
|
||||
payers: [{ user_id: 1, amount: 3 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }],
|
||||
})} />)
|
||||
|
||||
expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument()
|
||||
const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[]
|
||||
|
||||
// The seed is three decimals, so a two-decimal guard would reject every further
|
||||
// keystroke, including one typed in front of the decimal point.
|
||||
await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 })
|
||||
expect(shares[0].value).toBe('21.500')
|
||||
|
||||
// A fourth decimal is still not a fils.
|
||||
await user.clear(shares[1])
|
||||
await user.type(shares[1], '1.5005')
|
||||
expect(shares[1].value).toBe('1.500')
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2176: negative amounts record partial reimbursements ───────────────────
|
||||
describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
beforeEach(() => {
|
||||
seedAlice()
|
||||
clearExchangeRateCache()
|
||||
})
|
||||
afterEach(clearExchangeRateCache)
|
||||
|
||||
it('FE-W5COSTS-067: "-100" can be typed and saves a negative expense with its payer', async () => {
|
||||
const user = userEvent.setup()
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel refund' }), id: 12 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel refund')
|
||||
// The reported symptom: the '-' was stripped on input, so this was untypeable.
|
||||
await user.type(screen.getAllByPlaceholderText('0,00')[0], '-100')
|
||||
|
||||
const submits = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
expect(submits[submits.length - 1]).not.toBeDisabled()
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(-100)
|
||||
expect(posted!.payers).toEqual([{ user_id: 1, amount: -100 }])
|
||||
expect(posted!.member_ids).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-068: a refund reopens with its negative payer intact and saves it back', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/310', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item: dinner() })
|
||||
}))
|
||||
mount([expense({
|
||||
id: 310, name: 'Hotel refund', category: 'lodging', total_price: -100,
|
||||
payers: [{ user_id: 1, amount: -100 }],
|
||||
members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }],
|
||||
})])
|
||||
|
||||
// The row shows the amount twice — total pill and the payer chip, which used
|
||||
// to be filtered to positive amounts and left the refund's recipient invisible.
|
||||
const row = (await screen.findByText('Hotel refund')).closest('.exp-row') as HTMLElement
|
||||
expect(within(row).getAllByText(/100,00/).length).toBeGreaterThanOrEqual(2)
|
||||
// Not "Unfinished": the refund has its recipient recorded.
|
||||
expect(within(row).queryByText('Unfinished')).toBeNull()
|
||||
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
// The negative payer reopens selected instead of resetting to "Nobody",
|
||||
// and the total is seeded signed and padded.
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument()
|
||||
|
||||
// Saving untouched must not silently drop the negative payer.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.total_price).toBe(-100)
|
||||
expect(put!.payers).toEqual([{ user_id: 1, amount: -100 }])
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-069: a payer-less refund is marked Unfinished like a payer-less bill', async () => {
|
||||
mount([expense({ id: 320, name: 'Pending refund', category: 'misc', total_price: -50, payers: [], members: [{ user_id: 1, username: 'alice' }] })])
|
||||
|
||||
await screen.findByText('Pending refund')
|
||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-070: the category breakdown nets refunds and keeps a net-negative category listed', async () => {
|
||||
mount([
|
||||
expense({ id: 330, name: 'Dinner', category: 'food', total_price: 90, payers: [{ user_id: 1, amount: 90 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 331, name: 'Meal refund', category: 'food', total_price: -30, payers: [{ user_id: 1, amount: -30 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
expense({ id: 332, name: 'Cancelled tour', category: 'activities', total_price: -20, payers: [{ user_id: 1, amount: -20 }], members: [{ user_id: 1, username: 'alice' }] }),
|
||||
])
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
const breakdown = screen.getByText('By category').parentElement as HTMLElement
|
||||
// Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own.
|
||||
expect(within(breakdown).getByText('60 €')).toBeInTheDocument()
|
||||
expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100,
|
||||
payers: [{ user_id: 1, amount: -100 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }],
|
||||
})} />)
|
||||
|
||||
// -70 of -100: 30 still to hand out, not 30 too much.
|
||||
expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
|
||||
expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
onClose={() => {}} onSaved={vi.fn()} />)
|
||||
|
||||
const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement
|
||||
await user.type(total, '100')
|
||||
|
||||
// The iOS decimal pad has no minus key, so the sign has to be reachable without one.
|
||||
const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' })
|
||||
await user.click(toggle)
|
||||
|
||||
expect(total.value).toBe('-100')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — split maths', () => {
|
||||
it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => {
|
||||
expect(splitEqualShares(90, [], 1)).toEqual({})
|
||||
|
||||
@@ -10,14 +10,14 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -134,8 +134,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) > 0 && (e.payers || []).filter(p => p.amount > 0).length === 0
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
@@ -717,7 +718,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const c = catMeta(e.category)
|
||||
const Icon = c.Icon
|
||||
const cur = curOf(e)
|
||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||
const payers = (e.payers || []).filter(p => p.amount !== 0)
|
||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||
const unfinished = isUnfinished(e)
|
||||
const note = readUserNote(e)
|
||||
@@ -878,9 +879,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
// Categories net refunds against spend (#2176): a negative entry lowers its
|
||||
// category's sum, and a category that nets negative keeps its own row —
|
||||
// just without a bar, since the bars rank positive spend.
|
||||
const tot: Record<string, number> = {}
|
||||
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) !== 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
|
||||
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))' }}>{t('costs.noCategories')}</div>
|
||||
// Bars are scaled relative to the most expensive category (the top row fills the
|
||||
// bar), not to the trip grand total — makes the relative ranking readable.
|
||||
@@ -888,7 +892,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{rows.map(c => {
|
||||
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
|
||||
const v = tot[c.key]; const pct = maxCat > 0 && v > 0 ? v / maxCat * 100 : 0
|
||||
return (
|
||||
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
|
||||
@@ -966,7 +970,9 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
||||
// Seeded with the transfer's own currency decimals, so a reopened 4,90 reads
|
||||
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
|
||||
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
|
||||
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -1051,9 +1057,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || localToday())
|
||||
const [note, setNote] = useState(() => readUserNote(editing))
|
||||
// Edit and prefill seeds are padded to the currency's decimals (#2175): the DB
|
||||
// returns numbers, so a saved 4,90 would otherwise reopen as "4,9" and a saved
|
||||
// 5,00 as "5". A prefill has no currency of its own — it is read as `base`,
|
||||
// which is also what the currency field starts on.
|
||||
const [total, setTotal] = useState<string>(() => {
|
||||
if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
if (editing) return editing.total_price ? amountToInputString(editing.total_price, (editing.currency || base).toUpperCase()) : ''
|
||||
if (prefill?.amount != null) return amountToInputString(prefill.amount, base)
|
||||
return ''
|
||||
})
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
@@ -1064,8 +1074,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
|
||||
// one defaults to me. A negative payer (the recipient of a refund, #2176) is a
|
||||
// real payer — filtering on > 0 here would silently drop them on save.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount !== 0)
|
||||
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = initialPayers[0]
|
||||
@@ -1076,7 +1087,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = String(p.amount)
|
||||
for (const p of initialPayers) m[p.user_id] = amountToInputString(p.amount, currency)
|
||||
return m
|
||||
})
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
@@ -1101,7 +1112,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
if (editing && editing.members) {
|
||||
for (const member of editing.members) {
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
m[member.user_id] = String(member.amount)
|
||||
m[member.user_id] = amountToInputString(member.amount, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1130,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
// (#2176) the shares run negative, so a plain total minus sum flips under and
|
||||
// over around and sends the user the wrong way.
|
||||
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||
@@ -1131,17 +1146,22 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const enteredSum = [...participants]
|
||||
.filter(id => customAmounts[id])
|
||||
.reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const remaining = Math.max(0, totalNum - enteredSum)
|
||||
// Clamped toward zero on the total's own side, so an over-entered positive
|
||||
// split never suggests negative leftovers — while a negative total (#2176)
|
||||
// still previews its negative equal shares.
|
||||
const rest = totalNum - enteredSum
|
||||
const remaining = totalNum >= 0 ? Math.max(0, rest) : Math.min(0, rest)
|
||||
|
||||
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
// A negative total is a valid entry (a refund, #2176); only zero has nothing to say.
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
: totalNum !== 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
)
|
||||
|
||||
const onTotalChange = (v: string) => {
|
||||
@@ -1196,7 +1216,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
}
|
||||
@@ -1219,7 +1239,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
}
|
||||
@@ -1264,7 +1284,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
.filter(p => p.amount !== 0)
|
||||
: payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
// A receipt line can name somebody who is not ticked as a participant. Sending
|
||||
// only the ticked set would drop their share, leaving the member sum short of
|
||||
@@ -1334,8 +1354,9 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
@@ -1353,7 +1374,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && totalNum > 0 && (
|
||||
{currency !== base && totalNum !== 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
@@ -1422,7 +1443,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className="text-content"
|
||||
@@ -1591,7 +1612,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
{customBalanced
|
||||
? t('costs.splitBalanced')
|
||||
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
: t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
sum: sym(currency) + splitSum.toFixed(2),
|
||||
total: sym(currency) + totalNum.toFixed(2),
|
||||
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
|
||||
|
||||
@@ -1327,6 +1327,14 @@ function wsHandler(): (msg: Record<string, unknown>) => void {
|
||||
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
}
|
||||
|
||||
function openedNoteBody(): Promise<HTMLElement> {
|
||||
return waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full');
|
||||
if (!md) throw new Error('view modal not open yet');
|
||||
return md as HTMLElement;
|
||||
});
|
||||
}
|
||||
|
||||
describe('CollabNotes details', () => {
|
||||
let addToast: ReturnType<typeof vi.fn<AddToast>>;
|
||||
let filesChanged: number;
|
||||
@@ -1912,4 +1920,31 @@ describe('CollabNotes details', () => {
|
||||
// …and both rejected writes are one message, not one each.
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-033: a placeholder in angle brackets survives sanitizing', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 7, title: 'Hotel', content: 'Zimmernummer <TBD> bestaetigen' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
// Notes written before markdown rendering existed keep reading the way they
|
||||
// were typed (#2177).
|
||||
expect(within(full).getByText('Zimmernummer <TBD> bestaetigen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-034: a footnote reference lands on the footnote instead of a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 8, title: 'Booking', content: 'Hotel gebucht[^1]\n\n[^1]: Bestaetigung ABC123' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Booking');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
const ref = within(full).getByRole('link', { name: '1' });
|
||||
const href = ref.getAttribute('href')!;
|
||||
expect(href.startsWith('#')).toBe(true);
|
||||
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
|
||||
expect(ref).not.toHaveAttribute('target');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
@@ -445,7 +446,7 @@ function ViewNoteModal(S: NotesState) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownPlugins, sanitizedMarkdownComponents } from '../shared/markdownSanitize'
|
||||
import { Trash2, Pin, PinOff, Pencil, Maximize2 } from 'lucide-react'
|
||||
import { FONT } from './CollabNotes.constants'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
@@ -142,7 +143,7 @@ export function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdi
|
||||
maxHeight: '4.5em', overflow: 'hidden',
|
||||
wordBreak: 'break-word', fontFamily: FONT,
|
||||
}}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{note.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -732,3 +732,146 @@ describe('CollabPolls details', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// FE-COMP-POLLS-016 to FE-COMP-POLLS-022
|
||||
// Markdown questions, sanitization and multiline input (#2177).
|
||||
|
||||
describe('CollabPolls markdown & multiline', () => {
|
||||
it('FE-COMP-POLLS-016: a markdown question renders headings and bold text', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: '## Day trip\n\n**Vote** carefully' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const heading = await screen.findByText('Day trip');
|
||||
expect(heading.closest('h2')).not.toBeNull();
|
||||
expect(screen.getByText('Vote').closest('strong')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-017: a script/onerror injection attempt produces no live DOM nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({ question: 'Safe title <script>alert("xss")</script> <img src=x onerror=alert(1)>' })],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/Safe title/);
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
expect(document.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(document.querySelector('[onerror]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-018: blank lines split the question into separate paragraphs', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'First paragraph\n\nSecond paragraph' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const first = await screen.findByText('First paragraph');
|
||||
const second = screen.getByText('Second paragraph');
|
||||
expect(first.tagName).toBe('P');
|
||||
expect(second.tagName).toBe('P');
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-019: a plain-text question renders identically, without formatting nodes', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Best destination?' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const question = await screen.findByText('Best destination?');
|
||||
expect(question.textContent).toBe('Best destination?');
|
||||
// The question sits in a paragraph now, but no formatting was invented
|
||||
expect(question.parentElement!.querySelector('strong, em, h1, h2, a')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-020: the question field is a textarea — Enter adds a line instead of submitting', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ poll: buildPoll({ id: 60, question: 'Line one\nLine two' }) });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
const questionField = screen.getByPlaceholderText(/what should we do/i);
|
||||
expect(questionField.tagName).toBe('TEXTAREA');
|
||||
await user.type(questionField, 'Line one{enter}Line two');
|
||||
// Enter stayed in the field — the form was not submitted without options
|
||||
expect(body).toBeNull();
|
||||
expect(questionField).toHaveValue('Line one\nLine two');
|
||||
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
expect(optionInputs[0].tagName).toBe('TEXTAREA');
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await waitFor(() => expect(body).toMatchObject({ question: 'Line one\nLine two' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-021: a link in the question opens in a new tab with rel protection', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Where? [Our hotel](https://example.com)' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const link = await screen.findByRole('link', { name: 'Our hotel' });
|
||||
expect(link).toHaveAttribute('href', 'https://example.com');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-022: a long multiline option wraps instead of being clipped', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({
|
||||
polls: [buildPoll({
|
||||
options: [
|
||||
{ id: 1, text: 'Stay at the beach house\nwith the long unpronounceable name', voters: [] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
const label = (await screen.findByText(/Stay at the beach house/)).closest('span')!;
|
||||
expect(label.style.whiteSpace).toBe('pre-wrap');
|
||||
expect(label.style.overflowWrap).toBe('anywhere');
|
||||
expect(label.style.minWidth).toBe('0px');
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-023: a placeholder in angle brackets survives sanitizing', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Treffpunkt <noch offen> oder Hotel?' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
// Questions written before markdown rendering existed must keep reading the
|
||||
// way they were typed, and they cannot be edited afterwards (#2177).
|
||||
expect(await screen.findByText('Treffpunkt <noch offen> oder Hotel?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-POLLS-024: a script tag is shown as text, never as an element', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json({ polls: [buildPoll({ question: 'Plan <script>alert("xss")</script>' })] }),
|
||||
),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('Plan <script>alert("xss")</script>')).toBeInTheDocument();
|
||||
expect(document.querySelector('script')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -35,6 +39,34 @@ interface Poll {
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
// Block styling for the markdown question (#2177). Tailwind's preflight resets
|
||||
// headings/lists, so the sizes are set here — in em, on purpose: they scale with
|
||||
// the surrounding calc(13px * var(--fs-scale-body)) base, so the user's
|
||||
// text-size setting keeps working. Links and sanitizing come from
|
||||
// markdownSanitize (#1629): new tab plus rel protection, this is cross-user
|
||||
// content.
|
||||
const pollMarkdownComponents: Components = {
|
||||
...sanitizedMarkdownComponents,
|
||||
p: ({ children }) => (
|
||||
<p style={{ margin: '0 0 0.55em' }}>{children}</p>
|
||||
),
|
||||
h1: ({ children }) => (
|
||||
<h1 style={{ margin: '0 0 0.45em', fontSize: '1.4em', lineHeight: 1.2 }}>{children}</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 style={{ margin: '0 0 0.45em', fontSize: '1.25em', lineHeight: 1.25 }}>{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 style={{ margin: '0 0 0.45em', fontSize: '1.1em', lineHeight: 1.3 }}>{children}</h3>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'disc' }}>{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: '0 0 0.55em', paddingLeft: 20, listStyle: 'decimal' }}>{children}</ol>
|
||||
),
|
||||
}
|
||||
|
||||
function timeRemaining(deadline) {
|
||||
if (!deadline) return null
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
@@ -98,7 +130,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<textarea autoFocus rows={5} value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', minHeight: 110, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.4, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 3 }}>{t('collab.polls.markdownHint')}</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -107,8 +140,8 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
<textarea rows={2} value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, minWidth: 0, resize: 'vertical', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.35, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
@@ -223,7 +256,9 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={pollMarkdownComponents}>
|
||||
{poll.question}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
@@ -281,7 +316,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
<button type="button" key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
@@ -308,9 +343,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
{/* Label — options stay plain text, but long or multiline ones
|
||||
must wrap instead of being clipped by the button's
|
||||
overflow:hidden (#2177) */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word',
|
||||
lineHeight: 1.35, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.text}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-010
|
||||
// FE-COMP-COLLIST-001 to FE-COMP-COLLIST-011
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
@@ -157,4 +157,14 @@ describe('CollectionList', () => {
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toBeInTheDocument();
|
||||
expect(screen.getByText('Golden Gate Bridge').closest('.col-lrow')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIST-011: the row opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the row-wide role="button" shifted the status badge out from under the pointer,
|
||||
// so the click retargeted onto the row and opened the place instead of cycling the
|
||||
// status. The data-no-press attribute is the pin.
|
||||
renderList();
|
||||
|
||||
expect(screen.getByText('Blue Bottle Coffee').closest('.col-lrow')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,10 @@ export default function CollectionList({
|
||||
key={place.id}
|
||||
ref={active ? selectedRef : undefined}
|
||||
role="button"
|
||||
// Opt out of the global :active press-scale: shrinking the row-wide button
|
||||
// slides the status badge out from under the pointer mid-click, so the click
|
||||
// retargets onto the row and opens the place instead of cycling (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => (selectMode ? onToggleSelect(place.id) : onOpenPlace(place.id))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (selectMode) onToggleSelect(place.id); else onOpenPlace(place.id) } }}
|
||||
|
||||
@@ -189,6 +189,23 @@ describe('EntryCard', () => {
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// #2064: a stop switched off the route says so on its card, so a journal
|
||||
// that reads fine still shows which of its days the printed map skips.
|
||||
it('FE-JRN-CARD-018: marks an entry left out of the route, on the header and on the hero', () => {
|
||||
const { unmount } = mountCard(buildEntry({ stats_excluded: true }))
|
||||
expect(screen.getByText('Off route')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
mountCard(buildEntry({ stats_excluded: true, photos: [buildPhoto(100)] }))
|
||||
expect(screen.getByText('Off route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-019: carries no such mark on an ordinary entry', () => {
|
||||
mountCard(buildEntry({ photos: [buildPhoto(100)] }))
|
||||
|
||||
expect(screen.queryByText('Off route')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SkeletonCard', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
@@ -68,6 +68,14 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
{/* Switched off the route (#2064): the day is still in the journal,
|
||||
the printed map and the distance skip it, and the card says so. */}
|
||||
{entry.stats_excluded && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white tracking-wide">
|
||||
<RouteOff size={10} />
|
||||
{t('journey.entry.offRoute')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu top-right */}
|
||||
@@ -110,6 +118,11 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
<Clock size={10} /> {entry.entry_time}
|
||||
</span>
|
||||
)}
|
||||
{entry.stats_excluded && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-zinc-100 dark:bg-zinc-800 rounded-full text-[10px] font-semibold text-zinc-500">
|
||||
<RouteOff size={10} /> {t('journey.entry.offRoute')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="relative">
|
||||
|
||||
@@ -965,4 +965,67 @@ describe('EntryEditor', () => {
|
||||
expect(library[0]).toHaveAttribute('multiple')
|
||||
expect(camera[0]).not.toHaveAttribute('multiple')
|
||||
})
|
||||
|
||||
// #2064: a home airport written up as an entry is a stop on the printed
|
||||
// route. The editor offers the same switch the Studio panel has, but only
|
||||
// to an entry that is (or was) a stop: one without a point never counted,
|
||||
// and a new entry is not on the route yet.
|
||||
it('FE-JRN-EDITOR-051: offers to leave a located entry out of the route and saves it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({
|
||||
id: 10, location_name: 'Keflavík', location_lat: 63.98, location_lng: -22.6,
|
||||
}))
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: true })
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-052: keeps the switch away from a new entry, point or no point', () => {
|
||||
mountEditor(buildEntry({ location_lat: 63.98, location_lng: -22.6 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-053: keeps the switch away from an entry without a point, and sends nothing for it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Leave out of the route' })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0].stats_excluded).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-054: still offers the switch to a left-out entry, so it can be put back', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = mountEditor(buildEntry({ id: 10, stats_excluded: true }))
|
||||
|
||||
const toggle = screen.getByRole('button', { name: 'Leave out of the route' })
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled())
|
||||
expect(onSave.mock.calls[0][0]).toMatchObject({ stats_excluded: false })
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-055: flipping the switch is a change worth warning about', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
const user = userEvent.setup()
|
||||
const { onClose } = mountEditor(buildEntry({ id: 10, location_lat: 63.98, location_lng: -22.6 }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leave out of the route' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?')
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
confirmSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import { photoUrl, isValidGeoPoint, geoOnceErrorKey } from '../../pages/journeyD
|
||||
import MarkdownToolbar from './MarkdownToolbar'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
@@ -48,6 +49,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [mood, setMood] = useState(entry.mood || '')
|
||||
const [weather, setWeather] = useState(entry.weather || '')
|
||||
const [statsExcluded, setStatsExcluded] = useState(entry.stats_excluded ?? false)
|
||||
const [pros, setPros] = useState<string[]>(entry.pros_cons?.pros?.length ? entry.pros_cons.pros : [''])
|
||||
const [cons, setCons] = useState<string[]>(entry.pros_cons?.cons?.length ? entry.pros_cons.cons : [''])
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -87,6 +89,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
(locationLng ?? null) !== (entry.location_lng ?? null) ||
|
||||
mood !== (entry.mood || '') ||
|
||||
weather !== (entry.weather || '') ||
|
||||
statsExcluded !== (entry.stats_excluded ?? false) ||
|
||||
pros.filter(p => p.trim()).join('\n') !== originalPros ||
|
||||
cons.filter(c => c.trim()).join('\n') !== originalCons ||
|
||||
pendingFiles.length > 0 ||
|
||||
@@ -146,6 +149,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
location_name: locationName || null,
|
||||
location_lat: locationLat,
|
||||
location_lng: locationLng,
|
||||
stats_excluded: offersStatsToggle ? statsExcluded : undefined,
|
||||
mood: mood || null,
|
||||
weather: weather || null,
|
||||
pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) },
|
||||
@@ -214,6 +218,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
|
||||
: null
|
||||
|
||||
// The route switch belongs to an entry that is a stop, or was one: an entry
|
||||
// without a point was never on the route, and a new one is not on it yet.
|
||||
const offersStatsToggle = entry.id > 0 && (contextLocation != null || !!entry.stats_excluded)
|
||||
|
||||
const handleUseCurrentLocation = async () => {
|
||||
if (locating) return
|
||||
setLocating(true)
|
||||
@@ -695,6 +703,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Every located entry is a stop on the route Studio prints, the home
|
||||
airport included. This is the entry's own way off it, the same
|
||||
switch the Studio travel panel offers (#2064). */}
|
||||
{offersStatsToggle && (
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-semibold text-zinc-900 dark:text-white">{t('journey.editor.statsExcluded')}</div>
|
||||
<div className="text-[11px] leading-snug text-zinc-500 mt-0.5">{t('journey.editor.statsExcludedHint')}</div>
|
||||
</div>
|
||||
<ToggleSwitch on={statsExcluded} onToggle={() => setStatsExcluded(v => !v)} label={t('journey.editor.statsExcluded')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
|
||||
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-019
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { act } from 'react'
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
@@ -73,20 +73,41 @@ const entries: JourneyEntry[] = [
|
||||
},
|
||||
]
|
||||
|
||||
/** Mirrors the page wiring: the gallery reports its connected providers up,
|
||||
and the host renders one button per provider next to Upload. */
|
||||
function GalleryHarness({ gallery, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
gallery: GalleryPhoto[]
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
}) {
|
||||
const [providers, setProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const browseRef = useRef<((provider: string) => void) | null>(null)
|
||||
return (
|
||||
<>
|
||||
{providers.map(p => (
|
||||
<button type="button" key={p.id} onClick={() => browseRef.current?.(p.id)}>{p.name}</button>
|
||||
))}
|
||||
<GalleryView
|
||||
entries={entries}
|
||||
gallery={gallery}
|
||||
journeyId={JOURNEY_ID}
|
||||
userId={1}
|
||||
trips={trips}
|
||||
onPhotoClick={onPhotoClick}
|
||||
onRefresh={onRefresh}
|
||||
onRegisterUpload={onRegisterUpload}
|
||||
onRegisterProviders={(p, browse) => { setProviders(p); browseRef.current = browse }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
|
||||
const onPhotoClick = vi.fn()
|
||||
const onRefresh = vi.fn()
|
||||
const utils = render(
|
||||
<GalleryView
|
||||
entries={entries}
|
||||
gallery={gallery}
|
||||
journeyId={JOURNEY_ID}
|
||||
userId={1}
|
||||
trips={trips}
|
||||
onPhotoClick={onPhotoClick}
|
||||
onRefresh={onRefresh}
|
||||
onRegisterUpload={onRegisterUpload}
|
||||
/>,
|
||||
<GalleryHarness gallery={gallery} onPhotoClick={onPhotoClick} onRefresh={onRefresh} onRegisterUpload={onRegisterUpload} />,
|
||||
)
|
||||
return { ...utils, onPhotoClick, onRefresh }
|
||||
}
|
||||
@@ -352,4 +373,14 @@ describe('GalleryView', () => {
|
||||
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
|
||||
expect(container.querySelector('.animate-spin')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-GALLERY-019: the tile opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the tile shifted its corner delete button out from under the pointer, so the
|
||||
// click retargeted onto the tile and opened the lightbox instead of deleting.
|
||||
// The data-no-press attribute is the pin.
|
||||
const { container } = mountGallery([buildGalleryPhoto()])
|
||||
|
||||
expect(container.querySelector('.aspect-square')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { RefreshCw, Camera, Image, X, Play } from 'lucide-react'
|
||||
import { RefreshCw, Camera, X, Play } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
@@ -13,7 +13,7 @@ import { ProviderPicker } from './JourneyDetailPageProviderPicker'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
|
||||
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload, onRegisterProviders }: {
|
||||
entries: JourneyEntry[]
|
||||
gallery: GalleryPhoto[]
|
||||
journeyId: number
|
||||
@@ -22,16 +22,17 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
onPhotoClick: (photos: GalleryPhoto[], index: number) => void
|
||||
onRefresh: () => void
|
||||
onRegisterUpload?: (fn: () => void) => void
|
||||
onRegisterProviders?: (providers: { id: string; name: string }[], browse: (provider: string) => void) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
const [pickerProvider, setPickerProvider] = useState<string | null>(null)
|
||||
const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([])
|
||||
const [galleryProgress, setGalleryProgress] = useState<{ done: number; total: number } | null>(null)
|
||||
const galleryUploading = galleryProgress !== null
|
||||
const toast = useToast()
|
||||
|
||||
// check which providers are enabled AND connected for the current user
|
||||
// check which providers are enabled AND connected for the current user, then
|
||||
// hand them up: the page header renders the provider buttons next to Upload
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -46,7 +47,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
if (status.connected) connected.push({ id: p.id, name: p.name })
|
||||
} catch { }
|
||||
}
|
||||
setAvailableProviders(connected)
|
||||
onRegisterProviders?.(connected, browseProvider)
|
||||
} catch { }
|
||||
})()
|
||||
}, [])
|
||||
@@ -126,23 +127,11 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<div>
|
||||
<input ref={galleryFileRef} type="file" accept="image/*,video/*" multiple onChange={handleGalleryUpload} className="hidden" />
|
||||
|
||||
{/* Header */}
|
||||
{/* Header — the provider buttons live in the page header next to Upload */}
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-[0.07em]" style={{ background: 'var(--vg-surf2)', color: 'var(--vg-ink3)' }}>
|
||||
<Camera size={11} /> {allPhotos.length} {t('journey.detail.photos')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{availableProviders.map(p => (
|
||||
<button type="button"
|
||||
key={p.id}
|
||||
onClick={() => browseProvider(p.id)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[11px] font-medium text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<Image size={12} />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{allPhotos.length === 0 ? (
|
||||
@@ -153,6 +142,10 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
||||
<div
|
||||
key={photo.id}
|
||||
role="button"
|
||||
// No press-scale on the tile: shrinking it mid-click slides the corner
|
||||
// delete button out from under the pointer, so the click retargets onto
|
||||
// the tile and opens the lightbox instead (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => onPhotoClick(allPhotos, i)}
|
||||
|
||||
@@ -373,4 +373,40 @@ describe('JourneySettingsDialog', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('heading', { name: 'Journey Settings' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-023: the trip-GPX switch reads off by default and turns on in one click (#2194)', async () => {
|
||||
const { onRefresh, onSaved } = mountDialog()
|
||||
|
||||
const sw = screen.getByRole('switch', { name: /GPX/i })
|
||||
expect(sw).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await userEvent.click(sw)
|
||||
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: true }))
|
||||
// Refresh, never onSaved: onSaved closes the dialog, which would destroy the
|
||||
// switch as it is flipped and skip the unsaved-changes guard.
|
||||
await waitFor(() => expect(onRefresh).toHaveBeenCalled())
|
||||
expect(onSaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-024: a journey that already draws its tracks switches them back off (#2194)', async () => {
|
||||
// The column is INTEGER, so what arrives over the wire is 1, not true.
|
||||
mountDialog(buildJourney({ show_trip_tracks: 1 }))
|
||||
|
||||
const sw = screen.getByRole('switch', { name: /GPX/i })
|
||||
expect(sw).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
await userEvent.click(sw)
|
||||
await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { show_trip_tracks: false }))
|
||||
})
|
||||
|
||||
it('FE-JRN-SETTINGS-025: a failed switch write says so and leaves the dialog open (#2194)', async () => {
|
||||
updateJourney.mockRejectedValueOnce(new Error('nope'))
|
||||
const { onClose } = mountDialog()
|
||||
|
||||
await userEvent.click(screen.getByRole('switch', { name: /GPX/i }))
|
||||
|
||||
await waitFor(() => expect(toastSpy).toHaveBeenCalled())
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,6 +77,24 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}
|
||||
}
|
||||
|
||||
// Saved on the spot rather than on Save, like the archive switch above it:
|
||||
// it is a view setting, and the point of it is seeing the map change (#2194).
|
||||
const [savingTracks, setSavingTracks] = useState(false)
|
||||
const handleTracksToggle = async () => {
|
||||
setSavingTracks(true)
|
||||
try {
|
||||
await updateJourney(journey.id, { show_trip_tracks: !journey.show_trip_tracks })
|
||||
// onRefresh, not onSaved: onSaved closes the dialog, which would destroy the
|
||||
// switch the moment it is flipped AND skip handleClose's unsaved-changes
|
||||
// guard, silently dropping a title the owner had typed but not saved yet.
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setSavingTracks(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteJourney(journey.id)
|
||||
@@ -141,6 +159,30 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Trip GPX tracks on the journey map (#2194) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.tracks')}</label>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!journey.show_trip_tracks}
|
||||
disabled={savingTracks}
|
||||
onClick={handleTracksToggle}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.settings.showTripTracks')}</span>
|
||||
<span className="block text-[11px] text-zinc-500">{t('journey.settings.showTripTracksHint')}</span>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${journey.show_trip_tracks ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}
|
||||
>
|
||||
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${journey.show_trip_tracks ? 'translate-x-4' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-027
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-049
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -604,6 +604,39 @@ describe('JourneyMap', () => {
|
||||
expect(colors).not.toContain('#ffffff');
|
||||
});
|
||||
|
||||
/** The opening viewport is the assertion here, so the frame gets read out of the bounds call. */
|
||||
const fitCoords = () => vi.mocked(L.latLngBounds).mock.calls[0][0] as [number, number][];
|
||||
|
||||
// #2194: a recorded drive across the country used to pull the opening view out to
|
||||
// the whole trip, leaving the journey's own entries as a speck.
|
||||
it('FE-COMP-JOURNEYMAP-048: frames the entries, not the tracks, when the journey has both', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} tracks={[track({ points: [[10, 10], [11, 11]] })]} />
|
||||
);
|
||||
|
||||
expect(mockedMap().fitBounds).toHaveBeenCalled();
|
||||
expect(fitCoords()).toEqual([[48.8566, 2.3522], [52.52, 13.405]]);
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-049: with nothing else on the map the tracks are framed instead of the world', () => {
|
||||
const origRAF = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithoutCoords} tracks={[track()]} />);
|
||||
|
||||
expect(fitCoords()).toEqual([[47.1, 11.2], [47.2, 11.3]]);
|
||||
expect(mockedMap().setView).not.toHaveBeenCalledWith([30, 0], 2);
|
||||
} finally {
|
||||
globalThis.requestAnimationFrame = origRAF;
|
||||
}
|
||||
});
|
||||
|
||||
// A string handed to bindTooltip becomes innerHTML. A track name is a place
|
||||
// name off a shared trip and an entry label is a journey title, so both are
|
||||
// written by someone who is not necessarily the person reading the map — and
|
||||
|
||||
@@ -268,6 +268,23 @@ function JourneyMap(
|
||||
itemsRef.current = items
|
||||
|
||||
const allCoords: L.LatLngTuple[] = []
|
||||
/**
|
||||
* Track geometry is kept OUT of the fit set (#2194) and used only when there
|
||||
* is nothing else to frame.
|
||||
*
|
||||
* A journey's opening viewport should show the journey — its entries and the
|
||||
* trail between them. Letting a recorded GPX into the bounds meant one drive
|
||||
* across a country zoomed the map out until the entries were specks, which
|
||||
* is what the reporter's screenshot shows. JourneyMapGL already fits on
|
||||
* entries + trail alone, so for every journey that has entries the two
|
||||
* renderers now agree where they used to differ.
|
||||
*
|
||||
* They still differ for a journey with tracks and nothing else: this one
|
||||
* frames the tracks, JourneyMapGL falls back to the world view. Framing the
|
||||
* only thing on the map is the better of the two, and converging the GL side
|
||||
* is a change to a renderer this issue is not about.
|
||||
*/
|
||||
const trackCoords: L.LatLngTuple[] = []
|
||||
|
||||
if (stableTrail.length > 1) {
|
||||
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
|
||||
@@ -293,7 +310,7 @@ function JourneyMap(
|
||||
// becomes innerHTML, and a track name is a place name off a shared trip.
|
||||
if (track.name) line.bindTooltip(escapeHtml(track.name), { sticky: true, direction: 'top', className: 'map-tooltip' })
|
||||
line.addTo(map)
|
||||
coords.forEach(c => allCoords.push(c))
|
||||
coords.forEach(c => trackCoords.push(c))
|
||||
}
|
||||
|
||||
// route polyline — only in non-fullscreen (sidebar map) mode
|
||||
@@ -341,9 +358,12 @@ function JourneyMap(
|
||||
if (!mapRef.current) return
|
||||
try {
|
||||
map.invalidateSize()
|
||||
if (allCoords.length > 0) {
|
||||
// Tracks only get a say when the journey has nothing located of its own —
|
||||
// a world view would be worse than framing the one thing on the map.
|
||||
const fitCoords = allCoords.length > 0 ? allCoords : trackCoords
|
||||
if (fitCoords.length > 0) {
|
||||
const pb = paddingBottom || 50
|
||||
map.fitBounds(L.latLngBounds(allCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
map.fitBounds(L.latLngBounds(fitCoords), { paddingTopLeft: [50, 50], paddingBottomRight: [50, pb], maxZoom: 16 })
|
||||
} else {
|
||||
map.setView([30, 0], 2)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// FE-MAP-LOCBTN-001 to FE-MAP-LOCBTN-005
|
||||
import { render } from '@testing-library/react';
|
||||
import LocationButton from './LocationButton';
|
||||
import type { GeoWatchErrorCode } from '../../hooks/useGeolocation';
|
||||
|
||||
// useToast talks to the mounted ToastContainer through window.__addToast,
|
||||
// so a spy there observes exactly what the user would get shown. Outside a
|
||||
// TranslationProvider t(key) echoes the key, so assertions target the keys.
|
||||
const addToast = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
addToast.mockClear();
|
||||
window.__addToast = addToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function button(errorCode: GeoWatchErrorCode | null, error: string | null = errorCode && 'raw webkit text') {
|
||||
return <LocationButton mode="off" error={error} errorCode={errorCode} onClick={noop} />;
|
||||
}
|
||||
|
||||
describe('LocationButton', () => {
|
||||
it('FE-MAP-LOCBTN-001: shows the mode title and stays quiet without an error', () => {
|
||||
const { getByRole } = render(button(null));
|
||||
|
||||
expect(getByRole('button').getAttribute('title')).toBe('Show my location');
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-002: titles with the localized error text, not the raw browser string', () => {
|
||||
const { getByRole } = render(button('timeout'));
|
||||
|
||||
expect(getByRole('button').getAttribute('title')).toBe('map.location.timeout');
|
||||
expect(getByRole('button').getAttribute('aria-label')).toBe('map.location.timeout');
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-003: fires exactly one error toast when a code appears', () => {
|
||||
const { rerender } = render(button(null));
|
||||
expect(addToast).not.toHaveBeenCalled();
|
||||
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast).toHaveBeenCalledWith('map.location.denied', 'error', 6000);
|
||||
|
||||
// The same error sticking around must not re-announce itself.
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-004: only the transition from no error to an error toasts', () => {
|
||||
const { rerender } = render(button(null));
|
||||
rerender(button('permission-denied'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Code to code without recovering in between stays silent.
|
||||
rerender(button('timeout'));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
// After a recovery a fresh failure announces itself again.
|
||||
rerender(button(null));
|
||||
rerender(button('timeout'));
|
||||
expect(addToast).toHaveBeenCalledTimes(2);
|
||||
expect(addToast).toHaveBeenLastCalledWith('map.location.timeout', 'error', 6000);
|
||||
});
|
||||
|
||||
it('FE-MAP-LOCBTN-005: an insecure origin gets the HTTPS hint, not the permission one', () => {
|
||||
const { getByRole } = render(button('insecure-context', 'Only secure origins are allowed'));
|
||||
|
||||
expect(addToast).toHaveBeenCalledWith('journey.editor.locationInsecureContext', 'error', 6000);
|
||||
expect(getByRole('button').getAttribute('title')).toBe('journey.editor.locationInsecureContext');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,58 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode } from '../../hooks/useGeolocation'
|
||||
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface Props {
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
errorCode: GeoWatchErrorCode | null
|
||||
onClick: () => void
|
||||
// Offset from the bottom edge — callers push this up above the mobile
|
||||
// bottom nav. Defaults to 20px for desktop.
|
||||
bottomOffset?: number
|
||||
}
|
||||
|
||||
// The raw error string from the browser is unlocalized WebKit English, so the
|
||||
// user-facing message is looked up from the typed code instead. 'unsupported'
|
||||
// shares the generic text: a browser without geolocation cannot locate you.
|
||||
// 'insecure-context' borrows the journal editor's wording, which already says
|
||||
// exactly this in all locales.
|
||||
const ERROR_KEYS: Record<GeoWatchErrorCode, string> = {
|
||||
'permission-denied': 'map.location.denied',
|
||||
'unavailable': 'map.location.unavailable',
|
||||
'timeout': 'map.location.timeout',
|
||||
'unsupported': 'map.location.unavailable',
|
||||
'insecure-context': 'journey.editor.locationInsecureContext',
|
||||
}
|
||||
|
||||
// Three-state FAB. Matches the Apple/Google Maps pattern:
|
||||
// off → outline locate icon
|
||||
// show → filled locate (blue dot is visible on the map)
|
||||
// follow → filled navigation arrow (map follows + rotates with heading)
|
||||
export default function LocationButton({ mode, error, onClick, bottomOffset = 20 }: Props) {
|
||||
export default function LocationButton({ mode, error, errorCode, onClick, bottomOffset = 20 }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
// A title tooltip is invisible on touch, which is exactly where geolocation
|
||||
// errors happen (iOS PWA, see discussion #2095), so surface new errors as a
|
||||
// toast. The ref guards against re-fires: only the transition from no error
|
||||
// to an error announces itself.
|
||||
const prevCodeRef = useRef<GeoWatchErrorCode | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = prevCodeRef.current
|
||||
prevCodeRef.current = errorCode
|
||||
if (errorCode && prev === null) {
|
||||
// The denied message is long, so give the toast extra time to be read.
|
||||
toast.error(t(ERROR_KEYS[errorCode]), 6000)
|
||||
}
|
||||
}, [errorCode, t, toast])
|
||||
|
||||
const Icon = mode === 'follow' ? Navigation : mode === 'show' ? LocateFixed : Locate
|
||||
const isActive = mode !== 'off'
|
||||
const title = error
|
||||
? error
|
||||
const errorText = errorCode ? t(ERROR_KEYS[errorCode]) : error
|
||||
const title = errorText
|
||||
? errorText
|
||||
: mode === 'off'
|
||||
? 'Show my location'
|
||||
: mode === 'show'
|
||||
|
||||
@@ -36,6 +36,7 @@ const geoMock = vi.hoisted(() => ({
|
||||
position: null as { lat: number; lng: number; accuracy: number; heading: number | null } | null,
|
||||
mode: 'off' as 'off' | 'show' | 'follow',
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(),
|
||||
setMode: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -802,7 +802,7 @@ export const MapView = memo(function MapView({
|
||||
const TooltipOverlay = !hoverDisabled && hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode } = useGeolocation()
|
||||
// Desktop browsers only get IP-based geolocation (city-level accuracy),
|
||||
// so the button would be misleading. Mobile, where real GPS lives, keeps it.
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -941,6 +941,7 @@ export const MapView = memo(function MapView({
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
errorCode={trackingErrorCode}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={locationButtonBottom as unknown as number}
|
||||
/>}
|
||||
|
||||
@@ -195,6 +195,7 @@ const geoStub = vi.hoisted(() => ({
|
||||
position: null as GeoPosition | null,
|
||||
mode: 'off' as TrackingMode,
|
||||
error: null as string | null,
|
||||
errorCode: null as string | null,
|
||||
cycleMode: vi.fn(async () => {}),
|
||||
setMode: vi.fn((_m: TrackingMode | ((prev: TrackingMode) => TrackingMode)) => {}),
|
||||
}))
|
||||
|
||||
@@ -509,7 +509,7 @@ export function MapViewGL({
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
onMapReadyRef.current = onMapReady
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, errorCode: trackingErrorCode, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
onClickRefs.current.map = onMapClick
|
||||
@@ -1548,6 +1548,7 @@ export function MapViewGL({
|
||||
<LocationButton
|
||||
mode={trackingMode}
|
||||
error={trackingError}
|
||||
errorCode={trackingErrorCode}
|
||||
onClick={cycleTrackingMode}
|
||||
bottomOffset={buttonBottom as unknown as number}
|
||||
/>
|
||||
|
||||
@@ -223,7 +223,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
const allCostEntries: MoneyEntry[] = Object.values(assignments)
|
||||
.flatMap(a => a)
|
||||
.map(a => ({ amount: Number(a.place?.price) || 0, currency: a.place?.currency || tripCur }))
|
||||
const needsFx = allCostEntries.some(e => e.amount > 0 && e.currency.toUpperCase() !== tripCur)
|
||||
const needsFx = allCostEntries.some(e => e.amount !== 0 && e.currency.toUpperCase() !== tripCur)
|
||||
const fxRates = needsFx ? await fetchExchangeRates(tripCur) : null
|
||||
const totalCostLabel = formatMoneySum(allCostEntries, tripCur, loc || 'en', fxRates)
|
||||
|
||||
@@ -442,6 +442,7 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||
${item.data.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(item.data.notes)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}).join('')
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { X, Plus } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
|
||||
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { BagCard } from './PackingListPanelBagCard'
|
||||
|
||||
export function BagModal(S: PackingState) {
|
||||
const {
|
||||
setShowBagModal, t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
|
||||
} = S
|
||||
// These numbers describe what you are carrying. An item someone shared with you stays
|
||||
// in your list, but they are the one bringing it, so it is not your weight (#1767).
|
||||
// The ITEM LISTS still describe what you are carrying — an item someone shared
|
||||
// with you stays in your list, but they are the one bringing it (#1767).
|
||||
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
|
||||
// The WEIGHTS no longer do. A bag's load is the bag's, whoever packed it and
|
||||
// whether or not you may see the items, so it comes from the server (#2191).
|
||||
const bagWeightOf = (bag: typeof bags[number]) =>
|
||||
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
|
||||
// Reference for bags without a limit of their own — computed once instead of per bag.
|
||||
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(0,0,0,0.3)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: 20, paddingTop: 140, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflowY: 'auto' }}
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
@@ -30,7 +34,7 @@ export function BagModal(S: PackingState) {
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = myItems.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const totalWeight = bagWeightOf(bag)
|
||||
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} />
|
||||
@@ -40,8 +44,11 @@ export function BagModal(S: PackingState) {
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = myItems.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
|
||||
// Shown whenever there is weight to account for, even with no visible
|
||||
// items: the grand total counts it, and a total nothing adds up to is
|
||||
// the confusion this issue was about (#2191).
|
||||
if (unassigned.length === 0 && unassignedWeight === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 16, opacity: 0.6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
@@ -60,7 +67,13 @@ export function BagModal(S: PackingState) {
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => {
|
||||
// Same rule as the rows above it: a grand total mixing true bag
|
||||
// weights with a per-viewer remainder would be worse than either.
|
||||
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
|
||||
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
|
||||
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
|
||||
})()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
import { bagFillPct, countsTowardsMyLoad, itemWeight } from './packingListPanel.helpers'
|
||||
import { bagFillPct, bagTotalWeight, countsTowardsMyLoad, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { BagCard } from './PackingListPanelBagCard'
|
||||
|
||||
export function BagSidebar(S: PackingState) {
|
||||
const {
|
||||
t, bags, items, tripId, tripMembers, canEdit, currentUserId, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag,
|
||||
showAddBag, setShowAddBag, newBagName, setNewBagName, handleCreateBag, unassignedWeightGrams, serverWeightsFresh,
|
||||
} = S
|
||||
// These numbers describe what you are carrying. An item someone shared with you stays
|
||||
// in your list, but they are the one bringing it, so it is not your weight (#1767).
|
||||
// The ITEM LISTS still describe what you are carrying — an item someone shared
|
||||
// with you stays in your list, but they are the one bringing it (#1767).
|
||||
const myItems = items.filter(i => countsTowardsMyLoad(i, currentUserId))
|
||||
// The WEIGHTS no longer do: a bag's load is the bag's, whoever packed it (#2191).
|
||||
const bagWeightOf = (bag: typeof bags[number]) =>
|
||||
bagTotalWeight(bag, myItems.filter(i => i.bag_id === bag.id), serverWeightsFresh)
|
||||
// Reference for bags without a limit of their own — computed once instead of per bag.
|
||||
const heaviestBagWeight = Math.max(...bags.map(b => myItems.filter(i => i.bag_id === b.id).reduce((s, i) => s + itemWeight(i), 0)), 1)
|
||||
const heaviestBagWeight = Math.max(...bags.map(bagWeightOf), 1)
|
||||
return (
|
||||
<div className="hidden xl:block" style={{ width: 260, marginLeft: 16, borderLeft: '1px solid var(--border-secondary)', overflowY: 'auto', padding: 16, flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-faint)', marginBottom: 12 }}>
|
||||
@@ -21,7 +24,7 @@ export function BagSidebar(S: PackingState) {
|
||||
|
||||
{bags.map(bag => {
|
||||
const bagItems = myItems.filter(i => i.bag_id === bag.id)
|
||||
const totalWeight = bagItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
const totalWeight = bagWeightOf(bag)
|
||||
const pct = bagFillPct(totalWeight, bag.weight_limit_grams, heaviestBagWeight)
|
||||
return (
|
||||
<BagCard key={bag.id} bag={bag} bagItems={bagItems} totalWeight={totalWeight} pct={pct} tripId={tripId} tripMembers={tripMembers} canEdit={canEdit} onDelete={() => handleDeleteBag(bag.id)} onUpdate={handleUpdateBag} onSetMembers={handleSetBagMembers} t={t} compact />
|
||||
@@ -31,8 +34,10 @@ export function BagSidebar(S: PackingState) {
|
||||
{/* Unassigned */}
|
||||
{(() => {
|
||||
const unassigned = myItems.filter(i => !i.bag_id)
|
||||
const unassignedWeight = unassigned.reduce((s, i) => s + itemWeight(i), 0)
|
||||
if (unassigned.length === 0) return null
|
||||
const unassignedWeight = unassignedTotalWeight(unassignedWeightGrams, unassigned, serverWeightsFresh)
|
||||
// Shown whenever there is weight to account for, even with no visible
|
||||
// items — the grand total counts it either way (#2191).
|
||||
if (unassigned.length === 0 && unassignedWeight === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 14, opacity: 0.6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
@@ -51,7 +56,12 @@ export function BagSidebar(S: PackingState) {
|
||||
<div style={{ borderTop: '1px solid var(--border-secondary)', paddingTop: 10, marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
<span>{t('packing.totalWeight')}</span>
|
||||
<span>{(() => { const w = myItems.reduce((s, i) => s + itemWeight(i), 0); return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g` })()}</span>
|
||||
<span>{(() => {
|
||||
// Same rule as the rows above it (#2191).
|
||||
const w = bags.reduce((s, b) => s + bagWeightOf(b), 0)
|
||||
+ unassignedTotalWeight(unassignedWeightGrams, myItems.filter(i => !i.bag_id), serverWeightsFresh)
|
||||
return w >= 1000 ? `${(w / 1000).toFixed(1)} kg` : `${w} g`
|
||||
})()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-COMP-BAGWEIGHT-001 to FE-COMP-BAGWEIGHT-006
|
||||
|
||||
/**
|
||||
* What the bag panels actually print (#2191).
|
||||
*
|
||||
* The numbers used to be summed in the browser over the privacy-filtered item
|
||||
* list, so a bag shared with someone whose personal items you cannot see read
|
||||
* lighter than it was — and that figure is what gets measured against an
|
||||
* airline's weight limit. The server now sends the real total; these tests pin
|
||||
* that the panels show it, that the "no bag" pile and the grand total follow
|
||||
* the same rule, and that offline they fall back to the honest partial sum
|
||||
* rather than a frozen absolute one.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within } from '../../../tests/helpers/render'
|
||||
import { BagSidebar } from './PackingListPanelBagSidebar'
|
||||
import { BagModal } from './PackingListPanelBagModal'
|
||||
import type { PackingState } from './usePackingListPanel'
|
||||
|
||||
/** My own 300 g item; the buddy's 700 g personal item never reaches this client. */
|
||||
const ITEMS = [
|
||||
{ id: 1, name: 'Stove', weight_grams: 300, quantity: 1, bag_id: 10, is_private: 0, owner_id: 1, checked: 0, category: 'Kitchen' },
|
||||
{ id: 2, name: 'Sandwich', weight_grams: 150, quantity: 1, bag_id: null, is_private: 0, owner_id: 1, checked: 0, category: 'Food' },
|
||||
]
|
||||
|
||||
function buildState(over: Partial<PackingState> = {}): PackingState {
|
||||
return {
|
||||
t: (k: string) => k,
|
||||
bags: [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [], total_weight_grams: 1000 }],
|
||||
items: ITEMS,
|
||||
tripId: 1,
|
||||
tripMembers: [],
|
||||
canEdit: false,
|
||||
currentUserId: 1,
|
||||
unassignedWeightGrams: 500,
|
||||
serverWeightsFresh: true,
|
||||
handleDeleteBag: vi.fn(),
|
||||
handleUpdateBag: vi.fn(),
|
||||
handleSetBagMembers: vi.fn(),
|
||||
handleCreateBag: vi.fn(),
|
||||
setShowBagModal: vi.fn(),
|
||||
showAddBag: false,
|
||||
setShowAddBag: vi.fn(),
|
||||
newBagName: '',
|
||||
setNewBagName: vi.fn(),
|
||||
...over,
|
||||
} as unknown as PackingState
|
||||
}
|
||||
|
||||
describe('bag weights on the packing panels (#2191)', () => {
|
||||
it('FE-COMP-BAGWEIGHT-001: the sidebar shows the server total, not the sum of what you can see', () => {
|
||||
// Locally visible: 300 g. Actually in the bag: 1000 g.
|
||||
render(<BagSidebar {...buildState()} />)
|
||||
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
|
||||
expect(screen.queryByText('300 g')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-002: the grand total adds the bags and the unassigned pile', () => {
|
||||
// 1000 g in the bag + 500 g loose.
|
||||
const { container } = render(<BagSidebar {...buildState()} />)
|
||||
const total = within(container).getByText('packing.totalWeight').parentElement as HTMLElement
|
||||
expect(total).toHaveTextContent('1.5 kg')
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-003: the modal follows the same rule as the sidebar', () => {
|
||||
render(<BagModal {...buildState()} />)
|
||||
expect(screen.getByText('1.0 kg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-004: offline it falls back to summing what is visible', () => {
|
||||
// The server totals are frozen at the last online read and blind to the
|
||||
// mutation queue, so an honest partial number beats a stale absolute one.
|
||||
render(<BagSidebar {...buildState({ serverWeightsFresh: false })} />)
|
||||
expect(screen.getByText('300 g')).toBeInTheDocument()
|
||||
expect(screen.queryByText('1.0 kg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-005: a bag from before the field falls back to the local sum', () => {
|
||||
const bags = [{ id: 10, trip_id: 1, name: 'Duffel', color: '#6366f1', sort_order: 0, weight_limit_grams: null, members: [] }]
|
||||
render(<BagSidebar {...buildState({ bags: bags as never, unassignedWeightGrams: null })} />)
|
||||
expect(screen.getByText('300 g')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGWEIGHT-006: the unassigned row appears for weight with no visible items', () => {
|
||||
// Someone else's personal item is loose in the trip: the grand total counts
|
||||
// it, so a row has to account for it or the total adds up to nothing.
|
||||
render(<BagSidebar {...buildState({ items: [ITEMS[0]] as never, unassignedWeightGrams: 400 })} />)
|
||||
expect(screen.getByText('packing.noBag')).toBeInTheDocument()
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { katColor, itemWeight, bagFillPct, countsTowardsMyLoad, parseCsvLine, parseImportLines } from './packingListPanel.helpers'
|
||||
import { katColor, itemWeight, bagFillPct, bagTotalWeight, countsTowardsMyLoad, parseCsvLine, parseImportLines, unassignedTotalWeight } from './packingListPanel.helpers'
|
||||
import { KAT_COLORS } from './packingListPanel.constants'
|
||||
|
||||
describe('packingListPanel.helpers', () => {
|
||||
@@ -128,4 +128,29 @@ describe('packingListPanel.helpers', () => {
|
||||
expect(rows[0].name).toBe('Passport')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bagTotalWeight / unassignedTotalWeight (#2191)', () => {
|
||||
it('prefers the server total over anything summable locally', () => {
|
||||
// The whole point: the local list is privacy-filtered, so it can only ever
|
||||
// be the part of the bag this viewer is allowed to see.
|
||||
expect(bagTotalWeight({ total_weight_grams: 1000 }, [{ weight_grams: 800, quantity: 1 }])).toBe(1000)
|
||||
})
|
||||
|
||||
it('keeps a server-reported zero instead of falling back to the local sum', () => {
|
||||
// An empty bag really weighs 0; only an ABSENT field means "not told".
|
||||
expect(bagTotalWeight({ total_weight_grams: 0 }, [{ weight_grams: 800, quantity: 1 }])).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to the local sum for a bag cached before the field existed', () => {
|
||||
expect(bagTotalWeight({}, [{ weight_grams: 250, quantity: 3 }, { weight_grams: 50 }])).toBe(800)
|
||||
expect(bagTotalWeight({ total_weight_grams: null }, [{ weight_grams: 120 }])).toBe(120)
|
||||
})
|
||||
|
||||
it('applies the same rule to the unassigned pile', () => {
|
||||
expect(unassignedTotalWeight(150, [{ weight_grams: 900 }])).toBe(150)
|
||||
expect(unassignedTotalWeight(0, [{ weight_grams: 900 }])).toBe(0)
|
||||
expect(unassignedTotalWeight(null, [{ weight_grams: 900 }])).toBe(900)
|
||||
expect(unassignedTotalWeight(undefined, [])).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +30,43 @@ export const countsTowardsMyLoad = (
|
||||
return i.owner_id == null || i.owner_id === currentUserId
|
||||
}
|
||||
|
||||
/**
|
||||
* What a bag weighs, as shown (#2191).
|
||||
*
|
||||
* The server sums every member's items — including the private ones this viewer
|
||||
* may not see — and sends the figure on the bag itself. Adding it up locally
|
||||
* could only ever produce the part of the bag the viewer is allowed to look at,
|
||||
* which is the wrong number to measure against an airline's weight limit.
|
||||
*
|
||||
* The local sum is the fallback in two cases: a bag that reached the client
|
||||
* without the field (a row cached before #2191), and `serverFresh === false`,
|
||||
* which the surfaces pass while offline — bags have no repo and no Dexie table,
|
||||
* so their totals are frozen at the last online read and blind to whatever the
|
||||
* mutation queue is holding. A stale absolute number measured against an
|
||||
* airline limit is worse than an honest partial one.
|
||||
*
|
||||
* Note the explicit null check rather than `??` on a falsy value: an empty bag
|
||||
* legitimately weighs 0 and must not fall through to the local sum.
|
||||
*/
|
||||
export const bagTotalWeight = (
|
||||
bag: { total_weight_grams?: number | null },
|
||||
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
|
||||
serverFresh = true,
|
||||
): number =>
|
||||
serverFresh && bag.total_weight_grams != null
|
||||
? bag.total_weight_grams
|
||||
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
|
||||
/** The same rule for the pile that is in no bag (#2191). */
|
||||
export const unassignedTotalWeight = (
|
||||
serverTotal: number | null | undefined,
|
||||
visibleItems: { weight_grams?: number | null; quantity?: number | null }[],
|
||||
serverFresh = true,
|
||||
): number =>
|
||||
serverFresh && serverTotal != null
|
||||
? serverTotal
|
||||
: visibleItems.reduce((sum, i) => sum + itemWeight(i), 0)
|
||||
|
||||
/**
|
||||
* How full a bag's bar reads. A bag with a weight limit is measured against that limit —
|
||||
* that is the number an airline cares about. Without one there is nothing absolute to
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// FE-COMP-BAGPING-001 to FE-COMP-BAGPING-009
|
||||
|
||||
/**
|
||||
* The bag-totals ping listener (#2191).
|
||||
*
|
||||
* Bag weights are summed server-side across every member, so the event that
|
||||
* says they moved cannot carry the new numbers without leaking what produced
|
||||
* them. That makes every ping a refetch, and the server deliberately sends it
|
||||
* to the whole room including the writer — so the coalescing and the in-flight
|
||||
* guard here are what stop a busy trip turning one write into a storm of
|
||||
* listBags requests on every connected client.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import { setForcedOffline, _resetNetworkMode } from '../../sync/networkMode'
|
||||
|
||||
const listeners = new Set<(e: Record<string, unknown>) => void>()
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
|
||||
removeListener: (fn: (e: Record<string, unknown>) => void) => { listeners.delete(fn) },
|
||||
}))
|
||||
|
||||
/** Deliver an event the way the websocket layer would. */
|
||||
function emit(type: string): void {
|
||||
for (const fn of [...listeners]) fn({ type })
|
||||
}
|
||||
|
||||
/** Past the 250ms coalescing window. */
|
||||
async function flushCoalesce(): Promise<void> {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(300) })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
listeners.clear()
|
||||
_resetNetworkMode()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setForcedOffline(false)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useBagTotalsPing', () => {
|
||||
it('FE-COMP-BAGPING-001: reloads once a ping arrives', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-002: folds a burst of pings into a single refetch', async () => {
|
||||
// Clearing twelve checked items issues twelve deletes; an import writes a
|
||||
// whole list. Each one pings the room.
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => { for (let i = 0; i < 12; i++) emit('packing:bag-totals') })
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-003: never overlaps two refetches, and serves a ping that arrived mid-flight', async () => {
|
||||
let release: (() => void) | undefined
|
||||
const reload = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A second burst while the first request is still open must not start a
|
||||
// parallel one...
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// ...but it must not be dropped either: the totals moved again.
|
||||
await act(async () => { release?.(); await Promise.resolve() })
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-004: ignores every other event type', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => { emit('packing:created'); emit('packing:updated'); emit('place:created') })
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-005: subscribes to nothing while bag tracking is off', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(false, reload))
|
||||
|
||||
expect(listeners.size).toBe(0)
|
||||
act(() => emit('packing:bag-totals'))
|
||||
await flushCoalesce()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-006: unsubscribes on unmount and does not reload afterwards', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
|
||||
expect(listeners.size).toBe(1)
|
||||
|
||||
// A ping already collecting when the panel closes must not land on a
|
||||
// component that is gone.
|
||||
act(() => emit('packing:bag-totals'))
|
||||
unmount()
|
||||
await flushCoalesce()
|
||||
|
||||
expect(listeners.size).toBe(0)
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-007: reloads when the socket re-joins the room', async () => {
|
||||
// A ping sent while the socket was down is gone for good (a container
|
||||
// restart, a tunnel that dropped), and bags have no offline cache to catch
|
||||
// up from. The re-join is the only notice this client gets.
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => emit('joined'))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-008: reloads on the way back online, not on the way out', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => setForcedOffline(true))
|
||||
await flushCoalesce()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
// Going online is where the surfaces stop summing locally and show the
|
||||
// server totals again, so those totals had better not be the pre-outage ones.
|
||||
act(() => setForcedOffline(false))
|
||||
await flushCoalesce()
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-BAGPING-009: stops watching the network mode after unmount', async () => {
|
||||
const reload = vi.fn(async () => {})
|
||||
const { unmount } = renderHook(() => useBagTotalsPing(true, reload))
|
||||
|
||||
act(() => setForcedOffline(true))
|
||||
unmount()
|
||||
act(() => setForcedOffline(false))
|
||||
await flushCoalesce()
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { isEffectivelyOnline, onNetworkModeChange } from '../../sync/networkMode'
|
||||
|
||||
/**
|
||||
* How long pings are collected before one refetch goes out.
|
||||
*
|
||||
* Long enough to fold a burst into a single request — clearing twelve checked
|
||||
* items issues twelve deletes, and an import writes a whole list — short enough
|
||||
* that a single edit still reads as instant.
|
||||
*/
|
||||
const COALESCE_MS = 250
|
||||
|
||||
/**
|
||||
* Re-read the bag weights when the server says they moved (#2191).
|
||||
*
|
||||
* The ping is content-free by design: totals are summed across every member,
|
||||
* including people whose items this client may not see, so the event cannot
|
||||
* carry the new numbers without leaking what produced them. That makes every
|
||||
* ping a refetch, which is why this coalesces bursts and never lets two
|
||||
* requests overlap — the server pings the whole room, the originating socket
|
||||
* included, so a busy trip would otherwise have every client refetching once
|
||||
* per written item.
|
||||
*
|
||||
* A ping sent while this client is disconnected is gone for good, and bags have
|
||||
* no offline cache to sync back, so the two moments where that can have happened
|
||||
* count as pings of their own: the room re-join after a reconnect, and the
|
||||
* return to online.
|
||||
*
|
||||
* `reload` may change identity every render; the latest one is always used.
|
||||
*/
|
||||
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
|
||||
const reloadRef = useRef(reload)
|
||||
reloadRef.current = reload
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let inFlight = false
|
||||
/** A ping that arrived while a refetch was running: serve it once that lands. */
|
||||
let pendingAfterFlight = false
|
||||
let cancelled = false
|
||||
|
||||
const run = (): void => {
|
||||
if (cancelled) return
|
||||
if (inFlight) {
|
||||
pendingAfterFlight = true
|
||||
return
|
||||
}
|
||||
inFlight = true
|
||||
void reloadRef.current().finally(() => {
|
||||
inFlight = false
|
||||
if (pendingAfterFlight && !cancelled) {
|
||||
pendingAfterFlight = false
|
||||
run()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const schedule = (): void => {
|
||||
if (timer) return // already collecting this burst
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
run()
|
||||
}, COALESCE_MS)
|
||||
}
|
||||
|
||||
const handler = (event: Record<string, unknown>) => {
|
||||
// 'joined' is the room re-join the socket sends on every reconnect, so it
|
||||
// is also the moment the totals on screen are the ones from before the
|
||||
// drop: whatever moved them in the meantime pinged into a closed socket.
|
||||
if (event.type !== 'packing:bag-totals' && event.type !== 'joined') return
|
||||
schedule()
|
||||
}
|
||||
|
||||
let wasOnline = isEffectivelyOnline()
|
||||
const onMode = (): void => {
|
||||
const online = isEffectivelyOnline()
|
||||
// Only the way back matters: that is when the surfaces stop summing what
|
||||
// this client can see and go back to trusting the server numbers.
|
||||
if (online && !wasOnline) schedule()
|
||||
wasOnline = online
|
||||
}
|
||||
|
||||
addListener(handler)
|
||||
const unsubscribeMode = onNetworkModeChange(onMode)
|
||||
return () => {
|
||||
cancelled = true
|
||||
removeListener(handler)
|
||||
unsubscribeMode()
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [enabled])
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from 'react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { packingApi, tripsApi } from '../../api/client'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
import { useBagTotalsPing } from './useBagTotalsPing'
|
||||
import type { PackingItem, PackingBag } from '../../types'
|
||||
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
|
||||
import { parseImportLines } from './packingListPanel.helpers'
|
||||
@@ -210,6 +212,8 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
||||
const [bags, setBags] = useState<PackingBag[]>([])
|
||||
/** Server-summed weight of everything in no bag (#2191); null until the first load. */
|
||||
const [unassignedWeightGrams, setUnassignedWeightGrams] = useState<number | null>(null)
|
||||
const [newBagName, setNewBagName] = useState('')
|
||||
const [showAddBag, setShowAddBag] = useState(false)
|
||||
const [showBagModal, setShowBagModal] = useState(false)
|
||||
@@ -218,10 +222,35 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
if (!addonsLoaded) loadAddons()
|
||||
}, [addonsLoaded, loadAddons])
|
||||
|
||||
useEffect(() => {
|
||||
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||
const reloadBags = useCallback(async () => {
|
||||
if (!bagTrackingEnabled) return
|
||||
try {
|
||||
const r = await packingApi.listBags(tripId)
|
||||
setBags(r.bags || [])
|
||||
setUnassignedWeightGrams(r.unassigned_weight_grams ?? null)
|
||||
} catch {
|
||||
// Offline or a failed read: the surfaces fall back to the local sum
|
||||
// (see `serverWeightsFresh` below), so there is nothing to roll back.
|
||||
}
|
||||
}, [tripId, bagTrackingEnabled])
|
||||
|
||||
useEffect(() => { void reloadBags() }, [reloadBags])
|
||||
|
||||
// Bag weights are summed server-side across every member (#2191), so an item
|
||||
// this viewer may not even see still moves them. The item events cannot carry
|
||||
// that — a private item is delivered only to its owner, which is the very rule
|
||||
// that made the totals wrong — so the server pings the room content-free and
|
||||
// we re-read the numbers.
|
||||
useBagTotalsPing(bagTrackingEnabled, reloadBags)
|
||||
|
||||
// Bags are not part of the offline cache (no repo, no Dexie table), so while
|
||||
// offline the server totals are frozen at the last online read and cannot see
|
||||
// the optimistic item writes the mutation queue is holding. A stale absolute
|
||||
// number measured against an airline limit is worse than an honest partial
|
||||
// one, so offline the surfaces sum what they can see instead (#2191).
|
||||
const { offline } = useNetworkMode()
|
||||
const serverWeightsFresh = !offline
|
||||
|
||||
const handleCreateBag = async () => {
|
||||
if (!newBagName.trim()) return
|
||||
try {
|
||||
@@ -373,7 +402,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
|
||||
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
bagTrackingEnabled, bags, unassignedWeightGrams, serverWeightsFresh, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
|
||||
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
|
||||
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
|
||||
showSaveTemplate, setShowSaveTemplate, saveTemplateName, setSaveTemplateName,
|
||||
|
||||
@@ -56,9 +56,11 @@ interface DayDetailPanelProps {
|
||||
mobile?: boolean
|
||||
/** Rename the day from here — the sidebar pencil moved to the transit search (#1065). */
|
||||
onUpdateDayTitle?: (dayId: number, title: string) => void
|
||||
/** Name of the place the day's weather is anchored to — captioned above the forecast (#2167). */
|
||||
weatherPlaceName?: string | null
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle }: DayDetailPanelProps) {
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse, mobile = false, onUpdateDayTitle, weatherPlaceName = null }: DayDetailPanelProps) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const tripObj = useTripStore((s) => s.trip)
|
||||
@@ -211,6 +213,13 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
</div>
|
||||
) : weather ? (
|
||||
<div>
|
||||
{/* Which place the forecast is for — on a roadtrip "the day's weather"
|
||||
is ambiguous without it (#2167). */}
|
||||
{weatherPlaceName && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||
{t('day.weatherFor', { name: weatherPlaceName })}
|
||||
</div>
|
||||
)}
|
||||
{/* Summary row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -87,7 +87,9 @@ vi.mock('../../hooks/useDayNotes', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../Weather/WeatherWidget', () => ({
|
||||
default: () => <span data-testid="weather-widget" />,
|
||||
default: (props: { locationName?: string | null }) => (
|
||||
<span data-testid="weather-widget" data-location={props.locationName ?? ''} />
|
||||
),
|
||||
}))
|
||||
|
||||
// A stable toast object so tests can assert on the messages the sidebar raises.
|
||||
@@ -405,6 +407,14 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/10:00/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-012b: the day-specific assignment note shows as a caption line in the row (#2163)', () => {
|
||||
const place = buildPlace({ name: 'Louvre Museum' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place, notes: 'Book the 10:00 timed entry' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [assignment] } })} />)
|
||||
expect(screen.getByText('Book the 10:00 timed entry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-013: clicking a place calls onPlaceClick', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 42, name: 'Louvre Museum' })
|
||||
@@ -2888,11 +2898,34 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onReorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130: the weather badge falls back to any located trip place', () => {
|
||||
// #2167 — the badge used to borrow ANY located trip place, so a roadtrip day
|
||||
// without stops silently showed another city's weather. Day-local only now.
|
||||
it('FE-PLANNER-DAYPLAN-130: no weather badge for a day without located stops or a bookend hotel (#2167)', () => {
|
||||
const located = buildPlace({ id: 5, name: 'Somewhere', lat: 48.85, lng: 2.35 })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [located] })} />)
|
||||
expect(screen.getByTestId('weather-widget')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('weather-widget')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130b: the weather badge anchors to the day bookend hotel, independent of the optimize setting (#2167)', () => {
|
||||
// The hotel fallback is unconditional (matching the mobile timeline) — turn the
|
||||
// route-optimization setting OFF to prove the weather anchor ignores it.
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', temperature_unit: 'celsius', optimize_from_accommodation: false } } as any)
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
|
||||
const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_name: 'Hotel Roma', place_lat: 41.9, place_lng: 12.5 }]
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day, day2], accommodations: accommodations as any })} />)
|
||||
const widgets = screen.getAllByTestId('weather-widget')
|
||||
expect(widgets.length).toBeGreaterThan(0)
|
||||
expect(widgets[0]).toHaveAttribute('data-location', 'Hotel Roma')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-130c: the weather badge names the day-local stop it is anchored to (#2167)', () => {
|
||||
const place = buildPlace({ id: 5, name: 'Louvre', lat: 48.86, lng: 2.34 })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const a = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], places: [place], assignments: { '10': [a] } })} />)
|
||||
expect(screen.getByTestId('weather-widget')).toHaveAttribute('data-location', 'Louvre')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-131: a read-only trip drops the edit affordances from day and note rows', () => {
|
||||
@@ -3496,6 +3529,34 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-163b: every booking on the stop gets its own chip line (#2201)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Zoo' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const a = buildAssignment({ id: 11, day_id: 10, order_index: 0, place })
|
||||
const parking = buildReservation({
|
||||
id: 530, type: 'parking', title: 'Parking pass', status: 'confirmed', assignment_id: 11,
|
||||
reservation_time: '2025-06-01T09:00:00', reservation_end_time: '2025-06-01T09:30:00',
|
||||
} as any)
|
||||
const tickets = buildReservation({
|
||||
id: 531, type: 'activity', title: 'Zoo tickets', status: 'pending', assignment_id: 11,
|
||||
reservation_time: '2025-06-01T10:15:00',
|
||||
} as any)
|
||||
const onEditReservation = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
// Newest first, the order the store hands them over after a local create.
|
||||
days: [day], places: [place], assignments: { '10': [a] }, reservations: [tickets, parking],
|
||||
onEditReservation,
|
||||
})} />)
|
||||
expect(screen.getByText('09:00 – 09:30')).toBeInTheDocument()
|
||||
expect(screen.getByText('10:15')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
|
||||
// Earliest first, so the first pencil belongs to the parking pass.
|
||||
await user.click(screen.getAllByTitle('Edit')[0])
|
||||
expect(onEditReservation).toHaveBeenCalledWith(parking)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-164: travellers on a place row are shown as avatars with an overflow count', () => {
|
||||
const place = buildPlace({ id: 1, name: 'Group visit' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
|
||||
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, TramFront, Zap } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Compass, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon, Bookmark, StickyNote, TramFront, Zap } from 'lucide-react'
|
||||
import { type PickedPlace } from './TransitSearchPanel'
|
||||
import { assignmentsApi, reservationsApi, daysApi } from '../../api/client'
|
||||
import { calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl, type NamedWaypoint } from '../Map/RouteCalculator'
|
||||
@@ -30,7 +30,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
|
||||
getAssignmentReservations,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
@@ -592,8 +593,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const reachable = (h: { place_lat?: number | null; place_lng?: number | null } | undefined, w: typeof firstWay) =>
|
||||
!h || !w || w.isPlace || h.place_lat == null || h.place_lng == null
|
||||
|| withinDriveRange({ lat: h.place_lat, lng: h.place_lng }, w)
|
||||
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay)) && reachable(startHotel, firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay)) && reachable(endHotel, lastWay)
|
||||
// Same carrier evidence the map route uses (#2157): with a located carrier
|
||||
// endpoint on the day, the no-time default must not open a hotel leg.
|
||||
const dayHasCarrier = wayPts.some(w => w.carrierEdge != null)
|
||||
const wantTop = !!(startHotel && firstWay && bookends && day && shouldDrawMorningLeg(bookends, day, firstWay, dayHasCarrier)) && reachable(startHotel, firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay && bookends && day && shouldDrawEveningLeg(bookends, day, lastWay, dayHasCarrier)) && reachable(endHotel, lastWay)
|
||||
return { runs, startHotel, endHotel, firstWay, lastWay, wantTop, wantBottom }
|
||||
}
|
||||
|
||||
@@ -975,7 +979,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||
const day = days.find(d => d.id === dayId)
|
||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||
? getAccommodationAnchors(day, days, accommodations)
|
||||
? getAccommodationAnchors(
|
||||
day, days, accommodations,
|
||||
unlockedWithCoords.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng! })),
|
||||
(mergedItemsMap[dayId] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, dayId)),
|
||||
)
|
||||
: {}
|
||||
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||
@@ -1040,10 +1048,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
return formatMoneySum(entries, costBase, locale, fxRates)
|
||||
}, [days, assignments, currency, costBase, locale, fxRates])
|
||||
|
||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
||||
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
|
||||
|
||||
return {
|
||||
tripId,
|
||||
trip,
|
||||
@@ -1179,8 +1183,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
expandedRouteDayIds,
|
||||
setExpandedRouteDayIds,
|
||||
}
|
||||
@@ -1352,8 +1354,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleOptimize,
|
||||
handleDropOnDay,
|
||||
totalCostLabel,
|
||||
anyGeoAssignment,
|
||||
anyGeoPlace,
|
||||
expandedRouteDayIds,
|
||||
setExpandedRouteDayIds,
|
||||
} = S
|
||||
@@ -1551,7 +1551,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// routable when accommodation optimization can bookend it with a hotel
|
||||
// (hotel → place → hotel, the same line the map draws) — otherwise the tools
|
||||
// vanish on such a day (#1330). Purely additive to the 2+ case.
|
||||
const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
|
||||
// The weather anchor below must not depend on the optimize-from-accommodation
|
||||
// setting — where you wake up is a fact about the day, not a routing choice —
|
||||
// so the bookend lookup runs unconditionally (mirrors useMPlanTimeline) while
|
||||
// the route tools keep honoring the setting via routeBookends.
|
||||
const dayBookends = getDayBookendHotels(day, days, accommodations)
|
||||
const routeBookends = optimizeFromAccommodation !== false ? dayBookends : null
|
||||
const hasRouteBookend = !!(
|
||||
(routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
|
||||
(routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
|
||||
@@ -1582,11 +1587,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const dayExportStops = (): NamedWaypoint[] => {
|
||||
const dayStops = getDayAssignments(day.id).filter(a => a.place?.lat != null && a.place?.lng != null)
|
||||
const stops = dayStops.map(a => ({ lat: a.place!.lat!, lng: a.place!.lng!, name: a.place!.name }))
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null } : undefined
|
||||
const first = dayStops[0] ? { isPlace: true, time: dayStops[0].place?.place_time ?? null, lat: dayStops[0].place!.lat!, lng: dayStops[0].place!.lng! } : undefined
|
||||
const lastAssignment = dayStops[dayStops.length - 1]
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null } : undefined
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last)
|
||||
const last = lastAssignment ? { isPlace: true, time: lastAssignment.place?.place_time ?? null, lat: lastAssignment.place!.lat!, lng: lastAssignment.place!.lng! } : undefined
|
||||
// Same carrier gate as the drawn route (#2157): the exported link must not
|
||||
// start at a hotel you only reach tonight or lead back to one you left.
|
||||
const dayHasCarrier = (mergedItemsMap[day.id] || []).some(i => i.type === 'transport' && hasCarrierEndpointOnDay(i.data, day.id))
|
||||
const drawMorning = !!routeBookends && shouldDrawMorningLeg(routeBookends, day, first, dayHasCarrier)
|
||||
const drawEvening = !!routeBookends && shouldDrawEveningLeg(routeBookends, day, last, dayHasCarrier)
|
||||
const morning = drawMorning && routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null
|
||||
? { lat: routeBookends.morning.place_lat, lng: routeBookends.morning.place_lng, name: routeBookends.morning.place_name } : null
|
||||
const evening = drawEvening && routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null
|
||||
@@ -1627,6 +1635,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// own, hence the key handler only answers for the row — and it
|
||||
// toggles exactly like the click, rather than only selecting.
|
||||
role="button"
|
||||
// No press-scale on the wide row — it would shift the nested
|
||||
// buttons out from under the pointer mid-click (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => toggleDaySelection()}
|
||||
onKeyDown={e => {
|
||||
@@ -1659,12 +1670,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
>
|
||||
{/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
|
||||
{(() => {
|
||||
// anyGeoPlace is an assignment (has .place) or a bare place — read coords from either.
|
||||
const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined
|
||||
const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined
|
||||
const wLat = loc?.place?.lat ?? geoLat
|
||||
const wLng = loc?.place?.lng ?? geoLng
|
||||
const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
|
||||
// Day-local anchor only (#2167): the day's first located stop, else the
|
||||
// hotel you wake up in. No trip-wide fallback — on a roadtrip that
|
||||
// silently showed another city's weather with nothing naming the place.
|
||||
const weatherHotel = loc == null ? dayBookends.morning : undefined
|
||||
const wLat = loc?.place?.lat ?? weatherHotel?.place_lat
|
||||
const wLng = loc?.place?.lng ?? weatherHotel?.place_lng
|
||||
const weatherName = loc?.place?.name ?? weatherHotel?.place_name ?? null
|
||||
const hasWeather = !!(day.date && wLat != null && wLng != null)
|
||||
return (
|
||||
<div style={{
|
||||
// With weather the badge is a tall stack and has to start at
|
||||
@@ -1691,7 +1704,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<>
|
||||
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||
<div style={{ padding: '3px 0 4px' }}>
|
||||
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||
<WeatherWidget lat={wLat ?? null} lng={wLng ?? null} date={day.date} stacked locationName={weatherName} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1953,8 +1966,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Picking the place out of the day has no other trigger, so
|
||||
// the row is the control. Its grip, lock and arrows are
|
||||
// buttons in their own right, hence the key handler only
|
||||
// answers for the row itself.
|
||||
// answers for the row itself. No press-scale — see the
|
||||
// day header above (#2158).
|
||||
role="button"
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
draggable={canEditDays && !dragDisabled}
|
||||
onDragStart={e => {
|
||||
@@ -2121,87 +2136,107 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
// without swallowing the row.
|
||||
<div title={t('places.assignmentNotes')} style={{ marginTop: 2, display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden' }}>
|
||||
<StickyNote size={9} strokeWidth={2} style={{ flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>{assignment.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
const linked = getAssignmentReservations(reservations, assignment.id)
|
||||
if (linked.length === 0) return null
|
||||
// A stop can carry more than one booking (a parking pass and the
|
||||
// tickets for the same zoo), and every one of them is kept out of
|
||||
// the timeline, so this row is where they have to appear (#2201).
|
||||
// Stacked, because the chip lines are inline-flex and would
|
||||
// otherwise run together on one.
|
||||
return (
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
{linked.map(res => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
// The status, the time and the flight/train number used to sit in one
|
||||
// pill strung together on a middle dot, which read as one run-on
|
||||
// sentence. They are separate chips now: same tint so they still
|
||||
// belong together, own outline so the eye can take them one at a time.
|
||||
const RI = RES_ICONS[res.type] || Ticket
|
||||
const tint = confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'
|
||||
const chip: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 6px', borderRadius: 5,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
const { time: st } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: et } = splitReservationDateTime(res.reservation_end_time)
|
||||
const timeLabel = st || et
|
||||
? `${st ? formatTime(st, locale, timeFormat) : ''}${et ? ` – ${formatTime(et, locale, timeFormat)}` : ''}`
|
||||
: ''
|
||||
let meta: any = {}
|
||||
try { meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) } catch { meta = {} }
|
||||
const carrierLabel = meta
|
||||
? (meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.train_number || '')
|
||||
: ''
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
// No wrapping: the time belongs to the status it qualifies, so the
|
||||
// chips stay on one line even when the row gets narrow.
|
||||
<div key={res.id} style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, flexWrap: 'nowrap' }}>
|
||||
<div className={tint} style={chip}>
|
||||
<RI size={8} />
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
</div>
|
||||
{timeLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{timeLabel}</span>}
|
||||
{carrierLabel && <span className={tint} style={{ ...chip, fontWeight: 500 }}>{carrierLabel}</span>}
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{canEditDays && (() => {
|
||||
const isTransport = TRANSPORT_TYPES.has(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
@@ -2336,7 +2371,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<div
|
||||
// Opening the booking has no other trigger, so the row is
|
||||
// the control; the buttons inside it answer for themselves.
|
||||
// No press-scale — see the day header above (#2158).
|
||||
role="button"
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={openTransportRow}
|
||||
onKeyDown={e => {
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface PlaceFormData {
|
||||
// DEFAULT_FORM on purpose: the mobile sheet shares this type and never sets
|
||||
// it, and places.service already writes image_url through on create/update.
|
||||
image_url?: string
|
||||
// Day-specific note on the in-context assignment (#2163). Only hydrated when
|
||||
// the form opened with an assignment in context; both forms drop it from the
|
||||
// submit payload when unchanged, and useTripPlanner strips it off the place
|
||||
// update and PUTs it per assignment instead.
|
||||
assignment_notes?: string
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -143,6 +143,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// The day-specific note rides only with an assignment in context (#2163);
|
||||
// otherwise the key stays absent so submit never sends a notes write.
|
||||
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
|
||||
})
|
||||
} else if (prefillCoords) {
|
||||
setForm({
|
||||
@@ -471,13 +474,22 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const saved = await onSave({
|
||||
const payload = {
|
||||
...form,
|
||||
lat: form.lat ? Number.parseFloat(form.lat) : null,
|
||||
lng: form.lng ? Number.parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
}
|
||||
// #2163: the per-assignment note only travels when an assignment is in
|
||||
// context AND the value actually changed — an untouched note must not
|
||||
// produce a PUT (a legacy note longer than the textarea would otherwise
|
||||
// be re-sent on every unrelated save).
|
||||
const ctxAssignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
||||
if (!ctxAssignment || (form.assignment_notes ?? '') === (ctxAssignment.notes ?? '')) {
|
||||
delete payload.assignment_notes
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
// Open the Costs editor for the saved place when the user asked to
|
||||
// create/edit its linked expense — gated on an id, so a create that the
|
||||
// server refused never opens an editor pointing at nothing (#1298).
|
||||
@@ -924,6 +936,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Day-specific note — like the times, it lives on the assignment, not
|
||||
the pool place (#2163): only editable with one in context. */}
|
||||
{!!(place && assignmentId) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.assignmentNotes')}</label>
|
||||
<textarea
|
||||
value={form.assignment_notes ?? ''}
|
||||
onChange={e => handleChange('assignment_notes', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('places.assignmentNotesPlaceholder')}
|
||||
className="form-input" style={{ resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formWebsite')}</label>
|
||||
|
||||
@@ -425,6 +425,33 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const parking = buildReservation({
|
||||
id: 530, title: 'Parking pass', status: 'confirmed', type: 'parking', assignment_id: 99,
|
||||
reservation_time: '2025-06-01T09:00:00',
|
||||
} as any);
|
||||
const tickets = buildReservation({
|
||||
id: 531, title: 'Zoo tickets', status: 'pending', type: 'activity', assignment_id: 99,
|
||||
reservation_time: '2025-06-01T10:15:00',
|
||||
} as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[tickets, parking]}
|
||||
onEditReservation={onEditReservation}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Parking pass')).toBeTruthy();
|
||||
expect(screen.getByText('Zoo tickets')).toBeTruthy();
|
||||
fireEvent.click(screen.getByText('Zoo tickets').closest('[role="button"]') as HTMLElement);
|
||||
expect(onEditReservation).toHaveBeenCalledWith(tickets);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030b: the linked reservation opens its editor (#2012)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
@@ -1402,4 +1429,53 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Eiffel Tower')).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Day-specific assignment note (#2163) ─────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-099: the assignment note renders as markdown under its own eyebrow', () => {
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: 'Book the **10:00** timed entry' }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Notes for this day')).toBeTruthy();
|
||||
const strong = Array.from(document.querySelectorAll('strong')).find(el => el.textContent === '10:00');
|
||||
expect(strong).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-100: without a note (or without a day in context) the block stays away', () => {
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
const { rerender } = render(
|
||||
<PlaceInspector {...defaultProps} selectedDayId={1} selectedAssignmentId={99} assignments={{ '1': assignmentInDay }} />
|
||||
);
|
||||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||||
// A note exists but no day is selected — nothing to attribute it to, so no block.
|
||||
rerender(
|
||||
<PlaceInspector {...defaultProps} selectedDayId={null} assignments={{ '1': [{ ...assignmentInDay[0], notes: 'hidden' }] }} />
|
||||
);
|
||||
expect(screen.queryByText('Notes for this day')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-101: the reservation strip opts out of the press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics: the :active scale on the
|
||||
// composite strip slid the links inside out from under the pointer, so the
|
||||
// click landed elsewhere. The data-no-press attribute is the pin.
|
||||
const reservation = buildReservation({ title: 'Museum Ticket', status: 'confirmed', assignment_id: 99 } as any);
|
||||
const assignmentInDay = [{ id: 99, place, day_id: 1, place_id: place.id, order_index: 0, notes: null }];
|
||||
render(
|
||||
<PlaceInspector
|
||||
{...defaultProps}
|
||||
selectedDayId={1}
|
||||
selectedAssignmentId={99}
|
||||
assignments={{ '1': assignmentInDay }}
|
||||
reservations={[reservation]}
|
||||
onEditReservation={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Museum Ticket').closest('[role="button"]')).toHaveAttribute('data-no-press');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -6,12 +6,13 @@ import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route } from 'lucide-react'
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Map as MapIcon, Users, Mountain, TrendingUp, Bookmark, BookmarkCheck, Copy, Route, StickyNote } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import PlaceAvatarUpload from '../shared/PlaceAvatarUpload'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import TrackColorPicker from '../shared/TrackColorPicker'
|
||||
import { resolveTrackColor, inheritedTrackColor } from '../Map/trackColors'
|
||||
import { filesForPlace } from '../../utils/placeFiles'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import StatusBadge from '../Collections/StatusBadge'
|
||||
import { mapsApi, pluginsApi } from '../../api/client'
|
||||
@@ -30,7 +31,7 @@ import { splitReservationDateTime, formatTime, formatMoney } from '../../utils/f
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES } from '../../utils/dayMerge'
|
||||
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
@@ -337,7 +338,8 @@ export default function PlaceInspector({
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
// Its own files plus the ones on the bookings that hang on it (#2217).
|
||||
const placeFiles = filesForPlace(files, place.id, reservations, selectedAssignmentId != null ? [selectedAssignmentId] : [])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -428,6 +430,20 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day-specific assignment note (#2163) — written via MCP/API or the
|
||||
edit form; distinct from the pool-wide place.notes above, so it
|
||||
carries an eyebrow saying which day-scope it belongs to. */}
|
||||
{assignmentInDay?.notes && (
|
||||
<div className="bg-surface-hover" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, padding: '8px 12px' }}>
|
||||
<div className="text-content-faint" style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 3 }}>
|
||||
<StickyNote size={10} /> {t('places.assignmentNotes')}
|
||||
</div>
|
||||
<div className="collab-note-md text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: '1.5', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{assignmentInDay.notes}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation + Participants — trip-only (collections have no days) */}
|
||||
{mode === 'trip' && (
|
||||
<PlaceReservationParticipants selectedAssignmentId={selectedAssignmentId} reservations={reservations}
|
||||
@@ -825,92 +841,101 @@ function PlaceReservationParticipants({ selectedAssignmentId, reservations, assi
|
||||
return (
|
||||
<>
|
||||
{(() => {
|
||||
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
|
||||
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||
const currentParticipants = assignment?.participants || []
|
||||
const participantIds = currentParticipants.map(p => p.user_id)
|
||||
const allJoined = currentParticipants.length === 0
|
||||
const showParticipants = selectedAssignmentId && tripMembers.length > 1
|
||||
if (!res && !showParticipants) return null
|
||||
if (linked.length === 0 && !showParticipants) return null
|
||||
return (
|
||||
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Reservation */}
|
||||
{res && (() => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
// The strip summarised the booking but went nowhere, so its
|
||||
// attachments and fields had no route from the map (#2012).
|
||||
// A transport has its own form; picking by type is what the day
|
||||
// sidebar does, and an absent handler means this user may not
|
||||
// open this one — so the strip stays inert rather than lying.
|
||||
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
|
||||
const open = editor ? () => editor(res) : undefined
|
||||
return (
|
||||
<div
|
||||
role={open ? 'button' : undefined}
|
||||
aria-label={open ? t('inspector.editRes') : undefined}
|
||||
tabIndex={open ? 0 : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={open ? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
|
||||
} : undefined}
|
||||
title={open ? t('inspector.editRes') : undefined}
|
||||
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
|
||||
>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
|
||||
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
<div className={`grid ${linked.length > 0 && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Bookings pinned to this stop; several can share one (#2201) */}
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{linked.map(res => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
// The strip summarised the booking but went nowhere, so its
|
||||
// attachments and fields had no route from the map (#2012).
|
||||
// A transport has its own form; picking by type is what the day
|
||||
// sidebar does, and an absent handler means this user may not
|
||||
// open this one, so the strip stays inert rather than lying.
|
||||
const editor = TRANSPORT_TYPES.has(res.type) ? onEditTransport : onEditReservation
|
||||
const open = editor ? () => editor(res) : undefined
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
role={open ? 'button' : undefined}
|
||||
// No press-scale on the composite strip: shrinking it
|
||||
// mid-click slides the links inside out from under the
|
||||
// pointer (#2158).
|
||||
data-no-press
|
||||
aria-label={open ? t('inspector.editRes') : undefined}
|
||||
tabIndex={open ? 0 : undefined}
|
||||
onClick={open}
|
||||
onKeyDown={open ? (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open() }
|
||||
} : undefined}
|
||||
title={open ? t('inspector.editRes') : undefined}
|
||||
style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}`, cursor: open ? 'pointer' : undefined, textAlign: 'left' }}
|
||||
>
|
||||
<div className={confirmed ? 'bg-[rgba(22,163,74,0.08)]' : 'bg-[rgba(217,119,6,0.08)]'} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px' }}>
|
||||
<div className={confirmed ? 'bg-[#16a34a]' : 'bg-[#d97706]'} style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0 }} />
|
||||
<span className={confirmed ? 'text-[#16a34a]' : 'text-[#d97706]'} style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const parts: string[] = []
|
||||
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||
if (meta.train_number) parts.push(meta.train_number)
|
||||
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||
if (parts.length === 0) return null
|
||||
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{(() => {
|
||||
const { date, time: startTime } = splitReservationDateTime(res.reservation_time)
|
||||
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
|
||||
return (
|
||||
<>
|
||||
{date && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{(startTime || endTime) && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>
|
||||
{startTime ? formatTime(startTime, locale, timeFormat) : ''}
|
||||
{endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(8px * var(--fs-scale-caption, 1))', fontWeight: 600, textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div className="text-content" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500, marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div className="collab-note-md text-content-faint" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{res.notes}</Markdown></div>}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta || Object.keys(meta).length === 0) return null
|
||||
const parts: string[] = []
|
||||
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||
if (meta.train_number) parts.push(meta.train_number)
|
||||
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||
if (parts.length === 0) return null
|
||||
return <div className="text-content-muted" style={{ padding: '0 10px 6px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 500 }}>{parts.join(' · ')}</div>
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{showParticipants && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { buildAssignmentOptions } from './assignmentOptions'
|
||||
import AddressInput from './AddressInput'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2, ParkingSquare } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -32,31 +33,6 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const options = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
if (da.length === 0) continue
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
if (!place) continue
|
||||
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
|
||||
options.push({
|
||||
value: da[i].id,
|
||||
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||
searchLabel: place.name,
|
||||
groupLabel,
|
||||
dayDate: day.date || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
@@ -279,7 +255,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// the picked trip place/activity directly on the reservation (#1353).
|
||||
place_id: form.type === 'hotel' ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: [],
|
||||
// Omitted on an edit: the server replaces the endpoint set whenever the
|
||||
// key is present, and this form never edits endpoints, so sending an
|
||||
// empty list would drop a transit booking's stations (#2216).
|
||||
...(reservation?.id ? {} : { endpoints: [] }),
|
||||
needs_review: false,
|
||||
}
|
||||
if (form.type === 'hotel' && (form.hotel_start_day || form.hotel_end_day)) {
|
||||
|
||||
@@ -614,6 +614,9 @@ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contri
|
||||
className="bg-surface-card"
|
||||
onClick={() => onOpen(r)}
|
||||
role="button"
|
||||
// No press-scale on the card — it would shift the nested controls out
|
||||
// from under the pointer mid-click (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }}
|
||||
style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-TRANSITJOURNEY-001 to 016 — the journey view for a saved transit entry.
|
||||
// FE-PLANNER-TRANSITJOURNEY-001 to 018 — the journey view for a saved transit entry.
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
@@ -113,6 +113,21 @@ describe('TransitJourneyModal', () => {
|
||||
expect(onChangeRoute).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-017: edit details hands off to the full transport editor', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditDetails = vi.fn()
|
||||
render(<TransitJourneyModal {...makeProps({ onEditDetails })} />)
|
||||
await user.click(screen.getByRole('button', { name: /Edit details/ }))
|
||||
expect(onEditDetails).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-018: edit details stays hidden without the callback or edit rights', () => {
|
||||
const { rerender } = render(<TransitJourneyModal {...makeProps()} />)
|
||||
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
||||
rerender(<TransitJourneyModal {...makeProps({ onEditDetails: vi.fn(), canEdit: false })} />)
|
||||
expect(screen.queryByRole('button', { name: /Edit details/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSITJOURNEY-004: delete asks for confirmation, then calls onDelete', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDelete = vi.fn().mockResolvedValue({})
|
||||
|
||||
@@ -15,7 +15,9 @@ import type { Reservation } from '../../types'
|
||||
* The journey view for an automated public-transit entry (#1065): a roomy modal
|
||||
* around the stop-by-stop itinerary. The title renames inline right in the
|
||||
* header, notes get the full width with markdown support, and "Change route"
|
||||
* re-enters the transit search pre-seeded with this journey's route.
|
||||
* re-enters the transit search pre-seeded with this journey's route. "Edit
|
||||
* details" hands off to the full transport editor for the booking fields
|
||||
* (travelers, costs, files, code, status — #2148).
|
||||
*/
|
||||
|
||||
interface TransitLegMeta {
|
||||
@@ -38,10 +40,12 @@ interface TransitJourneyModalProps {
|
||||
onSave: (fields: { title: string; notes: string | null }) => Promise<unknown>
|
||||
onDelete: () => Promise<unknown>
|
||||
onChangeRoute: () => void
|
||||
/** Switch to the full transport editor (travelers, costs, files, code, status). */
|
||||
onEditDetails?: () => void
|
||||
canEdit: boolean
|
||||
}
|
||||
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, canEdit }: TransitJourneyModalProps) {
|
||||
export default function TransitJourneyModal({ reservation, onClose, onSave, onDelete, onChangeRoute, onEditDetails, canEdit }: TransitJourneyModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h'
|
||||
@@ -160,6 +164,15 @@ export default function TransitJourneyModal({ reservation, onClose, onSave, onDe
|
||||
<RefreshCw size={13} /> {t('transit.changeRoute')}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && onEditDetails && (
|
||||
<button type="button" onClick={onEditDetails} className="text-content-muted" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, padding: '8px 16px', borderRadius: 10,
|
||||
border: '1px solid var(--border-primary)', background: 'none',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Pencil size={13} /> {t('transit.editDetails')}
|
||||
</button>
|
||||
)}
|
||||
{canEdit ? (
|
||||
<button type="button" onClick={save} disabled={saving || !title.trim() || !dirty} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{
|
||||
padding: '8px 20px', borderRadius: 10, border: 'none',
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Assignment, Day, TranslationFn } from '../../types'
|
||||
|
||||
/** Day and month only, the shape the booking dialog has always shown here. */
|
||||
function dayBadge(dateStr: string, locale?: string): string {
|
||||
return new Date(dateStr + 'T00:00:00Z')
|
||||
.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export interface AssignmentOption {
|
||||
value: number | string
|
||||
label: string
|
||||
searchLabel?: string
|
||||
groupLabel?: string
|
||||
dayDate?: string | null
|
||||
disabled?: boolean
|
||||
isHeader?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The day plan as a flat option list: a disabled header per day, then its stops
|
||||
* in plan order. Shared by the desktop booking dialog and the phone sheet so a
|
||||
* booking can be linked to the same stop from either (#2216).
|
||||
*/
|
||||
export function buildAssignmentOptions(
|
||||
days: Day[] | undefined,
|
||||
assignments: Record<string, Assignment[]> | undefined,
|
||||
t: TranslationFn,
|
||||
locale?: string,
|
||||
): AssignmentOption[] {
|
||||
const options: AssignmentOption[] = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
if (da.length === 0) continue
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${dayBadge(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
if (!place) continue
|
||||
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
|
||||
options.push({
|
||||
value: da[i].id,
|
||||
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||
searchLabel: place.name,
|
||||
groupLabel,
|
||||
dayDate: day.date || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getNavigationTargets, showsAppleMaps } from './placeNavigation'
|
||||
import { getNavigationTargets, openNavigationTarget, showsAppleMaps } from './placeNavigation'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008
|
||||
@@ -105,3 +105,42 @@ describe('getNavigationTargets', () => {
|
||||
expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNavigationTarget', () => {
|
||||
const target = { id: 'google', label: 'Google Maps', url: 'https://maps.google.com/?q=1,2' } as const
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
function stubDisplayMode(installed: boolean, iosStandalone = false) {
|
||||
const open = vi.fn()
|
||||
const assigned: string[] = []
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: (q: string) => ({ matches: installed && q.includes('display-mode') }),
|
||||
navigator: { standalone: iosStandalone },
|
||||
open,
|
||||
get location() { return { get href() { return '' }, set href(v: string) { assigned.push(v) } } },
|
||||
})
|
||||
return { open, assigned }
|
||||
}
|
||||
|
||||
it('FE-PLANNER-NAV-101: a browser tab keeps opening a tab', () => {
|
||||
const { open, assigned } = stubDisplayMode(false)
|
||||
openNavigationTarget(target)
|
||||
expect(open).toHaveBeenCalledWith(target.url, '_blank', 'noopener,noreferrer')
|
||||
expect(assigned).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-102: an installed app hands over from the current context, leaving no blank window (#2218)', () => {
|
||||
const { open, assigned } = stubDisplayMode(true)
|
||||
openNavigationTarget(target)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(assigned).toEqual([target.url])
|
||||
})
|
||||
|
||||
it('FE-PLANNER-NAV-103: an iOS home-screen app is recognised without the display-mode query', () => {
|
||||
const { open, assigned } = stubDisplayMode(false, true)
|
||||
openNavigationTarget(target)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
expect(assigned).toEqual([target.url])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -95,7 +95,28 @@ export function getNavigationTargets(
|
||||
return targets
|
||||
}
|
||||
|
||||
/** Opens a target the way every external link in the planner is opened. */
|
||||
/**
|
||||
* Hands the place over to a maps application.
|
||||
*
|
||||
* In a browser tab this opens a tab, which is what a link should do. In an
|
||||
* installed app it must not: the maps application takes over from the new
|
||||
* context before it paints, so what the user comes back to is an empty window
|
||||
* they have to dismiss before TREK is usable again (#2218). Navigating the
|
||||
* current context instead means the handover happens from the page the user is
|
||||
* already on, and returning lands them back where they were, because the app
|
||||
* shell was never replaced by the time the platform switched away.
|
||||
*/
|
||||
export function openNavigationTarget(target: NavigationTarget): void {
|
||||
window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
if (isInstalledApp()) window.location.href = target.url
|
||||
else window.open(target.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** True in a display mode that has no tab strip to close a stray window from. */
|
||||
function isInstalledApp(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some(
|
||||
mode => window.matchMedia?.(`(display-mode: ${mode})`).matches,
|
||||
)
|
||||
// iOS predates the display-mode query for home-screen apps.
|
||||
return standalone || (window.navigator as { standalone?: boolean }).standalone === true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The one set of rules the three plugin-settings forms (admin instance form, desktop user
|
||||
* tab, mobile user screen) share: how a form is seeded from stored values + manifest
|
||||
* defaults, which required fields block Save, and what a save patch carries.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PluginUserSettingField } from '../../api/client';
|
||||
import { findMissingRequired, SECRET_MASK, seedSettingsValues, settingsPatch } from './settingsForm';
|
||||
|
||||
const f = (over: Partial<PluginUserSettingField> & { key: string }): PluginUserSettingField =>
|
||||
({ label: over.key, input_type: 'text', required: false, secret: false, ...over }) as PluginUserSettingField;
|
||||
|
||||
describe('seedSettingsValues', () => {
|
||||
it('seeds a declared default when nothing is stored, and a stored value wins', () => {
|
||||
const fields = [
|
||||
f({ key: 'url', default: 'https://d.example' }),
|
||||
f({ key: 'retries', input_type: 'number', default: 3 }),
|
||||
];
|
||||
expect(seedSettingsValues(fields, {})).toEqual({ url: 'https://d.example', retries: '3' });
|
||||
expect(seedSettingsValues(fields, { url: 'https://mine.example' })).toEqual({
|
||||
url: 'https://mine.example',
|
||||
retries: '3',
|
||||
});
|
||||
});
|
||||
it('coerces a checkbox to a boolean (default or stored) and everything else to a string', () => {
|
||||
const fields = [
|
||||
f({ key: 'on', input_type: 'checkbox', default: true }),
|
||||
f({ key: 'off', input_type: 'checkbox' }),
|
||||
f({ key: 'n' }),
|
||||
];
|
||||
expect(seedSettingsValues(fields, {})).toEqual({ on: true, off: false, n: '' });
|
||||
expect(seedSettingsValues(fields, { on: false, n: 5 })).toEqual({ on: false, off: false, n: '5' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findMissingRequired', () => {
|
||||
it('names the first required non-checkbox field that is blank (whitespace counts as blank)', () => {
|
||||
const fields = [f({ key: 'a', required: true }), f({ key: 'b', label: 'Bee', required: true })];
|
||||
expect(findMissingRequired(fields, { a: 'x', b: ' ' })?.key).toBe('b');
|
||||
expect(findMissingRequired(fields, { a: 'x', b: 'y' })).toBeUndefined();
|
||||
});
|
||||
it('exempts a required checkbox and treats an untouched secret (the mask) as filled', () => {
|
||||
const fields = [
|
||||
f({ key: 'consent', input_type: 'checkbox', required: true }),
|
||||
f({ key: 'tok', secret: true, required: true }),
|
||||
];
|
||||
expect(findMissingRequired(fields, { consent: false, tok: SECRET_MASK })).toBeUndefined();
|
||||
expect(findMissingRequired(fields, { consent: false, tok: '' })?.key).toBe('tok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsPatch', () => {
|
||||
it('sends every field except an untouched secret still showing the mask', () => {
|
||||
const fields = [f({ key: 'tok', secret: true }), f({ key: 'url' }), f({ key: 'on', input_type: 'checkbox' })];
|
||||
expect(settingsPatch(fields, { tok: SECRET_MASK, url: 'u', on: true })).toEqual({ url: 'u', on: true });
|
||||
expect(settingsPatch(fields, { tok: 'new', url: 'u', on: false })).toEqual({ tok: 'new', url: 'u', on: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Shared rules for the plugin-settings forms — the admin instance form
|
||||
* (useInstanceSettings), the desktop user tab (PluginSettingsTab) and the mobile user
|
||||
* screen (MSettingsPlugins) all render the same `fields` + `config` contract and used to
|
||||
* each hand-copy these loops, which is how the mobile screen missed `default` and the
|
||||
* required check.
|
||||
*/
|
||||
import type { PluginUserSettingField } from '../../api/client';
|
||||
|
||||
/** What the server echoes in place of a stored secret; sending it back means "unchanged". */
|
||||
export const SECRET_MASK = '••••••••';
|
||||
|
||||
export type SettingsValues = Record<string, string | boolean>;
|
||||
|
||||
/**
|
||||
* Form draft from the stored config, falling back to each field's manifest `default`
|
||||
* when nothing is stored (the same `stored ?? default` rule the runtime applies, so the
|
||||
* form shows the value the plugin actually gets). Checkboxes become booleans, everything
|
||||
* else a string.
|
||||
*/
|
||||
export function seedSettingsValues(fields: PluginUserSettingField[], config: Record<string, unknown>): SettingsValues {
|
||||
const values: SettingsValues = {};
|
||||
for (const f of fields) {
|
||||
const stored = config[f.key];
|
||||
const seeded = stored === undefined || stored === null ? f.default : stored;
|
||||
values[f.key] = f.input_type === 'checkbox' ? seeded === true : seeded == null ? '' : String(seeded);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `required` field the draft leaves blank, or undefined when Save may proceed.
|
||||
* Mirrors the server gate: a checkbox is exempt (required would mean "must be true", a
|
||||
* consent flow, not a setting), whitespace is blank, an untouched secret (still the mask)
|
||||
* is filled.
|
||||
*/
|
||||
export function findMissingRequired(
|
||||
fields: PluginUserSettingField[],
|
||||
values: SettingsValues
|
||||
): PluginUserSettingField | undefined {
|
||||
return fields.find((f) => {
|
||||
if (!f.required || f.input_type === 'checkbox') return false;
|
||||
const v = values[f.key];
|
||||
if (f.secret && v === SECRET_MASK) return false;
|
||||
return String(v ?? '').trim() === '';
|
||||
});
|
||||
}
|
||||
|
||||
/** The save body: every field's draft value, minus an untouched secret so the mask never overwrites the stored ciphertext. */
|
||||
export function settingsPatch(fields: PluginUserSettingField[], values: SettingsValues): Record<string, unknown> {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const f of fields) {
|
||||
const v = values[f.key];
|
||||
if (f.secret && v === SECRET_MASK) continue;
|
||||
patch[f.key] = v;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
@@ -275,7 +275,7 @@ describe('PluginSettingsTab', () => {
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-014: a plugin with only actions gets a card without a Save button', async () => {
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false, scope: 'user' }] });
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-015: running an action shows the message it returns', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ ok: true, message: 'Reached the API' })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -300,7 +300,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-016: a message-less result falls back to the generic ok/error labels', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: false })));
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
@@ -311,7 +311,7 @@ describe('PluginSettingsTab', () => {
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-017: a failing action reports an error result', async () => {
|
||||
const user = userEvent.setup();
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] });
|
||||
serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/test', () =>
|
||||
HttpResponse.json({ error: 'down' }, { status: 500 })));
|
||||
setPlugins([plugin()]);
|
||||
@@ -327,7 +327,7 @@ describe('PluginSettingsTab', () => {
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
let ran = 0;
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true }] });
|
||||
serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] });
|
||||
server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => {
|
||||
ran += 1;
|
||||
return HttpResponse.json({ ok: true, message: 'Wiped' });
|
||||
@@ -448,4 +448,67 @@ describe('PluginSettingsTab', () => {
|
||||
expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument();
|
||||
expect(within(cardFor('Notes')).getByText('Folder')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-026: pre-fills a declared default when no value is stored', async () => {
|
||||
serve('weather', {
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: {},
|
||||
});
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-027: a stored value wins over the default', async () => {
|
||||
serve('weather', {
|
||||
fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', default: 'https://auth.openbnb.org/authorize' }],
|
||||
config: { oauth_authorize_url: 'https://mine.example' },
|
||||
});
|
||||
setPlugins([plugin()]);
|
||||
render(<PluginSettingsTab />);
|
||||
|
||||
expect(await screen.findByDisplayValue('https://mine.example')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-028: blocks Save while a required field is empty and names it', async () => {
|
||||
const user = userEvent.setup();
|
||||
let called = false;
|
||||
serve('weather', {
|
||||
fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true }],
|
||||
config: {},
|
||||
});
|
||||
server.use(http.post('/api/plugin-settings/weather', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ config: {} });
|
||||
}));
|
||||
setPlugins([plugin()]);
|
||||
render(<><ToastContainer /><PluginSettingsTab /></>);
|
||||
|
||||
await screen.findByText('Client ID');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('"Client ID" is required')).toBeInTheDocument();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-COMP-PLUGINSETTINGS-029: a 400 from the server names the field it refused; a 500 stays generic', async () => {
|
||||
// The field list was loaded before a plugin update made `city` required, so the
|
||||
// client pre-check passes and only the server knows — its message must reach the user.
|
||||
const user = userEvent.setup();
|
||||
serve('weather', {
|
||||
fields: [{ key: 'city', label: 'City', input_type: 'text' }],
|
||||
config: {},
|
||||
});
|
||||
server.use(http.post('/api/plugin-settings/weather', () =>
|
||||
HttpResponse.json({ error: 'Missing required setting "city"' }, { status: 400 })));
|
||||
setPlugins([plugin()]);
|
||||
render(<><ToastContainer /><PluginSettingsTab /></>);
|
||||
|
||||
await screen.findByText('City');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(await screen.findByText('Missing required setting "city"')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import PluginActivityPanel from './PluginActivityPanel'
|
||||
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
|
||||
|
||||
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow +
|
||||
* holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */
|
||||
@@ -51,7 +52,6 @@ function PluginOAuthSection({ id, state, setState }: {
|
||||
)
|
||||
}
|
||||
|
||||
const SECRET_MASK = '••••••••'
|
||||
|
||||
/**
|
||||
* A user's own per-plugin settings (#plugins). The host renders the plugin's
|
||||
@@ -77,12 +77,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
if (!alive) return
|
||||
setFields(r.fields)
|
||||
setActions(r.actions ?? [])
|
||||
const init: Record<string, string | boolean> = {}
|
||||
for (const f of r.fields) {
|
||||
const v = r.config[f.key]
|
||||
init[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v))
|
||||
}
|
||||
setValues(init)
|
||||
setValues(seedSettingsValues(r.fields, r.config))
|
||||
})
|
||||
.catch(() => { if (alive) setFields([]) })
|
||||
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) })
|
||||
@@ -109,25 +104,22 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const missing = findMissingRequired(fields, values)
|
||||
if (missing) {
|
||||
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
// Skip an untouched secret (still shows the mask) so we never overwrite it with the mask.
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const f of fields) {
|
||||
const v = values[f.key]
|
||||
if (f.secret && v === SECRET_MASK) continue
|
||||
patch[f.key] = v
|
||||
}
|
||||
const r = await pluginsApi.saveUserSettings(id, patch)
|
||||
const next: Record<string, string | boolean> = {}
|
||||
for (const f of fields) {
|
||||
const v = r.config[f.key]
|
||||
next[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v))
|
||||
}
|
||||
setValues(next)
|
||||
const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
|
||||
setValues(seedSettingsValues(fields, r.config))
|
||||
toast.success(t('settings.plugins.saved'))
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
} catch (e) {
|
||||
// A 4xx names what the server refused (a required field it knows about and this
|
||||
// stale field list doesn't); a 5xx body is not for the user.
|
||||
const err = e as { response?: { status?: number; data?: { error?: string } } }
|
||||
const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined
|
||||
toast.error(refused || t('common.error'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -9,10 +9,14 @@ export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onT
|
||||
background: on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)',
|
||||
transition: 'background 0.2s',
|
||||
}}>
|
||||
{/* The knob reads against its own track: --accent-text is what the palette
|
||||
already picked to sit on --accent, so a light accent gets a dark knob
|
||||
instead of the white-on-near-white it used to be in dark mode. */}
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: on ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: 'white',
|
||||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: on ? 'var(--accent-text, #ffffff)' : 'var(--bg-card, #ffffff)',
|
||||
transition: 'left 0.2s', boxShadow: 'var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.2))',
|
||||
}} />
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { CSSProperties } from 'react'
|
||||
import type {
|
||||
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
|
||||
} from '@trek/shared'
|
||||
import { FONT_STACKS, photoSrc } from './bookRender'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { iconComponent } from './iconLibrary'
|
||||
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
|
||||
import { TravelElementView } from './TravelElements'
|
||||
@@ -223,7 +224,7 @@ function PhotoView({ el, big, print, dropLabel }: {
|
||||
{roomy && dropLabel && (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: FONT_STACKS.sans,
|
||||
fontFamily: fontStack('sans'),
|
||||
fontSize: `${labelSize}mm`,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.16em',
|
||||
@@ -403,7 +404,7 @@ export function ElementView({
|
||||
// pt, not px: the document speaks the print's language, and CSS knows
|
||||
// the conversion exactly.
|
||||
fontSize: `${el.size}pt`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { PeerCursors } from './PeerCursors'
|
||||
import type { PeerCursor } from './useBookPresence'
|
||||
import { FONT_STACKS } from './bookRender'
|
||||
import { elementId } from './bookIds'
|
||||
import { fontStack } from './bookFonts'
|
||||
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -65,6 +66,8 @@ export function StudioCanvas({
|
||||
dropLabel,
|
||||
cursors,
|
||||
onCursor,
|
||||
onDropFiles,
|
||||
fileDropLabel,
|
||||
}: {
|
||||
spread: BookSpread | null
|
||||
spreadIndex: number
|
||||
@@ -73,6 +76,14 @@ export function StudioCanvas({
|
||||
pxPerMm: number
|
||||
bookView: boolean
|
||||
dropLabel: string
|
||||
/**
|
||||
* Files from outside the browser, let go over the sheet. Absent for a
|
||||
* viewer, and then a file drag is left to the browser, which opens it.
|
||||
* `targetFrameId` is the photo frame under the pointer, when there is one.
|
||||
*/
|
||||
onDropFiles?: (files: File[], at: { x: number; y: number }, targetFrameId: string | null) => void
|
||||
/** What the veil says while a file drag is over the sheet. */
|
||||
fileDropLabel?: string
|
||||
/** The other editors' pointers, on this spread and elsewhere. */
|
||||
cursors?: PeerCursor[]
|
||||
/** Where this one is, in the spread's millimetres. Null once it leaves. */
|
||||
@@ -87,6 +98,8 @@ export function StudioCanvas({
|
||||
const commit = useStudioStore(s => s.commit)
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
/** A file drag from outside the browser is over the sheet. */
|
||||
const [fileOver, setFileOver] = useState(false)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
/** Where on the spread, in millimetres, a drop landed. */
|
||||
@@ -166,22 +179,34 @@ export function StudioCanvas({
|
||||
onPointerUp={finish}
|
||||
onPointerCancel={finish}
|
||||
onDragOver={e => {
|
||||
if (!e.dataTransfer.types.includes('application/x-trek-photo')) return
|
||||
const fromPanel = e.dataTransfer.types.includes('application/x-trek-photo')
|
||||
// A file drag is only claimed when somebody is there to take it; for a
|
||||
// viewer the browser keeps its default and opens the picture itself.
|
||||
const fromDisk = !!onDropFiles && e.dataTransfer.types.includes('Files')
|
||||
if (!fromPanel && !fromDisk) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
const p = pointInMm(e)
|
||||
setDropTarget(frameUnder(p.x, p.y)?.id ?? null)
|
||||
setFileOver(fromDisk && !fromPanel)
|
||||
}}
|
||||
onDragLeave={() => setDropTarget(null)}
|
||||
onDragLeave={() => { setDropTarget(null); setFileOver(false) }}
|
||||
onDrop={e => {
|
||||
const raw = e.dataTransfer.getData('application/x-trek-photo')
|
||||
if (!raw) return
|
||||
const files = Array.from(e.dataTransfer.files ?? [])
|
||||
if (!raw && !(files.length && onDropFiles)) return
|
||||
e.preventDefault()
|
||||
setDropTarget(null)
|
||||
setFileOver(false)
|
||||
|
||||
const p = pointInMm(e)
|
||||
if (!raw) {
|
||||
onDropFiles!(files, p, frameUnder(p.x, p.y)?.id ?? null)
|
||||
return
|
||||
}
|
||||
const photoId = Number(raw)
|
||||
if (!Number.isFinite(photoId)) return
|
||||
|
||||
const p = pointInMm(e)
|
||||
const target = frameUnder(p.x, p.y)
|
||||
if (target) {
|
||||
// Dropped onto a frame: fill it. That is the whole point of an empty
|
||||
@@ -200,7 +225,7 @@ export function StudioCanvas({
|
||||
// Dropped on bare paper: a new frame, centred on the cursor.
|
||||
const w = Math.min(page.pageWidth, page.pageHeight) * 0.5
|
||||
const h = w * 0.72
|
||||
const id = `p-${Math.random().toString(36).slice(2, 9)}`
|
||||
const id = elementId('p')
|
||||
addElement(spreadIndex, {
|
||||
id, kind: 'photo',
|
||||
frame: { x: p.x - w / 2, y: p.y - h / 2, w, h },
|
||||
@@ -211,6 +236,17 @@ export function StudioCanvas({
|
||||
select([id])
|
||||
}}
|
||||
>
|
||||
{/*
|
||||
Over the stage rather than inside the sheet, which is scaled: at 40%
|
||||
a label drawn on the page would be unreadable. It says what letting go
|
||||
will do, because a page is not an obvious drop target for a file the
|
||||
way an upload box is.
|
||||
*/}
|
||||
{fileOver && fileDropLabel && (
|
||||
<div className="st-file-veil" aria-hidden>
|
||||
<span>{fileDropLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="st-sheet"
|
||||
style={{
|
||||
@@ -271,7 +307,7 @@ export function StudioCanvas({
|
||||
height: el.frame.h * scaled,
|
||||
// The document is in pt; the overlay is on screen at the same zoom.
|
||||
fontSize: `${el.size * (96 / 72) * zoom}px`,
|
||||
fontFamily: FONT_STACKS[el.font],
|
||||
fontFamily: fontStack(el.font),
|
||||
fontWeight: el.weight,
|
||||
fontStyle: el.italic ? 'italic' : undefined,
|
||||
lineHeight: el.leading,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, X } from 'lucide-react'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { JourneySource, StudioT } from './StudioSidebar'
|
||||
import type { PhotoFilter } from './photoFilter'
|
||||
|
||||
/**
|
||||
* Which day's pictures the content browser is showing.
|
||||
*
|
||||
* A row of its own under the tabs rather than a control inside the search box:
|
||||
* the search answers "which words" and this answers "which day", and the two
|
||||
* combine. It takes the panel's full width, so the entry in force reads as a
|
||||
* heading over the grid rather than as one control among several, and a long
|
||||
* entry name has somewhere to go.
|
||||
*
|
||||
* The popover is the top bar's format menu, hung from the left edge instead of
|
||||
* the right: the side panel is 236px wide and a menu anchored right would open
|
||||
* into the rail.
|
||||
*/
|
||||
export function StudioContentFilter({
|
||||
source, filter, setFilter, admitted, pageEntryId, t, locale,
|
||||
}: {
|
||||
source: JourneySource
|
||||
filter: PhotoFilter
|
||||
setFilter: (f: PhotoFilter) => void
|
||||
/** How many pictures the current filter admits, for the chip's own count. */
|
||||
admitted: number
|
||||
/** The entry the spread on the sheet was laid out from, when it has one. */
|
||||
pageEntryId: number | null
|
||||
t: StudioT
|
||||
locale: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (!box.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Swallowed, as the format picker does it: the shell's Escape closes Studio.
|
||||
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
|
||||
}
|
||||
document.addEventListener('pointerdown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const pick = (f: PhotoFilter) => { setFilter(f); setOpen(false) }
|
||||
const withPhotos = source.entries.filter(e => e.photoIds.length > 0)
|
||||
const looseCount = source.photos.filter(p => !p.entryIds.length).length
|
||||
const nameOf = (e: { title: string | null; location: string | null } | undefined) =>
|
||||
e?.title || e?.location || t('journey.studio.untitled')
|
||||
|
||||
const label = filter.kind === 'loose' ? t('journey.studio.filterLoose')
|
||||
: filter.kind === 'recent' ? t('journey.studio.filterRecent')
|
||||
: filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id))
|
||||
: t('journey.studio.filterAll')
|
||||
|
||||
/*
|
||||
* The page on the sheet belongs to a day, so its pictures are one press
|
||||
* away. Offered only while the filter is not already there: a chip for the
|
||||
* state you are in is a chip that does nothing.
|
||||
*/
|
||||
const pageEntry = pageEntryId != null ? withPhotos.find(e => e.id === pageEntryId) : undefined
|
||||
const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id)
|
||||
|
||||
return (
|
||||
<div className="st-filter-row">
|
||||
<div className="st-picker" ref={box}>
|
||||
<span className={`st-chip st-filter-chip ${filter.kind !== 'all' ? 'is-on' : ''}`}>
|
||||
<button type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={t('journey.studio.filterPhotos')}
|
||||
>
|
||||
<span className="st-filter-name">{label}</span>
|
||||
<em>{admitted}</em>
|
||||
<ChevronDown size={12} style={{ opacity: .5 }} />
|
||||
</button>
|
||||
{filter.kind !== 'all' && (
|
||||
<button type="button" onClick={() => setFilter({ kind: 'all' })} aria-label={t('common.clear')}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{open && (
|
||||
<div className="st-menu" role="menu">
|
||||
<FilterItem
|
||||
on={filter.kind === 'all'}
|
||||
label={t('journey.studio.filterAll')}
|
||||
count={source.photos.length}
|
||||
onPick={() => pick({ kind: 'all' })}
|
||||
/>
|
||||
<FilterItem
|
||||
on={filter.kind === 'loose'}
|
||||
label={t('journey.studio.filterLoose')}
|
||||
count={looseCount}
|
||||
onPick={() => pick({ kind: 'loose' })}
|
||||
/>
|
||||
{withPhotos.length > 0 && <div className="st-menu-sep" />}
|
||||
{withPhotos.map(e => (
|
||||
<FilterItem
|
||||
key={e.id}
|
||||
on={filter.kind === 'entry' && filter.id === e.id}
|
||||
label={nameOf(e)}
|
||||
// The day, and the place when the title took the top line.
|
||||
dim={[e.date ? formatDate(e.date, locale) ?? e.date : null, e.title ? e.location : null]
|
||||
.filter(Boolean).join(', ')}
|
||||
count={e.photoIds.length}
|
||||
onPick={() => pick({ kind: 'entry', id: e.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{offerThisPage && (
|
||||
<button type="button" className="st-chip" onClick={() => setFilter({ kind: 'entry', id: pageEntry.id })}>
|
||||
{t('journey.studio.filterThisPage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One line of the menu: a name, a count, and a tick on the one in force. */
|
||||
function FilterItem({ on, label, dim, count, onPick }: {
|
||||
on: boolean
|
||||
label: string
|
||||
dim?: string
|
||||
count: number
|
||||
onPick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={on}
|
||||
className={`st-menu-item ${on ? 'is-active' : ''}`}
|
||||
onClick={onPick}
|
||||
>
|
||||
<span className="st-menu-text">
|
||||
<span className="st-menu-name">{label}</span>
|
||||
{dim && <span className="st-menu-dim">{dim}</span>}
|
||||
</span>
|
||||
<span className="st-menu-dim st-filter-count">{count}</span>
|
||||
{on && <Check size={14} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -36,18 +36,26 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
* remembered: coming back to an element and finding its panel folded up
|
||||
* differently from last time is worse than opening one group again.
|
||||
*/
|
||||
export function Section({ label, children, defaultOpen = true, hint }: {
|
||||
export function Section({ label, children, defaultOpen = true, hint, badge }: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
defaultOpen?: boolean
|
||||
/** A line under the label, for a group whose name cannot carry the whole idea. */
|
||||
hint?: string
|
||||
/**
|
||||
* A figure beside the label, for a group worth reading while it is folded.
|
||||
* The stops list is the case: how many of them count is the thing you came
|
||||
* to check, and opening a section to read one number is a section that
|
||||
* cannot usefully be closed.
|
||||
*/
|
||||
badge?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div className={`st-section is-fold ${open ? '' : 'is-folded'}`}>
|
||||
<button type="button" className="st-section-head" onClick={() => setOpen(!open)} aria-expanded={open}>
|
||||
<span className="st-section-label">{label}</span>
|
||||
{badge && <span className="st-section-badge">{badge}</span>}
|
||||
<ChevronDown size={13} className="st-section-caret" />
|
||||
</button>
|
||||
{open && (
|
||||
|
||||
@@ -191,6 +191,34 @@ export function StudioInspector({
|
||||
|
||||
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
|
||||
|
||||
/**
|
||||
* Stretch the picture over the page it is on, or over both pages, bleed
|
||||
* included.
|
||||
*
|
||||
* One commit rather than `set` followed by `raise`: the frame, the rotation
|
||||
* put back to zero (a turned picture leaves bare corners), the cover crop and
|
||||
* the move to the back are one press, and one press comes back with one
|
||||
* undo. Which page is the one under the frame's centre; a cover has only
|
||||
* the one.
|
||||
*/
|
||||
const fill = (scope: 'page' | 'spread') => commit(d => ({
|
||||
...d,
|
||||
spreads: d.spreads.map((sp, i) => {
|
||||
if (i !== spreadIndex) return sp
|
||||
const bleed = page.bleed
|
||||
const onRight = sp.role === 'inner' && scope === 'page'
|
||||
&& el.frame.x + el.frame.w / 2 >= page.pageWidth
|
||||
const frame = {
|
||||
x: onRight ? page.pageWidth - bleed : -bleed,
|
||||
y: -bleed,
|
||||
w: (scope === 'spread' ? 2 : 1) * page.pageWidth + 2 * bleed,
|
||||
h: page.pageHeight + 2 * bleed,
|
||||
}
|
||||
const filled = { ...el, frame, rotation: 0, fit: 'cover' } as BookElement
|
||||
return { ...sp, elements: [filled, ...sp.elements.filter(e => e.id !== el.id)] }
|
||||
}),
|
||||
}))
|
||||
|
||||
return (
|
||||
<aside className="st-panel st-inspector">
|
||||
{/*
|
||||
@@ -696,6 +724,28 @@ export function StudioInspector({
|
||||
<ChevronsDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
The picture as the page.
|
||||
|
||||
A full-bleed photograph is the commonest layout in any photo book
|
||||
and the hardest to make by hand: the frame has to reach exactly to
|
||||
the bleed on every side, and a millimetre short leaves a white
|
||||
hairline on the trimmed page. So it is one press. It goes to the
|
||||
back as well, since a page-sized picture in front of everything is
|
||||
a page with nothing else on it.
|
||||
*/}
|
||||
{one && el.kind === 'photo' && (
|
||||
<div className="st-row" style={{ marginTop: 6 }}>
|
||||
<button type="button" className="st-chip" onClick={() => fill('page')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillPage')}
|
||||
</button>
|
||||
{spread.role === 'inner' && (
|
||||
<button type="button" className="st-chip" onClick={() => fill('spread')} title={t('journey.studio.fillHint')}>
|
||||
{t('journey.studio.fillSpread')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PeerBadges } from './PeerBadges'
|
||||
import { TrimField } from './TrimField'
|
||||
import '../../styles/dashboard.css'
|
||||
import '../../styles/studio.css'
|
||||
import './bookFontFaces'
|
||||
|
||||
/**
|
||||
* The Studio shell: top bar, page rail, workbench, inspector.
|
||||
@@ -88,6 +89,9 @@ export default function StudioShell() {
|
||||
path={s.path}
|
||||
t={s.t}
|
||||
locale={s.locale}
|
||||
canEdit={s.canEdit}
|
||||
onUpload={s.uploadPhotos}
|
||||
onToggleStop={s.setStopExcluded}
|
||||
/>
|
||||
<Workbench s={s} bookView={bookView} />
|
||||
<StudioInspector
|
||||
@@ -430,7 +434,20 @@ function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
|
||||
dropLabel={s.t('journey.studio.dropPhotoHere')}
|
||||
cursors={s.cursors}
|
||||
onCursor={(x, y) => s.moveCursor(s.activeSpread, x, y)}
|
||||
onDropFiles={s.canEdit ? s.dropFiles : undefined}
|
||||
fileDropLabel={s.t('journey.studio.dropFilesHere')}
|
||||
/>
|
||||
{/*
|
||||
Pictures dropped on the sheet are on their way. The page keeps
|
||||
rendering underneath; the veil only says that something is happening
|
||||
where the pointer let go, so the second or two before the frame
|
||||
appears does not read as the drop having been ignored.
|
||||
*/}
|
||||
{s.canvasUpload && (
|
||||
<div className="st-upload-veil" role="status" aria-live="polite">
|
||||
{s.t('journey.studio.uploading', { done: s.canvasUpload.done, total: s.canvasUpload.total })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="st-zoom">
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Camera, Check, ChevronDown, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate,
|
||||
Plus, Search, Shapes, Trash2, Upload, X,
|
||||
} from 'lucide-react'
|
||||
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { useElementSize } from '../../hooks/useElementSize'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { SpreadFold, SpreadView } from './SpreadView'
|
||||
import { photoSrc } from './bookRender'
|
||||
import { elementId as uid } from './bookIds'
|
||||
import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText'
|
||||
import { coordValue } from './resolveBindings'
|
||||
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
|
||||
@@ -14,6 +19,9 @@ import { PanelHead as Head } from './StudioPanelHead'
|
||||
import { StudioElementsPanel } from './StudioElementsPanel'
|
||||
import { StudioTravelPanel } from './StudioTravelPanel'
|
||||
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
|
||||
import { StudioContentFilter } from './StudioContentFilter'
|
||||
import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter'
|
||||
import type { StudioUploader } from './studioUpload'
|
||||
|
||||
/**
|
||||
* The left side of Studio: a narrow rail of sections and one wide panel showing
|
||||
@@ -42,13 +50,17 @@ export interface JourneySource {
|
||||
weather: string | null
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
/** The pictures attached to this entry, as trek_photos ids. */
|
||||
photoIds: number[]
|
||||
}[]
|
||||
photos: { photoId: number; caption?: string | null }[]
|
||||
/** `entryIds` is empty for a picture that sits only in the gallery. */
|
||||
photos: { photoId: number; caption?: string | null; entryIds: number[] }[]
|
||||
/** photoId to the words of the entry it belongs to, lower-cased. */
|
||||
photoEntries: Record<number, string>
|
||||
}
|
||||
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
|
||||
/** The translator, with the parameters some of the strings here take. */
|
||||
export type StudioT = (k: string, params?: Record<string, string | number>) => string
|
||||
|
||||
/**
|
||||
* How wide a page preview may be drawn.
|
||||
@@ -72,6 +84,7 @@ const THUMB_CHROME = (2 + 3) * 2
|
||||
|
||||
export function StudioSidebar({
|
||||
page, pxPerMm, bookView, source, stats, path, t, locale,
|
||||
canEdit, onUpload, onToggleStop,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
pxPerMm: number
|
||||
@@ -81,8 +94,14 @@ export function StudioSidebar({
|
||||
stats: JourneyStats | null
|
||||
/** The roads the trip took, for a map that draws them. Empty when it has none. */
|
||||
path: [number, number][][]
|
||||
t: (k: string) => string
|
||||
t: StudioT
|
||||
locale: string
|
||||
/** False for a viewer: no upload, no switching stops, nothing that writes. */
|
||||
canEdit: boolean
|
||||
/** Pictures into the gallery, or into one entry. See studioUpload.ts. */
|
||||
onUpload: StudioUploader
|
||||
/** Switch a stop on or off in the figures. Resolves false when it did not land. */
|
||||
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>
|
||||
}) {
|
||||
const [section, setSection] = useState<Section>('pages')
|
||||
|
||||
@@ -113,9 +132,21 @@ export function StudioSidebar({
|
||||
|
||||
<aside className="st-panel st-side">
|
||||
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
||||
{section === 'content' && <ContentPanel source={source} page={page} t={t} locale={locale} />}
|
||||
{section === 'content' && (
|
||||
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload} />
|
||||
)}
|
||||
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
|
||||
{section === 'travel' && <StudioTravelPanel page={page} stats={stats} path={path} t={t} locale={locale} />}
|
||||
{section === 'travel' && (
|
||||
<StudioTravelPanel
|
||||
page={page}
|
||||
stats={stats}
|
||||
path={path}
|
||||
t={t}
|
||||
locale={locale}
|
||||
canEdit={canEdit}
|
||||
onToggleStop={onToggleStop}
|
||||
/>
|
||||
)}
|
||||
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
||||
</aside>
|
||||
</>
|
||||
@@ -296,36 +327,97 @@ function PagesPanel({
|
||||
* This is what separates a book maker from a drawing program: the pictures and
|
||||
* the words are already written, and the job is putting them on pages. Clicking
|
||||
* an item drops it on the current spread, centred, at a sensible size.
|
||||
*
|
||||
* Pictures can also come in here. A book is where you notice the one that
|
||||
* never made it into the journal, and going back to the journey to add it, then
|
||||
* finding the page again, was the one round trip Studio still asked for. What
|
||||
* comes in goes through the journey store like any other upload, so the journal
|
||||
* sees it the moment the panel does.
|
||||
*/
|
||||
function ContentPanel({
|
||||
source, page, t, locale,
|
||||
}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) {
|
||||
source, page, t, locale, canEdit, onUpload,
|
||||
}: {
|
||||
source: JourneySource
|
||||
page: BookPageSetup
|
||||
t: StudioT
|
||||
locale: string
|
||||
canEdit: boolean
|
||||
onUpload: StudioUploader
|
||||
}) {
|
||||
const [tab, setTab] = useState<'photos' | 'text'>('photos')
|
||||
const [query, setQuery] = useState('')
|
||||
const [filter, setFilter] = useState<PhotoFilter>({ kind: 'all' })
|
||||
const toast = useToast()
|
||||
|
||||
const entries = matchingEntries(source, query)
|
||||
const admitted = photosFor(source, filter)
|
||||
const photos = matchingPhotos(admitted, source, query)
|
||||
const emptyKey = emptyKeyFor(source, filter, admitted)
|
||||
|
||||
/*
|
||||
* Filtering, not a search index: a journey holds tens or hundreds of items, and
|
||||
* a substring match over what is already in memory answers instantly. A photo
|
||||
* matches on its own caption *and* on the entry it belongs to — most photos
|
||||
* carry no words at all, so matching only captions would make the box look
|
||||
* broken on exactly the journeys that need it.
|
||||
*/
|
||||
const q = query.trim().toLowerCase()
|
||||
const entries = q
|
||||
? source.entries.filter(e =>
|
||||
[e.title, e.story, e.location, ...e.pros, ...e.cons]
|
||||
.some(v => v && v.toLowerCase().includes(q)))
|
||||
: source.entries
|
||||
const photos = q
|
||||
? source.photos.filter(p =>
|
||||
(p.caption && p.caption.toLowerCase().includes(q))
|
||||
|| (source.photoEntries[p.photoId] || '').includes(q))
|
||||
: source.photos
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const active = useStudioStore(s => s.activeSpread)
|
||||
const doc = useStudioStore(s => s.doc)
|
||||
const spread = doc?.spreads[active]
|
||||
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
const [upload, setUpload] = useState<{ done: number; total: number } | null>(null)
|
||||
/* A ref as well as the state: two drops in one second would both read the
|
||||
state before either had set it. */
|
||||
const sending = useRef(false)
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
|
||||
/**
|
||||
* Into the entry the browser is filtered to, otherwise into the gallery. The
|
||||
* filter is the one thing on screen that names an entry, so it is also the
|
||||
* one thing that can say where a picture belongs; the line under it says so
|
||||
* before the press. Afterwards the new pictures get a filter of their own,
|
||||
* because the gallery sorts them to its end, and twelve pictures that arrive
|
||||
* out of sight read as twelve that did not arrive.
|
||||
*/
|
||||
const send = async (files: File[]) => {
|
||||
if (sending.current || !files.length) return
|
||||
sending.current = true
|
||||
setUpload({ done: 0, total: files.length })
|
||||
try {
|
||||
const entryId = filter.kind === 'entry' ? filter.id : null
|
||||
const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total }))
|
||||
if (sent.photoIds.length) {
|
||||
setFilter({ kind: 'recent', photoIds: sent.photoIds })
|
||||
setQuery('')
|
||||
toast.success(t('journey.photosUploaded', { count: sent.photoIds.length }))
|
||||
}
|
||||
} finally {
|
||||
sending.current = false
|
||||
setUpload(null)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The whole scrolling area takes a drop, not just the dashed cell: a drag
|
||||
* from the desktop arrives with the pointer wherever it arrives, and a target
|
||||
* seventy pixels square is one you miss. Only a file drag is claimed; the
|
||||
* panel's own photo drags carry another type and go to the sheet.
|
||||
*/
|
||||
const dropZone = canEdit && tab === 'photos' ? {
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes('Files')) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setDropOver(true)
|
||||
},
|
||||
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
// Moving onto a child of the box is not leaving the box.
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false)
|
||||
},
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = Array.from(e.dataTransfer.files ?? [])
|
||||
if (!files.length) return
|
||||
e.preventDefault()
|
||||
setDropOver(false)
|
||||
void send(files)
|
||||
},
|
||||
} : {}
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
@@ -444,33 +536,94 @@ function ContentPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="st-panel-scroll">
|
||||
{/*
|
||||
Which entry, as a row of its own under the tabs rather than a control
|
||||
inside the search box: the search answers "which words" and this
|
||||
answers "which day", and the two combine. The chip opens the same
|
||||
popover the bar uses for formats, hung from its left edge because the
|
||||
panel has no room to the right of it.
|
||||
*/}
|
||||
{tab === 'photos' && (
|
||||
<StudioContentFilter
|
||||
source={source}
|
||||
filter={filter}
|
||||
setFilter={setFilter}
|
||||
admitted={admitted.length}
|
||||
pageEntryId={spread?.entryId ?? null}
|
||||
t={t}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
|
||||
{tab === 'photos' ? (
|
||||
<div className="st-photo-grid">
|
||||
{photos.map(p => (
|
||||
<button type="button"
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => dropPhoto(p.photoId)}
|
||||
title={t('journey.studio.addToPage')}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
// HTML5 drag is the right tool for exactly this shape of
|
||||
// interaction — one item, from a list, onto a target — and it
|
||||
// gives us the thumbnail as the drag image for free. The
|
||||
// canvas uses pointer events instead, because free transform
|
||||
// needs a live position that a drop event cannot provide.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && (
|
||||
<p className="st-hint">{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}</p>
|
||||
<>
|
||||
{canEdit && (
|
||||
<p className="st-hint st-upload-where">
|
||||
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="st-photo-grid">
|
||||
{/*
|
||||
The first cell rather than a button above the grid: it is the
|
||||
size and shape of what it produces, and it stays where the eye
|
||||
already is. Disabled, not hidden, while a batch is on its way,
|
||||
so the count has somewhere to be read.
|
||||
*/}
|
||||
{canEdit && (
|
||||
<button type="button"
|
||||
className="st-photo-cell is-upload"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!!upload}
|
||||
title={t('journey.studio.uploadHint')}
|
||||
>
|
||||
<Upload size={18} strokeWidth={1.6} />
|
||||
<span>
|
||||
{upload
|
||||
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
|
||||
: t('journey.studio.uploadPhotos')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{photos.map(p => (
|
||||
<button type="button"
|
||||
key={p.photoId}
|
||||
className="st-photo-cell"
|
||||
onClick={() => dropPhoto(p.photoId)}
|
||||
title={t('journey.studio.addToPage')}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
// HTML5 drag is the right tool for exactly this shape of
|
||||
// interaction (one item, from a list, onto a target) and it
|
||||
// gives us the thumbnail as the drag image for free. The
|
||||
// canvas uses pointer events instead, because free transform
|
||||
// needs a live position that a drop event cannot provide.
|
||||
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
}}
|
||||
>
|
||||
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
||||
</button>
|
||||
))}
|
||||
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,.heic,.heif"
|
||||
hidden
|
||||
onChange={e => {
|
||||
const files = Array.from(e.target.files ?? [])
|
||||
// Cleared straight away, as the spread import does, so the
|
||||
// same pictures can be chosen twice in a row.
|
||||
e.target.value = ''
|
||||
void send(files)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="st-entries">
|
||||
{entries.map(e => (
|
||||
@@ -556,6 +709,22 @@ function ContentPanel({
|
||||
<em>{e.pros.length + e.cons.length}</em>
|
||||
</button>
|
||||
)}
|
||||
{/*
|
||||
Not a drop like the others: this one takes you to the
|
||||
entry's pictures. The words of a day and its pictures were
|
||||
two tabs apart with nothing joining them, and the day is
|
||||
how anyone thinks of the pictures.
|
||||
*/}
|
||||
{e.photoIds.length > 0 && (
|
||||
<button type="button"
|
||||
className="st-chip"
|
||||
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.studio.entryPhotos')}
|
||||
<em>{e.photoIds.length}</em>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, RouteOff } from 'lucide-react'
|
||||
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
|
||||
import { BOOK_METRICS } from '@trek/shared'
|
||||
import { useStudioStore } from '../../store/studioStore'
|
||||
import { PanelHead } from './StudioPanelHead'
|
||||
import { Section } from './StudioControls'
|
||||
import { TravelPreview } from './TravelPreview'
|
||||
import { formatBookCoords } from './entryText'
|
||||
import { elementId as uid } from './bookIds'
|
||||
import { routeFor } from './travelRefresh'
|
||||
import { useMapSources } from './mapSources'
|
||||
import { fetchRoads } from './roadRoute'
|
||||
@@ -36,7 +39,20 @@ import { fetchRoads } from './roadRoute'
|
||||
* short version is that the print renderer must never depend on a fetch.
|
||||
*/
|
||||
|
||||
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
|
||||
/**
|
||||
* One row of the stops list: a point of the route or a stop switched off.
|
||||
*
|
||||
* `entryId` is null for a stop the server took straight from a trip's places,
|
||||
* which is what a journey has before anyone wrote an entry. Such a stop has no
|
||||
* switch, because there is no entry to put it on.
|
||||
*/
|
||||
type StopRow = {
|
||||
entryId: number | null
|
||||
label: string
|
||||
date: string | null
|
||||
country: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
|
||||
/*
|
||||
* How many cards share a row.
|
||||
@@ -80,7 +96,7 @@ function Card({ label, onClick, children, recommended = false, title }: {
|
||||
}
|
||||
|
||||
export function StudioTravelPanel({
|
||||
page, stats, path, t, locale,
|
||||
page, stats, path, t, locale, canEdit, onToggleStop,
|
||||
}: {
|
||||
page: BookPageSetup
|
||||
stats: JourneyStats | null
|
||||
@@ -88,6 +104,10 @@ export function StudioTravelPanel({
|
||||
path: [number, number][][]
|
||||
t: (k: string) => string
|
||||
locale: string
|
||||
/** False for a viewer: the stops are still listed, none of them can be switched. */
|
||||
canEdit: boolean
|
||||
/** Switch one stop on or off in the figures. Resolves false when it did not land. */
|
||||
onToggleStop: (entryId: number, excluded: boolean) => Promise<boolean>
|
||||
}) {
|
||||
const addElement = useStudioStore(s => s.addElement)
|
||||
const updateElement = useStudioStore(s => s.updateElement)
|
||||
@@ -109,6 +129,17 @@ export function StudioTravelPanel({
|
||||
roadJobs.current.clear()
|
||||
}, [])
|
||||
|
||||
/*
|
||||
* The stops whose switch is on its way to the server.
|
||||
*
|
||||
* No optimistic flip: which stop is furthest and which countries remain is
|
||||
* the server's sum, so the row reads its state from the stats the hook
|
||||
* fetches once the change has landed. Until then the chip is only held,
|
||||
* so a second click cannot send the opposite of what the first one is
|
||||
* still delivering.
|
||||
*/
|
||||
const [pending, setPending] = useState<Set<number>>(() => new Set())
|
||||
|
||||
const centre = (w: number, h: number) => {
|
||||
const W = single ? page.pageWidth : page.pageWidth * 2
|
||||
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
|
||||
@@ -183,6 +214,56 @@ export function StudioTravelPanel({
|
||||
/** The linked trips that actually put stops on the route. */
|
||||
const tripsWithRoute = (stats.trips ?? []).filter(t => t.points > 0)
|
||||
|
||||
/*
|
||||
* The route as rows, counting and left-out stops together.
|
||||
*
|
||||
* Every point is listed, so the list reads as the route. The left-out
|
||||
* stops sit among the counting ones in date order rather than in a list of
|
||||
* their own, because the question the section answers is "which of these
|
||||
* count?", and that is only answerable with all of them in one sequence.
|
||||
* Undated stops go last, then by name.
|
||||
*/
|
||||
const stops: StopRow[] = [
|
||||
...stats.points.map(p => ({
|
||||
entryId: p.entryId, label: p.label, date: p.date, country: p.country, excluded: false,
|
||||
})),
|
||||
...(stats.excluded ?? []).map(x => ({
|
||||
entryId: x.entryId, label: x.label, date: x.date, country: null, excluded: true,
|
||||
})),
|
||||
].sort((a, b) => {
|
||||
if (a.date !== b.date) {
|
||||
if (a.date == null) return 1
|
||||
if (b.date == null) return -1
|
||||
return a.date < b.date ? -1 : 1
|
||||
}
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
const excludedCount = stops.filter(s => s.excluded).length
|
||||
/** Whether the country is worth a badge, or the same word on every row. */
|
||||
const manyCountries = stats.countries.length > 1
|
||||
|
||||
/**
|
||||
* The day of a stop, without the weekday.
|
||||
*
|
||||
* `formatDate` is the journal's format and carries "Tue," because a journal
|
||||
* entry is about a day somebody lived. A row here is about a leg of a route,
|
||||
* and it has 236px to hold a place name in as well: the weekday costs a
|
||||
* third of the badge and answers nothing anybody asks of this list.
|
||||
*/
|
||||
const stopDay = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
|
||||
const toggleStop = async (entryId: number, excluded: boolean) => {
|
||||
setPending(prev => new Set(prev).add(entryId))
|
||||
// A refusal is the hook's to report; here it only frees the chip again.
|
||||
await onToggleStop(entryId, excluded)
|
||||
setPending(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(entryId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
/* Satellite first: the recommendation should not be the second thing read. */
|
||||
const imagery = sources
|
||||
.filter(s => s.id === 'relief' || s.id === 'satellite')
|
||||
@@ -383,6 +464,83 @@ export function StudioTravelPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
The switch is here, beside the maps and the figures it changes, and
|
||||
not only in the entry editor: the moment somebody notices the home
|
||||
airport on the printed route is the moment they are looking at this
|
||||
panel. The rows carry no preview because they are not things to
|
||||
place; they are the input every tile below reads.
|
||||
*/}
|
||||
{stops.length > 0 && (
|
||||
<Section
|
||||
label={t('journey.studio.stops')}
|
||||
hint={t('journey.studio.stopsHint')}
|
||||
/*
|
||||
* Folded to begin with, and saying how many count while it is.
|
||||
*
|
||||
* A fortnight is fourteen rows, which is longer than everything
|
||||
* else in this panel put together, and most books never need to
|
||||
* touch it. The figure in the head is what anyone opening it came
|
||||
* to check anyway, so the closed section still answers the
|
||||
* question and the maps below stay in reach.
|
||||
*/
|
||||
defaultOpen={false}
|
||||
badge={`${stops.length - excludedCount}/${stops.length}`}
|
||||
>
|
||||
<ul className="st-stops">
|
||||
{stops.map((stop, i) => {
|
||||
const entryId = stop.entryId
|
||||
const on = !stop.excluded
|
||||
const state = t(on ? 'journey.studio.stopOn' : 'journey.studio.stopOff')
|
||||
const chip = `st-chip is-pick is-icon ${on ? 'is-on' : ''}`
|
||||
/*
|
||||
* A mark rather than the word.
|
||||
*
|
||||
* "Counts" and "Left out" are different lengths, so a column of
|
||||
* them jitters, and together they cost a third of a row that
|
||||
* has a place name to fit. The tick and the crossed-out route
|
||||
* say the same thing at a glance, and the word is still there
|
||||
* for anyone who hovers or reads with a screen reader.
|
||||
*/
|
||||
const mark = on ? <Check size={13} strokeWidth={2.4} /> : <RouteOff size={13} strokeWidth={2.2} />
|
||||
return (
|
||||
<li key={entryId ?? `place-${i}`} className={`st-stop ${on ? '' : 'is-off'}`}>
|
||||
{/*
|
||||
One line: the day, then the name, then the switch. The
|
||||
name and the day were stacked, which doubled the height
|
||||
of a list whose whole job is being read down.
|
||||
*/}
|
||||
{stop.date && <span className="st-badge">{stopDay(stop.date)}</span>}
|
||||
<span className="st-stop-label">{stop.label || t('journey.studio.untitled')}</span>
|
||||
{/*
|
||||
The country only where there is more than one to tell
|
||||
apart. On a journey inside one country it is the same
|
||||
word fourteen times, in the row that has least room.
|
||||
*/}
|
||||
{manyCountries && stop.country && (
|
||||
<span className="st-badge is-quiet">{countryName(stop.country)}</span>
|
||||
)}
|
||||
{canEdit && entryId != null ? (
|
||||
<button type="button"
|
||||
className={chip}
|
||||
title={`${state}: ${t('journey.studio.stopToggle')}`}
|
||||
aria-label={t('journey.studio.stopToggle')}
|
||||
aria-pressed={on}
|
||||
disabled={pending.has(entryId)}
|
||||
onClick={() => void toggleStop(entryId, !stop.excluded)}
|
||||
>
|
||||
{mark}
|
||||
</button>
|
||||
) : (
|
||||
<span className={chip} title={state} aria-label={state}>{mark}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="st-section">
|
||||
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
|
||||
{stats.points.length ? (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The @font-face rules behind the book's typefaces.
|
||||
*
|
||||
* bookFonts.ts names the seven families; this is where the faces they need
|
||||
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
|
||||
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
|
||||
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
|
||||
* they are down here rather than up there.
|
||||
*
|
||||
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
|
||||
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
|
||||
* cost the app's first paint nothing. Once that chunk is loaded they are
|
||||
* ordinary stylesheets on the document, which is what lets printSheets copy
|
||||
* them into the print frame with everything else.
|
||||
*
|
||||
* Upright only, as everywhere else in the app: the editor's italic toggle has
|
||||
* always been the browser's synthetic slant, and a second file per weight is a
|
||||
* lot of download to change that.
|
||||
*/
|
||||
import '@fontsource/inter/400.css'
|
||||
import '@fontsource/inter/500.css'
|
||||
import '@fontsource/inter/600.css'
|
||||
import '@fontsource/inter/700.css'
|
||||
|
||||
import '@fontsource/lora/400.css'
|
||||
import '@fontsource/lora/500.css'
|
||||
import '@fontsource/lora/600.css'
|
||||
import '@fontsource/lora/700.css'
|
||||
|
||||
import '@fontsource/eb-garamond/400.css'
|
||||
import '@fontsource/eb-garamond/500.css'
|
||||
import '@fontsource/eb-garamond/600.css'
|
||||
import '@fontsource/eb-garamond/700.css'
|
||||
|
||||
import '@fontsource/playfair-display/400.css'
|
||||
import '@fontsource/playfair-display/500.css'
|
||||
import '@fontsource/playfair-display/600.css'
|
||||
import '@fontsource/playfair-display/700.css'
|
||||
|
||||
import '@fontsource/museomoderno/500.css'
|
||||
import '@fontsource/museomoderno/600.css'
|
||||
|
||||
import '@fontsource/bebas-neue/400.css'
|
||||
@@ -13,6 +13,10 @@
|
||||
* every stack ends in a generic rather than in another named font nobody
|
||||
* shipped. What the editor draws is what the renderer has.
|
||||
*
|
||||
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
|
||||
* here and not imported there falls back to exactly the system font this file
|
||||
* was written against, which is how five of the seven shipped (#2183).
|
||||
*
|
||||
* ── Why these seven ───────────────────────────────────────────────────────
|
||||
*
|
||||
* A photo book needs more than one voice and fewer than a menu. Two workhorse
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Ids for the things a book is made of.
|
||||
*
|
||||
* Every panel that places an element mints one, and they were four copies of
|
||||
* the same line: a prefix, `Math.random().toString(36)` and a slice. Copies of
|
||||
* a line drift, and this one had already drifted in its slice length.
|
||||
*
|
||||
* `getRandomValues` rather than `Math.random`, for the reason
|
||||
* `utils/randomId.ts` gives at greater length: TREK's own quickstart is plain
|
||||
* http on a LAN address, where `crypto.randomUUID` is undefined but
|
||||
* `getRandomValues` still works. A book document is shared over a websocket and
|
||||
* merged by whoever saves last, so two editors minting the same id for two
|
||||
* different elements is a collision that shows up as one of them losing their
|
||||
* work. Short of Web Crypto entirely, the old arithmetic is still there.
|
||||
*
|
||||
* Seven characters, as before: 36^7 is about 78 billion, against a document
|
||||
* that holds a few hundred elements.
|
||||
*/
|
||||
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
export function elementId(prefix: string): string {
|
||||
const c = globalThis.crypto
|
||||
if (c && typeof c.getRandomValues === 'function') {
|
||||
const bytes = new Uint8Array(7)
|
||||
c.getRandomValues(bytes)
|
||||
let out = ''
|
||||
for (const b of bytes) out += ALPHABET[b % ALPHABET.length]
|
||||
return `${prefix}-${out}`
|
||||
}
|
||||
return `${prefix}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
@@ -6,19 +6,9 @@
|
||||
* exactly the kind of file you edit constantly while designing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The book's typefaces.
|
||||
*
|
||||
* Poppins ships with the client already; Georgia is a system serif. Print will
|
||||
* need these self-hosted and identical on the server, or the renderer silently
|
||||
* substitutes — which is what the current Journey PDF export does today, asking
|
||||
* for Inter while only Poppins is bundled.
|
||||
*/
|
||||
export const FONT_STACKS: Record<string, string> = {
|
||||
sans: '"Poppins", system-ui, sans-serif',
|
||||
serif: 'Georgia, "Times New Roman", serif',
|
||||
display: '"MuseoModerno", "Poppins", system-ui, sans-serif',
|
||||
}
|
||||
// The typefaces live in bookFonts.ts (fontStack et al.) — a second stack map
|
||||
// here is exactly how #2183 happened: the renderer kept a three-font copy
|
||||
// while the picker grew to seven, and everything else fell back to Poppins.
|
||||
|
||||
export function photoSrc(photoId: number, big: boolean): string {
|
||||
return `/api/photos/${photoId}/${big ? 'original' : 'thumbnail'}`
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { JourneySource } from './StudioSidebar'
|
||||
|
||||
/**
|
||||
* Which of the journey's pictures the content browser shows.
|
||||
*
|
||||
* `recent` holds ids rather than a flag on the photo: what just arrived is a
|
||||
* fact about this sitting, not about the picture, and the journey store has no
|
||||
* reason to remember it.
|
||||
*/
|
||||
export type PhotoFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'entry'; id: number }
|
||||
| { kind: 'loose' }
|
||||
| { kind: 'recent'; photoIds: number[] }
|
||||
|
||||
type Photo = JourneySource['photos'][number]
|
||||
|
||||
/**
|
||||
* The pictures a filter admits, before anything is searched.
|
||||
*
|
||||
* Which entry a picture belongs to is read off the journal's own junction
|
||||
* rather than off its words, which is what lets "not in an entry" mean exactly
|
||||
* that. Pure, and out here rather than inside the panel, because these four
|
||||
* rules are the feature and a rendered component is an awkward place to prove
|
||||
* them right.
|
||||
*/
|
||||
export function photosFor(source: JourneySource, filter: PhotoFilter): Photo[] {
|
||||
switch (filter.kind) {
|
||||
case 'entry':
|
||||
return source.photos.filter(p => p.entryIds.includes(filter.id))
|
||||
case 'loose':
|
||||
return source.photos.filter(p => !p.entryIds.length)
|
||||
case 'recent': {
|
||||
// In the order they were sent, which is the order they were chosen in,
|
||||
// not wherever the gallery sorted them.
|
||||
const byId = new Map(source.photos.map(p => [p.photoId, p] as const))
|
||||
return filter.photoIds.flatMap(id => {
|
||||
const photo = byId.get(id)
|
||||
return photo ? [photo] : []
|
||||
})
|
||||
}
|
||||
default:
|
||||
return source.photos
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The words, over what the filter left.
|
||||
*
|
||||
* A photo matches on its own caption and on the entry it belongs to: most
|
||||
* photos carry no words at all, so matching only captions would make the
|
||||
* search box look broken on exactly the journeys that need it.
|
||||
*/
|
||||
export function matchingPhotos(photos: Photo[], source: JourneySource, query: string): Photo[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return photos
|
||||
return photos.filter(p =>
|
||||
(p.caption && p.caption.toLowerCase().includes(q))
|
||||
|| (source.photoEntries[p.photoId] || '').includes(q))
|
||||
}
|
||||
|
||||
/** The entries the words admit, for the other tab. */
|
||||
export function matchingEntries(source: JourneySource, query: string): JourneySource['entries'] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return source.entries
|
||||
return source.entries.filter(e =>
|
||||
[e.title, e.story, e.location, ...e.pros, ...e.cons]
|
||||
.some(v => v && v.toLowerCase().includes(q)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the grid is empty, which is four different answers.
|
||||
*
|
||||
* "No photos" on a journey with three hundred of them, because the filter is
|
||||
* on a day that has none, reads as the pictures having gone.
|
||||
*/
|
||||
export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string {
|
||||
if (!source.photos.length) return 'journey.studio.noPhotos'
|
||||
if (admitted.length) return 'journey.studio.noMatches'
|
||||
if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos'
|
||||
if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos'
|
||||
return 'journey.studio.noMatches'
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import type { UploadProgress } from '../../utils/uploadQueue'
|
||||
|
||||
export interface StudioUploadOutcome {
|
||||
/** trek_photos ids of what arrived, in the order it was sent. */
|
||||
photoIds: number[]
|
||||
failed: number
|
||||
/** Left out before anything was sent, see below. */
|
||||
skippedVideos: number
|
||||
}
|
||||
|
||||
export type StudioUploader = (
|
||||
files: File[],
|
||||
entryId: number | null,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
) => Promise<StudioUploadOutcome>
|
||||
|
||||
/**
|
||||
* Pictures into the journey without leaving Studio.
|
||||
*
|
||||
* Into an entry when one is named, into the gallery otherwise. The journey
|
||||
* store is what changes either way, so the content browser sees the new
|
||||
* pictures the way it sees any other; there is no second list to keep in
|
||||
* step. HEIC is converted first, exactly as the journal's own upload does it.
|
||||
*
|
||||
* Videos are left out rather than refused. A drop that carries a clip usually
|
||||
* carries pictures too, and failing the lot over one file would teach the
|
||||
* wrong lesson; a book holds pictures, and the count of what was skipped goes
|
||||
* back so the panel can say so.
|
||||
*/
|
||||
export async function uploadStudioPhotos(
|
||||
journeyId: number,
|
||||
files: File[],
|
||||
entryId: number | null,
|
||||
onProgress?: (p: UploadProgress) => void,
|
||||
): Promise<StudioUploadOutcome> {
|
||||
const images = files.filter(f => !isVideoFile(f))
|
||||
const skippedVideos = files.length - images.length
|
||||
if (!images.length) return { photoIds: [], failed: 0, skippedVideos }
|
||||
|
||||
const normalized = await normalizeImageFiles(images)
|
||||
const store = useJourneyStore.getState()
|
||||
const sent = entryId != null
|
||||
? await store.uploadPhotos(entryId, normalized, { onProgress })
|
||||
: await store.uploadGalleryPhotos(journeyId, normalized, { onProgress })
|
||||
|
||||
return {
|
||||
photoIds: sent.succeeded.map(p => p.photo_id),
|
||||
failed: sent.failed.length,
|
||||
skippedVideos,
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,24 @@ describe('TodoListPanel', () => {
|
||||
expect(html.indexOf('High Prio')).toBeLessThan(html.indexOf('Low Prio'));
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-073: Sort by due date puts the nearest deadline first, undated tasks last (#2205)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = [
|
||||
buildTodoItem({ name: 'Due Later', due_date: '2099-07-17', checked: 0 }),
|
||||
buildTodoItem({ name: 'Due Tomorrow', due_date: '2099-07-16', checked: 0 }),
|
||||
buildTodoItem({ name: 'No Due Date', checked: 0 }),
|
||||
];
|
||||
render(<TodoListPanel tripId={1} items={items} />);
|
||||
const sortBtn = screen.getAllByRole('button').find(
|
||||
b => b.textContent?.includes('Due date') || b.getAttribute('title') === 'Due date'
|
||||
);
|
||||
expect(sortBtn).toBeTruthy();
|
||||
await user.click(sortBtn!);
|
||||
const html = document.body.innerHTML;
|
||||
expect(html.indexOf('Due Tomorrow')).toBeLessThan(html.indexOf('Due Later'));
|
||||
expect(html.indexOf('Due Later')).toBeLessThan(html.indexOf('No Due Date'));
|
||||
});
|
||||
|
||||
it('FE-COMP-TODO-019: Detail pane shows task name and allows editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
const items = [buildTodoItem({ id: 11, name: 'Edit Me', checked: 0 })];
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
const {
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
members, categories, today, filtered, selectedItem,
|
||||
totalCount, doneCount, overdueCount, myCount,
|
||||
@@ -89,7 +89,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
// full item order so unfiltered tasks keep their place.
|
||||
const [dragId, setDragId] = useState<number | null>(null)
|
||||
const [overId, setOverId] = useState<number | null>(null)
|
||||
const canReorder = canEdit && !sortByPrio
|
||||
const canReorder = canEdit && !sortByPrio && !sortByDue
|
||||
|
||||
const handleReorderDrop = (targetId: number) => {
|
||||
const from = dragId
|
||||
@@ -162,7 +162,7 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('todo.sidebar.sortBy')}
|
||||
</div>}
|
||||
<button type="button" onClick={() => setSortByPrio(v => !v)}
|
||||
<button type="button" onClick={() => { setSortByDue(false); setSortByPrio(v => !v) }}
|
||||
title={isMobile ? t('todo.priority') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
@@ -177,6 +177,21 @@ export default function TodoListPanel({ tripId, items, addItemSignal = 0 }: { tr
|
||||
<Flag size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.priority')}</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setSortByPrio(false); setSortByDue(v => !v) }}
|
||||
title={isMobile ? t('todo.detail.dueDate') : undefined}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: isMobile ? 'center' : 'flex-start',
|
||||
gap: isMobile ? 0 : 8, width: '100%', padding: isMobile ? '8px 0' : '7px 12px',
|
||||
border: 'none', borderRadius: 8, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
background: sortByDue ? '#f59e0b12' : 'transparent',
|
||||
color: sortByDue ? '#f59e0b' : 'var(--text-secondary)',
|
||||
fontWeight: sortByDue ? 600 : 400, transition: 'all 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!sortByDue) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!sortByDue) e.currentTarget.style.background = 'transparent' }}>
|
||||
<Calendar size={isMobile ? 18 : 15} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
{!isMobile && <span style={{ flex: 1, textAlign: 'left' }}>{t('todo.detail.dueDate')}</span>}
|
||||
</button>
|
||||
|
||||
{/* Categories */}
|
||||
{!isMobile && <div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', padding: '16px 12px 4px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-W4TDR-001 to FE-W4TDR-020
|
||||
// FE-W4TDR-001 to FE-W4TDR-022
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { TodoItem } from '../../types'
|
||||
@@ -207,4 +207,25 @@ describe('TodoRow', () => {
|
||||
)
|
||||
expect((container.firstElementChild as HTMLElement).style.boxShadow).toBe('inset 3px 0 0 0 var(--accent)')
|
||||
})
|
||||
|
||||
it('FE-W4TDR-021: the row opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active
|
||||
// scale on the row-wide role="button" shifted the checkbox out from under
|
||||
// the pointer, so the click retargeted onto the row and opened the detail
|
||||
// pane instead of toggling. The data-no-press attribute is the pin.
|
||||
const { container } = setup(todo())
|
||||
|
||||
expect(container.firstElementChild).toHaveAttribute('data-no-press')
|
||||
})
|
||||
|
||||
it('FE-W4TDR-022: the checkbox toggles without selecting or starting a drag', () => {
|
||||
const drag = dragHandlers()
|
||||
const { onToggle, onSelect, container } = setup(todo(), { drag })
|
||||
|
||||
fireEvent.click(container.querySelector('button')!)
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith(10, true)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(drag.onStart).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,10 @@ export default function TodoRow({ item, members, categories, today, isSelected,
|
||||
// checkbox and its drag handle answer for themselves, hence the key handler
|
||||
// only fires when the row itself has focus.
|
||||
role="button"
|
||||
// Opt out of the global :active press-scale: shrinking the row-wide button
|
||||
// slides the checkbox out from under the pointer mid-click, so the click
|
||||
// retargets onto the row and opens the detail pane instead (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(isSelected ? null : item.id)}
|
||||
onKeyDown={e => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
lastHandledAddSignal.current = addItemSignal
|
||||
}, [addItemSignal])
|
||||
const [sortByPrio, setSortByPrio] = useState(false)
|
||||
const [sortByDue, setSortByDue] = useState(false)
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
@@ -86,8 +87,15 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
const bp = b.priority || 99
|
||||
return ap - bp
|
||||
})
|
||||
// Nearest deadline first, undated tasks after all dated ones; ties keep the
|
||||
// manual order, the stable sort leaves them untouched (#2205).
|
||||
else if (sortByDue) result = [...result].sort((a, b) => {
|
||||
if (!a.due_date) return b.due_date ? 1 : 0
|
||||
if (!b.due_date) return -1
|
||||
return a.due_date < b.due_date ? -1 : a.due_date > b.due_date ? 1 : 0
|
||||
})
|
||||
return result
|
||||
}, [items, filter, currentUserId, today, sortByPrio])
|
||||
}, [items, filter, currentUserId, today, sortByPrio, sortByDue])
|
||||
|
||||
const selectedItem = items.find(i => i.id === selectedId) || null
|
||||
const totalCount = items.length
|
||||
@@ -109,7 +117,7 @@ export function useTodoList(tripId: number, items: TodoItem[], addItemSignal: nu
|
||||
return {
|
||||
canEdit, t, formatDate, toggleTodoItem, reorderTodoItems,
|
||||
isMobile, filter, setFilter, selectedId, setSelectedId,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio,
|
||||
isAddingNew, setIsAddingNew, sortByPrio, setSortByPrio, sortByDue, setSortByDue,
|
||||
addingCategory, setAddingCategory, newCategoryName, setNewCategoryName,
|
||||
members, categories, today, filtered, selectedItem,
|
||||
totalCount, doneCount, overdueCount, myCount,
|
||||
|
||||
@@ -141,4 +141,15 @@ describe('VacaySharedCalendars', () => {
|
||||
|
||||
expect(shareWithMock).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-VACAYSHARED-008: Incoming row opts out of the global press-scale (#2158)', () => {
|
||||
// jsdom cannot replay the browser mechanics behind #2158: the :active scale on
|
||||
// the row shifted the remove X out from under the pointer, so the click retargeted
|
||||
// onto the row and only toggled visibility. The data-no-press attribute is the pin.
|
||||
seedShares({ incomingShares: [incoming] })
|
||||
|
||||
render(<VacaySharedCalendars />)
|
||||
|
||||
expect(screen.getByText('Carol').closest('[role="button"]')).toHaveAttribute('data-no-press')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,10 @@ export default function VacaySharedCalendars() {
|
||||
{incomingShares.map(s => (
|
||||
<div key={s.id}
|
||||
role="button"
|
||||
// No press-scale on the row: shrinking it mid-click slides the remove X
|
||||
// out from under the pointer, so the click retargets onto the row and
|
||||
// toggles visibility instead of removing the share (#2158).
|
||||
data-no-press
|
||||
tabIndex={0}
|
||||
onClick={() => handleToggleHidden(s.id, !s.hidden)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleToggleHidden(s.id, !s.hidden) } }}
|
||||
|
||||
@@ -113,9 +113,9 @@ describe('WeatherWidget', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-WEATHERWIDGET-010: uses cached data from sessionStorage', async () => {
|
||||
it('FE-COMP-WEATHERWIDGET-010: uses fresh cached data from sessionStorage without refetching', async () => {
|
||||
const cached = buildWeather({ temp: 20 })
|
||||
sessionStorage.setItem('weather_48.86_2.35_2025-06-01', JSON.stringify(cached))
|
||||
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: cached, fetchedAt: Date.now() }))
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
await waitFor(() => {
|
||||
@@ -127,7 +127,7 @@ describe('WeatherWidget', () => {
|
||||
it('FE-COMP-WEATHERWIDGET-011: re-fetches in background for cached climate data', async () => {
|
||||
const climateData = buildWeather({ temp: 15, main: 'Clouds', type: 'climate', description: 'cloudy' })
|
||||
const forecastData = buildWeather({ temp: 22, main: 'Clear', type: 'forecast', description: 'clear sky' })
|
||||
sessionStorage.setItem('weather_48.86_2.35_2025-06-01', JSON.stringify(climateData))
|
||||
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify({ data: climateData, fetchedAt: Date.now() }))
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(forecastData)
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
|
||||
@@ -144,4 +144,109 @@ describe('WeatherWidget', () => {
|
||||
})
|
||||
expect(screen.queryByText(/Ø/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// #2167 — a standalone PWA keeps sessionStorage alive across days, so a
|
||||
// forecast fetched before the trip was still shown unchanged on the day itself.
|
||||
it('FE-COMP-WEATHERWIDGET-012: revalidates a forecast cache entry older than the TTL', async () => {
|
||||
const staleData = buildWeather({ temp: 20 })
|
||||
const freshData = buildWeather({ temp: 22 })
|
||||
sessionStorage.setItem(
|
||||
'weather_48.86_2.35_2025-06-01_en',
|
||||
JSON.stringify({ data: staleData, fetchedAt: Date.now() - 2 * 60 * 60 * 1000 }),
|
||||
)
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(freshData)
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
|
||||
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
|
||||
// Serve-then-revalidate: the stale value renders immediately …
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/20°C|22°C/)).toBeInTheDocument()
|
||||
})
|
||||
// … and the background refresh replaces it and the cache entry.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('22°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
||||
const stored = JSON.parse(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_en')!)
|
||||
expect(stored.data.temp).toBe(22)
|
||||
expect(typeof stored.fetchedAt).toBe('number')
|
||||
})
|
||||
|
||||
it('FE-COMP-WEATHERWIDGET-013: treats a pre-TTL raw cache entry as stale and refreshes it', async () => {
|
||||
// Live PWA sessions still hold entries in the old bare-result format.
|
||||
sessionStorage.setItem('weather_48.86_2.35_2025-06-01_en', JSON.stringify(buildWeather({ temp: 18 })))
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 25 }))
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
|
||||
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('25°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// #2167 (mrwulf) — the widget must be able to say WHERE the forecast is for.
|
||||
it('FE-COMP-WEATHERWIDGET-014: renders locationName as a tooltip title', async () => {
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" locationName="Rome" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('20°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTitle('Rome')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WEATHERWIDGET-015: sends the user language and keys the cache by it', async () => {
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 20 }))
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius', language: 'de' } })
|
||||
render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('20°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(weatherApi.get).toHaveBeenCalledWith(48.86, 2.35, '2025-06-01', 'de')
|
||||
expect(sessionStorage.getItem('weather_48.86_2.35_2025-06-01_de')).not.toBeNull()
|
||||
})
|
||||
|
||||
// The day-local anchor (#2167) moves whenever the day is edited, so a single
|
||||
// failed lookup must not keep the badge on the dash for the widget's lifetime.
|
||||
it('FE-COMP-WEATHERWIDGET-016: clears the error dash once a new anchor resolves', async () => {
|
||||
vi.mocked(weatherApi.get).mockRejectedValueOnce(new Error('Network error'))
|
||||
vi.mocked(weatherApi.get).mockResolvedValue(buildWeather({ temp: 21 }))
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
|
||||
const { rerender } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
rerender(<WeatherWidget lat={41.9} lng={12.5} date="2025-06-01" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('21°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText('—')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-WEATHERWIDGET-017: clears the error dash when the new anchor comes from cache', async () => {
|
||||
vi.mocked(weatherApi.get).mockRejectedValue(new Error('Network error'))
|
||||
sessionStorage.setItem(
|
||||
'weather_41.9_12.5_2025-06-01_en',
|
||||
JSON.stringify({ data: buildWeather({ temp: 24 }), fetchedAt: Date.now() }),
|
||||
)
|
||||
useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, temperature_unit: 'celsius' } })
|
||||
|
||||
const { rerender } = render(<WeatherWidget lat={48.86} lng={2.35} date="2025-06-01" />)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
rerender(<WeatherWidget lat={41.9} lng={12.5} date="2025-06-01" />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('24°C')).toBeInTheDocument()
|
||||
})
|
||||
expect(weatherApi.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,11 @@ function setWeatherCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
// Matches the server's own forecast TTL. In a standalone PWA sessionStorage lives
|
||||
// as long as the app process, so without a TTL a forecast fetched days before the
|
||||
// trip day was still shown unchanged on the day itself (#2167).
|
||||
const WEATHER_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
@@ -44,53 +49,74 @@ interface WeatherWidgetProps {
|
||||
compact?: boolean
|
||||
/** Vertical icon-over-temp layout that inherits its color (for the day badge). */
|
||||
stacked?: boolean
|
||||
/** Name of the place the forecast is anchored to — surfaces as a tooltip (#2167). */
|
||||
locationName?: string | null
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) {
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false, locationName = null }: WeatherWidgetProps) {
|
||||
const [weather, setWeather] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const language = useSettingsStore(s => s.settings.language) || 'en'
|
||||
|
||||
useEffect(() => {
|
||||
if (!lat || !lng || !date) return
|
||||
let cancelled = false
|
||||
// Every anchor, date or language change is a fresh attempt, so a failure from
|
||||
// the previous one must not outlive it. Since #2167 the anchor is day-local and
|
||||
// moves with each edit, which would otherwise pin the badge on the error dash.
|
||||
setFailed(false)
|
||||
const rLat = Math.round(lat * 100) / 100
|
||||
const rLng = Math.round(lng * 100) / 100
|
||||
const cacheKey = `weather_${rLat}_${rLng}_${date}`
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached === null) setFailed(true)
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
} else {
|
||||
setWeather(cached)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
} else {
|
||||
setWeatherCache(cacheKey, data)
|
||||
// The language is part of the key: descriptions come back localized, and the
|
||||
// server caches per language too — a lang-less key would re-mix them here.
|
||||
const cacheKey = `weather_${rLat}_${rLng}_${date}_${language}`
|
||||
|
||||
const revalidate = (acceptOnlyForecast) => {
|
||||
fetchWeather(lat, lng, date, language)
|
||||
.then(data => {
|
||||
if (cancelled || data.error || data.temp === undefined) return
|
||||
if (acceptOnlyForecast && data.type !== 'forecast') return
|
||||
setWeatherCache(cacheKey, { data, fetchedAt: Date.now() })
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { setFailed(true) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [lat, lng, date])
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined && cached !== null) {
|
||||
// Entries written before the TTL landed are the bare result — treat as stale.
|
||||
const entry = cached.data !== undefined && typeof cached.fetchedAt === 'number'
|
||||
? cached
|
||||
: { data: cached, fetchedAt: 0 }
|
||||
setWeather(entry.data)
|
||||
if (entry.data?.type === 'climate') {
|
||||
// Climate data: shown from cache, but re-fetched to upgrade to a forecast.
|
||||
revalidate(true)
|
||||
} else if (Date.now() - entry.fetchedAt > WEATHER_TTL_MS) {
|
||||
// Stale forecast: serve it, then refresh in the background.
|
||||
revalidate(false)
|
||||
}
|
||||
} else if (cached === null) {
|
||||
setFailed(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
fetchWeather(lat, lng, date, language)
|
||||
.then(data => {
|
||||
if (cancelled) return
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
} else {
|
||||
setWeatherCache(cacheKey, { data, fetchedAt: Date.now() })
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!cancelled) setFailed(true) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [lat, lng, date, language])
|
||||
|
||||
if (!lat || !lng) return null
|
||||
|
||||
@@ -113,9 +139,13 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const isClimate = weather.type === 'climate'
|
||||
|
||||
// The forecast's anchor place, as a tooltip on every variant — no layout change,
|
||||
// but the widget finally says WHERE the weather is for (#2167).
|
||||
const title = locationName || undefined
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||
<div title={title} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 'calc(9.5px * var(--fs-scale-caption, 1))', fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={13} />
|
||||
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
||||
</div>
|
||||
@@ -124,7 +154,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
<span title={title} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={12} />
|
||||
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
</span>
|
||||
@@ -132,7 +162,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false, stacked
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<div title={title} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: isClimate ? '#71717a' : '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={15} />
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
{weather.description && <span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-014
|
||||
//
|
||||
// #1513: tapping a pre-populated numeric field put the caret at the end, so the first
|
||||
// digit typed was appended rather than replacing the value — quantity 1 + "6" = "16".
|
||||
@@ -16,9 +16,9 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) {
|
||||
function Harness({ initial, mode, signToggleLabel }: { initial: string; mode?: NumericMode; signToggleLabel?: string }) {
|
||||
const [v, setV] = useState(initial)
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} aria-label="field" />
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} signToggleLabel={signToggleLabel} aria-label="field" />
|
||||
}
|
||||
|
||||
describe('NumericInput', () => {
|
||||
@@ -91,6 +91,71 @@ describe('NumericInput', () => {
|
||||
expect(input.value).toBe('36.72')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-010: signed-decimal mode keeps a leading minus and the comma (#2176)', () => {
|
||||
render(<Harness initial="" mode="signed-decimal" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
|
||||
// A refund typed on a European keypad: sign and comma both survive.
|
||||
fireEvent.change(input, { target: { value: '-12,50' } })
|
||||
expect(input.value).toBe('-12,50')
|
||||
|
||||
fireEvent.change(input, { target: { value: '-100' } })
|
||||
expect(input.value).toBe('-100')
|
||||
|
||||
// A minus anywhere but the front is not a sign.
|
||||
fireEvent.change(input, { target: { value: '12-50' } })
|
||||
expect(input.value).toBe('1250')
|
||||
|
||||
fireEvent.change(input, { target: { value: '-1x2.5eur' } })
|
||||
expect(input.value).toBe('-12.5')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-011: signed-decimal mode uses the decimal keypad', () => {
|
||||
render(<Harness initial="" mode="signed-decimal" />)
|
||||
expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-012: the sign toggle flips the sign of what is already there (#2176)', () => {
|
||||
// iOS renders inputMode="decimal" as a pad of digits, separator and backspace, with
|
||||
// no minus key, so a refund is untypeable on a phone without this button.
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
const toggle = screen.getByRole('button', { name: 'Toggle sign' })
|
||||
|
||||
fireEvent.change(input, { target: { value: '12,50' } })
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('-12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
// The button must not take the focus, or the phone keypad closes mid-entry.
|
||||
expect(fireEvent.mouseDown(toggle)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-013: an empty field toggles to a bare minus so the digits follow', () => {
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle sign' }))
|
||||
expect(input.value).toBe('-')
|
||||
|
||||
// The caret stays in the field, so the next keystroke appends to the sign.
|
||||
fireEvent.change(input, { target: { value: '-1' } })
|
||||
expect(input.value).toBe('-1')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-014: there is no toggle without a label, and none on an unsigned field', () => {
|
||||
const { unmount } = render(<Harness initial="" mode="signed-decimal" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
unmount()
|
||||
|
||||
render(<Harness initial="" mode="decimal" signToggleLabel="Toggle sign" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => {
|
||||
// The deferred select() exists for WebKit, but it must not fire *after* the user has
|
||||
// started typing: it would select the half-typed value and the next character would
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, type InputHTMLAttributes, type Ref } from 'react'
|
||||
|
||||
export type NumericMode = 'integer' | 'decimal' | 'signed'
|
||||
export type NumericMode = 'integer' | 'decimal' | 'signed' | 'signed-decimal'
|
||||
|
||||
const SANITIZERS: Record<NumericMode, (raw: string) => string> = {
|
||||
// Digits only — quantities, weights, day counts.
|
||||
@@ -14,8 +14,18 @@ const SANITIZERS: Record<NumericMode, (raw: string) => string> = {
|
||||
const digits = raw.replace(/[^0-9.]/g, '')
|
||||
return negative ? `-${digits}` : digits
|
||||
},
|
||||
// 'decimal' plus a leading '-' — money fields that accept a refund (#2176).
|
||||
// Distinct from 'signed' on purpose: that one strips the comma, which a
|
||||
// European keypad needs for amounts.
|
||||
'signed-decimal': raw => {
|
||||
const negative = raw.trimStart().startsWith('-')
|
||||
const digits = raw.replace(/[^0-9.,]/g, '')
|
||||
return negative ? `-${digits}` : digits
|
||||
},
|
||||
}
|
||||
|
||||
const flipSign = (raw: string) => (raw.startsWith('-') ? raw.slice(1) : `-${raw}`)
|
||||
|
||||
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value'> & {
|
||||
value: string | number | null | undefined
|
||||
/** Receives the sanitized raw string. Callers keep their own state and commit logic. */
|
||||
@@ -23,6 +33,13 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
mode?: NumericMode
|
||||
/** Escape hatch for a field that must not steal the caret (none today). */
|
||||
selectOnFocus?: boolean
|
||||
/**
|
||||
* Accessible name for a sign toggle rendered beside the field. Set it on a signed
|
||||
* money field: iOS draws inputMode="decimal" as a pad with digits, separator and
|
||||
* backspace only, so a refund (#2176) has no minus key to type. Ignored on the
|
||||
* unsigned modes, where there is no sign to flip.
|
||||
*/
|
||||
signToggleLabel?: string
|
||||
ref?: Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
@@ -54,34 +71,54 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
* others on blur. This owns only the part that was uniformly broken.
|
||||
*/
|
||||
export function NumericInput({
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, signToggleLabel, ref, ...rest
|
||||
}: Props) {
|
||||
// Set while a deferred select() is queued; any input in that window cancels it.
|
||||
const selectPending = useRef(false)
|
||||
const raw = String(value ?? '')
|
||||
const signable = mode === 'signed' || mode === 'signed-decimal'
|
||||
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
{signToggleLabel && signable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={signToggleLabel}
|
||||
aria-pressed={raw.startsWith('-')}
|
||||
disabled={rest.disabled}
|
||||
// Keeps the caret (and on a phone the keypad) in the field, so the sign can
|
||||
// be flipped mid-entry and typing carries on where it left off.
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onValueChange(SANITIZERS[mode](flipSign(raw)))}
|
||||
className="text-content-faint"
|
||||
style={{ border: 0, background: 'none', padding: '0 2px', font: 'inherit', lineHeight: 1, cursor: 'pointer' }}
|
||||
>
|
||||
±
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Components, Options } from 'react-markdown'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
|
||||
type HastNode = { type: string; children?: HastNode[] }
|
||||
|
||||
/**
|
||||
* react-markdown never builds an element out of raw HTML, it shows the markup
|
||||
* verbatim instead. It does that after the rehype plugins ran, though, so the
|
||||
* sanitizer gets the raw nodes first and drops them without a trace: a note
|
||||
* reading "Zimmernummer <TBD>" loses the placeholder on screen while the stored
|
||||
* text still has it (#2177). Turning raw into text up front keeps it readable
|
||||
* and still yields no element.
|
||||
*/
|
||||
function rehypeRawAsText() {
|
||||
return (tree: HastNode) => {
|
||||
const walk = (node: HastNode) => {
|
||||
if (node.type === 'raw') node.type = 'text'
|
||||
for (const child of node.children || []) walk(child)
|
||||
}
|
||||
walk(tree)
|
||||
}
|
||||
}
|
||||
|
||||
// remark-gfm already writes footnote ids with the `user-content-` prefix, and
|
||||
// the hrefs pointing at them carry it too. Clobbering them a second time only
|
||||
// makes target and reference disagree.
|
||||
const schema = { ...defaultSchema, clobberPrefix: '' }
|
||||
|
||||
export const sanitizedMarkdownPlugins: NonNullable<Options['rehypePlugins']> = [
|
||||
rehypeRawAsText,
|
||||
[rehypeSanitize, schema],
|
||||
]
|
||||
|
||||
/**
|
||||
* Links as in markdownLinkComponents (#1629): own tab plus `rel` protection.
|
||||
* The exception is a footnote or heading link, which points inside the text
|
||||
* that is already on screen, so opening a tab for it would only reload the trip
|
||||
* somewhere else.
|
||||
*/
|
||||
export const sanitizedMarkdownComponents: Components = {
|
||||
a: ({ children, href, ...rest }) =>
|
||||
href?.startsWith('#') ? (
|
||||
<a {...rest} href={href}>
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<a {...rest} href={href} target="_blank" rel="noopener noreferrer nofollow">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-HOOK-GEO-001 to FE-HOOK-GEO-025
|
||||
// FE-HOOK-GEO-001 to FE-HOOK-GEO-031
|
||||
import { StrictMode } from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation';
|
||||
@@ -54,6 +54,7 @@ describe('useGeolocation', () => {
|
||||
expect(result.current.mode).toBe('off');
|
||||
expect(result.current.position).toBeNull();
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.errorCode).toBeNull();
|
||||
expect(watchPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -305,6 +306,78 @@ describe('useGeolocation', () => {
|
||||
expect(result.current.mode).toBe('show');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[1, 'permission-denied'],
|
||||
[2, 'unavailable'],
|
||||
[3, 'timeout'],
|
||||
])('FE-HOOK-GEO-026: maps watch error code %i to errorCode "%s"', async (code, expected) => {
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
act(() => errorCb!(geoError(code)));
|
||||
|
||||
expect(result.current.errorCode).toBe(expected);
|
||||
expect(result.current.error).toBe('Location unavailable');
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-027: a denial still stops the watch and keeps the code for the UI', async () => {
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
act(() => errorCb!(geoError(1, 'User denied Geolocation')));
|
||||
|
||||
expect(result.current.mode).toBe('off');
|
||||
expect(clearWatch).toHaveBeenCalledWith(7);
|
||||
expect(result.current.errorCode).toBe('permission-denied');
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-028: a missing geolocation API reports the unsupported code', async () => {
|
||||
delete (navigator as { geolocation?: Geolocation }).geolocation;
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
expect(result.current.errorCode).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-029: a successful fix clears both error and errorCode', async () => {
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
act(() => errorCb!(geoError(3, 'Timeout expired')));
|
||||
expect(result.current.error).toBe('Timeout expired');
|
||||
expect(result.current.errorCode).toBe('timeout');
|
||||
|
||||
act(() => successCb!(fix()));
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.errorCode).toBeNull();
|
||||
expect(result.current.position).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-030: an insecure origin is named as such, not as a blocked permission', async () => {
|
||||
// Plain-HTTP self-hosting: the browser would answer PERMISSION_DENIED and the
|
||||
// user would be sent into device settings that are already correct.
|
||||
vi.stubGlobal('isSecureContext', false);
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
expect(watchPosition).not.toHaveBeenCalled();
|
||||
expect(result.current.mode).toBe('off');
|
||||
expect(result.current.errorCode).toBe('insecure-context');
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-031: a secure origin is not mistaken for an insecure one', async () => {
|
||||
vi.stubGlobal('isSecureContext', true);
|
||||
const { result } = renderHook(() => useGeolocation());
|
||||
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
expect(result.current.mode).toBe('show');
|
||||
expect(result.current.errorCode).toBeNull();
|
||||
expect(watchPosition).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => {
|
||||
const { result, unmount } = renderHook(() => useGeolocation());
|
||||
await act(() => result.current.cycleMode());
|
||||
|
||||
@@ -17,10 +17,16 @@ export interface GeoPosition {
|
||||
|
||||
export type TrackingMode = 'off' | 'show' | 'follow'
|
||||
|
||||
// Typed counterpart to the raw error string. The string is whatever the
|
||||
// browser produced (raw WebKit English on iOS), so the UI keys off this
|
||||
// code to show a localized, actionable message instead.
|
||||
export type GeoWatchErrorCode = 'permission-denied' | 'unavailable' | 'timeout' | 'unsupported' | 'insecure-context'
|
||||
|
||||
export interface UseGeolocationReturn {
|
||||
position: GeoPosition | null
|
||||
mode: TrackingMode
|
||||
error: string | null
|
||||
errorCode: GeoWatchErrorCode | null
|
||||
/** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */
|
||||
cycleMode: () => Promise<void>
|
||||
/** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */
|
||||
@@ -101,6 +107,7 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
const modeRef = useRef<TrackingMode>(mode)
|
||||
modeRef.current = mode
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [errorCode, setErrorCode] = useState<GeoWatchErrorCode | null>(null)
|
||||
const watchIdRef = useRef<number | null>(null)
|
||||
// True between the start of startWatch and the watchPosition call it awaits.
|
||||
const startingRef = useRef(false)
|
||||
@@ -129,6 +136,15 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
const startWatch = useCallback(async () => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
setError('Geolocation is not supported in this browser')
|
||||
setErrorCode('unsupported')
|
||||
return false
|
||||
}
|
||||
// Same block getCurrentPositionOnce guards against: on a plain-HTTP origin
|
||||
// the browser answers PERMISSION_DENIED, which would otherwise read as a
|
||||
// revoked device permission and send the user into their OS settings.
|
||||
if (window.isSecureContext === false) {
|
||||
setError('Location requires a secure (HTTPS) connection')
|
||||
setErrorCode('insecure-context')
|
||||
return false
|
||||
}
|
||||
// Already watching, or still waiting on the iOS prompt below: a second
|
||||
@@ -138,6 +154,7 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
startingRef.current = true
|
||||
const run = ++startRunRef.current
|
||||
setError(null)
|
||||
setErrorCode(null)
|
||||
|
||||
// iOS: ask for orientation permission up front; on Android and desktop
|
||||
// no prompt is needed and the method is undefined.
|
||||
@@ -184,6 +201,10 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
startingRef.current = false
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
// A fix arriving means any earlier failure (timeout, no signal)
|
||||
// has recovered, so clear the error state alongside it.
|
||||
setError(null)
|
||||
setErrorCode(null)
|
||||
setPosition({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
@@ -197,6 +218,12 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
},
|
||||
(err) => {
|
||||
setError(err.message || 'Location unavailable')
|
||||
setErrorCode(
|
||||
err.code === err.PERMISSION_DENIED ? 'permission-denied'
|
||||
: err.code === err.POSITION_UNAVAILABLE ? 'unavailable'
|
||||
: err.code === err.TIMEOUT ? 'timeout'
|
||||
: null,
|
||||
)
|
||||
// Stay subscribed so a later fix can still recover (e.g. GPS
|
||||
// lock takes a while indoors). Only fully stop on permission denial.
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
@@ -243,5 +270,5 @@ export function useGeolocation(): UseGeolocationReturn {
|
||||
|
||||
useEffect(() => stopWatch, [stopWatch])
|
||||
|
||||
return { position, mode, error, cycleMode, setMode }
|
||||
return { position, mode, error, errorCode, cycleMode, setMode }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Returns true while the dark palette is active, read off the `.dark` class on
|
||||
* `<html>` — the single source of truth that `applyAppearance()` writes.
|
||||
*
|
||||
* Deriving it from the settings store instead (`dark_mode === 'auto' &&
|
||||
* matchMedia(...)`) duplicates theme state into React and misses an OS-level
|
||||
* switch under `auto`, because nothing re-renders when the media query flips.
|
||||
* The observer sees every path, including the pre-paint replay in
|
||||
* `public/theme-boot.js`.
|
||||
*
|
||||
* Only for the handful of places that need the flag in JS — an `<img src>` that
|
||||
* differs per theme. Colours belong on tokens, not on this hook.
|
||||
*/
|
||||
export function useIsDark(): boolean {
|
||||
const [dark, setDark] = useState<boolean>(
|
||||
() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
|
||||
return dark
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport } from '../utils/dayMerge'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../utils/dayOrder'
|
||||
import { withinDriveRange } from '../utils/geo'
|
||||
import { resolveLegMode } from '../components/Planner/legMode'
|
||||
@@ -174,21 +174,25 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
const lastStop = [...entries].reverse().find(contributes)
|
||||
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
|
||||
if (!e) return undefined
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time }
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
// check-out hotel its origin, which the no-time bookend default respects (#2157).
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
// Same reachability test as the run builder: a hotel is not joined to a point no
|
||||
// one could have driven between.
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'))
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
||||
const runsWithHotel: RunPoint[][] = withHotelBookends(
|
||||
runs,
|
||||
|
||||
+32
-1
@@ -15,7 +15,14 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
reloads the installed PWA. Desktop/tablet keep the rules above. */
|
||||
@media (max-width: 767px) {
|
||||
html { overflow: visible; overscroll-behavior: none; }
|
||||
body { height: auto; overflow: visible; }
|
||||
/* overflow-x clip (not hidden: clip creates no scroll container) so a screen
|
||||
that accidentally overflows sideways cannot widen the document. Android's
|
||||
forced-zoom setting reacts to a wider document by growing the layout
|
||||
viewport that position:fixed resolves against, which pushed the top bar
|
||||
and the dock off the right edge on narrow phones. On body, never on html:
|
||||
body's overflow only propagates to the viewport while html stays visible,
|
||||
and the shared body scroll lock relies on that propagation. */
|
||||
body { height: auto; overflow: visible; overflow-x: clip; }
|
||||
/* Transparent so the shell's own background layer, which sits behind the page
|
||||
on a negative z-index, is not painted over: a negative layer goes above the
|
||||
canvas but below the background of ordinary blocks, and body is one. The
|
||||
@@ -122,8 +129,32 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
||||
font-family: inherit !important;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.25) !important;
|
||||
transition: none !important;
|
||||
max-width: min(280px, calc(100vw - 32px)) !important;
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
}
|
||||
.atlas-tooltip::before { border-top-color: rgba(10, 10, 20, 0.6) !important; }
|
||||
/* Bucket-list marker tooltip only — arbitrary-length user notes need a scroll
|
||||
cap. The scroll happens on .atlas-tooltip-scroll-inner, not this element,
|
||||
so the outer border-radius clips the scrollbar instead of it poking
|
||||
through the rounded corner. overflow-y on the inner div is toggled
|
||||
per-instance in useAtlas.ts, not forced here. */
|
||||
.atlas-tooltip-scrollable {
|
||||
width: min(480px, calc(100vw - 32px)) !important;
|
||||
max-width: min(480px, calc(100vw - 32px)) !important;
|
||||
overflow: hidden !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.atlas-tooltip-scroll-inner {
|
||||
max-height: min(200px, 40vh);
|
||||
overflow-y: hidden;
|
||||
/* Leaflet puts touch-action: none on the map container; without widening it
|
||||
back here a touch swipe over the note can never scroll it (#2153). */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.atlas-tooltip-scroll-inner::-webkit-scrollbar { width: 6px; }
|
||||
.atlas-tooltip-scroll-inner::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 3px; }
|
||||
html:not(.dark) .atlas-tooltip-scroll-inner::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); }
|
||||
html:not(.dark) .atlas-tooltip {
|
||||
background: rgba(255, 255, 255, 0.75) !important;
|
||||
color: #0f172a !important;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user