Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hook usePermission #143

Merged
merged 1 commit into from
Jun 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ import { useMountEffect } from "@react-hookz/web/esnext";

- [**`useNetworkState`**](https://react-hookz.github.io/web/?path=/docs/navigator-usenetwork)
— Tracks the state of browser's network connection.
- [**`usePermission`**](https://react-hookz.github.io/web/?path=/docs/navigator-usepermission)
— Tracks a permission state.

- #### Miscellaneous

Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export {
IUseNetworkState,
INetworkInformation,
} from './useNetworkState/useNetworkState';
export {
usePermission,
IAnyPermissionDescriptor,
IUsePermissionState,
} from './usePermission/usePermission';

// Miscellaneous
export { useSyncedRef } from './useSyncedRef/useSyncedRef';
Expand All @@ -63,7 +68,6 @@ export {
IUseResizeObserverCallback,
} from './useResizeObserver/useResizeObserver';
export { useMeasure } from './useMeasure/useMeasure';

export { useMediaQuery } from './useMediaQuery/useMediaQuery';

// Dom
Expand Down
31 changes: 31 additions & 0 deletions src/usePermission/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from 'react';
import { usePermission } from '../..';

export const Example: React.FC = () => {
const status = usePermission({ name: 'notifications' });

return (
<div>
<div>
<em>
We do not use any notifications, notifications permission requested only for presentation
purposes.
</em>
</div>
<br />
<div>
Notifications status: <code>{status}</code>
</div>
<div>
{status === 'prompt' && (
<button
onClick={() => {
Notification.requestPermission();
}}>
Request notifications permission
</button>
)}
</div>
</div>
);
};
35 changes: 35 additions & 0 deletions src/usePermission/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="Navigator/usePermission" component={Example} />

# usePermission

Tracks a permission state.

- SSR-compatible
- Automatically updates on permission state change.

#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function usePermission(descriptor: IAnyPermissionDescriptor): IUsePermissionState;
```

#### Arguments

#### Return

0. **state** - Permission state, can be one of the following strings:
- **`not-requested`** - State not requested yet, yielded only on mount, before permission
state requested.
- **`requested`** - State requested, but request promise is pending yet.
- **`prompt`** - Permission not requested from user, and can be requested via target API.
- **`denied`** - User denied permission.
- **`granted`** - User granted permission.
98 changes: 98 additions & 0 deletions src/usePermission/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { renderHook, act } from '@testing-library/react-hooks/dom';
import { usePermission } from '../..';

describe('usePermission', () => {
let querySpy: jest.SpyInstance;
const initialPermissions = navigator.permissions;

beforeAll(() => {
jest.useFakeTimers();

querySpy = jest.fn(
() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({ state: 'prompt' } as PermissionStatus);
}, 1);
})
);

Object.defineProperty(navigator, 'permissions', { value: { query: querySpy } });
});

afterEach(() => {
jest.clearAllTimers();
querySpy.mockClear();
});

afterAll(() => {
jest.useRealTimers();
Object.defineProperty(navigator, 'permissions', { value: initialPermissions });
});

it('should be defined', () => {
expect(usePermission).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => usePermission({ name: 'geolocation' }));
expect(result.error).toBeUndefined();
});

it('should have `not-requested` state initially', () => {
const { result } = renderHook(() => usePermission({ name: 'geolocation' }));
expect(result.all[0]).toBe('not-requested');
});

it('should have `requested` state initially', () => {
const { result } = renderHook(() => usePermission({ name: 'geolocation' }));
expect(result.current).toBe('requested');
});

it('should request permission state from `navigator.permissions.query`', () => {
renderHook(() => usePermission({ name: 'geolocation' }));
expect(querySpy).toHaveBeenCalledWith({ name: 'geolocation' });
});

it('should have permission state on promise resolve', async () => {
const { result, waitForNextUpdate } = renderHook(() => usePermission({ name: 'geolocation' }));

act(() => {
jest.advanceTimersByTime(1);
});

await waitForNextUpdate();
expect(result.current).toBe('prompt');
});

it('should update hook state on permission state change', async () => {
querySpy.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
const status = {
state: 'prompt',
addEventListener: (_n: any, listener: any) => {
status.state = 'granted';
setTimeout(() => listener(), 1);
},
};

resolve(status);
}, 1);
})
);
const { result, waitForNextUpdate } = renderHook(() => usePermission({ name: 'geolocation' }));

act(() => {
jest.advanceTimersByTime(1);
});
await waitForNextUpdate();
expect(result.current).toBe('prompt');

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe('granted');
});
});
13 changes: 13 additions & 0 deletions src/usePermission/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { usePermission } from '../..';

describe('usePermission', () => {
it('should be defined', () => {
expect(usePermission).toBeDefined();
});

it('should render', () => {
const { result } = renderHook(() => usePermission({ name: 'geolocation' }));
expect(result.error).toBeUndefined();
});
});
48 changes: 48 additions & 0 deletions src/usePermission/usePermission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { MutableRefObject, useEffect } from 'react';
import { useSafeState } from '..';
import { off, on } from '../util/misc';

export type IAnyPermissionDescriptor =
| PermissionDescriptor
| DevicePermissionDescriptor
| MidiPermissionDescriptor
| PushPermissionDescriptor;

export type IUsePermissionState = PermissionState | 'not-requested' | 'requested';

/**
* Tracks a permission state.
*
* @param descriptor Permission request descriptor that passed to `navigator.permissions.query`
*/
export function usePermission(descriptor: IAnyPermissionDescriptor): IUsePermissionState {
const [state, setState] = useSafeState<IUsePermissionState>('not-requested');

useEffect(() => {
const unmount: MutableRefObject<(() => void) | null> = { current: null };

setState('requested');

navigator.permissions.query(descriptor).then((status) => {
const handleChange = () => {
setState(status.state);
};

setState(status.state);
on(status, 'change', handleChange, { passive: true });

unmount.current = () => {
off(status, 'change', handleChange);
};
});

return () => {
if (unmount.current) {
unmount.current();
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [descriptor.name]);

return state;
}