Skip to content

Commit

Permalink
feat(react): react SDK with context provider
Browse files Browse the repository at this point in the history
  • Loading branch information
charIeszhao committed Mar 8, 2022
1 parent ca4ad2c commit 1be502d
Show file tree
Hide file tree
Showing 9 changed files with 542 additions and 76 deletions.
9 changes: 7 additions & 2 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,26 @@
"report": "WITH_REPORT=true pnpm build"
},
"dependencies": {
"@logto/browser": "^0.1.0"
"@logto/browser": "^0.1.0",
"@silverhand/essentials": "^1.1.6"
},
"devDependencies": {
"@jest/types": "^27.5.1",
"@silverhand/eslint-config": "^0.9.1",
"@silverhand/eslint-config-react": "^0.9.1",
"@silverhand/ts-config": "^0.9.1",
"@silverhand/ts-config-react": "^0.9.1",
"@testing-library/react-hooks": "^7.0.2",
"@types/jest": "^27.4.1",
"@types/react": "^17.0.39",
"eslint": "^8.9.0",
"jest": "^27.5.1",
"lint-staged": "^12.3.4",
"prettier": "^2.5.1",
"react": "^17.0.2",
"stylelint": "^13.13.1",
"ts-jest": "^27.0.4",
"typescript": "^4.5.5"
"typescript": "^4.6.2"
},
"peerDependencies": {
"react": ">=16.8.0"
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import LogtoClient from '@logto/browser';
import { createContext } from 'react';

export type LogtoContextProps = {
logtoClient?: LogtoClient;
loadingCount: number;
setLoadingCount: React.Dispatch<React.SetStateAction<number>>;
};

export const LogtoContext = createContext<LogtoContextProps>({
logtoClient: undefined,
loadingCount: 0,
setLoadingCount: (): never => {
throw new Error('Must be used inside <LogtoProvider>');
},
});
76 changes: 76 additions & 0 deletions packages/react/src/hooks/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import LogtoClient from '@logto/browser';
import { renderHook, act } from '@testing-library/react-hooks';
import React, { ComponentType } from 'react';

import useLogto from '.';
import { LogtoProvider } from '../provider';

const isSignInRedirected = jest.fn(() => false);
const handleSignInCallback = jest.fn(async () => Promise.resolve());

jest.mock('@logto/browser', () => {
return jest.fn().mockImplementation(() => {
return {
isSignInRedirected,
handleSignInCallback,
signIn: jest.fn(async () => Promise.resolve()),
signOut: jest.fn(async () => Promise.resolve()),
};
});
});

const endpoint = 'https://logto.dev';
const clientId = 'foo';

const createHookWrapper =
() =>
({ children }: { children: ComponentType<unknown> }) =>
<LogtoProvider logtoConfig={{ endpoint, clientId }}>{children}</LogtoProvider>;

describe('useLogto', () => {
test('without provider should throw', () => {
const { result } = renderHook(() => useLogto());
expect(result.error).not.toBeUndefined();
});

test('useLogto should call LogtoClient constructor', () => {
renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});

expect(LogtoClient).toHaveBeenCalledWith({ endpoint, clientId });
});

test('useLogto should return LogtoClient property methods', async () => {
const { result, waitFor } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});

await waitFor(() => {
const { signIn, signOut, handleSignInCallback } = result.current;

expect(signIn).not.toBeUndefined();
expect(signOut).not.toBeUndefined();
expect(handleSignInCallback).not.toBeUndefined();
});
});

test('not in callback url should not call `handleSignInCallback`', async () => {
const { result } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});
await act(async () => result.current.signIn('redirectUri'));
expect(handleSignInCallback).not.toHaveBeenCalled();
});

test('in callback url should call `handleSignInCallback`', async () => {
isSignInRedirected.mockImplementation(() => true);
const { result, waitFor } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});
await act(async () => result.current.signIn('redirectUri'));
await waitFor(() => {
expect(handleSignInCallback).toHaveBeenCalledTimes(1);
});
});
});
125 changes: 125 additions & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { IdTokenClaims, UserInfoResponse } from '@logto/browser';
import { Nullable } from '@silverhand/essentials';
import { useCallback, useContext, useEffect, useState } from 'react';

import { LogtoContext } from '../context';

