mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(tours): bike tours in the planner, CyclOSM, and the planned cards' last points (BIK-01 to BIK-06, RTE-01, IO-01, LIB-02)
The tour type is a dropdown; a bike tour picks its profile, routes for it, says when a point lies off every way, and shows what it runs on: surfaces, kinds of way and, for a mountain bike, the trail grades, each pointed at on the map. A type's own map (OpenHikingMap for a hike, CyclOSM for a bike tour) is offered while a tour of that type is open, with an admin switch each. From the planned cards: a hike avoids steep climbs, steps or ferries, nothing else; a saved tour can be shared where the platform takes GPX files; and the tours rail adds a tour from one menu: plan new, import GPX, or take one over from another trip.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { canShareFile, shareFile } from './fileShare';
|
||||
|
||||
const gpx = () => new File(['<gpx/>'], 'Kramer.gpx', { type: 'application/gpx+xml' });
|
||||
|
||||
function stubShare(share: unknown, canShare: unknown) {
|
||||
Object.defineProperty(navigator, 'share', { value: share, configurable: true });
|
||||
Object.defineProperty(navigator, 'canShare', { value: canShare, configurable: true });
|
||||
}
|
||||
|
||||
afterEach(() => stubShare(undefined, undefined));
|
||||
|
||||
describe('canShareFile (IO-01)', () => {
|
||||
it('asks the platform about this very kind of file', () => {
|
||||
const canShare = vi.fn((data: ShareData) => data.files?.[0]?.name.endsWith('.gpx') === true);
|
||||
stubShare(vi.fn(), canShare);
|
||||
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(true);
|
||||
expect(canShare.mock.calls[0][0].files?.[0]).toMatchObject({ name: 'tour.gpx', type: 'application/gpx+xml' });
|
||||
canShare.mockReturnValue(false);
|
||||
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('is no without the share API, without canShare, or when the question itself throws', () => {
|
||||
stubShare(undefined, () => true);
|
||||
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
|
||||
stubShare(vi.fn(), undefined);
|
||||
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
|
||||
stubShare(vi.fn(), () => {
|
||||
throw new TypeError('files not supported');
|
||||
});
|
||||
expect(canShareFile('tour.gpx', 'application/gpx+xml')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareFile (IO-01)', () => {
|
||||
it('opens the sheet with the file and its title', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
stubShare(share, () => true);
|
||||
const file = gpx();
|
||||
await expect(shareFile(file, 'Kramer')).resolves.toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ files: [file], title: 'Kramer' });
|
||||
});
|
||||
|
||||
it('tells a closed sheet, a refused tap and any other failure apart', async () => {
|
||||
const share = vi.fn();
|
||||
stubShare(share, () => true);
|
||||
share.mockRejectedValueOnce(new DOMException('closed', 'AbortError'));
|
||||
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('cancelled');
|
||||
share.mockRejectedValueOnce(new DOMException('no activation', 'NotAllowedError'));
|
||||
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('needs-gesture');
|
||||
share.mockRejectedValueOnce(new DOMException('bad data', 'DataError'));
|
||||
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('failed');
|
||||
share.mockRejectedValueOnce(new Error('odd'));
|
||||
await expect(shareFile(gpx(), 'Kramer')).resolves.toBe('failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Handing a file to the system's share sheet (the Web Share API), where the
|
||||
* platform takes files of that kind. Chrome takes only an allow-list of file
|
||||
* types, Safari and the phones most, so whether a share is offered is asked
|
||||
* per file name and type, never assumed from `navigator.share` alone.
|
||||
*/
|
||||
|
||||
/** Whether this browser would hand a file of this name and type to the share sheet. */
|
||||
export function canShareFile(name: string, type: string): boolean {
|
||||
if (typeof navigator === 'undefined' || typeof navigator.share !== 'function') return false;
|
||||
if (typeof navigator.canShare !== 'function') return false;
|
||||
try {
|
||||
return navigator.canShare({ files: [new File([''], name, { type })] });
|
||||
} catch {
|
||||
// A browser that cannot even build the question cannot share the file either.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a share ended: `cancelled` when the sheet was closed without a target,
|
||||
* `needs-gesture` when the browser refused because the tap that asked lies too
|
||||
* far back (Safari, after the file had to be fetched first), `failed` otherwise.
|
||||
*/
|
||||
export type FileShareOutcome = 'shared' | 'cancelled' | 'needs-gesture' | 'failed';
|
||||
|
||||
/** Opens the share sheet with the file; call it straight from the tap that asks for it. */
|
||||
export async function shareFile(file: File, title: string): Promise<FileShareOutcome> {
|
||||
try {
|
||||
await navigator.share({ files: [file], title });
|
||||
return 'shared';
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled';
|
||||
if (error instanceof DOMException && error.name === 'NotAllowedError') return 'needs-gesture';
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user