From e4f5a8a07ed9ece064b612f4a0cb1878bcb18fe5 Mon Sep 17 00:00:00 2001 From: pikax Date: Mon, 13 Jan 2020 12:44:11 +0000 Subject: [PATCH 1/3] feat: add geolocation --- packages/web/src/web/geolocation.ts | 49 +++++++++++++++++++++++++++++ packages/web/src/web/index.ts | 9 +++--- 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/web/geolocation.ts diff --git a/packages/web/src/web/geolocation.ts b/packages/web/src/web/geolocation.ts new file mode 100644 index 000000000..d5c3f1348 --- /dev/null +++ b/packages/web/src/web/geolocation.ts @@ -0,0 +1,49 @@ +import { ref, watch, onMounted, onUnmounted } from "@vue/composition-api"; +export function useGeolocation(options?: PositionOptions) { + const supported = !!navigator.geolocation; + + const error = ref(null); + + const timestamp = ref(null); + const coords = ref(null); + const highAccuracy = ref(options && options.enableHighAccuracy); + + if (supported) { + const setPosition = (pos: Position) => { + timestamp.value = pos.timestamp; + coords.value = pos.coords; + error.value = null; + }; + const setError = (err: PositionError) => { + timestamp.value = Date.now(); + coords.value = null; + error.value = err; + }; + const clearWatch = () => + watchId && navigator.geolocation.clearWatch(watchId); + + let watchId = 0; + + onMounted(() => + watch(highAccuracy, acc => { + clearWatch(); + + watchId = navigator.geolocation.watchPosition( + setPosition, + setError, + options ? { ...options, enableHighAccuracy: acc } : undefined + ); + }) + ); + onUnmounted(clearWatch); + } + + return { + supported, + error, + + timestamp, + coords, + highAccuracy + }; +} diff --git a/packages/web/src/web/index.ts b/packages/web/src/web/index.ts index de4948785..8046d868c 100644 --- a/packages/web/src/web/index.ts +++ b/packages/web/src/web/index.ts @@ -1,4 +1,5 @@ -export * from './fetch'; -export * from './webSocket'; -export * from './intersectionObserver' -export * from './networkInformation' \ No newline at end of file +export * from "./fetch"; +export * from "./webSocket"; +export * from "./intersectionObserver"; +export * from "./networkInformation"; +export * from "./geolocation"; From 55fe6497405a2390911600671d3a7986fcc54181 Mon Sep 17 00:00:00 2001 From: pikax Date: Sat, 18 Jan 2020 11:30:37 +0000 Subject: [PATCH 2/3] allow to postpone the initial location request --- packages/web/src/web/geolocation.ts | 33 +++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/web/src/web/geolocation.ts b/packages/web/src/web/geolocation.ts index d5c3f1348..df6f3ee72 100644 --- a/packages/web/src/web/geolocation.ts +++ b/packages/web/src/web/geolocation.ts @@ -1,13 +1,29 @@ import { ref, watch, onMounted, onUnmounted } from "@vue/composition-api"; -export function useGeolocation(options?: PositionOptions) { +import { NO_OP } from "@vue-composable/core"; + + +export interface GeolocationOptions { + /** + * Executes request location immediately, default = true + */ + immediate?: boolean +} + +export function useGeolocation(options?: PositionOptions & GeolocationOptions) { const supported = !!navigator.geolocation; + // used to check if the execution is lazy + const lazy = ref(options ? options?.immediate === false : undefined); + const error = ref(null); const timestamp = ref(null); const coords = ref(null); const highAccuracy = ref(options && options.enableHighAccuracy); + // allow manual control on when the geolocation is requested + let enable = NO_OP; + if (supported) { const setPosition = (pos: Position) => { timestamp.value = pos.timestamp; @@ -20,19 +36,25 @@ export function useGeolocation(options?: PositionOptions) { error.value = err; }; const clearWatch = () => - watchId && navigator.geolocation.clearWatch(watchId); + lazy.value !== true && watchId && navigator.geolocation.clearWatch(watchId); + + if (lazy.value) { + enable = () => lazy.value = false; + } let watchId = 0; onMounted(() => - watch(highAccuracy, acc => { + watch(() => [highAccuracy, lazy], ([acc]) => { clearWatch(); watchId = navigator.geolocation.watchPosition( setPosition, setError, - options ? { ...options, enableHighAccuracy: acc } : undefined + options ? { ...options, enableHighAccuracy: acc.value } : undefined ); + }, { + lazy: lazy.value }) ); onUnmounted(clearWatch); @@ -40,6 +62,9 @@ export function useGeolocation(options?: PositionOptions) { return { supported, + + enable, + error, timestamp, From 6a634366eafaa543bdb32eb5e439a872cb2d4c85 Mon Sep 17 00:00:00 2001 From: pikax Date: Sat, 18 Jan 2020 14:44:51 +0000 Subject: [PATCH 3/3] implement test and documentation --- .../components/GeolocationExample.vue | 35 +++ docs/.vuepress/config.js | 3 +- docs/README.md | 1 + docs/composable/web/geolocation.md | 93 +++++++ packages/web/__tests__/utils.ts | 12 +- .../web/__tests__/web/geolocation.spec.ts | 263 ++++++++++++++++++ packages/web/src/web/geolocation.ts | 38 ++- 7 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 docs/.vuepress/components/GeolocationExample.vue create mode 100644 docs/composable/web/geolocation.md create mode 100644 packages/web/__tests__/web/geolocation.spec.ts diff --git a/docs/.vuepress/components/GeolocationExample.vue b/docs/.vuepress/components/GeolocationExample.vue new file mode 100644 index 000000000..059c04415 --- /dev/null +++ b/docs/.vuepress/components/GeolocationExample.vue @@ -0,0 +1,35 @@ + + + diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 68942d46e..53c3920f7 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -215,7 +215,8 @@ module.exports = { ["composable/web/networkInformation", "NetworkInformation"], ["composable/web/online", "Navigator.onLine"], ["composable/web/pageVisibility", "PageVisibilityAPI"], - ["composable/web/language", "Language"] + ["composable/web/language", "Language"], + ["composable/web/geolocation", "Geolocation API"] ] }, { diff --git a/docs/README.md b/docs/README.md index 537db85d8..142dd5fff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,6 +84,7 @@ Check out the [examples folder](examples) or start hacking on [codesandbox](http - [Online](composable/web/online) - reactive `navigator.onLine` wrapper - [PageVisibility](composable/web/pageVisibility) - reactive `Page Visibility API` - [Language](composable/web/language) - reactive `NavigatorLanguage` +- [Geolocation](composable/web/geolocation) - reactive `Geolocation API` ### External diff --git a/docs/composable/web/geolocation.md b/docs/composable/web/geolocation.md new file mode 100644 index 000000000..b5f732a10 --- /dev/null +++ b/docs/composable/web/geolocation.md @@ -0,0 +1,93 @@ +# Geolocation API + +> [Geolocation API](https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API). + +## Parameters + +```js +import { useGeolocation } from "vue-composable"; + +const geolocation = useGeolocation(options?); +``` + +| Parameters | Type | Required | Default | Description | +| ---------- | ------------------------------------------- | -------- | --------------------- | ----------------------------------------------------------------------------------- | +| options | `PositionOptions` & `{immediate?: boolean}` | | `{ immediate: true }` | Options to handle geoLocation, `immediate` will trigger `watchPosition` on mounting | + +## State + +The `useGeolocation` function exposes the following reactive state: + +```js +import { useGeolocation } from "vue-composable"; + +const { supported, coords, highAccuracy, error, timestamp } = useGeolocation(); +``` + +| State | Type | Description | +| ------------ | -------------------- | ------------------------------------------ | +| supported | `Boolean` | Checks if the browser supports Geolocation | +| coords | `Ref` | Position object | +| highAccuracy | `Ref` | enable or disable highAccuracy mode | +| error | `Ref` | last position error | +| timestamp | `Ref` | Timestamp of the last position or error | + +## Methods + +The `useGeolocation` function exposes the following methods: + +```js +import { useGeolocation } from "vue-composable"; + +const { refresh } = useGeolocation(); +``` + +| Signature | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `refresh` | Refreshes the location. If `immediate:false` it will add a watch on `watchPosition` and if called multiple times will call `getCurrentPosition` | + +## Example + + + + + +### Code + +```vue + + + +``` diff --git a/packages/web/__tests__/utils.ts b/packages/web/__tests__/utils.ts index 8b05942a6..7f3cf594e 100644 --- a/packages/web/__tests__/utils.ts +++ b/packages/web/__tests__/utils.ts @@ -3,10 +3,20 @@ export const Vue: any = require('vue/dist/vue.common'); Vue.config.productionTip = false; Vue.config.devtools = false; + +Vue.config.warnHandler = (err: any) => { + throw err; +} + +Vue.config.erroHandler = (err: any) => { + throw err; +} + + export function nextTick(callback: (this: T) => void, context?: T): void; export function nextTick(): Promise; export function nextTick(callback?: (this: T) => void, context?: T) { - if(!callback) { + if (!callback) { return Vue.nextTick(); } return Vue.nextTick(callback, context); diff --git a/packages/web/__tests__/web/geolocation.spec.ts b/packages/web/__tests__/web/geolocation.spec.ts new file mode 100644 index 000000000..bb01a10ce --- /dev/null +++ b/packages/web/__tests__/web/geolocation.spec.ts @@ -0,0 +1,263 @@ +import { useGeolocation } from '../../src'; +import { Vue, nextTick } from '../utils' + +describe('geolocation', () => { + const __geolocation = navigator.geolocation; + + const clearWatchFn = jest.fn(); + const getCurrentPositionFn = jest.fn(); + const watchPositionFn = jest.fn().mockImplementation(() => 1); + + const geolocation: Geolocation = { + clearWatch: clearWatchFn, + getCurrentPosition: getCurrentPositionFn, + watchPosition: watchPositionFn, + } + + beforeEach(() => { + (navigator as any).geolocation = geolocation; + clearWatchFn.mockClear(); + getCurrentPositionFn.mockClear(); + watchPositionFn.mockClear(); + }) + + + afterAll(() => { + (navigator as any).geolocation = __geolocation; + }) + + + + it('should not be supported', () => { + (navigator as any).geolocation = false; + const { supported } = useGeolocation(); + + expect(supported).toBe(false); + }) + + + it('should pass the arguments to the watch', () => { + let promise: Promise = Promise.resolve(); + const vm = new Vue({ + template: "
", + setup() { + const opts: PositionOptions = { + maximumAge: 10, + timeout: 10, + enableHighAccuracy: false + } + const geo = useGeolocation(opts); + + expect(geo.supported).toBe(true); + + promise = nextTick().then(async x => { + expect(clearWatchFn).not.toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining(opts)); + }) + + return { + geo + }; + } + }); + vm.$mount(); + return promise + }) + + it('refresh should call getCurrentPosition', () => { + const vm = new Vue({ + template: "
", + setup() { + const geo = useGeolocation(); + + expect(getCurrentPositionFn).not.toHaveBeenCalled(); + geo.refresh(); + expect(getCurrentPositionFn).toHaveBeenCalled(); + + return { + geo + }; + } + }); + vm.$mount(); + }) + + it('should be lazy `immediate` = false', () => { + let promise: Promise = Promise.resolve(); + const vm = new Vue({ + template: "
", + setup() { + const geo = useGeolocation({ immediate: false }); + + expect(getCurrentPositionFn).not.toHaveBeenCalled(); + geo.refresh(); + + promise = nextTick().then(async () => { + expect(clearWatchFn).not.toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + expect(getCurrentPositionFn).not.toHaveBeenCalled(); + + geo.refresh(); + expect(getCurrentPositionFn).toHaveBeenCalled(); + }) + + return { + geo + }; + } + }); + vm.$mount(); + return promise + }) + + it('should watchPosition', async () => { + let promise: Promise = Promise.resolve(); + const vm = new Vue({ + template: "
", + setup() { + const geo = useGeolocation(); + + promise = nextTick().then(async x => { + expect(clearWatchFn).not.toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + }) + + return { + geo + }; + } + }); + vm.$mount(); + await promise; + vm.$destroy(); + expect(clearWatchFn).toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + }) + + it('should update the watchPosition if highAccuracy changes', async () => { + let promise: Promise = Promise.resolve(); + const vm = new Vue({ + template: "
", + setup() { + const geo = useGeolocation(); + + promise = nextTick().then(async () => { + expect(clearWatchFn).not.toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + + geo.highAccuracy.value = true; + await nextTick() + + expect(clearWatchFn).toHaveBeenCalledTimes(1); + expect(watchPositionFn).toHaveBeenCalledTimes(2); + + + geo.highAccuracy.value = false; + await nextTick() + + expect(clearWatchFn).toHaveBeenCalledTimes(2); + expect(watchPositionFn).toHaveBeenCalledTimes(3); + }) + + return { + geo + }; + } + }); + vm.$mount(); + await promise; + vm.$destroy(); + expect(clearWatchFn).toHaveBeenCalled(); + }) + + + it('should set the correct values', async () => { + + let promise: Promise = Promise.resolve(); + const vm = new Vue({ + template: "
", + setup() { + const geo = useGeolocation(); + + promise = nextTick().then(async x => { + expect(clearWatchFn).not.toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + + const [setPosition, setError] = watchPositionFn.mock.calls[0]; + + expect(setPosition).toBeInstanceOf(Function); + expect(setError).toBeInstanceOf(Function); + + + expect(geo.coords.value).toBeNull(); + expect(geo.timestamp.value).toBeNull(); + expect(geo.highAccuracy.value).toBeNull(); + expect(geo.error.value).toBeNull(); + + const pos: Position = { + coords: { + accuracy: 10, + altitude: 20, + altitudeAccuracy: 30, + heading: 40, + latitude: 50, + longitude: 60, + speed: 70 + }, + timestamp: 11111111 + } + + setPosition(pos); + + await nextTick() + + expect(geo.coords.value).toBe(pos.coords); + expect(geo.timestamp.value).toBe(pos.timestamp); + expect(geo.error.value).toBeNull(); + + const error = { + err: 1 + } + setError(error) + await nextTick() + + expect(geo.coords.value).toBeNull(); + expect(geo.timestamp.value).not.toBe(pos.timestamp); + expect(geo.error.value).toBe(error); + + const pos2: Position = { + coords: { + accuracy: 1, + altitude: 2, + altitudeAccuracy: 3, + heading: 4, + latitude: 5, + longitude: 6, + speed: 7 + }, + timestamp: 22222 + } + + setPosition(pos2); + + await nextTick() + + expect(geo.coords.value).toBe(pos2.coords); + expect(geo.timestamp.value).toBe(pos2.timestamp); + expect(geo.error.value).toBeNull(); + }) + + return { + geo + }; + } + }); + vm.$mount(); + await promise; + vm.$destroy(); + expect(clearWatchFn).toHaveBeenCalled(); + expect(watchPositionFn).toHaveBeenCalledTimes(1); + }) + + +}) \ No newline at end of file diff --git a/packages/web/src/web/geolocation.ts b/packages/web/src/web/geolocation.ts index df6f3ee72..f09d8743b 100644 --- a/packages/web/src/web/geolocation.ts +++ b/packages/web/src/web/geolocation.ts @@ -1,28 +1,28 @@ import { ref, watch, onMounted, onUnmounted } from "@vue/composition-api"; -import { NO_OP } from "@vue-composable/core"; - +import { NO_OP, isBoolean } from "@vue-composable/core"; export interface GeolocationOptions { /** - * Executes request location immediately, default = true + * @description Executes request location immediately + * @default true */ - immediate?: boolean + immediate?: boolean; } export function useGeolocation(options?: PositionOptions & GeolocationOptions) { const supported = !!navigator.geolocation; // used to check if the execution is lazy - const lazy = ref(options ? options?.immediate === false : undefined); + const lazy = ref(options ? options.immediate === false : undefined); const error = ref(null); const timestamp = ref(null); const coords = ref(null); - const highAccuracy = ref(options && options.enableHighAccuracy); + const highAccuracy = ref(options && options.enableHighAccuracy || null); // allow manual control on when the geolocation is requested - let enable = NO_OP; + let refresh = NO_OP; if (supported) { const setPosition = (pos: Position) => { @@ -35,23 +35,35 @@ export function useGeolocation(options?: PositionOptions & GeolocationOptions) { coords.value = null; error.value = err; }; - const clearWatch = () => - lazy.value !== true && watchId && navigator.geolocation.clearWatch(watchId); + const clearWatch = () => lazy.value !== true && watchId && navigator.geolocation.clearWatch(watchId); + + let _currentPositionRefresh = () => navigator.geolocation.getCurrentPosition(setPosition, setError, options); if (lazy.value) { - enable = () => lazy.value = false; + refresh = () => { + if (lazy.value) { + lazy.value = false; + } else { + _currentPositionRefresh(); + } + } + } else { + // NOTE probably useless?? + refresh = _currentPositionRefresh; } let watchId = 0; onMounted(() => - watch(() => [highAccuracy, lazy], ([acc]) => { + watch([highAccuracy, lazy], (a) => { clearWatch(); + const enableHighAccuracy = isBoolean(a[0]) ? a[0] : options ? options.enableHighAccuracy : undefined; + watchId = navigator.geolocation.watchPosition( setPosition, setError, - options ? { ...options, enableHighAccuracy: acc.value } : undefined + options ? { ...options, enableHighAccuracy } : { enableHighAccuracy } ); }, { lazy: lazy.value @@ -63,7 +75,7 @@ export function useGeolocation(options?: PositionOptions & GeolocationOptions) { return { supported, - enable, + refresh, error,