type Logto = {
isAuthenticated: boolean;
isLoading: boolean;
fetchUserInfo: () => Promise<UserInfoResponse>;
getAccessToken: (resource?: string) => Promise<Nullable<string>>;
getIdTokenClaims: () => IdTokenClaims;
handleSignInCallback: (callbackUri: string) => Promise<void>;
signIn: (redirectUri: string) => Promise<void>;
signOut: (postLogoutRedirectUri: string) => Promise<void>;
};

const notInProviderErrorMessage = 'useLogto hook must be used inside LogtoProvider context';

export default function useLogto(): Logto {
const { logtoClient, loadingCount, setLoadingCount } = useContext(LogtoContext);
const [isAuthenticated, setIsAuthenticated] = useState(logtoClient?.isAuthenticated ?? false);
const isLoading = loadingCount > 0;

const setLoadingState = useCallback(
(state: boolean) => {
if (state) {
setLoadingCount((count) => count + 1);
} else {
setLoadingCount((count) => Math.max(0, count - 1));
}
},
[setLoadingCount]
);

const signIn = useCallback(
async (redirectUri: string) => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}

setLoadingState(true);
await logtoClient.signIn(redirectUri);
setLoadingState(false);
},
[logtoClient, setLoadingState]
);

const handleSignInCallback = useCallback(
async (callbackUri: string) => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}
setLoadingState(true);
await logtoClient.handleSignInCallback(callbackUri);
setLoadingState(false);
setIsAuthenticated(true);
},
[logtoClient, setLoadingState]
);

const signOut = useCallback(
async (postLogoutRedirectUri: string) => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}
setLoadingState(true);
await logtoClient.signOut(postLogoutRedirectUri);
setLoadingState(false);
setIsAuthenticated(false);
},
[logtoClient, setLoadingState]
);

const fetchUserInfo = useCallback(async () => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}
setLoadingState(true);
const userInfo = await logtoClient.fetchUserInfo();
setLoadingState(false);

return userInfo;
}, [logtoClient, setLoadingState]);

const getAccessToken = useCallback(async () => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}
setLoadingState(true);
const accessToken = await logtoClient.getAccessToken();
setLoadingState(false);

return accessToken;
}, [logtoClient, setLoadingState]);

const getIdTokenClaims = useCallback(() => {
if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}

return logtoClient.getIdTokenClaims();
}, [logtoClient]);

useEffect(() => {
if (!isAuthenticated && logtoClient?.isSignInRedirected(window.location.href)) {
void handleSignInCallback(window.location.href);
}
}, [handleSignInCallback, isAuthenticated, logtoClient]);

if (!logtoClient) {
throw new Error(notInProviderErrorMessage);
}

return {
isAuthenticated,
isLoading,
signIn,
handleSignInCallback,
signOut,
fetchUserInfo,
getAccessToken,
getIdTokenClaims,
};
}
8 changes: 0 additions & 8 deletions packages/react/src/index.test.ts

This file was deleted.

6 changes: 4 additions & 2 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
/* istanbul ignore file */
export type TODO = 'TODO';
export type { LogtoContextProps } from './context';
export type { IdTokenClaims, UserInfoResponse } from '@logto/browser';
export * from './provider';
export { default as useLogto } from './hooks';
27 changes: 27 additions & 0 deletions packages/react/src/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import LogtoClient, { LogtoConfig } from '@logto/browser';
import React, { useMemo, useState } from 'react';

import { LogtoContext } from './context';

export type LogtoProviderProps = {
logtoConfig: LogtoConfig;
children?: React.ReactNode;
};

export const LogtoProvider = ({ logtoConfig, children }: LogtoProviderProps) => {
const [loadingCount, setLoadingCount] = useState(0);
const memorizedLogtoClient = useMemo(
() => ({ logtoClient: new LogtoClient(logtoConfig) }),
[logtoConfig]
);
const memorizedContextValue = useMemo(
() => ({
...memorizedLogtoClient,
loadingCount,
setLoadingCount,
}),
[memorizedLogtoClient, loadingCount]
);

return <LogtoContext.Provider value={memorizedContextValue}>{children}</LogtoContext.Provider>;
};
1 change: 1 addition & 0 deletions packages/react/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
],
"exclude": [
"**/*.test.ts",
"**/*.test.tsx",
]
}
Loading

0 comments on commit 1be502d

Please sign in to comment.