mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e90ece087 | |||
| 65e5d4ab72 | |||
| 154620b99a | |||
| adbe3a40f6 | |||
| 44c6fbf372 | |||
| 45690ecd14 | |||
| c6149cd64d | |||
| 4587cf2099 | |||
| 4585988dfd | |||
| a37a9c0fa8 | |||
| 0cde099c7f | |||
| e798eb712e | |||
| 0dae908e05 | |||
| 01c7bc9de2 | |||
| 2e683f8609 | |||
| f8e51cb83d | |||
| eedfb08597 | |||
| 0aee0a019e | |||
| 91bc1944dc | |||
| f99b40cd81 | |||
| 59e68d81b2 | |||
| 86513fda0d | |||
| 61378559ca | |||
| 84df69c18a | |||
| 6194a658d9 | |||
| 97188c16d1 | |||
| 247d5fc519 | |||
| 8659262e59 | |||
| 327ebc354f | |||
| 1625a10424 | |||
| 94c2ef35b5 | |||
| bf8e04741d | |||
| 0b598680ec | |||
| 13332cf06b | |||
| e6d52cb061 | |||
| bef8fc20f1 | |||
| 9d2ffc91af | |||
| 10ad64d170 | |||
| 1c6a175ebc | |||
| 0a25de2c6d | |||
| 59a6d92cea | |||
| e0982e708f | |||
| d17457de56 | |||
| 790fccb92c | |||
| e59992d56d | |||
| 382797febc | |||
| a1d9cdc8f2 | |||
| b31666d1c9 | |||
| 19e4f0dadf | |||
| 8fde643347 | |||
| f44dd92ee0 | |||
| db0fbc1ccd | |||
| 095bed2db8 | |||
| fb2429ebb2 | |||
| 29298dcfe5 | |||
| 630f15b5ea | |||
| 04debce79e | |||
| 4003769a5c | |||
| 7faf771f9c | |||
| 51b991f851 | |||
| 8692b8790c | |||
| 1b6a156969 | |||
| f076cb82da | |||
| 073db83421 | |||
| cafc8dd46e |
@@ -14,7 +14,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const badTitles = [
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const { data: pulls } = await github.rest.pulls.list({
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -342,7 +342,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -401,7 +401,7 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Flag or clear wrong base branch
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const base = context.payload.pull_request.base.ref;
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR if checks failed
|
||||
if: steps.checks.outcome == 'failure'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.createComment({
|
||||
|
||||
@@ -74,12 +74,12 @@ jobs:
|
||||
load: true
|
||||
tags: trek:scan
|
||||
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- uses: docker/scout-action@221e7f4860634eeb1579e3bd7ca232e577bd1864 # v1.26.0
|
||||
- uses: docker/scout-action@e7e2a72bf1485d10a58f30696b6c513b261ce4fa # v1.29.0
|
||||
with:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
|
||||
@@ -1100,7 +1100,7 @@ jobs:
|
||||
path: plugin-sdk/coverage/
|
||||
|
||||
- name: Scan
|
||||
uses: SonarSource/sonarqube-scan-action@d209202bc7d53ff1cc128f7f907dac145c9d6ae9 # v8.3.0
|
||||
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
|
||||
env:
|
||||
# SonarQube Cloud is the scan action's default host, so no
|
||||
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
|
||||
@@ -1274,6 +1274,14 @@ jobs:
|
||||
--set httpRoute.enabled=true \
|
||||
--set 'httpRoute.parentRefs[0].name=gateway' \
|
||||
> "$out/http-route.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set podSecurityContext.runAsUser=1000 \
|
||||
--set podSecurityContext.runAsGroup=1000 \
|
||||
--set podSecurityContext.runAsNonRoot=true \
|
||||
--set podSecurityContext.seccompProfile.type=RuntimeDefault \
|
||||
--set securityContext.allowPrivilegeEscalation=false \
|
||||
--set 'securityContext.capabilities.drop[0]=ALL' \
|
||||
> "$out/non-root.yaml"
|
||||
helm template trek charts/trek \
|
||||
--set ingress.enabled=true \
|
||||
--set fullnameOverride=custom-trek \
|
||||
|
||||
@@ -36,7 +36,7 @@ TREK has grown from a travel planner into a whole ecosystem for trips: plan toge
|
||||
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=for-the-badge&label=Stars&color=6B7280" /></a>
|
||||
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
<sub>PUBLIC SERVICES PROUDLY SPONSORED BY</sub><br /><a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><picture><source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" /><source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" /><img src="docs/sponsors/dataforest-dark.svg" alt="dataforest cloud" height="30" /></picture></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -218,13 +218,13 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
<br />
|
||||
|
||||
## Powered by dataforest cloud
|
||||
## Powered by [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek)
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/?mtm_campaign=trek">
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/sponsors/dataforest-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/sponsors/dataforest-dark.svg" />
|
||||
@@ -235,17 +235,17 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
<br />
|
||||
<br />
|
||||
|
||||
**Every TREK installation in the world searches through servers that dataforest cloud sponsors.**
|
||||
**Every TREK installation in the world searches through servers that [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) sponsors.**
|
||||
|
||||
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on dataforest cloud in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
|
||||
The place index with over 88 million places, the address search in every TREK language and the planet-wide routing all run on [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) in Frankfurt, Germany, free of charge for everyone who self-hosts TREK. Without this sponsorship, these services would not exist in this form.
|
||||
|
||||
Thank you, dataforest, for backing open source this generously. 💚
|
||||
Thank you, [dataforest](https://cloud.dataforest.net/en?mtm_campaign=trek), for backing open source this generously. 💚
|
||||
|
||||
<br />
|
||||
|
||||
<a href="https://cloud.dataforest.net/?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
<a href="https://cloud.dataforest.net/en?mtm_campaign=trek"><img alt="Visit dataforest cloud" src="https://img.shields.io/badge/Visit-dataforest_cloud-02C382?style=for-the-badge" /></a>
|
||||
|
||||
Looking for a server for your own TREK? dataforest cloud is where TREK's own services live.
|
||||
Looking for a server for your own TREK? [dataforest cloud](https://cloud.dataforest.net/en?mtm_campaign=trek) is where TREK's own services live.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -277,7 +277,7 @@ Open `http://localhost:3000`, or `http://<server-ip>:3000` from another machine.
|
||||
`COOKIE_SECURE=false` is only for trying TREK over plain HTTP: without it the browser drops the session cookie and the login does not stick. Once TREK sits behind HTTPS (see [Reverse Proxy](#reverse-proxy)), remove it.
|
||||
|
||||
> [!TIP]
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
> **No server yet?** TREK's own public services run on [**dataforest cloud**](https://cloud.dataforest.net/en?mtm_campaign=trek), our infrastructure sponsor. A small seed is plenty for a TREK instance and is ready within a minute.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -56,4 +56,5 @@ See `values.yaml` for more options.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
- Web Push needs no chart settings: TREK generates its key pair on first start and keeps it in the database on the data PVC. To bring your own pair, set `env.VAPID_PUBLIC_KEY` and `secretEnv.VAPID_PRIVATE_KEY` together (and optionally `env.VAPID_SUBJECT`); changing the pair later means every device has to turn push on again.
|
||||
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
|
||||
- `podSecurityContext` defaults to `fsGroup: 1000` with `fsGroupChangePolicy: OnRootMismatch`, and `securityContext` (the container's) is empty. The container then starts as root, hands any file under `/app/data` and `/app/uploads` that is not owned by `node` yet to `node`, and drops to `node`. To run as `node` from the start, for example under the `restricted` Pod Security Standard, set `podSecurityContext.runAsUser: 1000` and `runAsGroup: 1000`; the image then skips that step and the privilege drop. See the example in `values.yaml`.
|
||||
- Use `extraVolumes` and `extraVolumeMounts` to add your own volumes to the pod. To give the booking import more KItinerary extractors, mount a ConfigMap at `/usr/local/share/kitinerary/extractors`, with one `.json` and one `.js` key for each extractor; see the example in `values.yaml`.
|
||||
|
||||
@@ -29,12 +29,18 @@ spec:
|
||||
- name: {{ .name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.podSecurityContext }}
|
||||
securityContext:
|
||||
fsGroup: 1000
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: trek
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
{{- with .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with .Values.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
|
||||
@@ -10,6 +10,34 @@ image:
|
||||
# processes on one host, which network filesystems do not provide.
|
||||
updateStrategy: Recreate
|
||||
|
||||
# Pod-level security context. fsGroup gives the node user (uid/gid 1000) group access
|
||||
# to the data and uploads volumes; OnRootMismatch lets the kubelet skip its recursive
|
||||
# permission walk once the volume root already matches, so a big PVC does not hold up
|
||||
# every start.
|
||||
#
|
||||
# By default the container starts as root, hands any files that are not node's yet to
|
||||
# node, and then drops to node itself. To start it as node straight away (needed under
|
||||
# the "restricted" Pod Security Standard), add the user here and lock the container
|
||||
# down below; the image then skips the ownership step and the privilege drop:
|
||||
# podSecurityContext:
|
||||
# fsGroup: 1000
|
||||
# fsGroupChangePolicy: OnRootMismatch
|
||||
# runAsUser: 1000
|
||||
# runAsGroup: 1000
|
||||
# runAsNonRoot: true
|
||||
# seccompProfile:
|
||||
# type: RuntimeDefault
|
||||
# securityContext:
|
||||
# allowPrivilegeEscalation: false
|
||||
# capabilities:
|
||||
# drop: [ALL]
|
||||
podSecurityContext:
|
||||
fsGroup: 1000
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
|
||||
# Security context of the trek container itself. Empty by default.
|
||||
securityContext: {}
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
|
||||
// A visible DOM node can still be clipped by a panel. Check the whole bar
|
||||
// before interacting: Playwright's automatic scroll-into-view could mask it.
|
||||
async function expectBarInsidePanel(bar: Locator, panel: Locator) {
|
||||
await expect(bar).toBeInViewport({ ratio: 1 });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await bar.boundingBox();
|
||||
const parent = await panel.boundingBox();
|
||||
return (
|
||||
!!bounds &&
|
||||
!!parent &&
|
||||
bounds.y >= parent.y &&
|
||||
bounds.y + bounds.height <= parent.y + parent.height + 1 &&
|
||||
bounds.x >= parent.x &&
|
||||
bounds.x + bounds.width <= parent.x + parent.width + 1
|
||||
);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
for (const layout of [
|
||||
{ name: 'desktop', viewport: { width: 1440, height: 900 }, hasTouch: false },
|
||||
{ name: 'tablet', viewport: { width: 834, height: 1112 }, hasTouch: true },
|
||||
{ name: 'short desktop', viewport: { width: 1280, height: 600 }, hasTouch: false },
|
||||
]) {
|
||||
test.describe(layout.name, () => {
|
||||
test.use({
|
||||
viewport: layout.viewport,
|
||||
hasTouch: layout.hasTouch,
|
||||
});
|
||||
|
||||
for (const tours of [false, true]) {
|
||||
for (const count of [10, 27, 190]) {
|
||||
test(`${count} places, Tours ${tours ? 'on' : 'off'}: selection bar stays reachable`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const activate = (button: Locator) => (layout.hasTouch ? button.tap() : button.click());
|
||||
const addonsResponse = await page.request.get('/api/admin/addons');
|
||||
expect(addonsResponse.ok()).toBeTruthy();
|
||||
const { addons } = await addonsResponse.json();
|
||||
const originalTours = addons.find((addon: { id: string }) => addon.id === 'tours');
|
||||
expect(originalTours).toBeDefined();
|
||||
let tripId: number | undefined;
|
||||
|
||||
try {
|
||||
expect((await page.request.put('/api/admin/addons/tours', { data: { enabled: tours } })).ok()).toBeTruthy();
|
||||
const tripResponse = await page.request.post('/api/trips', {
|
||||
data: {
|
||||
title: `Selection bar ${layout.name} ${count}`,
|
||||
start_date: '2027-01-01',
|
||||
end_date: '2027-01-02',
|
||||
},
|
||||
});
|
||||
expect(tripResponse.ok()).toBeTruthy();
|
||||
tripId = (await tripResponse.json()).trip.id;
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const response = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: `Selection place ${i}`, lat: 48.85, lng: 2.35 },
|
||||
});
|
||||
expect(response.ok(), `seed place ${i}`).toBeTruthy();
|
||||
}
|
||||
|
||||
await page.goto(`/trips/${tripId}`);
|
||||
if (layout.name === 'tablet') await page.locator('button[aria-label="Places"]').click();
|
||||
const sidebar = page
|
||||
.locator('[data-touch-drag]')
|
||||
.filter({ has: page.getByRole('button', { name: 'Select', exact: true }) });
|
||||
const panel = sidebar.locator('..');
|
||||
const rows = sidebar.getByRole('option');
|
||||
await expect(rows).toHaveCount(count);
|
||||
await expect(panel.getByRole('tab', { name: 'Tours', exact: true })).toHaveCount(tours ? 1 : 0);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
|
||||
const bar = sidebar.getByRole('status').locator('../..');
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
|
||||
const list = sidebar.locator('.trek-stagger');
|
||||
const before = await bar.boundingBox();
|
||||
// Only the list scrolls; the bar must remain at the panel's foot.
|
||||
await list.evaluate((el) => {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
if (count >= 27) await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBeGreaterThan(0);
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
expect((await bar.boundingBox())?.y).toBe(before?.y);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
|
||||
await expect(sidebar.getByRole('status')).toHaveText(String(count));
|
||||
await activate(sidebar.getByRole('button', { name: 'Done', exact: true }));
|
||||
await expect(sidebar.getByRole('status')).toHaveCount(0);
|
||||
|
||||
await activate(sidebar.getByRole('button', { name: 'Select', exact: true }));
|
||||
await list.evaluate((el) => {
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
await expectBarInsidePanel(bar, panel);
|
||||
await activate(sidebar.getByRole('button', { name: 'Select all', exact: true }));
|
||||
await activate(sidebar.getByRole('button', { name: 'Delete selected', exact: true }));
|
||||
await activate(page.getByRole('button', { name: 'Delete', exact: true }));
|
||||
await expect(rows).toHaveCount(0);
|
||||
} finally {
|
||||
if (tripId !== undefined) expect((await page.request.delete(`/api/trips/${tripId}`)).ok()).toBeTruthy();
|
||||
expect(
|
||||
(await page.request.put('/api/admin/addons/tours', { data: { enabled: !!originalTours.enabled } })).ok()
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -138,7 +138,7 @@
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"rollup-plugin-visualizer": "^7.1.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.70.0",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
"max-lines-per-function": 130,
|
||||
"react-hooks/static-components": 78,
|
||||
"react-hooks/static-components": 50,
|
||||
"react-hooks/preserve-manual-memoization": 12,
|
||||
"no-useless-assignment": 7,
|
||||
"no-empty": 19,
|
||||
@@ -25,7 +25,7 @@
|
||||
"max-depth": 21,
|
||||
"react-hooks/void-use-memo": 1,
|
||||
"@typescript-eslint/no-unused-expressions": 2,
|
||||
"react-hooks/purity": 2,
|
||||
"react-hooks/purity": 1,
|
||||
"preserve-caught-error": 53,
|
||||
"no-useless-escape": 1
|
||||
},
|
||||
@@ -34,8 +34,8 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1915,
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1914,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -742,11 +742,8 @@
|
||||
{
|
||||
"feature": "Settings / Map: mapSettingsModel",
|
||||
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
|
||||
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": [
|
||||
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMap.tsx"
|
||||
]
|
||||
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Map: useMapSettingsForm",
|
||||
@@ -983,8 +980,11 @@
|
||||
}
|
||||
],
|
||||
"mobileOnly": {
|
||||
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
|
||||
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
|
||||
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
|
||||
"src/mobile/screens/admin/MPluginVersionDate.tsx": "Markup only; the published date of a plugin version in MAdminPluginsPanel, formatted with the user's date format through useFormatDate.",
|
||||
"src/mobile/screens/admin/MStorageReclaimCard.tsx": "Markup only; renders the reclaim view and handlers that MAdminStoragePanel takes from useStoragePanel, the twin of StorageReclaimSection.",
|
||||
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
|
||||
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
|
||||
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
|
||||
|
||||
@@ -2,23 +2,22 @@
|
||||
"e2e/help/fixtures.ts": 2291,
|
||||
"src/api/client.ts": 1873,
|
||||
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2730,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/PDF/TripPDF.tsx": 1039,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2936,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
|
||||
"src/components/Planner/PlaceInspector.tsx": 1290,
|
||||
"src/components/Studio/TravelElements.tsx": 1559,
|
||||
"src/components/Studio/countryShapes.ts": 2595,
|
||||
"src/index.css": 1636,
|
||||
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1507,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4030,
|
||||
"src/pages/JourneyDetailPage.test.tsx": 4028,
|
||||
"src/pages/LoginPage.tsx": 1348,
|
||||
"src/pages/TripPlannerPage.tsx": 1128,
|
||||
"src/pages/atlas/useAtlas.ts": 1210,
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
"components/Packing/PackingListPanelHeader.tsx": 5,
|
||||
@@ -284,7 +284,7 @@
|
||||
"mobile/screens/vacay/MVacay.tsx": 23,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
|
||||
@@ -436,6 +436,28 @@ describe('App — on-mount effects', () => {
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false))
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-018c: app-config is asked again after a failed answer, so managed is not lost', async () => {
|
||||
// A page opened while the server restarts got a 502 here and kept every
|
||||
// default for the session, which brought back the operator's settings.
|
||||
let calls = 0
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () => {
|
||||
calls += 1
|
||||
return calls === 1 ? new HttpResponse(null, { status: 502 }) : HttpResponse.json({ managed: true })
|
||||
})
|
||||
)
|
||||
const setManaged = vi.fn()
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
isAuthenticated: false,
|
||||
loadUser: vi.fn().mockResolvedValue(undefined),
|
||||
setManaged,
|
||||
})
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true), { timeout: 4000 })
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
|
||||
+25
-3
@@ -323,7 +323,21 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
// Asked again until it answers: a page opened while the server restarts got a
|
||||
// 502 here and kept the defaults for the whole session, which on a managed
|
||||
// install put back every setting the operator owns.
|
||||
let cancelled = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||
type AppConfigAnswer = Awaited<ReturnType<typeof authApi.getAppConfig>>
|
||||
const fetchAppConfig = (attempt: number): Promise<AppConfigAnswer> => authApi.getAppConfig().catch((err: unknown) => {
|
||||
if (cancelled) throw err
|
||||
return new Promise<AppConfigAnswer>((resolve, reject) => {
|
||||
retryTimer = setTimeout(() => {
|
||||
fetchAppConfig(attempt + 1).then(resolve, reject)
|
||||
}, Math.min(30_000, 1_000 * 2 ** attempt))
|
||||
})
|
||||
})
|
||||
fetchAppConfig(0).then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; max_upload_mb?: number; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -347,6 +361,10 @@ export default function App() {
|
||||
// Last, since a new release reloads the page from here.
|
||||
await reconcileAppVersion(config?.version)
|
||||
}).catch(() => {})
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(retryTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
@@ -402,6 +420,10 @@ export default function App() {
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
// The Help & Docs page is the wiki, written for somebody who runs TREK
|
||||
// themselves: keys, backups, updates, the server behind it. A managed install
|
||||
// does not offer it; the in-app help center stays.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
@@ -456,7 +478,7 @@ export default function App() {
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -464,7 +486,7 @@ export default function App() {
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
{managed ? <Navigate to="/dashboard" replace /> : <HelpPage />}
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { StorageReclaimConfirmRequest, StorageReclaimState } from '@trek/shared';
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** Admin > Storage, unreferenced files: the nightly deletion switch, a check on demand, and the large-share confirmation. */
|
||||
export const storageReclaimApi = {
|
||||
setDelete: (deleteEnabled: boolean): Promise<StorageReclaimState> =>
|
||||
apiClient.put('/admin/storage/reclaim', { deleteEnabled }).then((r) => r.data),
|
||||
/** Starts a report-only check in the background (202); its report arrives through the admin state. */
|
||||
check: (): Promise<{ started: true }> => apiClient.post('/admin/storage/reclaim/run').then((r) => r.data),
|
||||
/** `unreferenced` is the count the admin was shown: the server refuses when its own count has moved on. */
|
||||
confirm: (request: StorageReclaimConfirmRequest): Promise<StorageReclaimState> =>
|
||||
apiClient
|
||||
.post('/admin/storage/reclaim/confirm', request satisfies StorageReclaimConfirmRequest)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
@@ -679,6 +680,16 @@ describe('AddonManager', () => {
|
||||
expect(isOn(screen.getByRole('button', { name: 'wanderer' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-042b: a managed install has no wanderer switch under Tours', async () => {
|
||||
seedStore(useAuthStore, { managed: true });
|
||||
server.use(addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByRole('button', { name: /\d+ options?/, expanded: false });
|
||||
await unfoldShelves();
|
||||
expect(screen.queryByRole('button', { name: 'wanderer' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-043: a failed wanderer save puts the switch back', async () => {
|
||||
server.use(
|
||||
addonsRoute([buildAddon({ id: 'tours', name: 'Tours', icon: 'Mountain', enabled: true })]),
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
@@ -157,14 +158,18 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
// "N options" bar the way Collab's and Documents' are.
|
||||
if (addon.id === 'tours' && addon.enabled) {
|
||||
return [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
...(wandererOffered
|
||||
? [
|
||||
<AddonSubRow
|
||||
key="wanderer"
|
||||
icon={<WandererMark size={14} />}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
description={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
...mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<AddonSubRow
|
||||
key={key}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Key, Trash2, User, Loader2, Shield, KeyRound, Bot } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
@@ -64,9 +65,10 @@ export default function AdminMcpTokensPanel() {
|
||||
sessions, sessionsLoading, tokens, tokensLoading, expandedScopes, toggleScopes,
|
||||
revokeConfirmId, setRevokeConfirmId, deleteConfirmId, setDeleteConfirmId, handleRevoke, handleDelete,
|
||||
} = useMcpTokensAdmin()
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(locale)
|
||||
const date = (iso: string) => formatDate(new Date(iso))
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type RangeWarning } from './useRangeBypass'
|
||||
import { deriveCaps } from './pluginCaps'
|
||||
import PluginPoiCategoryList from './PluginPoiCategoryList'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
@@ -33,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
|
||||
import PluginFilterMenu from './plugins/PluginFilterMenu'
|
||||
import PluginInstalledRow from './plugins/PluginInstalledRow'
|
||||
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
|
||||
import VersionLine from './plugins/VersionLine'
|
||||
|
||||
/**
|
||||
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
|
||||
@@ -303,7 +305,7 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{versionPicker && (
|
||||
<VersionPickerDialog plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
|
||||
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -745,6 +747,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const { detail, failed } = usePluginDetail(item.id)
|
||||
const { formatDate } = useFormatDate()
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const permissions = manifest?.permissions ?? []
|
||||
@@ -910,7 +913,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{(item.trek || item.minTrekVersion) && (
|
||||
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
|
||||
)}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={new Date(item.reviewedAt).toLocaleDateString(locale)} />}
|
||||
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
|
||||
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
|
||||
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
|
||||
)}
|
||||
@@ -934,7 +937,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
) : (
|
||||
<span className="text-content-faint" style={fs(11.5)}>{t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })}</span>
|
||||
)}>
|
||||
<VersionLine v={v} locale={locale} />
|
||||
<VersionLine v={v} />
|
||||
</DialogListItem>
|
||||
))}
|
||||
</DialogList>
|
||||
@@ -944,17 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
)
|
||||
}
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
function VersionLine({ v, locale }: { v: VersionInfo; locale: string }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
|
||||
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{new Date(v.publishedAt).toLocaleDateString(locale)}</span>}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
|
||||
@@ -970,9 +962,9 @@ function Meta({ k, v }: { k: string; v: string }) {
|
||||
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
|
||||
* explained and never offered.
|
||||
*/
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
|
||||
function VersionPickerDialog({ plugin, versions, failed, busy, t, onPick, onClose }: {
|
||||
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
|
||||
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
|
||||
t: T; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<DialogShell
|
||||
@@ -994,7 +986,7 @@ function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick
|
||||
const current = v.version === plugin.version
|
||||
return (
|
||||
<div key={v.version} data-testid={`version-row-${v.version}`} className="flex items-center gap-3 px-3.5 py-2.5">
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} locale={locale} /></div>
|
||||
<div className="min-w-0 flex-1"><VersionLine v={v} /></div>
|
||||
{current ? (
|
||||
<StatusPill tone="success" icon={<Check size={11} />}>{t('admin.plugins.installed')}</StatusPill>
|
||||
) : v.compatible ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-029
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-031
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -70,7 +70,7 @@ function cartoInput(): HTMLInputElement {
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string | RegExp) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
const choices = await screen.findAllByRole('button', { name: option });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
@@ -481,3 +481,34 @@ describe('DefaultUserSettingsTab week start (#2029)', () => {
|
||||
await waitFor(() => expect(puts).toEqual([{ week_start: 'sunday' }, { week_start: null }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DefaultUserSettingsTab date format (#2971)', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-030: the date-format default saves its own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'YYYY-MM-DD' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
// The choices are labelled by how they write 31 December.
|
||||
await pickFromSelect(user, '2026-12-31', '31.12.2026');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'DD.MM.YYYY' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '31.12.2026' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-031: auto is offered with the example of the interface language', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ date_format: 'DD/MM/YYYY' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, '31/12/2026', /\(12\/31\/2026\)/);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ date_format: 'auto' }]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,10 @@ import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { dateFormatOptions } from '../../utils/dateFormat'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../Settings/mapSettingsModel'
|
||||
import type { MapProvider } from '../Settings/mapSettingsModel'
|
||||
import RoutingInstanceFields from './RoutingInstanceFields'
|
||||
import { useDefaultUserSettings, type Defaults } from './useDefaultUserSettings'
|
||||
|
||||
@@ -66,26 +67,40 @@ function errorText(err: unknown, fallback: string): string {
|
||||
return err instanceof Error ? err.message : fallback
|
||||
}
|
||||
|
||||
interface ResetProps {
|
||||
shown: boolean
|
||||
onReset: () => void
|
||||
label: string
|
||||
}
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
|
||||
if (!shown) return null
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onReset}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const {
|
||||
defaults, loaded, managed, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
defaults, loaded, managed, presets, canMapbox, isSet, save, reset, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, mapboxStyle, setMapboxStyle, mapPreviewPlaces, mapProvider, glStylePresets, styleKey,
|
||||
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
|
||||
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
|
||||
|
||||
// Inside the row's own <label>, after the name: the link only shows once the
|
||||
// default is set, and the name stays the label's text.
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
onClick={() => reset(field)}
|
||||
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
|
||||
style={fs(11)}
|
||||
>
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
) : null
|
||||
const resetOf = (field: keyof Defaults): ResetProps => ({
|
||||
shown: isSet(field),
|
||||
onReset: () => reset(field),
|
||||
label: t('admin.defaultSettings.resetToBuiltIn'),
|
||||
})
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
@@ -118,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<SettingRows>
|
||||
{/* Color Mode */}
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.colorMode')}
|
||||
value={colorModeValue(darkMode)}
|
||||
@@ -132,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Temperature */}
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.temperature')}
|
||||
value={defaults.temperature_unit ?? ''}
|
||||
@@ -145,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Distance */}
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
|
||||
<Segmented<DistanceUnit | ''>
|
||||
label={t('settings.distance')}
|
||||
value={defaults.distance_unit ?? ''}
|
||||
@@ -158,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Time Format */}
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.timeFormat')}
|
||||
value={defaults.time_format ?? ''}
|
||||
@@ -171,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Week start: the first column of every date picker (#2029) */}
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
|
||||
<Segmented<WeekStart | ''>
|
||||
label={t('settings.weekStart')}
|
||||
value={defaults.week_start ?? ''}
|
||||
@@ -180,8 +195,19 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Date format: every date field and numeric date (#2971) */}
|
||||
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.date_format ?? ''}
|
||||
onChange={value => { if (value) void save({ date_format: String(value) as Defaults['date_format'] }) }}
|
||||
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
|
||||
/>
|
||||
</div>
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Default Currency */}
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
|
||||
<div className="w-[220px] max-w-full">
|
||||
<CustomSelect
|
||||
value={defaults.default_currency || ''}
|
||||
@@ -194,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</ChoiceRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('settings.blurBookingCodes')}
|
||||
value={onOffValue(defaults.blur_booking_codes)}
|
||||
@@ -211,13 +237,13 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapTileUrl}
|
||||
onChange={pickTilePreset}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
@@ -232,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
@@ -273,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<SettingRows>
|
||||
<ChoiceRow
|
||||
stacked
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
<Segmented<MapProvider>
|
||||
@@ -283,7 +309,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
onChange={saveMapProvider}
|
||||
options={[
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
...(canMapbox ? [{ value: 'mapbox-gl' as const, label: t('admin.defaultSettings.providerMapbox') }] : []),
|
||||
{ value: 'maplibre-gl', label: t('admin.defaultSettings.providerMapLibre') },
|
||||
]}
|
||||
/>
|
||||
@@ -293,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
@@ -310,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div className={FIELD_ROW}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
|
||||
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
@@ -333,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapbox3d')}
|
||||
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
|
||||
@@ -345,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
/>
|
||||
</ChoiceRow>
|
||||
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
|
||||
<Segmented<string>
|
||||
label={t('admin.defaultSettings.mapboxQuality')}
|
||||
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-011
|
||||
// FE-ADMIN-CONTRIB-001 to FE-ADMIN-CONTRIB-013
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { PlaceContribDecisionRequest, PlaceContribState } from '@trek/shared';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -201,4 +201,23 @@ describe('PlaceContribPanel', () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-012: standing alone, a card the operator switched off is not shown', async () => {
|
||||
const asked = vi.fn();
|
||||
server.use(
|
||||
http.get('/api/admin/places-contribute', () => {
|
||||
asked();
|
||||
return HttpResponse.json({ ...UNDECIDED, eligible: false, ineligibleReason: 'env_off', showPrompt: false });
|
||||
})
|
||||
);
|
||||
const { container } = render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
await waitFor(() => expect(asked).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-CONTRIB-013: standing alone, any other reason is still explained', async () => {
|
||||
serve({ ...UNDECIDED, eligible: false, ineligibleReason: 'not_production', showPrompt: false });
|
||||
render(<PlaceContribPanel hideWhenSwitchedOff />);
|
||||
expect(await screen.findByText('admin.contribute.ineligible.not_production')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,15 @@ function Figures({ state, t }: { state: PlaceContribState; t: TranslationFn }) {
|
||||
* way to take everything back. Turning it on for the first time opens the same
|
||||
* prompt an admin gets on the first visit, so an agreement always comes from it.
|
||||
*/
|
||||
export default function PlaceContribPanel() {
|
||||
/**
|
||||
* Standing alone (managed settings), a card that can only say "not available"
|
||||
* because the operator switched sharing off is a dead end.
|
||||
*/
|
||||
function hiddenAlone(state: PlaceContribState, alone: boolean): boolean {
|
||||
return alone && state.ineligibleReason === 'env_off';
|
||||
}
|
||||
|
||||
export default function PlaceContribPanel({ hideWhenSwitchedOff = false }: { hideWhenSwitchedOff?: boolean } = {}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const contrib = usePlaceContrib();
|
||||
@@ -70,6 +78,7 @@ export default function PlaceContribPanel() {
|
||||
const [confirm, setConfirm] = useState<'off' | 'withdraw' | null>(null);
|
||||
|
||||
if (!state) return null;
|
||||
if (hiddenAlone(state, hideWhenSwitchedOff)) return null;
|
||||
const status = contribStatus(state);
|
||||
const agreed = state.consent.decision === 'agreed' && state.consent.termsVersion === state.termsVersion;
|
||||
// The prompt opens by itself once per visit while the server asks for it.
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function PlaceContribPrompt({
|
||||
))}
|
||||
</ul>
|
||||
<p className="m-0 leading-normal text-content-muted" style={fs(12, 'body')}>
|
||||
{t('admin.contribute.prompt.how')}
|
||||
{t('admin.contribute.prompt.how')} {t('admin.contribute.prompt.past')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cycleMapAdminApi, hikingMapAdminApi, skiRoutingAdminApi } from '../../.
|
||||
import { wandererAdminApi } from '../../../api/wanderer';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAddonStore } from '../../../store/addonStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { type Addon, type ProviderOption, isPhotosAddon } from './addonModel';
|
||||
import { aiAddonsOf, isAiAddon } from './aiAddons';
|
||||
@@ -36,6 +37,9 @@ export function useAddonManager() {
|
||||
const [addons, setAddons] = useState<Addon[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toursWanderer, setToursWanderer] = useState(false);
|
||||
// wanderer sends a user's searches to a server of their choosing, which a
|
||||
// managed install's terms do not cover; the operator decides, not the admin.
|
||||
const wandererOffered = !useAuthStore((s) => s.managed);
|
||||
const [toursHikingMap, setToursHikingMap] = useState(false);
|
||||
const [toursCycleMap, setToursCycleMap] = useState(false);
|
||||
const [toursSkiRouting, setToursSkiRouting] = useState(false);
|
||||
@@ -160,6 +164,7 @@ export function useAddonManager() {
|
||||
providerOptions,
|
||||
documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { VersionInfo } from './pluginModel';
|
||||
|
||||
/** A version number with its date and, when the author signed it, a quiet tick. */
|
||||
export default function VersionLine({ v }: { v: VersionInfo }) {
|
||||
const { formatDate } = useFormatDate();
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<span
|
||||
className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`}
|
||||
style={fs(12.5)}
|
||||
>
|
||||
v{v.version}
|
||||
</span>
|
||||
{v.publishedAt && (
|
||||
<span className="text-content-faint" style={fs(11.5)}>
|
||||
{formatDate(new Date(v.publishedAt))}
|
||||
</span>
|
||||
)}
|
||||
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageCategory, type StorageConfig } from '@trek/shared';
|
||||
import { MASKED_SETTING_VALUE, STORAGE_RECLAIM_CATEGORIES, type StorageAdminState, type StorageCategory, type StorageConfig, type StorageReclaimReport } from '@trek/shared';
|
||||
import { server } from '../../../../tests/helpers/msw/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render';
|
||||
import { ToastContainer } from '../../shared/Toast';
|
||||
@@ -11,6 +11,16 @@ const S3_MASKED = {
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
};
|
||||
|
||||
/** A last check with three covers a week old and two younger ones; every other category never reclaimed. */
|
||||
function reclaimReport(): StorageReclaimReport {
|
||||
const categories = Object.fromEntries(
|
||||
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, { objects: 0, unreferenced: { objects: 0, bytes: 0 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: 'not_reclaimed' as const }]),
|
||||
) as StorageReclaimReport['categories'];
|
||||
categories.covers = { objects: 40, unreferenced: { objects: 5, bytes: 5000 }, eligible: { objects: 3, bytes: 3000 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: false, skipped: null };
|
||||
categories.places = { objects: 10, unreferenced: { objects: 3, bytes: 300 }, eligible: { objects: 0, bytes: 0 }, deleted: { objects: 0, bytes: 0 }, confirmationRequired: true, skipped: null };
|
||||
return { startedAt: Date.now() - 60_000, finishedAt: Date.now() - 30_000, mode: 'report', stopped: null, categories };
|
||||
}
|
||||
|
||||
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
|
||||
return {
|
||||
backends: [
|
||||
@@ -35,6 +45,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
@@ -1217,4 +1228,55 @@ describe('AdminStoragePanel', () => {
|
||||
expect(within(row).getByText(/Sync failed: disk full/)).toBeInTheDocument();
|
||||
expect(within(row).getByRole('button', { name: 'Sync now' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-051: unreferenced files: the last check shows, the switch, the check and a confirmation go to the server', async () => {
|
||||
const lastRun = reclaimReport();
|
||||
let put: unknown = null;
|
||||
let runs = 0;
|
||||
let confirm: unknown = null;
|
||||
server.use(
|
||||
http.put('/api/admin/storage/reclaim', async ({ request }) => {
|
||||
put = await request.json();
|
||||
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
|
||||
}),
|
||||
http.post('/api/admin/storage/reclaim/run', () => {
|
||||
runs += 1;
|
||||
return HttpResponse.json({ started: true }, { status: 202 });
|
||||
}),
|
||||
http.post('/api/admin/storage/reclaim/confirm', async ({ request }) => {
|
||||
confirm = await request.json();
|
||||
return HttpResponse.json({ deleteEnabled: true, running: false, lastRun, lastDeleteRun: null });
|
||||
}),
|
||||
);
|
||||
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun, lastDeleteRun: null } }));
|
||||
// Once a check started, the state the panel polls says it runs.
|
||||
server.use(
|
||||
http.get('/api/admin/storage', () =>
|
||||
HttpResponse.json(baseState({ reclaim: { deleteEnabled: true, running: runs > 0, lastRun, lastDeleteRun: null } })),
|
||||
),
|
||||
);
|
||||
expect(screen.getByText('Unreferenced files')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('storage-reclaim-covers')).getByText(/Can be freed/)).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('storage-reclaim-places')).getByText(/More than a fifth/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete unreferenced files' }));
|
||||
await waitFor(() => expect(put).toEqual({ deleteEnabled: true }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Delete unreferenced files' })).toHaveAttribute('aria-pressed', 'true'));
|
||||
fireEvent.click(within(screen.getByTestId('storage-reclaim-places')).getByRole('button', { name: 'Confirm' }));
|
||||
await waitFor(() => expect(confirm).toEqual({ category: 'places', unreferenced: 3 }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
|
||||
await waitFor(() => expect(runs).toBe(1));
|
||||
// The check runs in the background: the panel shows it running until the state says otherwise.
|
||||
await waitFor(() => expect(screen.getByText('Checking…')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-051b: a refused check names the reason in the operator\'s language', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/storage/reclaim/run', () =>
|
||||
HttpResponse.json({ error: 'busy', code: 'reclaim_busy' }, { status: 409 }),
|
||||
),
|
||||
);
|
||||
await renderPanel(baseState({ reclaim: { deleteEnabled: false, running: false, lastRun: null, lastDeleteRun: null } }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check now' }));
|
||||
await waitFor(() => expect(screen.getByText('Another storage task is running. Try again in a moment.')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
StatusPill,
|
||||
} from '../../Settings/settingsKit'
|
||||
import BackendForm from './BackendForm'
|
||||
import StorageReclaimSection from './StorageReclaimSection'
|
||||
import { CACHE_CATEGORIES, primaryNameOf } from './storageModel'
|
||||
import { categoryNames, migrationPromptLine, useStoragePanel } from './useStoragePanel'
|
||||
|
||||
@@ -96,6 +97,7 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
const {
|
||||
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
|
||||
closeMigratePrompt, migrationQueue, handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
|
||||
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
|
||||
} = useStoragePanel()
|
||||
// Open/closed only: the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh rather than reading the
|
||||
@@ -627,6 +629,18 @@ export default function AdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<StorageReclaimSection
|
||||
reclaim={reclaim}
|
||||
checking={reclaimChecking}
|
||||
saving={reclaimSaving}
|
||||
confirming={reclaimConfirming}
|
||||
onConfirm={(request) => void handleReclaimConfirm(request)}
|
||||
onCheck={() => void handleReclaimCheck()}
|
||||
onDelete={(enabled) => void handleReclaimDelete(enabled)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Eraser, Loader2, ScanSearch } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { relativeTime } from '../../../utils/relativeTime';
|
||||
import Section from '../../Settings/Section';
|
||||
import ToggleSwitch from '../../Settings/ToggleSwitch';
|
||||
import { SETTINGS_BUTTON, SettingRow, SettingRows, SettingsHint, StatusPill } from '../../Settings/settingsKit';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { ReclaimCount, ReclaimView } from './reclaimModel';
|
||||
|
||||
interface Props {
|
||||
reclaim: ReclaimView;
|
||||
/** A check was asked for or is running. */
|
||||
checking: boolean;
|
||||
/** The switch is on its way to the server. */
|
||||
saving: boolean;
|
||||
onCheck: () => void;
|
||||
onDelete: (enabled: boolean) => void;
|
||||
/** The category whose confirmation is on its way. */
|
||||
confirming: ReclaimView['lines'][number]['category'] | null;
|
||||
onConfirm: (request: { category: ReclaimView['lines'][number]['category']; unreferenced: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreferenced files on the desktop storage panel: what the last check found,
|
||||
* the switch that lets the nightly run delete them, a check on demand and the
|
||||
* confirmation a category with an unusually large share of unused files waits
|
||||
* for. The phone shows the same through MStorageReclaimCard, off the same view model.
|
||||
*/
|
||||
export default function StorageReclaimSection({
|
||||
reclaim,
|
||||
checking,
|
||||
saving,
|
||||
onCheck,
|
||||
onDelete,
|
||||
confirming,
|
||||
onConfirm,
|
||||
}: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation();
|
||||
const count = (c: ReclaimCount) => t('storage.usage.line', { count: c.objects, size: formatBytes(c.bytes) });
|
||||
const checked = reclaim.checkedAt !== null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('storage.reclaim.title')}
|
||||
icon={Eraser}
|
||||
hint={t('storage.reclaim.hint')}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12.5, 'body')}
|
||||
onClick={onCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
{checking ? (
|
||||
<Loader2 size={14} strokeWidth={2.2} className="animate-spin" />
|
||||
) : (
|
||||
<ScanSearch size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('storage.reclaim.delete')}
|
||||
hint={t('storage.reclaim.deleteHint')}
|
||||
dimmed={saving}
|
||||
control={
|
||||
<ToggleSwitch
|
||||
on={reclaim.deleteEnabled}
|
||||
onToggle={() => onDelete(!reclaim.deleteEnabled)}
|
||||
label={t('storage.reclaim.delete')}
|
||||
disabled={saving}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="px-3.5 py-3" data-testid="storage-reclaim-summary">
|
||||
<p className="m-0 text-content-faint" style={fs(11.5)}>
|
||||
{reclaim.checkedAt !== null
|
||||
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
|
||||
: t('storage.reclaim.never')}
|
||||
</p>
|
||||
{reclaim.stopped && (
|
||||
<p role="note" className="m-0 mt-1.5 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
|
||||
</p>
|
||||
)}
|
||||
{checked && (
|
||||
<div className="mt-2.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<Figure label={t('storage.reclaim.freeable')} value={count(reclaim.freeable)} />
|
||||
<Figure label={t('storage.reclaim.waiting')} value={count(reclaim.waiting)} />
|
||||
<Figure label={t('storage.reclaim.deleted')} value={count(reclaim.freed)} />
|
||||
</div>
|
||||
)}
|
||||
{checked && !reclaim.stopped && reclaim.lines.length === 0 && (
|
||||
<SettingsHint className="mt-2.5">{t('storage.reclaim.nothing')}</SettingsHint>
|
||||
)}
|
||||
{reclaim.lines.length > 0 && (
|
||||
<ul className="m-0 mt-3 flex list-none flex-col gap-2 p-0">
|
||||
{reclaim.lines.map((line) => (
|
||||
<li
|
||||
key={line.category}
|
||||
data-testid={`storage-reclaim-${line.category}`}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<span className="font-medium text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t(`storage.category.${line.category}`)}
|
||||
</span>
|
||||
{line.freeable.objects > 0 && (
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.reclaim.freeable')}: {count(line.freeable)}
|
||||
</span>
|
||||
)}
|
||||
{line.waiting.objects > 0 && (
|
||||
<span className="font-geist tabular-nums text-content-faint" style={fs(11.5)}>
|
||||
{t('storage.reclaim.waiting')}: {count(line.waiting)}
|
||||
</span>
|
||||
)}
|
||||
{line.skipped && (
|
||||
<StatusPill tone="warning">{t(`storage.reclaim.skip.${line.skipped}`)}</StatusPill>
|
||||
)}
|
||||
</div>
|
||||
{line.confirmationRequired && (
|
||||
<div className="flex flex-wrap items-center gap-2.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5">
|
||||
<p className="m-0 min-w-0 flex-1 basis-56 leading-snug text-warning" style={fs(11.5)}>
|
||||
{t('storage.reclaim.confirmNeeded')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={SETTINGS_BUTTON}
|
||||
style={fs(12, 'body')}
|
||||
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
|
||||
disabled={confirming === line.category}
|
||||
>
|
||||
{confirming === line.category && (
|
||||
<Loader2 size={13} strokeWidth={2.2} className="animate-spin" />
|
||||
)}
|
||||
{t('storage.reclaim.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="min-w-0 rounded-[12px] bg-surface-secondary px-3 py-2">
|
||||
<p className="m-0 text-content-faint" style={fs(11)}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="m-0 mt-0.5 font-geist font-semibold tabular-nums text-content" style={fs(12.5, 'body')}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// FE-ADMIN-STOR-RECLAIM-001 to -007: the reclaim report as both storage panels show it.
|
||||
import { STORAGE_RECLAIM_CATEGORIES, type StorageReclaimCategoryReport, type StorageReclaimReport } from '@trek/shared';
|
||||
|
||||
import { reclaimView } from './reclaimModel';
|
||||
|
||||
const none = { objects: 0, bytes: 0 };
|
||||
|
||||
function category(patch: Partial<StorageReclaimCategoryReport> = {}): StorageReclaimCategoryReport {
|
||||
return {
|
||||
objects: 0,
|
||||
unreferenced: none,
|
||||
eligible: none,
|
||||
deleted: none,
|
||||
confirmationRequired: false,
|
||||
skipped: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function report(
|
||||
patch: Partial<Record<(typeof STORAGE_RECLAIM_CATEGORIES)[number], StorageReclaimCategoryReport>>,
|
||||
mode: StorageReclaimReport['mode'] = 'report'
|
||||
): StorageReclaimReport {
|
||||
const categories = Object.fromEntries(
|
||||
STORAGE_RECLAIM_CATEGORIES.map((c) => [c, category({ skipped: 'not_reclaimed' })])
|
||||
) as StorageReclaimReport['categories'];
|
||||
return { startedAt: 1_000, finishedAt: 2_000, mode, stopped: null, categories: { ...categories, ...patch } };
|
||||
}
|
||||
|
||||
describe('reclaimView', () => {
|
||||
it('FE-ADMIN-STOR-RECLAIM-001: no state reads as deletion off, not running and never checked', () => {
|
||||
expect(reclaimView(null)).toEqual({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
checkedAt: null,
|
||||
stopped: null,
|
||||
freeable: none,
|
||||
waiting: none,
|
||||
freed: none,
|
||||
freedAt: null,
|
||||
lines: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-002: freeable is what is a week old, waiting what is younger, summed over categories', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({
|
||||
covers: category({
|
||||
objects: 40,
|
||||
unreferenced: { objects: 5, bytes: 500 },
|
||||
eligible: { objects: 3, bytes: 300 },
|
||||
}),
|
||||
places: category({ objects: 10, unreferenced: { objects: 1, bytes: 100 } }),
|
||||
}),
|
||||
});
|
||||
expect(view.deleteEnabled).toBe(true);
|
||||
expect(view.checkedAt).toBe(2_000);
|
||||
expect(view.freeable).toEqual({ objects: 3, bytes: 300 });
|
||||
expect(view.waiting).toEqual({ objects: 3, bytes: 300 });
|
||||
expect(view.lines.map((line) => line.category)).toEqual(['covers', 'places']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-003: freed comes from the last run with deletion on, which a check on demand does not replace', () => {
|
||||
const deleting = report(
|
||||
{
|
||||
files: category({
|
||||
unreferenced: { objects: 2, bytes: 20 },
|
||||
eligible: { objects: 2, bytes: 20 },
|
||||
deleted: { objects: 2, bytes: 20 },
|
||||
}),
|
||||
},
|
||||
'delete'
|
||||
);
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: { ...deleting, finishedAt: 900 },
|
||||
lastRun: report({}),
|
||||
});
|
||||
expect(view.freed).toEqual({ objects: 2, bytes: 20 });
|
||||
expect(view.freedAt).toBe(900);
|
||||
expect(view.freeable).toEqual(none);
|
||||
expect(view.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-004: a skipped category shows its reason and counts toward nothing, one never reclaimed shows nothing', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({ avatars: category({ skipped: 'foreign_install', unreferenced: { objects: 9, bytes: 9 } }) }),
|
||||
});
|
||||
expect(view.lines).toEqual([
|
||||
{
|
||||
category: 'avatars',
|
||||
freeable: none,
|
||||
waiting: none,
|
||||
confirmationRequired: false,
|
||||
unreferenced: 9,
|
||||
skipped: 'foreign_install',
|
||||
},
|
||||
]);
|
||||
expect(view.waiting).toEqual(none);
|
||||
expect(view.lines.some((line) => line.category === 'backups')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-005: a pass that stopped says why, and a category it stopped in keeps its partial figures', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: false,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: {
|
||||
...report({ covers: category({ skipped: 'interrupted', unreferenced: { objects: 4, bytes: 40 } }) }),
|
||||
stopped: 'failed',
|
||||
},
|
||||
});
|
||||
expect(view.stopped).toBe('failed');
|
||||
expect(view.waiting).toEqual({ objects: 4, bytes: 40 });
|
||||
expect(view.lines[0]).toMatchObject({ category: 'covers', skipped: 'interrupted' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-006: a category waiting for a confirmation shows even with nothing old enough yet', () => {
|
||||
const view = reclaimView({
|
||||
deleteEnabled: true,
|
||||
running: false,
|
||||
lastDeleteRun: null,
|
||||
lastRun: report({ places: category({ objects: 10, confirmationRequired: true }) }),
|
||||
});
|
||||
expect(view.lines).toEqual([
|
||||
{ category: 'places', freeable: none, waiting: none, confirmationRequired: true, unreferenced: 0, skipped: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-RECLAIM-007: a running check reads as running', () => {
|
||||
expect(reclaimView({ deleteEnabled: false, running: true, lastRun: null, lastDeleteRun: null }).running).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
STORAGE_RECLAIM_CATEGORIES,
|
||||
type StorageReclaimCategoryReport,
|
||||
type StorageReclaimReport,
|
||||
type StorageReclaimState,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A number of files and their size, as the report counts them. */
|
||||
export interface ReclaimCount {
|
||||
objects: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** What one category of the last check has to say, when it has anything to say. */
|
||||
interface ReclaimCategoryLine {
|
||||
category: (typeof STORAGE_RECLAIM_CATEGORIES)[number];
|
||||
/** Unused for a week or more: what the nightly run deletes once deletion is on. */
|
||||
freeable: ReclaimCount;
|
||||
/** Unused for less than a week: these get their week first. */
|
||||
waiting: ReclaimCount;
|
||||
/**
|
||||
* An unusually large share of the category looks unused: nothing in it is
|
||||
* deleted until an admin confirms that this is expected (a cleaned-up
|
||||
* backlog, say, rather than a second installation in the same bucket).
|
||||
*/
|
||||
confirmationRequired: boolean;
|
||||
/** Everything the check found unused in the category, the count a confirmation is given for. */
|
||||
unreferenced: number;
|
||||
/** Why the category was left alone, or stopped part way; null when it was checked through. */
|
||||
skipped: Exclude<StorageReclaimCategoryReport['skipped'], 'not_reclaimed'> | null;
|
||||
}
|
||||
|
||||
/** The admin's view of the reclaim pass: the switch and what the last check found. */
|
||||
export interface ReclaimView {
|
||||
deleteEnabled: boolean;
|
||||
/** A check, nightly or on demand, is running now. */
|
||||
running: boolean;
|
||||
/** When the last check finished, ms; null before the first one. */
|
||||
checkedAt: number | null;
|
||||
/** Why the last check stopped before the end; null when it ran through. */
|
||||
stopped: StorageReclaimReport['stopped'];
|
||||
freeable: ReclaimCount;
|
||||
waiting: ReclaimCount;
|
||||
/** What the last run with deletion on removed: a check on demand only reports, so it never resets this. */
|
||||
freed: ReclaimCount;
|
||||
/** When that run finished, ms; null when nothing ever ran with deletion on. */
|
||||
freedAt: number | null;
|
||||
/** Only the categories with something to free, something waiting, something to confirm, or a reason they were skipped. */
|
||||
lines: ReclaimCategoryLine[];
|
||||
}
|
||||
|
||||
const NONE: ReclaimCount = { objects: 0, bytes: 0 };
|
||||
|
||||
function add(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
|
||||
return { objects: a.objects + b.objects, bytes: a.bytes + b.bytes };
|
||||
}
|
||||
|
||||
function minus(a: ReclaimCount, b: ReclaimCount): ReclaimCount {
|
||||
return { objects: Math.max(0, a.objects - b.objects), bytes: Math.max(0, a.bytes - b.bytes) };
|
||||
}
|
||||
|
||||
function freedBy(run: StorageReclaimReport | null): ReclaimCount {
|
||||
if (!run) return NONE;
|
||||
return STORAGE_RECLAIM_CATEGORIES.reduce(
|
||||
(sum, category) => add(sum, run.categories[category]?.deleted ?? NONE),
|
||||
NONE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reclaim state as the storage panels show it, desktop and phone alike.
|
||||
* `null` (a server from before the reclaim pass, or a state not loaded) reads
|
||||
* as deletion off, not running and never checked.
|
||||
*/
|
||||
/**
|
||||
* One category of the last check as a line, or null when it has nothing to
|
||||
* say. A category the pass stopped in still counts what it found; one it
|
||||
* never judged counts nothing.
|
||||
*/
|
||||
function lineOf(
|
||||
category: ReclaimCategoryLine['category'],
|
||||
report: StorageReclaimCategoryReport | undefined
|
||||
): ReclaimCategoryLine | null {
|
||||
if (!report || report.skipped === 'not_reclaimed') return null;
|
||||
const judged = report.skipped === null || report.skipped === 'interrupted' || report.skipped === 'delete_failed';
|
||||
// What was deleted on this run no longer waits to be freed.
|
||||
const freeable = judged ? minus(report.eligible, report.deleted) : NONE;
|
||||
const waiting = judged ? minus(report.unreferenced, report.eligible) : NONE;
|
||||
const line = {
|
||||
category,
|
||||
freeable,
|
||||
waiting,
|
||||
confirmationRequired: report.confirmationRequired,
|
||||
unreferenced: report.unreferenced.objects,
|
||||
skipped: report.skipped,
|
||||
};
|
||||
const quiet = freeable.objects === 0 && waiting.objects === 0 && !line.confirmationRequired && line.skipped === null;
|
||||
return quiet ? null : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reclaim state as the storage panels show it, desktop and phone alike.
|
||||
* `null` (a server from before the reclaim pass, or a state not loaded) reads
|
||||
* as deletion off, not running and never checked.
|
||||
*/
|
||||
export function reclaimView(state: StorageReclaimState | null): ReclaimView {
|
||||
const run = state?.lastRun ?? null;
|
||||
const lines = STORAGE_RECLAIM_CATEGORIES.flatMap((category) => lineOf(category, run?.categories[category]) ?? []);
|
||||
const freeable = lines.reduce((sum, line) => add(sum, line.freeable), NONE);
|
||||
const waiting = lines.reduce((sum, line) => add(sum, line.waiting), NONE);
|
||||
const deleteRun = state?.lastDeleteRun ?? null;
|
||||
return {
|
||||
deleteEnabled: state?.deleteEnabled ?? false,
|
||||
running: state?.running ?? false,
|
||||
checkedAt: run?.finishedAt ?? null,
|
||||
stopped: run?.stopped ?? null,
|
||||
freeable,
|
||||
waiting,
|
||||
freed: freedBy(deleteRun),
|
||||
freedAt: deleteRun?.finishedAt ?? null,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,7 @@ const STATE: StorageAdminState = {
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
|
||||
@@ -14,6 +14,7 @@ function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminStat
|
||||
categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'],
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import type {
|
||||
StorageAdminState,
|
||||
StorageBackend,
|
||||
StorageConfig,
|
||||
StorageConfigPut,
|
||||
StorageReclaimConfirmRequest,
|
||||
StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { storageReclaimApi } from '../../../api/storageReclaim'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** A refused reclaim call: the server's code when it sent one the panel can name, its message otherwise. */
|
||||
export type ReclaimFailure = { code: string } | { message: string }
|
||||
|
||||
function reclaimFailureOf(err: unknown, fallback: string): ReclaimFailure {
|
||||
const code = (err as ApiError | null)?.response?.data as { code?: unknown } | undefined
|
||||
return typeof code?.code === 'string' && code.code.startsWith('reclaim_')
|
||||
? { code: code.code }
|
||||
: { message: getApiErrorMessage(err, fallback) }
|
||||
}
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
@@ -58,6 +76,12 @@ export interface StorageAdmin {
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Switches the nightly deletion of unreferenced files on or off; null on success. */
|
||||
setReclaimDelete: (enabled: boolean) => Promise<ReclaimFailure | null>
|
||||
/** Starts a check that only reports, in the background; the state polls until it is done. Null on success. */
|
||||
checkReclaim: () => Promise<ReclaimFailure | null>
|
||||
/** Lets the nightly run delete in a category where an unusually large share looks unused. Null on success. */
|
||||
confirmReclaim: (request: StorageReclaimConfirmRequest) => Promise<ReclaimFailure | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
@@ -271,7 +295,8 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
!state?.migrations.some((m) => m.status === 'running') &&
|
||||
!state?.reclaim?.running
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
@@ -356,6 +381,71 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
/**
|
||||
* Writes the reclaim part of the state. It counts as a deliberate state
|
||||
* application, so a poll GET that was in flight before it is dropped instead
|
||||
* of putting the switch back where it was.
|
||||
*/
|
||||
const setReclaim = useCallback((reclaim: NonNullable<StorageAdminState['reclaim']>) => {
|
||||
const prev = stateRef.current
|
||||
if (!prev) return
|
||||
stateSeq.current += 1
|
||||
const next = { ...prev, reclaim }
|
||||
stateRef.current = next
|
||||
setState(next)
|
||||
}, [])
|
||||
|
||||
const setReclaimDelete = useCallback(
|
||||
async (enabled: boolean): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
setReclaim(await storageReclaimApi.setDelete(enabled))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return reclaimFailureOf(err, genericError)
|
||||
}
|
||||
},
|
||||
[genericError, setReclaim],
|
||||
)
|
||||
|
||||
const checkReclaim = useCallback(async (): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
await storageReclaimApi.check()
|
||||
// Running from here on: the poll above picks the report up once the pass is done.
|
||||
const current = stateRef.current?.reclaim
|
||||
setReclaim({
|
||||
deleteEnabled: current?.deleteEnabled ?? false,
|
||||
running: true,
|
||||
lastRun: current?.lastRun ?? null,
|
||||
lastDeleteRun: current?.lastDeleteRun ?? null,
|
||||
})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return reclaimFailureOf(err, genericError)
|
||||
}
|
||||
}, [genericError, setReclaim])
|
||||
|
||||
const confirmReclaim = useCallback(
|
||||
async (request: StorageReclaimConfirmRequest): Promise<ReclaimFailure | null> => {
|
||||
try {
|
||||
setReclaim(await storageReclaimApi.confirm(request))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
const failure = reclaimFailureOf(err, genericError)
|
||||
// The count moved on since it was shown: show the current one before the admin confirms again.
|
||||
if ('code' in failure && failure.code === 'reclaim_confirm_stale') {
|
||||
try {
|
||||
await refreshState()
|
||||
} catch (reloadErr: unknown) {
|
||||
// Without the new figure there is nothing to confirm against: say why instead.
|
||||
return reclaimFailureOf(reloadErr, genericError)
|
||||
}
|
||||
}
|
||||
return failure
|
||||
}
|
||||
},
|
||||
[genericError, setReclaim, refreshState],
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
storageBusy,
|
||||
@@ -376,6 +466,9 @@ export function useStorageAdmin(genericError: string, conflictError: string): St
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
setReclaimDelete,
|
||||
checkReclaim,
|
||||
confirmReclaim,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-ADMIN-STOR-PANEL-001 to -017: the storage panel logic behind both admin shells.
|
||||
// FE-ADMIN-STOR-PANEL-001 to -020: the storage panel logic behind both admin shells.
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { STORAGE_CATEGORIES, type StorageAdminState, type StorageBackend, type StorageConfig } from '@trek/shared';
|
||||
@@ -28,6 +28,7 @@ function baseState(): StorageAdminState {
|
||||
) as StorageAdminState['categories'],
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
reclaim: null,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
@@ -57,6 +58,9 @@ function makeAdmin(state: StorageAdminState | null, draft?: StorageConfig): Stor
|
||||
startMigration: vi.fn(async () => null),
|
||||
cancelMigration: vi.fn(async () => null),
|
||||
refreshStats: vi.fn(async () => null),
|
||||
setReclaimDelete: vi.fn(async () => null),
|
||||
checkReclaim: vi.fn(async () => null),
|
||||
confirmReclaim: vi.fn(async () => null),
|
||||
refreshState: vi.fn(async () => {}),
|
||||
discardDraft: vi.fn(async () => {}),
|
||||
};
|
||||
@@ -282,3 +286,48 @@ describe('migrationPromptLine', () => {
|
||||
).toBe('storage.migrate.promptLineUnknown {"category":"storage.category.covers","from":"a","to":"b"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStoragePanel: unreferenced files', () => {
|
||||
it('FE-ADMIN-STOR-PANEL-018: the reclaim switch and the check go through the admin hook, a failure as a toast', async () => {
|
||||
fake.admin = makeAdmin(baseState());
|
||||
vi.mocked(fake.admin.checkReclaim).mockResolvedValueOnce({ code: 'reclaim_busy' });
|
||||
vi.mocked(fake.admin.setReclaimDelete).mockResolvedValueOnce({ message: 'disk full' });
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimDelete(true);
|
||||
});
|
||||
expect(fake.admin.setReclaimDelete).toHaveBeenCalledWith(true);
|
||||
// A failure without a code the panel knows shows the server's message.
|
||||
expect(toast.error).toHaveBeenCalledWith('disk full');
|
||||
expect(result.current.reclaimSaving).toBe(false);
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimCheck();
|
||||
});
|
||||
expect(fake.admin.checkReclaim).toHaveBeenCalledTimes(1);
|
||||
// A code the panel knows is shown in the operator's language.
|
||||
expect(toast.error).toHaveBeenCalledWith('storage.reclaim.error.reclaim_busy');
|
||||
expect(result.current.reclaimChecking).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-PANEL-020: confirming a category goes through the admin hook and says so; a running check keeps the button busy', async () => {
|
||||
const state = baseState();
|
||||
state.reclaim = { deleteEnabled: true, running: true, lastRun: null, lastDeleteRun: null };
|
||||
fake.admin = makeAdmin(state);
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
expect(result.current.reclaimChecking).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.handleReclaimConfirm({ category: 'places', unreferenced: 3 });
|
||||
});
|
||||
expect(fake.admin.confirmReclaim).toHaveBeenCalledWith({ category: 'places', unreferenced: 3 });
|
||||
expect(result.current.reclaimConfirming).toBeNull();
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.reclaim.confirmed');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STOR-PANEL-019: a server without the reclaim state reads as deletion off and never checked', () => {
|
||||
fake.admin = makeAdmin(baseState());
|
||||
const { result } = renderHook(() => useStoragePanel());
|
||||
expect(result.current.reclaim.deleteEnabled).toBe(false);
|
||||
expect(result.current.reclaim.checkedAt).toBeNull();
|
||||
expect(result.current.reclaim.lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,13 @@ import type {
|
||||
StorageConfig,
|
||||
StorageConfigPut,
|
||||
StorageMigrationStatus,
|
||||
StorageReclaimConfirmRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { useToast } from '../../shared/Toast';
|
||||
import { reclaimView } from './reclaimModel';
|
||||
import {
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel';
|
||||
import { useStorageAdmin } from './useStorageAdmin';
|
||||
import { useStorageAdmin, type ReclaimFailure } from './useStorageAdmin';
|
||||
|
||||
/** Display-name mapper for joined category lists: the raw id renders only in the badge. */
|
||||
export const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
@@ -165,6 +167,35 @@ export function useStoragePanel() {
|
||||
if (error) toast.error(error);
|
||||
};
|
||||
|
||||
// The click itself, until the server answered; the pass that follows shows through `reclaim.running`.
|
||||
const [reclaimStarting, setReclaimStarting] = useState(false);
|
||||
const [reclaimSaving, setReclaimSaving] = useState(false);
|
||||
const reclaimError = (failure: ReclaimFailure) =>
|
||||
'code' in failure ? t(`storage.reclaim.error.${failure.code}`) : failure.message;
|
||||
const handleReclaimCheck = async () => {
|
||||
setReclaimStarting(true);
|
||||
const failure = await admin.checkReclaim();
|
||||
setReclaimStarting(false);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
};
|
||||
const handleReclaimDelete = async (enabled: boolean) => {
|
||||
setReclaimSaving(true);
|
||||
const failure = await admin.setReclaimDelete(enabled);
|
||||
setReclaimSaving(false);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
};
|
||||
// The category whose confirmation is on its way, so its button waits for the answer.
|
||||
const [reclaimConfirming, setReclaimConfirming] = useState<StorageReclaimConfirmRequest['category'] | null>(null);
|
||||
const handleReclaimConfirm = async (request: StorageReclaimConfirmRequest) => {
|
||||
setReclaimConfirming(request.category);
|
||||
const failure = await admin.confirmReclaim(request);
|
||||
setReclaimConfirming(null);
|
||||
if (failure) toast.error(reclaimError(failure));
|
||||
else toast.success(t('storage.reclaim.confirmed'));
|
||||
};
|
||||
const reclaim = reclaimView(admin.state?.reclaim ?? null);
|
||||
const reclaimChecking = reclaimStarting || reclaim.running;
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return;
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev));
|
||||
@@ -371,6 +402,13 @@ export function useStoragePanel() {
|
||||
migrationQueue,
|
||||
handleCancelMigration,
|
||||
handleRefreshStats,
|
||||
reclaim,
|
||||
reclaimChecking,
|
||||
reclaimSaving,
|
||||
reclaimConfirming,
|
||||
handleReclaimCheck,
|
||||
handleReclaimDelete,
|
||||
handleReclaimConfirm,
|
||||
handleStartBackfill,
|
||||
handleCancelBackfill,
|
||||
loaded,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
@@ -41,7 +41,7 @@ export function auditUserLabel(e: AuditEntry): string {
|
||||
* cards render their own markup over it.
|
||||
*/
|
||||
export function useAuditLog(serverTimezone?: string) {
|
||||
const { locale } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -92,7 +92,7 @@ export function useAuditLog(serverTimezone?: string) {
|
||||
|
||||
const fmtTime = (iso: string) => {
|
||||
try {
|
||||
return new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString(locale, {
|
||||
return formatDateTime(new Date(iso.endsWith('Z') ? iso : iso + 'Z'), {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
timeZone: serverTimezone || undefined,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ChangeEvent, useEffect, useEffectEvent, useRef, useState } from 'react';
|
||||
|
||||
import { backupApi } from '../../api/client';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
@@ -104,7 +105,8 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
const { t, locale } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const is12h = useSettingsStore((s) => s.settings.time_format) === '12h';
|
||||
|
||||
const loadBackups = async () => {
|
||||
@@ -241,7 +243,7 @@ export function useBackupAdmin({ keepSettingsWhenMissing = false }: Options = {}
|
||||
minute: '2-digit',
|
||||
};
|
||||
if (serverTimezone) opts.timeZone = serverTimezone;
|
||||
return new Date(dateStr).toLocaleString(locale, opts);
|
||||
return formatDateTime(new Date(dateStr), opts);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DateFormat, DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl';
|
||||
import {
|
||||
defaultStyleForProvider,
|
||||
@@ -11,7 +12,13 @@ import {
|
||||
normalizeStyleForProvider,
|
||||
styleSettingKey,
|
||||
} from '../Map/glProviders';
|
||||
import { type MapProvider, normalizeProvider, styleForProvider } from '../Settings/mapSettingsModel';
|
||||
import {
|
||||
type MapProvider,
|
||||
mapboxOffered,
|
||||
mapPresetsFor,
|
||||
normalizeProvider,
|
||||
styleForProvider,
|
||||
} from '../Settings/mapSettingsModel';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import type { RoutingDefaults } from './RoutingInstanceFields';
|
||||
|
||||
@@ -21,6 +28,7 @@ export type Defaults = RoutingDefaults & {
|
||||
dark_mode?: string | boolean;
|
||||
time_format?: string;
|
||||
week_start?: WeekStart;
|
||||
date_format?: DateFormat;
|
||||
default_currency?: string;
|
||||
blur_booking_codes?: boolean;
|
||||
map_tile_url?: string;
|
||||
@@ -53,6 +61,9 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [mapTileUrl, setMapTileUrl] = useState('');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
|
||||
// The operator's token reaches the admin's own settings the same way it reaches everybody's.
|
||||
const operatorMapboxToken = useSettingsStore((s) => s.settings.mapbox_access_token);
|
||||
const [mapboxToken, setMapboxToken] = useState('');
|
||||
const [cartoKey, setCartoKey] = useState('');
|
||||
const [mapboxStyle, setMapboxStyle] = useState('');
|
||||
@@ -153,6 +164,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
defaults,
|
||||
loaded,
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, operatorMapboxToken),
|
||||
isSet,
|
||||
save,
|
||||
reset,
|
||||
|
||||
@@ -2301,15 +2301,12 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// The entered amount was nowhere on the row, so $791.57 read as a wrong copy of
|
||||
// the 801.76 in the dialog. The pill beside the line is the last step.
|
||||
expect(screen.getByText(/^\$801\.76 → 685,26\s€$/)).toBeInTheDocument()
|
||||
// Row total and payer chip stay the booked euros at today's rate, and so does what
|
||||
// I lent: half of 685.26 EUR, the figure settle-up offers. Printing $400.88 here
|
||||
// had Bob pay back more than he owed and reopened the trip.
|
||||
expect(screen.getAllByText('$791.55')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $395.77')).toBeInTheDocument()
|
||||
expect(screen.queryByText('you lent $400.88')).toBeNull()
|
||||
// A bill in the display currency counts as typed (#2965), as the balances net it:
|
||||
// row total and payer chip read the dollars entered, nothing to explain under it,
|
||||
// and what I lent is half of them, which settle-up offers too.
|
||||
expect(screen.queryByText(/^\$801\.76 → /)).toBeNull()
|
||||
expect(screen.getAllByText('$801.76')).toHaveLength(2)
|
||||
expect(screen.getByText('you lent $400.88')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-087: a bill booked at today\'s rate reads exactly as typed everywhere on the page', async () => {
|
||||
@@ -2344,7 +2341,8 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
await waitFor(() => expect(exported).toBeTruthy())
|
||||
const lines = (await exported!.text()).replace(/^\uFEFF/, '').split('\r\n')
|
||||
expect(lines[0]).toBe('Date;Name;Category;Amount;Currency;Amount (EUR);Amount (USD);Note')
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;791\.55;$/)
|
||||
// The trip-currency figure it was booked at, then the dollars as typed (#2965).
|
||||
expect(lines[1]).toMatch(/;801\.76;USD;685\.26;801\.76;$/)
|
||||
} finally {
|
||||
createObjURL.mockRestore(); revokeObjURL.mockRestore(); clickSpy.mockRestore()
|
||||
}
|
||||
@@ -2357,13 +2355,13 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
// 120 USD at 1.17 is 102.56 EUR, $118.47 today; with the hotel's $791.55 that is
|
||||
// $910.02, on the day header and on the total spend card.
|
||||
expect(screen.getByText(/^\$120\.00 → 102,56\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$910.02 spent')).toBeInTheDocument()
|
||||
// Both in the display currency, both as typed (#2965): $801.76 and $120.00 make
|
||||
// $921.76, on the day header and on the total spend card.
|
||||
expect(screen.queryByText(/^\$120\.00 → /)).toBeNull()
|
||||
expect(screen.getByText('$921.76 spent')).toBeInTheDocument()
|
||||
// Label, its text block, the header row, then the card itself.
|
||||
const card = screen.getByText('Total trip spend').parentElement!.parentElement!.parentElement!
|
||||
expect(card.textContent).toContain('$910.02')
|
||||
expect(card.textContent).toContain('$921.76')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-084: the totals follow the rates when they arrive after the expenses', async () => {
|
||||
@@ -2399,7 +2397,7 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
expect(screen.queryByText(/^\$50\.00 →/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars is booked like an expense, a legacy one stays as entered', async () => {
|
||||
it('FE-W5COSTS-082: a payment recorded in dollars reads as entered, a legacy one too (#2965)', async () => {
|
||||
mount([hotel()], {
|
||||
settlements: [
|
||||
{ id: 21, from_user_id: 2, to_user_id: 1, amount: 400.88, currency: 'USD', exchange_rate: 1.17, created_at: '2026-08-07 09:00:00' },
|
||||
@@ -2410,29 +2408,26 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
})
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(await screen.findByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
expect(await screen.findByText('$30.00')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
expect(outsideChips('$400.88')).toHaveLength(1)
|
||||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-085: the edit dialog previews the rate the save keeps, the one the row was booked at', async () => {
|
||||
it('FE-W5COSTS-085: the edit dialog has nothing to convert for a bill in the display currency (#2965)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mount([hotel()])
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
await user.click(screen.getByRole('button', { name: /^Edit(:|$)/ }))
|
||||
expect(await screen.findByDisplayValue('801.76')).toBeInTheDocument()
|
||||
// Same currency as the list, so the dialog said nothing, and 801.76 there beside
|
||||
// $791.57 in the list looked like two different expenses.
|
||||
const hint = screen.getByText(/live rate/).parentElement as HTMLElement
|
||||
expect(within(hint).getByText('$801.76')).toBeInTheDocument()
|
||||
expect(within(hint).getByText(/^685,26\s€$/)).toBeInTheDocument()
|
||||
expect(within(hint).getByText('$791.55')).toBeInTheDocument()
|
||||
// Each share next to what it counts as, the figure the row's "you lent" is made of.
|
||||
expect(screen.getByText(/\$400\.88 → \$395\.77 per person/)).toBeInTheDocument()
|
||||
// The list reads the dollars typed, so there is no conversion to preview.
|
||||
expect(screen.queryByText(/live rate/)).toBeNull()
|
||||
expect(screen.getByText(/\$400\.88 per person/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/\$400\.88 → /)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-086: the final budget names the bill by what was entered beside its booked figure', async () => {
|
||||
|
||||
@@ -89,7 +89,8 @@ describe('costsModel: recorded payments', () => {
|
||||
|
||||
it('FE-BUDGET-COSTSMODEL-005: a legacy payment without a currency was entered in the display currency', () => {
|
||||
const inUsd: CostsCtx = { ...ctx, displayCurrency: 'USD' };
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(5);
|
||||
// Entered in the display currency, it reads as entered (#2965).
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), inUsd)).toBe(10);
|
||||
expect(paymentAmount(payment({ amount: 10, currency: null }), ctx)).toBe(10);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { BudgetParticipantFinal, BudgetUnconverted, CostCategory } from '@trek/shared';
|
||||
import { convertBooked, convertedLine, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import { readDateFormat } from '../../hooks/useDateFormat';
|
||||
import { convertBooked, convertedLine, inDisplayCurrency, tripAmountOf } from '../../hooks/useExchangeRates';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { formatDisplayDate } from '../../utils/dateFormat';
|
||||
import { currencyDecimals } from '../../utils/formatters';
|
||||
import { currenciesWith, SYMBOLS } from './BudgetPanel.constants';
|
||||
import { paidByUser, readUserNote, settlementDate, splitEqualShares } from './CostsPanel.helpers';
|
||||
@@ -30,8 +32,12 @@ export function currencyOf(e: BudgetItem, ctx: CostsCtx): string {
|
||||
return (e.currency || ctx.tripCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** An amount of this expense in the display currency, at the rate the expense was booked at. */
|
||||
/**
|
||||
* An amount of this expense in the display currency, at the rate the expense was booked at;
|
||||
* as typed when it was entered in the display currency (#2965).
|
||||
*/
|
||||
export function booked(amount: number, e: BudgetItem, ctx: CostsCtx): number {
|
||||
if (inDisplayCurrency(e.currency, ctx.tripCurrency, ctx.displayCurrency)) return amount;
|
||||
return convertBooked(amount, e.currency, e.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
@@ -130,9 +136,14 @@ function paymentCurrency(s: CostsSettlement, ctx: CostsCtx): string {
|
||||
return (s.currency || ctx.displayCurrency).toUpperCase();
|
||||
}
|
||||
|
||||
/** A recorded payment in the display currency, at the rate frozen when it was settled (#1445). */
|
||||
/**
|
||||
* A recorded payment in the display currency, at the rate frozen when it was settled (#1445);
|
||||
* as entered when it was made in the display currency (#2965).
|
||||
*/
|
||||
export function paymentAmount(s: CostsSettlement, ctx: CostsCtx): number {
|
||||
return convertBooked(s.amount, paymentCurrency(s, ctx), s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
const currency = paymentCurrency(s, ctx);
|
||||
if (inDisplayCurrency(currency, ctx.tripCurrency, ctx.displayCurrency)) return s.amount;
|
||||
return convertBooked(s.amount, currency, s.exchange_rate, ctx.tripCurrency, ctx.convert);
|
||||
}
|
||||
|
||||
/** The line under a payment shown converted, explained the way an expense's is (#2525). */
|
||||
@@ -369,12 +380,17 @@ export function buildCostsCsv(items: BudgetItem[], opts: CsvBuildOptions): { fil
|
||||
const fmtDate = (iso: string) => {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
return new Date(iso + 'T00:00:00Z').toLocaleDateString(opts.locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
return formatDisplayDate(
|
||||
new Date(iso + 'T00:00:00Z'),
|
||||
opts.locale,
|
||||
{
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
readDateFormat()
|
||||
);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ describe('editingCurrencyOf', () => {
|
||||
|
||||
describe('expenseFxPreview', () => {
|
||||
it('previews an edit that keeps the currency at the rate frozen on entry', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })!
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'GBP' })!
|
||||
expect(fx.inTrip).toBeCloseTo(685.26, 2)
|
||||
expect(fx.shown).toBeCloseTo(791.55, 2)
|
||||
expect(fx.shown).toBeCloseTo(convert(801.76 / 1.17, 'EUR'), 2)
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency, which counts as typed (#2965)', () => {
|
||||
// Frozen at 1.17 while a euro buys 1.1551 today: still the dollars typed.
|
||||
expect(expenseFxPreview({ ...base, total: 801.76, currency: 'USD', base: 'USD' })).toBeNull()
|
||||
})
|
||||
|
||||
it('has nothing to preview for a bill in the display currency that reads back as typed', () => {
|
||||
@@ -47,8 +52,8 @@ describe('expenseFxPreview', () => {
|
||||
|
||||
describe('splitShareLabel', () => {
|
||||
it('prints the entered share, then the share the row lends or borrows', () => {
|
||||
const fx = expenseFxPreview({ ...base, total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', fx, 2, 'USD', 'en-US')).toBe('$400.88 → $395.77')
|
||||
const fx = expenseFxPreview({ ...base, total: 1700, currency: 'JPY', editing: null })
|
||||
expect(splitShareLabel(850, 'JPY', fx, 2, 'USD', 'en-US')).toBe(`${formatMoney(850, 'JPY', 'en-US')} → ${formatMoney(convert(1700, 'JPY') / 2, 'USD', 'en-US')}`)
|
||||
// Read in the trip currency, the share is the booked euros.
|
||||
const inEur = expenseFxPreview({ ...base, base: 'EUR', total: 801.76, currency: 'USD' })
|
||||
expect(splitShareLabel(400.88, 'USD', inEur, 2, 'EUR', 'en-US')).toBe(`$400.88 → ${formatMoney(342.63, 'EUR', 'en-US')}`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookedInTrip, useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { currencyDecimals, formatMoney } from '../../utils/formatters'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The conversion preview of the expense editors, shared by the desktop ExpenseModal and the
|
||||
@@ -33,8 +33,8 @@ export interface ExpenseFx {
|
||||
* What the amount being edited will count as. An edit that keeps the currency keeps the rate
|
||||
* frozen when the expense was entered (the server re-freezes only when the currency changes),
|
||||
* so it is previewed at that rate, the way the list will show it after the save. Anything
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, including a bill
|
||||
* in the display currency that still reads back as typed.
|
||||
* else is previewed at today's rate. Null when there is nothing to convert, and for a bill in
|
||||
* the display currency, which counts as typed (#2965).
|
||||
*/
|
||||
export function expenseFxPreview(args: {
|
||||
total: number
|
||||
@@ -46,12 +46,9 @@ export function expenseFxPreview(args: {
|
||||
convert: (amount: number, from: string | null | undefined) => number
|
||||
}): ExpenseFx | null {
|
||||
const { total, currency, editing, editingCurrency, tripCurrency, base, convert } = args
|
||||
if (total === 0) return null
|
||||
if (total === 0 || currency === base) return null
|
||||
const inTrip = editing && currency === editingCurrency ? bookedInTrip(total, currency, editing.exchange_rate, tripCurrency) : null
|
||||
if (currency === base && inTrip == null) return null
|
||||
const shown = inTrip == null ? convert(total, currency) : convert(inTrip, tripCurrency)
|
||||
const unit = 10 ** currencyDecimals(base)
|
||||
if (currency === base && Math.round(shown * unit) === Math.round(total * unit)) return null
|
||||
return { inTrip, shown: inTrip != null && tripCurrency === base ? null : shown }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { DialogButton, DialogFooter, DialogHeader, DialogShell, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
@@ -64,6 +65,7 @@ export default function DawarichAcceptDialog({
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
const phone = useIsPhone()
|
||||
const labelId = useId()
|
||||
|
||||
@@ -140,7 +142,7 @@ export default function DawarichAcceptDialog({
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
from: formatDateTime(new Date(suggestion.startedAt)),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FileText, FileImage, File, FileVideo, Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||
import { downloadFile } from '../../utils/fileDownload'
|
||||
import { readDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
export function isImage(mimeType?: string | null) {
|
||||
if (!mimeType) return false
|
||||
@@ -57,10 +59,10 @@ export function triggerDownload(url: string, filename: string) {
|
||||
downloadFile(url, filename).catch(() => {})
|
||||
}
|
||||
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string) {
|
||||
export function formatDateWithLocale(dateStr?: string | null, locale?: string, dateFormat: string = readDateFormat()) {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
return formatDisplayDate(new Date(dateStr), locale, { day: '2-digit', month: '2-digit', year: 'numeric' }, dateFormat)
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from '../../../i18n/TranslationContext'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import { Tooltip } from '../../shared/Tooltip'
|
||||
|
||||
@@ -83,11 +84,12 @@ export function StateBadge({ state, compact }: { state: string; compact?: boolea
|
||||
|
||||
/** "2h ago", or "never" when a binding has not run yet. */
|
||||
export function LastRun({ at }: { at: string | null }) {
|
||||
const { t, language, locale } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
|
||||
const ms = Date.parse(at)
|
||||
if (Number.isNaN(ms)) return null
|
||||
return <Tooltip label={new Date(ms).toLocaleString(locale)}><span>{relativeTime(ms, language)}</span></Tooltip>
|
||||
return <Tooltip label={formatDateTime(new Date(ms))}><span>{relativeTime(ms, language)}</span></Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HelpGuide } from '../../help/types'
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
@@ -27,6 +28,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
const openLightbox = useHelpStore(s => s.openLightbox)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
// No Help & Docs page on a managed install (App.tsx), so no link into it.
|
||||
const docs = useAuthStore(s => s.managed) ? undefined : guide.docs
|
||||
const openGuide = useHelpStore(s => s.openGuide)
|
||||
const title = t(guideKey(guide.id, 'title'))
|
||||
const screen = getHelpContext(guide.context)
|
||||
@@ -180,9 +183,9 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
)}
|
||||
|
||||
{/* Go there / docs / related */}
|
||||
{(guide.link || guide.docs || related.length > 0) && (
|
||||
{(guide.link || docs || related.length > 0) && (
|
||||
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(guide.link || guide.docs) && (
|
||||
{(guide.link || docs) && (
|
||||
<div className="rounded-2xl border border-edge bg-surface-card p-4 flex flex-col gap-3">
|
||||
<HelpBadge tone="neutral" icon={Link2} uppercase className="self-start">{t('help.center.docsSection')}</HelpBadge>
|
||||
{guide.link && (
|
||||
@@ -195,8 +198,8 @@ export default function HelpGuideView({ guide }: { guide: HelpGuide }): React.Re
|
||||
<ArrowUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
)}
|
||||
{guide.docs && (
|
||||
<HelpDocLink to={docsRoute(guide.docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, guide.docs.slug)} />
|
||||
{docs && (
|
||||
<HelpDocLink to={docsRoute(docs)} title={t('help.center.openDocs')} subtitle={docTitle(docIndex, docs.slug)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { HelpContext, HelpDocLink as HelpDocTarget, HelpGuide } from '../..
|
||||
import { helpIcon } from './helpIcons'
|
||||
import HelpBadge from './HelpBadge'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import HelpSizeChip from './HelpSizeChip'
|
||||
import { useScreenRoute } from './useScreenRoute'
|
||||
|
||||
@@ -19,6 +20,8 @@ import { useScreenRoute } from './useScreenRoute'
|
||||
export default function HelpHome({ context }: { context: HelpContext | null }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const docIndex = useHelpStore(s => s.docIndex)
|
||||
// No Help & Docs page on a managed install (App.tsx), so no links into it.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
const closeHelp = useHelpStore(s => s.closeHelp)
|
||||
const screenRoute = useScreenRoute(context?.route)
|
||||
|
||||
@@ -35,7 +38,7 @@ export default function HelpHome({ context }: { context: HelpContext | null }):
|
||||
const guides = guidesFor(context)
|
||||
const bullets = Array.from({ length: context.bullets }, (_, i) => t(ctxBulletKey(context.id, i + 1)))
|
||||
const children = childHelpContexts(context.id)
|
||||
const docs = onePerPage(context.docs)
|
||||
const docs = managed ? [] : onePerPage(context.docs)
|
||||
const trail = helpContextTrail(context).slice(0, -1)
|
||||
const Icon = helpIcon(context.icon)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { allHelpGuides, guideKey, guideStepKey, guideTipKey, ctxKey } from '../.
|
||||
import type { HelpGuide } from '../../help/types'
|
||||
import { GuideCard, SectionLabel } from './HelpHome'
|
||||
import HelpDocLink from './HelpDocLink'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
/** Guides shown for a query, at most. The docs list is capped by the server. */
|
||||
const MAX_GUIDES = 8
|
||||
@@ -62,6 +63,8 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const query = useHelpStore(s => s.query)
|
||||
const status = useHelpStore(s => s.searchStatus)
|
||||
// No Help & Docs page on a managed install (App.tsx): guides only.
|
||||
const managed = useAuthStore(s => s.managed)
|
||||
const docHits = useHelpStore(s => s.docHits)
|
||||
const q = query.trim()
|
||||
|
||||
@@ -75,7 +78,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
.map(x => x.g)
|
||||
}, [q, t])
|
||||
|
||||
const nothing = guides.length === 0 && status === 'done' && docHits.length === 0
|
||||
const nothing = guides.length === 0 && (managed || (status === 'done' && docHits.length === 0))
|
||||
|
||||
return (
|
||||
<div className="trek-help-view flex flex-col gap-8">
|
||||
@@ -93,6 +96,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!managed && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionLabel>{t('help.center.searchDocs')}</SectionLabel>
|
||||
@@ -113,6 +117,7 @@ export default function HelpSearchResults(): React.ReactElement {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{nothing && (
|
||||
<div className="flex flex-col items-center text-center gap-2 py-10">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpe
|
||||
import { seedStore } from '../../../tests/helpers/store'
|
||||
import { buildSettings } from '../../../tests/helpers/factories'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
|
||||
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
|
||||
@@ -102,6 +103,8 @@ beforeEach(() => {
|
||||
// The time field reads time_format, so pin it: without this the cases that
|
||||
// assert a 24h string depend on whichever test ran before them (#2067).
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
|
||||
// A photo provider switched on for the install, so the External photos source is offered.
|
||||
seedStore(useAddonStore, { addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true } as never] })
|
||||
window.__addToast = toastSpy
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
|
||||
@@ -430,6 +433,14 @@ describe('EntryEditor', () => {
|
||||
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-018b: offers no External photos source where no photo provider is on', () => {
|
||||
seedStore(useAddonStore, { addons: [] })
|
||||
mountEditor(buildEntry({ id: 10 }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'External photos' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Upload photos/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
|
||||
server.use(http.get('/api/addons', () => HttpResponse.json({})))
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, D
|
||||
import { AddRowButton, EditorField, INPUT } from '../shared/dialogParts'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useJourneyEntryForm, type PendingProviderGroup } from './useJourneyEntryForm'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
|
||||
/** A chip of the mood and weather rows, as in the collection dialogs' category row. */
|
||||
const CHIP = 'inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-semibold transition-colors'
|
||||
@@ -139,6 +140,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const photoProvidersOn = useAddonStore(s => s.isEnabled('memories'))
|
||||
const {
|
||||
title, setTitle, story, setStory, entryDate, setEntryDate, entryTime, setEntryTime,
|
||||
locationName,
|
||||
@@ -273,14 +275,19 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
aria-pressed={photoTab === 'external'}
|
||||
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
|
||||
</button>
|
||||
{/* Only where the install has a photo provider (Immich, Synology) switched
|
||||
on, as on the phone: a source that can only say "nothing connected"
|
||||
is not worth its width, and the other two share the row instead. */}
|
||||
{photoProvidersOn && (
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('external'); setShowGalleryPick(false) }}
|
||||
disabled={saving}
|
||||
aria-pressed={photoTab === 'external'}
|
||||
className={`${SOURCE} flex-1 ${photoTab === 'external' ? SOURCE_ON : SOURCE_OFF}`}
|
||||
>
|
||||
<Image size={13} /> {t('journey.editor.externalPhotos') || 'External photos'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The gallery picker, directly below the buttons. Safari collapses
|
||||
@@ -312,7 +319,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{photoTab === 'external' && (
|
||||
{photoTab === 'external' && photoProvidersOn && (
|
||||
<div className={`${PHOTO_PANEL} flex flex-col overflow-hidden`} style={{ height: 'min(56vh, 520px)' }}>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-edge-faint px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -87,6 +87,16 @@ describe('Navbar', () => {
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-009b: a managed install keeps the help center but has no Help & Docs entry', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, managed: true });
|
||||
render(<Navbar />);
|
||||
expect(document.querySelector('[data-help-button]')).not.toBeNull();
|
||||
await user.click(screen.getByText('testuser'));
|
||||
expect(screen.queryByText('Help')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Navbar />);
|
||||
|
||||
@@ -35,7 +35,7 @@ interface Addon {
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings }: NavbarProps): React.ReactElement {
|
||||
const { user, logout, isPrerelease, appVersion } = useAuthStore()
|
||||
const { user, logout, isPrerelease, appVersion, managed } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { addons: allAddons, loadAddons } = useAddonStore()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -326,6 +326,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
{t('nav.settings')}
|
||||
</Link>
|
||||
|
||||
{!managed && (
|
||||
<Link to="/help" onClick={() => setUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary"
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
@@ -333,6 +334,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
|
||||
<BookOpen className="w-4 h-4" />
|
||||
{t('nav.help')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
|
||||
import { pdfSwitchOn } from '../../utils/pdfSwitch'
|
||||
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
|
||||
import { onlyMyPlan, planAssignments } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
|
||||
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
return renderIconMarkup(
|
||||
createElement(icon, props)
|
||||
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
|
||||
markdown.dispose()
|
||||
if (signal?.aborted) return
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'pdf-preview-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove()
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
|
||||
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
|
||||
})
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
|
||||
})
|
||||
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
|
||||
}
|
||||
|
||||
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
|
||||
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
|
||||
return classes.length ? ` class="${classes.join(' ')}"` : ''
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
|
||||
</button>`
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
|
||||
if (!button) return
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true'
|
||||
button.setAttribute('aria-checked', String(on))
|
||||
button.style.background = trackColour(on)
|
||||
const knob = button.firstElementChild as HTMLElement | null
|
||||
if (knob) knob.style.left = knobOffset(on)
|
||||
onChange(on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
|
||||
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
|
||||
* there the preview hands the export to Safari instead of offering a button that
|
||||
* does nothing.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
|
||||
|
||||
const tr = (key: string) => key;
|
||||
|
||||
function open(overrides: Partial<PdfPreviewOptions> = {}) {
|
||||
openPdfPreview({
|
||||
html: '<html><body>plan</body></html>',
|
||||
title: 'Lisbon',
|
||||
tripId: 9,
|
||||
tr,
|
||||
breaksPerDay: true,
|
||||
showTransportNotes: true,
|
||||
hasTransportNotes: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function standalone(value: boolean) {
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('pdf-preview-overlay')?.remove();
|
||||
delete (navigator as unknown as Record<string, unknown>).standalone;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('openPdfPreview', () => {
|
||||
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
|
||||
open();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
expect(document.getElementById('pdf-safari-hint')).toBeNull();
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
|
||||
const print = vi.fn();
|
||||
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
|
||||
const button = document.getElementById('pdf-print-btn');
|
||||
expect(button?.textContent).toBe('pdf.saveAsPdf');
|
||||
button?.click();
|
||||
expect(print).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
|
||||
standalone(true);
|
||||
open();
|
||||
expect(document.getElementById('pdf-print-btn')).toBeNull();
|
||||
|
||||
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
|
||||
expect(link.textContent).toBe('pdf.openInSafari');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener');
|
||||
const url = new URL(link.href);
|
||||
expect(url.pathname).toBe('/trips/9');
|
||||
expect(url.searchParams.get('export')).toBe('pdf');
|
||||
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
|
||||
standalone(true);
|
||||
open({ tripId: undefined });
|
||||
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
|
||||
open({ title: '<b>Lisbon</b>' });
|
||||
const overlay = document.getElementById('pdf-preview-overlay');
|
||||
expect(overlay?.querySelector('b')).toBeNull();
|
||||
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
|
||||
document.getElementById('pdf-close-btn')?.click();
|
||||
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
|
||||
open();
|
||||
const toggle = document.getElementById('pdf-daybreak-toggle');
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('true');
|
||||
toggle?.click();
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('false');
|
||||
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { escapeHtml } from '@trek/shared';
|
||||
|
||||
import { isIosStandalone } from '../../utils/iosDevice';
|
||||
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
|
||||
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK =
|
||||
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
|
||||
const TOGGLE_KNOB =
|
||||
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
|
||||
|
||||
const HEADER_ACTION =
|
||||
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface PdfPreviewOptions {
|
||||
/** The finished document, shown in a sandboxed srcdoc frame. */
|
||||
html: string;
|
||||
title: string;
|
||||
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
|
||||
tripId?: number;
|
||||
tr: Translate;
|
||||
breaksPerDay: boolean;
|
||||
showTransportNotes: boolean;
|
||||
/** The transport-notes switch only shows when there is a note to hide. */
|
||||
hasTransportNotes: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that saves the document. A browser prints the frame and offers
|
||||
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
|
||||
* Screen ignores print() altogether, so there the control opens the trip in
|
||||
* Safari with the export dialog up, and a line under the header says why.
|
||||
*/
|
||||
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
|
||||
if (tripId == null || !isIosStandalone()) {
|
||||
return {
|
||||
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
|
||||
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
|
||||
hint: tr('pdf.safariHint'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
|
||||
export function openPdfPreview({
|
||||
html,
|
||||
title,
|
||||
tripId,
|
||||
tr,
|
||||
breaksPerDay,
|
||||
showTransportNotes,
|
||||
hasTransportNotes,
|
||||
}: PdfPreviewOptions): void {
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pdf-preview-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);display:flex;align-items:center;justify-content:center;padding:8px;';
|
||||
overlay.onclick = (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
};
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.style.cssText =
|
||||
'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
|
||||
|
||||
const save = saveControl(tripId, tr);
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText =
|
||||
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
${save.html}
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;';
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals';
|
||||
iframe.srcdoc = html;
|
||||
|
||||
card.appendChild(header);
|
||||
if (save.hint) {
|
||||
const hint = document.createElement('p');
|
||||
hint.id = 'pdf-safari-hint';
|
||||
hint.style.cssText =
|
||||
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
hint.textContent = save.hint;
|
||||
card.appendChild(hint);
|
||||
}
|
||||
card.appendChild(iframe);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove();
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
|
||||
if (printBtn)
|
||||
printBtn.onclick = () => {
|
||||
iframe.contentWindow?.print();
|
||||
};
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
|
||||
});
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
|
||||
});
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
|
||||
if (!button) return;
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true';
|
||||
button.setAttribute('aria-checked', String(on));
|
||||
button.style.background = trackColour(on);
|
||||
const knob = button.firstElementChild as HTMLElement | null;
|
||||
if (knob) knob.style.insetInlineStart = knobOffset(on);
|
||||
onChange(on);
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import { airtrailApi } from '../../api/client'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { parseReservationMetadata } from '../../utils/flightLegs'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
interface AirTrailImportModalProps {
|
||||
isOpen: boolean
|
||||
@@ -52,15 +54,15 @@ export function detectConnections(flights: AirtrailFlight[]): AirtrailFlight[][]
|
||||
}
|
||||
|
||||
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
||||
function fmtDate(d: string | null, locale: string): string {
|
||||
function fmtDate(d: string | null, locale: string, dateFormat: string): string {
|
||||
if (!d) return ''
|
||||
try {
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||
return formatDisplayDate(new Date(d + 'T00:00:00Z'), locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
}, dateFormat)
|
||||
} catch {
|
||||
return d
|
||||
}
|
||||
@@ -68,6 +70,7 @@ function fmtDate(d: string | null, locale: string): string {
|
||||
|
||||
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const dateFormat = useDateFormat()
|
||||
const toast = useToast()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const reservations = useTripStore(s => s.reservations)
|
||||
@@ -270,7 +273,7 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<span style={{ display: 'block', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||
<span style={{ display: 'block', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-muted)' }}>
|
||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale, dateFormat)}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{already && (
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// FE-PLANNER-DAYDETAIL-DF-001 to FE-PLANNER-DAYDETAIL-DF-002
|
||||
// The day panel's heading with "date first in day headings" on (#2921).
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { buildAdmin, buildDay, buildTrip } from '../../../tests/helpers/factories';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import DayDetailPanel from './DayDetailPanel';
|
||||
|
||||
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
|
||||
|
||||
const props = {
|
||||
day,
|
||||
days: [day],
|
||||
places: [],
|
||||
categories: [],
|
||||
tripId: 1,
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
lat: null,
|
||||
lng: null,
|
||||
onClose: vi.fn(),
|
||||
onAccommodationChange: vi.fn(),
|
||||
};
|
||||
|
||||
function settings(dayDateFirst: boolean) {
|
||||
seedStore(useSettingsStore, {
|
||||
settings: { time_format: '24h', temperature_unit: 'celsius', day_date_first: dayDateFirst },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
|
||||
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [] }))
|
||||
);
|
||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
});
|
||||
|
||||
describe('DayDetailPanel with the date first', () => {
|
||||
it('FE-PLANNER-DAYDETAIL-DF-001: the pencil sits with the name, and renaming keeps the date in view', async () => {
|
||||
settings(true);
|
||||
const user = userEvent.setup();
|
||||
const onUpdateDayTitle = vi.fn();
|
||||
render(<DayDetailPanel {...props} onUpdateDayTitle={onUpdateDayTitle} />);
|
||||
const pencil = screen.getByLabelText('Edit');
|
||||
expect(screen.getByText('Day in Paris').parentElement).toContainElement(pencil);
|
||||
expect(screen.getByText(/June 15/).parentElement).not.toContainElement(pencil);
|
||||
await user.click(pencil);
|
||||
const input = await screen.findByDisplayValue('Day in Paris');
|
||||
expect(screen.getByText(/June 15/)).toBeInTheDocument();
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Vegueta');
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onUpdateDayTitle).toHaveBeenCalledWith(1, 'Vegueta');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-DAYDETAIL-DF-002: with the name first the pencil stays on the heading, as before', async () => {
|
||||
settings(false);
|
||||
const user = userEvent.setup();
|
||||
render(<DayDetailPanel {...props} onUpdateDayTitle={vi.fn()} />);
|
||||
const pencil = screen.getByLabelText('Edit');
|
||||
expect(screen.getByText('Day in Paris').parentElement).toContainElement(pencil);
|
||||
await user.click(pencil);
|
||||
expect(await screen.findByDisplayValue('Day in Paris')).toBeInTheDocument();
|
||||
expect(screen.getByText(/June 15/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -142,6 +142,28 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||
) : null
|
||||
const heading = dayHeadingParts(day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' }), formattedDate, dateFirst)
|
||||
// The title input takes the place and size of the line it edits: the heading, or the line under it.
|
||||
const titleInput = (lead: boolean) => (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
className={`w-full border-0 border-b-[1.5px] border-content bg-transparent p-0 text-content outline-none placeholder:text-content-faint ${lead ? 'font-bold' : 'font-geist'}`}
|
||||
style={lead ? fs(17, 'subtitle') : fs(12.5)}
|
||||
/>
|
||||
)
|
||||
const renamePencil = canEditDays && onUpdateDayTitle && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" onClick={startRename} aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-content">
|
||||
<Pencil size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
const showError = (err: unknown) => toast.error(apiErrorMessage(err) || t('common.unknownError'))
|
||||
|
||||
// Saving the picker: a new stay or the one being edited. Both say what went
|
||||
@@ -180,18 +202,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
<Calendar size={collapsed ? 16 : 20} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
placeholder={t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
className="w-full border-0 border-b-[1.5px] border-content bg-transparent p-0 font-bold text-content outline-none placeholder:text-content-faint"
|
||||
style={fs(17, 'subtitle')}
|
||||
/>
|
||||
{editingTitle && (collapsed || heading.nameFirst) ? (
|
||||
titleInput(true)
|
||||
) : collapsed ? (
|
||||
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>
|
||||
{heading.primary}
|
||||
@@ -200,17 +212,20 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-bold tracking-[-0.01em] text-content" style={fs(17, 'subtitle')}>{heading.primary}</span>
|
||||
{canEditDays && onUpdateDayTitle && (
|
||||
<Tooltip label={t('common.edit')}>
|
||||
<button type="button" onClick={startRename} aria-label={t('common.edit')}
|
||||
className="grid h-6 w-6 flex-none place-items-center rounded-full text-content-faint hover:bg-surface-card hover:text-content">
|
||||
<Pencil size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{heading.nameFirst && renamePencil}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && heading.secondary && <div className="mt-0.5 truncate font-geist text-content-muted" style={fs(12.5)}>{heading.secondary}</div>}
|
||||
{/* With the date first the name is this line, so its pencil and its input are here too (#2921). */}
|
||||
{!collapsed && heading.secondary && (
|
||||
editingTitle && !heading.nameFirst ? (
|
||||
<div className="mt-0.5">{titleInput(false)}</div>
|
||||
) : (
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-geist text-content-muted" style={fs(12.5)}>{heading.secondary}</span>
|
||||
{!heading.nameFirst && renamePencil}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<HeadButton label={collapsed ? t('common.expand') : t('common.collapse')} expanded={!collapsed}
|
||||
onClick={() => toggleCollapse()}>
|
||||
|
||||
@@ -49,12 +49,12 @@ import { getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, sho
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay,
|
||||
getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop, stopDayIds,
|
||||
getAssignmentReservations, timedSlot, storedRideSlot, seatTimedDrop, stopDayIds, chronoKeyOf,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { withinDriveRange } from '@trek/shared/roadtrip'
|
||||
import { formatDate, formatTime, formatMoneySum, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { dayHeadingParts } from '../../utils/dayLabel'
|
||||
import { dayHeadingParts, headingFit } from '../../utils/dayLabel'
|
||||
import { pendingStayIds } from '../../utils/pendingStays'
|
||||
import { dayWeatherAnchor } from '../../utils/dayWeather'
|
||||
import { metadataWithLegPositions, moveRow, planMergedOrder, withTransportPositions } from '../../utils/mergedOrder'
|
||||
@@ -935,12 +935,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const keyOf = chronoKeyOf(m, dayId, getDisplayTimeForDay)
|
||||
const timedInOrder = moveRow(m, fromIdx, matchTo, insertAfter).order
|
||||
.map(i => {
|
||||
if (i.type === 'transport') return parseTimeToMinutes(getDisplayTimeForDay(i.data, dayId))
|
||||
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||
return null
|
||||
})
|
||||
.map(i => (i.type === 'note' ? null : keyOf(i)))
|
||||
.filter(t => t !== null)
|
||||
const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1])
|
||||
|
||||
@@ -1667,11 +1664,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
<div className={hasDayPills ? 'min-w-0 pt-[5px]' : 'min-w-0'}>
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="min-w-0 truncate font-bold text-content" style={fs(13.5, 'body')}>
|
||||
<span className={`${headingFit(heading).primary} font-bold text-content`} style={fs(13.5, 'body')}>
|
||||
{heading.primary}
|
||||
</span>
|
||||
{heading.secondary && (
|
||||
<span className="flex-none whitespace-nowrap text-content-faint" style={fs(11)}>
|
||||
<span className={`${headingFit(heading).secondary} text-content-faint`} style={fs(11)}>
|
||||
{heading.secondary}
|
||||
</span>
|
||||
)}
|
||||
@@ -1829,12 +1826,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
// Check chronological order of all timed items in the new order
|
||||
const placeTime = place.place_time
|
||||
if (parseTimeToMinutes(placeTime) !== null) {
|
||||
const keyOf = chronoKeyOf(m, day.id, getDisplayTimeForDay)
|
||||
const timedInNewOrder = newOrder
|
||||
.map(i => {
|
||||
if (i.type === 'transport') return parseTimeToMinutes(getDisplayTimeForDay(i.data, day.id))
|
||||
if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time)
|
||||
return null
|
||||
})
|
||||
.map(i => (i.type === 'note' ? null : keyOf(i)))
|
||||
.filter(t => t !== null)
|
||||
const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1])
|
||||
if (!isChronological) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { BarButton, PANEL_BAR } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => setExportOpen(true))
|
||||
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -39,7 +39,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
onDragOver={dragDisabled ? undefined : handleSidebarDragOver}
|
||||
onDragLeave={dragDisabled ? undefined : handleSidebarDragLeave}
|
||||
onDrop={dragDisabled ? undefined : handleSidebarDrop}
|
||||
className="relative flex h-full flex-col"
|
||||
className="relative flex h-full min-h-0 flex-col"
|
||||
style={{ fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
{((!dragDisabled && sidebarDragOver) || tourFileImport.importing) && <PlacesDropOverlay {...S} />}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { CalendarDays, Pencil, Trash2, ChevronDown, Check, Eye } from 'lucide-react'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
|
||||
export function MobileDayPickerSheet(S: SidebarState) {
|
||||
const {
|
||||
dayPickerPlace, setDayPickerPlace, setMobileShowDays, onPlaceClick, canEditPlaces, onEditPlace,
|
||||
t, days, mobileShowDays, onAssignToDay, assignments, onDeletePlace,
|
||||
} = S
|
||||
const dateFormat = useDateFormat()
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
@@ -64,7 +67,7 @@ export function MobileDayPickerSheet(S: SidebarState) {
|
||||
<div className="bg-surface-tertiary text-content" style={{ width: 28, height: 28, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500 }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
{day.date && <div className="text-content-faint" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>{formatDisplayDate(new Date(day.date + 'T00:00:00Z'), undefined, { timeZone: 'UTC' }, dateFormat)}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cheapestEnergy, type ChargingEnergyPrice, type ChargingInfo as Info } from '@trek/shared';
|
||||
import { ChevronDown, Coins, PlugZap } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFormatDate } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import { chargingRepo } from '../../repo/chargingRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
@@ -76,6 +77,7 @@ export default function ChargingInfo({
|
||||
}: ChargingTarget & { compact?: boolean }) {
|
||||
const tripId = useTripStore((s) => s.trip?.id);
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime } = useFormatDate();
|
||||
const [info, setInfo] = useState<Info | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
@@ -133,7 +135,7 @@ export default function ChargingInfo({
|
||||
const cheapest = info ? cheapestEnergy(info.tariffs) : null;
|
||||
const price = (value: number, currency: string) =>
|
||||
new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(value);
|
||||
const label = `${status}${info?.updatedAt ? ` (${new Date(info.updatedAt).toLocaleString()})` : ''}`;
|
||||
const label = `${status}${info?.updatedAt ? ` (${formatDateTime(new Date(info.updatedAt))})` : ''}`;
|
||||
if (compact)
|
||||
return (
|
||||
<ChargingBadges
|
||||
@@ -211,7 +213,7 @@ export default function ChargingInfo({
|
||||
</a>
|
||||
<br />
|
||||
{info.license}
|
||||
{info.updatedAt && ` · ${new Date(info.updatedAt).toLocaleString()}`}
|
||||
{info.updatedAt && ` · ${formatDateTime(new Date(info.updatedAt))}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('AboutTab', () => {
|
||||
|
||||
it('FE-COMP-ABOUT-018: the sponsor panel links dataforest cloud wherever it is named, with both logos bundled', () => {
|
||||
render(<AboutTab appVersion="1.0.0" />);
|
||||
const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/?mtm_campaign=trek"]');
|
||||
const links = document.querySelectorAll('a[href="https://cloud.dataforest.net/en?mtm_campaign=trek"]');
|
||||
// The logo tile, the call to action, and the brand name in the title and in the body.
|
||||
expect(links).toHaveLength(4);
|
||||
for (const link of links) {
|
||||
|
||||
@@ -93,7 +93,7 @@ function LinkTile({
|
||||
);
|
||||
}
|
||||
|
||||
const DATAFOREST_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
|
||||
const DATAFOREST_HREF = 'https://cloud.dataforest.net/en?mtm_campaign=trek';
|
||||
const DATAFOREST_NAME = 'dataforest cloud';
|
||||
const DATAFOREST = '#02C382'; // theme-lint-disable: brand colour (dataforest cloud)
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, Foot
|
||||
import { EditorField, GRID_2, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDateFormat } from '../../hooks/useDateFormat'
|
||||
import { formatDisplayDate } from '../../utils/dateFormat'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { authApi } from '../../api/client'
|
||||
|
||||
@@ -55,10 +57,10 @@ const SCOPE_ICONS: Record<PublicApiScope, LucideIcon> = {
|
||||
}
|
||||
|
||||
/** SQLite hands out "YYYY-MM-DD HH:MM:SS" in UTC, which Safari will not parse as it stands. */
|
||||
function formatStamp(ts: string, locale: string): string {
|
||||
function formatStamp(ts: string, locale: string, dateFormat: string): string {
|
||||
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T')
|
||||
const d = new Date(/(Z|[+-]\d{2}:?\d{2})$/.test(iso) ? iso : `${iso}Z`)
|
||||
return Number.isNaN(d.getTime()) ? ts : d.toLocaleDateString(locale)
|
||||
return Number.isNaN(d.getTime()) ? ts : formatDisplayDate(d, locale, {}, dateFormat)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -380,6 +382,7 @@ export default function ApiKeysSection(): React.ReactElement {
|
||||
|
||||
function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: string; onDelete: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const dateFormat = useDateFormat()
|
||||
const scopes = apiKey.scope_mode === 'limited' ? apiKey.scopes : undefined
|
||||
return (
|
||||
<li className="flex items-start gap-3 px-3.5 py-3">
|
||||
@@ -394,10 +397,10 @@ function ApiKeyRow({ apiKey, locale, onDelete }: { apiKey: ApiKey; locale: strin
|
||||
</code>
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 tabular-nums text-content-faint first-letter:uppercase" style={fs(11.5)}>
|
||||
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale)}
|
||||
{t('settings.apiKeys.createdAt')} {formatStamp(apiKey.created_at, locale, dateFormat)}
|
||||
{', '}
|
||||
{apiKey.last_used_at
|
||||
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale)}`
|
||||
? `${t('settings.apiKeys.usedAt')} ${formatStamp(apiKey.last_used_at, locale, dateFormat)}`
|
||||
: t('settings.apiKeys.neverUsed')}
|
||||
</p>
|
||||
{/* What the key may read. Shown on the row rather than behind a detail
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Loader2, Save, Plug, RefreshCw, Unplug } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { useDawarichConnection } from '../../hooks/useDawarichConnection'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Section from './Section'
|
||||
@@ -23,7 +24,7 @@ import { fs } from '../shared/DialogShell'
|
||||
* no reason is the version of this card that generates support questions.
|
||||
*/
|
||||
export default function DawarichConnectionSection(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const S = useDawarichConnection()
|
||||
|
||||
return (
|
||||
@@ -123,7 +124,7 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DawarichConnectionStatus state={S} locale={locale} />
|
||||
<DawarichConnectionStatus state={S} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -138,12 +139,11 @@ export default function DawarichConnectionSection(): React.ReactElement {
|
||||
*/
|
||||
function DawarichConnectionStatus({
|
||||
state,
|
||||
locale,
|
||||
}: {
|
||||
state: ReturnType<typeof useDawarichConnection>
|
||||
locale: string
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const { formatDateTime } = useFormatDate()
|
||||
if (!state.connected && !state.probeMessage) return null
|
||||
|
||||
const missing: string[] = []
|
||||
@@ -161,7 +161,7 @@ function DawarichConnectionStatus({
|
||||
{state.connected && (
|
||||
<p className="m-0 text-content-secondary">
|
||||
{state.lastSyncAt
|
||||
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
|
||||
? t('dawarich.lastSync', { when: formatDateTime(new Date(state.lastSyncAt)) })
|
||||
: t('dawarich.neverSynced')}
|
||||
{state.lastSyncState === 'partial' && `, ${t('dawarich.syncPartial')}`}
|
||||
</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-055
|
||||
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-057
|
||||
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -484,6 +484,35 @@ describe('DisplaySettingsTab week start (#2029)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Date format (056, 057) ────────────────────────────────────────────────────
|
||||
|
||||
describe('DisplaySettingsTab date format (#2971)', () => {
|
||||
it('FE-COMP-DISPLAY-056: shows the language format when unset and saves the format picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ date_format: undefined }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
|
||||
// Each choice is labelled by how it writes 31 December; auto carries the language's own way.
|
||||
await user.click(screen.getByRole('button', { name: /\(12\/31\/2026\)/ }));
|
||||
await user.click(await screen.findByText('31.12.2026'));
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith('date_format', 'DD.MM.YYYY');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-057: a rejected change surfaces the error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockRejectedValue(new Error('Save failed'));
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ date_format: 'YYYY-MM-DD' }), updateSetting });
|
||||
render(<><ToastContainer /><DisplaySettingsTab /></>);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /2026-12-31/ }));
|
||||
await user.click(await screen.findByText('31/12/2026'));
|
||||
|
||||
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisplaySettingsTab – the demo account', () => {
|
||||
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
|
||||
@@ -16,8 +16,9 @@ import { useSettingSaver } from './useSettingSaver'
|
||||
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
|
||||
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { DEFAULT_DATE_FORMAT, DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import { dateFormatOptions } from '../../utils/dateFormat'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
@@ -265,6 +266,23 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Date format: every date field and every date written in numbers (#2971) */}
|
||||
<SettingRow
|
||||
label={t('settings.dateFormat')}
|
||||
hint={t('settings.dateFormatHint')}
|
||||
control={
|
||||
<div className={SELECT_BOX}>
|
||||
<CustomSelect
|
||||
value={settings.date_format || DEFAULT_DATE_FORMAT}
|
||||
onChange={v => {
|
||||
void save('date_format', String(v))
|
||||
}}
|
||||
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Section from './Section'
|
||||
import React, { useId } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useFormatDate } from '../../hooks/useDateFormat'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Trash2, Copy, Terminal, Plus, Check, Key, KeyRound, ChevronRight, RefreshCw, AlertTriangle, Link2 } from 'lucide-react'
|
||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||
@@ -62,12 +63,13 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
|
||||
function useIntegrations() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { formatDate } = useFormatDate()
|
||||
const toast = useToast()
|
||||
const { mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled, wandererEnabled, managed } = useIntegrationGates({ reloadAddons: true })
|
||||
|
||||
const mcp = useMcpIntegration({ enabled: mcpEnabled, reloadClientsAfterWrite: false })
|
||||
|
||||
return { t, locale, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
|
||||
return { t, locale, formatDate, toast, mcpEnabled, airtrailEnabled, llmEnabled, aiAssistantEnabled, aiConnectionEnabled, ownParsing, dawarichEnabled,
|
||||
wandererEnabled,
|
||||
managed, ...mcp,
|
||||
}
|
||||
@@ -171,7 +173,7 @@ function ConfigBlock({ open, onToggle, json, hint, copied, onCopy, t }: {
|
||||
|
||||
/** One registered OAuth client: what it is, the scopes it may use, and its two actions. */
|
||||
function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsState }) {
|
||||
const { t, locale, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
|
||||
const { t, formatDate, oauthScopesExpanded, setOauthScopesExpanded, setOauthRotateId, setOauthDeleteId } = S
|
||||
const expanded = !!oauthScopesExpanded[client.id]
|
||||
return (
|
||||
<div className={`${ROW} items-start`}>
|
||||
@@ -185,7 +187,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
</div>
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="min-w-0 truncate font-geist">{t('settings.oauth.clientId')}: {client.client_id}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(client.created_at))}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1" style={fs(10.5)}>
|
||||
{(expanded ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => (
|
||||
@@ -216,7 +218,7 @@ function OAuthClientRow({ client, S }: { client: OAuthClient; S: IntegrationsSta
|
||||
/** The OAuth tab: the snippet, the registered clients and the sessions they hold. */
|
||||
function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
const {
|
||||
t, locale, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
t, formatDate, oauthClients, oauthSessions, setOauthCreateOpen, setOauthNewName, setOauthNewUris, setOauthNewScopes, setOauthCreatedClient, setOauthRevokeId, setOauthIsMachine, configOpenOAuth, setConfigOpenOAuth, copiedKey, mcpJsonConfigOAuth, handleCopy,
|
||||
} = S
|
||||
return (
|
||||
<>
|
||||
@@ -260,7 +262,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{session.client_name}</p>
|
||||
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="min-w-0 truncate">{t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')}</span>
|
||||
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.oauth.sessionExpires')} {formatDate(new Date(session.access_token_expires_at))}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setOauthRevokeId(session.id)}
|
||||
@@ -278,7 +280,7 @@ function OAuthTab({ S }: { S: IntegrationsState }) {
|
||||
|
||||
/** The deprecated API tokens tab: the warning, the snippet and the tokens. */
|
||||
function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
const { t, locale, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
const { t, formatDate, configOpenToken, setConfigOpenToken, mcpTokens, setMcpModalOpen, setMcpNewName, setMcpCreatedToken, setMcpDeleteId, copiedKey, mcpJsonConfig, handleCopy } = S
|
||||
return (
|
||||
<>
|
||||
<WarningNote>{t('settings.mcp.apiTokensDeprecated')}</WarningNote>
|
||||
@@ -311,9 +313,9 @@ function ApiTokensTab({ S }: { S: IntegrationsState }) {
|
||||
<p className="m-0 truncate font-semibold text-content" style={fs(13, 'body')}>{token.name}</p>
|
||||
<p className="m-0 mt-0.5 flex min-w-0 flex-wrap gap-x-3 text-content-faint" style={fs(11.5)}>
|
||||
<span className="font-geist">{token.token_prefix}...</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenCreatedAt')} {formatDate(new Date(token.created_at))}</span>
|
||||
{token.last_used_at && (
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)}</span>
|
||||
<span className="tabular-nums">{t('settings.mcp.tokenUsedAt')} {formatDate(new Date(token.last_used_at))}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -386,6 +386,27 @@ describe('MapSettingsTab – CARTO key', () => {
|
||||
expect(screen.queryByText('CARTO API key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032b: a managed instance without an operator token offers no Mapbox and no Amap tiles', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true, hasAmapKey: false });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: '', map_tile_url: '' }) });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.queryByText('Mapbox GL')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('MapLibre GL')).toBeInTheDocument();
|
||||
await user.click(screen.getByText('Select template...'));
|
||||
expect(screen.queryByText('高德地图 (Amap)')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('OpenStreetMap')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-032c: the operator token brings Mapbox back on a managed instance', () => {
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, managed: true });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_provider: 'leaflet', mapbox_access_token: 'pk.operator' }) });
|
||||
render(<MapSettingsTab />);
|
||||
|
||||
expect(screen.getByText('Mapbox GL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAP-033: the typed key is part of the save patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -15,7 +15,7 @@ import ToggleSwitch from './ToggleSwitch'
|
||||
import { SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill } from './settingsKit'
|
||||
import { withTileApiKey } from '../../utils/tileUrl'
|
||||
import { defaultStyleForProvider, getStylePresets, type GlMapProvider } from '../Map/glProviders'
|
||||
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
|
||||
import { PREVIEW_CENTER, PREVIEW_ZOOM } from './mapSettingsModel'
|
||||
import { useMapSettingsForm } from './useMapSettingsForm'
|
||||
|
||||
/** A field holding a URL, token or key: the box look in Geist, so the characters read apart. */
|
||||
@@ -130,7 +130,7 @@ function ProviderTile({ active, onClick, icon: Icon, name, subtitle, badge }: {
|
||||
export default function MapSettingsTab(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
|
||||
} = useMapSettingsForm()
|
||||
@@ -149,7 +149,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
{/* Provider picker — big tiles so the choice is obvious */}
|
||||
<SettingRows>
|
||||
<SettingRow label={t('settings.mapProvider')} hint={t('settings.mapProviderHint')} stacked>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<div className={`grid grid-cols-1 gap-2 ${canMapbox ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
|
||||
<ProviderTile
|
||||
active={provider === 'leaflet'}
|
||||
onClick={() => changeProvider('leaflet')}
|
||||
@@ -157,15 +157,17 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
name="Leaflet"
|
||||
subtitle={t('settings.mapLeafletSubtitle')}
|
||||
/>
|
||||
<ProviderTile
|
||||
active={provider === 'mapbox-gl'}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
icon={Box}
|
||||
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
|
||||
subtitle={t('settings.mapMapboxSubtitle')}
|
||||
// Only on ≥sm; on a narrow window there's no room next to the title.
|
||||
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
|
||||
/>
|
||||
{canMapbox && (
|
||||
<ProviderTile
|
||||
active={provider === 'mapbox-gl'}
|
||||
onClick={() => changeProvider('mapbox-gl')}
|
||||
icon={Box}
|
||||
name={<><span className="sm:hidden">Mapbox</span><span className="hidden sm:inline">Mapbox GL</span></>}
|
||||
subtitle={t('settings.mapMapboxSubtitle')}
|
||||
// Only on ≥sm; on a narrow window there's no room next to the title.
|
||||
badge={<span className="hidden sm:inline-flex"><StatusPill tone="warning">{t('settings.mapExperimental')}</StatusPill></span>}
|
||||
/>
|
||||
)}
|
||||
<ProviderTile
|
||||
active={provider === 'maplibre-gl'}
|
||||
onClick={() => changeProvider('maplibre-gl')}
|
||||
@@ -185,7 +187,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
value={mapTileUrl}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map(p => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
<input
|
||||
id={`${fieldId}-tile`}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// FE-MAP-MODEL-001 to FE-MAP-MODEL-004
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AMAP_ROAD, AMAP_SATELLITE } from '../../constants/mapDefaults';
|
||||
import { MAP_PRESETS, mapboxOffered, mapPresetsFor } from './mapSettingsModel';
|
||||
|
||||
const urls = (list: { url: string }[]) => list.map((p) => p.url);
|
||||
|
||||
describe('mapPresetsFor', () => {
|
||||
it('FE-MAP-MODEL-001: a self-hosted install offers every preset, Amap included', () => {
|
||||
expect(mapPresetsFor(false, false)).toEqual(MAP_PRESETS);
|
||||
});
|
||||
|
||||
it('FE-MAP-MODEL-002: a managed install offers Amap only where the operator set it up', () => {
|
||||
const without = urls(mapPresetsFor(true, false));
|
||||
expect(without).not.toContain(AMAP_ROAD);
|
||||
expect(without).not.toContain(AMAP_SATELLITE);
|
||||
expect(without).toHaveLength(MAP_PRESETS.length - 2);
|
||||
expect(mapPresetsFor(true, true)).toEqual(MAP_PRESETS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapboxOffered', () => {
|
||||
it('FE-MAP-MODEL-003: a self-hosted install always offers Mapbox, the token field follows', () => {
|
||||
expect(mapboxOffered(false, null)).toBe(true);
|
||||
expect(mapboxOffered(false, '')).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-MODEL-004: a managed install offers Mapbox only with the operator token', () => {
|
||||
expect(mapboxOffered(true, undefined)).toBe(false);
|
||||
expect(mapboxOffered(true, '')).toBe(false);
|
||||
expect(mapboxOffered(true, 'pk.operator')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,26 @@ export const MAP_PRESETS: MapPreset[] = [
|
||||
{ name: '高德卫星 (Amap Satellite)', url: AMAP_SATELLITE },
|
||||
];
|
||||
|
||||
/**
|
||||
* The presets this install offers. On a managed install Amap is the operator's
|
||||
* service like every other keyed one: its tiles are offered only where the
|
||||
* operator set Amap up (has_amap_key), so a hosted user is never pointed at a
|
||||
* provider the instance does not use.
|
||||
*/
|
||||
export function mapPresetsFor(managed: boolean, hasAmapKey: boolean): MapPreset[] {
|
||||
if (!managed || hasAmapKey) return MAP_PRESETS;
|
||||
return MAP_PRESETS.filter((p) => p.url !== AMAP_ROAD && p.url !== AMAP_SATELLITE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Mapbox GL can be picked. A managed install brings the token with the
|
||||
* instance and offers no field for one, so without an operator token the
|
||||
* choice would only lead to a map that never loads.
|
||||
*/
|
||||
export function mapboxOffered(managed: boolean, token: string | null | undefined): boolean {
|
||||
return !managed || !!token;
|
||||
}
|
||||
|
||||
export type MapProvider = 'leaflet' | GlMapProvider;
|
||||
|
||||
export function normalizeProvider(value: unknown): MapProvider {
|
||||
|
||||
@@ -2,9 +2,11 @@ import { escapeHtml } from '@trek/shared';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { adminApi, authApi } from '../../api/client';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { getApiErrorMessage, type UserWithOidc } from '../../types';
|
||||
import { formatDisplayDateTime } from '../../utils/dateFormat';
|
||||
import { passwordErrorKey } from '../../utils/passwordError';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
@@ -35,6 +37,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const dateFormat = useDateFormat();
|
||||
const toast = useToast();
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -126,7 +129,7 @@ export function useAccountSettings({ avatarRemoveErrorKey, ignoreEmptyBackupCode
|
||||
if (skipBackupCodes) return;
|
||||
const html = `<!doctype html><html><head><meta charset="utf-8"/><title>TREK MFA Backup Codes</title>
|
||||
<style>body{font-family:Arial,sans-serif;padding:32px}h1{font-size:20px}pre{font-size:16px;line-height:1.6}</style>
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(new Date().toLocaleString())}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
|
||||
</head><body><h1>TREK MFA Backup Codes</h1><p>${escapeHtml(formatDisplayDateTime(new Date(), undefined, {}, dateFormat))}</p><pre>${escapeHtml(backupCodesText)}</pre></body></html>`;
|
||||
const w = window.open('', '_blank', 'width=900,height=700');
|
||||
if (!w) return;
|
||||
w.document.open();
|
||||
|
||||
@@ -7,7 +7,15 @@ import type { Place } from '../../types';
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders';
|
||||
import { useWebGL2 } from '../Map/glSupport';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
|
||||
import {
|
||||
type MapProvider,
|
||||
mapboxOffered,
|
||||
mapPresetsFor,
|
||||
normalizeProvider,
|
||||
previewPlace,
|
||||
slotStyle,
|
||||
styleForProvider,
|
||||
} from './mapSettingsModel';
|
||||
|
||||
export interface MapSettingsFormOptions {
|
||||
/**
|
||||
@@ -32,6 +40,7 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
const [provider, setProvider] = useState<MapProvider>(initialProvider);
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '');
|
||||
const managed = useAuthStore((s) => s.managed);
|
||||
const hasAmapKey = useAuthStore((s) => s.hasAmapKey);
|
||||
const [mapboxToken, setMapboxToken] = useState<string>(settings.mapbox_access_token || '');
|
||||
const [cartoKey, setCartoKey] = useState<string>(settings.carto_api_key || '');
|
||||
const [mapboxStyle, setMapboxStyle] = useState<string>(
|
||||
@@ -94,6 +103,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
|
||||
return {
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, settings.mapbox_access_token),
|
||||
saving,
|
||||
provider,
|
||||
changeProvider,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { authApi, type PasskeyCredential } from '../../api/client';
|
||||
import { readDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { getApiErrorMessage } from '../../types';
|
||||
import { formatDisplayDate } from '../../utils/dateFormat';
|
||||
import { useToast } from '../shared/Toast';
|
||||
|
||||
/** Parse a SQLite UTC timestamp ("YYYY-MM-DD HH:MM:SS") into a local date string. */
|
||||
@@ -10,7 +12,7 @@ export function fmtDate(ts: string | null): string | null {
|
||||
if (!ts) return null;
|
||||
const iso = ts.includes('T') ? ts : ts.replace(' ', 'T');
|
||||
const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||||
return Number.isNaN(d.getTime()) ? null : d.toLocaleDateString();
|
||||
return Number.isNaN(d.getTime()) ? null : formatDisplayDate(d, undefined, {}, readDateFormat());
|
||||
}
|
||||
|
||||
/** True when the browser cancellation / no-matching-credential DOMExceptions fire. */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { pluginsApi } from '../../api/client';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { formatDisplayDateTime } from '../../utils/dateFormat';
|
||||
|
||||
export interface ActivityRow {
|
||||
ts: string;
|
||||
@@ -18,6 +20,7 @@ export interface ActivityRow {
|
||||
*/
|
||||
export function usePluginActivity() {
|
||||
const { locale } = useTranslation();
|
||||
const dateFormat = useDateFormat();
|
||||
const [rows, setRows] = useState<ActivityRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -36,7 +39,7 @@ export function usePluginActivity() {
|
||||
|
||||
const fmtWhen = (ts: string): string => {
|
||||
const d = new Date(ts);
|
||||
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString(locale);
|
||||
return Number.isNaN(d.getTime()) ? ts : formatDisplayDateTime(d, locale, {}, dateFormat);
|
||||
};
|
||||
|
||||
return { rows, loading, load, fmtWhen };
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('ReleaseNoticeModal', () => {
|
||||
const links = screen.getAllByRole('link', { name: 'dataforest cloud' });
|
||||
expect(links).toHaveLength(2);
|
||||
for (const link of links) {
|
||||
expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/?mtm_campaign=trek');
|
||||
expect(link).toHaveAttribute('href', 'https://cloud.dataforest.net/en?mtm_campaign=trek');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function SteamingCoffee() {
|
||||
}
|
||||
|
||||
const SPONSOR = 'dataforest cloud';
|
||||
const SPONSOR_HREF = 'https://cloud.dataforest.net/?mtm_campaign=trek';
|
||||
const SPONSOR_HREF = 'https://cloud.dataforest.net/en?mtm_campaign=trek';
|
||||
|
||||
/** The sponsor's name is a brand and reads the same in every language, so wherever a line names it, it links. */
|
||||
function withSponsorLink(text: string): React.ReactNode {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-004
|
||||
// FE-GPX-PREVIEW-001 to FE-GPX-PREVIEW-007
|
||||
// The GPX preview as ACT-01 uses it: preset to the tour's type, a city walk
|
||||
// imported as one, and when a track was recorded shown where it carried times.
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { TourGpxPreviewTrack } from '@trek/shared';
|
||||
import type { TourFileLeftOut, TourGpxPreviewTrack } from '@trek/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import TourGpxPreviewDialog from './TourGpxPreviewDialog';
|
||||
import type { TourGpxImport } from './useTourGpxImport';
|
||||
@@ -27,10 +27,10 @@ function track(overrides: Partial<TourGpxPreviewTrack> = {}): TourGpxPreviewTrac
|
||||
};
|
||||
}
|
||||
|
||||
function gpxImport(tracks: TourGpxPreviewTrack[]): TourGpxImport {
|
||||
function gpxImport(tracks: TourGpxPreviewTrack[], leftOut?: TourFileLeftOut): TourGpxImport {
|
||||
return {
|
||||
importing: false,
|
||||
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks }],
|
||||
preview: [{ file: new File(['<gpx/>'], 'morning.gpx'), tracks, leftOut }],
|
||||
confirm: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
pick: vi.fn(),
|
||||
@@ -63,19 +63,54 @@ describe('TourGpxPreviewDialog (ACT-01)', () => {
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' });
|
||||
expect(current.confirm).toHaveBeenCalledWith(
|
||||
{ tour_type: 'kayak', boat_type: 'sup', water_context: 'lake' },
|
||||
{ points: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-003: a city walk is imported as a city walk, and without a preset as a hike', () => {
|
||||
const walk = gpxImport([track()]);
|
||||
const { unmount } = render(<TourGpxPreviewDialog gpxImport={walk} initial={{ tour_type: 'walk' }} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' });
|
||||
expect(walk.confirm).toHaveBeenCalledWith({ tour_type: 'walk' }, { points: false });
|
||||
unmount();
|
||||
|
||||
const hike = gpxImport([track()]);
|
||||
render(<TourGpxPreviewDialog gpxImport={hike} />);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' });
|
||||
expect(hike.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourGpxPreviewDialog with points beside the lines (#2969)', () => {
|
||||
it('FE-GPX-PREVIEW-005: offers the points as places, on by default, and imports them with the tour', () => {
|
||||
const current = gpxImport([track()], { points: 4, areas: 0, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
expect(screen.getByTestId('gpx-preview-points')).toBeInTheDocument();
|
||||
// Added as places, they are not listed as left out.
|
||||
expect(screen.queryByTestId('gpx-preview-left-out')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-006: switched off, the points are named as left out and not imported', () => {
|
||||
const current = gpxImport([track()], { points: 4, areas: 1, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /tours\.import\.pointsAsPlaces|Add the points as places/ }));
|
||||
expect(screen.getByTestId('gpx-preview-left-out')).toHaveTextContent(/tours\.import\.leftOutPoints|4 pins/);
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: false });
|
||||
});
|
||||
|
||||
it('FE-GPX-PREVIEW-007: a file whose every track the trip holds can still bring its points', () => {
|
||||
const current = gpxImport([track({ duplicate: true })], { points: 2, areas: 0, other: 0 });
|
||||
render(<TourGpxPreviewDialog gpxImport={current} />);
|
||||
const button = screen.getByTestId('gpx-preview-import');
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).toHaveTextContent(/tours\.import\.pointsConfirm|Add places/);
|
||||
expect(screen.queryByText(/tours\.import\.previewAllDuplicates|nothing to import/)).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(current.confirm).toHaveBeenCalledWith({ tour_type: 'hike' }, { points: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TourFileLeftOut, TourGpxImportFields } from '@trek/shared';
|
||||
import { AlertTriangle, Ban, Clock, FileDown, Layers, MapPin, Shapes } from 'lucide-react';
|
||||
import { useId, useMemo } from 'react';
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { OFM_POSITRON } from '../../constants/mapDefaults';
|
||||
import { useTileUrl } from '../../hooks/useTileUrl';
|
||||
import { useTranslation } from '../../i18n';
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../utils/units';
|
||||
import { MapViewAuto } from '../Map/MapViewAuto';
|
||||
import { SoftPill } from '../Planner/planParts';
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
@@ -98,8 +99,8 @@ function PreviewTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** What all the files held besides their lines, summed by kind; null when they held nothing else. */
|
||||
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
|
||||
/** What all the files held besides their lines, summed by kind. */
|
||||
function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut {
|
||||
const sum = { points: 0, areas: 0, other: 0 };
|
||||
for (const { leftOut } of files) {
|
||||
if (!leftOut) continue;
|
||||
@@ -107,18 +108,46 @@ function leftOutOf(files: readonly GpxPreviewFile[]): TourFileLeftOut | null {
|
||||
sum.areas += leftOut.areas;
|
||||
sum.other += leftOut.other;
|
||||
}
|
||||
return sum.points + sum.areas + sum.other > 0 ? sum : null;
|
||||
return sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* IO-02: what a KML or KMZ file held besides its lines, named before the
|
||||
* import so nothing goes missing unseen: a dashed card with each kind as a
|
||||
* counted badge, and why in the info dot.
|
||||
* #2969: the single points of the files (GPX waypoints, KML pins), which no
|
||||
* tour is made of, offered as places of the trip. On by default: a file with
|
||||
* a route and its stops is usually meant as both.
|
||||
*/
|
||||
function PreviewLeftOut({ files }: { files: readonly GpxPreviewFile[] }) {
|
||||
function PreviewPoints({ count, on, onToggle }: { count: number; on: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const leftOut = leftOutOf(files);
|
||||
if (!leftOut) return null;
|
||||
const hintId = useId();
|
||||
return (
|
||||
<div
|
||||
data-testid="gpx-preview-points"
|
||||
className="mt-2 flex items-center gap-2.5 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2"
|
||||
>
|
||||
<MapPin size={14} strokeWidth={2} className="flex-none text-content-muted" aria-hidden="true" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex flex-wrap items-center gap-1.5 font-semibold text-content" style={fs(12.5, 'body')}>
|
||||
{t('tours.import.pointsAsPlaces')}
|
||||
<SoftPill>{t('tours.import.leftOutPoints', { count })}</SoftPill>
|
||||
</span>
|
||||
<span id={hintId} className="text-content-muted" style={fs(11.5, 'body')}>
|
||||
{t('tours.import.pointsAsPlacesHint')}
|
||||
</span>
|
||||
</div>
|
||||
<ToggleSwitch on={on} onToggle={onToggle} label={t('tours.import.pointsAsPlaces')} describedBy={hintId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* IO-02: what a file held besides its lines and does not take in, named
|
||||
* before the import so nothing goes missing unseen: a dashed card with each
|
||||
* kind as a counted badge, and why in the info dot. Its points only while
|
||||
* they are not added as places.
|
||||
*/
|
||||
function PreviewLeftOut({ leftOut }: { leftOut: TourFileLeftOut }) {
|
||||
const { t } = useTranslation();
|
||||
if (leftOut.points + leftOut.areas + leftOut.other === 0) return null;
|
||||
const kinds = [
|
||||
{ key: 'points', count: leftOut.points, icon: MapPin, labelKey: 'tours.import.leftOutPoints' },
|
||||
{ key: 'areas', count: leftOut.areas, icon: Shapes, labelKey: 'tours.import.leftOutAreas' },
|
||||
@@ -170,6 +199,29 @@ export default function TourGpxPreviewDialog({
|
||||
|
||||
const HIKE: TourGpxImportFields = { tour_type: 'hike' };
|
||||
|
||||
/** A fresh line needs its type complete; a file of known lines can still be confirmed for its points alone. */
|
||||
function canConfirm(fresh: number, addsPoints: boolean, typeComplete: boolean): boolean {
|
||||
return fresh > 0 ? typeComplete : addsPoints;
|
||||
}
|
||||
|
||||
/** The points offered as places, and below them whatever else stays out. */
|
||||
function PreviewExtras({
|
||||
leftOut,
|
||||
withPoints,
|
||||
onTogglePoints,
|
||||
}: {
|
||||
leftOut: TourFileLeftOut;
|
||||
withPoints: boolean;
|
||||
onTogglePoints: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{leftOut.points > 0 && <PreviewPoints count={leftOut.points} on={withPoints} onToggle={onTogglePoints} />}
|
||||
<PreviewLeftOut leftOut={withPoints ? { ...leftOut, points: 0 } : leftOut} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDialog({
|
||||
gpxImport,
|
||||
files,
|
||||
@@ -183,6 +235,9 @@ function PreviewDialog({
|
||||
const labelId = useId();
|
||||
const tileUrl = useTileUrl(OFM_POSITRON);
|
||||
const choices = useGpxImportChoices(initial);
|
||||
const leftOut = useMemo(() => leftOutOf(files), [files]);
|
||||
const [withPoints, setWithPoints] = useState(true);
|
||||
const addsPoints = withPoints && leftOut.points > 0;
|
||||
|
||||
const tracks = files.flatMap((file) => file.tracks.map((track) => ({ file: file.file, track })));
|
||||
const fresh = tracks.filter(({ track }) => !track.duplicate).length;
|
||||
@@ -194,7 +249,8 @@ function PreviewDialog({
|
||||
[files]
|
||||
);
|
||||
const focusPoints = useMemo(() => lines.flat(), [lines]);
|
||||
const canImport = fresh > 0 && importMissing(choices.chosen) === null && !gpxImport.importing;
|
||||
const canImport = canConfirm(fresh, addsPoints, importMissing(choices.chosen) === null) && !gpxImport.importing;
|
||||
const confirmLabel = fresh > 0 ? t('tours.import.previewConfirm', { count: fresh }) : t('tours.import.pointsConfirm');
|
||||
const fields = importFields(choices.chosen);
|
||||
const title = files.length === 1 ? files[0].file.name : t('tours.import.previewFiles', { count: files.length });
|
||||
|
||||
@@ -231,9 +287,9 @@ function PreviewDialog({
|
||||
data-testid="gpx-preview-import"
|
||||
icon={<FileDown size={14} strokeWidth={2} />}
|
||||
disabled={!canImport}
|
||||
onClick={() => void gpxImport.confirm(fields)}
|
||||
onClick={() => void gpxImport.confirm(fields, { points: addsPoints })}
|
||||
>
|
||||
{t('tours.import.previewConfirm', { count: fresh })}
|
||||
{confirmLabel}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
@@ -258,8 +314,8 @@ function PreviewDialog({
|
||||
<PreviewTrackRow key={`${file.name}-${index}`} name={file.name} number={index + 1} track={track} />
|
||||
))}
|
||||
</ul>
|
||||
<PreviewLeftOut files={files} />
|
||||
{fresh === 0 && (
|
||||
<PreviewExtras leftOut={leftOut} withPoints={withPoints} onTogglePoints={() => setWithPoints((on) => !on)} />
|
||||
{fresh === 0 && !addsPoints && (
|
||||
<div className="mt-2">
|
||||
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
|
||||
{t('tours.import.previewAllDuplicates')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTourMapVisibilityStore } from '../../store/tourMapVisibilityStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { placesApi } from '../../api/client'
|
||||
import { tourGpxApi } from '../../api/tourGpx'
|
||||
import ToursSidebar from './ToursSidebar'
|
||||
import { pickFiles } from '../../../tests/helpers/filePicker'
|
||||
@@ -457,6 +458,7 @@ describe('ToursSidebar', () => {
|
||||
left_out: { points: 3, areas: 1, other: 0 },
|
||||
})
|
||||
const importCall = vi.spyOn(tourGpxApi, 'import').mockResolvedValue({ tours: [tour], caution: false, skipped: 0 })
|
||||
const pinsCall = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 3 })
|
||||
seedStore(useTripStore, { refreshPlaces: vi.fn().mockResolvedValue(undefined) })
|
||||
render(
|
||||
<ToursSidebar tripId={1} days={[]} tours={[]} canEdit={true} onAssignToDay={vi.fn()} onSelectTour={vi.fn()} />
|
||||
@@ -469,9 +471,11 @@ describe('ToursSidebar', () => {
|
||||
const rows = screen.getAllByTestId('gpx-preview-track')
|
||||
expect(rows.map((row) => row.textContent?.slice(0, 1))).toEqual(['1', '2'])
|
||||
expect(rows[1]).toHaveTextContent('No heights in the file')
|
||||
// The pins are offered as places (#2969); only the area stays out.
|
||||
expect(screen.getByTestId('gpx-preview-points')).toHaveTextContent('3 pins')
|
||||
const leftOut = screen.getByTestId('gpx-preview-left-out')
|
||||
expect(leftOut).toHaveTextContent('Not imported')
|
||||
expect(leftOut).toHaveTextContent('3 pins')
|
||||
expect(leftOut).not.toHaveTextContent('pins')
|
||||
expect(leftOut).toHaveTextContent('1 area')
|
||||
expect(leftOut).not.toHaveTextContent('other item')
|
||||
expect(within(leftOut).getByRole('button', { name: 'Not imported' })).toBeInTheDocument()
|
||||
@@ -479,6 +483,8 @@ describe('ToursSidebar', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import 2 tours' }))
|
||||
await waitFor(() => expect(importCall).toHaveBeenCalledWith(1, file, { tour_type: 'hike' }))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 tour imported'))
|
||||
expect(pinsCall).toHaveBeenCalledWith(1, file, { points: true, paths: false })
|
||||
expect(toast.success).toHaveBeenCalledWith('3 places added')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('a ride taken from a GPX file (HOR-02)', () => {
|
||||
expect(gpxImport.confirm).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('gpx-preview-import'));
|
||||
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' });
|
||||
expect(gpxImport.confirm).toHaveBeenCalledWith({ tour_type: 'horse' }, { points: false });
|
||||
});
|
||||
|
||||
it('FE-HORSE-IMPORT-005: a recorded ride in view carries the card, with the heights its file lacks named', () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { placesApi } from '../../api/client';
|
||||
import { tourRepo } from '../../repo/tourRepo';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useTourGpxImport } from './useTourGpxImport';
|
||||
|
||||
// The toasts are caught at useToast, so no window global has to be set for them.
|
||||
@@ -67,3 +69,66 @@ describe('useTourGpxImport without a line in the file', () => {
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.unreadableIn', 'error', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useTourGpxImport with points beside the lines (#2969)', () => {
|
||||
const route = new File(['<gpx/>'], 'sygic.gpx');
|
||||
const pins = new File(['<kml/>'], 'pins.kml');
|
||||
const line = {
|
||||
name: 'Route',
|
||||
geometry: [[47, 11] as [number, number], [47.01, 11] as [number, number]],
|
||||
point_count: 2,
|
||||
distance_km: 1,
|
||||
elevation_gain_m: null,
|
||||
elevation_loss_m: null,
|
||||
duplicate: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
useTripStore.setState({ refreshPlaces: vi.fn(async () => {}) } as never);
|
||||
vi.spyOn(tourRepo, 'importGpx').mockResolvedValue({ tours: [], skipped: 0, caution: false } as never);
|
||||
});
|
||||
|
||||
const previewed = async (preview: (picked: File) => object) => {
|
||||
vi.spyOn(tourRepo, 'previewGpx').mockImplementation(async (_tripId, picked) => preview(picked as File) as never);
|
||||
const hook = renderHook(() => useTourGpxImport({ tripId: 2, canEdit: true }));
|
||||
await act(() => hook.result.current.importFiles([route, pins]));
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('adds the points of each file as places through the places import, waypoints only', async () => {
|
||||
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
|
||||
const importMap = vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 2 });
|
||||
const { result } = await previewed((picked) => ({
|
||||
tracks: [line],
|
||||
left_out: { points: picked === route ? 3 : 2, areas: 0, other: 0 },
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
|
||||
expect(importGpx).toHaveBeenCalledWith(2, route, { waypoints: true, routes: false, tracks: false });
|
||||
expect(importMap).toHaveBeenCalledWith(2, pins, { points: true, paths: false });
|
||||
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.pointsImported', 'success', undefined);
|
||||
});
|
||||
|
||||
it('leaves the points out when they are not asked for, and skips the Tours import for a file of known lines', async () => {
|
||||
const importGpx = vi.spyOn(placesApi, 'importGpx').mockResolvedValue({ count: 3 });
|
||||
const { result } = await previewed(() => ({
|
||||
tracks: [{ ...line, duplicate: true }],
|
||||
left_out: { points: 3, areas: 0, other: 0 },
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }));
|
||||
expect(importGpx).not.toHaveBeenCalled();
|
||||
expect(tourRepo.importGpx).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names a file whose points could not be added, and still takes in its tour', async () => {
|
||||
vi.spyOn(placesApi, 'importGpx').mockRejectedValue(new Error('offline'));
|
||||
vi.spyOn(placesApi, 'importMapFile').mockResolvedValue({ count: 0 });
|
||||
const { result } = await previewed((picked) => ({
|
||||
tracks: [line],
|
||||
...(picked === route ? { left_out: { points: 1, areas: 0, other: 0 } } : {}),
|
||||
}));
|
||||
await act(() => result.current.confirm({ tour_type: 'hike' }, { points: true }));
|
||||
expect(addToast).toHaveBeenCalledWith('tours.import.pointsFailedIn', 'error', undefined);
|
||||
expect(tourRepo.importGpx).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,10 +40,62 @@ function fileErrorMessage(error: unknown, name: string, t: Translate): string {
|
||||
export interface GpxPreviewFile {
|
||||
file: File;
|
||||
tracks: TourGpxPreviewTrack[];
|
||||
/** What a KML or KMZ file held besides its lines, which no tour is made of; absent for GPX. */
|
||||
/** What the file held besides its lines, which no tour is made of: GPX waypoints, KML pins, areas and the rest. */
|
||||
leftOut?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
/** What to do with the previewed files besides their lines. */
|
||||
export interface TourImportExtras {
|
||||
/** Add each file's points (GPX waypoints, KML pins) as places of the trip (#2969). */
|
||||
points?: boolean;
|
||||
}
|
||||
|
||||
interface ToursImported {
|
||||
tours: TourListItem[];
|
||||
skipped: number;
|
||||
caution: boolean;
|
||||
}
|
||||
|
||||
/** Each file with a line the trip does not hold yet, imported as tours; a file that fails is handed to `onError`. */
|
||||
async function importTours(
|
||||
tripId: number,
|
||||
files: readonly GpxPreviewFile[],
|
||||
fields: TourGpxImportFields,
|
||||
onError: (error: unknown, file: File) => void
|
||||
): Promise<ToursImported> {
|
||||
const sum: ToursImported = { tours: [], skipped: 0, caution: false };
|
||||
for (const { file, tracks } of files) {
|
||||
if (tracks.every((track) => track.duplicate)) continue;
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file, fields);
|
||||
sum.tours.push(...result.tours);
|
||||
sum.skipped += result.skipped;
|
||||
sum.caution ||= result.caution;
|
||||
} catch (error: unknown) {
|
||||
onError(error, file);
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** The points of each file that holds any, added as places (#2969); a file that fails is handed to `onError`. */
|
||||
async function importPoints(
|
||||
tripId: number,
|
||||
files: readonly GpxPreviewFile[],
|
||||
onError: (file: File) => void
|
||||
): Promise<number> {
|
||||
let places = 0;
|
||||
for (const { file, leftOut } of files) {
|
||||
if (!leftOut?.points) continue;
|
||||
try {
|
||||
places += await tourRepo.importFilePoints(tripId, file);
|
||||
} catch {
|
||||
onError(file);
|
||||
}
|
||||
}
|
||||
return places;
|
||||
}
|
||||
|
||||
interface TourGpxImportOptions {
|
||||
tripId: number;
|
||||
canEdit: boolean;
|
||||
@@ -115,26 +167,29 @@ export function useTourGpxImport({ tripId, canEdit, onToursChanged, onImported,
|
||||
[canEdit, fileError, importing, onNoTrack, preview, t, toast, tripId]
|
||||
);
|
||||
|
||||
/** The previewed files imported as the chosen type, summed up in one set of toasts. */
|
||||
/**
|
||||
* The previewed files imported as the chosen type, summed up in one set of
|
||||
* toasts. With `points`, what each file holds as single points comes in as
|
||||
* places as well, so a file with a route and its stops loses neither (#2969).
|
||||
* A file whose every line the trip already holds is not sent to the Tours
|
||||
* import at all, only for its points.
|
||||
*/
|
||||
const confirm = useCallback(
|
||||
async (fields: TourGpxImportFields) => {
|
||||
async (fields: TourGpxImportFields, extras: TourImportExtras = {}) => {
|
||||
if (!canEdit || !preview || importing) return;
|
||||
setImporting(true);
|
||||
const imported: TourListItem[] = [];
|
||||
let skipped = 0;
|
||||
let caution = false;
|
||||
try {
|
||||
for (const { file } of preview) {
|
||||
try {
|
||||
const result = await tourRepo.importGpx(tripId, file, fields);
|
||||
imported.push(...result.tours);
|
||||
skipped += result.skipped;
|
||||
caution ||= result.caution;
|
||||
} catch (error: unknown) {
|
||||
fileError(error, file);
|
||||
}
|
||||
}
|
||||
const { tours: imported, skipped, caution } = await importTours(tripId, preview, fields, fileError);
|
||||
const places = extras.points
|
||||
? await importPoints(tripId, preview, (file) =>
|
||||
toast.error(t('tours.import.pointsFailedIn', { name: file.name }))
|
||||
)
|
||||
: 0;
|
||||
setPreview(null);
|
||||
if (places > 0) {
|
||||
if (imported.length === 0) await refreshPlaces(tripId);
|
||||
toast.success(t('tours.import.pointsImported', { count: places }));
|
||||
}
|
||||
if (imported.length > 0) {
|
||||
// The server's place:created skips this socket, so the places are read
|
||||
// again here for the new ones and their lines. Only the places: a whole
|
||||
|
||||
@@ -18,8 +18,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { useDateFormat } from '../../../hooks/useDateFormat';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDisplayDate } from '../../../utils/dateFormat';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { BarButton, WhiteBadge } from '../../Planner/planParts';
|
||||
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell';
|
||||
@@ -428,12 +430,13 @@ function SnapshotNotice({
|
||||
onOpen: (placeId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dateFormat = useDateFormat();
|
||||
const snapshot = preview.snapshot;
|
||||
if (preview.state === 'new' || !snapshot) {
|
||||
return <TourNotice>{t('wanderer.preview.newHint')}</TourNotice>;
|
||||
}
|
||||
const when = snapshot.importedAt
|
||||
? new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`).toLocaleDateString(locale)
|
||||
? formatDisplayDate(new Date(`${snapshot.importedAt.replace(' ', 'T')}Z`), locale, {}, dateFormat)
|
||||
: '';
|
||||
const open = (
|
||||
<button
|
||||
|
||||
@@ -78,6 +78,19 @@ afterEach(() => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
// The hire date is the shared date picker (#2971): typed through its manual
|
||||
// field, cleared from its calendar.
|
||||
function typeDate(trigger: HTMLElement, value: string) {
|
||||
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
|
||||
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
|
||||
fireEvent.change(input, { target: { value } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
}
|
||||
function clearDate(trigger: HTMLElement) {
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
|
||||
}
|
||||
|
||||
describe('VacaySettings calendars', () => {
|
||||
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
|
||||
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
|
||||
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
|
||||
})
|
||||
render(<VacaySettings onClose={vi.fn()} />)
|
||||
|
||||
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
|
||||
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
|
||||
expect(hire()).toHaveAccessibleName(/2020/)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
|
||||
typeDate(hire(), '2021-03-02')
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
|
||||
clearDate(hire())
|
||||
expect(updateYearSettings).toHaveBeenLastCalledWith({
|
||||
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRigh
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { useSchoolHolidayCountries } from './useSchoolHolidayCountries'
|
||||
import type { VacayHolidayCalendar } from '../../types'
|
||||
@@ -355,12 +356,12 @@ function YearTypePicker() {
|
||||
|
||||
{type === 'anniversary' && (
|
||||
<div style={{ paddingInlineStart: 36, marginTop: 8 }}>
|
||||
<input
|
||||
type="date"
|
||||
<CustomDatePicker
|
||||
value={yearSettings.hire_date || ''}
|
||||
onChange={e => save({ hire_date: e.target.value || null })}
|
||||
aria-label={t('vacay.hireDate')}
|
||||
style={{ width: '100%', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, padding: '8px 14px', borderRadius: 10, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||
onChange={v => save({ hire_date: v || null })}
|
||||
placeholder={t('vacay.hireDate')}
|
||||
ariaLabel={t('vacay.hireDate')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{!yearSettings.hire_date && (
|
||||
<p className="text-[11px] mt-1 text-content-faint">{t('vacay.hireDateHint')}</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user