diff --git a/.eslintrc.js b/.eslintrc.js index af531e4f0404..7ce7a34be528 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -27,6 +27,7 @@ module.exports = { 'packages/babel-config/src/__tests__/__fixtures__/**/*', 'packages/core/**/__fixtures__/**/*', 'packages/codemods/**/__testfixtures__/**/*', + 'packages/cli/**/__testfixtures__/**/*', 'packages/core/config/storybook/**/*', 'packages/studio/dist-*/**/*', ], diff --git a/docs/docs/cli-commands.md b/docs/docs/cli-commands.md index 7e85cddf3516..2337b3da8b1a 100644 --- a/docs/docs/cli-commands.md +++ b/docs/docs/cli-commands.md @@ -1999,11 +1999,44 @@ We perform a simple compatibility check in an attempt to make you aware of poten It's the author of the npm package's responsibility to specify the correct compatibility range, so **you should always research the packages you use with this command**. Especially since they will be executing code on your machine! +### setup graphql + +This command creates the necessary files to support GraphQL features like fragments. + +#### Usage + +Run `yarn rw setup graphql ` + +#### setup graphql fragments + +This command creates the necessary configuration to start using [GraphQL Fragments](./graphql/fragments.md). + +``` +yarn redwood setup graphql fragments +``` + +| Arguments & Options | Description | +| :------------------ | :--------------------------------------- | +| `--force, -f` | Overwrite existing files and skip checks | + +#### Usage + +Run `yarn rw setup graphql fragments` + +#### Example + +```bash +~/redwood-app$ yarn rw setup graphql fragments +✔ Update Redwood Project Configuration to enable GraphQL Fragments +✔ Generate possibleTypes.ts +✔ Import possibleTypes in App.tsx +✔ Add possibleTypes to the GraphQL cache config +``` + ### setup realtime This command creates the necessary files, installs the required packages, and provides examples to setup RedwoodJS Realtime from GraphQL live queries and subscriptions. See the Realtime docs for more information. - ``` yarn redwood setup realtime ``` diff --git a/docs/docs/graphql/fragments.md b/docs/docs/graphql/fragments.md index e66041550247..71f693423fa4 100644 --- a/docs/docs/graphql/fragments.md +++ b/docs/docs/graphql/fragments.md @@ -83,6 +83,12 @@ With `registerFragment`, you can register a fragment with the registry and get b which can then be used to work with the registered fragment. +### Setup + +`yarn rw setup graphql fragments` + +See more in [cli commands - setup graphql fragments](../cli-commands.md#setup-graphql-fragments). + ### registerFragment To register a fragment, you can simply register it with `registerFragment`. @@ -200,7 +206,7 @@ the `getCacheKey` is a function where `getCacheKey(42)` would return `Book:42`. import { registerFragment } from '@redwoodjs/web/apollo' const { useRegisteredFragment } = registerFragment( -... + // ... ) ``` @@ -281,17 +287,19 @@ To make this easier to maintain, RedwoodJS GraphQL CodeGen automatically generat ```ts +// web/src/App.tsx + import possibleTypes from 'src/graphql/possibleTypes' -... -/// web/src/App.tsx - +// ... + +const graphQLClientConfig = { + cacheConfig: { + ...possibleTypes, + }, +} + + ``` To generate the `src/graphql/possibleTypes` file, enable fragments in `redwood.toml`: diff --git a/packages/cli-helpers/src/lib/index.ts b/packages/cli-helpers/src/lib/index.ts index 30861f18062c..d55f3fa08123 100644 --- a/packages/cli-helpers/src/lib/index.ts +++ b/packages/cli-helpers/src/lib/index.ts @@ -54,7 +54,17 @@ export const transformTSToJS = (filename: string, content: string) => { */ export const prettierOptions = () => { try { - return require(path.join(getPaths().base, 'prettier.config.js')) + const options = require(path.join(getPaths().base, 'prettier.config.js')) + + if (options.tailwindConfig?.startsWith('.')) { + // Make this work with --cwd + options.tailwindConfig = path.join( + process.env.RWJS_CWD ?? process.cwd(), + options.tailwindConfig + ) + } + + return options } catch (e) { return undefined } diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js deleted file mode 100644 index a1ed78aa66e1..000000000000 --- a/packages/cli/jest.config.js +++ /dev/null @@ -1,9 +0,0 @@ -module.exports = { - testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], - testPathIgnorePatterns: ['fixtures', 'dist'], - moduleNameMapper: { - '^src/(.*)': '/src/$1', - }, - testTimeout: 15000, - setupFilesAfterEnv: ['./jest.setup.js'], -} diff --git a/packages/cli/jest.config.ts b/packages/cli/jest.config.ts new file mode 100644 index 000000000000..92eb1ec9cce9 --- /dev/null +++ b/packages/cli/jest.config.ts @@ -0,0 +1,39 @@ +import type { Config } from 'jest' + +const config: Config = { + projects: [ + { + displayName: 'root', + testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)'], + testPathIgnorePatterns: [ + '__fixtures__', + '__testfixtures__', + '__codemod_tests__', + '__tests__/utils/*', + '__tests__/fixtures/*', + '.d.ts', + 'dist', + ], + moduleNameMapper: { + '^src/(.*)': '/src/$1', + }, + setupFilesAfterEnv: ['./jest.setup.js'], + }, + { + displayName: 'setup codemods', + testMatch: ['**/commands/setup/**/__codemod_tests__/*.ts'], + testPathIgnorePatterns: [ + '__fixtures__', + '__testfixtures__', + '__tests__/utils/*', + '__tests__/fixtures/*', + '.d.ts', + 'dist', + ], + setupFilesAfterEnv: ['./src/jest.codemods.setup.ts'], + }, + ], + testTimeout: 20_000, +} + +export default config diff --git a/packages/cli/package.json b/packages/cli/package.json index 938e808b46ed..e97e99cbfc11 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "yarn build:js", "build:clean-dist": "rimraf 'dist/**/*/__tests__' --glob", - "build:js": "babel src -d dist --extensions \".js,.jsx,.ts,.tsx\" --copy-files --no-copy-ignored && yarn build:clean-dist", + "build:js": "babel src -d dist --extensions \".js,.jsx,.ts,.tsx\" --ignore \"src/**/__tests__/**\" --ignore \"src/**/__testfixtures__/**\" --copy-files --no-copy-ignored && yarn build:clean-dist", "build:pack": "yarn pack -o redwoodjs-cli.tgz", "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx,template\" --ignore dist --exec \"yarn build && yarn fix:permissions\"", "dev": "RWJS_CWD=../../__fixtures__/example-todo-main node dist/index.js", diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts new file mode 100644 index 000000000000..56397b430698 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appGqlConfigTransform.test.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { findUp } from '@redwoodjs/project-config' + +describe('fragments graphQLClientConfig', () => { + test('App.tsx with no graphQLClientConfig', async () => { + await matchFolderTransform('appGqlConfigTransform', 'config-simple', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing inline graphQLClientConfig', async () => { + await matchFolderTransform('appGqlConfigTransform', 'existingPropInline', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariable', + { + useJsCodeshift: true, + } + ) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable, without cacheConfig property', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariableNoCacheConfig', + { + useJsCodeshift: true, + } + ) + }) + + test('App.tsx with existing graphQLClientConfig in separate variable with non-standard name', async () => { + await matchFolderTransform( + 'appGqlConfigTransform', + 'existingPropVariableCustomName', + { + useJsCodeshift: true, + } + ) + }) + + test('test-project App.tsx', async () => { + const rootFwPath = path.dirname(findUp('lerna.json') || '') + const testProjectAppTsx = fs.readFileSync( + path.join( + rootFwPath, + '__fixtures__', + 'test-project', + 'web', + 'src', + 'App.tsx' + ), + 'utf-8' + ) + await matchInlineTransformSnapshot( + 'appGqlConfigTransform', + testProjectAppTsx, + `import { FatalErrorBoundary, RedwoodProvider } from \"@redwoodjs/web\"; + import { RedwoodApolloProvider } from \"@redwoodjs/web/apollo\"; + + import FatalErrorPage from \"src/pages/FatalErrorPage\"; + import Routes from \"src/Routes\"; + + import { AuthProvider, useAuth } from \"./auth\"; + + import \"./scaffold.css\"; + import \"./index.css\"; + + const graphQLClientConfig = { + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, + }; + + const App = () => ( + + + + + + + + + + ); + + export default App; + ` + ) + }) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts new file mode 100644 index 000000000000..d5cff2bb93bb --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__codemod_tests__/appImportTransform.test.ts @@ -0,0 +1,13 @@ +describe('fragments possibleTypes import', () => { + test('Default App.tsx', async () => { + await matchFolderTransform('appImportTransform', 'import-simple', { + useJsCodeshift: true, + }) + }) + + test('App.tsx with existing import', async () => { + await matchFolderTransform('appImportTransform', 'existingImport', { + useJsCodeshift: true, + }) + }) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/input/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx new file mode 100644 index 000000000000..f48cd4dd3b94 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/config-simple/output/App.tsx @@ -0,0 +1,28 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/input/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingImport/output/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx new file mode 100644 index 000000000000..d8555cf11797 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/input/App.tsx @@ -0,0 +1,35 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx new file mode 100644 index 000000000000..7f4f8110a38b --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropInline/output/App.tsx @@ -0,0 +1,38 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx new file mode 100644 index 000000000000..98a008ac1ee6 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/input/App.tsx @@ -0,0 +1,37 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx new file mode 100644 index 000000000000..7f4f8110a38b --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariable/output/App.tsx @@ -0,0 +1,38 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + cacheConfig: { + resultCaching: true, + resultCacheMaxSize: 1024, + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx new file mode 100644 index 000000000000..10f479bfe1d2 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/input/App.tsx @@ -0,0 +1,30 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const config = { + uri: '/graphql', +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx new file mode 100644 index 000000000000..7be341895a09 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableCustomName/output/App.tsx @@ -0,0 +1,34 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const config = { + uri: '/graphql', + + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx new file mode 100644 index 000000000000..df01f546ba6d --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/input/App.tsx @@ -0,0 +1,33 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx new file mode 100644 index 000000000000..e80646b99fea --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/existingPropVariableNoCacheConfig/output/App.tsx @@ -0,0 +1,37 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' +import './index.css' + +const graphQLClientConfig = { + uri: '/graphql', + + cacheConfig: { + possibleTypes: possibleTypes.possibleTypes, + }, +} + +const App = () => ( + + + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx new file mode 100644 index 000000000000..5e7beac76c02 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/input/App.tsx @@ -0,0 +1,20 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx new file mode 100644 index 000000000000..1b1ca2d977e9 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__testfixtures__/import-simple/output/App.tsx @@ -0,0 +1,22 @@ +import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' +import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' + +import possibleTypes from 'src/graphql/possibleTypes' + +import FatalErrorPage from 'src/pages/FatalErrorPage' +import Routes from 'src/Routes' + +import './scaffold.css' +import './index.css' + +const App = () => ( + + + + + + + +) + +export default App diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts new file mode 100644 index 000000000000..c60ea5f4100d --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/__tests__/fragmentsHandler.test.ts @@ -0,0 +1,203 @@ +let mockExecutedTaskTitles: Array = [] +let mockSkippedTaskTitles: Array = [] + +jest.mock('fs', () => require('memfs').fs) +jest.mock('node:fs', () => require('memfs').fs) +jest.mock('execa') +// The jscodeshift parts are tested by another test +jest.mock('../runTransform', () => { + return { + runTransform: () => { + return {} + }, + } +}) + +jest.mock('listr2', () => { + return { + // Return a constructor function, since we're calling `new` on Listr + Listr: jest.fn().mockImplementation((tasks: Array) => { + return { + run: async () => { + mockExecutedTaskTitles = [] + mockSkippedTaskTitles = [] + + for (const task of tasks) { + const skip = + typeof task.skip === 'function' ? task.skip : () => task.skip + + if (skip()) { + mockSkippedTaskTitles.push(task.title) + } else { + mockExecutedTaskTitles.push(task.title) + await task.task() + } + } + }, + } + }), + } +}) + +import { vol } from 'memfs' + +import { handler } from '../fragmentsHandler' + +// Set up RWJS_CWD +let original_RWJS_CWD: string | undefined +const FIXTURE_PATH = '/redwood-app' + +beforeAll(() => { + original_RWJS_CWD = process.env.RWJS_CWD + process.env.RWJS_CWD = FIXTURE_PATH +}) + +afterAll(() => { + process.env.RWJS_CWD = original_RWJS_CWD + jest.resetAllMocks() + jest.resetModules() +}) + +test('`fragments = true` is added to redwood.toml', async () => { + vol.fromJSON({ 'redwood.toml': '', 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toMatch( + /fragments = true/ + ) +}) + +test('all tasks are being called', async () => { + vol.fromJSON({ 'redwood.toml': '', 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(mockExecutedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + "Generate possibleTypes.ts", + "Import possibleTypes in App.tsx", + "Add possibleTypes to the GraphQL cache config", + ] + `) +}) + +test('redwood.toml update is skipped if fragments are already enabled', async () => { + vol.fromJSON( + { + 'redwood.toml': '[graphql]\nfragments = true', + 'web/src/App.tsx': '', + }, + FIXTURE_PATH + ) + + await handler({ force: false }) + + expect(mockExecutedTaskTitles).toMatchInlineSnapshot(` + [ + "Generate possibleTypes.ts", + "Import possibleTypes in App.tsx", + "Add possibleTypes to the GraphQL cache config", + ] + `) + + expect(mockSkippedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + ] + `) +}) + +test('redwood.toml update is skipped if fragments are already enabled, together with other settings', async () => { + const toml = ` +[graphql] +foo = "bar" +fragments = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(mockSkippedTaskTitles).toMatchInlineSnapshot(` + [ + "Update Redwood Project Configuration to enable GraphQL Fragments", + ] + `) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(toml) +}) + +test('redwood.toml is updated even if `fragments = true` exists for other sections', async () => { + const toml = ` +[notGraphql] + fragments = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual( + toml + '\n\n[graphql]\n fragments = true' + ) +}) + +test('`fragments = true` is added to existing [graphql] section', async () => { + const toml = ` +[graphql] + + isAwesome = true + +[browser] + open = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(` +[graphql] + + isAwesome = true + fragments = true + +[browser] + open = true +`) +}) + +test("`fragments = true` is not indented if other settings aren't", async () => { + const toml = ` +[graphql] +isAwesome = true + +[browser] +open = true +` + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual(` +[graphql] +isAwesome = true +fragments = true + +[browser] +open = true +`) +}) + +test('[graphql] is last section in redwood.toml', async () => { + const toml = ` +[graphql] + isAwesome = true` + + vol.fromJSON({ 'redwood.toml': toml, 'web/src/App.tsx': '' }, FIXTURE_PATH) + + await handler({ force: false }) + + expect(vol.toJSON()[FIXTURE_PATH + '/redwood.toml']).toEqual( + toml + '\n fragments = true' + ) +}) diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts new file mode 100644 index 000000000000..10ae99afc799 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/appGqlConfigTransform.ts @@ -0,0 +1,211 @@ +import type { + FileInfo, + API, + JSXExpressionContainer, + ObjectExpression, + ObjectProperty, + Identifier, +} from 'jscodeshift' + +function isJsxExpressionContainer(node: any): node is JSXExpressionContainer { + return node.type === 'JSXExpressionContainer' +} + +function isObjectExpression(node: any): node is ObjectExpression { + return node.type === 'ObjectExpression' +} + +function isObjectProperty(node: any): node is ObjectProperty { + return node.type === 'ObjectProperty' +} + +function isIdentifier(node: any): node is Identifier { + return node.type === 'Identifier' +} + +function isPropertyWithName(node: any, name: string) { + return ( + isObjectProperty(node) && + node.key.type === 'Identifier' && + node.key.name === name + ) +} + +export default function transform(file: FileInfo, api: API) { + const j = api.jscodeshift + const root = j(file.source) + + // Find the RedwoodApolloProvider component + const redwoodApolloProvider = root.findJSXElements('RedwoodApolloProvider') + + // Find the graphQLClientConfig prop + const graphQLClientConfigCollection = redwoodApolloProvider.find( + j.JSXAttribute, + { + name: { name: 'graphQLClientConfig' }, + } + ) + + let graphQLClientConfig: ReturnType + + if (graphQLClientConfigCollection.length === 0) { + // No pre-existing graphQLClientConfig prop found + // Creating `graphQLClientConfig={{}}` + graphQLClientConfig = j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.objectExpression([])) + ) + } else { + graphQLClientConfig = graphQLClientConfigCollection.get(0).node + } + + // We now have a graphQLClientConfig prop. Either one the user already had, + // or one we just created. + // Now we want to grab the value of that prop. The value can either be an + // object, like + // graphQLClientConfig={{ cacheConfig: { resultCaching: true } }} + // or it can be a variable, like + // graphQLClientConfig={graphQLClientConfig} + + const graphQLClientConfigExpression = isJsxExpressionContainer( + graphQLClientConfig.value + ) + ? graphQLClientConfig.value.expression + : j.jsxEmptyExpression() + + let graphQLClientConfigVariableName = '' + + if (isIdentifier(graphQLClientConfigExpression)) { + // graphQLClientConfig is already something like + // + // Get the variable name + graphQLClientConfigVariableName = graphQLClientConfigExpression.name + } + + if ( + !graphQLClientConfigVariableName && + !isObjectExpression(graphQLClientConfigExpression) + ) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + "(Could not find a graphQLClientConfigExpression of the correct type, it's a " + + graphQLClientConfigExpression.type + + ')' + ) + } + + if (isObjectExpression(graphQLClientConfigExpression)) { + // graphQLClientConfig is something like + // + + // Find + // `const App = () => { ... }` + // and insert + // `const graphQLClientConfig = { cacheConfig: { resultCaching: true } }` + // before it + graphQLClientConfigVariableName = 'graphQLClientConfig' + root + .find(j.VariableDeclaration, { + declarations: [ + { + type: 'VariableDeclarator', + id: { type: 'Identifier', name: 'App' }, + }, + ], + }) + .insertBefore( + j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier(graphQLClientConfigVariableName), + graphQLClientConfigExpression + ), + ]) + ) + } + + // Find `const graphQLClientConfig = { ... }`. It's going to either be the + // one we just created above, or the one the user already had, with the name + // we found in the `graphQLClientConfig prop expression. + const configVariableDeclarators = root.findVariableDeclarators( + graphQLClientConfigVariableName + ) + + const configExpression = configVariableDeclarators.get(0)?.node.init + + if (!isObjectExpression(configExpression)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(Could not find a graphQLClientConfig variable ObjectExpression)' + ) + } + + // Now we have the value of the graphQLClientConfig const. And we know it's + // an object. Let's see if the object has a `cacheConfig` property. + + let cacheConfig = configExpression.properties.find((prop) => + isPropertyWithName(prop, 'cacheConfig') + ) + + if (!cacheConfig) { + // No `cacheConfig` property. Let's insert one! + cacheConfig = j.objectProperty( + j.identifier('cacheConfig'), + j.objectExpression([]) + ) + configExpression.properties.push(cacheConfig) + } + + if (!isObjectProperty(cacheConfig)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(cacheConfig is not an ObjectProperty)' + ) + } + + const cacheConfigValue = cacheConfig.value + + if (!isObjectExpression(cacheConfigValue)) { + throw new Error( + "Error configuring possibleTypes. You'll have to do it manually. " + + '(cacheConfigValue is not an ObjectExpression)' + ) + } + + // Now we know we have a `graphQLClientConfig` object, and that it has a + // `cacheConfig` property. Let's check if it has a `possibleTypes` property. + // If it doesn't we'll insert one, with the correct value + + const possibleTypes = cacheConfigValue.properties.find((prop) => + isPropertyWithName(prop, 'possibleTypes') + ) + + if (!possibleTypes) { + const property = j.property( + 'init', + j.identifier('possibleTypes'), + j.identifier('possibleTypes.possibleTypes') + ) + // property.shorthand = true + cacheConfigValue.properties.push(property) + } + + // Now we have a proper graphQLClientConfig object stored in a const. Now we + // just need to tell about it by setting the + // `graphQLClientConfig` prop + + // Remove existing graphQLClientConfig prop (if there is one) and then add a + // new one for the variable we created or updated + graphQLClientConfigCollection.remove() + redwoodApolloProvider + .get(0) + .node.openingElement.attributes.push( + j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.identifier(graphQLClientConfigVariableName)) + ) + ) + + return root.toSource() +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts new file mode 100644 index 000000000000..8cec36fc41de --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/appImportTransform.ts @@ -0,0 +1,28 @@ +import type { FileInfo, API } from 'jscodeshift' + +export default function transform(file: FileInfo, api: API) { + const j = api.jscodeshift + const root = j(file.source) + + const possibleTypesImports = root.find(j.ImportDeclaration) + + const hasPossibleTypesImport = possibleTypesImports.some((i) => { + return ( + i.get('source').value.value === 'src/graphql/possibleTypes' || + i.get('source').value.value === './graphql/possibleTypes' + ) + }) + + if (!hasPossibleTypesImport) { + possibleTypesImports + .at(1) + .insertAfter( + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('possibleTypes'))], + j.literal('src/graphql/possibleTypes') + ) + ) + } + + return root.toSource() +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragments.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragments.ts new file mode 100644 index 000000000000..a473168e6f94 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragments.ts @@ -0,0 +1,22 @@ +import type { Argv } from 'yargs' + +export const command = 'fragments' +export const description = 'Set up Fragments for GraphQL' + +export function builder(yargs: Argv) { + return yargs.option('force', { + alias: 'f', + default: false, + description: 'Overwrite existing configuration', + type: 'boolean', + }) +} + +export interface Args { + force: boolean +} + +export async function handler({ force }: Args) { + const { handler } = await import('./fragmentsHandler.js') + return handler({ force }) +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts new file mode 100644 index 000000000000..fb296f120b83 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/fragmentsHandler.ts @@ -0,0 +1,154 @@ +import fs from 'node:fs' +import path from 'node:path' + +import toml from '@iarna/toml' +import execa from 'execa' +import { Listr } from 'listr2' +import { format } from 'prettier' + +import { + colors, + recordTelemetryAttributes, + prettierOptions, +} from '@redwoodjs/cli-helpers' +import { getConfigPath, getPaths } from '@redwoodjs/project-config' + +import type { Args } from './fragments' +import { runTransform } from './runTransform' + +export const command = 'fragments' +export const description = 'Set up Fragments for GraphQL' + +export async function handler({ force }: Args) { + recordTelemetryAttributes({ + command: 'setup graphql fragments', + force, + }) + + const redwoodTomlPath = getConfigPath() + const redwoodTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + // Can't type toml.parse because this PR has not been included in a released yet + // https://github.com/iarna/iarna-toml/commit/5a89e6e65281e4544e23d3dbaf9e8428ed8140e9 + const redwoodTomlObject = toml.parse(redwoodTomlContent) as any + + const tasks = new Listr( + [ + { + title: + 'Update Redwood Project Configuration to enable GraphQL Fragments', + skip: () => { + if (force) { + // Never skip when --force is used + return false + } + + if (redwoodTomlObject?.graphql?.fragments) { + return 'GraphQL Fragments are already enabled.' + } + + return false + }, + task: () => { + const redwoodTomlPath = getConfigPath() + const originalTomlContent = fs.readFileSync(redwoodTomlPath, 'utf-8') + const hasExistingGraphqlSection = !!redwoodTomlObject?.graphql + + let newTomlContent = + originalTomlContent + '\n\n[graphql]\n fragments = true' + + if (hasExistingGraphqlSection) { + const existingGraphqlSetting = Object.keys( + redwoodTomlObject.graphql + ) + + let inGraphqlSection = false + let indentation = '' + let lastGraphqlSettingIndex = 0 + + const tomlLines = originalTomlContent.split('\n') + tomlLines.forEach((line, index) => { + if (line.startsWith('[graphql]')) { + inGraphqlSection = true + lastGraphqlSettingIndex = index + } else { + if (/^\s*\[/.test(line)) { + inGraphqlSection = false + } + } + + if (inGraphqlSection) { + const matches = line.match( + new RegExp(`^(\\s*)(${existingGraphqlSetting})\\s*=`, 'i') + ) + + if (matches) { + indentation = matches[1] + } + + if (/^\s*\w+\s*=/.test(line)) { + lastGraphqlSettingIndex = index + } + } + }) + + tomlLines.splice( + lastGraphqlSettingIndex + 1, + 0, + `${indentation}fragments = true` + ) + + newTomlContent = tomlLines.join('\n') + } + + fs.writeFileSync(redwoodTomlPath, newTomlContent) + }, + }, + { + title: 'Generate possibleTypes.ts', + task: () => { + execa.commandSync('yarn redwood generate types', { stdio: 'ignore' }) + }, + }, + { + title: 'Import possibleTypes in App.tsx', + task: () => { + return runTransform({ + transformPath: path.join(__dirname, 'appImportTransform.js'), + targetPaths: [getPaths().web.app], + }) + }, + }, + { + title: 'Add possibleTypes to the GraphQL cache config', + task: async () => { + const result = await runTransform({ + transformPath: path.join(__dirname, 'appGqlConfigTransform.js'), + targetPaths: [getPaths().web.app], + }) + + if (result.error) { + throw new Error(result.error) + } + + const appPath = getPaths().web.app + const source = fs.readFileSync(appPath, 'utf-8') + + const prettifiedApp = format(source, { + ...prettierOptions(), + parser: 'babel-ts', + }) + + fs.writeFileSync(getPaths().web.app, prettifiedApp, 'utf-8') + }, + }, + ], + { rendererOptions: { collapseSubtasks: false } } + ) + + try { + await tasks.run() + } catch (e: any) { + console.error(colors.error(e.message)) + process.exit(e?.exitCode || 1) + } +} diff --git a/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts b/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts new file mode 100644 index 000000000000..40fb8d78dec3 --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/features/fragments/runTransform.ts @@ -0,0 +1,108 @@ +/** + * A simple wrapper around jscodeshift. + * + * @see jscodeshift JS usage {@link https://github.com/facebook/jscodeshift#usage-js} + * @see prisma/codemods {@link https://github.com/prisma/codemods/blob/main/utils/runner.ts} + * @see react-codemod {@link https://github.com/reactjs/react-codemod/blob/master/bin/cli.js} + */ +import * as jscodeshift from 'jscodeshift/src/Runner' + +// jscodeshift has an `Options` type export we could use here, but currently +// it's just a map of anys, so not really useful. In our case, leaving that +// type out is actually better and leads to stronger typings for `runTransform` +const defaultJscodeshiftOpts = { + // 0, 1 or 2 + verbose: 0, + dry: false, + // Doesn't do anything when running programmatically + print: false, + babel: true, + extensions: 'js,ts,jsx,tsx', + ignorePattern: '**/node_modules/**', + ignoreConfig: [], + runInBand: false, + silent: true, + parser: 'babel', + parserConfig: {}, + // `silent` has to be `false` for this option to do anything + failOnError: false, + stdin: false, +} + +type OptionKeys = keyof typeof defaultJscodeshiftOpts + +export interface RunTransform { + /** Path to the transform */ + transformPath: string + /** Path(s) to the file(s) to transform. Can also be a directory */ + targetPaths: string[] + parser?: 'babel' | 'ts' | 'tsx' + /** jscodeshift options and transform options */ + options?: Partial> +} + +export const runTransform = async ({ + transformPath, + targetPaths, + parser = 'tsx', + options = {}, +}: RunTransform) => { + // We have to do this here for the tests, because jscodeshift.run actually + // spawns a different process. If we use getPaths() in the transform, it + // would not find redwood.toml + if (process.env.NODE_ENV === 'test' && process.env.RWJS_CWD) { + process.chdir(process.env.RWJS_CWD) + } + + // Unfortunately this seems to be the only way to capture output from + // jscodeshift + const { output, stdoutWrite } = patchStdoutWrite() + + const result = await jscodeshift.run(transformPath, targetPaths, { + ...defaultJscodeshiftOpts, + parser, + babel: process.env.NODE_ENV === 'test', + ...options, // Putting options here lets users override all the defaults. + }) + + restoreStdoutWrite(stdoutWrite) + + let error: string | undefined + + if (result.error) { + // If there is an error it's going to be the first line that starts with + // "Error: " + error = output.value + .split('\n') + .find((line) => line.startsWith('Error: ')) + ?.slice('Error: '.length) + } + + return { + ...result, + error, + output: output.value, + } +} + +function patchStdoutWrite() { + const stdoutWrite = process.stdout.write + + const output = { + value: '', + } + + process.stdout.write = (chunk) => { + if (typeof chunk === 'string') { + output.value += chunk + } + + return true + } + + return { output, stdoutWrite } +} + +function restoreStdoutWrite(stdoutWrite: typeof process.stdout.write) { + process.stdout.write = stdoutWrite +} diff --git a/packages/cli/src/commands/setup/graphql/graphql.ts b/packages/cli/src/commands/setup/graphql/graphql.ts new file mode 100644 index 000000000000..aca51785336d --- /dev/null +++ b/packages/cli/src/commands/setup/graphql/graphql.ts @@ -0,0 +1,17 @@ +import terminalLink from 'terminal-link' +import type { Argv } from 'yargs' + +import * as fragmentsCommand from './features/fragments/fragments' + +export const command = 'graphql ' +export const description = 'Set up GraphQL feature support' +export function builder(yargs: Argv) { + return yargs + .command(fragmentsCommand) + .epilogue( + `Also see the ${terminalLink( + 'Redwood CLI Reference', + 'https://redwoodjs.com/docs/cli-commands#setup-graphql' + )}` + ) +} diff --git a/packages/cli/src/jest.codemods.setup.ts b/packages/cli/src/jest.codemods.setup.ts new file mode 100644 index 000000000000..9a77e6be7996 --- /dev/null +++ b/packages/cli/src/jest.codemods.setup.ts @@ -0,0 +1,55 @@ +/* eslint-env node, jest */ + +import { formatCode } from './testUtils' + +// Disable telemetry within framework tests +process.env.REDWOOD_DISABLE_TELEMETRY = 1 + +const fs = require('fs') +const path = require('path') + +globalThis.matchTransformSnapshot = + require('./testUtils/matchTransformSnapshot').matchTransformSnapshot +globalThis.matchInlineTransformSnapshot = + require('./testUtils/matchInlineTransformSnapshot').matchInlineTransformSnapshot +globalThis.matchFolderTransform = + require('./testUtils/matchFolderTransform').matchFolderTransform + +// Custom matcher for checking fixtures using paths +// e.g. expect(transformedPath).toMatchFileContents(expectedPath) +// Mainly so we throw more helpful errors +expect.extend({ + toMatchFileContents( + receivedPath, + expectedPath, + { removeWhitespace } = { removeWhitespace: false } + ) { + let pass = true + let message = '' + try { + let actualOutput = fs.readFileSync(receivedPath, 'utf-8') + let expectedOutput = fs.readFileSync(expectedPath, 'utf-8') + + if (removeWhitespace) { + actualOutput = actualOutput.replace(/\s/g, '') + expectedOutput = expectedOutput.replace(/\s/g, '') + } + + expect(formatCode(actualOutput)).toEqual(formatCode(expectedOutput)) + } catch (e) { + const relativePath = path.relative( + path.join(__dirname, 'src/commands/setup'), + expectedPath + ) + pass = false + message = `${e}\nFile contents do not match for fixture at: \n ${relativePath}` + } + + return { + pass, + message: () => message, + expected: expectedPath, + received: receivedPath, + } + }, +}) diff --git a/packages/cli/src/testLib/cells.ts b/packages/cli/src/testLib/cells.ts new file mode 100644 index 000000000000..6b261601b8d8 --- /dev/null +++ b/packages/cli/src/testLib/cells.ts @@ -0,0 +1,258 @@ +import fs from 'fs' +import path from 'path' + +import { types } from '@babel/core' +import type { ParserPlugin } from '@babel/parser' +import { parse as babelParse } from '@babel/parser' +import traverse from '@babel/traverse' +import fg from 'fast-glob' +import type { + DocumentNode, + FieldNode, + InlineFragmentNode, + OperationDefinitionNode, + OperationTypeNode, +} from 'graphql' +import { parse, visit } from 'graphql' + +import { getPaths } from '@redwoodjs/project-config' + +export const findCells = (cwd: string = getPaths().web.src) => { + const modules = fg.sync('**/*Cell.{js,jsx,ts,tsx}', { + cwd, + absolute: true, + ignore: ['node_modules'], + }) + return modules.filter(isCellFile) +} + +export const isCellFile = (p: string) => { + const { dir, name } = path.parse(p) + + // If the path isn't on the web side it cannot be a cell + if (!isFileInsideFolder(p, getPaths().web.src)) { + return false + } + + // A Cell must be a directory named module. + if (!dir.endsWith(name)) { + return false + } + + const ast = fileToAst(p) + + // A Cell should not have a default export. + if (hasDefaultExport(ast)) { + return false + } + + // A Cell must export QUERY and Success. + const exports = getNamedExports(ast) + const exportedQUERY = exports.findIndex((v) => v.name === 'QUERY') !== -1 + const exportedSuccess = exports.findIndex((v) => v.name === 'Success') !== -1 + if (!exportedQUERY && !exportedSuccess) { + return false + } + + return true +} + +export const isFileInsideFolder = (filePath: string, folderPath: string) => { + const { dir } = path.parse(filePath) + const relativePathFromFolder = path.relative(folderPath, dir) + if ( + !relativePathFromFolder || + relativePathFromFolder.startsWith('..') || + path.isAbsolute(relativePathFromFolder) + ) { + return false + } else { + return true + } +} + +export const hasDefaultExport = (ast: types.Node): boolean => { + let exported = false + traverse(ast, { + ExportDefaultDeclaration() { + exported = true + return + }, + }) + return exported +} + +interface NamedExports { + name: string + type: 're-export' | 'variable' | 'function' | 'class' +} + +export const getNamedExports = (ast: types.Node): NamedExports[] => { + const namedExports: NamedExports[] = [] + traverse(ast, { + ExportNamedDeclaration(path) { + // Re-exports from other modules + // Eg: export { a, b } from './module' + const specifiers = path.node?.specifiers + if (specifiers.length) { + for (const s of specifiers) { + const id = s.exported as types.Identifier + namedExports.push({ + name: id.name, + type: 're-export', + }) + } + return + } + + const declaration = path.node.declaration + if (!declaration) { + return + } + + if (declaration.type === 'VariableDeclaration') { + const id = declaration.declarations[0].id as types.Identifier + namedExports.push({ + name: id.name as string, + type: 'variable', + }) + } else if (declaration.type === 'FunctionDeclaration') { + namedExports.push({ + name: declaration?.id?.name as string, + type: 'function', + }) + } else if (declaration.type === 'ClassDeclaration') { + namedExports.push({ + name: declaration?.id?.name, + type: 'class', + }) + } + }, + }) + + return namedExports +} + +export const fileToAst = (filePath: string): types.Node => { + const code = fs.readFileSync(filePath, 'utf-8') + + // use jsx plugin for web files, because in JS, the .jsx extension is not used + const isJsxFile = + path.extname(filePath).match(/[jt]sx$/) || + isFileInsideFolder(filePath, getPaths().web.base) + + const plugins = [ + 'typescript', + 'nullishCoalescingOperator', + 'objectRestSpread', + isJsxFile && 'jsx', + ].filter(Boolean) as ParserPlugin[] + + try { + return babelParse(code, { + sourceType: 'module', + plugins, + }) + } catch (e: any) { + // console.error(chalk.red(`Error parsing: ${filePath}`)) + console.error(e) + throw new Error(e?.message) // we throw, so typescript doesn't complain about returning + } +} + +export const getCellGqlQuery = (ast: types.Node) => { + let cellQuery: string | undefined = undefined + traverse(ast, { + ExportNamedDeclaration({ node }) { + if ( + node.exportKind === 'value' && + types.isVariableDeclaration(node.declaration) + ) { + const exportedQueryNode = node.declaration.declarations.find((d) => { + return ( + types.isIdentifier(d.id) && + d.id.name === 'QUERY' && + types.isTaggedTemplateExpression(d.init) + ) + }) + + if (exportedQueryNode) { + const templateExpression = + exportedQueryNode.init as types.TaggedTemplateExpression + + cellQuery = templateExpression.quasi.quasis[0].value.raw + } + } + return + }, + }) + + return cellQuery +} + +export const parseGqlQueryToAst = (gqlQuery: string) => { + const ast = parse(gqlQuery) + return parseDocumentAST(ast) +} + +export const parseDocumentAST = (document: DocumentNode) => { + const operations: Array = [] + + visit(document, { + OperationDefinition(node: OperationDefinitionNode) { + const fields: any[] = [] + + node.selectionSet.selections.forEach((field) => { + fields.push(getFields(field as FieldNode)) + }) + + operations.push({ + operation: node.operation, + name: node.name?.value, + fields, + }) + }, + }) + + return operations +} + +interface Operation { + operation: OperationTypeNode + name: string | undefined + fields: Array +} + +interface Field { + string: Array +} + +const getFields = (field: FieldNode): any => { + // base + if (!field.selectionSet) { + return field.name.value + } else { + const obj: Record = { + [field.name.value]: [], + } + + const lookAtFieldNode = (node: FieldNode | InlineFragmentNode): void => { + node.selectionSet?.selections.forEach((subField) => { + switch (subField.kind) { + case 'Field': + obj[field.name.value].push(getFields(subField as FieldNode)) + break + case 'FragmentSpread': + // TODO: Maybe this will also be needed, right now it's accounted for to not crash in the tests + break + case 'InlineFragment': + lookAtFieldNode(subField) + } + }) + } + + lookAtFieldNode(field) + + return obj + } +} diff --git a/packages/cli/src/testLib/fetchFileFromTemplate.ts b/packages/cli/src/testLib/fetchFileFromTemplate.ts new file mode 100644 index 000000000000..156881e53165 --- /dev/null +++ b/packages/cli/src/testLib/fetchFileFromTemplate.ts @@ -0,0 +1,11 @@ +import { fetch } from '@whatwg-node/fetch' + +/** + * @param tag should be something like 'v0.42.1' + * @param file should be something like 'prettier.config.js', 'api/src/index.ts', 'web/src/index.ts' + */ +export default async function fetchFileFromTemplate(tag: string, file: string) { + const URL = `https://raw.githubusercontent.com/redwoodjs/redwood/${tag}/packages/create-redwood-app/template/${file}` + const res = await fetch(URL) + return res.text() +} diff --git a/packages/cli/src/testLib/getFilesWithPattern.ts b/packages/cli/src/testLib/getFilesWithPattern.ts new file mode 100644 index 000000000000..f0a37290679d --- /dev/null +++ b/packages/cli/src/testLib/getFilesWithPattern.ts @@ -0,0 +1,33 @@ +/** + * Uses ripgrep to search files for a pattern, + * returning the name of the files that contain the pattern. + * + * @see {@link https://github.com/burntsushi/ripgrep} + */ +import { rgPath } from '@vscode/ripgrep' +import execa from 'execa' + +const getFilesWithPattern = ({ + pattern, + filesToSearch, +}: { + pattern: string + filesToSearch: string[] +}) => { + try { + const { stdout } = execa.sync(rgPath, [ + '--files-with-matches', + pattern, + ...filesToSearch, + ]) + + /** + * Return an array of files that contain the pattern + */ + return stdout.toString().split('\n') + } catch (e) { + return [] + } +} + +export default getFilesWithPattern diff --git a/packages/cli/src/testLib/getRootPackageJSON.ts b/packages/cli/src/testLib/getRootPackageJSON.ts new file mode 100644 index 000000000000..f68c3a52a605 --- /dev/null +++ b/packages/cli/src/testLib/getRootPackageJSON.ts @@ -0,0 +1,16 @@ +import fs from 'fs' +import path from 'path' + +import { getPaths } from '@redwoodjs/project-config' + +const getRootPackageJSON = () => { + const rootPackageJSONPath = path.join(getPaths().base, 'package.json') + + const rootPackageJSON = JSON.parse( + fs.readFileSync(rootPackageJSONPath, 'utf8') + ) + + return [rootPackageJSON, rootPackageJSONPath] +} + +export default getRootPackageJSON diff --git a/packages/cli/src/testLib/isTSProject.ts b/packages/cli/src/testLib/isTSProject.ts new file mode 100644 index 000000000000..51fb3a1fa5a6 --- /dev/null +++ b/packages/cli/src/testLib/isTSProject.ts @@ -0,0 +1,10 @@ +import fg from 'fast-glob' + +import { getPaths } from '@redwoodjs/project-config' + +const isTSProject = + fg.sync(`${getPaths().base}/**/tsconfig.json`, { + ignore: ['**/node_modules/**'], + }).length > 0 + +export default isTSProject diff --git a/packages/cli/src/testLib/prettify.ts b/packages/cli/src/testLib/prettify.ts new file mode 100644 index 000000000000..7a2927f24e85 --- /dev/null +++ b/packages/cli/src/testLib/prettify.ts @@ -0,0 +1,24 @@ +import path from 'path' + +import { format } from 'prettier' + +import { getPaths } from '@redwoodjs/project-config' + +const getPrettierConfig = () => { + try { + return require(path.join(getPaths().base, 'prettier.config.js')) + } catch (e) { + return undefined + } +} + +const prettify = (code: string, options: Record = {}) => + format(code, { + singleQuote: true, + semi: false, + ...getPrettierConfig(), + parser: 'babel', + ...options, + }) + +export default prettify diff --git a/packages/cli/src/testLib/runTransform.ts b/packages/cli/src/testLib/runTransform.ts new file mode 100644 index 000000000000..96a52c2b8dba --- /dev/null +++ b/packages/cli/src/testLib/runTransform.ts @@ -0,0 +1,68 @@ +/** + * A simple wrapper around the jscodeshift. + * + * @see jscodeshift CLI's usage {@link https://github.com/facebook/jscodeshift#usage-cli} + * @see prisma/codemods {@link https://github.com/prisma/codemods/blob/main/utils/runner.ts} + * @see react-codemod {@link https://github.com/reactjs/react-codemod/blob/master/bin/cli.js} + */ +import * as jscodeshift from 'jscodeshift/src/Runner' + +const defaultJscodeshiftOpts = { + verbose: 0, + dry: false, + print: false, + babel: true, + extensions: 'js', + ignorePattern: '**/node_modules/**', + ignoreConfig: [], + runInBand: false, + silent: false, + parser: 'babel', + parserConfig: {}, + failOnError: false, + stdin: false, +} + +export interface RunTransform { + /** + * Path to the transform. + */ + transformPath: string + /** + * Path(s) to the file(s) to transform. Can also be a directory. + */ + targetPaths: string[] + parser?: 'babel' | 'ts' | 'tsx' + /** + * jscodeshift options and transform options. + */ + options?: Partial> +} + +export const runTransform = async ({ + transformPath, + targetPaths, + parser = 'tsx', + options = {}, +}: RunTransform) => { + try { + // We have to do this here for the tests, because jscodeshift.run actually spawns + // a different process. If we use getPaths() in the transform, it would not find redwood.toml + if (process.env.NODE_ENV === 'test' && process.env.RWJS_CWD) { + process.chdir(process.env.RWJS_CWD) + } + + await jscodeshift.run(transformPath, targetPaths, { + ...defaultJscodeshiftOpts, + parser, + babel: process.env.NODE_ENV === 'test', + ...options, // Putting options here lets them override all the defaults. + }) + } catch (e: any) { + console.error('Transform Error', e.message) + + throw new Error('Failed to invoke transform') + } +} + +export default runTransform diff --git a/packages/cli/src/testLib/ts2js.ts b/packages/cli/src/testLib/ts2js.ts new file mode 100644 index 000000000000..926f47fd1b19 --- /dev/null +++ b/packages/cli/src/testLib/ts2js.ts @@ -0,0 +1,30 @@ +import { transform } from '@babel/core' + +import { getPaths } from '@redwoodjs/project-config' + +import prettify from './prettify' + +const ts2js = (file: string) => { + const result = transform(file, { + cwd: getPaths().base, + configFile: false, + plugins: [ + [ + '@babel/plugin-transform-typescript', + { + isTSX: true, + allExtensions: true, + }, + ], + ], + retainLines: true, + }) + + if (result?.code) { + return prettify(result.code) + } + + return null +} + +export default ts2js diff --git a/packages/cli/src/testUtils/index.ts b/packages/cli/src/testUtils/index.ts new file mode 100644 index 000000000000..257868193ef3 --- /dev/null +++ b/packages/cli/src/testUtils/index.ts @@ -0,0 +1,21 @@ +import fs from 'fs' +import path from 'path' + +import { format } from 'prettier' +import parserBabel from 'prettier/parser-babel' +import tempy from 'tempy' + +export const formatCode = (code: string) => { + return format(code, { + parser: 'babel-ts', + plugins: [parserBabel], + }) +} + +export const createProjectMock = () => { + const tempDir = tempy.directory() + // add fake redwood.toml + fs.closeSync(fs.openSync(path.join(tempDir, 'redwood.toml'), 'w')) + + return tempDir +} diff --git a/packages/cli/src/testUtils/matchFolderTransform.ts b/packages/cli/src/testUtils/matchFolderTransform.ts new file mode 100644 index 000000000000..178e6d721794 --- /dev/null +++ b/packages/cli/src/testUtils/matchFolderTransform.ts @@ -0,0 +1,131 @@ +import path from 'path' + +import fg from 'fast-glob' +import fse from 'fs-extra' + +import runTransform from '../testLib/runTransform' + +import { createProjectMock } from './index' + +type Options = { + removeWhitespace?: boolean + targetPathsGlob?: string + /** + * Use this option, when you want to run a codemod that uses jscodeshift + * as well as modifies file names. e.g. convertJsToJsx + */ + useJsCodeshift?: boolean +} + +type MatchFolderTransformFunction = ( + transformFunctionOrName: (() => any) | string, + fixtureName: string, + options?: Options +) => Promise + +export const matchFolderTransform: MatchFolderTransformFunction = async ( + transformFunctionOrName, + fixtureName, + { + removeWhitespace = false, + targetPathsGlob = '**/*', + useJsCodeshift = false, + } = {} +) => { + const tempDir = createProjectMock() + + // Override paths used in getPaths() utility func + const original_RWJS_CWD = process.env.RWJS_CWD + const originalCwd = process.cwd() + process.env.RWJS_CWD = tempDir + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + if (!testPath) { + throw new Error('Could not find test path') + } + + const fixtureFolder = path.join( + testPath, + '../../__testfixtures__', + fixtureName + ) + + const fixtureInputDir = path.join(fixtureFolder, 'input') + const fixtureOutputDir = path.join(fixtureFolder, 'output') + + // Step 1: Copy files recursively from fixture folder to temp + fse.copySync(fixtureInputDir, tempDir, { + overwrite: true, + }) + + const GLOB_CONFIG = { + absolute: false, + dot: true, + ignore: ['redwood.toml', '**/*.DS_Store'], // ignore the fake redwood.toml added for getPaths + } + + // Step 2: Run transform against temp dir + if (useJsCodeshift) { + if (typeof transformFunctionOrName !== 'string') { + throw new Error( + 'When running matchFolderTransform with useJsCodeshift, transformFunction must be a string (file name of jscodeshift transform)' + ) + } + const transformName = transformFunctionOrName + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + const targetPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: tempDir, + }) + + // So that the transform can use getPaths() utility func + // This is used inside the runTransform function + process.env.RWJS_CWD = tempDir + + await runTransform({ + transformPath, + targetPaths: targetPaths.map((p) => path.join(tempDir, p)), + }) + } else { + if (typeof transformFunctionOrName !== 'function') { + throw new Error( + 'transformFunction must be a function, if useJsCodeshift set to false' + ) + } + const transformFunction = transformFunctionOrName + await transformFunction() + } + + const transformedPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: tempDir, + }) + + const expectedPaths = fg.sync(targetPathsGlob, { + ...GLOB_CONFIG, + cwd: fixtureOutputDir, + }) + + // Step 3: Check output paths + expect(transformedPaths).toEqual(expectedPaths) + + // Step 4: Check contents of each file + transformedPaths.forEach((transformedFile) => { + const actualPath = path.join(tempDir, transformedFile) + const expectedPath = path.join(fixtureOutputDir, transformedFile) + + expect(actualPath).toMatchFileContents(expectedPath, { removeWhitespace }) + }) + + if (original_RWJS_CWD) { + process.env.RWJS_CWD = original_RWJS_CWD + } else { + delete process.env.RWJS_CWD + } + process.chdir(originalCwd) +} diff --git a/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts b/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts new file mode 100644 index 000000000000..fc302e947a05 --- /dev/null +++ b/packages/cli/src/testUtils/matchInlineTransformSnapshot.ts @@ -0,0 +1,46 @@ +import fs from 'fs' +import path from 'path' + +import tempy from 'tempy' + +import runTransform from '../testLib/runTransform' + +import { formatCode } from './index' + +export const matchInlineTransformSnapshot = async ( + transformName: string, + fixtureCode: string, + expectedCode: string, + parser: 'ts' | 'tsx' | 'babel' = 'tsx' +) => { + const tempFilePath = tempy.file() + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + if (!testPath) { + throw new Error('Could not find test path') + } + + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + // Step 1: Write passed in code to a temp file + fs.writeFileSync(tempFilePath, fixtureCode) + + // Step 2: Run transform against temp file + await runTransform({ + transformPath, + targetPaths: [tempFilePath], + options: { + verbose: 1, + }, + parser, + }) + + // Step 3: Read modified file and snapshot + const transformedContent = fs.readFileSync(tempFilePath, 'utf-8') + + expect(formatCode(transformedContent)).toEqual(formatCode(expectedCode)) +} diff --git a/packages/cli/src/testUtils/matchTransformSnapshot.ts b/packages/cli/src/testUtils/matchTransformSnapshot.ts new file mode 100644 index 000000000000..a8384bea4411 --- /dev/null +++ b/packages/cli/src/testUtils/matchTransformSnapshot.ts @@ -0,0 +1,60 @@ +import fs from 'fs' +import path from 'path' + +import tempy from 'tempy' + +import runTransform from '../testLib/runTransform' + +import { formatCode } from './index' + +export interface MatchTransformSnapshotFunction { + (transformName: string, fixtureName?: string, parser?: 'ts' | 'tsx'): void +} + +export const matchTransformSnapshot: MatchTransformSnapshotFunction = async ( + transformName, + fixtureName, + parser +) => { + const tempFilePath = tempy.file() + + // Looks up the path of the caller + const testPath = expect.getState().testPath + + if (!testPath) { + throw new Error('Could not find test path') + } + + // Use require.resolve, so we can pass in ts/js/tsx/jsx without specifying + const fixturePath = require.resolve( + path.join(testPath, '../../__testfixtures__', `${fixtureName}.input`) + ) + + const transformPath = require.resolve( + path.join(testPath, '../../', transformName) + ) + + // Step 1: Copy fixture to temp file + fs.copyFileSync(fixturePath, tempFilePath, fs.constants.COPYFILE_FICLONE) + + // Step 2: Run transform against temp file + await runTransform({ + transformPath, + targetPaths: [tempFilePath], + parser, + options: { + verbose: 1, + print: true, + }, + }) + + // Step 3: Read modified file and snapshot + const transformedContent = fs.readFileSync(tempFilePath, 'utf-8') + + const expectedOutput = fs.readFileSync( + fixturePath.replace('.input.', '.output.'), + 'utf-8' + ) + + expect(formatCode(transformedContent)).toEqual(formatCode(expectedOutput)) +} diff --git a/packages/cli/testUtils.d.ts b/packages/cli/testUtils.d.ts new file mode 100644 index 000000000000..8ebdea3fcfa4 --- /dev/null +++ b/packages/cli/testUtils.d.ts @@ -0,0 +1,86 @@ +/* eslint-disable no-var */ +// For some reason, testutils types aren't exported.... I just dont... +// Partially copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/jscodeshift/src/testUtils.d.ts +declare module 'jscodeshift/dist/testUtils' { + import type { Transform, Options, Parser } from 'jscodeshift' + function defineTest( + dirName: string, + transformName: string, + options?: Options | null, + testFilePrefix?: string | null, + testOptions?: { + parser: 'ts' | 'tsx' | 'js' | 'jsx' | Parser + } + ): () => any + + function defineInlineTest( + module: Transform, + options: Options, + inputSource: string, + expectedOutputSource: string, + testName?: string + ): () => any + + function runInlineTest( + module: Transform, + options: Options, + input: { + path?: string + source: string + }, + expectedOutput: string, + testOptions?: TestOptions + ): string +} + +// @NOTE: Redefining types, because they get lost when importing from the testUtils file +type MatchTransformSnapshotFunction = ( + transformName: string, + fixtureName?: string, + parser?: 'ts' | 'tsx' +) => Promise + +type MatchFolderTransformFunction = ( + transformFunctionOrName: (() => any) | string, + fixtureName: string, + options?: { + removeWhitespace?: boolean + targetPathsGlob?: string + /** + * Use this option, when you want to run a codemod that uses jscodeshift + * as well as modifies file names. e.g. convertJsToJsx + */ + useJsCodeshift?: boolean + } +) => Promise + +type MatchInlineTransformSnapshotFunction = ( + transformName: string, + fixtureCode: string, + expectedCode: string, + parser: 'ts' | 'tsx' | 'babel' = 'tsx' +) => Promise + +// These files gets loaded in jest setup, so becomes available globally in tests +declare global { + var matchTransformSnapshot: MatchTransformSnapshotFunction + var matchInlineTransformSnapshot: MatchInlineTransformSnapshotFunction + var matchFolderTransform: MatchFolderTransformFunction + + namespace jest { + interface Matchers { + toMatchFileContents( + fixturePath: string, + { removeWhitespace }: { removeWhitespace: boolean } + ): R + } + } + + namespace NodeJS { + interface ProcessEnv { + REDWOOD_DISABLE_TELEMETRY: number + } + } +} + +export {} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 000000000000..a12c30f06606 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.compilerOption.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["src", "./testUtils.d.ts"], + "exclude": ["**/__testfixtures__"] +} diff --git a/packages/codemods/README.md b/packages/codemods/README.md index 5d692de07a41..83ad0b7d07e4 100644 --- a/packages/codemods/README.md +++ b/packages/codemods/README.md @@ -255,3 +255,45 @@ RWJS_CWD=/path/to/rw-project node "./packages/codemods/dist/codemods.js" {your-c > # Assuming in packages/codemods/ > watch -p "./src/**/*" -c "yarn build" > ``` + +4. Debugging + +If you have a node and want to see/confirm what you're working with you can +pass it to jscodeshift and then call `.toSource()` on it. + +** Example ** + +``` +const j = api.jscodeshift +const root = j(file.source) + +const graphQLClientConfig = j.jsxAttribute( + j.jsxIdentifier('graphQLClientConfig'), + j.jsxExpressionContainer(j.objectExpression([])) +) + +console.log('graphQLClientConfig prop', j(graphQLClientConfig).toSource()) +// Will log: +// graphQLClientConfig={{}} +``` + +If you have a collection of nodes you first need to get just one of the +collection items, and then get the node out of that. + +** Example ** + +``` +const j = api.jscodeshift +const root = j(file.source) + +const redwoodApolloProvider = root.findJSXElements('RedwoodApolloProvider') + +console.log( + '', + j(redwoodApolloProvider.get(0).node).toSource() +) +// Will log: +// +// +// +```