diff --git a/packages/react-meteor-accounts/README.md b/packages/react-meteor-accounts/README.md index ff6eab3f..d0c04f0f 100644 --- a/packages/react-meteor-accounts/README.md +++ b/packages/react-meteor-accounts/README.md @@ -8,8 +8,14 @@ Simple hooks and higher-order components (HOCs) for getting reactive, stateful v - [Peer npm dependencies](#peer-npm-dependencies) - [Changelog](#changelog) - [Usage](#usage) - - [`useUser` / `withUser`](#useuser--withUser) - - [`useUserId` / `withUserId`](#useuserid--withUserId) + - [`useUser`](#useuser) + - [`useUserId`](#useuserid) + - [`useLoggingIn`](#useloggingin) + - [`useLoggingOut`](#useloggingout) + - [`withUser`](#withuser) + - [`withUserId`](#withuserid) + - [`withLoggingIn`](#withloggingin) + - [`withLoggingOut`](#withloggingout) ## Installation @@ -39,32 +45,19 @@ Utilities for each data source are available for the two ways of writing React c _Note:_ All HOCs forward refs. -### useUser() / withUser(...) +### useUser() -Get a stateful value of the current user record from [`Meteor.user`](https://docs.meteor.com/api/accounts.html#Meteor-user), a reactive data source. - -The hook, `useUser()`, returns a stateful value of the current user record. +Get a stateful value of the current user record. A hook. Uses [`Meteor.user`](https://docs.meteor.com/api/accounts.html#Meteor-user), a reactive data source. - Arguments: *none*. - Returns: `object | null`. -The HOC, `withUser(Component)`, returns a wrapped version of `Component`, where `Component` receives a prop of the current user record, `user`. - -- Arguments: - -| Argument | Type | Required | Description | -| --- | --- | --- | --- | -| Component | `any` | yes | A React component. | - -- Returns: `React.ForwardRefExoticComponent`. - -Examples: +Example: ```tsx import React from 'react'; -import { useUser, withUser } from 'meteor/react-meteor-accounts'; +import { useUser } from 'meteor/react-meteor-accounts'; -// Hook function Foo() { const user = useUser(); @@ -74,47 +67,122 @@ function Foo() { return

Hello {user.username}

; } +``` -// HOC -class Bar extends React.Component { - render() { - if (this.props.user === null) { - return

Log in

; - } +TypeScript signature: - return

Hello {this.props.user.username}

; - } -} +```ts +function useUser(): Meteor.User | null; +``` + +### useUserId() + +Get a stateful value of the current user id. A hook. Uses [`Meteor.userId`](https://docs.meteor.com/api/accounts.html#Meteor-userId), a reactive data source. -const WrappedBar = withUser(Bar); +- Arguments: *none*. +- Returns: `string | null`. + +Example: + +```tsx +import React from 'react'; +import { useUserId } from 'meteor/react-meteor-accounts'; + +function Foo() { + const userId = useUserId(); + + return ( +
+

Account Details

+ {userId ? ( +

Your unique account id is {userId}.

+ ) : ( +

Log-in to view your account details.

+ )} +
+ ); +} ``` -TypeScript signatures: +TypeScript signature: ```ts -// Hook -const useUser: () => Meteor.User; +function useUserId(): string | null; +``` + +### useLoggingIn() + +Get a stateful value of whether a login method (e.g. `loginWith`) is currently in progress. A hook. Uses [`Meteor.loggingIn`](https://docs.meteor.com/api/accounts.html#Meteor-loggingIn), a reactive data source. + +- Arguments: *none*. +- Returns: `boolean`. + +Example: + +```tsx +import React from 'react'; +import { useLoggingIn } from 'meteor/react-meteor-accounts'; + +function Foo() { + const loggingIn = useLoggingIn(); -// HOC -const withUser: (Component: any) => React.ForwardRefExoticComponent>; + if (!loggingIn) { + return null; + } + + return ( +
Logging in, please wait a moment.
+ ); +} ``` -### useUserId() / withUserId(...) +TypeScript signature: -Get a stateful value of the current user id from [`Meteor.userId`](https://docs.meteor.com/api/accounts.html#Meteor-userId), a reactive data source. +```ts +function useLoggingIn(): boolean; +``` + +### useLoggingOut() -The hook, `useUserId()`, returns a stateful value of the current user id. +Get a stateful value of whether the logout method is currently in progress. A hook. Uses `Meteor.loggingOut` (no online documentation), a reactive data source. - Arguments: *none*. -- Returns: `string`. +- Returns: `boolean`. + +Example: + +```tsx +import React from 'react'; +import { useLoggingOut } from 'meteor/react-meteor-accounts'; + +function Foo() { + const loggingOut = useLoggingOut(); + + if (!loggingOut) { + return null; + } + + return ( +
Logging out, please wait a moment.
+ ); +} +``` -The HOC, `withUserId(Component)`, returns a wrapped version of `Component`, where `Component` receives a prop of the current user id, `userId`. +TypeScript signature: + +```ts +function useLoggingOut(): boolean; +``` + +### withUser(...) + +Return a wrapped version of the given component, where the component receives a stateful prop of the current user record, `user`. A higher-order component. Uses [`Meteor.user`](https://docs.meteor.com/api/accounts.html#Meteor-user), a reactive data source. - Arguments: | Argument | Type | Required | Description | | --- | --- | --- | --- | -| Component | `any` | yes | A React component. | +| Component | `React.ComponentType` | yes | A React component. | - Returns: `React.ForwardRefExoticComponent`. @@ -122,26 +190,46 @@ Examples: ```tsx import React from 'react'; -import { useUserId, withUserId } from 'meteor/react-meteor-accounts'; +import { withUser } from 'meteor/react-meteor-accounts'; -// Hook -function Foo() { - const userId = useUserId(); +class Foo extends React.Component { + render() { + if (this.props.user === null) { + return

