Skip to content

Commit

Permalink
Allow to override geolocation providers via Fork API
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev committed Aug 1, 2024
1 parent 409488f commit a00daf0
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 82 deletions.
38 changes: 38 additions & 0 deletions apps/website/docs/web-api/geolocation.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,21 @@ import demoFile from './geolocation.live.vue?raw';

## Additional capabilities

While creating an integration, you can override the default geolocation provider with your custom one. It can be done by passing an array of providers to the `trackGeolocation` function.

```ts
import { trackGeolocation } from '@withease/web-api';

const geo = trackGeolocation({
/* ... */
// by default providers field contains trackGeolocation.browserProvider
// which represents the browser built-in Geolocation API
providers: [trackGeolocation.browserProvider],
});
```

The logic is quite straightforward: integration will call providers one by one until one of them returns the location. The first provider that returns the location will be used.

### Regional restrictions

In some countries and regions, the use of geolocation can be restricted. If you are aiming to provide a service in such locations, you use some local providers to get the location of the user. For example, in China, you can use [Baidu](https://lbsyun.baidu.com/index.php?title=jspopular/guide/geolocation), [Autonavi](https://lbsyun.baidu.com/index.php?title=jspopular/guide/geolocation), or [Tencent](https://lbs.qq.com/webApi/component/componentGuide/componentGeolocation).
Expand Down Expand Up @@ -227,3 +242,26 @@ const geo = trackGeolocation({
],
});
```

### Testing

You can pass a [_Store_](https://effector.dev/docs/api/effector/store) to `providers` option to get a way to mock the geolocation provider during testing via [Fork API](/magazine/fork_api_rules).

```ts
import { createStore, fork } from 'effector';
import { trackGeolocation } from '@withease/web-api';

// Create a store with the default provider
const $geolocationProviders = createStore([trackGeolocation.browserProvider]);

// Create an integration with the store
const geo = trackGeolocation({
/* ... */
providers: $geolocationProviders,
});

