Skip to content

Commit

Permalink
feat: new hook usePermission
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Jun 21, 2021
1 parent 131d98e commit 513b404
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 1 deletion.
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;
}

0 comments on commit 513b404

Please sign in to comment.