diff --git a/.changesets/10418.md b/.changesets/10418.md new file mode 100644 index 000000000000..67e279473290 --- /dev/null +++ b/.changesets/10418.md @@ -0,0 +1,3 @@ +- fix(middleware): Handle POST requests in middleware router too (#10418) by @dac09 + +Fixes issue with middleware router not accepted POST requests. diff --git a/.github/actions/check_changesets/yarn.lock b/.github/actions/check_changesets/yarn.lock index d89dca47afe7..737ac337e013 100644 --- a/.github/actions/check_changesets/yarn.lock +++ b/.github/actions/check_changesets/yarn.lock @@ -205,11 +205,11 @@ __metadata: linkType: hard "undici@npm:^5.25.4": - version: 5.28.3 - resolution: "undici@npm:5.28.3" + version: 5.28.4 + resolution: "undici@npm:5.28.4" dependencies: "@fastify/busboy": "npm:^2.0.0" - checksum: 10c0/3c559ae50ef3104b7085251445dda6f4de871553b9e290845649d2f80b06c0c9cfcdf741b0029c6b20d36c82e6a74dc815b139fa9a26757d70728074ca6d6f5c + checksum: 10c0/08d0f2596553aa0a54ca6e8e9c7f45aef7d042c60918564e3a142d449eda165a80196f6ef19ea2ef2e6446959e293095d8e40af1236f0d67223b06afac5ecad7 languageName: node linkType: hard diff --git a/.github/actions/detect-changes/detectChanges.mjs b/.github/actions/detect-changes/detectChanges.mjs index 0176d8454c13..7f7c20ca460e 100644 --- a/.github/actions/detect-changes/detectChanges.mjs +++ b/.github/actions/detect-changes/detectChanges.mjs @@ -226,7 +226,7 @@ async function main() { // If there's no branch, we're not in a pull request. if (!branch) { - core.setOutput('code', false) + core.setOutput('code', true) core.setOutput('rsc', false) core.setOutput('ssr', false) return diff --git a/.vscode/settings.json b/.vscode/settings.json index 53cfbc6d3cc9..2ee344962552 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,6 +32,7 @@ "memfs", "opentelemetry", "pino", + "Pistorius", "redwoodjs", "RWJS", "tailwindcss", diff --git a/packages/auth-providers/supabase/web/package.json b/packages/auth-providers/supabase/web/package.json index be6238f9776c..d9244119c6ce 100644 --- a/packages/auth-providers/supabase/web/package.json +++ b/packages/auth-providers/supabase/web/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@babel/runtime-corejs3": "7.24.1", + "@redwoodjs/auth": "workspace:*", "core-js": "3.36.1" }, "devDependencies": { diff --git a/packages/auth/.babelrc.js b/packages/auth/.babelrc.js deleted file mode 100644 index 3b2c815712d9..000000000000 --- a/packages/auth/.babelrc.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = { extends: '../../babel.config.js' } diff --git a/packages/auth/build.ts b/packages/auth/build.ts new file mode 100644 index 000000000000..c859e8581af0 --- /dev/null +++ b/packages/auth/build.ts @@ -0,0 +1,36 @@ +import { renameSync, writeFileSync } from 'node:fs' + +import { build, defaultBuildOptions } from '@redwoodjs/framework-tools' + +// ESM build +await build({ + buildOptions: { + ...defaultBuildOptions, + tsconfig: 'tsconfig.build-esm.json', + format: 'esm', + outdir: 'dist/esm', + packages: 'external', + }, +}) + +// CJS build +await build({ + buildOptions: { + ...defaultBuildOptions, + tsconfig: 'tsconfig.build.json', + packages: 'external', + }, +}) + +// Because the package.json files has `type: module` the CJS entry file can't +// be named `index.js` because in that case it would be treated as an ESM file. +// By changing it to .cjs it will be treated as a CommonJS file. +renameSync('dist/index.js', 'dist/index.cjs') + +// Place a package.json file with `type: commonjs` in the dist folder so that +// all .js files are treated as CommonJS files. +writeFileSync('dist/package.json', JSON.stringify({ type: 'commonjs' })) + +// Place a package.json file with `type: module` in the dist/esm folder so that +// all .js files are treated as ES Module files. +writeFileSync('dist/esm/package.json', JSON.stringify({ type: 'module' })) diff --git a/packages/auth/jest.config.js b/packages/auth/jest.config.js deleted file mode 100644 index e691bb8f6dbd..000000000000 --- a/packages/auth/jest.config.js +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - testEnvironment: 'jest-environment-jsdom', - testPathIgnorePatterns: ['fixtures', 'dist'], -} diff --git a/packages/auth/modules.d.ts b/packages/auth/modules.d.ts new file mode 100644 index 000000000000..574a2b1aa368 --- /dev/null +++ b/packages/auth/modules.d.ts @@ -0,0 +1 @@ +declare module 'whatwg-fetch' diff --git a/packages/auth/package.json b/packages/auth/package.json index dd8ba5b1fd61..b60ee36b3145 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -7,35 +7,37 @@ "directory": "packages/auth" }, "license": "MIT", - "main": "./dist/index.js", + "type": "module", + "exports": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/index.cjs" + }, "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { - "build": "yarn build:js && yarn build:types", - "build:js": "babel src -d dist --extensions \".js,.jsx,.ts,.tsx\"", + "build": "tsx ./build.ts && yarn build:types", "build:pack": "yarn pack -o redwoodjs-auth.tgz", - "build:types": "tsc --build --verbose", + "build:types": "tsc --build --verbose tsconfig.build.json && tsc --build --verbose tsconfig.build-esm.json", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"", "prepublishOnly": "NODE_ENV=production yarn build", - "test": "jest src", - "test:watch": "yarn test --watch" + "test": "vitest run", + "test:watch": "vitest watch" }, "dependencies": { - "@babel/runtime-corejs3": "7.24.1", "core-js": "3.36.1", "react": "18.3.0-canary-a870b2d54-20240314" }, "devDependencies": { - "@babel/cli": "7.24.1", - "@babel/core": "^7.22.20", + "@redwoodjs/framework-tools": "workspace:*", "@testing-library/jest-dom": "6.4.2", "@testing-library/react": "14.2.2", - "jest": "29.7.0", - "jest-environment-jsdom": "29.7.0", "msw": "1.3.3", - "typescript": "5.4.3" + "tsx": "4.7.1", + "typescript": "5.4.3", + "vitest": "1.4.0" }, "gitHead": "3905ed045508b861b495f8d5630d76c7a157d8f1" } diff --git a/packages/auth/src/AuthProvider/AuthProvider.tsx b/packages/auth/src/AuthProvider/AuthProvider.tsx index 17e40c47e69a..f88d00333183 100644 --- a/packages/auth/src/AuthProvider/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider/AuthProvider.tsx @@ -1,22 +1,22 @@ import type { ReactNode } from 'react' import React, { useContext, useEffect, useState } from 'react' -import type { AuthContextInterface, CurrentUser } from '../AuthContext' -import type { AuthImplementation } from '../AuthImplementation' - -import type { AuthProviderState } from './AuthProviderState' -import { defaultAuthProviderState } from './AuthProviderState' -import { ServerAuthContext } from './ServerAuthProvider' -import { useCurrentUser } from './useCurrentUser' -import { useForgotPassword } from './useForgotPassword' -import { useHasRole } from './useHasRole' -import { useLogIn } from './useLogIn' -import { useLogOut } from './useLogOut' -import { useReauthenticate } from './useReauthenticate' -import { useResetPassword } from './useResetPassword' -import { useSignUp } from './useSignUp' -import { useToken } from './useToken' -import { useValidateResetToken } from './useValidateResetToken' +import type { AuthContextInterface, CurrentUser } from '../AuthContext.js' +import type { AuthImplementation } from '../AuthImplementation.js' + +import type { AuthProviderState } from './AuthProviderState.js' +import { defaultAuthProviderState } from './AuthProviderState.js' +import { ServerAuthContext } from './ServerAuthProvider.js' +import { useCurrentUser } from './useCurrentUser.js' +import { useForgotPassword } from './useForgotPassword.js' +import { useHasRole } from './useHasRole.js' +import { useLogIn } from './useLogIn.js' +import { useLogOut } from './useLogOut.js' +import { useReauthenticate } from './useReauthenticate.js' +import { useResetPassword } from './useResetPassword.js' +import { useSignUp } from './useSignUp.js' +import { useToken } from './useToken.js' +import { useValidateResetToken } from './useValidateResetToken.js' export interface AuthProviderProps { children: ReactNode diff --git a/packages/auth/src/AuthProvider/AuthProviderState.ts b/packages/auth/src/AuthProvider/AuthProviderState.ts index f9dbe07c02ff..3bcaa6ed6035 100644 --- a/packages/auth/src/AuthProvider/AuthProviderState.ts +++ b/packages/auth/src/AuthProvider/AuthProviderState.ts @@ -1,4 +1,4 @@ -import type { CurrentUser } from '../AuthContext' +import type { CurrentUser } from '../AuthContext.js' export type AuthProviderState = { loading: boolean diff --git a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx index b9ca2e8364a2..c90f568fab15 100644 --- a/packages/auth/src/AuthProvider/ServerAuthProvider.tsx +++ b/packages/auth/src/AuthProvider/ServerAuthProvider.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from 'react' import React from 'react' -import type { AuthProviderState } from './AuthProviderState' -import { defaultAuthProviderState } from './AuthProviderState' +import type { AuthProviderState } from './AuthProviderState.js' +import { defaultAuthProviderState } from './AuthProviderState.js' export type ServerAuthState = AuthProviderState & { cookieHeader?: string diff --git a/packages/auth/src/AuthProvider/useCurrentUser.ts b/packages/auth/src/AuthProvider/useCurrentUser.ts index bd385c9bc1cb..480528a9f33d 100644 --- a/packages/auth/src/AuthProvider/useCurrentUser.ts +++ b/packages/auth/src/AuthProvider/useCurrentUser.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' -import { useToken } from './useToken' +import { useToken } from './useToken.js' export const useCurrentUser = (authImplementation: AuthImplementation) => { const getToken = useToken(authImplementation) diff --git a/packages/auth/src/AuthProvider/useForgotPassword.ts b/packages/auth/src/AuthProvider/useForgotPassword.ts index 7589648c735f..945e7a9c303c 100644 --- a/packages/auth/src/AuthProvider/useForgotPassword.ts +++ b/packages/auth/src/AuthProvider/useForgotPassword.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' export const useForgotPassword = < TUser, diff --git a/packages/auth/src/AuthProvider/useHasRole.ts b/packages/auth/src/AuthProvider/useHasRole.ts index fd324c40bf1c..0201baddb055 100644 --- a/packages/auth/src/AuthProvider/useHasRole.ts +++ b/packages/auth/src/AuthProvider/useHasRole.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { CurrentUser } from '../AuthContext' +import type { CurrentUser } from '../AuthContext.js' export const useHasRole = (currentUser: CurrentUser | null) => { /** diff --git a/packages/auth/src/AuthProvider/useLogIn.ts b/packages/auth/src/AuthProvider/useLogIn.ts index 1660186d3354..efbc0b763efc 100644 --- a/packages/auth/src/AuthProvider/useLogIn.ts +++ b/packages/auth/src/AuthProvider/useLogIn.ts @@ -1,11 +1,11 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' -import type { AuthProviderState } from './AuthProviderState' -import { defaultAuthProviderState } from './AuthProviderState' -import type { useCurrentUser } from './useCurrentUser' -import { useReauthenticate } from './useReauthenticate' +import type { AuthProviderState } from './AuthProviderState.js' +import { defaultAuthProviderState } from './AuthProviderState.js' +import type { useCurrentUser } from './useCurrentUser.js' +import { useReauthenticate } from './useReauthenticate.js' export const useLogIn = < TUser, diff --git a/packages/auth/src/AuthProvider/useLogOut.ts b/packages/auth/src/AuthProvider/useLogOut.ts index a5d0b942db23..e7306eb06676 100644 --- a/packages/auth/src/AuthProvider/useLogOut.ts +++ b/packages/auth/src/AuthProvider/useLogOut.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' -import type { AuthProviderState } from './AuthProviderState' +import type { AuthProviderState } from './AuthProviderState.js' export const useLogOut = < TUser, diff --git a/packages/auth/src/AuthProvider/useReauthenticate.ts b/packages/auth/src/AuthProvider/useReauthenticate.ts index b7d202dc0844..93d2d84b1d27 100644 --- a/packages/auth/src/AuthProvider/useReauthenticate.ts +++ b/packages/auth/src/AuthProvider/useReauthenticate.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' -import type { AuthProviderState } from './AuthProviderState' -import type { useCurrentUser } from './useCurrentUser' -import { useToken } from './useToken' +import type { AuthProviderState } from './AuthProviderState.js' +import type { useCurrentUser } from './useCurrentUser.js' +import { useToken } from './useToken.js' const notAuthenticatedState = { isAuthenticated: false, diff --git a/packages/auth/src/AuthProvider/useResetPassword.ts b/packages/auth/src/AuthProvider/useResetPassword.ts index 9ce8c4587f3c..162994e3294c 100644 --- a/packages/auth/src/AuthProvider/useResetPassword.ts +++ b/packages/auth/src/AuthProvider/useResetPassword.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' export const useResetPassword = < TUser, diff --git a/packages/auth/src/AuthProvider/useSignUp.ts b/packages/auth/src/AuthProvider/useSignUp.ts index 45e835d9be2e..c136ac935cb5 100644 --- a/packages/auth/src/AuthProvider/useSignUp.ts +++ b/packages/auth/src/AuthProvider/useSignUp.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' -import type { AuthProviderState } from './AuthProviderState' -import type { useCurrentUser } from './useCurrentUser' -import { useReauthenticate } from './useReauthenticate' +import type { AuthProviderState } from './AuthProviderState.js' +import type { useCurrentUser } from './useCurrentUser.js' +import { useReauthenticate } from './useReauthenticate.js' export const useSignUp = < TUser, diff --git a/packages/auth/src/AuthProvider/useToken.ts b/packages/auth/src/AuthProvider/useToken.ts index 10ba69b51635..9e17e0cac997 100644 --- a/packages/auth/src/AuthProvider/useToken.ts +++ b/packages/auth/src/AuthProvider/useToken.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' export const useToken = (authImplementation: AuthImplementation) => { return useCallback(async () => { diff --git a/packages/auth/src/AuthProvider/useValidateResetToken.ts b/packages/auth/src/AuthProvider/useValidateResetToken.ts index f7ea795f3d86..eeafd5a3c9f6 100644 --- a/packages/auth/src/AuthProvider/useValidateResetToken.ts +++ b/packages/auth/src/AuthProvider/useValidateResetToken.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' -import type { AuthImplementation } from '../AuthImplementation' +import type { AuthImplementation } from '../AuthImplementation.js' export const useValidateResetToken = < TUser, diff --git a/packages/auth/src/__tests__/AuthProvider.test.tsx b/packages/auth/src/__tests__/AuthProvider.test.tsx index 52e2f7ff6db2..37292bf98586 100644 --- a/packages/auth/src/__tests__/AuthProvider.test.tsx +++ b/packages/auth/src/__tests__/AuthProvider.test.tsx @@ -1,21 +1,39 @@ -require('whatwg-fetch') - import React, { useEffect, useState } from 'react' import { + act, render, + renderHook, screen, fireEvent, waitFor, configure, } from '@testing-library/react' -import { renderHook, act } from '@testing-library/react' -import '@testing-library/jest-dom/jest-globals' import { graphql } from 'msw' import { setupServer } from 'msw/node' +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from 'vitest' +import { + fetch as fetchPolyfill, + Headers as HeadersPolyfill, + Request as RequestPolyfill, + Response as ResponsePolyfill, +} from 'whatwg-fetch' + +globalThis.fetch = fetchPolyfill +globalThis.Headers = HeadersPolyfill +globalThis.Request = RequestPolyfill +globalThis.Response = ResponsePolyfill -import type { CustomTestAuthClient } from './fixtures/customTestAuth' -import { createCustomTestAuth } from './fixtures/customTestAuth' +import type { CustomTestAuthClient } from './fixtures/customTestAuth.js' +import { createCustomTestAuth } from './fixtures/customTestAuth.js' configure({ asyncUtilTimeout: 5_000, @@ -61,7 +79,7 @@ const customTestAuth: CustomTestAuthClient = { signup: () => {}, logout: () => {}, getToken: () => 'hunter2', - getUserMetadata: jest.fn(() => null), + getUserMetadata: vi.fn(() => null), forgotPassword: () => {}, resetPassword: () => true, validateResetToken: () => ({}), @@ -82,7 +100,7 @@ beforeEach(() => { name: 'Peter Pistorius', email: 'nospam@example.net', } - customTestAuth.getUserMetadata = jest.fn(() => null) + customTestAuth.getUserMetadata = vi.fn(() => null) }) describe('Custom auth provider', () => { @@ -176,7 +194,7 @@ describe('Custom auth provider', () => { expect(mockAuthClient.getUserMetadata).toBeCalledTimes(1) // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -204,9 +222,9 @@ describe('Custom auth provider', () => { await waitFor(() => screen.getByText('Log In')) }) - /// @MARK: Breaking change! + /// @MARK: Technically a breaking change. // skipFetchCurrentUser used to be used for nHost only - // and isn't something we want to support anymore + // and isn't something we need to support anymore /** * This is especially helpful if you want to update the currentUser state. @@ -225,7 +243,7 @@ describe('Custom auth provider', () => { expect(mockAuthClient.getUserMetadata).toBeCalledTimes(1) // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -262,7 +280,7 @@ describe('Custom auth provider', () => { ) const mockAuthClient = customTestAuth - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -311,7 +329,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -358,7 +376,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -405,7 +423,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -452,7 +470,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -498,7 +516,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -541,7 +559,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -588,7 +606,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -634,7 +652,7 @@ describe('Custom auth provider', () => { expect(screen.queryByText('Has Super User:')).not.toBeInTheDocument() // Replace "getUserMetadata" with actual data, and login! - mockAuthClient.getUserMetadata = jest.fn(() => { + mockAuthClient.getUserMetadata = vi.fn(() => { return { sub: 'abcdefg|123456', username: 'peterp', @@ -655,7 +673,7 @@ describe('Custom auth provider', () => { }) test('proxies forgotPassword() calls to client', async () => { - const mockedForgotPassword = jest.spyOn(customTestAuth, 'forgotPassword') + const mockedForgotPassword = vi.spyOn(customTestAuth, 'forgotPassword') mockedForgotPassword.mockImplementation((username: string) => { expect(username).toEqual('username') }) @@ -718,7 +736,7 @@ describe('Custom auth provider', () => { }) test("getToken doesn't fail if client throws an error", async () => { - customTestAuth.getToken = jest.fn(() => { + customTestAuth.getToken = vi.fn(() => { throw 'Login Required' }) diff --git a/packages/auth/src/__tests__/fixtures/customTestAuth.ts b/packages/auth/src/__tests__/fixtures/customTestAuth.ts index 6536c4eee079..9592f6d5465e 100644 --- a/packages/auth/src/__tests__/fixtures/customTestAuth.ts +++ b/packages/auth/src/__tests__/fixtures/customTestAuth.ts @@ -1,4 +1,4 @@ -import { CurrentUser, createAuthentication } from '../../index' +import { CurrentUser, createAuthentication } from '../../index.js' interface User { sub: string diff --git a/packages/auth/src/authFactory.ts b/packages/auth/src/authFactory.ts index 33c00aa5732d..ec909c729108 100644 --- a/packages/auth/src/authFactory.ts +++ b/packages/auth/src/authFactory.ts @@ -1,8 +1,8 @@ -import type { CurrentUser } from './AuthContext' -import { createAuthContext } from './AuthContext' -import type { AuthImplementation } from './AuthImplementation' -import { createAuthProvider } from './AuthProvider/AuthProvider' -import { createUseAuth } from './useAuth' +import type { CurrentUser } from './AuthContext.js' +import { createAuthContext } from './AuthContext.js' +import type { AuthImplementation } from './AuthImplementation.js' +import { createAuthProvider } from './AuthProvider/AuthProvider.js' +import { createUseAuth } from './useAuth.js' export function createAuthentication< TUser, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 14a8cc83fbba..3f0f49c51925 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -1,7 +1,8 @@ -export { AuthContextInterface, CurrentUser } from './AuthContext' -export { useNoAuth, UseAuth } from './useAuth' -export { createAuthentication } from './authFactory' -export type { AuthImplementation } from './AuthImplementation' +export type { AuthContextInterface, CurrentUser } from './AuthContext.js' +export { useNoAuth } from './useAuth.js' +export type { UseAuth } from './useAuth.js' +export { createAuthentication } from './authFactory.js' +export type { AuthImplementation } from './AuthImplementation.js' -export * from './AuthProvider/AuthProviderState' -export * from './AuthProvider/ServerAuthProvider' +export * from './AuthProvider/AuthProviderState.js' +export * from './AuthProvider/ServerAuthProvider.js' diff --git a/packages/auth/src/useAuth.ts b/packages/auth/src/useAuth.ts index 301aa66877b8..80b74f92bdc7 100644 --- a/packages/auth/src/useAuth.ts +++ b/packages/auth/src/useAuth.ts @@ -1,6 +1,6 @@ import React from 'react' -import type { AuthContextInterface } from './AuthContext' +import type { AuthContextInterface } from './AuthContext.js' export function createUseAuth< TUser, diff --git a/packages/auth/tsconfig.build-esm.json b/packages/auth/tsconfig.build-esm.json new file mode 100644 index 000000000000..de5a45368b9e --- /dev/null +++ b/packages/auth/tsconfig.build-esm.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "isolatedModules": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "rootDir": "src", + "outDir": "dist/esm", + }, + "include": ["src", "ambient.d.ts"], + "references": [ + { "path": "../framework-tools" } + ] +} diff --git a/packages/auth/tsconfig.build.json b/packages/auth/tsconfig.build.json new file mode 100644 index 000000000000..6cb547bce6f0 --- /dev/null +++ b/packages/auth/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "isolatedModules": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "rootDir": "src", + "outDir": "dist", + }, + "include": ["src", "ambient.d.ts"], + "references": [ + { "path": "../framework-tools" } + ] +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json index 35c7dc051767..d435973639c6 100644 --- a/packages/auth/tsconfig.json +++ b/packages/auth/tsconfig.json @@ -1,8 +1,20 @@ { "extends": "../../tsconfig.compilerOption.json", "compilerOptions": { - "rootDir": "src", - "outDir": "dist", + "isolatedModules": true, + "moduleResolution": "NodeNext", + "module": "NodeNext", + "outDir": "dist" }, - "include": ["src", "ambient.d.ts"] + "include": [ + "src", + "ambient.d.ts", + "modules.d.ts", + "./vitest.setup.ts", + "__tests__" + ], + "exclude": ["dist", "node_modules", "**/__mocks__", "**/__fixtures__"], + "references": [ + { "path": "../framework-tools" } + ] } diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts new file mode 100644 index 000000000000..a832fed4d8b8 --- /dev/null +++ b/packages/auth/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig, configDefaults } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: [...configDefaults.exclude, '**/fixtures'], + environment: 'jsdom', + setupFiles: ['vitest.setup.ts'], + }, +}) diff --git a/packages/auth/vitest.setup.ts b/packages/auth/vitest.setup.ts new file mode 100644 index 000000000000..5b6e47c1e415 --- /dev/null +++ b/packages/auth/vitest.setup.ts @@ -0,0 +1,11 @@ +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' +import '@testing-library/jest-dom/vitest' + +afterEach(() => { + // If vitest globals are enabled testing-library will clean up after each + // test automatically, but we don't enable globals, so we have to manually + // clean up here + // https://testing-library.com/docs/react-testing-library/api/#cleanup + cleanup() +}) diff --git a/packages/forms/src/CheckboxField.tsx b/packages/forms/src/CheckboxField.tsx new file mode 100644 index 000000000000..b987e281dbaf --- /dev/null +++ b/packages/forms/src/CheckboxField.tsx @@ -0,0 +1,63 @@ +import type { ForwardedRef } from 'react' +import React, { forwardRef } from 'react' + +import type { FieldProps } from './FieldProps' +import { useErrorStyles } from './useErrorStyles' +import { useRegister } from './useRegister' + +export interface CheckboxFieldProps + extends Omit, 'type' | 'emptyAs'>, + Omit, 'name' | 'type'> {} + +/** Renders an `` field */ +export const CheckboxField = forwardRef( + ( + { + name, + id, + // for useErrorStyles + errorClassName, + errorStyle, + className, + style, + // for useRegister + validation, + onBlur, + onChange, + ...rest + }: CheckboxFieldProps, + ref: ForwardedRef, + ) => { + const styles = useErrorStyles({ + name, + errorClassName, + errorStyle, + className, + style, + }) + + const type = 'checkbox' + + const useRegisterReturn = useRegister( + { + name, + validation, + onBlur, + onChange, + type, + }, + ref, + ) + + return ( + + ) + }, +) diff --git a/packages/forms/src/FieldError.tsx b/packages/forms/src/FieldError.tsx new file mode 100644 index 000000000000..43492f824f39 --- /dev/null +++ b/packages/forms/src/FieldError.tsx @@ -0,0 +1,72 @@ +import React from 'react' + +import { get, useFormContext } from 'react-hook-form' + +export interface FieldErrorProps + extends React.ComponentPropsWithoutRef<'span'> { + /** + * The name of the field the ``'s associated with. + */ + name: string +} + +const DEFAULT_MESSAGES = { + required: 'is required', + pattern: 'is not formatted correctly', + minLength: 'is too short', + maxLength: 'is too long', + min: 'is too low', + max: 'is too high', + validate: 'is not valid', +} + +/** + * Renders a `` with an error message if there's a validation error on the corresponding field. + * If no error message is provided, a default one is used based on the type of validation that caused the error. + * + * @example Displaying a validation error message with `` + * + * `` doesn't render (i.e. returns `null`) when there's no error on ``. + * + * ```jsx + * + * + * + * ``` + * + * @see {@link https://redwoodjs.com/docs/tutorial/chapter3/forms#fielderror} + * + * @privateRemarks + * + * This is basically a helper for a common pattern you see in `react-hook-form`: + * + * ```jsx + *
+ * + * {errors.firstName?.type === 'required' && "First name is required"} + * ``` + * + * @see {@link https://react-hook-form.com/get-started#Handleerrors} + */ +export const FieldError = ({ name, ...rest }: FieldErrorProps) => { + const { + formState: { errors }, + } = useFormContext() + + const validationError = get(errors, name) + + const errorMessage = + validationError && + (validationError.message || + `${name} ${ + DEFAULT_MESSAGES[validationError.type as keyof typeof DEFAULT_MESSAGES] + }`) + + return validationError ? {errorMessage} : null +} diff --git a/packages/forms/src/FieldProps.ts b/packages/forms/src/FieldProps.ts new file mode 100644 index 000000000000..38171b593a34 --- /dev/null +++ b/packages/forms/src/FieldProps.ts @@ -0,0 +1,31 @@ +import type { EmptyAsValue, RedwoodRegisterOptions } from './coercion' + +/** + * The main interface, just to have some sort of source of truth. + * + * @typeParam E - The type of element; we're only ever working with a few HTMLElements. + * + * `extends` constrains the generic while `=` provides a default. + * + * @see {@link https://www.typescriptlang.org/docs/handbook/2/generics.html#generic-constraints} + * + * @internal + */ +export interface FieldProps< + Element extends + | HTMLTextAreaElement + | HTMLSelectElement + | HTMLInputElement = HTMLInputElement, +> { + name: string + id?: string + emptyAs?: EmptyAsValue + errorClassName?: string + errorStyle?: React.CSSProperties + className?: string + style?: React.CSSProperties + validation?: RedwoodRegisterOptions + type?: string + onBlur?: React.FocusEventHandler + onChange?: React.ChangeEventHandler +} diff --git a/packages/forms/src/Form.tsx b/packages/forms/src/Form.tsx new file mode 100644 index 000000000000..dff2fc3c801a --- /dev/null +++ b/packages/forms/src/Form.tsx @@ -0,0 +1,96 @@ +import React, { forwardRef } from 'react' +import type { ForwardedRef } from 'react' + +import { useForm, FormProvider } from 'react-hook-form' +import type { FieldValues, UseFormReturn, UseFormProps } from 'react-hook-form' + +import { ServerErrorsContext } from './ServerErrorsContext' + +export interface FormProps + extends Omit, 'onSubmit'> { + error?: any + /** + * The methods returned by `useForm`. + * This prop is only necessary if you've called `useForm` yourself to get + * access to one of its functions, like `reset`. + * + * @example + * + * ```typescript + * const formMethods = useForm() + * + * const onSubmit = (data: FormData) => { + * sendDataToServer(data) + * formMethods.reset() + * } + * + * return ( + * + * ) + * ``` + */ + formMethods?: UseFormReturn + /** + * Configures how React Hook Form performs validation, among other things. + * + * @example + * + * ```jsx + * + * ``` + * + * @see {@link https://react-hook-form.com/api/useform} + */ + config?: UseFormProps + onSubmit?: (value: TFieldValues, event?: React.BaseSyntheticEvent) => void +} + +/** + * Renders a `` with the required context. + */ +function FormInner( + { + config, + error: errorProps, + formMethods: propFormMethods, + onSubmit, + children, + ...rest + }: FormProps, + ref: ForwardedRef, +) { + const hookFormMethods = useForm(config) + const formMethods = propFormMethods || hookFormMethods + + return ( + + onSubmit?.(data, event), + )} + > + + {children} + + + ) +} + +// Sorry about the `as` type assertion (type cast) here. Normally I'd redeclare +// forwardRef to only return a plain function, allowing us to use TypeScript's +// Higher-order Function Type Inference. But that gives us problems with the +// ForwardRefExoticComponent type we use for our InputComponents. So instead +// of changing that type (because it's correct) I use a type assertion here. +// forwardRef is notoriously difficult to use with UI component libs. +// Chakra-UI also says: +// > To be honest, the forwardRef type is quite complex [...] I'd recommend +// > that you cast the type +// https://github.com/chakra-ui/chakra-ui/issues/4528#issuecomment-902566185 +export const Form = forwardRef(FormInner) as ( + props: FormProps & React.RefAttributes, +) => React.ReactElement | null diff --git a/packages/forms/src/InputComponents.tsx b/packages/forms/src/InputComponents.tsx new file mode 100644 index 000000000000..6d1efd289dc9 --- /dev/null +++ b/packages/forms/src/InputComponents.tsx @@ -0,0 +1,160 @@ +import type { ForwardedRef } from 'react' +import React, { forwardRef } from 'react' + +import pascalcase from 'pascalcase' + +import type { FieldProps } from './FieldProps' +import { useErrorStyles } from './useErrorStyles' +import { useRegister } from './useRegister' + +/** + * All the types we'll be generating named `` for (which is basically all of them). + * Note that `'checkbox'` isn't here because we handle it separately above. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types} + */ +const INPUT_TYPES = [ + 'button', + 'color', + 'date', + 'datetime-local', + 'email', + 'file', + 'hidden', + 'image', + 'month', + 'number', + 'password', + 'radio', + 'range', + 'reset', + 'search', + 'submit', + 'tel', + 'text', + 'time', + 'url', + 'week', +] as const + +type InputType = (typeof INPUT_TYPES)[number] + +export interface InputFieldProps + extends Omit, 'type'>, + Omit, 'name' | 'type'> { + /** + * @privateRemarks + * + * With this typing, passing `'checkbox'` to ``'s type is an error, which, + * at face value, feels like it shouldn't be. + * + * Even though we provide a separate ``, maybe we should reconsider the typing here? + */ + type?: InputType +} + +/** + * Renders an `` field. + * + * @see {@link https://redwoodjs.com/docs/form#inputfields} + */ +export const InputField = forwardRef( + ( + { + name, + id, + emptyAs, + // for useErrorStyles + errorClassName, + errorStyle, + className, + style, + // for useRegister + validation, + onBlur, + onChange, + type, + ...rest + }: InputFieldProps, + ref: ForwardedRef, + ) => { + const styles = useErrorStyles({ + name, + errorClassName, + errorStyle, + className, + style, + }) + + const useRegisterReturn = useRegister( + { + name, + validation, + onBlur, + onChange, + type, + }, + ref, + emptyAs, + ) + + return ( + + ) + }, +) + +/** + * `React.ForwardRefExoticComponent` is `forwardRef`'s return type. + * You can hover over `` above to see the type inference at work. + */ +const InputComponents: Record< + string, + React.ForwardRefExoticComponent> +> = {} + +/** + * Create a component for each type in `INPUT_TYPES`. + * + * Rather than writing out each and every component definition, + * we use a bit of JS metaprogramming to create them all with the appropriate name. + * + * We end up with `InputComponents.TextField`, `InputComponents.TimeField`, etc. + * Export those and we're good to go! + */ +INPUT_TYPES.forEach((type) => { + InputComponents[`${pascalcase(type)}Field`] = forwardRef< + HTMLInputElement, + Omit + >((props, ref) => ) +}) + +export const { + ButtonField, + ColorField, + DateField, + DatetimeLocalField, + EmailField, + FileField, + HiddenField, + ImageField, + MonthField, + NumberField, + PasswordField, + RadioField, + RangeField, + ResetField, + SearchField, + SubmitField, + TelField, + TextField, + TimeField, + UrlField, + WeekField, +} = InputComponents diff --git a/packages/forms/src/Label.tsx b/packages/forms/src/Label.tsx new file mode 100644 index 000000000000..4eee9819495c --- /dev/null +++ b/packages/forms/src/Label.tsx @@ -0,0 +1,38 @@ +import React from 'react' + +import type { FieldProps } from './FieldProps' +import { useErrorStyles } from './useErrorStyles' + +export interface LabelProps + extends Pick, + React.ComponentPropsWithoutRef<'label'> { + name: string +} + +/** + * Renders a `