// during testing, you can replace the provider with your mock
const scope = fork({ values: [[$geolocationProviders, myFakeProvider]] });
```

That is it, any calculations on the created [_Scope_](https://effector.dev/docs/api/effector/scope) will use the `myFakeProvider` instead of the default one.
1 change: 1 addition & 0 deletions packages/web-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"scripts": {
"test:run": "vitest run --typecheck",
"test:watch": "vitest --typecheck",
"build": "vite build",
"size": "size-limit",
"publint": "node ../../tools/publint.mjs",
Expand Down
56 changes: 55 additions & 1 deletion packages/web-api/src/geolocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*/

import { allSettled, createStore, createWatch, fork } from 'effector';
import { trackGeolocation } from 'geolocation';
import { describe, expect, test, vi } from 'vitest';

import { trackGeolocation } from './geolocation';

describe('trackGeolocation', () => {
test('request', async () => {
let lat = 41.890221;
Expand Down Expand Up @@ -143,3 +144,56 @@ describe('trackGeolocation', () => {
expect(() => trackGeolocation()).not.toThrow();
});
});

describe('trackGeolocation, providers as a Store', () => {
const firstProvider = () => ({
name: 'firstProvider',
async getCurrentPosition() {
return {
coords: { latitude: 1, longitude: 1 },
timestamp: Date.now(),
};
},
watchPosition(success: any, error: any) {
return () => {};
},
});

const secondProvider = () => ({
name: 'secondProvider',
async getCurrentPosition() {
return {
coords: { latitude: 2, longitude: 2 },
timestamp: Date.now(),
};
},
watchPosition(success: any, error: any) {
return () => {};
},
});

const $providers = createStore([firstProvider]);

const geo = trackGeolocation({ providers: $providers });

test('request', async () => {
const scopeWithOriginal = fork();
const scopeWithReplace = fork({ values: [[$providers, [secondProvider]]] });

await allSettled(geo.request, { scope: scopeWithReplace });
expect(scopeWithReplace.getState(geo.$location)).toMatchInlineSnapshot(`
{
"latitude": 2,
"longitude": 2,
}
`);

await allSettled(geo.request, { scope: scopeWithOriginal });
expect(scopeWithOriginal.getState(geo.$location)).toMatchInlineSnapshot(`
{
"latitude": 1,
"longitude": 1,
}
`);
});
});
205 changes: 124 additions & 81 deletions packages/web-api/src/geolocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import {
type Event,
type EventCallable,
type Store,
type Effect,
combine,
createEvent,
createStore,
createEffect,
sample,
attach,
scopeBind,
is,
} from 'effector';

import { readonly } from './shared';
Expand Down Expand Up @@ -77,37 +78,41 @@ const BrowserProvider = Symbol('BrowserProvider');

export function trackGeolocation(
params?: GeolocationParams & {
providers?: Array<
typeof BrowserProvider | CustomProvider | globalThis.Geolocation
>;
providers?:
| Array<typeof BrowserProvider | CustomProvider | globalThis.Geolocation>
| Store<
Array<
typeof BrowserProvider | CustomProvider | globalThis.Geolocation
>
>;
}
): Geolocation {
const providres = (
params?.providers ?? /* In case of no providers, we will use the default one only */ [
BrowserProvider,
]
)
.map((provider) => {
/* BrowserProvider symbol means usage of navigator.geolocation */
if (provider === BrowserProvider) {
const browserGeolocationAvailable =
globalThis.navigator && 'geolocation' in globalThis.navigator;
if (!browserGeolocationAvailable) {
return null;
}

return globalThis.navigator.geolocation;
}
let $providers: Store<
Array<typeof BrowserProvider | CustomProvider | globalThis.Geolocation>
>;
if (is.store(params?.providers)) {
$providers = params.providers;
} else {
$providers = createStore(params?.providers ?? [BrowserProvider]);
}

if (isDefaultProvider(provider)) {
return provider;
}
const initializeAllProvidersFx = attach({
source: $providers,
effect(providers) {
return providers
.map((provider) => initializeProvider(provider, params))
.filter(Boolean) as Array<
ReturnType<CustomProvider> | globalThis.Geolocation
>;
},
});

return provider(params ?? {});
})
.filter(Boolean) as Array<
const $initializedProviders = createStore<Array<
ReturnType<CustomProvider> | globalThis.Geolocation
>;
> | null>(null, { serialize: 'ignore' }).on(
initializeAllProvidersFx.doneData,
(_, providers) => providers
);

// -- units

Expand Down Expand Up @@ -138,6 +143,13 @@ export function trackGeolocation(

// -- shared logic

sample({
clock: [request, startWatching],
source: $initializedProviders,
filter: (providers) => !providers,
target: initializeAllProvidersFx,
});

const newPosition = createEvent<
CustomGeolocationPosition | globalThis.GeolocationPosition
>();
Expand All @@ -150,35 +162,38 @@ export function trackGeolocation(

// -- get current position

const getCurrentPositionFx = createEffect<
const getCurrentPositionFx: Effect<
void,
CustomGeolocationPosition | globalThis.GeolocationPosition,
CustomGeolocationError | globalThis.GeolocationPositionError
>(async () => {
let geolocation:
| globalThis.GeolocationPosition
| CustomGeolocationPosition
| null = null;

for (const provider of providres) {
if (isDefaultProvider(provider)) {
geolocation = await new Promise<GeolocationPosition>(
(resolve, rejest) =>
provider.getCurrentPosition(resolve, rejest, params)
);
} else {
geolocation = await provider.getCurrentPosition();
> = attach({
source: $initializedProviders,
async effect(providers) {
let geolocation:
| globalThis.GeolocationPosition
| CustomGeolocationPosition
| null = null;

for (const provider of providers ?? []) {
if (isDefaultProvider(provider)) {
geolocation = await new Promise<GeolocationPosition>(
(resolve, reject) =>
provider.getCurrentPosition(resolve, reject, params)
);
} else {
geolocation = await provider.getCurrentPosition();
}
}
}

if (!geolocation) {
throw {
code: 'POSITION_UNAVAILABLE',
message: 'No avaiable geolocation provider',
};
}
if (!geolocation) {
throw {
code: 'POSITION_UNAVAILABLE',
message: 'No available geolocation provider',
};
}

return geolocation;
return geolocation;
},
});

sample({ clock: request, target: getCurrentPositionFx });
Expand All @@ -192,40 +207,46 @@ export function trackGeolocation(

const $unsubscribe = createStore<Unsubscribe | null>(null);

const watchPositionFx = createEffect(() => {
const boundNewPosition = scopeBind(newPosition, { safe: true });
const boundFailed = scopeBind(failed, { safe: true });

const defaultUnwatchMap = new Map<(id: number) => void, number>();
const customUnwatchSet = new Set<Unsubscribe>();

for (const provider of providres) {
if (isDefaultProvider(provider)) {
const watchId = provider.watchPosition(
boundNewPosition,
boundFailed,
params
);

defaultUnwatchMap.set((id: number) => provider.clearWatch(id), watchId);
} else {
const unwatch = provider.watchPosition(boundNewPosition, boundFailed);

customUnwatchSet.add(unwatch);
const watchPositionFx = attach({
source: $initializedProviders,
effect(providers) {
const boundNewPosition = scopeBind(newPosition, { safe: true });
const boundFailed = scopeBind(failed, { safe: true });

const defaultUnwatchMap = new Map<(id: number) => void, number>();
const customUnwatchSet = new Set<Unsubscribe>();

for (const provider of providers ?? []) {
if (isDefaultProvider(provider)) {
const watchId = provider.watchPosition(
boundNewPosition,
boundFailed,
params
);

defaultUnwatchMap.set(
(id: number) => provider.clearWatch(id),
watchId
);
} else {
const unwatch = provider.watchPosition(boundNewPosition, boundFailed);

customUnwatchSet.add(unwatch);
}
}
}

return () => {
for (const [unwatch, id] of defaultUnwatchMap) {
unwatch(id);
defaultUnwatchMap.delete(unwatch);
}
return () => {
for (const [unwatch, id] of defaultUnwatchMap) {
unwatch(id);
defaultUnwatchMap.delete(unwatch);
}

for (const unwatch of customUnwatchSet) {
unwatch();
customUnwatchSet.delete(unwatch);
}
};
for (const unwatch of customUnwatchSet) {
unwatch();
customUnwatchSet.delete(unwatch);
}
};
},
});

const unwatchPositionFx = attach({
Expand Down Expand Up @@ -262,6 +283,28 @@ export function trackGeolocation(

trackGeolocation.browserProvider = BrowserProvider;

function initializeProvider(
provider: typeof BrowserProvider | CustomProvider | globalThis.Geolocation,
params?: GeolocationParams
) {
/* BrowserProvider symbol means usage of navigator.geolocation */
if (provider === BrowserProvider) {
const browserGeolocationAvailable =
globalThis.navigator && 'geolocation' in globalThis.navigator;
if (!browserGeolocationAvailable) {
return null;
}

return globalThis.navigator.geolocation;
}

if (isDefaultProvider(provider)) {
return provider;
}

return provider(params ?? {});
}

function isDefaultProvider(provider: any): provider is globalThis.Geolocation {
return (
'getCurrentPosition' in provider &&
Expand Down

0 comments on commit a00daf0

Please sign in to comment.