mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"react-hooks/exhaustive-deps": 46
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-explicit-any": 489,
|
||||
"@typescript-eslint/no-non-null-assertion": 1915,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
|
||||
@@ -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,10 @@
|
||||
}
|
||||
],
|
||||
"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/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,
|
||||
|
||||
@@ -8,17 +8,16 @@
|
||||
"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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { DistanceUnit, WeekStart } from '../../types'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
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'
|
||||
|
||||
@@ -69,7 +69,7 @@ function errorText(err: unknown, fallback: string): string {
|
||||
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()) })
|
||||
@@ -217,7 +217,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
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"
|
||||
@@ -283,7 +283,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') },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { DistanceUnit, Place, WeekStart } from '../../types';
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl';
|
||||
import {
|
||||
@@ -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';
|
||||
|
||||
@@ -53,6 +60,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 +163,8 @@ export function useDefaultUserSettings({ errorMessage, previewCreatedAt }: Defau
|
||||
defaults,
|
||||
loaded,
|
||||
managed,
|
||||
presets: mapPresetsFor(managed, hasAmapKey),
|
||||
canMapbox: mapboxOffered(managed, operatorMapboxToken),
|
||||
isSet,
|
||||
save,
|
||||
reset,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -232,3 +232,30 @@ describe('help registry: helpers', () => {
|
||||
expect(docsRoute({ slug: 'Creating-a-Trip', anchor: 'fields' })).toBe('/help/Creating-a-Trip#fields')
|
||||
})
|
||||
})
|
||||
|
||||
describe('help on a managed install', () => {
|
||||
it('leaves out the guides and screens about what the operator runs', async () => {
|
||||
const { useAuthStore } = await import('../store/authStore')
|
||||
const { useAddonStore } = await import('../store/addonStore')
|
||||
const reg = await import('./registry')
|
||||
useAuthStore.setState({ managed: true })
|
||||
try {
|
||||
expect(reg.getHelpGuide('create-backup')).toBeNull()
|
||||
expect(reg.getHelpGuide('instance-keys')).toBeNull()
|
||||
expect(reg.getHelpContext('admin-backup')).toBeNull()
|
||||
expect(reg.allHelpGuides().some(g => g.context === 'admin-storage')).toBe(false)
|
||||
expect(reg.getHelpGuide('files-sync')).toBeNull()
|
||||
// Integrations follow whether the instance has them on.
|
||||
useAddonStore.setState({ addons: [] })
|
||||
expect(reg.getHelpGuide('connect-dawarich')).toBeNull()
|
||||
useAddonStore.setState({ addons: [{ id: 'dawarich', enabled: true } as never] })
|
||||
expect(reg.getHelpGuide('connect-dawarich')).not.toBeNull()
|
||||
// What a hosted admin can still do keeps its guide.
|
||||
expect(reg.getHelpGuide('create-user')).not.toBeNull()
|
||||
} finally {
|
||||
useAuthStore.setState({ managed: false })
|
||||
useAddonStore.setState({ addons: [] })
|
||||
}
|
||||
expect(reg.getHelpGuide('create-backup')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { HelpContext, HelpGuide } from './types'
|
||||
import { useAddonStore } from '../store/addonStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { dashboardContext, dashboardGuides } from './contexts/dashboard'
|
||||
import { vacayContext, vacayGuides } from './contexts/vacay'
|
||||
import { atlasContext, atlasGuides } from './contexts/atlas'
|
||||
@@ -74,36 +76,87 @@ const GUIDE_LIST: HelpGuide[] = [
|
||||
export const HELP_CONTEXTS: ReadonlyMap<string, HelpContext> = new Map(CONTEXT_LIST.map(c => [c.id, c]))
|
||||
export const HELP_GUIDES: ReadonlyMap<string, HelpGuide> = new Map(GUIDE_LIST.map(g => [g.id, g]))
|
||||
|
||||
/**
|
||||
* What a managed install leaves out: the admin screens it hides (AdminPage) and
|
||||
* the guides about what the operator runs there, such as keys, OIDC, place
|
||||
* search, routing, backups, storage, releases and plugins. A guide that walks
|
||||
* somebody through a panel they cannot open is worse than none.
|
||||
*/
|
||||
const MANAGED_HIDDEN_CONTEXTS: ReadonlySet<string> = new Set([
|
||||
'admin-plugins', 'admin-storage', 'admin-github', 'admin-backup', 'settings-plugins',
|
||||
])
|
||||
const MANAGED_HIDDEN_GUIDES: ReadonlySet<string> = new Set([
|
||||
'oidc', 'instance-keys', 'places-transit', 'rotate-jwt-secret', 'own-routing-engine',
|
||||
'install-plugin', 'storage-backends', 'release-history', 'create-backup', 'auto-backup',
|
||||
'plugin-settings', 'files-sync', 'files-sync-settings', 'document-providers',
|
||||
])
|
||||
|
||||
/**
|
||||
* Guides about an integration, shown on a managed install only while it is on
|
||||
* there: the operator decides which integrations the instance has, and a guide
|
||||
* to connect one it does not offer leads nowhere.
|
||||
*/
|
||||
const addonOn = (id: string) => (): boolean => useAddonStore.getState().isEnabled(id)
|
||||
const MANAGED_FEATURE_GUIDES: Readonly<Record<string, () => boolean>> = {
|
||||
'connect-dawarich': addonOn('dawarich'),
|
||||
'dawarich-countries': addonOn('dawarich'),
|
||||
'dawarich-wishes': addonOn('dawarich'),
|
||||
'dawarich-stays': addonOn('dawarich'),
|
||||
'journey-day-stays': addonOn('dawarich'),
|
||||
'map-dawarich-trail': addonOn('dawarich'),
|
||||
'connect-airtrail': addonOn('airtrail'),
|
||||
'airtrail-import': addonOn('airtrail'),
|
||||
'photo-providers': addonOn('memories'),
|
||||
'mcp-oauth': addonOn('mcp'),
|
||||
'api-keys': addonOn('mcp'),
|
||||
'mcp-tokens-admin': addonOn('mcp'),
|
||||
'tour-connect-wanderer': () => useAddonStore.getState().toursWanderer,
|
||||
'tour-wanderer-watch': () => useAddonStore.getState().toursWanderer,
|
||||
'tour-import-wanderer': () => useAddonStore.getState().toursWanderer,
|
||||
}
|
||||
|
||||
/** Read at call time: `managed` arrives with app-config, after the registry loaded. */
|
||||
const isManaged = (): boolean => useAuthStore.getState().managed
|
||||
const shownContext = (c: HelpContext): boolean => !(isManaged() && MANAGED_HIDDEN_CONTEXTS.has(c.id))
|
||||
const shownGuide = (g: HelpGuide): boolean => {
|
||||
if (!isManaged()) return true
|
||||
if (MANAGED_HIDDEN_GUIDES.has(g.id) || MANAGED_HIDDEN_CONTEXTS.has(g.context)) return false
|
||||
const feature = MANAGED_FEATURE_GUIDES[g.id]
|
||||
return feature ? feature() : true
|
||||
}
|
||||
|
||||
export function getHelpContext(id: string | null | undefined): HelpContext | null {
|
||||
return id ? (HELP_CONTEXTS.get(id) ?? null) : null
|
||||
const context = id ? HELP_CONTEXTS.get(id) : undefined
|
||||
return context && shownContext(context) ? context : null
|
||||
}
|
||||
|
||||
export function getHelpGuide(id: string | null | undefined): HelpGuide | null {
|
||||
return id ? (HELP_GUIDES.get(id) ?? null) : null
|
||||
const guide = id ? HELP_GUIDES.get(id) : undefined
|
||||
return guide && shownGuide(guide) ? guide : null
|
||||
}
|
||||
|
||||
/** The guides of a context, in the order the context lists them. */
|
||||
export function guidesFor(context: HelpContext): HelpGuide[] {
|
||||
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined)
|
||||
return context.guides.map(id => HELP_GUIDES.get(id)).filter((g): g is HelpGuide => g !== undefined && shownGuide(g))
|
||||
}
|
||||
|
||||
export function allHelpGuides(): HelpGuide[] {
|
||||
return GUIDE_LIST
|
||||
return isManaged() ? GUIDE_LIST.filter(shownGuide) : GUIDE_LIST
|
||||
}
|
||||
|
||||
/** Every screen that has help, in registration order, for the screen switcher. */
|
||||
export function allHelpContexts(): HelpContext[] {
|
||||
return CONTEXT_LIST
|
||||
return isManaged() ? CONTEXT_LIST.filter(shownContext) : CONTEXT_LIST
|
||||
}
|
||||
|
||||
/** The screens that stand on their own; their sub-screens hang under them in the switcher. */
|
||||
export function topLevelHelpContexts(): HelpContext[] {
|
||||
return CONTEXT_LIST.filter(c => !c.parent)
|
||||
return CONTEXT_LIST.filter(c => !c.parent && shownContext(c))
|
||||
}
|
||||
|
||||
/** The sub-screens of a screen, in registration order. */
|
||||
export function childHelpContexts(parentId: string): HelpContext[] {
|
||||
return CONTEXT_LIST.filter(c => c.parent === parentId)
|
||||
return CONTEXT_LIST.filter(c => c.parent === parentId && shownContext(c))
|
||||
}
|
||||
|
||||
/** The screen and the screens above it, outermost first, for a breadcrumb. */
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
|
||||
* once, the way Safari lands after the iOS Home Screen app handed the PDF over.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useExportDeepLink } from './useExportDeepLink';
|
||||
|
||||
function setup(entry: string) {
|
||||
const openExport = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[entry]}>{children}</MemoryRouter>
|
||||
);
|
||||
const { result, rerender } = renderHook(
|
||||
() => {
|
||||
useExportDeepLink(openExport);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper }
|
||||
);
|
||||
return { openExport, result, rerender };
|
||||
}
|
||||
|
||||
describe('useExportDeepLink', () => {
|
||||
it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
|
||||
const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.pathname).toBe('/trips/7');
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
|
||||
rerender();
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
|
||||
const { openExport, result } = setup('/trips/7?tab=plan');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
|
||||
const { openExport, result } = setup('/trips/7?export=gpx');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?export=gpx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useEffectEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
|
||||
|
||||
/**
|
||||
* Opens the trip's export when the address asks for it (`?export=pdf`), the way
|
||||
* the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
|
||||
* once read, so a reload or a copied link does not open the dialog again.
|
||||
*/
|
||||
export function useExportDeepLink(openExport: () => void): void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
|
||||
// The shells pass a fresh closure on every render; the request is answered once.
|
||||
const open = useEffectEvent(openExport);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requested) return;
|
||||
open();
|
||||
setSearchParams(
|
||||
(p) => {
|
||||
p.delete(EXPORT_PARAM);
|
||||
return p;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [requested, setSearchParams]);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export default function MAdmin() {
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Blocks },
|
||||
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle },
|
||||
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Puzzle }]),
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive }]),
|
||||
{ id: 'notifications', label: t('admin.tabs.notifications'), icon: Bell },
|
||||
...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens'), icon: Plug }] : []),
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
const {
|
||||
addons, loading, handleToggle, tripAddons, globalAddons, integrationAddons, aiAddons, providerOptions, documentProviderOptions,
|
||||
toursWanderer,
|
||||
wandererOffered,
|
||||
toggleToursWanderer,
|
||||
mapSwitches,
|
||||
} = useAddonManager()
|
||||
@@ -115,13 +116,15 @@ export default function MAdminAddonManager({ bagTrackingEnabled, onToggleBagTrac
|
||||
{addon.id === 'documents' && addon.enabled && <MProviderShelf options={documentProviderOptions} />}
|
||||
{addon.id === 'tours' && addon.enabled && (
|
||||
<>
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
{wandererOffered && (
|
||||
<MSubRow
|
||||
providerIcon={WandererMark}
|
||||
title={t('admin.toursWanderer.title')}
|
||||
subtitle={t('admin.toursWanderer.subtitle')}
|
||||
enabled={toursWanderer}
|
||||
onToggle={() => void toggleToursWanderer()}
|
||||
/>
|
||||
)}
|
||||
{mapSwitches.map(({ key, Icon, titleKey, subtitleKey, enabled, toggle }) => (
|
||||
<MSubRow
|
||||
key={key}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getApiErrorMessage, type DistanceUnit, type WeekStart } from '../../../
|
||||
import { weekStartOptions } from '../../../utils/calendarWeek'
|
||||
import { withTileApiKey } from '../../../utils/tileUrl'
|
||||
import { defaultStyleForProvider } from '../../../components/Map/glProviders'
|
||||
import { MAP_PRESETS, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import type { MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { useDefaultUserSettings, type Defaults } from '../../../components/Admin/useDefaultUserSettings'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
@@ -22,7 +22,7 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
|
||||
export default function MAdminDefaultUserSettings(): 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: getApiErrorMessage, previewCreatedAt: () => new Date().toISOString() })
|
||||
@@ -80,14 +80,14 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
]
|
||||
const providerOptions: { value: string; label: React.ReactNode }[] = [
|
||||
{ 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') },
|
||||
]
|
||||
|
||||
const currencyLabel = defaults.default_currency
|
||||
? (SYMBOLS[defaults.default_currency] ? `${defaults.default_currency} ${SYMBOLS[defaults.default_currency]}` : defaults.default_currency)
|
||||
: t('settings.currency')
|
||||
const tilePresetLabel = MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
|
||||
const tilePresetLabel = presets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')
|
||||
const stylePresetLabel = glStylePresets.find((p) => p.url === mapboxStyle)?.name || t('admin.defaultSettings.mapboxStylePlaceholder')
|
||||
const chevron = <ChevronDown size={13} strokeWidth={2} className="flex-none text-m-faint" />
|
||||
|
||||
@@ -302,7 +302,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
|
||||
title={t('settings.mapTemplate')}
|
||||
value={mapTileUrl}
|
||||
onSelect={pickTilePreset}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
options={presets.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
|
||||
<MSetPickerSheet
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin';
|
||||
import type { TranslationFn } from '../../../types';
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput } from './MAdminUi';
|
||||
|
||||
interface MAdminOidcCardProps {
|
||||
admin: ReturnType<typeof useAdmin>;
|
||||
t: TranslationFn;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** OIDC / SSO, as on the desktop settings tab. The settings section leaves it out on a managed install. */
|
||||
export default function MAdminOidcCard({ admin, t, onSave }: MAdminOidcCardProps) {
|
||||
const { oidcConfig, setOidcConfig, savingOidc } = admin;
|
||||
return (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.oidcDisplayName')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.display_name}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
|
||||
placeholder={t('admin.oidcDisplayNamePlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.issuer}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField
|
||||
label={t('admin.oidcDiscoveryUrl')}
|
||||
hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}
|
||||
>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientId')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientSecret')}>
|
||||
<MAdminInput
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
|
||||
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminButton busy={savingOidc} onClick={onSave}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
|
||||
import MAdminOidcCard from './MAdminOidcCard'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import {
|
||||
MAdminButton,
|
||||
@@ -31,7 +32,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
placesPhotosEnabled, placesAutocompleteEnabled, placesDetailsEnabled, placesEnrichEnabled,
|
||||
placesGoogleOnly, handleTogglePlacesGoogleOnly,
|
||||
placeShadowEnabled,
|
||||
oidcConfig, setOidcConfig, savingOidc,
|
||||
passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration,
|
||||
oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration,
|
||||
envOverrideOidcOnly, oidcConfigured, requireMfa,
|
||||
@@ -134,6 +134,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
{t('admin.passkey.notConfigured')}
|
||||
</p>
|
||||
)}
|
||||
{/* The relying party is the instance's address, which the operator owns on a
|
||||
managed install (server managed.ts), same as the desktop tab. */}
|
||||
{!managed && (
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.passkey.rpId')} hint={t('admin.passkey.rpIdHint')}>
|
||||
<MAdminInput
|
||||
@@ -155,6 +158,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
</MAdminCard>
|
||||
|
||||
{/* Require 2FA */}
|
||||
@@ -187,7 +191,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
</div>
|
||||
</MAdminCard>
|
||||
|
||||
{/* API keys */}
|
||||
{/* API keys, place search and routing: the operator's on a managed install,
|
||||
same as the desktop tab. Share place details has its own section there. */}
|
||||
{!managed && (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
|
||||
<div className="space-y-3">
|
||||
@@ -379,55 +385,10 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
)}
|
||||
</div>
|
||||
</MAdminCard>
|
||||
)}
|
||||
|
||||
{/* OIDC / SSO */}
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.oidcTitle')} hint={t('admin.oidcSubtitle')} />
|
||||
<div className="space-y-3">
|
||||
<MAdminField label={t('admin.oidcDisplayName')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.display_name}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, display_name: e.target.value }))}
|
||||
placeholder={t('admin.oidcDisplayNamePlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcIssuer')} hint={t('admin.oidcIssuerHint')}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.issuer}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, issuer: e.target.value }))}
|
||||
placeholder="https://accounts.google.com"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('admin.oidcDiscoveryUrl')} hint={t('admin.oidcDiscoveryUrlHint', { path: '<issuer>/.well-known/openid-configuration' })}>
|
||||
<MAdminInput
|
||||
type="url"
|
||||
value={oidcConfig.discovery_url}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, discovery_url: e.target.value }))}
|
||||
placeholder="https://auth.example.com/application/o/trek/.well-known/openid-configuration"
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientId')}>
|
||||
<MAdminInput
|
||||
type="text"
|
||||
value={oidcConfig.client_id}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_id: e.target.value }))}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminField label={t('settings.oauth.clientSecret')}>
|
||||
<MAdminInput
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={(e) => setOidcConfig((c) => ({ ...c, client_secret: e.target.value }))}
|
||||
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
|
||||
/>
|
||||
</MAdminField>
|
||||
<MAdminButton busy={savingOidc} onClick={saveOidc}>
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
</MAdminCard>
|
||||
{/* OIDC / SSO: the operator's on a managed install, as on the desktop tab. */}
|
||||
{!managed && <MAdminOidcCard admin={admin} t={t} onSave={saveOidc} />}
|
||||
|
||||
{/* Danger zone */}
|
||||
<MAdminCard className="border-[color:color-mix(in_srgb,var(--m-st-danger)_28%,transparent)]">
|
||||
|
||||
@@ -17,6 +17,7 @@ import MToggle from '../../components/MToggle'
|
||||
import MStorageTestResult from '../../components/MStorageTestResult'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import MStorageReclaimCard from './MStorageReclaimCard'
|
||||
import { MSetSelectRow } from '../settings/MSettingsUi'
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminField, MAdminInput, MAdminSecretInput } from './MAdminUi'
|
||||
|
||||
@@ -172,6 +173,7 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
const {
|
||||
admin, editing, setEditing, confirmRemove, setConfirmRemove, syncPrompt, setSyncPrompt, migratePrompt,
|
||||
handleCancelMigration, handleRefreshStats, handleStartBackfill, handleCancelBackfill, loaded,
|
||||
reclaim, reclaimChecking, reclaimSaving, reclaimConfirming, handleReclaimCheck, handleReclaimDelete, handleReclaimConfirm,
|
||||
} = useStoragePanel()
|
||||
const [categoryPicker, setCategoryPicker] = useState<StorageCategory | null>(null)
|
||||
|
||||
@@ -565,6 +567,16 @@ export default function MAdminStoragePanel(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MStorageReclaimCard
|
||||
reclaim={reclaim}
|
||||
checking={reclaimChecking}
|
||||
saving={reclaimSaving}
|
||||
confirming={reclaimConfirming}
|
||||
onConfirm={(request) => void handleReclaimConfirm(request)}
|
||||
onCheck={() => void handleReclaimCheck()}
|
||||
onDelete={(enabled) => void handleReclaimDelete(enabled)}
|
||||
/>
|
||||
|
||||
<MConfirmSheet
|
||||
open={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import type { ReclaimCount, ReclaimView } from '../../../components/Admin/storage/reclaimModel';
|
||||
import { fs } from '../../../components/shared/DialogShell';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { formatBytes } from '../../../utils/formatBytes';
|
||||
import { relativeTime } from '../../../utils/relativeTime';
|
||||
import MToggle from '../../components/MToggle';
|
||||
import { MAdminButton, MAdminCard, MAdminCardHead, MAdminRow } from './MAdminUi';
|
||||
|
||||
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 phone's storage panel, off the view model StorageReclaimSection shows on desktop. */
|
||||
export default function MStorageReclaimCard({
|
||||
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 (
|
||||
<MAdminCard>
|
||||
<MAdminCardHead
|
||||
title={t('storage.reclaim.title')}
|
||||
hint={t('storage.reclaim.hint')}
|
||||
trailing={
|
||||
<MAdminButton variant="ghost" busy={checking} onClick={onCheck}>
|
||||
{checking ? t('storage.reclaim.running') : t('storage.reclaim.check')}
|
||||
</MAdminButton>
|
||||
}
|
||||
/>
|
||||
<MAdminRow
|
||||
first
|
||||
title={t('storage.reclaim.delete')}
|
||||
hint={t('storage.reclaim.deleteHint')}
|
||||
trailing={
|
||||
<MToggle
|
||||
checked={reclaim.deleteEnabled}
|
||||
onChange={onDelete}
|
||||
disabled={saving}
|
||||
ariaLabel={t('storage.reclaim.delete')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-[11px]" data-testid="m-storage-reclaim-summary">
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{reclaim.checkedAt !== null
|
||||
? t('storage.reclaim.checked', { age: relativeTime(reclaim.checkedAt, locale) })
|
||||
: t('storage.reclaim.never')}
|
||||
</p>
|
||||
{reclaim.stopped && (
|
||||
<p role="note" className="mt-1 font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
|
||||
{t(reclaim.stopped === 'failed' ? 'storage.reclaim.stoppedFailed' : 'storage.reclaim.stopped')}
|
||||
</p>
|
||||
)}
|
||||
{checked && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<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 && (
|
||||
<p className="mt-2 font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.nothing')}
|
||||
</p>
|
||||
)}
|
||||
{reclaim.lines.length > 0 && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{reclaim.lines.map((line) => (
|
||||
<div key={line.category} data-testid={`m-storage-reclaim-${line.category}`}>
|
||||
<p className="font-bold text-m-ink" style={fs(12, 'body')}>
|
||||
{t(`storage.category.${line.category}`)}
|
||||
</p>
|
||||
{line.skipped && (
|
||||
<p className="font-geist text-[color:var(--m-st-pending)]" style={fs(10)}>
|
||||
{t(`storage.reclaim.skip.${line.skipped}`)}
|
||||
</p>
|
||||
)}
|
||||
{line.freeable.objects > 0 && (
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.freeable')}: {count(line.freeable)}
|
||||
</p>
|
||||
)}
|
||||
{line.waiting.objects > 0 && (
|
||||
<p className="font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.waiting')}: {count(line.waiting)}
|
||||
</p>
|
||||
)}
|
||||
{line.confirmationRequired && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<p className="min-w-0 flex-1 font-geist text-m-muted" style={fs(10)}>
|
||||
{t('storage.reclaim.confirmNeeded')}
|
||||
</p>
|
||||
<MAdminButton
|
||||
variant="ghost"
|
||||
busy={confirming === line.category}
|
||||
onClick={() => onConfirm({ category: line.category, unreferenced: line.unreferenced })}
|
||||
>
|
||||
{t('storage.reclaim.confirm')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MAdminCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-geist text-m-muted" style={fs(11)}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-geist font-bold tabular-nums text-m-ink" style={fs(11)}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export default function MSettings() {
|
||||
{ id: 'map', label: t('settings.tabs.map'), icon: Map },
|
||||
{ id: 'notifications', label: t('settings.tabs.notifications'), icon: Bell },
|
||||
...(hasIntegrations ? [{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug }] : []),
|
||||
...(hasPlugins ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
|
||||
...(hasPlugins && !managed ? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Puzzle }] : []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
{ id: 'account', label: t('settings.tabs.account'), icon: User },
|
||||
// Same call as the desktop page: About is about the project — what TREK is,
|
||||
@@ -116,7 +116,7 @@ export default function MSettings() {
|
||||
{active.id === 'notifications' && <MSettingsNotifications />}
|
||||
{active.id === 'integrations' && hasIntegrations && <MSettingsIntegrations />}
|
||||
{/* Per-plugin settings still reuse the existing responsive tab. */}
|
||||
{active.id === 'plugins' && hasPlugins && <MSettingsPlugins />}
|
||||
{active.id === 'plugins' && hasPlugins && !managed && <MSettingsPlugins />}
|
||||
{active.id === 'offline' && <MSettingsOffline />}
|
||||
{active.id === 'account' && <MSettingsAccount />}
|
||||
{active.id === 'about' && appVersion && <MSettingsAbout appVersion={appVersion} />}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MapView } from '../../../components/Map/MapView'
|
||||
import ErrorBoundary from '../../../components/shared/ErrorBoundary'
|
||||
import { GlMapPreviewMapbox, GlMapPreviewMaplibre } from '../../../components/Map/glLazy'
|
||||
import { defaultStyleForProvider, getStylePresets } from '../../../components/Map/glProviders'
|
||||
import { MAP_PRESETS, PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { PREVIEW_CENTER, PREVIEW_ZOOM, type MapProvider } from '../../../components/Settings/mapSettingsModel'
|
||||
import { useMapSettingsForm } from '../../../components/Settings/useMapSettingsForm'
|
||||
import { withTileApiKey } from '../../../utils/tileUrl'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -20,7 +20,7 @@ import MSetPickerSheet from './MSetPickerSheet'
|
||||
export default function MSettingsMap() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
managed, presets: tilePresets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
|
||||
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
|
||||
@@ -29,7 +29,7 @@ export default function MSettingsMap() {
|
||||
|
||||
const providers: { id: MapProvider; name: string; sub: string; icon: typeof Layers }[] = [
|
||||
{ id: 'leaflet', name: 'Leaflet', sub: t('settings.mapLeafletSubtitle'), icon: Layers },
|
||||
{ id: 'mapbox-gl', name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box },
|
||||
...(canMapbox ? [{ id: 'mapbox-gl' as const, name: 'Mapbox GL', sub: t('settings.mapMapboxSubtitle'), icon: Box }] : []),
|
||||
{ id: 'maplibre-gl', name: 'MapLibre GL', sub: t('settings.mapMapLibreSubtitle'), icon: Globe2 },
|
||||
]
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MSettingsMap() {
|
||||
<>
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.mapTemplate')}</MSetEyebrow>
|
||||
<MSetSelectRow
|
||||
label={MAP_PRESETS.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
|
||||
label={tilePresets.find((p) => p.url === mapTileUrl)?.name || t('settings.mapTemplatePlaceholder.select')}
|
||||
trailing={chevron}
|
||||
onClick={() => setPresetOpen(true)}
|
||||
/>
|
||||
@@ -221,7 +221,7 @@ export default function MSettingsMap() {
|
||||
title={t('settings.mapTemplate')}
|
||||
value={mapTileUrl}
|
||||
onSelect={setMapTileUrl}
|
||||
options={MAP_PRESETS.map((p) => ({ value: p.url, label: p.name }))}
|
||||
options={tilePresets.map((p) => ({ value: p.url, label: p.name }))}
|
||||
/>
|
||||
|
||||
<MSetPickerSheet
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
TRANSPORT_TYPES, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints, hidesOnMiddleDay,
|
||||
parseTimeToMinutes,
|
||||
TRANSPORT_TYPES, chronoKeyOf, getAssignmentReservations, getDisplayTimeForDay, getSpanPhase, getTransportRouteEndpoints,
|
||||
hidesOnMiddleDay, parseTimeToMinutes,
|
||||
} from '../../../../utils/dayMerge'
|
||||
import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
@@ -310,12 +310,8 @@ export function breaksChronology(
|
||||
dayId: number,
|
||||
getDisplayTime: (r: Reservation, dayId: number) => string | null,
|
||||
): boolean {
|
||||
const times = order
|
||||
.map(it => {
|
||||
if (it.type === 'place') return parseTimeToMinutes((it.data as Assignment).place?.place_time)
|
||||
if (it.type === 'note') return parseTimeToMinutes((it.data as DayNote).time)
|
||||
return parseTimeToMinutes(getDisplayTime(it.data as Reservation, dayId))
|
||||
})
|
||||
.filter((m): m is number => m != null)
|
||||
// The key the day is drawn by, so flights put in the order they happen across zones still read as in order (#2929).
|
||||
const keyOf = chronoKeyOf(order, dayId, getDisplayTime)
|
||||
const times = order.map(keyOf).filter((m): m is number => m != null)
|
||||
return times.some((m, i) => i > 0 && m < times[i - 1])
|
||||
}
|
||||
|
||||
@@ -210,6 +210,28 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
// A day_number of 0 is as unusable as a missing one — both fall back to the row position.
|
||||
const dayLabel = day?.title || t('planner.dayN', { n: day ? day.day_number || dayIndex + 1 : '?' })
|
||||
const heading = dayHeadingParts(dayLabel, formattedDate, dateFirst)
|
||||
// The input takes the size of the line it edits: the heading, or the line under the date.
|
||||
const titleInput = (lead: boolean) => (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
placeholder={t('planner.dayN', { n: dayIndex + 1 })}
|
||||
className={`w-full border-b-[1.5px] border-[color:var(--m-ink)] bg-transparent p-0 font-[inherit] outline-none ${lead ? 'text-[1.0625rem] font-bold' : ''}`}
|
||||
/>
|
||||
)
|
||||
const renamePencil = canEditDays && day && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(day.title || '')}
|
||||
aria-label={t('mobileTrip.renameDay')}
|
||||
className="flex flex-none p-[3px] text-m-faint"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={1.8} />
|
||||
</button>
|
||||
)
|
||||
const WeatherIcon = weatherIconFor(weather?.main)
|
||||
|
||||
const stayBadge = (acc: (typeof dayAccommodations)[number]) => {
|
||||
@@ -224,34 +246,25 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
{day && (
|
||||
<>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
{/* The pencil and the input go with the name: under the date when it leads (#2921). */}
|
||||
<TileHeader
|
||||
icon={<CalendarDays size={19} strokeWidth={1.8} />}
|
||||
title={editingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
value={titleDraft}
|
||||
onChange={e => setTitleDraft(e.target.value)}
|
||||
onBlur={commitRename}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setEditingTitle(false) }}
|
||||
placeholder={t('planner.dayN', { n: dayIndex + 1 })}
|
||||
className="w-full border-b-[1.5px] border-[color:var(--m-ink)] bg-transparent p-0 font-[inherit] text-[1.0625rem] font-bold outline-none"
|
||||
/>
|
||||
title={editingTitle && heading.nameFirst ? (
|
||||
titleInput(true)
|
||||
) : (
|
||||
<>
|
||||
<span className="truncate">{heading.primary}</span>
|
||||
{canEditDays && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startRename(day.title || '')}
|
||||
aria-label={t('mobileTrip.renameDay')}
|
||||
className="flex flex-none p-[3px] text-m-faint"
|
||||
>
|
||||
<Pencil size={12} strokeWidth={1.8} />
|
||||
</button>
|
||||
)}
|
||||
{heading.nameFirst && renamePencil}
|
||||
</>
|
||||
)}
|
||||
sub={heading.secondary}
|
||||
sub={!heading.nameFirst && heading.secondary ? (
|
||||
editingTitle ? titleInput(false) : (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate">{heading.secondary}</span>
|
||||
{renamePencil}
|
||||
</span>
|
||||
)
|
||||
) : heading.secondary}
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useTripExport } from '../../../../components/Planner/useTripExport'
|
||||
import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const open = shell.sheet?.id === 'export'
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => shell.openSheet('export'))
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
|
||||
// applies the day plan's filter itself, so both shells print the same. The PDF stays
|
||||
// open here; a calendar or GPX file closes the sheet.
|
||||
|
||||
@@ -1837,4 +1837,12 @@ describe('AdminPage', () => {
|
||||
await screen.findByRole('button', { name: /^users$/i });
|
||||
expect(screen.queryByRole('button', { name: /^storage$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PAGE-ADMIN-STOR-003: managed mode hides the Plugins tab, and ?tab=plugins falls back to Users', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
render(<AdminPage />, { initialEntries: ['/admin?tab=plugins'] });
|
||||
await screen.findByRole('button', { name: /^users$/i });
|
||||
expect(screen.queryByRole('button', { name: /^plugins$/i })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByRole('heading', { name: /plugins/i })).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,9 @@ function AdminPageDesktop(): React.ReactElement {
|
||||
{ id: 'config', label: t('admin.tabs.config'), icon: SlidersHorizontal, group: gConfig },
|
||||
{ id: 'settings', label: t('admin.tabs.settings'), icon: SettingsIcon, group: gConfig },
|
||||
{ id: 'addons', label: t('admin.tabs.addons'), icon: Puzzle, group: gConfig },
|
||||
{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig },
|
||||
// Plugins run third-party code that can send data anywhere; a hosted instance
|
||||
// offers none, so there is nothing to manage here (TREK_PLUGINS_ENABLED is off).
|
||||
...(managed ? [] : [{ id: 'plugins', label: t('admin.tabs.plugins'), icon: Blocks, group: gConfig }]),
|
||||
// Storage backends and their credentials are hoster-level configuration —
|
||||
// the server refuses the whole surface in managed mode (MANAGED_FORBIDDEN).
|
||||
...(managed ? [] : [{ id: 'storage', label: t('admin.tabs.storage'), icon: HardDrive, group: gConfig }]),
|
||||
|
||||
@@ -4,10 +4,9 @@ import { render, screen, waitFor, cleanup, within } from '../../tests/helpers/re
|
||||
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 { resetAllStores } from '../../tests/helpers/store';
|
||||
import { seedJourneyOwner } from '../../tests/helpers/journeyDetail';
|
||||
import { buildUser } from '../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePermissionsStore } from '../store/permissionsStore';
|
||||
import { useJourneyStore } from '../store/journeyStore';
|
||||
import JourneyDetailPage from './JourneyDetailPage';
|
||||
|
||||
@@ -238,8 +237,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAllStores();
|
||||
useJourneyStore.setState(useJourneyStore.getState(), true);
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: ownerUser });
|
||||
seedStore(usePermissionsStore, { level: 'owner' } as any);
|
||||
seedJourneyOwner(ownerUser);
|
||||
setupDefaultHandlers();
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
// Always there: API keys sit in this tab and need no addon. The addon backed
|
||||
// sections inside it gate themselves.
|
||||
{ id: 'integrations', label: t('settings.tabs.integrations'), icon: Plug },
|
||||
...(hasPlugins
|
||||
...(hasPlugins && !managed
|
||||
? [{ id: 'plugins', label: t('settings.tabs.plugins'), icon: Blocks }]
|
||||
: []),
|
||||
{ id: 'offline', label: t('settings.tabs.offline'), icon: CloudOff },
|
||||
@@ -87,7 +87,7 @@ function SettingsPageDesktop(): React.ReactElement {
|
||||
{activeTab === 'map' && <MapSettingsTab />}
|
||||
{activeTab === 'notifications' && <NotificationsTab />}
|
||||
{activeTab === 'integrations' && <IntegrationsTab />}
|
||||
{activeTab === 'plugins' && hasPlugins && <PluginSettingsTab />}
|
||||
{activeTab === 'plugins' && hasPlugins && !managed && <PluginSettingsTab />}
|
||||
{activeTab === 'offline' && <OfflineTab />}
|
||||
{activeTab === 'account' && <AccountTab />}
|
||||
{activeTab === 'about' && appVersion && <AboutTab appVersion={appVersion} />}
|
||||
|
||||
@@ -78,6 +78,21 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
</EditorField>
|
||||
)
|
||||
|
||||
const tripRemindersCard = (
|
||||
<SettingsCard
|
||||
icon={CalendarClock}
|
||||
title={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
return (<>
|
||||
{/* Two columns from xl up, the same shape as the Settings tab. Most of these
|
||||
cards are a title with a switch on the far right, so on a wide screen the
|
||||
@@ -168,19 +183,7 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
action={<LockedSwitch />}
|
||||
/>
|
||||
|
||||
{/* Trip Reminders Toggle */}
|
||||
<SettingsCard
|
||||
icon={CalendarClock}
|
||||
title={t('admin.notifications.tripReminders.title')}
|
||||
hint={t('admin.notifications.tripReminders.hint')}
|
||||
action={
|
||||
<ToggleSwitch
|
||||
on={tripRemindersActive}
|
||||
label={t('admin.notifications.tripReminders.title')}
|
||||
onToggle={toggleTripReminders}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!managed && tripRemindersCard}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
@@ -284,6 +287,9 @@ export default function AdminNotificationsTab({ admin, t }: AdminNotificationsTa
|
||||
over, so it belongs under the targets it routes to rather than across the
|
||||
full width below both columns. */}
|
||||
<AdminNotificationsPanel t={t} toast={toast} />
|
||||
{/* Without the admin alert cards the right column is short, so the
|
||||
reminder switch moves over on a managed install. */}
|
||||
{managed && tripRemindersCard}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -320,24 +320,24 @@ describe('AdminSettingsTab', () => {
|
||||
expect(admin.handleSavePlacesProvider).toHaveBeenCalledWith('amap');
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022e: a managed install hides the keys but keeps the provider choice', () => {
|
||||
// The operator owns the credentials; which of them answers place search is
|
||||
// still the admin's call (server managed.ts lists places_provider as theirs).
|
||||
it('FE-ADMSET-022e: a managed install hides place search, routing and the keys', () => {
|
||||
// All of it is the operator's there (server managed.ts locks places_provider);
|
||||
// Share place details has a tab of its own on AdminPage.
|
||||
renderTab({ managed: true, placesProvider: 'auto' });
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'API Keys' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Google Maps API Key')).not.toBeInTheDocument();
|
||||
expect(within(card(SERVICES)).getByRole('button', { name: 'Automatic' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: SERVICES })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Automatic' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-022f: an operator key keeps the managed install quiet', () => {
|
||||
// The key fields are not on the page here, and an operator key set through
|
||||
// the environment would not show up in them anyway. app-config knows.
|
||||
renderTab({ managed: true, placesProvider: 'google', mapsKey: '', hasMapsKey: true });
|
||||
it('FE-ADMSET-022f: a managed install moves file types to the right column', () => {
|
||||
// With place search and the keys gone the right column would stand empty.
|
||||
renderTab({ managed: true });
|
||||
|
||||
expect(
|
||||
within(card(SERVICES)).queryByText(/falls back to OpenStreetMap/i),
|
||||
).not.toBeInTheDocument();
|
||||
const right = screen.getByRole('heading', { name: 'Allowed File Types' }).closest('.min-w-0');
|
||||
expect(right).not.toBeNull();
|
||||
expect(within(right as HTMLElement).queryByRole('heading', { name: /two-factor/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMSET-023: the maps Test button is disabled without a key', () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions'
|
||||
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
|
||||
import ProviderBlock from './ProviderBlock'
|
||||
import TrekApiCard from './TrekApiCard'
|
||||
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
|
||||
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
|
||||
import { placesGoogleOnlyHint } from '../../utils/placeSource'
|
||||
import type { TranslationFn } from '../../types'
|
||||
@@ -140,6 +141,29 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
const passwordLoginLocked = !passwordLogin && !oidcLogin
|
||||
const oidcLoginLocked = !passwordLogin && oidcLogin
|
||||
|
||||
// On a managed install the right column loses place search, routing and the
|
||||
// keys to the operator, so file types move over and the columns stay even.
|
||||
const fileTypesCard = (
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('admin.fileTypes')}
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
|
||||
</div>
|
||||
<SaveBar
|
||||
label={t('common.save')}
|
||||
saving={savingFileTypes}
|
||||
onClick={saveFileTypes}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Two columns from xl up. Nearly every card here is "label left,
|
||||
@@ -351,33 +375,24 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Allowed File Types */}
|
||||
<SettingsCard icon={FileType} title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')}>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('admin.fileTypes')}
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
className={`${INPUT} font-geist`}
|
||||
/>
|
||||
<SettingsHint className="mt-1.5">{t('admin.fileTypesFormat')}</SettingsHint>
|
||||
</div>
|
||||
<SaveBar
|
||||
label={t('common.save')}
|
||||
saving={savingFileTypes}
|
||||
onClick={saveFileTypes}
|
||||
/>
|
||||
</SettingsCard>
|
||||
{!managed && fileTypesCard}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
{managed && fileTypesCard}
|
||||
{/* Inside the place search card elsewhere; that card is the operator's here,
|
||||
sharing place details is still the admin's decision. */}
|
||||
{managed && <PlaceContribPanel hideWhenSwitchedOff />}
|
||||
|
||||
{/* Place search and routing: TREK's own services first, then which provider
|
||||
answers search and transit, and the search log. Apart from the keys, so a
|
||||
keyless install reads its whole setup in one card. Everything here saves
|
||||
as it is changed. */}
|
||||
{/* On a managed install place search and routing are the operator's: the
|
||||
services, their allowance and the providers behind them come with the
|
||||
instance. Share place details stays the admin's call and has a tab of
|
||||
its own there (AdminPage). */}
|
||||
{!managed && (
|
||||
<SettingsCard icon={Compass} title={t('admin.servicesCard.title')} hint={t('admin.servicesCard.hint')}>
|
||||
<TrekApiCard t={t} managed={managed} />
|
||||
{!managed && (<>
|
||||
@@ -486,6 +501,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
|
||||
</SettingRows>
|
||||
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
|
||||
the operator's on a managed install, so the card is not shown there;
|
||||
|
||||
@@ -33,7 +33,7 @@ const ADMIN_TAB_IDS = [
|
||||
* hosted instance. Checked in an effect rather than in the initial state
|
||||
* because `managed` arrives with /app-config, after the first render.
|
||||
*/
|
||||
const MANAGED_HIDDEN = ['storage', 'github', 'backup']
|
||||
const MANAGED_HIDDEN = ['storage', 'github', 'backup', 'plugins']
|
||||
|
||||
/** How often the open users list asks again who is online and active. */
|
||||
const USERS_REFRESH_MS = 30_000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { computeTourMetrics, estimatePaddleTime, paddleCheckSummaryOf, paddleLayoutOfTrack, paddleMovingSeconds, recommendedPaddleSpeedKmh, type GeometryPoint, type TourActivityAssignRequest, type TourObstacleCheck, type TourObstacleReviewsRequest, type TourPaddlePlanRequest, type TourSkiClassificationRequest, type TourCreateRequest, type TourCreateResponse, type TourDetailResponse, type TourGpxImportFields, type TourGpxPreviewResponse, type TourImportGpxResponse, type TourListItem, type TourWaypoint, walkStationFacts } from '@trek/shared'
|
||||
import { toursApi } from '../api/client'
|
||||
import { placesApi, toursApi } from '../api/client'
|
||||
import { tourGpxApi } from '../api/tourGpx'
|
||||
import { recordedTrackApi } from '../api/recordedTrack'
|
||||
import { tourActivitiesApi } from '../api/tourActivities'
|
||||
@@ -499,6 +499,19 @@ export const tourRepo = {
|
||||
return result
|
||||
},
|
||||
|
||||
/**
|
||||
* The single points of a file (GPX waypoints, KML pins) added as places of
|
||||
* the trip through the places import, which reads nothing else of it here
|
||||
* (#2969). Online only, like the Tours import beside it. Answers how many
|
||||
* places came in.
|
||||
*/
|
||||
async importFilePoints(tripId: number | string, file: File): Promise<number> {
|
||||
const result = file.name.toLowerCase().endsWith('.gpx')
|
||||
? await placesApi.importGpx(tripId, file, { waypoints: true, routes: false, tracks: false })
|
||||
: await placesApi.importMapFile(tripId, file, { points: true, paths: false })
|
||||
return result.count ?? 0
|
||||
},
|
||||
|
||||
/** Reads a GPX, KML or KMZ file for its preview; online only, like the import, and nothing is cached. */
|
||||
async previewGpx(tripId: number | string, file: File): Promise<TourGpxPreviewResponse> {
|
||||
return tourGpxApi.preview(tripId, file)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-UTIL-DAYLABEL-001 to FE-UTIL-DAYLABEL-003
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dayHeadingParts, dayLabel } from './dayLabel'
|
||||
import { dayHeadingParts, dayLabel, headingFit } from './dayLabel'
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`
|
||||
|
||||
@@ -23,15 +23,30 @@ describe('dayLabel', () => {
|
||||
|
||||
describe('dayHeadingParts', () => {
|
||||
it('FE-UTIL-DAYLABEL-004: the name leads by default and the date follows', () => {
|
||||
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', false)).toEqual({ primary: 'Day 2', secondary: 'Mon, Aug 17' })
|
||||
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', false)).toEqual({ primary: 'Day 2', secondary: 'Mon, Aug 17', nameFirst: true })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLABEL-005: planning by the calendar puts the date first', () => {
|
||||
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', true)).toEqual({ primary: 'Mon, Aug 17', secondary: 'Day 2' })
|
||||
expect(dayHeadingParts('Day 2', 'Mon, Aug 17', true)).toEqual({ primary: 'Mon, Aug 17', secondary: 'Day 2', nameFirst: false })
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLABEL-006: a day without a date keeps its name in front either way', () => {
|
||||
expect(dayHeadingParts('Day 2', null, true)).toEqual({ primary: 'Day 2', secondary: null })
|
||||
expect(dayHeadingParts('Day 2', '', false)).toEqual({ primary: 'Day 2', secondary: null })
|
||||
expect(dayHeadingParts('Day 2', null, true)).toEqual({ primary: 'Day 2', secondary: null, nameFirst: true })
|
||||
expect(dayHeadingParts('Day 2', '', false)).toEqual({ primary: 'Day 2', secondary: null, nameFirst: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('headingFit', () => {
|
||||
it('FE-UTIL-DAYLABEL-007: with the date first the name behind it gives way and the date stays whole (#2920)', () => {
|
||||
const fit = headingFit(dayHeadingParts('Arrival, East Coast & Optional Vegueta Evening', 'Thursday, November 12', true))
|
||||
expect(fit.primary).toContain('flex-none')
|
||||
expect(fit.primary).not.toContain('truncate')
|
||||
expect(fit.secondary).toContain('truncate')
|
||||
})
|
||||
|
||||
it('FE-UTIL-DAYLABEL-008: with the name first the name gives way, as before', () => {
|
||||
const fit = headingFit({ nameFirst: true })
|
||||
expect(fit.primary).toContain('truncate')
|
||||
expect(fit.secondary).toContain('flex-none')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,8 +33,19 @@ export function dayDate(iso: string, locale: string): string | null {
|
||||
* A day heading's two parts in reading order. The day's name ("Day 3" or its own
|
||||
* title) leads, unless the traveller plans by the calendar and asked for the date
|
||||
* first (#1953). A day without a date always leads with its name.
|
||||
*
|
||||
* `nameFirst` says which of the two is the name: the rename pencil and the title
|
||||
* input belong with the name wherever it stands (#2921), and it is the name that
|
||||
* gives way when the line is short, never the date (#2920).
|
||||
*/
|
||||
export function dayHeadingParts(name: string, date: string | null | undefined, dateFirst: boolean): { primary: string; secondary: string | null } {
|
||||
if (dateFirst && date) return { primary: date, secondary: name }
|
||||
return { primary: name, secondary: date || null }
|
||||
export function dayHeadingParts(name: string, date: string | null | undefined, dateFirst: boolean): { primary: string; secondary: string | null; nameFirst: boolean } {
|
||||
if (dateFirst && date) return { primary: date, secondary: name, nameFirst: false }
|
||||
return { primary: name, secondary: date || null, nameFirst: true }
|
||||
}
|
||||
|
||||
/** On one line the name gives way when it runs long, the date stays whole: with the date first a long title squeezed it to "Th…" (#2920). */
|
||||
export function headingFit({ nameFirst }: { nameFirst: boolean }): { primary: string; secondary: string } {
|
||||
const gives = 'min-w-0 truncate'
|
||||
const keeps = 'flex-none whitespace-nowrap'
|
||||
return nameFirst ? { primary: gives, secondary: keeps } : { primary: keeps, secondary: gives }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, pinnedAway, stopDayIds, getPlaceBookings, getMergedItems, chronoKeyOf, nightRowsOf, rideSeatKey, seatTimedDrop, stayStartingOn, storedRideSlot, timedSlot, type MergedItem } from './dayMerge'
|
||||
import { planMergedOrder, withTransportPositions } from './mergedOrder'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
@@ -648,3 +648,102 @@ describe('seatTimedDrop', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// The report behind #2929: Auckland to Los Angeles leaves at 20:20 and lands at 12:25 the
|
||||
// same date, local time at each end; the flight on to Seattle leaves Los Angeles at 17:00.
|
||||
// By the clock alone 17:00 comes first, so the second flight was drawn above the first.
|
||||
describe('getMergedItems: bookings in different time zones (#2929)', () => {
|
||||
const DAY = 5
|
||||
const days = [{ id: DAY, day_number: 1, date: '2026-11-20' }]
|
||||
const flight = (id: number, time: string, end: string, from: string | null, to: string | null) => ({
|
||||
id, type: 'flight', day_id: DAY, end_day_id: DAY,
|
||||
reservation_time: `2026-11-20T${time}`, reservation_end_time: `2026-11-20T${end}`,
|
||||
metadata: JSON.stringify({ ...(from ? { departure_timezone: from } : {}), ...(to ? { arrival_timezone: to } : {}) }),
|
||||
})
|
||||
const toLA = flight(1, '20:20', '12:25', 'Pacific/Auckland', 'America/Los_Angeles')
|
||||
const toSeattle = flight(2, '17:00', '19:45', 'America/Los_Angeles', 'America/Los_Angeles')
|
||||
const ids = (items: MergedItem[]) => items.map(i => i.data.id)
|
||||
|
||||
it('puts the Auckland flight ahead of the Los Angeles one it connects to', () => {
|
||||
for (const dayTransports of [[toLA, toSeattle], [toSeattle, toLA]]) {
|
||||
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }))).toEqual([1, 2])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the stops of the day where their clock puts them', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 10, order_index: 0, place: { place_time: '09:00' } },
|
||||
{ id: 11, order_index: 1, place: { place_time: '18:00' } },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
|
||||
// The flights swap the slots they hold between them (17:00 and 20:20); the stops stay put.
|
||||
expect(result.map(i => `${i.type}:${i.data.id}`)).toEqual(['place:10', 'transport:1', 'place:11', 'transport:2'])
|
||||
})
|
||||
|
||||
it('reads the zone from the endpoints when the metadata names none', () => {
|
||||
const endpointsOf = (fromTz: string, toTz: string) => [
|
||||
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 0, lng: 0, timezone: fromTz, local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 1, name: 'B', code: null, lat: 0, lng: 0, timezone: toTz, local_time: null, local_date: null },
|
||||
]
|
||||
const a = { ...flight(1, '20:20', '12:25', null, null), endpoints: endpointsOf('Pacific/Auckland', 'America/Los_Angeles') }
|
||||
const b = { ...flight(2, '17:00', '19:45', null, null), endpoints: endpointsOf('America/Los_Angeles', 'America/Los_Angeles') }
|
||||
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [b, a], dayId: DAY }))).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('orders the legs of one multi-leg booking by when they leave', () => {
|
||||
const booking = {
|
||||
id: 3, type: 'flight', day_id: DAY, end_day_id: DAY,
|
||||
reservation_time: '2026-11-20T20:20', reservation_end_time: '2026-11-20T19:45',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Auckland', code: 'AKL', lat: -37.0, lng: 174.8, timezone: 'Pacific/Auckland', local_time: null, local_date: null },
|
||||
{ role: 'stop', sequence: 1, name: 'Los Angeles', code: 'LAX', lat: 33.9, lng: -118.4, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
|
||||
{ role: 'to', sequence: 2, name: 'Seattle', code: 'SEA', lat: 47.4, lng: -122.3, timezone: 'America/Los_Angeles', local_time: null, local_date: null },
|
||||
],
|
||||
metadata: JSON.stringify({ legs: [
|
||||
{ from: 'AKL', to: 'LAX', dep_day_id: DAY, dep_time: '20:20', arr_day_id: DAY, arr_time: '12:25' },
|
||||
{ from: 'LAX', to: 'SEA', dep_day_id: DAY, dep_time: '17:00', arr_day_id: DAY, arr_time: '19:45' },
|
||||
] }),
|
||||
}
|
||||
const dayTransports = getTransportForDay({ reservations: [booking], dayId: DAY, dayAssignmentIds: [], days })
|
||||
const legs = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports, dayId: DAY }).map(i => i.data.__leg.from)
|
||||
expect(legs).toEqual(['AKL', 'LAX'])
|
||||
})
|
||||
|
||||
it('orders by the clock when a booking names no zone', () => {
|
||||
const noZone = flight(1, '20:20', '12:25', null, null)
|
||||
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [noZone, toSeattle], dayId: DAY }))).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('orders by the clock when the time carries no date', () => {
|
||||
const a = { ...toLA, reservation_time: '20:20' }
|
||||
const b = { ...toSeattle, reservation_time: '17:00' }
|
||||
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [a, b], dayId: DAY }))).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('reads the arrival zone on the day an overnight flight lands', () => {
|
||||
const NEXT = 6
|
||||
// Leaves Auckland the evening before and lands in Los Angeles at 12:25 local (20:25 UTC).
|
||||
// A flight out of New York at 14:00 local (19:00 UTC) left before it, though its clock
|
||||
// reads later, and 12:25 read in Auckland's zone would put the landing a day early.
|
||||
const overnight = {
|
||||
id: 1, type: 'flight', day_id: DAY, end_day_id: NEXT,
|
||||
reservation_time: '2026-11-19T20:20', reservation_end_time: '2026-11-21T12:25',
|
||||
metadata: JSON.stringify({ departure_timezone: 'Pacific/Auckland', arrival_timezone: 'America/Los_Angeles' }),
|
||||
}
|
||||
const hop = { id: 2, type: 'flight', day_id: NEXT, end_day_id: NEXT, reservation_time: '2026-11-21T14:00',
|
||||
metadata: JSON.stringify({ departure_timezone: 'America/New_York' }) }
|
||||
expect(ids(getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [overnight, hop], dayId: NEXT }))).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('seats a dragged flight where its zone puts it', () => {
|
||||
const order = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toLA, toSeattle], dayId: DAY })
|
||||
expect(ids(seatTimedDrop([order[1], order[0]], 0, DAY))).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('gives the zoned flights the key they are drawn by, so the drawn order reads as in order', () => {
|
||||
const items = getMergedItems({ dayAssignments: [], dayNotes: [], dayTransports: [toSeattle, toLA], dayId: DAY })
|
||||
const keyOf = chronoKeyOf(items, DAY, getDisplayTimeForDay)
|
||||
// Drawn Auckland first: it takes the 17:00 slot, the Seattle flight the 20:20 one.
|
||||
expect(items.map(keyOf)).toEqual([17 * 60, 20 * 60 + 20])
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user