Log in

; + } - return ( -
-

Account Details

- {userId ? ( -

Your unique account id is {userId}.

- ) : ( -

Log-in to view your account details.

- )} -
- ); + return

Hello {this.props.user.username}

; + } } -// HOC -class Bar extends React.Component { +const FooWithUser = withUser(Foo); +``` + +TypeScript signature: + +```ts +function withUser

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +``` + +### withUserId(...) + +Return a wrapped version of the given component, where the component receives a stateful prop of the current user id. A higher-order component. Uses [`Meteor.userId`](https://docs.meteor.com/api/accounts.html#Meteor-userId), a reactive data source. + +- Arguments: + +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| Component | `React.ComponentType` | yes | A React component. | + +- Returns: `React.ForwardRefExoticComponent`. + +Example: + +```tsx +import React from 'react'; +import { withUserId } from 'meteor/react-meteor-accounts'; + +class Foo extends React.Component { render() { return (

@@ -156,15 +244,89 @@ class Bar extends React.Component { } } -const WrappedBar = withUserId(Bar); +const FooWithUserId = withUserId(Foo); +``` + +TypeScript signature: + +```ts +function withUserId

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +``` + +### withLoggingIn(...) + +Return a wrapped version of the given component, where the component receives a stateful prop of whether a login method (e.g. `loginWith`) is currently in progress. A higher-order component. Uses [`Meteor.loggingIn`](https://docs.meteor.com/api/accounts.html#Meteor-loggingIn), a reactive data source. + +- Arguments: + +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| Component | `React.ComponentType` | yes | A React component. | + +- Returns: `React.ForwardRefExoticComponent`. + +Example: + +```tsx +import React from 'react'; +import { withLoggingIn } from 'meteor/react-meteor-accounts'; + +class Foo extends React.Component { + render() { + if (!this.props.loggingIn) { + return null; + } + + return ( +

Logging in, please wait a moment.
+ ); + } +} + +const FooWithLoggingIn = withLoggingIn(Foo); ``` TypeScript signatures: ```ts -// Hook -const useUserId: () => string; +function withLoggingIn

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +``` + +### withLoggingOut(...) + +Return a wrapped version of the given component, where the component receives a stateful prop of whether the logout method is currently in progress. A higher-order component. Uses `Meteor.loggingOut` (no online documentation), a reactive data source. + +- Arguments: -// HOC -const withUserId: (Component: any) => React.ForwardRefExoticComponent>; +| Argument | Type | Required | Description | +| --- | --- | --- | --- | +| Component | `React.ComponentType` | yes | A React component. | + +- Returns: `React.ForwardRefExoticComponent`. + +Example: + +```tsx +import React from 'react'; +import { withLoggingOut } from 'meteor/react-meteor-accounts'; + +class Foo extends React.Component { + render() { + if (!this.props.loggingOut) { + return null; + } + + return ( +

Logging out, please wait a moment.
+ ); + } +} + +const FooWithLoggingOut = withLoggingOut(Foo); +``` + +TypeScript signature: + +```ts +function withLoggingOut

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; ``` diff --git a/packages/react-meteor-accounts/index.tests.ts b/packages/react-meteor-accounts/index.tests.ts new file mode 100644 index 00000000..628be844 --- /dev/null +++ b/packages/react-meteor-accounts/index.tests.ts @@ -0,0 +1 @@ +import './react-accounts.tests.tsx'; diff --git a/packages/react-meteor-accounts/index.ts b/packages/react-meteor-accounts/index.ts new file mode 100644 index 00000000..a7312bdf --- /dev/null +++ b/packages/react-meteor-accounts/index.ts @@ -0,0 +1,21 @@ +import { Meteor } from 'meteor/meteor'; +import React from 'react'; + +if (Meteor.isDevelopment) { + // Custom check instead of `checkNpmVersions` to reduce prod bundle size (~8kb). + const v = React.version.split('.').map(val => parseInt(val)); + if (v[0] < 16 || (v[0] === 16 && v[1] < 8)) { + console.warn('react-accounts requires React version >= 16.8.'); + } +} + +export { + useUser, + useUserId, + useLoggingIn, + useLoggingOut, + withUser, + withUserId, + withLoggingIn, + withLoggingOut +} from './react-accounts'; diff --git a/packages/react-meteor-accounts/package-lock.json b/packages/react-meteor-accounts/package-lock.json index b4ae92e5..da1779fa 100644 --- a/packages/react-meteor-accounts/package-lock.json +++ b/packages/react-meteor-accounts/package-lock.json @@ -100,6 +100,36 @@ "@testing-library/dom": "^7.22.3" } }, + "@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "react-error-boundary": "^3.1.0" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.16.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.5.tgz", + "integrity": "sha512-TXWihFIS3Pyv5hzR7j6ihmeLkZfrXGxAr5UfSl8CHf+6q/wpiYDkUau0czckpYG8QmnCIuPpdLtuA9VmuGGyMA==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, + "@tsconfig/recommended": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@tsconfig/recommended/-/recommended-1.0.1.tgz", + "integrity": "sha512-2xN+iGTbPBEzGSnVp/Hd64vKJCJWxsi9gfs88x4PPMyEjHJoA3o5BY9r5OLPHIZU2pAQxkSAsJFqn6itClP8mQ==", + "dev": true + }, "@types/aria-query": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.0.tgz", @@ -192,6 +222,24 @@ "csstype": "^3.0.2" } }, + "@types/react-dom": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", + "integrity": "sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/underscore": { "version": "1.10.24", "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.10.24.tgz", @@ -433,6 +481,26 @@ "scheduler": "^0.19.1" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.16.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.5.tgz", + "integrity": "sha512-TXWihFIS3Pyv5hzR7j6ihmeLkZfrXGxAr5UfSl8CHf+6q/wpiYDkUau0czckpYG8QmnCIuPpdLtuA9VmuGGyMA==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/packages/react-meteor-accounts/package.js b/packages/react-meteor-accounts/package.js index 3fa1fb84..8facc24f 100644 --- a/packages/react-meteor-accounts/package.js +++ b/packages/react-meteor-accounts/package.js @@ -2,7 +2,7 @@ Package.describe({ name: 'react-accounts', - summary: 'React hook for reactively tracking Meteor data', + summary: 'React hook for reactively tracking Meteor Accounts data', version: '1.0.0-beta.1', documentation: 'README.md', git: 'https://github.com/meteor/react-packages', @@ -10,8 +10,20 @@ Package.describe({ Package.onUse((api) => { api.versionsFrom(['1.10', '2.3']); - api.use('tracker'); - api.use('typescript'); - api.mainModule('react-accounts.tsx', ['client', 'server'], { lazy: true }); + api.use(['accounts-base', 'tracker', 'typescript']); + + api.mainModule('index.ts', ['client', 'server'], { lazy: true }); +}); + +Package.onTest((api) => { + api.use([ + 'accounts-base', + 'accounts-password', + 'tinytest', + 'tracker', + 'typescript', + ]); + + api.mainModule('index.tests.ts'); }); diff --git a/packages/react-meteor-accounts/package.json b/packages/react-meteor-accounts/package.json index 03667b32..4f38b650 100644 --- a/packages/react-meteor-accounts/package.json +++ b/packages/react-meteor-accounts/package.json @@ -1,10 +1,12 @@ { "name": "meteor-react-accounts", "scripts": { - "make-types": "npx typescript *.tsx --jsx preserve --declaration --emitDeclarationOnly --outDir types" + "make-types": "npx typescript react-accounts.tsx --jsx preserve --declaration --emitDeclarationOnly --esModuleInterop --outDir types --strict" }, "devDependencies": { "@testing-library/react": "^10.0.2", + "@testing-library/react-hooks": "^7.0.2", + "@tsconfig/recommended": "^1.0.1", "@types/meteor": "^1.4.42", "@types/react": "^16.9.34", "react": "16.13.1", diff --git a/packages/react-meteor-accounts/react-accounts.tests.tsx b/packages/react-meteor-accounts/react-accounts.tests.tsx new file mode 100644 index 00000000..834c8798 --- /dev/null +++ b/packages/react-meteor-accounts/react-accounts.tests.tsx @@ -0,0 +1,332 @@ +import { renderHook } from '@testing-library/react-hooks/dom'; +import { cleanup, render, waitFor } from "@testing-library/react"; +import { Accounts } from "meteor/accounts-base"; +import { Meteor } from "meteor/meteor"; +import { Tinytest } from "meteor/tinytest"; +import React from "react"; +import { + useLoggingIn, + useLoggingOut, + useUser, + useUserId, + withLoggingIn, + WithLoggingInProps, + withLoggingOut, + WithLoggingOutProps, + withUser, + withUserId, + WithUserIdProps, + WithUserProps, +} from "./react-accounts"; + +// Prepare method for clearing DB (doesn't need to be isomorphic). +if (Meteor.isServer) { + Meteor.methods({ + reset() { + Meteor.users.remove({}); + }, + }); +} + +if (Meteor.isClient) { + // fixture data + const username = "username"; + const password = "password"; + + // common test actions + async function login() { + await new Promise((resolve, reject) => { + Meteor.loginWithPassword(username, password, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + async function logout() { + await new Promise((resolve, reject) => { + Meteor.logout((error) => { + if (error) reject(error); + else resolve(); + }); + }); + } + + // common test arrangements + async function beforeEach() { + // reset DB; must complete before creation to avoid potential overlap + await new Promise((resolve, reject) => { + Meteor.call("reset", (error, result) => { + if (error) reject(error); + else resolve(result); + }); + }); + // prepare sample user + await new Promise((resolve, reject) => { + Accounts.createUser({ username, password }, (error) => { + if (error) reject(error); + else resolve(); + }); + }); + // logout since `createUser` auto-logs-in + await logout(); + } + + // NOTE: each test body has three blocks: Arrange, Act, Assert. + + Tinytest.addAsync( + "Hooks - useUserId - has initial value of `null`", + async function (test, onComplete) { + await beforeEach(); + + const { result } = renderHook(() => useUserId()); + + test.isNull(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useUserId - is reactive to login", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useUserId()); + // use `waitFor*` instead of `await`; mimics consumer usage + login(); + await waitForNextUpdate(); + + test.isNotNull(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useUserId - is reactive to logout", + async function (test, onComplete) { + await beforeEach(); + await login(); + + const { result, waitForNextUpdate } = renderHook(() => useUserId()); + // use `waitFor*` instead of `await`; mimics consumer usage + logout(); + await waitForNextUpdate(); + + test.isNull(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingIn - has initial value of `false`", + async function (test, onComplete) { + await beforeEach(); + + const { result } = renderHook(() => useLoggingIn()); + + test.isFalse(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingIn - is reactive to login starting", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useLoggingIn()); + login(); + // first update will be while login strategy is in progress + await waitForNextUpdate(); + + test.isTrue(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingIn - is reactive to login finishing", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useLoggingIn()); + login(); + await waitForNextUpdate(); + // second update will be after login strategy finishes + await waitForNextUpdate(); + + test.isFalse(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingOut - has initial value of `false`", + async function (test, onComplete) { + await beforeEach(); + + const { result } = renderHook(() => useLoggingOut()); + + test.isFalse(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingOut - is reactive to logout starting", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useLoggingOut()); + logout(); + // first update will be while logout is in progress + await waitForNextUpdate(); + + test.isTrue(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useLoggingOut - is reactive to logout finishing", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useLoggingOut()); + logout(); + await waitForNextUpdate(); + // second update will be after logout finishes + await waitForNextUpdate(); + + test.isFalse(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useUser - has initial value of `null`", + async function (test, onComplete) { + await beforeEach(); + + const { result } = renderHook(() => useUser()); + + test.isNull(result.current); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useUser - is reactive to login", + async function (test, onComplete) { + await beforeEach(); + + const { result, waitForNextUpdate } = renderHook(() => useUser()); + // use `waitFor*` instead of `await`; mimics consumer usage + login(); + await waitForNextUpdate(); + + test.isNotNull(result.current); + test.equal( + result.current.username, + username, + "Expected username to match" + ); + onComplete(); + } + ); + + Tinytest.addAsync( + "Hooks - useUser - is reactive to logout", + async function (test, onComplete) { + await beforeEach(); + await login(); + + const { result, waitForNextUpdate } = renderHook(() => useUser()); + // use `waitFor*` instead of `await`; mimics consumer usage + logout(); + await waitForNextUpdate(); + + test.isNull(result.current); + onComplete(); + } + ); + + // Since the HOCs wrap with hooks, the logic is already tested in 'Hooks' tests, and we only really need to test for prop forwarding. However, doing so for the "non-initial" case of all these values seems more prudent than just checking the default of `null` or `false`. + + // :NOTE: these tests can be flaky (like 1 in 5 runs). + + Tinytest.addAsync( + "HOCs - withUserId - forwards reactive value", + async function (test, onComplete) { + await beforeEach(); + function Foo({ userId }: WithUserIdProps) { + // need something we can easily find; we don't know the id + return {Boolean(userId).toString()}; + } + const FooWithUserId = withUserId(Foo); + const { findByText } = render(); + + login(); + + await waitFor(() => findByText("true")); + cleanup(); + onComplete(); + } + ); + + // :TODO: this is flaky, fails ~1 in 10 + Tinytest.addAsync( + "HOCs - withUser - forwards reactive value", + async function (test, onComplete) { + await beforeEach(); + function Foo({ user }: WithUserProps) { + return {user?.username || String(user)}; + } + const FooWithUser = withUser(Foo); + const { findByText } = render(); + + login(); + + await waitFor(() => findByText(username)); + cleanup(); + onComplete(); + } + ); + + Tinytest.addAsync( + "HOCs - withLoggingIn - forwards reactive value", + async function (test, onComplete) { + await beforeEach(); + function Foo({ loggingIn }: WithLoggingInProps) { + return {loggingIn.toString()}; + } + const FooWithLoggingIn = withLoggingIn(Foo); + const { findByText } = render(); + + login(); + + await waitFor(() => findByText("true")); + cleanup(); + onComplete(); + } + ); + + // :TODO: this is flaky, fails ~1 in 5 + Tinytest.addAsync( + "HOCs - withLoggingOut - forwards reactive value", + async function (test, onComplete) { + await beforeEach(); + function Foo({ loggingOut }: WithLoggingOutProps) { + return {loggingOut.toString()}; + } + const FooWithLoggingOut = withLoggingOut(Foo); + const { findByText } = render(); + await login(); + + logout(); + + await waitFor(() => findByText("true")); + cleanup(); + onComplete(); + } + ); +} diff --git a/packages/react-meteor-accounts/react-accounts.tsx b/packages/react-meteor-accounts/react-accounts.tsx index 95a92334..13516461 100644 --- a/packages/react-meteor-accounts/react-accounts.tsx +++ b/packages/react-meteor-accounts/react-accounts.tsx @@ -1,8 +1,12 @@ import { Meteor } from 'meteor/meteor' import { Tracker } from 'meteor/tracker' -import { useState, useEffect, forwardRef } from 'react' +import React, { useState, useEffect, forwardRef } from 'react' -export const useUserId = () => { +/** + * Hook to get a stateful value of the current user id. Uses `Meteor.userId`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-userId + */ +export function useUserId() { const [userId, setUserId] = useState(Meteor.userId()) useEffect(() => { const computation = Tracker.autorun(() => { @@ -15,29 +19,142 @@ export const useUserId = () => { return userId } -export const withUserId = (Component) => ( - forwardRef((props, ref) => { - const userId = useUserId(); - return - }) -) +export interface WithUserIdProps { + userId: string | null; +} + +/** + * HOC to forward a stateful value of the current user id. Uses `Meteor.userId`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-userId + */ +export function withUserId

(Component: React.ComponentType

) { + return forwardRef( + // Use `Omit` so instantiation doesn't require the prop. Union with `Partial` because prop should be optionally overridable / the wrapped component will be prepared for it anyways. + (props: Omit & Partial, ref) => { + const userId = useUserId(); + return ( + + ); + } + ); +} -export const useUser = () => { - const [user, setUser] = useState(Meteor.user()) +/** + * Hook to get a stateful value of the current user record. Uses `Meteor.user`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-user + */ +export function useUser() { + const [user, setUser] = useState(Meteor.user()); useEffect(() => { const computation = Tracker.autorun(() => { - setUser(Meteor.user()) - }) + let user = Meteor.user(); + // `Meteor.user` returns `undefined` after logout, but that ruins type signature and test parity. So, cast until that's fixed. + if (user === undefined) { + user = null; + } + setUser(user); + }); return () => { - computation.stop() + computation.stop(); + }; + }, []); + return user; +} + +export interface WithUserProps { + user: Meteor.User | null; +} + +/** + * HOC to get a stateful value of the current user record. Uses `Meteor.user`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-user + */ +export function withUser

(Component: React.ComponentType

) { + return forwardRef( + (props: Omit & Partial, ref) => { + const user = useUser(); + return ; } - }, []) - return user + ); +} + +/** + * Hook to get a stateful value of whether a login method (e.g. `loginWith`) is currently in progress. Uses `Meteor.loggingIn`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-loggingIn + */ +export function useLoggingIn(): boolean { + const [loggingIn, setLoggingIn] = useState(Meteor.loggingIn()); + useEffect(() => { + const computation = Tracker.autorun(() => { + setLoggingIn(Meteor.loggingIn()); + }); + return () => { + computation.stop(); + }; + }, []); + return loggingIn; +} + +export interface WithLoggingInProps { + loggingIn: boolean; +} + +/** + * HOC to forward a stateful value of whether a login method (e.g. `loginWith`) is currently in progress. Uses `Meteor.loggingIn`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-loggingIn + */ +export function withLoggingIn

(Component: React.ComponentType

) { + return forwardRef( + ( + props: Omit & Partial, + ref + ) => { + const loggingIn = useLoggingIn(); + return ( + + ); + } + ); } -export const withUser = (Component) => ( - forwardRef((props, ref) => { - const user = useUser(); - return - }) -) +/** + * Hook to get a stateful value of whether the logout method is currently in progress. Uses `Meteor.loggingOut`, a reactive data source. + */ +export function useLoggingOut(): boolean { + const [loggingOut, setLoggingOut] = useState(Meteor.loggingOut()); + useEffect(() => { + const computation = Tracker.autorun(() => { + setLoggingOut(Meteor.loggingOut()); + }); + return () => { + computation.stop(); + }; + }, []); + return loggingOut; +} + +export interface WithLoggingOutProps { + loggingOut: boolean; +} + +/** + * HOC to forward a stateful value of whether the logout method is currently in progress. Uses `Meteor.loggingOut`, a reactive data source. + */ +export function withLoggingOut

(Component: React.ComponentType

) { + return forwardRef( + ( + props: Omit & Partial, + ref + ) => { + const loggingOut = useLoggingOut(); + return ( + + ); + } + ); +} diff --git a/packages/react-meteor-accounts/tsconfig.json b/packages/react-meteor-accounts/tsconfig.json new file mode 100644 index 00000000..1cb0c5fb --- /dev/null +++ b/packages/react-meteor-accounts/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@tsconfig/recommended/tsconfig.json", + "compilerOptions": { + "jsx": "preserve" + } +} \ No newline at end of file diff --git a/packages/react-meteor-accounts/types/react-accounts.d.ts b/packages/react-meteor-accounts/types/react-accounts.d.ts index c66598b5..0a90ab56 100644 --- a/packages/react-meteor-accounts/types/react-accounts.d.ts +++ b/packages/react-meteor-accounts/types/react-accounts.d.ts @@ -1,6 +1,57 @@ -/// import { Meteor } from 'meteor/meteor'; -export declare const useUserId: () => string; -export declare const withUserId: (Component: any) => import("react").ForwardRefExoticComponent>; -export declare const useUser: () => Meteor.User; -export declare const withUser: (Component: any) => import("react").ForwardRefExoticComponent>; +import React from 'react'; +declare module 'meteor/meteor' { + module Meteor { + function loggingOut(): boolean; + } +} +/** + * Hook to get a stateful value of the current user id. Uses `Meteor.userId`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-userId + */ +export declare function useUserId(): string | null; +export interface WithUserIdProps { + userId: string | null; +} +/** + * HOC to forward a stateful value of the current user id. Uses `Meteor.userId`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-userId + */ +export declare function withUserId

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +/** + * Hook to get a stateful value of the current user record. Uses `Meteor.user`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-user + */ +export declare function useUser(): Meteor.User | null; +export interface WithUserProps { + user: Meteor.User | null; +} +/** + * HOC to get a stateful value of the current user record. Uses `Meteor.user`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-user + */ +export declare function withUser

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +/** + * Hook to get a stateful value of whether a login method (e.g. `loginWith`) is currently in progress. Uses `Meteor.loggingIn`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-loggingIn + */ +export declare function useLoggingIn(): boolean; +export interface WithLoggingInProps { + loggingIn: boolean; +} +/** + * HOC to forward a stateful value of whether a login method (e.g. `loginWith`) is currently in progress. Uses `Meteor.loggingIn`, a reactive data source. + * @see https://docs.meteor.com/api/accounts.html#Meteor-loggingIn + */ +export declare function withLoggingIn

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>; +/** + * Hook to get a stateful value of whether the logout method is currently in progress. Uses `Meteor.loggingOut`, a reactive data source. + */ +export declare function useLoggingOut(): boolean; +export interface WithLoggingOutProps { + loggingOut: boolean; +} +/** + * HOC to forward a stateful value of whether the logout method is currently in progress. Uses `Meteor.loggingOut`, a reactive data source. + */ +export declare function withLoggingOut

(Component: React.ComponentType

): React.ForwardRefExoticComponent & Partial> & React.RefAttributes>;