From 80e4a4f56909f525eb6735aa93de4ecf42db3089 Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Mon, 8 Jan 2024 05:08:55 -0500 Subject: [PATCH] chore: Improved Possible Types DX and Framework integration for GraphQL Fragments with Union and Interface support (#9594) Co-authored-by: Tobbe Lundberg --- .github/workflows/ci.yml | 90 ++++ .../{web => }/.editorconfig | 0 .../fragment-test-project/.env.defaults | 19 + .../fragment-test-project/.env.example | 4 + __fixtures__/fragment-test-project/.gitignore | 22 + .../fragment-test-project/.redwood/README.md | 44 ++ .../.redwood/schema.graphql | 102 ---- .../.vscode/extensions.json | 16 + .../fragment-test-project/.vscode/launch.json | 56 ++ .../.vscode/settings.json | 11 + .../fragment-test-project/.vscode/tasks.json | 29 + .../fragment-test-project/.yarnrc.yml | 15 + __fixtures__/fragment-test-project/README.md | 124 ++++- .../migration.sql | 34 ++ .../migration.sql | 8 + .../migration.sql | 28 + .../api/db/migrations/migration_lock.toml | 3 + .../api/db/schema.prisma | 59 ++- .../fragment-test-project/api/package.json | 5 +- .../fragment-test-project/api/src/.keep | 0 .../api/src/__tests__/context.test.ts | 14 + .../requireAuth/requireAuth.test.ts | 4 +- .../api/src/functions/auth.ts | 202 +++++++ .../api/src/functions/graphql.ts | 6 + .../api/src/graphql/contacts.sdl.ts | 32 ++ .../api/src/graphql/posts.sdl.ts | 33 ++ .../api/src/graphql/produces.sdl.ts | 54 ++ .../api/src/graphql/stalls.sdl.ts | 27 +- .../api/src/graphql/users.sdl.ts | 25 + .../fragment-test-project/api/src/lib/auth.ts | 128 ++++- .../fragment-test-project/api/src/lib/db.ts | 4 +- .../api/src/lib/persistedOperations.json | 4 - .../services/contacts/contacts.scenarios.ts | 12 + .../src/services/contacts/contacts.test.ts | 59 +++ .../api/src/services/contacts/contacts.ts | 37 ++ .../api/src/services/fruit.ts | 29 - .../api/src/services/groceries.ts | 6 +- .../api/src/services/posts/posts.scenarios.ts | 38 ++ .../api/src/services/posts/posts.test.ts | 55 ++ .../api/src/services/posts/posts.ts | 42 ++ .../services/produces/produces.scenarios.ts | 27 + .../src/services/produces/produces.test.ts | 67 +++ .../api/src/services/produces/produces.ts | 47 ++ .../api/src/services/stalls.ts | 21 - .../src/services/stalls/stalls.scenarios.ts | 11 + .../api/src/services/stalls/stalls.test.ts | 50 ++ .../api/src/services/stalls/stalls.ts | 45 ++ .../api/src/services/users/users.scenarios.ts | 26 + .../api/src/services/users/users.test.ts | 10 + .../api/src/services/users/users.ts | 17 + .../api/src/services/vegetables.ts | 22 - .../fragment-test-project/jest.config.js | 8 + .../fragment-test-project/package.json | 23 + .../fragment-test-project/prettier.config.js | 20 + .../fragment-test-project/redwood.toml | 18 +- .../fragment-test-project/scripts/seed.ts | 195 +++++++ .../scripts/tsconfig.json | 42 ++ .../web/config/postcss.config.js | 8 + .../web/config/tailwind.config.js | 8 + .../fragment-test-project/web/package.json | 22 +- .../web/public/README.md | 35 ++ .../web/public/favicon.png | Bin 0 -> 1741 bytes .../web/public/robots.txt | 2 + .../fragment-test-project/web/src/App.tsx | 20 +- .../web/src/Redwood.stories.mdx | 15 + .../fragment-test-project/web/src/Routes.tsx | 41 +- .../fragment-test-project/web/src/auth.ts | 5 + .../src/components/Author/Author.stories.tsx | 35 ++ .../web/src/components/Author/Author.test.tsx | 19 + .../web/src/components/Author/Author.tsx | 16 + .../components/AuthorCell/AuthorCell.mock.ts | 8 + .../AuthorCell/AuthorCell.stories.tsx | 35 ++ .../components/AuthorCell/AuthorCell.test.tsx | 42 ++ .../src/components/AuthorCell/AuthorCell.tsx | 39 ++ .../components/BlogPost/BlogPost.stories.tsx | 26 + .../src/components/BlogPost/BlogPost.test.tsx | 14 + .../web/src/components/BlogPost/BlogPost.tsx | 41 ++ .../BlogPostCell/BlogPostCell.mock.ts | 15 + .../BlogPostCell/BlogPostCell.stories.tsx | 35 ++ .../BlogPostCell/BlogPostCell.test.tsx | 42 ++ .../components/BlogPostCell/BlogPostCell.tsx | 46 ++ .../BlogPostsCell/BlogPostsCell.mock.ts | 41 ++ .../BlogPostsCell/BlogPostsCell.stories.tsx | 35 ++ .../BlogPostsCell/BlogPostsCell.test.tsx | 42 ++ .../BlogPostsCell/BlogPostsCell.tsx | 43 ++ .../web/src/components/{Card => }/Card.tsx | 0 .../components/Contact/Contact/Contact.tsx | 98 ++++ .../Contact/ContactCell/ContactCell.tsx | 40 ++ .../Contact/ContactForm/ContactForm.tsx | 101 ++++ .../components/Contact/Contacts/Contacts.tsx | 102 ++++ .../Contact/ContactsCell/ContactsCell.tsx | 48 ++ .../EditContactCell/EditContactCell.tsx | 89 ++++ .../Contact/NewContact/NewContact.tsx | 55 ++ .../components/{Fruit.tsx => FruitInfo.tsx} | 12 +- .../Post/EditPostCell/EditPostCell.tsx | 78 +++ .../src/components/Post/NewPost/NewPost.tsx | 52 ++ .../web/src/components/Post/Post/Post.tsx | 98 ++++ .../src/components/Post/PostCell/PostCell.tsx | 38 ++ .../src/components/Post/PostForm/PostForm.tsx | 102 ++++ .../web/src/components/Post/Posts/Posts.tsx | 102 ++++ .../components/Post/PostsCell/PostsCell.tsx | 45 ++ .../{Produce.tsx => ProduceInfo.tsx} | 8 +- .../components/{Stall.tsx => StallInfo.tsx} | 6 +- .../{Vegetable.tsx => VegetableInfo.tsx} | 12 +- .../WaterfallBlogPostCell.mock.ts | 15 + .../WaterfallBlogPostCell.stories.tsx | 35 ++ .../WaterfallBlogPostCell.test.tsx | 42 ++ .../WaterfallBlogPostCell.tsx | 67 +++ .../web/src/entry.client.tsx | 6 + .../web/src/graphql/persistedOperations.json | 4 - .../web/src/graphql/possible-types.ts | 12 - .../web/src/graphql/possibleTypes.ts | 30 +- .../fragment-test-project/web/src/index.css | 13 + .../layouts/BlogLayout/BlogLayout.stories.tsx | 13 + .../layouts/BlogLayout/BlogLayout.test.tsx | 14 + .../web/src/layouts/BlogLayout/BlogLayout.tsx | 80 +++ .../layouts/ScaffoldLayout/ScaffoldLayout.tsx | 37 ++ .../web/src/lib/formatters.test.tsx | 192 +++++++ .../web/src/lib/formatters.tsx | 58 ++ .../src/pages/AboutPage/AboutPage.stories.tsx | 13 + .../src/pages/AboutPage/AboutPage.test.tsx | 14 + .../web/src/pages/AboutPage/AboutPage.tsx | 13 + .../BlogPostPage/BlogPostPage.routeHooks.ts | 5 + .../BlogPostPage/BlogPostPage.stories.tsx | 17 + .../pages/BlogPostPage/BlogPostPage.test.tsx | 14 + .../src/pages/BlogPostPage/BlogPostPage.tsx | 20 + .../pages/Contact/ContactPage/ContactPage.tsx | 11 + .../Contact/ContactsPage/ContactsPage.tsx | 7 + .../EditContactPage/EditContactPage.tsx | 11 + .../Contact/NewContactPage/NewContactPage.tsx | 7 + .../ContactUsPage/ContactUsPage.stories.tsx | 13 + .../ContactUsPage/ContactUsPage.test.tsx | 14 + .../src/pages/ContactUsPage/ContactUsPage.tsx | 108 ++++ .../pages/DoublePage/DoublePage.stories.tsx | 13 + .../src/pages/DoublePage/DoublePage.test.tsx | 14 + .../web/src/pages/DoublePage/DoublePage.tsx | 25 + .../ForgotPasswordPage/ForgotPasswordPage.tsx | 94 ++++ .../GroceriesPage/GroceriesPage.stories.tsx | 13 + .../GroceriesPage/GroceriesPage.test.tsx | 14 + .../src/pages/GroceriesPage/GroceriesPage.tsx | 32 +- .../src/pages/HomePage/HomePage.stories.tsx | 13 + .../web/src/pages/HomePage/HomePage.test.tsx | 14 + .../web/src/pages/HomePage/HomePage.tsx | 10 + .../web/src/pages/LoginPage/LoginPage.tsx | 134 +++++ .../pages/Post/EditPostPage/EditPostPage.tsx | 11 + .../pages/Post/NewPostPage/NewPostPage.tsx | 7 + .../web/src/pages/Post/PostPage/PostPage.tsx | 11 + .../src/pages/Post/PostsPage/PostsPage.tsx | 7 + .../pages/ProfilePage/ProfilePage.stories.tsx | 13 + .../pages/ProfilePage/ProfilePage.test.tsx | 21 + .../web/src/pages/ProfilePage/ProfilePage.tsx | 55 ++ .../ResetPasswordPage/ResetPasswordPage.tsx | 121 +++++ .../web/src/pages/SignupPage/SignupPage.tsx | 148 ++++++ .../WaterfallPage/WaterfallPage.routeHooks.ts | 3 + .../WaterfallPage/WaterfallPage.stories.tsx | 17 + .../WaterfallPage/WaterfallPage.test.tsx | 14 + .../src/pages/WaterfallPage/WaterfallPage.tsx | 11 + .../web/src/scaffold.css | 243 +++++++++ .../fragment-test-project/web/tsconfig.json | 5 +- .../web/types/graphql.d.ts | 353 ++++++++++++- .../web/types/possible-types.ts | 20 - .../fragment-test-project/web/vite.config.ts | 27 +- __fixtures__/test-project/scripts/seed.ts | 26 +- package.json | 1 + packages/cli/src/commands/buildHandler.js | 19 + .../templates/js/web/src/App.jsx | 9 +- .../js/web/src/graphql/possibleTypes.js | 5 - .../templates/ts/web/src/App.tsx | 9 +- .../ts/web/src/graphql/possibleTypes.ts | 11 - .../tests/templates.test.js | 4 - .../internal/src/generate/clientPreset.ts | 1 + packages/vite/src/index.ts | 1 - packages/web/src/apollo/index.tsx | 2 + .../web/src/components/cell/createCell.tsx | 4 +- .../fragments-dev/playwright.config.ts | 25 + .../fragments-dev/tests/fragments.spec.ts | 17 + .../fragments-serve/playwright.config.ts | 21 + .../fragments-serve/tests/fragments.spec.ts | 17 + tasks/test-project/add-gql-fragments.ts | 29 + tasks/test-project/codemods/groceriesPage.ts | 208 ++++++++ tasks/test-project/codemods/models.js | 28 +- tasks/test-project/codemods/producesSdl.ts | 5 + tasks/test-project/codemods/seed.js | 26 +- tasks/test-project/codemods/seedFragments.ts | 83 +++ .../rebuild-fragments-test-project-fixture.ts | 495 ++++++++++++++++++ .../rebuild-test-project-fixture.ts | 2 +- tasks/test-project/tasks.js | 122 ++++- .../templates/api/groceries.sdl.ts | 49 ++ tasks/test-project/templates/api/groceries.ts | 32 ++ tasks/test-project/templates/web/Card.tsx | 9 + .../test-project/templates/web/FruitInfo.tsx | 37 ++ .../templates/web/ProduceInfo.tsx | 28 + .../test-project/templates/web/StallInfo.tsx | 26 + .../templates/web/VegetableInfo.tsx | 37 ++ tasks/test-project/tui-tasks.js | 118 +++++ 195 files changed, 7373 insertions(+), 426 deletions(-) rename __fixtures__/fragment-test-project/{web => }/.editorconfig (100%) create mode 100644 __fixtures__/fragment-test-project/.env.defaults create mode 100644 __fixtures__/fragment-test-project/.env.example create mode 100644 __fixtures__/fragment-test-project/.gitignore create mode 100644 __fixtures__/fragment-test-project/.redwood/README.md delete mode 100644 __fixtures__/fragment-test-project/.redwood/schema.graphql create mode 100644 __fixtures__/fragment-test-project/.vscode/extensions.json create mode 100644 __fixtures__/fragment-test-project/.vscode/launch.json create mode 100644 __fixtures__/fragment-test-project/.vscode/settings.json create mode 100644 __fixtures__/fragment-test-project/.vscode/tasks.json create mode 100644 __fixtures__/fragment-test-project/.yarnrc.yml create mode 100644 __fixtures__/fragment-test-project/api/db/migrations/20220101120000_create_post_user/migration.sql create mode 100644 __fixtures__/fragment-test-project/api/db/migrations/20220102120000_create_contact/migration.sql create mode 100644 __fixtures__/fragment-test-project/api/db/migrations/20240106111257_create_produce_stall/migration.sql create mode 100644 __fixtures__/fragment-test-project/api/db/migrations/migration_lock.toml delete mode 100644 __fixtures__/fragment-test-project/api/src/.keep create mode 100644 __fixtures__/fragment-test-project/api/src/__tests__/context.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/functions/auth.ts create mode 100644 __fixtures__/fragment-test-project/api/src/graphql/contacts.sdl.ts create mode 100644 __fixtures__/fragment-test-project/api/src/graphql/posts.sdl.ts create mode 100644 __fixtures__/fragment-test-project/api/src/graphql/produces.sdl.ts create mode 100644 __fixtures__/fragment-test-project/api/src/graphql/users.sdl.ts delete mode 100644 __fixtures__/fragment-test-project/api/src/lib/persistedOperations.json create mode 100644 __fixtures__/fragment-test-project/api/src/services/contacts/contacts.scenarios.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/contacts/contacts.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/contacts/contacts.ts delete mode 100644 __fixtures__/fragment-test-project/api/src/services/fruit.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/posts/posts.scenarios.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/posts/posts.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/posts/posts.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/produces/produces.scenarios.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/produces/produces.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/produces/produces.ts delete mode 100644 __fixtures__/fragment-test-project/api/src/services/stalls.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/stalls/stalls.scenarios.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/stalls/stalls.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/stalls/stalls.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/users/users.scenarios.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/users/users.test.ts create mode 100644 __fixtures__/fragment-test-project/api/src/services/users/users.ts delete mode 100644 __fixtures__/fragment-test-project/api/src/services/vegetables.ts create mode 100644 __fixtures__/fragment-test-project/jest.config.js create mode 100644 __fixtures__/fragment-test-project/package.json create mode 100644 __fixtures__/fragment-test-project/prettier.config.js create mode 100644 __fixtures__/fragment-test-project/scripts/seed.ts create mode 100644 __fixtures__/fragment-test-project/scripts/tsconfig.json create mode 100644 __fixtures__/fragment-test-project/web/config/postcss.config.js create mode 100644 __fixtures__/fragment-test-project/web/config/tailwind.config.js create mode 100644 __fixtures__/fragment-test-project/web/public/README.md create mode 100644 __fixtures__/fragment-test-project/web/public/favicon.png create mode 100644 __fixtures__/fragment-test-project/web/public/robots.txt create mode 100644 __fixtures__/fragment-test-project/web/src/Redwood.stories.mdx create mode 100644 __fixtures__/fragment-test-project/web/src/auth.ts create mode 100644 __fixtures__/fragment-test-project/web/src/components/Author/Author.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Author/Author.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Author/Author.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.mock.ts create mode 100644 __fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.mock.ts create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.mock.ts create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.tsx rename __fixtures__/fragment-test-project/web/src/components/{Card => }/Card.tsx (100%) create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/Contact/Contact.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/ContactCell/ContactCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/ContactForm/ContactForm.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/Contacts/Contacts.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/ContactsCell/ContactsCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/EditContactCell/EditContactCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Contact/NewContact/NewContact.tsx rename __fixtures__/fragment-test-project/web/src/components/{Fruit.tsx => FruitInfo.tsx} (74%) create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/EditPostCell/EditPostCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/NewPost/NewPost.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/Post/Post.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/PostCell/PostCell.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/PostForm/PostForm.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/Posts/Posts.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/Post/PostsCell/PostsCell.tsx rename __fixtures__/fragment-test-project/web/src/components/{Produce.tsx => ProduceInfo.tsx} (73%) rename __fixtures__/fragment-test-project/web/src/components/{Stall.tsx => StallInfo.tsx} (83%) rename __fixtures__/fragment-test-project/web/src/components/{Vegetable.tsx => VegetableInfo.tsx} (74%) create mode 100644 __fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts create mode 100644 __fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.tsx delete mode 100644 __fixtures__/fragment-test-project/web/src/graphql/persistedOperations.json delete mode 100644 __fixtures__/fragment-test-project/web/src/graphql/possible-types.ts create mode 100644 __fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/lib/formatters.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/lib/formatters.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts create mode 100644 __fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Contact/ContactPage/ContactPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Contact/ContactsPage/ContactsPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Contact/EditContactPage/EditContactPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Contact/NewContactPage/NewContactPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/LoginPage/LoginPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Post/EditPostPage/EditPostPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Post/NewPostPage/NewPostPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Post/PostPage/PostPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/Post/PostsPage/PostsPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/SignupPage/SignupPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts create mode 100644 __fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.stories.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.test.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.tsx create mode 100644 __fixtures__/fragment-test-project/web/src/scaffold.css delete mode 100644 __fixtures__/fragment-test-project/web/types/possible-types.ts delete mode 100644 packages/create-redwood-app/templates/js/web/src/graphql/possibleTypes.js delete mode 100644 packages/create-redwood-app/templates/ts/web/src/graphql/possibleTypes.ts create mode 100644 tasks/smoke-tests/fragments-dev/playwright.config.ts create mode 100644 tasks/smoke-tests/fragments-dev/tests/fragments.spec.ts create mode 100644 tasks/smoke-tests/fragments-serve/playwright.config.ts create mode 100644 tasks/smoke-tests/fragments-serve/tests/fragments.spec.ts create mode 100755 tasks/test-project/add-gql-fragments.ts create mode 100644 tasks/test-project/codemods/groceriesPage.ts create mode 100644 tasks/test-project/codemods/producesSdl.ts create mode 100644 tasks/test-project/codemods/seedFragments.ts create mode 100755 tasks/test-project/rebuild-fragments-test-project-fixture.ts create mode 100644 tasks/test-project/templates/api/groceries.sdl.ts create mode 100644 tasks/test-project/templates/api/groceries.ts create mode 100644 tasks/test-project/templates/web/Card.tsx create mode 100644 tasks/test-project/templates/web/FruitInfo.tsx create mode 100644 tasks/test-project/templates/web/ProduceInfo.tsx create mode 100644 tasks/test-project/templates/web/StallInfo.tsx create mode 100644 tasks/test-project/templates/web/VegetableInfo.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 923978293a20..8d439012c71e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -702,6 +702,96 @@ jobs: steps: - run: echo "Skipped" + fragments-smoke-tests: + needs: check + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: 📄 Fragments Smoke tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + env: + REDWOOD_CI: 1 + REDWOOD_VERBOSE_TELEMETRY: 1 + + steps: + - uses: actions/checkout@v4 + + - name: Enable Corepack + run: corepack enable + + - name: ⬢ Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Enable Corepack + run: corepack enable + + - name: 🐈 Set up yarn cache + uses: ./.github/actions/set-up-yarn-cache + + - name: 🐈 Yarn install + run: yarn install --inline-builds + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: 🔨 Build + run: yarn build + + - name: 🌲 Set up test project + id: set-up-test-project + uses: ./.github/actions/set-up-test-project + with: + bundler: vite + canary: true + env: + REDWOOD_DISABLE_TELEMETRY: 1 + YARN_ENABLE_IMMUTABLE_INSTALLS: false + + - name: Run Fragments codemods on test project + run: npx -y tsx ./tasks/test-project/add-gql-fragments ${{ steps.set-up-test-project.outputs.test-project-path }} + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: 🎭 Install playwright dependencies + run: npx playwright install --with-deps chromium + + - name: Run Fragments dev smoke tests + working-directory: ./tasks/smoke-tests/fragments-dev + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Build for production + working-directory: ${{ steps.set-up-test-project.outputs.test-project-path }} + run: yarn rw build + env: + REDWOOD_DISABLE_TELEMETRY: 1 + + - name: Run Fragments serve smoke tests + working-directory: ./tasks/smoke-tests/fragments-serve + run: npx playwright test + env: + REDWOOD_TEST_PROJECT_PATH: '${{ steps.set-up-test-project.outputs.test-project-path }}' + REDWOOD_DISABLE_TELEMETRY: 1 + + fragments-smoke-tests-skip: + needs: detect-changes + + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + + name: 📄 Fragments Smoke tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + steps: + - run: echo "Skipped" + crwa: needs: check diff --git a/__fixtures__/fragment-test-project/web/.editorconfig b/__fixtures__/fragment-test-project/.editorconfig similarity index 100% rename from __fixtures__/fragment-test-project/web/.editorconfig rename to __fixtures__/fragment-test-project/.editorconfig diff --git a/__fixtures__/fragment-test-project/.env.defaults b/__fixtures__/fragment-test-project/.env.defaults new file mode 100644 index 000000000000..fb88fb33b334 --- /dev/null +++ b/__fixtures__/fragment-test-project/.env.defaults @@ -0,0 +1,19 @@ +# These environment variables will be used by default if you do not create any +# yourself in .env. This file should be safe to check into your version control +# system. Any custom values should go in .env and .env should *not* be checked +# into version control. + +# schema.prisma defaults +DATABASE_URL=file:./dev.db + +# location of the test database for api service scenarios (defaults to ./.redwood/test.db if not set) +# TEST_DATABASE_URL=file:./.redwood/test.db + +# disables Prisma CLI update notifier +PRISMA_HIDE_UPDATE_MESSAGE=true + +# Option to override the current environment's default api-side log level +# See: https://redwoodjs.com/docs/logger for level options, defaults to "trace" otherwise. +# Most applications want "debug" or "info" during dev, "trace" when you have issues and "warn" in production. +# Ordered by how verbose they are: trace | debug | info | warn | error | silent +# LOG_LEVEL=debug diff --git a/__fixtures__/fragment-test-project/.env.example b/__fixtures__/fragment-test-project/.env.example new file mode 100644 index 000000000000..2a2de6c026ca --- /dev/null +++ b/__fixtures__/fragment-test-project/.env.example @@ -0,0 +1,4 @@ +# DATABASE_URL=file:./dev.db +# TEST_DATABASE_URL=file:./.redwood/test.db +# PRISMA_HIDE_UPDATE_MESSAGE=true +# LOG_LEVEL=trace diff --git a/__fixtures__/fragment-test-project/.gitignore b/__fixtures__/fragment-test-project/.gitignore new file mode 100644 index 000000000000..9b8149560d9b --- /dev/null +++ b/__fixtures__/fragment-test-project/.gitignore @@ -0,0 +1,22 @@ +.idea +.DS_Store +.env +.netlify +.redwood/* +!.redwood/README.md +dev.db* +dist +dist-babel +node_modules +yarn-error.log +web/public/mockServiceWorker.js +web/types/graphql.d.ts +api/types/graphql.d.ts +api/src/lib/generateGraphiQLHeader.* +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/__fixtures__/fragment-test-project/.redwood/README.md b/__fixtures__/fragment-test-project/.redwood/README.md new file mode 100644 index 000000000000..f22b586a47cc --- /dev/null +++ b/__fixtures__/fragment-test-project/.redwood/README.md @@ -0,0 +1,44 @@ +# .redwood + +## What is this directory? + +Redwood uses this `.redwood` directory to store transitory data that aids in the smooth and convenient operation of your Redwood project. + +## Do I need to do anything with this directory? + +No. You shouldn't have to create, edit or delete anything in this directory in your day-to-day work with Redwood. + +You don't need to commit any other contents of this directory to your version control system. It's ignored by default. + +## What's in this directory? + +### Files + +| Name | Description | +| :---------------- | :------- | +| commandCache.json | This file contains mappings to assist the Redwood CLI in efficiently executing commands. | +| schema.graphql | This is the GraphQL schema which has been automatically generated from your Redwood project. | +| studio.db | The sqlite database used by the experimental `rw exp studio` feature. | +| telemetry.txt | Contains a unique ID used for telemetry. This value is rotated every 24 hours to protect your project's anonymity. | +| test.db | The sqlite database used when running tests. | + +### Directories + +| Name | Description | +| :---------- | :------- | +| locks | Stores temporary files that Redwood uses to keep track of the execution of async/background tasks between processes. | +| logs | Stores log files for background tasks such as update checking. | +| prebuild | Stores transpiled JavaScript that is generated as part of Redwood's build process. | +| telemetry | Stores the recent telemetry that the Redwood CLI has generated. You may inspect these files to see everything Redwood is anonymously collecting. | +| types | Stores the results of type generation. | +| updateCheck | Stores a file which contains the results of checking for Redwood updates. | + +We try to keep this README up to date but you may, from time to time, find other files or directories in this `.redwood` directory that have not yet been documented here. This is likely nothing to worry about but feel free to let us know and we'll update this list. + +### Telemetry + +RedwoodJS collects completely anonymous telemetry data about general usage. For transparency, that data is viewable in the respective directories and files. To learn more and manage your project's settings, visit [telemetry.redwoodjs.com](https://telemetry.redwoodjs.com). + +### Have any questions? + +Feel free to reach out to us in the [RedwoodJS Community](https://community.redwoodjs.com/) forum if you have any questions. diff --git a/__fixtures__/fragment-test-project/.redwood/schema.graphql b/__fixtures__/fragment-test-project/.redwood/schema.graphql deleted file mode 100644 index 1cd2ea849879..000000000000 --- a/__fixtures__/fragment-test-project/.redwood/schema.graphql +++ /dev/null @@ -1,102 +0,0 @@ -""" -Use to check whether or not a user is authenticated and is associated -with an optional set of roles. -""" -directive @requireAuth(roles: [String]) on FIELD_DEFINITION - -"""Use to skip authentication checks and allow public access.""" -directive @skipAuth on FIELD_DEFINITION - -scalar BigInt - -scalar Date - -scalar DateTime - -type Fruit implements Grocery { - id: ID! - - """Seedless is only for fruits""" - isSeedless: Boolean - name: String! - nutrients: String - price: Int! - quantity: Int! - region: String! - - """Ripeness is only for fruits""" - ripenessIndicators: String - stall: Stall! -} - -union Groceries = Fruit | Vegetable - -interface Grocery { - id: ID! - name: String! - nutrients: String - price: Int! - quantity: Int! - region: String! - stall: Stall! -} - -scalar JSON - -scalar JSONObject - -"""About the Redwood queries.""" -type Query { - fruitById(id: ID!): Fruit - fruits: [Fruit!]! - groceries: [Groceries!]! - - """Fetches the Redwood root schema.""" - redwood: Redwood - stallById(id: ID!): Stall - stalls: [Stall!]! - vegetableById(id: ID!): Vegetable - vegetables: [Vegetable!]! -} - -""" -The RedwoodJS Root Schema - -Defines details about RedwoodJS such as the current user and version information. -""" -type Redwood { - """The current user.""" - currentUser: JSON - - """The version of Prisma.""" - prismaVersion: String - - """The version of Redwood.""" - version: String -} - -type Stall { - fruits: [Fruit] - id: ID! - name: String! - stallNumber: String! - vegetables: [Vegetable] -} - -scalar Time - -type Vegetable implements Grocery { - id: ID! - - """Pickled is only for vegetables""" - isPickled: Boolean - name: String! - nutrients: String - price: Int! - quantity: Int! - region: String! - stall: Stall! - - """Veggie Family is only for vegetables""" - vegetableFamily: String -} \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/.vscode/extensions.json b/__fixtures__/fragment-test-project/.vscode/extensions.json new file mode 100644 index 000000000000..6e458a923135 --- /dev/null +++ b/__fixtures__/fragment-test-project/.vscode/extensions.json @@ -0,0 +1,16 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", + "eamodio.gitlens", + "ofhumanbondage.react-proptypes-intellisense", + "mgmcdermott.vscode-language-babel", + "wix.vscode-import-cost", + "pflannery.vscode-versionlens", + "editorconfig.editorconfig", + "prisma.prisma", + "graphql.vscode-graphql", + "csstools.postcss", + "bradlc.vscode-tailwindcss" + ], + "unwantedRecommendations": [] +} \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/.vscode/launch.json b/__fixtures__/fragment-test-project/.vscode/launch.json new file mode 100644 index 000000000000..340be43c34da --- /dev/null +++ b/__fixtures__/fragment-test-project/.vscode/launch.json @@ -0,0 +1,56 @@ +{ + "version": "0.3.0", + "configurations": [ + { + "command": "yarn redwood dev --apiDebugPort 18911", // you can add --fwd='--open=false' to prevent the browser from opening + "name": "Run Dev Server", + "request": "launch", + "type": "node-terminal" + }, + { + "name": "Attach API debugger", + "port": 18911, // you can change this port, see https://redwoodjs.com/docs/project-configuration-dev-test-build#debugger-configuration + "request": "attach", + "skipFiles": [ + "/**" + ], + "type": "node", + "localRoot": "${workspaceFolder}/node_modules/@redwoodjs/api-server/dist", + "remoteRoot": "${workspaceFolder}/node_modules/@redwoodjs/api-server/dist", + "sourceMaps": true, + "restart": true, + "preLaunchTask": "WaitForDevServer", + }, + { + "name": "Launch Web debugger", + "type": "chrome", + "request": "launch", + "url": "http://localhost:8910", + "webRoot": "${workspaceRoot}/web/src", + "preLaunchTask": "WaitForDevServer", + }, + { + "command": "yarn redwood test api", + "name": "Test api", + "request": "launch", + "type": "node-terminal" + }, + { + "command": "yarn redwood test web", + "name": "Test web", + "request": "launch", + "type": "node-terminal" + }, + ], + "compounds": [ + { + "name": "Start Debug", + "configurations": [ + "Run Dev Server", + "Attach API debugger", + "Launch Web debugger" + ], + "stopAll": true + } + ] +} diff --git a/__fixtures__/fragment-test-project/.vscode/settings.json b/__fixtures__/fragment-test-project/.vscode/settings.json new file mode 100644 index 000000000000..6887d360eb96 --- /dev/null +++ b/__fixtures__/fragment-test-project/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.tabSize": 2, + "files.trimTrailingWhitespace": true, + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "[prisma]": { + "editor.formatOnSave": true + } +} diff --git a/__fixtures__/fragment-test-project/.vscode/tasks.json b/__fixtures__/fragment-test-project/.vscode/tasks.json new file mode 100644 index 000000000000..549249ec6324 --- /dev/null +++ b/__fixtures__/fragment-test-project/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "WaitForDevServer", + "group": "none", + "type": "shell", + "command": "bash", + "args": [ + "-c", + "while ! echo -n > /dev/tcp/localhost/18911; do sleep 1; done;" + ], + "windows": { + "command": "powershell", + "args": [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "while (-not (Test-NetConnection -ComputerName localhost -Port 18911)) { Start-Sleep -Seconds 1 };" + ] + }, + "presentation": { + "reveal": "silent", + "revealProblems": "onProblem", + "panel": "shared", + "close": true + } + }, + ] +} diff --git a/__fixtures__/fragment-test-project/.yarnrc.yml b/__fixtures__/fragment-test-project/.yarnrc.yml new file mode 100644 index 000000000000..e8c5d50aa786 --- /dev/null +++ b/__fixtures__/fragment-test-project/.yarnrc.yml @@ -0,0 +1,15 @@ +# Yarn's manifest file. You can configure yarn here. +# See https://yarnpkg.com/configuration/yarnrc. + +# For `node_modules` (see `nodeLinker` below), this is almost always the preferred option. +compressionLevel: 0 + +enableGlobalCache: true + +# Lets yarn use hardlinks inside `node_modules` to dedupe packages. +# For a more pnpm-like experience, consider `hardlinks-global` where hardlinks point to a global store. +nmMode: hardlinks-local + +# How to install Node packages. +# Heads up: right now, Redwood expects this to be `node-modules`. +nodeLinker: node-modules diff --git a/__fixtures__/fragment-test-project/README.md b/__fixtures__/fragment-test-project/README.md index 861af7d9e65e..60a38fe2c6ac 100644 --- a/__fixtures__/fragment-test-project/README.md +++ b/__fixtures__/fragment-test-project/README.md @@ -1,4 +1,122 @@ -# Project to Test GraphQL Fragments +# README -* Has unions and interfaces to test: https://www.apollographql.com/docs/react/data/fragments/#using-fragments-with-unions-and-interfaces -* Generates "possible types" for Apollo Client, see: https://www.apollographql.com/docs/react/data/fragments/#defining-possibletypes-manually +Welcome to [RedwoodJS](https://redwoodjs.com)! + +> **Prerequisites** +> +> - Redwood requires [Node.js](https://nodejs.org/en/) (=20.x) and [Yarn](https://yarnpkg.com/) +> - Are you on Windows? For best results, follow our [Windows development setup](https://redwoodjs.com/docs/how-to/windows-development-setup) guide + +Start by installing dependencies: + +``` +yarn install +``` + +Then start the development server: + +``` +yarn redwood dev +``` + +Your browser should automatically open to [http://localhost:8910](http://localhost:8910) where you'll see the Welcome Page, which links out to many great resources. + +> **The Redwood CLI** +> +> Congratulations on running your first Redwood CLI command! From dev to deploy, the CLI is with you the whole way. And there's quite a few commands at your disposal: +> +> ``` +> yarn redwood --help +> ``` +> +> For all the details, see the [CLI reference](https://redwoodjs.com/docs/cli-commands). + +## Prisma and the database + +Redwood wouldn't be a full-stack framework without a database. It all starts with the schema. Open the [`schema.prisma`](api/db/schema.prisma) file in `api/db` and replace the `UserExample` model with the following `Post` model: + +```prisma +model Post { + id Int @id @default(autoincrement()) + title String + body String + createdAt DateTime @default(now()) +} +``` + +Redwood uses [Prisma](https://www.prisma.io/), a next-gen Node.js and TypeScript ORM, to talk to the database. Prisma's schema offers a declarative way of defining your app's data models. And Prisma [Migrate](https://www.prisma.io/migrate) uses that schema to make database migrations hassle-free: + +``` +yarn rw prisma migrate dev + +# ... + +? Enter a name for the new migration: › create posts +``` + +> `rw` is short for `redwood` + +You'll be prompted for the name of your migration. `create posts` will do. + +Now let's generate everything we need to perform all the CRUD (Create, Retrieve, Update, Delete) actions on our `Post` model: + +``` +yarn redwood generate scaffold post +``` + +Navigate to [http://localhost:8910/posts/new](http://localhost:8910/posts/new), fill in the title and body, and click "Save". + +Did we just create a post in the database? Yup! With `yarn rw generate scaffold `, Redwood created all the pages, components, and services necessary to perform all CRUD actions on our posts table. + +## Frontend first with Storybook + +Don't know what your data models look like? That's more than ok—Redwood integrates Storybook so that you can work on design without worrying about data. Mockup, build, and verify your React components, even in complete isolation from the backend: + +``` +yarn rw storybook +``` + +Seeing "Couldn't find any stories"? That's because you need a `*.stories.{tsx,jsx}` file. The Redwood CLI makes getting one easy enough—try generating a [Cell](https://redwoodjs.com/docs/cells), Redwood's data-fetching abstraction: + +``` +yarn rw generate cell examplePosts +``` + +The Storybook server should hot reload and now you'll have four stories to work with. They'll probably look a little bland since there's no styling. See if the Redwood CLI's `setup ui` command has your favorite styling library: + +``` +yarn rw setup ui --help +``` + +## Testing with Jest + +It'd be hard to scale from side project to startup without a few tests. Redwood fully integrates Jest with both the front- and back-ends, and makes it easy to keep your whole app covered by generating test files with all your components and services: + +``` +yarn rw test +``` + +To make the integration even more seamless, Redwood augments Jest with database [scenarios](https://redwoodjs.com/docs/testing#scenarios) and [GraphQL mocking](https://redwoodjs.com/docs/testing#mocking-graphql-calls). + +## Ship it + +Redwood is designed for both serverless deploy targets like Netlify and Vercel and serverful deploy targets like Render and AWS: + +``` +yarn rw setup deploy --help +``` + +Don't go live without auth! Lock down your app with Redwood's built-in, database-backed authentication system ([dbAuth](https://redwoodjs.com/docs/authentication#self-hosted-auth-installation-and-setup)), or integrate with nearly a dozen third-party auth providers: + +``` +yarn rw setup auth --help +``` + +## Next Steps + +The best way to learn Redwood is by going through the comprehensive [tutorial](https://redwoodjs.com/docs/tutorial/foreword) and joining the community (via the [Discourse forum](https://community.redwoodjs.com) or the [Discord server](https://discord.gg/redwoodjs)). + +## Quick Links + +- Stay updated: read [Forum announcements](https://community.redwoodjs.com/c/announcements/5), follow us on [Twitter](https://twitter.com/redwoodjs), and subscribe to the [newsletter](https://redwoodjs.com/newsletter) +- [Learn how to contribute](https://redwoodjs.com/docs/contributing) diff --git a/__fixtures__/fragment-test-project/api/db/migrations/20220101120000_create_post_user/migration.sql b/__fixtures__/fragment-test-project/api/db/migrations/20220101120000_create_post_user/migration.sql new file mode 100644 index 000000000000..9dd73df9b6ac --- /dev/null +++ b/__fixtures__/fragment-test-project/api/db/migrations/20220101120000_create_post_user/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "UserExample" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "name" TEXT +); + +-- CreateTable +CREATE TABLE "Post" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "authorId" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME, + "roles" TEXT +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserExample_email_key" ON "UserExample"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/__fixtures__/fragment-test-project/api/db/migrations/20220102120000_create_contact/migration.sql b/__fixtures__/fragment-test-project/api/db/migrations/20220102120000_create_contact/migration.sql new file mode 100644 index 000000000000..8d7bd91beb4d --- /dev/null +++ b/__fixtures__/fragment-test-project/api/db/migrations/20220102120000_create_contact/migration.sql @@ -0,0 +1,8 @@ +-- CreateTable +CREATE TABLE "Contact" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "message" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/__fixtures__/fragment-test-project/api/db/migrations/20240106111257_create_produce_stall/migration.sql b/__fixtures__/fragment-test-project/api/db/migrations/20240106111257_create_produce_stall/migration.sql new file mode 100644 index 000000000000..f7f6352f3e01 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/db/migrations/20240106111257_create_produce_stall/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "Produce" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "price" INTEGER NOT NULL, + "nutrients" TEXT, + "region" TEXT NOT NULL, + "isSeedless" BOOLEAN, + "ripenessIndicators" TEXT, + "vegetableFamily" TEXT, + "isPickled" BOOLEAN, + "stallId" TEXT NOT NULL, + CONSTRAINT "Produce_stallId_fkey" FOREIGN KEY ("stallId") REFERENCES "Stall" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Stall" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "stallNumber" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Produce_name_key" ON "Produce"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Stall_stallNumber_key" ON "Stall"("stallNumber"); diff --git a/__fixtures__/fragment-test-project/api/db/migrations/migration_lock.toml b/__fixtures__/fragment-test-project/api/db/migrations/migration_lock.toml new file mode 100644 index 000000000000..e5e5c4705ab0 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/api/db/schema.prisma b/__fixtures__/fragment-test-project/api/db/schema.prisma index 20b2df54078c..bf7e70169a1b 100644 --- a/__fixtures__/fragment-test-project/api/db/schema.prisma +++ b/__fixtures__/fragment-test-project/api/db/schema.prisma @@ -1,7 +1,12 @@ +// Don't forget to tell Prisma about your edits to this file using +// `yarn rw prisma migrate dev` or `yarn rw prisma db push`. +// `migrate` is like committing while `push` is for prototyping. +// Read more about both here: +// https://www.prisma.io/docs/orm/prisma-migrate + datasource db { - provider = "sqlite" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") + provider = "sqlite" + url = env("DATABASE_URL") } generator client { @@ -9,11 +14,42 @@ generator client { binaryTargets = "native" } -model Stall { - id String @id @default(cuid()) - name String - stallNumber String @unique - produce Produce[] +// Define your own datamodels here and run `yarn redwood prisma migrate dev` +// to create migrations for them and apply to your dev DB. +// TODO: Please remove the following example: +model UserExample { + id Int @id @default(autoincrement()) + email String @unique + name String? +} + +model Post { + id Int @id @default(autoincrement()) + title String + body String + authorId Int + author User @relation(fields: [authorId], references: [id]) + createdAt DateTime @default(now()) +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + hashedPassword String + fullName String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + roles String? + posts Post[] +} + +model Contact { + id Int @id @default(autoincrement()) + name String + email String + message String + createdAt DateTime @default(now()) } model Produce { @@ -34,3 +70,10 @@ model Produce { stall Stall @relation(fields: [stallId], references: [id], onDelete: Cascade) stallId String } + +model Stall { + id String @id @default(cuid()) + name String + stallNumber String @unique + produce Produce[] +} diff --git a/__fixtures__/fragment-test-project/api/package.json b/__fixtures__/fragment-test-project/api/package.json index 4a05d2980285..1fe562e03fbe 100644 --- a/__fixtures__/fragment-test-project/api/package.json +++ b/__fixtures__/fragment-test-project/api/package.json @@ -3,7 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@redwoodjs/api": "6.2.0", - "@redwoodjs/graphql-server": "6.2.0" + "@redwoodjs/api": "6.0.7", + "@redwoodjs/auth-dbauth-api": "6.0.7", + "@redwoodjs/graphql-server": "6.0.7" } } diff --git a/__fixtures__/fragment-test-project/api/src/.keep b/__fixtures__/fragment-test-project/api/src/.keep deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/__fixtures__/fragment-test-project/api/src/__tests__/context.test.ts b/__fixtures__/fragment-test-project/api/src/__tests__/context.test.ts new file mode 100644 index 000000000000..972c4756e85d --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/__tests__/context.test.ts @@ -0,0 +1,14 @@ +test('Set a mock user on the context', async () => { + const user = { + id: 0o7, + name: 'Bond, James Bond', + email: 'totallyNotASpy@example.com', + roles: 'secret_agent', + } + mockCurrentUser(user) + expect(context.currentUser).toStrictEqual(user) +}) + +test('Context is isolated between tests', () => { + expect(context).toStrictEqual({}) +}) diff --git a/__fixtures__/fragment-test-project/api/src/directives/requireAuth/requireAuth.test.ts b/__fixtures__/fragment-test-project/api/src/directives/requireAuth/requireAuth.test.ts index 0f01aa367a85..abd5e1864a95 100644 --- a/__fixtures__/fragment-test-project/api/src/directives/requireAuth/requireAuth.test.ts +++ b/__fixtures__/fragment-test-project/api/src/directives/requireAuth/requireAuth.test.ts @@ -11,7 +11,9 @@ describe('requireAuth directive', () => { it('requireAuth has stub implementation. Should not throw when current user', () => { // If you want to set values in context, pass it through e.g. // mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }}) - const mockExecution = mockRedwoodDirective(requireAuth, { context: {} }) + const mockExecution = mockRedwoodDirective(requireAuth, { + context: { currentUser: { id: 1, roles: 'ADMIN', email: 'b@zinga.com' } }, + }) expect(mockExecution).not.toThrowError() }) diff --git a/__fixtures__/fragment-test-project/api/src/functions/auth.ts b/__fixtures__/fragment-test-project/api/src/functions/auth.ts new file mode 100644 index 000000000000..d71b437e9802 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/functions/auth.ts @@ -0,0 +1,202 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda' + +import { DbAuthHandler } from '@redwoodjs/auth-dbauth-api' +import type { DbAuthHandlerOptions, UserType } from '@redwoodjs/auth-dbauth-api' + +import { cookieName } from 'src/lib/auth' +import { db } from 'src/lib/db' + +export const handler = async ( + event: APIGatewayProxyEvent, + context: Context +) => { + const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = { + // handler() is invoked after verifying that a user was found with the given + // username. This is where you can send the user an email with a link to + // reset their password. With the default dbAuth routes and field names, the + // URL to reset the password will be: + // + // https://example.com/reset-password?resetToken=${user.resetToken} + // + // Whatever is returned from this function will be returned from + // the `forgotPassword()` function that is destructured from `useAuth()`. + // You could use this return value to, for example, show the email + // address in a toast message so the user will know it worked and where + // to look for the email. + // + // Note that this return value is sent to the client in *plain text* + // so don't include anything you wouldn't want prying eyes to see. The + // `user` here has been sanitized to only include the fields listed in + // `allowedUserFields` so it should be safe to return as-is. + handler: (user, _resetToken) => { + // TODO: Send user an email/message with a link to reset their password, + // including the `resetToken`. The URL should look something like: + // `http://localhost:8910/reset-password?resetToken=${resetToken}` + + return user + }, + + // How long the resetToken is valid for, in seconds (default is 24 hours) + expires: 60 * 60 * 24, + + errors: { + // for security reasons you may want to be vague here rather than expose + // the fact that the email address wasn't found (prevents fishing for + // valid email addresses) + usernameNotFound: 'Username not found', + // if the user somehow gets around client validation + usernameRequired: 'Username is required', + }, + } + + const loginOptions: DbAuthHandlerOptions['login'] = { + // handler() is called after finding the user that matches the + // username/password provided at login, but before actually considering them + // logged in. The `user` argument will be the user in the database that + // matched the username/password. + // + // If you want to allow this user to log in simply return the user. + // + // If you want to prevent someone logging in for another reason (maybe they + // didn't validate their email yet), throw an error and it will be returned + // by the `logIn()` function from `useAuth()` in the form of: + // `{ message: 'Error message' }` + handler: (user) => { + return user + }, + + errors: { + usernameOrPasswordMissing: 'Both username and password are required', + usernameNotFound: 'Username ${username} not found', + // For security reasons you may want to make this the same as the + // usernameNotFound error so that a malicious user can't use the error + // to narrow down if it's the username or password that's incorrect + incorrectPassword: 'Incorrect password for ${username}', + }, + + // How long a user will remain logged in, in seconds + expires: 60 * 60 * 24 * 365 * 10, + } + + const resetPasswordOptions: DbAuthHandlerOptions['resetPassword'] = { + // handler() is invoked after the password has been successfully updated in + // the database. Returning anything truthy will automatically log the user + // in. Return `false` otherwise, and in the Reset Password page redirect the + // user to the login page. + handler: (_user) => { + return true + }, + + // If `false` then the new password MUST be different from the current one + allowReusedPassword: true, + + errors: { + // the resetToken is valid, but expired + resetTokenExpired: 'resetToken is expired', + // no user was found with the given resetToken + resetTokenInvalid: 'resetToken is invalid', + // the resetToken was not present in the URL + resetTokenRequired: 'resetToken is required', + // new password is the same as the old password (apparently they did not forget it) + reusedPassword: 'Must choose a new password', + }, + } + + interface UserAttributes { + 'full-name': string + } + + const signupOptions: DbAuthHandlerOptions< + UserType, + UserAttributes + >['signup'] = { + // Whatever you want to happen to your data on new user signup. Redwood will + // check for duplicate usernames before calling this handler. At a minimum + // you need to save the `username`, `hashedPassword` and `salt` to your + // user table. `userAttributes` contains any additional object members that + // were included in the object given to the `signUp()` function you got + // from `useAuth()`. + // + // If you want the user to be immediately logged in, return the user that + // was created. + // + // If this handler throws an error, it will be returned by the `signUp()` + // function in the form of: `{ error: 'Error message' }`. + // + // If this returns anything else, it will be returned by the + // `signUp()` function in the form of: `{ message: 'String here' }`. + handler: ({ username, hashedPassword, salt, userAttributes }) => { + return db.user.create({ + data: { + email: username, + hashedPassword: hashedPassword, + salt: salt, + fullName: userAttributes['full-name'], + }, + }) + }, + + // Include any format checks for password here. Return `true` if the + // password is valid, otherwise throw a `PasswordValidationError`. + // Import the error along with `DbAuthHandler` from `@redwoodjs/api` above. + passwordValidation: (_password) => { + return true + }, + + errors: { + // `field` will be either "username" or "password" + fieldMissing: '${field} is required', + usernameTaken: 'Username `${username}` already in use', + }, + } + + const authHandler = new DbAuthHandler(event, context, { + // Provide prisma db client + db: db, + + // The name of the property you'd call on `db` to access your user table. + // i.e. if your Prisma model is named `User` this value would be `user`, as in `db.user` + authModelAccessor: 'user', + + // A map of what dbAuth calls a field to what your database calls it. + // `id` is whatever column you use to uniquely identify a user (probably + // something like `id` or `userId` or even `email`) + authFields: { + id: 'id', + username: 'email', + hashedPassword: 'hashedPassword', + salt: 'salt', + resetToken: 'resetToken', + resetTokenExpiresAt: 'resetTokenExpiresAt', + }, + + // A list of fields on your user object that are safe to return to the + // client when invoking a handler that returns a user (like forgotPassword + // and signup). This list should be as small as possible to be sure not to + // leak any sensitive information to the client. + allowedUserFields: ['id', 'email'], + + // Specifies attributes on the cookie that dbAuth sets in order to remember + // who is logged in. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies + cookie: { + attributes: { + HttpOnly: true, + Path: '/', + SameSite: 'Strict', + Secure: process.env.NODE_ENV !== 'development', + + // If you need to allow other domains (besides the api side) access to + // the dbAuth session cookie: + // Domain: 'example.com', + }, + name: cookieName, + }, + + forgotPassword: forgotPasswordOptions, + login: loginOptions, + resetPassword: resetPasswordOptions, + signup: signupOptions, + }) + + return await authHandler.invoke() +} diff --git a/__fixtures__/fragment-test-project/api/src/functions/graphql.ts b/__fixtures__/fragment-test-project/api/src/functions/graphql.ts index f395c3b0f852..e9c53e285fad 100644 --- a/__fixtures__/fragment-test-project/api/src/functions/graphql.ts +++ b/__fixtures__/fragment-test-project/api/src/functions/graphql.ts @@ -1,13 +1,19 @@ +import { createAuthDecoder } from '@redwoodjs/auth-dbauth-api' import { createGraphQLHandler } from '@redwoodjs/graphql-server' import directives from 'src/directives/**/*.{js,ts}' import sdls from 'src/graphql/**/*.sdl.{js,ts}' import services from 'src/services/**/*.{js,ts}' +import { cookieName, getCurrentUser } from 'src/lib/auth' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' +const authDecoder = createAuthDecoder(cookieName) + export const handler = createGraphQLHandler({ + authDecoder, + getCurrentUser, loggerConfig: { logger, options: {} }, directives, sdls, diff --git a/__fixtures__/fragment-test-project/api/src/graphql/contacts.sdl.ts b/__fixtures__/fragment-test-project/api/src/graphql/contacts.sdl.ts new file mode 100644 index 000000000000..7dec262a579b --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/graphql/contacts.sdl.ts @@ -0,0 +1,32 @@ +export const schema = gql` + type Contact { + id: Int! + name: String! + email: String! + message: String! + createdAt: DateTime! + } + + type Query { + contacts: [Contact!]! @requireAuth + contact(id: Int!): Contact @requireAuth + } + + input CreateContactInput { + name: String! + email: String! + message: String! + } + + input UpdateContactInput { + name: String + email: String + message: String + } + + type Mutation { + createContact(input: CreateContactInput!): Contact @skipAuth + updateContact(id: Int!, input: UpdateContactInput!): Contact! @requireAuth + deleteContact(id: Int!): Contact! @requireAuth(roles: ["ADMIN"]) + } +` diff --git a/__fixtures__/fragment-test-project/api/src/graphql/posts.sdl.ts b/__fixtures__/fragment-test-project/api/src/graphql/posts.sdl.ts new file mode 100644 index 000000000000..09cf9b2cc6b2 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/graphql/posts.sdl.ts @@ -0,0 +1,33 @@ +export const schema = gql` + type Post { + id: Int! + title: String! + body: String! + authorId: Int! + author: User! + createdAt: DateTime! + } + + type Query { + posts: [Post!]! @skipAuth + post(id: Int!): Post @skipAuth + } + + input CreatePostInput { + title: String! + body: String! + authorId: Int! + } + + input UpdatePostInput { + title: String + body: String + authorId: Int + } + + type Mutation { + createPost(input: CreatePostInput!): Post! @requireAuth + updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth + deletePost(id: Int!): Post! @requireAuth + } +` diff --git a/__fixtures__/fragment-test-project/api/src/graphql/produces.sdl.ts b/__fixtures__/fragment-test-project/api/src/graphql/produces.sdl.ts new file mode 100644 index 000000000000..1a3342f66270 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/graphql/produces.sdl.ts @@ -0,0 +1,54 @@ +export const schema = gql` + type Produce { + id: String! + name: String! + quantity: Int! + price: Int! + nutrients: String + region: String! + isSeedless: Boolean + ripenessIndicators: String + vegetableFamily: String + isPickled: Boolean + stall: Stall! + stallId: String! + } + + type Query { + produces: [Produce!]! @skipAuth + produce(id: String!): Produce @skipAuth + } + + input CreateProduceInput { + name: String! + quantity: Int! + price: Int! + nutrients: String + region: String! + isSeedless: Boolean + ripenessIndicators: String + vegetableFamily: String + isPickled: Boolean + stallId: String! + } + + input UpdateProduceInput { + name: String + quantity: Int + price: Int + nutrients: String + region: String + isSeedless: Boolean + ripenessIndicators: String + vegetableFamily: String + isPickled: Boolean + stallId: String + } + + type Mutation { + createProduce(input: CreateProduceInput!): Produce! @skipAuth + updateProduce(id: String!, input: UpdateProduceInput!): Produce! + @skipAuth + deleteProduce(id: String!): Produce! @skipAuth + } +` diff --git a/__fixtures__/fragment-test-project/api/src/graphql/stalls.sdl.ts b/__fixtures__/fragment-test-project/api/src/graphql/stalls.sdl.ts index ff159aa2df9a..c934eeb4a5fd 100644 --- a/__fixtures__/fragment-test-project/api/src/graphql/stalls.sdl.ts +++ b/__fixtures__/fragment-test-project/api/src/graphql/stalls.sdl.ts @@ -1,14 +1,29 @@ export const schema = gql` type Stall { - id: ID! - stallNumber: String! + id: String! name: String! - fruits: [Fruit] - vegetables: [Vegetable] + stallNumber: String! + produce: [Produce]! } type Query { - stalls: [Stall!]! @skipAuth - stallById(id: ID!): Stall @skipAuth + stalls: [Stall!]! @requireAuth + stall(id: String!): Stall @requireAuth + } + + input CreateStallInput { + name: String! + stallNumber: String! + } + + input UpdateStallInput { + name: String + stallNumber: String + } + + type Mutation { + createStall(input: CreateStallInput!): Stall! @requireAuth + updateStall(id: String!, input: UpdateStallInput!): Stall! @requireAuth + deleteStall(id: String!): Stall! @requireAuth } ` diff --git a/__fixtures__/fragment-test-project/api/src/graphql/users.sdl.ts b/__fixtures__/fragment-test-project/api/src/graphql/users.sdl.ts new file mode 100644 index 000000000000..e2d1c0bed1d1 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/graphql/users.sdl.ts @@ -0,0 +1,25 @@ +export const schema = gql` + type User { + id: Int! + email: String! + fullName: String! + roles: String + posts: [Post]! + } + + type Query { + user(id: Int!): User @skipAuth + } + + input CreateUserInput { + email: String! + fullName: String! + roles: String + } + + input UpdateUserInput { + email: String + fullName: String + roles: String + } +` diff --git a/__fixtures__/fragment-test-project/api/src/lib/auth.ts b/__fixtures__/fragment-test-project/api/src/lib/auth.ts index f98fe93a960c..4e8f9005ebe5 100644 --- a/__fixtures__/fragment-test-project/api/src/lib/auth.ts +++ b/__fixtures__/fragment-test-project/api/src/lib/auth.ts @@ -1,25 +1,121 @@ +import type { Decoded } from '@redwoodjs/api' +import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' + +import { db } from './db' + +/** + * The name of the cookie that dbAuth sets + * + * %port% will be replaced with the port the api server is running on. + * If you have multiple RW apps running on the same host, you'll need to + * make sure they all use unique cookie names + */ +export const cookieName = 'session_%port%' + /** - * Once you are ready to add authentication to your application - * you'll build out requireAuth() with real functionality. For - * now we just return `true` so that the calls in services - * have something to check against, simulating a logged - * in user that is allowed to access that service. + * The session object sent in as the first argument to getCurrentUser() will + * have a single key `id` containing the unique ID of the logged in user + * (whatever field you set as `authFields.id` in your auth function config). + * You'll need to update the call to `db` below if you use a different model + * name or unique field name, for example: * - * See https://redwoodjs.com/docs/authentication for more info. + * return await db.profile.findUnique({ where: { email: session.id } }) + * ───┬─── ──┬── + * model accessor ─┘ unique id field name ─┘ + * + * !! BEWARE !! Anything returned from this function will be available to the + * client--it becomes the content of `currentUser` on the web side (as well as + * `context.currentUser` on the api side). You should carefully add additional + * fields to the `select` object below once you've decided they are safe to be + * seen if someone were to open the Web Inspector in their browser. */ -export const isAuthenticated = () => { - return true +export const getCurrentUser = async (session: Decoded) => { + if (!session || typeof session.id !== 'number') { + throw new Error('Invalid session') + } + + return await db.user.findUnique({ + where: { id: session.id }, + select: { id: true, roles: true, email: true }, + }) } -export const hasRole = ({ roles }) => { - return roles !== undefined +/** + * The user is authenticated if there is a currentUser in the context + * + * @returns {boolean} - If the currentUser is authenticated + */ +export const isAuthenticated = (): boolean => { + return !!context.currentUser +} + +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ +type AllowedRoles = string | string[] | undefined + +/** + * Checks if the currentUser is authenticated (and assigned one of the given roles) + * + * @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles + * + * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles, + * or when no roles are provided to check against. Otherwise returns false. + */ +export const hasRole = (roles: AllowedRoles): boolean => { + if (!isAuthenticated()) { + return false + } + + const currentUserRoles = context.currentUser?.roles as string | string[] + + if (typeof roles === 'string') { + if (typeof currentUserRoles === 'string') { + // roles to check is a string, currentUser.roles is a string + return currentUserRoles === roles + } else if (Array.isArray(currentUserRoles)) { + // roles to check is a string, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => roles === allowedRole) + } + } + + if (Array.isArray(roles)) { + if (Array.isArray(currentUserRoles)) { + // roles to check is an array, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => + roles.includes(allowedRole) + ) + } else if (typeof currentUserRoles === 'string') { + // roles to check is an array, currentUser.roles is a string + return roles.some((allowedRole) => currentUserRoles === allowedRole) + } + } + + // roles not found + return false } -// This is used by the redwood directive -// in ./api/src/directives/requireAuth +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param roles: {@link AllowedRoles} - When checking role membership, these roles grant access. + * + * @returns - If the currentUser is authenticated (and assigned one of the given roles) + * + * @throws {@link AuthenticationError} - If the currentUser is not authenticated + * @throws {@link ForbiddenError} If the currentUser is not allowed due to role permissions + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => { + if (!isAuthenticated()) { + throw new AuthenticationError("You don't have permission to do that.") + } -// Roles are passed in by the requireAuth directive if you have auth setup -// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars -export const requireAuth = ({ roles }) => { - return isAuthenticated() + if (roles && !hasRole(roles)) { + throw new ForbiddenError("You don't have access to do that.") + } } diff --git a/__fixtures__/fragment-test-project/api/src/lib/db.ts b/__fixtures__/fragment-test-project/api/src/lib/db.ts index 6743f0c11d9a..5006d00aae49 100644 --- a/__fixtures__/fragment-test-project/api/src/lib/db.ts +++ b/__fixtures__/fragment-test-project/api/src/lib/db.ts @@ -11,11 +11,11 @@ import { logger } from './logger' * Instance of the Prisma Client */ export const db = new PrismaClient({ - log: emitLogLevels(['info', 'warn', 'error', 'query']), + log: emitLogLevels(['info', 'warn', 'error']), }) handlePrismaLogging({ db, logger, - logLevels: ['info', 'warn', 'error', 'query'], + logLevels: ['info', 'warn', 'error'], }) diff --git a/__fixtures__/fragment-test-project/api/src/lib/persistedOperations.json b/__fixtures__/fragment-test-project/api/src/lib/persistedOperations.json deleted file mode 100644 index e5a8039db67f..000000000000 --- a/__fixtures__/fragment-test-project/api/src/lib/persistedOperations.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "GetGroceries": "9198a2438e6e5dbcf42362657baca24494a9f6cf83a6033983d2a978c1c1c703", - "GetProduce": "a8ee227d80bda6e1f785083aac537e8f1cd0340e0b52faaa27e18dbe4d629241" -} \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.scenarios.ts b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.scenarios.ts new file mode 100644 index 000000000000..37b062292193 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.scenarios.ts @@ -0,0 +1,12 @@ +import type { Prisma, Contact } from '@prisma/client' + +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + contact: { + one: { data: { name: 'String', email: 'String', message: 'String' } }, + two: { data: { name: 'String', email: 'String', message: 'String' } }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.test.ts b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.test.ts new file mode 100644 index 000000000000..8bb328e175e9 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.test.ts @@ -0,0 +1,59 @@ +import type { Contact } from '@prisma/client' + +import { + contacts, + contact, + createContact, + updateContact, + deleteContact, +} from './contacts' +import type { StandardScenario } from './contacts.scenarios' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-services +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('contacts', () => { + scenario('returns all contacts', async (scenario: StandardScenario) => { + const result = await contacts() + + expect(result.length).toEqual(Object.keys(scenario.contact).length) + }) + + scenario('returns a single contact', async (scenario: StandardScenario) => { + const result = await contact({ id: scenario.contact.one.id }) + + expect(result).toEqual(scenario.contact.one) + }) + + scenario('creates a contact', async () => { + const result = await createContact({ + input: { name: 'String', email: 'String', message: 'String' }, + }) + + expect(result.name).toEqual('String') + expect(result.email).toEqual('String') + expect(result.message).toEqual('String') + }) + + scenario('updates a contact', async (scenario: StandardScenario) => { + const original = (await contact({ id: scenario.contact.one.id })) as Contact + const result = await updateContact({ + id: original.id, + input: { name: 'String2' }, + }) + + expect(result.name).toEqual('String2') + }) + + scenario('deletes a contact', async (scenario: StandardScenario) => { + const original = (await deleteContact({ + id: scenario.contact.one.id, + })) as Contact + const result = await contact({ id: original.id }) + + expect(result).toEqual(null) + }) +}) diff --git a/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.ts b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.ts new file mode 100644 index 000000000000..453651e37b3c --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/contacts/contacts.ts @@ -0,0 +1,37 @@ +import type { QueryResolvers, MutationResolvers } from 'types/graphql' + +import { db } from 'src/lib/db' + +export const contacts: QueryResolvers['contacts'] = () => { + return db.contact.findMany() +} + +export const contact: QueryResolvers['contact'] = ({ id }) => { + return db.contact.findUnique({ + where: { id }, + }) +} + +export const createContact: MutationResolvers['createContact'] = ({ + input, +}) => { + return db.contact.create({ + data: input, + }) +} + +export const updateContact: MutationResolvers['updateContact'] = ({ + id, + input, +}) => { + return db.contact.update({ + data: input, + where: { id }, + }) +} + +export const deleteContact: MutationResolvers['deleteContact'] = ({ id }) => { + return db.contact.delete({ + where: { id }, + }) +} diff --git a/__fixtures__/fragment-test-project/api/src/services/fruit.ts b/__fixtures__/fragment-test-project/api/src/services/fruit.ts deleted file mode 100644 index b94ff9c3796d..000000000000 --- a/__fixtures__/fragment-test-project/api/src/services/fruit.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { QueryResolvers } from 'types/graphql' - -import { db } from 'src/lib/db' -import { logger } from 'src/lib/logger' - -const isFruit = { isSeedless: { not: null }, ripenessIndicators: { not: null } } - -export const fruits: QueryResolvers['fruits'] = async () => { - const result = await db.produce.findMany({ - where: { ...isFruit }, - include: { stall: true }, - orderBy: { name: 'asc' }, - }) - - logger.debug({ result }, 'frroooooots') - - return result -} - -export const fruitById: QueryResolvers['fruitById'] = async ({ id }) => { - const result = await db.produce.findUnique({ - where: { id, ...isFruit }, - include: { stall: true }, - }) - - logger.debug({ result }, 'frroot') - - return result -} diff --git a/__fixtures__/fragment-test-project/api/src/services/groceries.ts b/__fixtures__/fragment-test-project/api/src/services/groceries.ts index 8a34942d27a3..09eb5de330ff 100644 --- a/__fixtures__/fragment-test-project/api/src/services/groceries.ts +++ b/__fixtures__/fragment-test-project/api/src/services/groceries.ts @@ -1,6 +1,8 @@ +import { Produce } from 'types/graphql' + import { db } from 'src/lib/db' -const isFruit = (grocery) => { +const isFruit = (grocery: Produce) => { return grocery.isSeedless !== null && grocery.ripenessIndicators !== null } @@ -11,7 +13,7 @@ export const groceries = async () => { }) const avail = result.map((grocery) => { - if (isFruit) { + if (isFruit(grocery)) { return { ...grocery, __typename: 'Fruit', diff --git a/__fixtures__/fragment-test-project/api/src/services/posts/posts.scenarios.ts b/__fixtures__/fragment-test-project/api/src/services/posts/posts.scenarios.ts new file mode 100644 index 000000000000..9201d41f7c03 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/posts/posts.scenarios.ts @@ -0,0 +1,38 @@ +import type { Prisma, Post } from '@prisma/client' + +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + post: { + one: { + data: { + title: 'String', + body: 'String', + author: { + create: { + email: 'String12', + hashedPassword: 'String', + fullName: 'String', + salt: 'String', + }, + }, + }, + }, + two: { + data: { + title: 'String', + body: 'String', + author: { + create: { + email: 'String68383', + hashedPassword: 'String', + fullName: 'String', + salt: 'String', + }, + }, + }, + }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/fragment-test-project/api/src/services/posts/posts.test.ts b/__fixtures__/fragment-test-project/api/src/services/posts/posts.test.ts new file mode 100644 index 000000000000..fc316574f5ab --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/posts/posts.test.ts @@ -0,0 +1,55 @@ +import type { Post } from '@prisma/client' + +import { posts, post, createPost, updatePost, deletePost } from './posts' +import type { StandardScenario } from './posts.scenarios' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-services +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('posts', () => { + scenario('returns all posts', async (scenario: StandardScenario) => { + const result = await posts() + + expect(result.length).toEqual(Object.keys(scenario.post).length) + }) + + scenario('returns a single post', async (scenario: StandardScenario) => { + const result = await post({ id: scenario.post.one.id }) + + expect(result).toEqual(scenario.post.one) + }) + + scenario('creates a post', async (scenario: StandardScenario) => { + const result = await createPost({ + input: { + title: 'String', + body: 'String', + authorId: scenario.post.two.authorId, + }, + }) + + expect(result.title).toEqual('String') + expect(result.body).toEqual('String') + expect(result.authorId).toEqual(scenario.post.two.authorId) + }) + + scenario('updates a post', async (scenario: StandardScenario) => { + const original = (await post({ id: scenario.post.one.id })) as Post + const result = await updatePost({ + id: original.id, + input: { title: 'String2' }, + }) + + expect(result.title).toEqual('String2') + }) + + scenario('deletes a post', async (scenario: StandardScenario) => { + const original = (await deletePost({ id: scenario.post.one.id })) as Post + const result = await post({ id: original.id }) + + expect(result).toEqual(null) + }) +}) diff --git a/__fixtures__/fragment-test-project/api/src/services/posts/posts.ts b/__fixtures__/fragment-test-project/api/src/services/posts/posts.ts new file mode 100644 index 000000000000..eaeff31fb74b --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/posts/posts.ts @@ -0,0 +1,42 @@ +import type { + QueryResolvers, + MutationResolvers, + PostRelationResolvers, +} from 'types/graphql' + +import { db } from 'src/lib/db' + +export const posts: QueryResolvers['posts'] = () => { + return db.post.findMany() +} + +export const post: QueryResolvers['post'] = ({ id }) => { + return db.post.findUnique({ + where: { id }, + }) +} + +export const createPost: MutationResolvers['createPost'] = ({ input }) => { + return db.post.create({ + data: input, + }) +} + +export const updatePost: MutationResolvers['updatePost'] = ({ id, input }) => { + return db.post.update({ + data: input, + where: { id }, + }) +} + +export const deletePost: MutationResolvers['deletePost'] = ({ id }) => { + return db.post.delete({ + where: { id }, + }) +} + +export const Post: PostRelationResolvers = { + author: (_obj, { root }) => { + return db.post.findUnique({ where: { id: root?.id } }).author() + }, +} diff --git a/__fixtures__/fragment-test-project/api/src/services/produces/produces.scenarios.ts b/__fixtures__/fragment-test-project/api/src/services/produces/produces.scenarios.ts new file mode 100644 index 000000000000..475e638a5d58 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/produces/produces.scenarios.ts @@ -0,0 +1,27 @@ +import type { Prisma, Produce } from '@prisma/client' +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + produce: { + one: { + data: { + name: 'String6430168', + quantity: 7893718, + price: 1113110, + region: 'String', + stall: { create: { name: 'String', stallNumber: 'String1437797' } }, + }, + }, + two: { + data: { + name: 'String2325729', + quantity: 9170370, + price: 9020391, + region: 'String', + stall: { create: { name: 'String', stallNumber: 'String8553241' } }, + }, + }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/fragment-test-project/api/src/services/produces/produces.test.ts b/__fixtures__/fragment-test-project/api/src/services/produces/produces.test.ts new file mode 100644 index 000000000000..38e3457c1150 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/produces/produces.test.ts @@ -0,0 +1,67 @@ +import type { Produce } from '@prisma/client' + +import { + produces, + produce, + createProduce, + updateProduce, + deleteProduce, +} from './produces' +import type { StandardScenario } from './produces.scenarios' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-services +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('produces', () => { + scenario('returns all produces', async (scenario: StandardScenario) => { + const result = await produces() + + expect(result.length).toEqual(Object.keys(scenario.produce).length) + }) + + scenario('returns a single produce', async (scenario: StandardScenario) => { + const result = await produce({ id: scenario.produce.one.id }) + + expect(result).toEqual(scenario.produce.one) + }) + + scenario('creates a produce', async (scenario: StandardScenario) => { + const result = await createProduce({ + input: { + name: 'String9956690', + quantity: 4900208, + price: 6716153, + region: 'String', + stallId: scenario.produce.two.stallId, + }, + }) + + expect(result.name).toEqual('String9956690') + expect(result.quantity).toEqual(4900208) + expect(result.price).toEqual(6716153) + expect(result.region).toEqual('String') + expect(result.stallId).toEqual(scenario.produce.two.stallId) + }) + + scenario('updates a produce', async (scenario: StandardScenario) => { + const original = (await produce({ id: scenario.produce.one.id })) as Produce + const result = await updateProduce({ + id: original.id, + input: { name: 'String87087152' }, + }) + + expect(result.name).toEqual('String87087152') + }) + + scenario('deletes a produce', async (scenario: StandardScenario) => { + const original = (await deleteProduce({ + id: scenario.produce.one.id, + })) as Produce + const result = await produce({ id: original.id }) + + expect(result).toEqual(null) + }) +}) diff --git a/__fixtures__/fragment-test-project/api/src/services/produces/produces.ts b/__fixtures__/fragment-test-project/api/src/services/produces/produces.ts new file mode 100644 index 000000000000..5e99407a6d7b --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/produces/produces.ts @@ -0,0 +1,47 @@ +import type { + QueryResolvers, + MutationResolvers, + ProduceRelationResolvers, +} from 'types/graphql' + +import { db } from 'src/lib/db' + +export const produces: QueryResolvers['produces'] = () => { + return db.produce.findMany() +} + +export const produce: QueryResolvers['produce'] = ({ id }) => { + return db.produce.findUnique({ + where: { id }, + }) +} + +export const createProduce: MutationResolvers['createProduce'] = ({ + input, +}) => { + return db.produce.create({ + data: input, + }) +} + +export const updateProduce: MutationResolvers['updateProduce'] = ({ + id, + input, +}) => { + return db.produce.update({ + data: input, + where: { id }, + }) +} + +export const deleteProduce: MutationResolvers['deleteProduce'] = ({ id }) => { + return db.produce.delete({ + where: { id }, + }) +} + +export const Produce: ProduceRelationResolvers = { + stall: (_obj, { root }) => { + return db.produce.findUnique({ where: { id: root?.id } }).stall() + }, +} diff --git a/__fixtures__/fragment-test-project/api/src/services/stalls.ts b/__fixtures__/fragment-test-project/api/src/services/stalls.ts deleted file mode 100644 index aa7d417fd10c..000000000000 --- a/__fixtures__/fragment-test-project/api/src/services/stalls.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { QueryResolvers } from 'types/graphql' - -import { db } from 'src/lib/db' - -export const stalls: QueryResolvers['stalls'] = async () => { - const result = await db.stall.findMany({ - include: { produce: true }, - orderBy: { name: 'asc' }, - }) - - return result -} - -export const stallById: QueryResolvers['stallById'] = async ({ id }) => { - const result = await db.stall.findUnique({ - where: { id }, - include: { produce: true }, - }) - - return result -} diff --git a/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.scenarios.ts b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.scenarios.ts new file mode 100644 index 000000000000..fd57beaade6e --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.scenarios.ts @@ -0,0 +1,11 @@ +import type { Prisma, Stall } from '@prisma/client' +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + stall: { + one: { data: { name: 'String', stallNumber: 'String3227467' } }, + two: { data: { name: 'String', stallNumber: 'String3142426' } }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.test.ts b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.test.ts new file mode 100644 index 000000000000..e527a42691f6 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.test.ts @@ -0,0 +1,50 @@ +import type { Stall } from '@prisma/client' + +import { stalls, stall, createStall, updateStall, deleteStall } from './stalls' +import type { StandardScenario } from './stalls.scenarios' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-services +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('stalls', () => { + scenario('returns all stalls', async (scenario: StandardScenario) => { + const result = await stalls() + + expect(result.length).toEqual(Object.keys(scenario.stall).length) + }) + + scenario('returns a single stall', async (scenario: StandardScenario) => { + const result = await stall({ id: scenario.stall.one.id }) + + expect(result).toEqual(scenario.stall.one) + }) + + scenario('creates a stall', async () => { + const result = await createStall({ + input: { name: 'String', stallNumber: 'String7681055' }, + }) + + expect(result.name).toEqual('String') + expect(result.stallNumber).toEqual('String7681055') + }) + + scenario('updates a stall', async (scenario: StandardScenario) => { + const original = (await stall({ id: scenario.stall.one.id })) as Stall + const result = await updateStall({ + id: original.id, + input: { name: 'String2' }, + }) + + expect(result.name).toEqual('String2') + }) + + scenario('deletes a stall', async (scenario: StandardScenario) => { + const original = (await deleteStall({ id: scenario.stall.one.id })) as Stall + const result = await stall({ id: original.id }) + + expect(result).toEqual(null) + }) +}) diff --git a/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.ts b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.ts new file mode 100644 index 000000000000..91c7ce00cda3 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/stalls/stalls.ts @@ -0,0 +1,45 @@ +import type { + QueryResolvers, + MutationResolvers, + StallRelationResolvers, +} from 'types/graphql' + +import { db } from 'src/lib/db' + +export const stalls: QueryResolvers['stalls'] = () => { + return db.stall.findMany() +} + +export const stall: QueryResolvers['stall'] = ({ id }) => { + return db.stall.findUnique({ + where: { id }, + }) +} + +export const createStall: MutationResolvers['createStall'] = ({ input }) => { + return db.stall.create({ + data: input, + }) +} + +export const updateStall: MutationResolvers['updateStall'] = ({ + id, + input, +}) => { + return db.stall.update({ + data: input, + where: { id }, + }) +} + +export const deleteStall: MutationResolvers['deleteStall'] = ({ id }) => { + return db.stall.delete({ + where: { id }, + }) +} + +export const Stall: StallRelationResolvers = { + produce: (_obj, { root }) => { + return db.stall.findUnique({ where: { id: root?.id } }).produce() + }, +} diff --git a/__fixtures__/fragment-test-project/api/src/services/users/users.scenarios.ts b/__fixtures__/fragment-test-project/api/src/services/users/users.scenarios.ts new file mode 100644 index 000000000000..fccdc7ae6d7e --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/users/users.scenarios.ts @@ -0,0 +1,26 @@ +import type { Prisma, User } from '@prisma/client' + +import type { ScenarioData } from '@redwoodjs/testing/api' + +export const standard = defineScenario({ + user: { + one: { + data: { + email: 'String8', + hashedPassword: 'String', + fullName: 'String', + salt: 'String', + }, + }, + two: { + data: { + email: 'String16', + hashedPassword: 'String', + fullName: 'String', + salt: 'String', + }, + }, + }, +}) + +export type StandardScenario = ScenarioData diff --git a/__fixtures__/fragment-test-project/api/src/services/users/users.test.ts b/__fixtures__/fragment-test-project/api/src/services/users/users.test.ts new file mode 100644 index 000000000000..0e183cf8cc2d --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/users/users.test.ts @@ -0,0 +1,10 @@ +import { user } from './users' +import type { StandardScenario } from './users.scenarios' + +describe('users', () => { + scenario('returns a single user', async (scenario: StandardScenario) => { + const result = await user({ id: scenario.user.one.id }) + + expect(result).toEqual(scenario.user.one) + }) +}) diff --git a/__fixtures__/fragment-test-project/api/src/services/users/users.ts b/__fixtures__/fragment-test-project/api/src/services/users/users.ts new file mode 100644 index 000000000000..1160d12f8400 --- /dev/null +++ b/__fixtures__/fragment-test-project/api/src/services/users/users.ts @@ -0,0 +1,17 @@ +import type { QueryResolvers, UserRelationResolvers } from 'types/graphql' + +import { db } from 'src/lib/db' + +export {} + +export const user: QueryResolvers['user'] = ({ id }) => { + return db.user.findUnique({ + where: { id }, + }) +} + +export const User: UserRelationResolvers = { + posts: (_obj, { root }) => { + return db.user.findUnique({ where: { id: root?.id } }).posts() + }, +} diff --git a/__fixtures__/fragment-test-project/api/src/services/vegetables.ts b/__fixtures__/fragment-test-project/api/src/services/vegetables.ts deleted file mode 100644 index 7caf39d47fea..000000000000 --- a/__fixtures__/fragment-test-project/api/src/services/vegetables.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { QueryResolvers } from 'types/graphql' - -import { db } from 'src/lib/db' - -const isVegetable = { vegetableFamily: { not: null }, isPickled: { not: null } } - -export const vegetables: QueryResolvers['vegetables'] = async () => { - return await db.produce.findMany({ - where: { ...isVegetable }, - include: { stall: true }, - orderBy: { name: 'asc' }, - }) -} - -export const vegetableById: QueryResolvers['vegetableById'] = async ({ - id, -}) => { - return await db.produce.findUnique({ - where: { id, ...isVegetable }, - include: { stall: true }, - }) -} diff --git a/__fixtures__/fragment-test-project/jest.config.js b/__fixtures__/fragment-test-project/jest.config.js new file mode 100644 index 000000000000..c6b395cb762a --- /dev/null +++ b/__fixtures__/fragment-test-project/jest.config.js @@ -0,0 +1,8 @@ +// This the Redwood root jest config +// Each side, e.g. ./web/ and ./api/ has specific config that references this root +// More info at https://redwoodjs.com/docs/project-configuration-dev-test-build + +module.exports = { + rootDir: '.', + projects: ['/{*,!(node_modules)/**/}/jest.config.js'], +} diff --git a/__fixtures__/fragment-test-project/package.json b/__fixtures__/fragment-test-project/package.json new file mode 100644 index 000000000000..1ab4a33bedab --- /dev/null +++ b/__fixtures__/fragment-test-project/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "workspaces": { + "packages": [ + "api", + "web" + ] + }, + "devDependencies": { + "@redwoodjs/core": "6.0.7" + }, + "eslintConfig": { + "extends": "@redwoodjs/eslint-config", + "root": true + }, + "engines": { + "node": "=20.x" + }, + "prisma": { + "seed": "yarn rw exec seed" + }, + "packageManager": "yarn@4.0.2" +} diff --git a/__fixtures__/fragment-test-project/prettier.config.js b/__fixtures__/fragment-test-project/prettier.config.js new file mode 100644 index 000000000000..e532314d54ac --- /dev/null +++ b/__fixtures__/fragment-test-project/prettier.config.js @@ -0,0 +1,20 @@ +// https://prettier.io/docs/en/options.html +/** @type {import('prettier').RequiredOptions} */ +module.exports = { + trailingComma: 'es5', + bracketSpacing: true, + tabWidth: 2, + semi: false, + singleQuote: true, + arrowParens: 'always', + overrides: [ + { + files: 'Routes.*', + options: { + printWidth: 999, + }, + }, + ], + tailwindConfig: './web/config/tailwind.config.js', + plugins: [require('prettier-plugin-tailwindcss')], +} diff --git a/__fixtures__/fragment-test-project/redwood.toml b/__fixtures__/fragment-test-project/redwood.toml index 32d7509275c0..e21a6085fa47 100644 --- a/__fixtures__/fragment-test-project/redwood.toml +++ b/__fixtures__/fragment-test-project/redwood.toml @@ -7,12 +7,18 @@ [web] title = "Redwood App" - port = 8910 - apiUrl = "/.redwood/functions" # you can customise graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths - includeEnvironmentVariables = [] # any ENV vars that should be available to the web side, see https://redwoodjs.com/docs/environment-variables#web -[graphql] - fragments = true + port = "${WEB_DEV_PORT:8910}" + apiUrl = "/.redwood/functions" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths + includeEnvironmentVariables = [ + # Add any ENV vars that should be available to the web side to this array + # See https://redwoodjs.com/docs/environment-variables#web + ] [api] - port = 8911 + port = "${API_DEV_PORT:8911}" [browser] open = true +[notifications] + versionUpdates = ["latest"] + +[graphql] + fragments = true diff --git a/__fixtures__/fragment-test-project/scripts/seed.ts b/__fixtures__/fragment-test-project/scripts/seed.ts new file mode 100644 index 000000000000..899534721c78 --- /dev/null +++ b/__fixtures__/fragment-test-project/scripts/seed.ts @@ -0,0 +1,195 @@ +import type { Prisma } from '@prisma/client' +import { db } from 'api/src/lib/db' + +export default async () => { + try { + const users = [ + { + id: 1, + email: 'user.one@example.com', + hashedPassword: 'fake_hash', + fullName: 'User One', + salt: 'fake_salt', + }, + { + id: 2, + email: 'user.two@example.com', + hashedPassword: 'fake_hash', + fullName: 'User Two', + salt: 'fake_salt', + }, + ] + + if ((await db.user.count()) === 0) { + await Promise.all(users.map((user) => db.user.create({ data: user }))) + } else { + console.log('Users already seeded') + } + } catch (error) { + console.error(error) + } + + try { + const posts = [ + { + title: 'Welcome to the blog!', + body: "I'm baby single- origin coffee kickstarter lo - fi paleo skateboard.Tumblr hashtag austin whatever DIY plaid knausgaard fanny pack messenger bag blog next level woke.Ethical bitters fixie freegan,helvetica pitchfork 90's tbh chillwave mustache godard subway tile ramps art party. Hammock sustainable twee yr bushwick disrupt unicorn, before they sold out direct trade chicharrones etsy polaroid hoodie. Gentrify offal hoodie fingerstache.", + authorId: 1, + }, + { + title: 'A little more about me', + body: "Raclette shoreditch before they sold out lyft. Ethical bicycle rights meh prism twee. Tote bag ennui vice, slow-carb taiyaki crucifix whatever you probably haven't heard of them jianbing raw denim DIY hot chicken. Chillwave blog succulents freegan synth af ramps poutine wayfarers yr seitan roof party squid. Jianbing flexitarian gentrify hexagon portland single-origin coffee raclette gluten-free. Coloring book cloud bread street art kitsch lumbersexual af distillery ethical ugh thundercats roof party poke chillwave. 90's palo santo green juice subway tile, prism viral butcher selvage etsy pitchfork sriracha tumeric bushwick.", + authorId: 1, + }, + { + title: 'What is the meaning of life?', + body: 'Meh waistcoat succulents umami asymmetrical, hoodie post-ironic paleo chillwave tote bag. Trust fund kitsch waistcoat vape, cray offal gochujang food truck cloud bread enamel pin forage. Roof party chambray ugh occupy fam stumptown. Dreamcatcher tousled snackwave, typewriter lyft unicorn pabst portland blue bottle locavore squid PBR&B tattooed.', + authorId: 2, + }, + ] + + if ((await db.post.count()) === 0) { + await Promise.all( + posts.map(async (post) => { + const newPost = await db.post.create({ data: post }) + + console.log(newPost) + }) + ) + } else { + console.log('Posts already seeded') + } + } catch (error) { + console.error(error) + } + + try { + const stalls = [ + { + id: 'clr0zv6ow000012nvo6r09vog', + name: 'Salad Veggies', + stallNumber: '1', + }, + { + id: 'clr0zvne2000112nvyhzf1ifk', + name: 'Pie Veggies', + stallNumber: '2', + }, + { + id: 'clr0zvne3000212nv6boae9qw', + name: 'Root Veggies', + stallNumber: '3', + }, + ] + + if ((await db.stall.count()) === 0) { + await Promise.all( + stalls.map(async (stall) => { + const newStall = await db.stall.create({ data: stall }) + + console.log(newStall) + }) + ) + } else { + console.log('Stalls already seeded') + } + + const produce = [ + { + id: 'clr0zwyoq000312nvfsu1efcw', + name: 'Lettuce', + quantity: 10, + price: 2, + ripenessIndicators: null, + region: '', + isSeedless: false, + vegetableFamily: 'Asteraceae', + stallId: 'clr0zv6ow000012nvo6r09vog', + }, + { + id: 'clr0zy32x000412nvsya5g8q0', + name: 'Strawberries', + quantity: 24, + price: 3, + ripenessIndicators: 'Vitamin C', + region: 'California', + isSeedless: false, + vegetableFamily: 'Soft', + stallId: 'clr0zvne2000112nvyhzf1ifk', + }, + ] + + if ((await db.produce.count()) === 0) { + await Promise.all( + produce.map(async (produce) => { + const newProduce = await db.produce.create({ data: produce }) + + console.log(newProduce) + }) + ) + } else { + console.log('Produce already seeded') + } + } catch (error) { + console.error(error) + } + + try { + // + // Manually seed via `yarn rw prisma db seed` + // Seeds automatically with `yarn rw prisma migrate dev` and `yarn rw prisma migrate reset` + // + // Update "const data = []" to match your data model and seeding needs + // + const data: Prisma.UserExampleCreateArgs['data'][] = [ + // To try this example data with the UserExample model in schema.prisma, + // uncomment the lines below and run 'yarn rw prisma migrate dev' + // + // { name: 'alice', email: 'alice@example.com' }, + // { name: 'mark', email: 'mark@example.com' }, + // { name: 'jackie', email: 'jackie@example.com' }, + // { name: 'bob', email: 'bob@example.com' }, + ] + console.log( + "\nUsing the default './scripts/seed.{js,ts}' template\nEdit the file to add seed data\n" + ) + + // Note: if using PostgreSQL, using `createMany` to insert multiple records is much faster + // @see: https://www.prisma.io/docs/reference/api-reference/prisma-client-reference#createmany + await Promise.all( + // + // Change to match your data model and seeding needs + // + data.map(async (data: Prisma.UserExampleCreateArgs['data']) => { + const record = await db.userExample.create({ data }) + console.log(record) + }) + ) + + // If using dbAuth and seeding users, you'll need to add a `hashedPassword` + // and associated `salt` to their record. Here's how to create them using + // the same algorithm that dbAuth uses internally: + // + // import { hashPassword } from '@redwoodjs/auth-dbauth-api' + // + // const users = [ + // { name: 'john', email: 'john@example.com', password: 'secret1' }, + // { name: 'jane', email: 'jane@example.com', password: 'secret2' } + // ] + // + // for (const user of users) { + // const [hashedPassword, salt] = hashPassword(user.password) + // await db.user.create({ + // data: { + // name: user.name, + // email: user.email, + // hashedPassword, + // salt + // } + // }) + // } + } catch (error) { + console.warn('Please define your seed data.') + console.error(error) + } +} diff --git a/__fixtures__/fragment-test-project/scripts/tsconfig.json b/__fixtures__/fragment-test-project/scripts/tsconfig.json new file mode 100644 index 000000000000..babc7c436be6 --- /dev/null +++ b/__fixtures__/fragment-test-project/scripts/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "noEmit": true, + "allowJs": true, + "esModuleInterop": true, + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "baseUrl": "./", + "paths": { + "$api/*": [ + "../api/*" + ], + "api/*": [ + "../api/*" + ], + "$web/*": [ + "../web/*" + ], + "web/*": [ + "../web/*" + ], + "$web/src/*": [ + "../web/src/*", + "../.redwood/types/mirror/web/src/*" + ], + "web/src/*": [ + "../web/src/*", + "../.redwood/types/mirror/web/src/*" + ], + "types/*": ["../types/*", "../web/types/*", "../api/types/*"] + }, + "typeRoots": ["../node_modules/@types"], + "jsx": "preserve" + }, + "include": [ + ".", + "../.redwood/types/includes/all-*", + "../.redwood/types/includes/web-*", + "../types" + ] +} diff --git a/__fixtures__/fragment-test-project/web/config/postcss.config.js b/__fixtures__/fragment-test-project/web/config/postcss.config.js new file mode 100644 index 000000000000..ca420cad420e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/config/postcss.config.js @@ -0,0 +1,8 @@ +const path = require('path') + +module.exports = { + plugins: [ + require('tailwindcss')(path.resolve(__dirname, 'tailwind.config.js')), + require('autoprefixer'), + ], +} diff --git a/__fixtures__/fragment-test-project/web/config/tailwind.config.js b/__fixtures__/fragment-test-project/web/config/tailwind.config.js new file mode 100644 index 000000000000..baf368f417d2 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/config/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/__fixtures__/fragment-test-project/web/package.json b/__fixtures__/fragment-test-project/web/package.json index 1bffdb2f6902..089dd533bc0c 100644 --- a/__fixtures__/fragment-test-project/web/package.json +++ b/__fixtures__/fragment-test-project/web/package.json @@ -11,14 +11,22 @@ ] }, "dependencies": { - "@redwoodjs/forms": "6.2.0", - "@redwoodjs/router": "6.2.0", - "@redwoodjs/web": "6.2.0", - "prop-types": "15.8.1", - "react": "18.2.0", - "react-dom": "18.2.0" + "@redwoodjs/auth-dbauth-web": "6.0.7", + "@redwoodjs/forms": "6.0.7", + "@redwoodjs/router": "6.0.7", + "@redwoodjs/web": "6.0.7", + "humanize-string": "2.1.0", + "react": "0.0.0-experimental-e5205658f-20230913", + "react-dom": "0.0.0-experimental-e5205658f-20230913" }, "devDependencies": { - "@redwoodjs/vite": "6.2.0" + "@redwoodjs/vite": "6.0.7", + "@types/react": "18.2.37", + "@types/react-dom": "18.2.15", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.33", + "postcss-loader": "^7.3.4", + "prettier-plugin-tailwindcss": "0.4.1", + "tailwindcss": "^3.4.1" } } diff --git a/__fixtures__/fragment-test-project/web/public/README.md b/__fixtures__/fragment-test-project/web/public/README.md new file mode 100644 index 000000000000..618395f02033 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/public/README.md @@ -0,0 +1,35 @@ +# Static Assets +Use this folder to add static files directly to your app. All included files and folders will be copied directly into the `/dist` folder (created when Vite builds for production). They will also be available during development when you run `yarn rw dev`. +>Note: files will *not* hot reload while the development server is running. You'll need to manually stop/start to access file changes. + +### Example Use +A file like `favicon.png` will be copied to `/dist/favicon.png`. A folder containing a file such as `static-files/my-logo.jpg` will be copied to `/dist/static-files/my-logo.jpg`. These can be referenced in your code directly without any special handling, e.g. +``` + +``` +and +``` + alt="Logo" /> +``` + + +## Best Practices +Because assets in this folder are bypassing the javascript module system, **this folder should be used sparingly** for assets such as favicons, robots.txt, manifests, libraries incompatible with Vite, etc. + +In general, it's best to import files directly into a template, page, or component. This allows Vite to include that file in the bundle when small enough, or to copy it over to the `dist` folder with a hash. + +### Example Asset Import with Vite +Instead of handling our logo image as a static file per the example above, we can do the following: +``` +import React from "react" +import logo from "./my-logo.jpg" + + +function Header() { + return Logo +} + +export default Header +``` + +See Vite's docs for [static asset handling](https://vitejs.dev/guide/assets.html) diff --git a/__fixtures__/fragment-test-project/web/public/favicon.png b/__fixtures__/fragment-test-project/web/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..47414294173cb0795dcafb8813599fc382282556 GIT binary patch literal 1741 zcmV;;1~U1HP)u3dvWaK1Jt7p7xtk~lm38V(vb%~9EcN4itP(!;||l>?RBBL}g^A8<`Fn z=_ofw?w2~Qt#0f9Ac3O;;Nt1}TFWmPb1YZ9hDBXZ zTK55jh;jRpRArCUs~@6m!BMLSuZE&5;HTqrDc^;f)?K|FaV6o1RTFbt+uA;);7z?5 z9axBZCgX!V;dhWl*HZCE&V7oz;oZ;*lOh^wZ2aYlLI<1rXkc0&HH!|5!S0|*s- zM*~yi#Ef4dES_G+_-z+`S<%x__Ulk8{Z?I!;wv8DmN?3t1H$+fJ*q^w!} z8`oOx{i(WL4oLgKN0~^gQyJ3t#+tnIhR=h}6@BVu1&_1g7*O6j$-5z)KLsPi3dqCH zq+n<+)2a$Afvr|B97(#s5f6-oU6qYHP<2rWEKfC)aEc=?j9nPwEyIiT4XCI%BScNpoU1Cro6M@BSt>YU4@z^JQPbj- zbMl0tf(CkBNTVH0run?8E#6lyouay;Bf8|_ud%WyA2Dkqc}nAEGkyiO!|#6>OX~jC z_3u?iQ>Xm%XNGGb_3~zzqyj(lHYRC##{sV_zNQl$KP40jQHRR#WeJ!akxfaL;HU(y z@6A7KA;pjflPx?{&_wwQ<6?f(Uld(h*XSf+Ct`QR3EDfau;y#nNiKfJ`Ny24=O+_9 z{chAh!5R0T(`<1ayxDvCtBZ?9Rn)QBoddzqchGPN4C8rB2tQ(*#m6zlySN7XwxM)X zNo%g}Q*?B_&%_K;!PvNxj9-D>BYn6zcIb@VGE=-?gP+zjpQ4x$*@_cm*TL-MtWeV+ z%v$Vh+2e#jDJ4Yc3NPgE9Uhr~V;6)j#bgMC+5!L2yYdX5ef->+k9d_?db{`}fWW+F zU&GKd9pW?cv0e8pA%20doi=OgaTV=dLOHx7cgAQlYDkLWaAUksGbO`Z7+>qo}~5K=?ZI!b@vaF5}r7- zyP2aiwSn}KbwGhrQ0A?W4L_Jwg?C#vAElLzpK~}}&ny0d@_GVhUqVEfXX9}XI8%B; z;BYTG$dM}6WS8urD4fqn$733@mNss6jB7yHY*76e*L=X6apM|Dgg^tZhpge9{Ojy9 z{Sl&x=vUbHU+7KFQEas^U*jQ8^rj_XAzI=0y_Nmx3ChT&K?_-b!N10g5+C9TqMGZ@!a>mh#`}nJM>Cu2v@32F*rQ(x05Xb64 zV-ML!u$4W31M7A@mi~3fnSOQSZ->>TC+02Mt+0csMl0*2TCklB$VOH11pW{4 zD1)V+^h4n@OYlO&;Z!-dk{(LVtA%;(o#!>jYgG>s%eL0iXx~jJsrfL3rwo;cc52kP zRnvwZId>`-FV`PUvUKk4gU&nzX&+gTEm1bNsCdaXc zvaOny-3X43Fs?Jn;>*U?jaR1`9KIVP?p(?ulraQZc;T0UKos^SChGJoJYVu1%?E0v zDGNOfZKPrPKtyFYEU~bZZ~rB{4X2ko>_VJlJw3rw-!>TIT6R!3;POq5yNZdnfu$Ao j!CVlN4fQVi0D=DiS&&%ubg+{I00000NkvXXu0mjf8bDG2 literal 0 HcmV?d00001 diff --git a/__fixtures__/fragment-test-project/web/public/robots.txt b/__fixtures__/fragment-test-project/web/public/robots.txt new file mode 100644 index 000000000000..eb0536286f30 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/__fixtures__/fragment-test-project/web/src/App.tsx b/__fixtures__/fragment-test-project/web/src/App.tsx index f60aa1dfc22a..65419d60c7d6 100644 --- a/__fixtures__/fragment-test-project/web/src/App.tsx +++ b/__fixtures__/fragment-test-project/web/src/App.tsx @@ -1,22 +1,22 @@ import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' -import introspection 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 = () => ( - - - + + + + + ) diff --git a/__fixtures__/fragment-test-project/web/src/Redwood.stories.mdx b/__fixtures__/fragment-test-project/web/src/Redwood.stories.mdx new file mode 100644 index 000000000000..4481bff2fdc9 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/Redwood.stories.mdx @@ -0,0 +1,15 @@ +import { Meta } from '@storybook/addon-docs' + + + +

+ +

Redwood

+

+ +_by Tom Preston-Werner, Peter Pistorius, Rob Cameron, David Price, and more than +250 amazing contributors (see end of file for a full list)._ + +**Redwood is an opinionated, full-stack, JavaScript/TypeScript web application +framework designed to keep you moving fast as your app grows from side project +to startup.** diff --git a/__fixtures__/fragment-test-project/web/src/Routes.tsx b/__fixtures__/fragment-test-project/web/src/Routes.tsx index 71fc2e0f7098..a8b80e306513 100644 --- a/__fixtures__/fragment-test-project/web/src/Routes.tsx +++ b/__fixtures__/fragment-test-project/web/src/Routes.tsx @@ -7,13 +7,46 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route } from '@redwoodjs/router' +import { Router, Route, Private, Set } from '@redwoodjs/router' + +import BlogLayout from 'src/layouts/BlogLayout' +import ScaffoldLayout from 'src/layouts/ScaffoldLayout' +import HomePage from 'src/pages/HomePage' + +import { useAuth } from './auth' const Routes = () => { return ( - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/__fixtures__/fragment-test-project/web/src/auth.ts b/__fixtures__/fragment-test-project/web/src/auth.ts new file mode 100644 index 000000000000..143e75bd61a2 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/auth.ts @@ -0,0 +1,5 @@ +import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web' + +const dbAuthClient = createDbAuthClient() + +export const { AuthProvider, useAuth } = createAuth(dbAuthClient) diff --git a/__fixtures__/fragment-test-project/web/src/components/Author/Author.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/Author/Author.stories.tsx new file mode 100644 index 000000000000..662b48ec9883 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Author/Author.stories.tsx @@ -0,0 +1,35 @@ +// Pass props to your component by passing an `args` object to your story +// +// ```tsx +// export const Primary: Story = { +// args: { +// propName: propValue +// } +// } +// ``` +// +// See https://storybook.js.org/docs/react/writing-stories/args. + +import type { Meta, StoryObj } from '@storybook/react' + +import Author from './Author' + +const meta: Meta = { + component: Author, + tags: ['autodocs'], +} + +export default meta + +type Story = StoryObj + +const author = { + email: 'story.user@email.com', + fullName: 'Story User', +} + +export const Primary: Story = { + render: () => { + return + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Author/Author.test.tsx b/__fixtures__/fragment-test-project/web/src/components/Author/Author.test.tsx new file mode 100644 index 000000000000..9dfa8c73830f --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Author/Author.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@redwoodjs/testing/web' + +import Author from './Author' + +const author = { + email: 'test.user@email.com', + fullName: 'Test User', +} + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-components + +describe('Author', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/Author/Author.tsx b/__fixtures__/fragment-test-project/web/src/components/Author/Author.tsx new file mode 100644 index 000000000000..f59f8b98f106 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Author/Author.tsx @@ -0,0 +1,16 @@ +interface Props { + author: { + email: string + fullName: string + } +} + +const Author = ({ author }: Props) => { + return ( + + {author.fullName} ({author.email}) + + ) +} + +export default Author diff --git a/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.mock.ts b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.mock.ts new file mode 100644 index 000000000000..4473d93d09de --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.mock.ts @@ -0,0 +1,8 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + author: { + id: 42, + email: 'fortytwo@42.com', + fullName: 'Forty Two', + }, +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.stories.tsx new file mode 100644 index 000000000000..fd0a2b73376b --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Loading, Empty, Failure, Success } from './AuthorCell' +import { standard } from './AuthorCell.mock' + +const meta: Meta = { + title: 'Cells/AuthorCell', + tags: ['autodocs'], +} + +export default meta + +export const loading: StoryObj = { + render: () => { + return Loading ? : <> + }, +} + +export const empty: StoryObj = { + render: () => { + return Empty ? : <> + }, +} + +export const failure: StoryObj = { + render: (args) => { + return Failure ? : <> + }, +} + +export const success: StoryObj = { + render: (args) => { + return Success ? : <> + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.test.tsx b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.test.tsx new file mode 100644 index 000000000000..ade2ee4f44c7 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@redwoodjs/testing/web' + +import { Loading, Empty, Failure, Success } from './AuthorCell' +import { standard } from './AuthorCell.mock' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float and DateTime types. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-cells +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('AuthorCell', () => { + it('renders Loading successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Empty successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Failure successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + // When you're ready to test the actual output of your component render + // you could test that, for example, certain text is present: + // + // 1. import { screen } from '@redwoodjs/testing/web' + // 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument() + + it('renders Success successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.tsx b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.tsx new file mode 100644 index 000000000000..f4633be36fb3 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/AuthorCell/AuthorCell.tsx @@ -0,0 +1,39 @@ +import type { FindAuthorQuery, FindAuthorQueryVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Author from 'src/components/Author' + +export const QUERY: TypedDocumentNode< + FindAuthorQuery, + FindAuthorQueryVariables +> = gql` + query FindAuthorQuery($id: Int!) { + author: user(id: $id) { + email + fullName + } + } +` + +export const Loading = () => Loading... + +export const Empty = () => Empty + +export const Failure = ({ + error, +}: CellFailureProps) => ( + Error: {error?.message} +) + +export const Success = ({ + author, +}: CellSuccessProps) => ( + + + +) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.stories.tsx new file mode 100644 index 000000000000..c1d94a471350 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.stories.tsx @@ -0,0 +1,26 @@ +// Pass props to your component by passing an `args` object to your story +// +// ```tsx +// export const Primary: Story = { +// args: { +// propName: propValue +// } +// } +// ``` +// +// See https://storybook.js.org/docs/react/writing-stories/args. + +import type { Meta, StoryObj } from '@storybook/react' + +import BlogPost from './BlogPost' + +const meta: Meta = { + component: BlogPost, + tags: ['autodocs'], +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.test.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.test.tsx new file mode 100644 index 000000000000..df026f691ddd --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import BlogPost from './BlogPost' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-components + +describe('BlogPost', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.tsx new file mode 100644 index 000000000000..380053bc4899 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPost/BlogPost.tsx @@ -0,0 +1,41 @@ +import { FindBlogPostQuery } from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' + +import Author from 'src/components/Author' + +interface Props extends FindBlogPostQuery {} + +const BlogPost = ({ blogPost }: Props) => { + return ( +
+ {blogPost && ( + <> +
+

+ {new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date(blogPost.createdAt))}{' '} + - By: +

+

+ + {blogPost.title} + +

+
+
+ {blogPost.body} +
+ + )} +
+ ) +} + +export default BlogPost diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.mock.ts b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.mock.ts new file mode 100644 index 000000000000..a42723a44fa3 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.mock.ts @@ -0,0 +1,15 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + blogPost: { + id: 42, + title: 'Mocked title', + body: 'Mocked body', + createdAt: '2022-01-17T13:57:51.607Z', + authorId: 5, + + author: { + email: 'five@5.com', + fullName: 'Five Lastname', + }, + }, +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.stories.tsx new file mode 100644 index 000000000000..361d13a91123 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Loading, Empty, Failure, Success } from './BlogPostCell' +import { standard } from './BlogPostCell.mock' + +const meta: Meta = { + title: 'Cells/BlogPostCell', + tags: ['autodocs'], +} + +export default meta + +export const loading: StoryObj = { + render: () => { + return Loading ? : <> + }, +} + +export const empty: StoryObj = { + render: () => { + return Empty ? : <> + }, +} + +export const failure: StoryObj = { + render: (args) => { + return Failure ? : <> + }, +} + +export const success: StoryObj = { + render: (args) => { + return Success ? : <> + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.test.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.test.tsx new file mode 100644 index 000000000000..0affa7cb9239 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@redwoodjs/testing/web' + +import { Loading, Empty, Failure, Success } from './BlogPostCell' +import { standard } from './BlogPostCell.mock' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float and DateTime types. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-cells +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('BlogPostCell', () => { + it('renders Loading successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Empty successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Failure successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + // When you're ready to test the actual output of your component render + // you could test that, for example, certain text is present: + // + // 1. import { screen } from '@redwoodjs/testing/web' + // 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument() + + it('renders Success successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.tsx new file mode 100644 index 000000000000..a763bbbce76c --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostCell/BlogPostCell.tsx @@ -0,0 +1,46 @@ +import type { + FindBlogPostQuery, + FindBlogPostQueryVariables, +} from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import BlogPost from 'src/components/BlogPost' + +export const QUERY: TypedDocumentNode< + FindBlogPostQuery, + FindBlogPostQueryVariables +> = gql` + query FindBlogPostQuery($id: Int!) { + blogPost: post(id: $id) { + id + title + body + author { + email + fullName + } + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ + error, +}: CellFailureProps) => ( +
Error: {error?.message}
+) + +export const Success = ({ + blogPost, +}: CellSuccessProps) => ( + +) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.mock.ts b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.mock.ts new file mode 100644 index 000000000000..d75f304b927b --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.mock.ts @@ -0,0 +1,41 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + blogPosts: [ + { + id: 42, + title: 'Mocked title', + body: 'Mocked body', + createdAt: '2022-01-17T13:57:51.607Z', + authorId: 5, + + author: { + email: 'five@5.com', + fullName: 'Five Lastname', + }, + }, + { + id: 43, + title: 'Mocked title', + body: 'Mocked body', + createdAt: '2022-01-17T13:57:51.607Z', + authorId: 5, + + author: { + email: 'five@5.com', + fullName: 'Five Lastname', + }, + }, + { + id: 44, + title: 'Mocked title', + body: 'Mocked body', + createdAt: '2022-01-17T13:57:51.607Z', + authorId: 5, + + author: { + email: 'five@5.com', + fullName: 'Five Lastname', + }, + }, + ], +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.stories.tsx new file mode 100644 index 000000000000..bbd0cb8b0f19 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Loading, Empty, Failure, Success } from './BlogPostsCell' +import { standard } from './BlogPostsCell.mock' + +const meta: Meta = { + title: 'Cells/BlogPostsCell', + tags: ['autodocs'], +} + +export default meta + +export const loading: StoryObj = { + render: () => { + return Loading ? : <> + }, +} + +export const empty: StoryObj = { + render: () => { + return Empty ? : <> + }, +} + +export const failure: StoryObj = { + render: (args) => { + return Failure ? : <> + }, +} + +export const success: StoryObj = { + render: (args) => { + return Success ? : <> + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.test.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.test.tsx new file mode 100644 index 000000000000..223be46186c9 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@redwoodjs/testing/web' + +import { Loading, Empty, Failure, Success } from './BlogPostsCell' +import { standard } from './BlogPostsCell.mock' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float and DateTime types. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-cells +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('BlogPostsCell', () => { + it('renders Loading successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Empty successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Failure successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + // When you're ready to test the actual output of your component render + // you could test that, for example, certain text is present: + // + // 1. import { screen } from '@redwoodjs/testing/web' + // 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument() + + it('renders Success successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.tsx b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.tsx new file mode 100644 index 000000000000..0a9d4931792e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/BlogPostsCell/BlogPostsCell.tsx @@ -0,0 +1,43 @@ +import type { BlogPostsQuery, BlogPostsQueryVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import BlogPost from 'src/components/BlogPost' + +export const QUERY: TypedDocumentNode< + BlogPostsQuery, + BlogPostsQueryVariables +> = gql` + query BlogPostsQuery { + blogPosts: posts { + id + title + body + author { + email + fullName + } + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ error }: CellFailureProps) => ( +
Error: {error?.message}
+) + +export const Success = ({ blogPosts }: CellSuccessProps) => ( +
+ {blogPosts.map((post) => ( + + ))} +
+) diff --git a/__fixtures__/fragment-test-project/web/src/components/Card/Card.tsx b/__fixtures__/fragment-test-project/web/src/components/Card.tsx similarity index 100% rename from __fixtures__/fragment-test-project/web/src/components/Card/Card.tsx rename to __fixtures__/fragment-test-project/web/src/components/Card.tsx diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/Contact/Contact.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/Contact/Contact.tsx new file mode 100644 index 000000000000..3864b6d57ae7 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/Contact/Contact.tsx @@ -0,0 +1,98 @@ +import type { + DeleteContactMutation, + DeleteContactMutationVariables, + FindContactById, +} from 'types/graphql' + +import { Link, routes, navigate } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { timeTag } from 'src/lib/formatters' + +const DELETE_CONTACT_MUTATION: TypedDocumentNode< + DeleteContactMutation, + DeleteContactMutationVariables +> = gql` + mutation DeleteContactMutation($id: Int!) { + deleteContact(id: $id) { + id + } + } +` + +interface Props { + contact: NonNullable +} + +const Contact = ({ contact }: Props) => { + const [deleteContact] = useMutation(DELETE_CONTACT_MUTATION, { + onCompleted: () => { + toast.success('Contact deleted') + navigate(routes.contacts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onDeleteClick = (id: DeleteContactMutationVariables['id']) => { + if (confirm('Are you sure you want to delete contact ' + id + '?')) { + deleteContact({ variables: { id } }) + } + } + + return ( + <> +
+
+

+ Contact {contact.id} Detail +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
Id{contact.id}
Name{contact.name}
Email{contact.email}
Message{contact.message}
Created at{timeTag(contact.createdAt)}
+
+ + + ) +} + +export default Contact diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/ContactCell/ContactCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactCell/ContactCell.tsx new file mode 100644 index 000000000000..309a5423c034 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactCell/ContactCell.tsx @@ -0,0 +1,40 @@ +import type { FindContactById, FindContactByIdVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Contact from 'src/components/Contact/Contact' + +export const QUERY: TypedDocumentNode< + FindContactById, + FindContactByIdVariables +> = gql` + query FindContactById($id: Int!) { + contact: contact(id: $id) { + id + name + email + message + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Contact not found
+ +export const Failure = ({ + error, +}: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + contact, +}: CellSuccessProps) => { + return +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/ContactForm/ContactForm.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactForm/ContactForm.tsx new file mode 100644 index 000000000000..f56f7f4a4219 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactForm/ContactForm.tsx @@ -0,0 +1,101 @@ +import type { EditContactById, UpdateContactInput } from 'types/graphql' + +import type { RWGqlError } from '@redwoodjs/forms' +import { + Form, + FormError, + FieldError, + Label, + TextField, + Submit, +} from '@redwoodjs/forms' + +type FormContact = NonNullable + +interface ContactFormProps { + contact?: EditContactById['contact'] + onSave: (data: UpdateContactInput, id?: FormContact['id']) => void + error: RWGqlError + loading: boolean +} + +const ContactForm = (props: ContactFormProps) => { + const onSubmit = (data: FormContact) => { + props.onSave(data, props?.contact?.id) + } + + return ( +
+ onSubmit={onSubmit} error={props.error}> + + + + + + + + + + + + + + + + + + + + +
+ + Save + +
+ +
+ ) +} + +export default ContactForm diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/Contacts/Contacts.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/Contacts/Contacts.tsx new file mode 100644 index 000000000000..c0830231111e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/Contacts/Contacts.tsx @@ -0,0 +1,102 @@ +import type { + DeleteContactMutation, + DeleteContactMutationVariables, + FindContacts, +} from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { QUERY } from 'src/components/Contact/ContactsCell' +import { timeTag, truncate } from 'src/lib/formatters' + +const DELETE_CONTACT_MUTATION: TypedDocumentNode< + DeleteContactMutation, + DeleteContactMutationVariables +> = gql` + mutation DeleteContactMutation($id: Int!) { + deleteContact(id: $id) { + id + } + } +` + +const ContactsList = ({ contacts }: FindContacts) => { + const [deleteContact] = useMutation(DELETE_CONTACT_MUTATION, { + onCompleted: () => { + toast.success('Contact deleted') + }, + onError: (error) => { + toast.error(error.message) + }, + // This refetches the query on the list page. Read more about other ways to + // update the cache over here: + // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + }) + + const onDeleteClick = (id: DeleteContactMutationVariables['id']) => { + if (confirm('Are you sure you want to delete contact ' + id + '?')) { + deleteContact({ variables: { id } }) + } + } + + return ( +
+ + + + + + + + + + + + + {contacts.map((contact) => ( + + + + + + + + + ))} + +
IdNameEmailMessageCreated at 
{truncate(contact.id)}{truncate(contact.name)}{truncate(contact.email)}{truncate(contact.message)}{timeTag(contact.createdAt)} + +
+
+ ) +} + +export default ContactsList diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/ContactsCell/ContactsCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactsCell/ContactsCell.tsx new file mode 100644 index 000000000000..bf6c2edd0d7d --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/ContactsCell/ContactsCell.tsx @@ -0,0 +1,48 @@ +import type { FindContacts, FindContactsVariables } from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Contacts from 'src/components/Contact/Contacts' + +export const QUERY: TypedDocumentNode< + FindContacts, + FindContactsVariables +> = gql` + query FindContacts { + contacts { + id + name + email + message + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () => { + return ( +
+ {'No contacts yet. '} + + {'Create one?'} + +
+ ) +} + +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + contacts, +}: CellSuccessProps) => { + return +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/EditContactCell/EditContactCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/EditContactCell/EditContactCell.tsx new file mode 100644 index 000000000000..f51c92b720a3 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/EditContactCell/EditContactCell.tsx @@ -0,0 +1,89 @@ +import type { + EditContactById, + UpdateContactInput, + UpdateContactMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' +import { useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import ContactForm from 'src/components/Contact/ContactForm' + +export const QUERY: TypedDocumentNode = gql` + query EditContactById($id: Int!) { + contact: contact(id: $id) { + id + name + email + message + createdAt + } + } +` + +const UPDATE_CONTACT_MUTATION: TypedDocumentNode< + EditContactById, + UpdateContactMutationVariables +> = gql` + mutation UpdateContactMutation($id: Int!, $input: UpdateContactInput!) { + updateContact(id: $id, input: $input) { + id + name + email + message + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ contact }: CellSuccessProps) => { + const [updateContact, { loading, error }] = useMutation( + UPDATE_CONTACT_MUTATION, + { + onCompleted: () => { + toast.success('Contact updated') + navigate(routes.contacts()) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + + const onSave = ( + input: UpdateContactInput, + id: EditContactById['contact']['id'] + ) => { + updateContact({ variables: { id, input } }) + } + + return ( +
+
+

+ Edit Contact {contact?.id} +

+
+
+ +
+
+ ) +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Contact/NewContact/NewContact.tsx b/__fixtures__/fragment-test-project/web/src/components/Contact/NewContact/NewContact.tsx new file mode 100644 index 000000000000..a5bfeefa18e6 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Contact/NewContact/NewContact.tsx @@ -0,0 +1,55 @@ +import type { + CreateContactMutation, + CreateContactInput, + CreateContactMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import ContactForm from 'src/components/Contact/ContactForm' + +const CREATE_CONTACT_MUTATION: TypedDocumentNode< + CreateContactMutation, + CreateContactMutationVariables +> = gql` + mutation CreateContactMutation($input: CreateContactInput!) { + createContact(input: $input) { + id + } + } +` + +const NewContact = () => { + const [createContact, { loading, error }] = useMutation( + CREATE_CONTACT_MUTATION, + { + onCompleted: () => { + toast.success('Contact created') + navigate(routes.contacts()) + }, + onError: (error) => { + toast.error(error.message) + }, + } + ) + + const onSave = (input: CreateContactInput) => { + createContact({ variables: { input } }) + } + + return ( +
+
+

New Contact

+
+
+ +
+
+ ) +} + +export default NewContact diff --git a/__fixtures__/fragment-test-project/web/src/components/Fruit.tsx b/__fixtures__/fragment-test-project/web/src/components/FruitInfo.tsx similarity index 74% rename from __fixtures__/fragment-test-project/web/src/components/Fruit.tsx rename to __fixtures__/fragment-test-project/web/src/components/FruitInfo.tsx index 8cdbb8f4bdd7..95015ee57764 100644 --- a/__fixtures__/fragment-test-project/web/src/components/Fruit.tsx +++ b/__fixtures__/fragment-test-project/web/src/components/FruitInfo.tsx @@ -2,8 +2,8 @@ import type { Fruit } from 'types/graphql' import { registerFragment } from '@redwoodjs/web/apollo' -import Card from 'src/components/Card/Card' -import Stall from 'src/components/Stall' +import Card from 'src/components/Card' +import StallInfo from 'src/components/StallInfo' const { useRegisteredFragment } = registerFragment( gql` @@ -19,21 +19,19 @@ const { useRegisteredFragment } = registerFragment( ` ) -const Fruit = ({ id }: { id: string }) => { +const FruitInfo = ({ id }: { id: string }) => { const { data: fruit, complete } = useRegisteredFragment(id) - console.log(fruit) - return ( complete && (

Fruit Name: {fruit.name}

Seeds? {fruit.isSeedless ? 'Yes' : 'No'}

Ripeness: {fruit.ripenessIndicators}

- +
) ) } -export default Fruit +export default FruitInfo diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/EditPostCell/EditPostCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/EditPostCell/EditPostCell.tsx new file mode 100644 index 000000000000..70f76473bb8e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/EditPostCell/EditPostCell.tsx @@ -0,0 +1,78 @@ +import type { + EditPostById, + UpdatePostInput, + UpdatePostMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' +import { useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import PostForm from 'src/components/Post/PostForm' + +export const QUERY: TypedDocumentNode = gql` + query EditPostById($id: Int!) { + post: post(id: $id) { + id + title + body + authorId + createdAt + } + } +` + +const UPDATE_POST_MUTATION: TypedDocumentNode< + EditPostById, + UpdatePostMutationVariables +> = gql` + mutation UpdatePostMutation($id: Int!, $input: UpdatePostInput!) { + updatePost(id: $id, input: $input) { + id + title + body + authorId + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ post }: CellSuccessProps) => { + const [updatePost, { loading, error }] = useMutation(UPDATE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post updated') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSave = (input: UpdatePostInput, id: EditPostById['post']['id']) => { + updatePost({ variables: { id, input } }) + } + + return ( +
+
+

+ Edit Post {post?.id} +

+
+
+ +
+
+ ) +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/NewPost/NewPost.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/NewPost/NewPost.tsx new file mode 100644 index 000000000000..3809b3b2f088 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/NewPost/NewPost.tsx @@ -0,0 +1,52 @@ +import type { + CreatePostMutation, + CreatePostInput, + CreatePostMutationVariables, +} from 'types/graphql' + +import { navigate, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import PostForm from 'src/components/Post/PostForm' + +const CREATE_POST_MUTATION: TypedDocumentNode< + CreatePostMutation, + CreatePostMutationVariables +> = gql` + mutation CreatePostMutation($input: CreatePostInput!) { + createPost(input: $input) { + id + } + } +` + +const NewPost = () => { + const [createPost, { loading, error }] = useMutation(CREATE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post created') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSave = (input: CreatePostInput) => { + createPost({ variables: { input } }) + } + + return ( +
+
+

New Post

+
+
+ +
+
+ ) +} + +export default NewPost diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/Post/Post.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/Post/Post.tsx new file mode 100644 index 000000000000..cf9512556964 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/Post/Post.tsx @@ -0,0 +1,98 @@ +import type { + DeletePostMutation, + DeletePostMutationVariables, + FindPostById, +} from 'types/graphql' + +import { Link, routes, navigate } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { timeTag } from 'src/lib/formatters' + +const DELETE_POST_MUTATION: TypedDocumentNode< + DeletePostMutation, + DeletePostMutationVariables +> = gql` + mutation DeletePostMutation($id: Int!) { + deletePost(id: $id) { + id + } + } +` + +interface Props { + post: NonNullable +} + +const Post = ({ post }: Props) => { + const [deletePost] = useMutation(DELETE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post deleted') + navigate(routes.posts()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onDeleteClick = (id: DeletePostMutationVariables['id']) => { + if (confirm('Are you sure you want to delete post ' + id + '?')) { + deletePost({ variables: { id } }) + } + } + + return ( + <> +
+
+

+ Post {post.id} Detail +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
Id{post.id}
Title{post.title}
Body{post.body}
Author id{post.authorId}
Created at{timeTag(post.createdAt)}
+
+ + + ) +} + +export default Post diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/PostCell/PostCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/PostCell/PostCell.tsx new file mode 100644 index 000000000000..8c90134ccfef --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/PostCell/PostCell.tsx @@ -0,0 +1,38 @@ +import type { FindPostById, FindPostByIdVariables } from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Post from 'src/components/Post/Post' + +export const QUERY: TypedDocumentNode< + FindPostById, + FindPostByIdVariables +> = gql` + query FindPostById($id: Int!) { + post: post(id: $id) { + id + title + body + authorId + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Post not found
+ +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + post, +}: CellSuccessProps) => { + return +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/PostForm/PostForm.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/PostForm/PostForm.tsx new file mode 100644 index 000000000000..02d4901a7f96 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/PostForm/PostForm.tsx @@ -0,0 +1,102 @@ +import type { EditPostById, UpdatePostInput } from 'types/graphql' + +import type { RWGqlError } from '@redwoodjs/forms' +import { + Form, + FormError, + FieldError, + Label, + TextField, + NumberField, + Submit, +} from '@redwoodjs/forms' + +type FormPost = NonNullable + +interface PostFormProps { + post?: EditPostById['post'] + onSave: (data: UpdatePostInput, id?: FormPost['id']) => void + error: RWGqlError + loading: boolean +} + +const PostForm = (props: PostFormProps) => { + const onSubmit = (data: FormPost) => { + props.onSave(data, props?.post?.id) + } + + return ( +
+ onSubmit={onSubmit} error={props.error}> + + + + + + + + + + + + + + + + + + + + +
+ + Save + +
+ +
+ ) +} + +export default PostForm diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/Posts/Posts.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/Posts/Posts.tsx new file mode 100644 index 000000000000..dfe9766df104 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/Posts/Posts.tsx @@ -0,0 +1,102 @@ +import type { + DeletePostMutation, + DeletePostMutationVariables, + FindPosts, +} from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import type { TypedDocumentNode } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +import { QUERY } from 'src/components/Post/PostsCell' +import { timeTag, truncate } from 'src/lib/formatters' + +const DELETE_POST_MUTATION: TypedDocumentNode< + DeletePostMutation, + DeletePostMutationVariables +> = gql` + mutation DeletePostMutation($id: Int!) { + deletePost(id: $id) { + id + } + } +` + +const PostsList = ({ posts }: FindPosts) => { + const [deletePost] = useMutation(DELETE_POST_MUTATION, { + onCompleted: () => { + toast.success('Post deleted') + }, + onError: (error) => { + toast.error(error.message) + }, + // This refetches the query on the list page. Read more about other ways to + // update the cache over here: + // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates + refetchQueries: [{ query: QUERY }], + awaitRefetchQueries: true, + }) + + const onDeleteClick = (id: DeletePostMutationVariables['id']) => { + if (confirm('Are you sure you want to delete post ' + id + '?')) { + deletePost({ variables: { id } }) + } + } + + return ( +
+ + + + + + + + + + + + + {posts.map((post) => ( + + + + + + + + + ))} + +
IdTitleBodyAuthor idCreated at 
{truncate(post.id)}{truncate(post.title)}{truncate(post.body)}{truncate(post.authorId)}{timeTag(post.createdAt)} + +
+
+ ) +} + +export default PostsList diff --git a/__fixtures__/fragment-test-project/web/src/components/Post/PostsCell/PostsCell.tsx b/__fixtures__/fragment-test-project/web/src/components/Post/PostsCell/PostsCell.tsx new file mode 100644 index 000000000000..c36d118aaf22 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/Post/PostsCell/PostsCell.tsx @@ -0,0 +1,45 @@ +import type { FindPosts, FindPostsVariables } from 'types/graphql' + +import { Link, routes } from '@redwoodjs/router' +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import Posts from 'src/components/Post/Posts' + +export const QUERY: TypedDocumentNode = gql` + query FindPosts { + posts { + id + title + body + authorId + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () => { + return ( +
+ {'No posts yet. '} + + {'Create one?'} + +
+ ) +} + +export const Failure = ({ error }: CellFailureProps) => ( +
{error?.message}
+) + +export const Success = ({ + posts, +}: CellSuccessProps) => { + return +} diff --git a/__fixtures__/fragment-test-project/web/src/components/Produce.tsx b/__fixtures__/fragment-test-project/web/src/components/ProduceInfo.tsx similarity index 73% rename from __fixtures__/fragment-test-project/web/src/components/Produce.tsx rename to __fixtures__/fragment-test-project/web/src/components/ProduceInfo.tsx index fe3798c80578..f06a68ad5e9d 100644 --- a/__fixtures__/fragment-test-project/web/src/components/Produce.tsx +++ b/__fixtures__/fragment-test-project/web/src/components/ProduceInfo.tsx @@ -2,7 +2,7 @@ import type { Produce } from 'types/graphql' import { registerFragment } from '@redwoodjs/web/apollo' -import Card from 'src/components/Card/Card' +import Card from 'src/components/Card' const { useRegisteredFragment } = registerFragment( gql` @@ -13,11 +13,9 @@ const { useRegisteredFragment } = registerFragment( ` ) -const Produce = ({ id }: { id: string }) => { +const ProduceInfo = ({ id }: { id: string }) => { const { data, complete } = useRegisteredFragment(id) - console.log('>>>>>>>>>>>Produce', data) - return ( complete && ( @@ -27,4 +25,4 @@ const Produce = ({ id }: { id: string }) => { ) } -export default Produce +export default ProduceInfo diff --git a/__fixtures__/fragment-test-project/web/src/components/Stall.tsx b/__fixtures__/fragment-test-project/web/src/components/StallInfo.tsx similarity index 83% rename from __fixtures__/fragment-test-project/web/src/components/Stall.tsx rename to __fixtures__/fragment-test-project/web/src/components/StallInfo.tsx index 3244714500bd..24b2fbb58d35 100644 --- a/__fixtures__/fragment-test-project/web/src/components/Stall.tsx +++ b/__fixtures__/fragment-test-project/web/src/components/StallInfo.tsx @@ -11,11 +11,9 @@ const { useRegisteredFragment } = registerFragment( ` ) -const Stall = ({ id }: { id: string }) => { +const StallInfo = ({ id }: { id: string }) => { const { data, complete } = useRegisteredFragment(id) - console.log(data) - return ( complete && (
@@ -25,4 +23,4 @@ const Stall = ({ id }: { id: string }) => { ) } -export default Stall +export default StallInfo diff --git a/__fixtures__/fragment-test-project/web/src/components/Vegetable.tsx b/__fixtures__/fragment-test-project/web/src/components/VegetableInfo.tsx similarity index 74% rename from __fixtures__/fragment-test-project/web/src/components/Vegetable.tsx rename to __fixtures__/fragment-test-project/web/src/components/VegetableInfo.tsx index 461cb9377f48..96f6208b19e9 100644 --- a/__fixtures__/fragment-test-project/web/src/components/Vegetable.tsx +++ b/__fixtures__/fragment-test-project/web/src/components/VegetableInfo.tsx @@ -2,8 +2,8 @@ import type { Vegetable } from 'types/graphql' import { registerFragment } from '@redwoodjs/web/apollo' -import Card from 'src/components/Card/Card' -import Stall from 'src/components/Stall' +import Card from 'src/components/Card' +import StallInfo from 'src/components/StallInfo' const { useRegisteredFragment } = registerFragment( gql` @@ -19,21 +19,19 @@ const { useRegisteredFragment } = registerFragment( ` ) -const Vegetable = ({ id }: { id: string }) => { +const VegetableInfo = ({ id }: { id: string }) => { const { data: vegetable, complete } = useRegisteredFragment(id) - console.log(vegetable) - return ( complete && (

Vegetable Name: {vegetable.name}

Pickled? {vegetable.isPickled ? 'Yes' : 'No'}

Family: {vegetable.vegetableFamily}

- +
) ) } -export default Vegetable +export default VegetableInfo diff --git a/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts new file mode 100644 index 000000000000..55dd744ca5a8 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.mock.ts @@ -0,0 +1,15 @@ +// Define your own mock data here: +export const standard = (/* vars, { ctx, req } */) => ({ + waterfallBlogPost: { + id: 42, + title: 'Mocked title', + body: 'Mocked body', + createdAt: '2022-01-17T13:57:51.607Z', + authorId: 7, + + author: { + email: 'se7en@7.com', + fullName: 'Se7en Lastname', + }, + }, +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.stories.tsx b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.stories.tsx new file mode 100644 index 000000000000..7109babeb381 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Loading, Empty, Failure, Success } from './WaterfallBlogPostCell' +import { standard } from './WaterfallBlogPostCell.mock' + +const meta: Meta = { + title: 'Cells/WaterfallBlogPostCell', + tags: ['autodocs'], +} + +export default meta + +export const loading: StoryObj = { + render: () => { + return Loading ? : <> + }, +} + +export const empty: StoryObj = { + render: () => { + return Empty ? : <> + }, +} + +export const failure: StoryObj = { + render: (args) => { + return Failure ? : <> + }, +} + +export const success: StoryObj = { + render: (args) => { + return Success ? : <> + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.test.tsx b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.test.tsx new file mode 100644 index 000000000000..9217b98d4c30 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.test.tsx @@ -0,0 +1,42 @@ +import { render } from '@redwoodjs/testing/web' + +import { Loading, Empty, Failure, Success } from './WaterfallBlogPostCell' +import { standard } from './WaterfallBlogPostCell.mock' + +// Generated boilerplate tests do not account for all circumstances +// and can fail without adjustments, e.g. Float and DateTime types. +// Please refer to the RedwoodJS Testing Docs: +// https://redwoodjs.com/docs/testing#testing-cells +// https://redwoodjs.com/docs/testing#jest-expect-type-considerations + +describe('WaterfallBlogPostCell', () => { + it('renders Loading successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Empty successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + it('renders Failure successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) + + // When you're ready to test the actual output of your component render + // you could test that, for example, certain text is present: + // + // 1. import { screen } from '@redwoodjs/testing/web' + // 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument() + + it('renders Success successfully', async () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.tsx b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.tsx new file mode 100644 index 000000000000..210b9153ca46 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/components/WaterfallBlogPostCell/WaterfallBlogPostCell.tsx @@ -0,0 +1,67 @@ +import type { + FindWaterfallBlogPostQuery, + FindWaterfallBlogPostQueryVariables, +} from 'types/graphql' + +import type { + CellSuccessProps, + CellFailureProps, + TypedDocumentNode, +} from '@redwoodjs/web' + +import AuthorCell from 'src/components/AuthorCell' + +export const QUERY: TypedDocumentNode< + FindWaterfallBlogPostQuery, + FindWaterfallBlogPostQueryVariables +> = gql` + query FindWaterfallBlogPostQuery($id: Int!) { + waterfallBlogPost: post(id: $id) { + id + title + body + authorId + createdAt + } + } +` + +export const Loading = () =>
Loading...
+ +export const Empty = () =>
Empty
+ +export const Failure = ({ + error, +}: CellFailureProps) => ( +
Error: {error?.message}
+) + +export const Success = ({ + waterfallBlogPost, +}: CellSuccessProps< + FindWaterfallBlogPostQuery, + FindWaterfallBlogPostQueryVariables +>) => ( +
+ {waterfallBlogPost && ( + <> +
+

+ {new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date(waterfallBlogPost.createdAt))}{' '} + - By: +

+

+ {waterfallBlogPost.title} +

+
+
+ {waterfallBlogPost.body} +
+ + )} +
+) diff --git a/__fixtures__/fragment-test-project/web/src/entry.client.tsx b/__fixtures__/fragment-test-project/web/src/entry.client.tsx index ffee44f85869..d55036f35465 100644 --- a/__fixtures__/fragment-test-project/web/src/entry.client.tsx +++ b/__fixtures__/fragment-test-project/web/src/entry.client.tsx @@ -9,6 +9,12 @@ import App from './App' */ const redwoodAppElement = document.getElementById('redwood-app') +if (!redwoodAppElement) { + throw new Error( + "Could not find an element with ID 'redwood-app'. Please ensure it exists in your 'web/src/index.html' file." + ) +} + if (redwoodAppElement.children?.length > 0) { hydrateRoot(redwoodAppElement, ) } else { diff --git a/__fixtures__/fragment-test-project/web/src/graphql/persistedOperations.json b/__fixtures__/fragment-test-project/web/src/graphql/persistedOperations.json deleted file mode 100644 index e5a8039db67f..000000000000 --- a/__fixtures__/fragment-test-project/web/src/graphql/persistedOperations.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "GetGroceries": "9198a2438e6e5dbcf42362657baca24494a9f6cf83a6033983d2a978c1c1c703", - "GetProduce": "a8ee227d80bda6e1f785083aac537e8f1cd0340e0b52faaa27e18dbe4d629241" -} \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/web/src/graphql/possible-types.ts b/__fixtures__/fragment-test-project/web/src/graphql/possible-types.ts deleted file mode 100644 index 889b16ceebfb..000000000000 --- a/__fixtures__/fragment-test-project/web/src/graphql/possible-types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface PossibleTypesResultData { - possibleTypes: { - [key: string]: string[] - } -} -const result: PossibleTypesResultData = { - possibleTypes: { - Groceries: ['Fruit', 'Vegetable'], - Grocery: ['Fruit', 'Vegetable'], - }, -} -export default result diff --git a/__fixtures__/fragment-test-project/web/src/graphql/possibleTypes.ts b/__fixtures__/fragment-test-project/web/src/graphql/possibleTypes.ts index 79a376225982..889b16ceebfb 100644 --- a/__fixtures__/fragment-test-project/web/src/graphql/possibleTypes.ts +++ b/__fixtures__/fragment-test-project/web/src/graphql/possibleTypes.ts @@ -1,20 +1,12 @@ - - export interface PossibleTypesResultData { - possibleTypes: { - [key: string]: string[] - } - } - const result: PossibleTypesResultData = { - "possibleTypes": { - "Groceries": [ - "Fruit", - "Vegetable" - ], - "Grocery": [ - "Fruit", - "Vegetable" - ] +export interface PossibleTypesResultData { + possibleTypes: { + [key: string]: string[] } -}; - export default result; - \ No newline at end of file +} +const result: PossibleTypesResultData = { + possibleTypes: { + Groceries: ['Fruit', 'Vegetable'], + Grocery: ['Fruit', 'Vegetable'], + }, +} +export default result diff --git a/__fixtures__/fragment-test-project/web/src/index.css b/__fixtures__/fragment-test-project/web/src/index.css index e69de29bb2d1..b31cb3378fae 100644 --- a/__fixtures__/fragment-test-project/web/src/index.css +++ b/__fixtures__/fragment-test-project/web/src/index.css @@ -0,0 +1,13 @@ +/** + * START --- SETUP TAILWINDCSS EDIT + * + * `yarn rw setup ui tailwindcss` placed these directives here + * to inject Tailwind's styles into your CSS. + * For more information, see: https://tailwindcss.com/docs/installation + */ +@tailwind base; +@tailwind components; +@tailwind utilities; +/** + * END --- SETUP TAILWINDCSS EDIT + */ diff --git a/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.stories.tsx b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.stories.tsx new file mode 100644 index 000000000000..43f3b5d106dc --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import BlogLayout from './BlogLayout' + +const meta: Meta = { + component: BlogLayout, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.test.tsx b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.test.tsx new file mode 100644 index 000000000000..f1ebed53c0a1 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import BlogLayout from './BlogLayout' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('BlogLayout', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.tsx b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.tsx new file mode 100644 index 000000000000..7236aa4b1f07 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/layouts/BlogLayout/BlogLayout.tsx @@ -0,0 +1,80 @@ +type BlogLayoutProps = { + children?: React.ReactNode +} + +import { Link, routes } from '@redwoodjs/router' + +import { useAuth } from 'src/auth' + +const BlogLayout = ({ children }: BlogLayoutProps) => { + const { logOut, isAuthenticated } = useAuth() + + return ( + <> +
+

+ + Redwood Blog + +

+ +
+
+ {children} +
+ + ) +} + +export default BlogLayout diff --git a/__fixtures__/fragment-test-project/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx b/__fixtures__/fragment-test-project/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx new file mode 100644 index 000000000000..2912b56706d6 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/layouts/ScaffoldLayout/ScaffoldLayout.tsx @@ -0,0 +1,37 @@ +import { Link, routes } from '@redwoodjs/router' +import { Toaster } from '@redwoodjs/web/toast' + +type LayoutProps = { + title: string + titleTo: string + buttonLabel: string + buttonTo: string + children: React.ReactNode +} + +const ScaffoldLayout = ({ + title, + titleTo, + buttonLabel, + buttonTo, + children, +}: LayoutProps) => { + return ( +
+ +
+

+ + {title} + +

+ +
+
{buttonLabel} + +
+
{children}
+
+ ) +} + +export default ScaffoldLayout diff --git a/__fixtures__/fragment-test-project/web/src/lib/formatters.test.tsx b/__fixtures__/fragment-test-project/web/src/lib/formatters.test.tsx new file mode 100644 index 000000000000..56593386e4f2 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/lib/formatters.test.tsx @@ -0,0 +1,192 @@ +import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import { + formatEnum, + jsonTruncate, + truncate, + timeTag, + jsonDisplay, + checkboxInputTag, +} from './formatters' + +describe('formatEnum', () => { + it('handles nullish values', () => { + expect(formatEnum(null)).toEqual('') + expect(formatEnum('')).toEqual('') + expect(formatEnum(undefined)).toEqual('') + }) + + it('formats a list of values', () => { + expect( + formatEnum(['RED', 'ORANGE', 'YELLOW', 'GREEN', 'BLUE', 'VIOLET']) + ).toEqual('Red, Orange, Yellow, Green, Blue, Violet') + }) + + it('formats a single value', () => { + expect(formatEnum('DARK_BLUE')).toEqual('Dark blue') + }) + + it('returns an empty string for values of the wrong type (for JS projects)', () => { + // @ts-expect-error - Testing JS scenario + expect(formatEnum(5)).toEqual('') + }) +}) + +describe('truncate', () => { + it('truncates really long strings', () => { + expect(truncate('na '.repeat(1000) + 'batman').length).toBeLessThan(1000) + expect(truncate('na '.repeat(1000) + 'batman')).not.toMatch(/batman/) + }) + + it('does not modify short strings', () => { + expect(truncate('Short strinG')).toEqual('Short strinG') + }) + + it('adds ... to the end of truncated strings', () => { + expect(truncate('repeat'.repeat(1000))).toMatch(/\w\.\.\.$/) + }) + + it('accepts numbers', () => { + expect(truncate(123)).toEqual('123') + expect(truncate(0)).toEqual('0') + expect(truncate(0o000)).toEqual('0') + }) + + it('handles arguments of invalid type', () => { + // @ts-expect-error - Testing JS scenario + expect(truncate(false)).toEqual('false') + + expect(truncate(undefined)).toEqual('') + expect(truncate(null)).toEqual('') + }) +}) + +describe('jsonTruncate', () => { + it('truncates large json structures', () => { + expect( + jsonTruncate({ + foo: 'foo', + bar: 'bar', + baz: 'baz', + kittens: 'kittens meow', + bazinga: 'Sheldon', + nested: { + foobar: 'I have no imagination', + two: 'Second nested item', + }, + five: 5, + bool: false, + }) + ).toMatch(/.+\n.+\w\.\.\.$/s) + }) +}) + +describe('timeTag', () => { + it('renders a date', async () => { + render(
{timeTag(new Date('1970-08-20').toUTCString())}
) + + await waitFor(() => screen.getByText(/1970.*00:00:00/)) + }) + + it('can take an empty input string', async () => { + expect(timeTag('')).toEqual('') + }) +}) + +describe('jsonDisplay', () => { + it('produces the correct output', () => { + expect( + jsonDisplay({ + title: 'TOML Example (but in JSON)', + database: { + data: [['delta', 'phi'], [3.14]], + enabled: true, + ports: [8000, 8001, 8002], + temp_targets: { + case: 72.0, + cpu: 79.5, + }, + }, + owner: { + dob: '1979-05-27T07:32:00-08:00', + name: 'Tom Preston-Werner', + }, + servers: { + alpha: { + ip: '10.0.0.1', + role: 'frontend', + }, + beta: { + ip: '10.0.0.2', + role: 'backend', + }, + }, + }) + ).toMatchInlineSnapshot(` +
+        
+          {
+        "title": "TOML Example (but in JSON)",
+        "database": {
+          "data": [
+            [
+              "delta",
+              "phi"
+            ],
+            [
+              3.14
+            ]
+          ],
+          "enabled": true,
+          "ports": [
+            8000,
+            8001,
+            8002
+          ],
+          "temp_targets": {
+            "case": 72,
+            "cpu": 79.5
+          }
+        },
+        "owner": {
+          "dob": "1979-05-27T07:32:00-08:00",
+          "name": "Tom Preston-Werner"
+        },
+        "servers": {
+          "alpha": {
+            "ip": "10.0.0.1",
+            "role": "frontend"
+          },
+          "beta": {
+            "ip": "10.0.0.2",
+            "role": "backend"
+          }
+        }
+      }
+        
+      
+ `) + }) +}) + +describe('checkboxInputTag', () => { + it('can be checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeChecked() + }) + + it('can be unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).not.toBeChecked() + }) + + it('is disabled when checked', () => { + render(checkboxInputTag(true)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) + + it('is disabled when unchecked', () => { + render(checkboxInputTag(false)) + expect(screen.getByRole('checkbox')).toBeDisabled() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/lib/formatters.tsx b/__fixtures__/fragment-test-project/web/src/lib/formatters.tsx new file mode 100644 index 000000000000..8ab9e806e3cd --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/lib/formatters.tsx @@ -0,0 +1,58 @@ +import React from 'react' + +import humanize from 'humanize-string' + +const MAX_STRING_LENGTH = 150 + +export const formatEnum = (values: string | string[] | null | undefined) => { + let output = '' + + if (Array.isArray(values)) { + const humanizedValues = values.map((value) => humanize(value)) + output = humanizedValues.join(', ') + } else if (typeof values === 'string') { + output = humanize(values) + } + + return output +} + +export const jsonDisplay = (obj: unknown) => { + return ( +
+      {JSON.stringify(obj, null, 2)}
+    
+ ) +} + +export const truncate = (value: string | number) => { + let output = value?.toString() ?? '' + + if (output.length > MAX_STRING_LENGTH) { + output = output.substring(0, MAX_STRING_LENGTH) + '...' + } + + return output +} + +export const jsonTruncate = (obj: unknown) => { + return truncate(JSON.stringify(obj, null, 2)) +} + +export const timeTag = (dateTime?: string) => { + let output: string | JSX.Element = '' + + if (dateTime) { + output = ( + + ) + } + + return output +} + +export const checkboxInputTag = (checked: boolean) => { + return +} diff --git a/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.stories.tsx new file mode 100644 index 000000000000..b8259100eb85 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import AboutPage from './AboutPage' + +const meta: Meta = { + component: AboutPage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.test.tsx new file mode 100644 index 000000000000..571b85e65599 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import AboutPage from './AboutPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('AboutPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.tsx new file mode 100644 index 000000000000..6428b03989d7 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/AboutPage/AboutPage.tsx @@ -0,0 +1,13 @@ +import { Link, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' + +const AboutPage = () => { + return ( +

+ This site was created to demonstrate my mastery of Redwood: Look on my + works, ye mighty, and despair! +

+ ) +} + +export default AboutPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts new file mode 100644 index 000000000000..389ac0183756 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.routeHooks.ts @@ -0,0 +1,5 @@ +import { db } from '$api/src/lib/db' + +export async function routeParameters() { + return (await db.post.findMany({ take: 7 })).map((post) => ({ id: post.id })) +} diff --git a/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.stories.tsx new file mode 100644 index 000000000000..b8abecc30483 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import BlogPostPage from './BlogPostPage' + +const meta: Meta = { + component: BlogPostPage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = { + render: (args) => { + return + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.test.tsx new file mode 100644 index 000000000000..707f289b3be6 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import BlogPostPage from './BlogPostPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('BlogPostPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.tsx new file mode 100644 index 000000000000..415fbe886478 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/BlogPostPage/BlogPostPage.tsx @@ -0,0 +1,20 @@ +import { Link, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' + +type BlogPostPageProps = { + id: number +} + +import BlogPostCell from 'src/components/BlogPostCell' + +const BlogPostPage = ({ id }: BlogPostPageProps) => { + return ( + <> + + + + + ) +} + +export default BlogPostPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactPage/ContactPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactPage/ContactPage.tsx new file mode 100644 index 000000000000..9af63b0a3d0e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactPage/ContactPage.tsx @@ -0,0 +1,11 @@ +import ContactCell from 'src/components/Contact/ContactCell' + +type ContactPageProps = { + id: number +} + +const ContactPage = ({ id }: ContactPageProps) => { + return +} + +export default ContactPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactsPage/ContactsPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactsPage/ContactsPage.tsx new file mode 100644 index 000000000000..7bc4048094fe --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Contact/ContactsPage/ContactsPage.tsx @@ -0,0 +1,7 @@ +import ContactsCell from 'src/components/Contact/ContactsCell' + +const ContactsPage = () => { + return +} + +export default ContactsPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Contact/EditContactPage/EditContactPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Contact/EditContactPage/EditContactPage.tsx new file mode 100644 index 000000000000..7241f71f7f34 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Contact/EditContactPage/EditContactPage.tsx @@ -0,0 +1,11 @@ +import EditContactCell from 'src/components/Contact/EditContactCell' + +type ContactPageProps = { + id: number +} + +const EditContactPage = ({ id }: ContactPageProps) => { + return +} + +export default EditContactPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Contact/NewContactPage/NewContactPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Contact/NewContactPage/NewContactPage.tsx new file mode 100644 index 000000000000..2d4cc9274eef --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Contact/NewContactPage/NewContactPage.tsx @@ -0,0 +1,7 @@ +import NewContact from 'src/components/Contact/NewContact' + +const NewContactPage = () => { + return +} + +export default NewContactPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.stories.tsx new file mode 100644 index 000000000000..80eb779856a4 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import ContactUsPage from './ContactUsPage' + +const meta: Meta = { + component: ContactUsPage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.test.tsx new file mode 100644 index 000000000000..8568edc66802 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import ContactUsPage from './ContactUsPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('ContactUsPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.tsx new file mode 100644 index 000000000000..529d72d8bfbc --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ContactUsPage/ContactUsPage.tsx @@ -0,0 +1,108 @@ +import { useForm } from 'react-hook-form' + +import { + Form, + TextField, + TextAreaField, + Submit, + FieldError, + Label, +} from '@redwoodjs/forms' +import { Link, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { useMutation } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +const CREATE_CONTACT = gql` + mutation CreateContactMutation($input: CreateContactInput!) { + createContact(input: $input) { + id + } + } +` + +const ContactUsPage = () => { + const formMethods = useForm() + + const [create, { loading, error }] = useMutation(CREATE_CONTACT, { + onCompleted: () => { + toast.success('Thank you for your submission!') + formMethods.reset() + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSubmit = (data) => { + create({ variables: { input: data } }) + console.log(data) + } + + return ( + <> + +
+ + + + + + + + + + + + + + Save + + + + ) +} + +export default ContactUsPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.stories.tsx new file mode 100644 index 000000000000..adb222dbce94 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import DoublePage from './DoublePage' + +const meta: Meta = { + component: DoublePage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.test.tsx new file mode 100644 index 000000000000..be5818c2d1d7 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import DoublePage from './DoublePage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('DoublePage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.tsx b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.tsx new file mode 100644 index 000000000000..fafa953fb3a4 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/DoublePage/DoublePage.tsx @@ -0,0 +1,25 @@ +import { Metadata } from '@redwoodjs/web' + +const DoublePage = () => { + return ( + <> + + +

DoublePage

+

+ This page exists to make sure we don't regress on{' '} + + #7757 + +

+

It needs to be a page that is not wrapped in a Set

+ + ) +} + +export default DoublePage diff --git a/__fixtures__/fragment-test-project/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx new file mode 100644 index 000000000000..4d3f34fe28d3 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react' + +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const ForgotPasswordPage = () => { + const { isAuthenticated, forgotPassword } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const usernameRef = useRef(null) + useEffect(() => { + usernameRef?.current?.focus() + }, []) + + const onSubmit = async (data: { username: string }) => { + const response = await forgotPassword(data.username) + + if (response.error) { + toast.error(response.error) + } else { + // The function `forgotPassword.handler` in api/src/functions/auth.js has + // been invoked, let the user know how to get the link to reset their + // password (sent in email, perhaps?) + toast.success( + 'A link to reset your password was sent to ' + response.email + ) + navigate(routes.login()) + } + } + + return ( + <> + + +
+ +
+
+
+

+ Forgot Password +

+
+ +
+
+
+
+ + + + +
+ +
+ Submit +
+
+
+
+
+
+
+ + ) +} + +export default ForgotPasswordPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.stories.tsx new file mode 100644 index 000000000000..86979d77f126 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import GroceriesPage from './GroceriesPage' + +const meta: Meta = { + component: GroceriesPage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.test.tsx new file mode 100644 index 000000000000..61548072a989 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import GroceriesPage from './GroceriesPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('GroceriesPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.tsx index ab5e464ffe89..5ce6f8a14302 100644 --- a/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.tsx +++ b/__fixtures__/fragment-test-project/web/src/pages/GroceriesPage/GroceriesPage.tsx @@ -1,11 +1,9 @@ -import type { GetGroceries, GetProduce } from 'types/graphql' +import type { GetGroceries, GetProduce } from "types/graphql"; +import { Metadata, useQuery } from '@redwoodjs/web'; -import { MetaTags } from '@redwoodjs/web' -import { useQuery } from '@redwoodjs/web' - -import Fruit from 'src/components/Fruit' -import Produce from 'src/components/Produce' -import Vegetable from 'src/components/Vegetable' +import FruitInfo from "src/components/FruitInfo"; +import ProduceInfo from "src/components/ProduceInfo"; +import VegetableInfo from "src/components/VegetableInfo"; const GET_GROCERIES = gql` query GetGroceries { @@ -14,17 +12,17 @@ const GET_GROCERIES = gql` ...Vegetable_info } } -` +`; const GET_PRODUCE = gql` query GetProduce { - produce { + produces { ...Produce_info } } -` +`; -const FruitsPage = () => { +const GroceriesPage = () => { const { data: groceryData, loading: groceryLoading } = useQuery(GET_GROCERIES) const { data: produceData, loading: produceLoading } = @@ -32,26 +30,26 @@ const FruitsPage = () => { return (
- +
{!groceryLoading && groceryData.groceries.map((fruit) => ( - + ))} {!groceryLoading && groceryData.groceries.map((vegetable) => ( - + ))} {!produceLoading && - produceData.produce.map((produce) => ( - + produceData.produces?.map((produce) => ( + ))}
) } -export default FruitsPage +export default GroceriesPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.stories.tsx new file mode 100644 index 000000000000..d9631ae6579d --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import HomePage from './HomePage' + +const meta: Meta = { + component: HomePage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.test.tsx new file mode 100644 index 000000000000..c684c7a1e13b --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import HomePage from './HomePage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('HomePage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.tsx b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.tsx new file mode 100644 index 000000000000..290c7a31f29a --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/HomePage/HomePage.tsx @@ -0,0 +1,10 @@ +import { Link, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' + +import BlogPostsCell from 'src/components/BlogPostsCell' + +const HomePage = () => { + return +} + +export default HomePage diff --git a/__fixtures__/fragment-test-project/web/src/pages/LoginPage/LoginPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 000000000000..a61d5ffaedee --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,134 @@ +import { useRef } from 'react' +import { useEffect } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const LoginPage = () => { + const { isAuthenticated, logIn } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const usernameRef = useRef(null) + useEffect(() => { + usernameRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await logIn({ + username: data.username, + password: data.password, + }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + toast.success('Welcome back!') + } + } + + return ( + <> + + +
+ +
+
+
+

Login

+
+ +
+
+
+ + + + + + + + +
+ + Forgot Password? + +
+ + + +
+ Login +
+ +
+
+
+
+ Don't have an account?{' '} + + Sign up! + +
+
+
+ + ) +} + +export default LoginPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Post/EditPostPage/EditPostPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Post/EditPostPage/EditPostPage.tsx new file mode 100644 index 000000000000..f3f8c7bfc820 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Post/EditPostPage/EditPostPage.tsx @@ -0,0 +1,11 @@ +import EditPostCell from 'src/components/Post/EditPostCell' + +type PostPageProps = { + id: number +} + +const EditPostPage = ({ id }: PostPageProps) => { + return +} + +export default EditPostPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Post/NewPostPage/NewPostPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Post/NewPostPage/NewPostPage.tsx new file mode 100644 index 000000000000..0b3c453cc3b6 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Post/NewPostPage/NewPostPage.tsx @@ -0,0 +1,7 @@ +import NewPost from 'src/components/Post/NewPost' + +const NewPostPage = () => { + return +} + +export default NewPostPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Post/PostPage/PostPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Post/PostPage/PostPage.tsx new file mode 100644 index 000000000000..ca4048740a0e --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Post/PostPage/PostPage.tsx @@ -0,0 +1,11 @@ +import PostCell from 'src/components/Post/PostCell' + +type PostPageProps = { + id: number +} + +const PostPage = ({ id }: PostPageProps) => { + return +} + +export default PostPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/Post/PostsPage/PostsPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/Post/PostsPage/PostsPage.tsx new file mode 100644 index 000000000000..f5b3668d4024 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/Post/PostsPage/PostsPage.tsx @@ -0,0 +1,7 @@ +import PostsCell from 'src/components/Post/PostsCell' + +const PostsPage = () => { + return +} + +export default PostsPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.stories.tsx new file mode 100644 index 000000000000..ebc171846e2a --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import ProfilePage from './ProfilePage' + +const meta: Meta = { + component: ProfilePage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.test.tsx new file mode 100644 index 000000000000..ef30ff78fed5 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.test.tsx @@ -0,0 +1,21 @@ +import { render, waitFor, screen } from '@redwoodjs/testing/web' + +import ProfilePage from './ProfilePage' + +describe('ProfilePage', () => { + it('renders successfully', async () => { + mockCurrentUser({ + email: 'danny@bazinga.com', + id: 84849020, + roles: 'BAZINGA', + }) + + await waitFor(async () => { + expect(() => { + render() + }).not.toThrow() + }) + + expect(await screen.findByText('danny@bazinga.com')).toBeInTheDocument() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.tsx b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.tsx new file mode 100644 index 000000000000..49911999021d --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ProfilePage/ProfilePage.tsx @@ -0,0 +1,55 @@ +import { Link, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' + +import { useAuth } from 'src/auth' + +const ProfilePage = () => { + const { currentUser, isAuthenticated, hasRole, loading } = useAuth() + + if (loading) { + return

Loading...

+ } + + return ( + <> + + +

Profile

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyValue
ID{currentUser.id}
ROLES{currentUser.roles}
EMAIL{currentUser.email}
isAuthenticated{JSON.stringify(isAuthenticated)}
Is Admin{JSON.stringify(hasRole('ADMIN'))}
+ + ) +} + +export default ProfilePage diff --git a/__fixtures__/fragment-test-project/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx new file mode 100644 index 000000000000..191b39d43231 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react' + +import { + Form, + Label, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => { + const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } = + useAuth() + const [enabled, setEnabled] = useState(true) + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + useEffect(() => { + const validateToken = async () => { + const response = await validateResetToken(resetToken) + if (response.error) { + setEnabled(false) + toast.error(response.error) + } else { + setEnabled(true) + } + } + validateToken() + }, [resetToken, validateResetToken]) + + const passwordRef = useRef(null) + useEffect(() => { + passwordRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await resetPassword({ + resetToken, + password: data.password, + }) + + if (response.error) { + toast.error(response.error) + } else { + toast.success('Password changed!') + await reauthenticate() + navigate(routes.login()) + } + } + + return ( + <> + + +
+ +
+
+
+

+ Reset Password +

+
+ +
+
+
+
+ + + + +
+ +
+ + Submit + +
+
+
+
+
+
+
+ + ) +} + +export default ResetPasswordPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/SignupPage/SignupPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/SignupPage/SignupPage.tsx new file mode 100644 index 000000000000..d92e41baeeb1 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/SignupPage/SignupPage.tsx @@ -0,0 +1,148 @@ +import { useRef } from 'react' +import { useEffect } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + FieldError, + Submit, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const SignupPage = () => { + const { isAuthenticated, signUp } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + // focus on username box on page load + const usernameRef = useRef(null) + useEffect(() => { + usernameRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await signUp({ + username: data.username, + password: data.password, + 'full-name': data['full-name'], + }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + // user is signed in automatically + toast.success('Welcome!') + } + } + + return ( + <> + + +
+ +
+
+
+

Signup

+
+ +
+
+
+ + + + + + + + + + + + +
+ + Sign Up + +
+ +
+
+
+
+ Already have an account?{' '} + + Log in! + +
+
+
+ + ) +} + +export default SignupPage diff --git a/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts new file mode 100644 index 000000000000..88a6dd0b6166 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.routeHooks.ts @@ -0,0 +1,3 @@ +export async function routeParameters() { + return [{ id: 2 }] +} diff --git a/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.stories.tsx b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.stories.tsx new file mode 100644 index 000000000000..9b15c7347441 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import WaterfallPage from './WaterfallPage' + +const meta: Meta = { + component: WaterfallPage, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = { + render: (args) => { + return + }, +} diff --git a/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.test.tsx b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.test.tsx new file mode 100644 index 000000000000..3f0b4e17d567 --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import WaterfallPage from './WaterfallPage' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-pages-layouts + +describe('WaterfallPage', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.tsx b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.tsx new file mode 100644 index 000000000000..6c4f24a14c6d --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/pages/WaterfallPage/WaterfallPage.tsx @@ -0,0 +1,11 @@ +import WaterfallBlogPostCell from 'src/components/WaterfallBlogPostCell' + +type WaterfallPageProps = { + id: number +} + +const WaterfallPage = ({ id }: WaterfallPageProps) => ( + +) + +export default WaterfallPage diff --git a/__fixtures__/fragment-test-project/web/src/scaffold.css b/__fixtures__/fragment-test-project/web/src/scaffold.css new file mode 100644 index 000000000000..ffa9142b717f --- /dev/null +++ b/__fixtures__/fragment-test-project/web/src/scaffold.css @@ -0,0 +1,243 @@ +.rw-scaffold { + @apply bg-white text-gray-600; +} +.rw-scaffold h1, +.rw-scaffold h2 { + @apply m-0; +} +.rw-scaffold a { + @apply bg-transparent; +} +.rw-scaffold ul { + @apply m-0 p-0; +} +.rw-scaffold input:-ms-input-placeholder { + @apply text-gray-500; +} +.rw-scaffold input::-ms-input-placeholder { + @apply text-gray-500; +} +.rw-scaffold input::placeholder { + @apply text-gray-500; +} +.rw-header { + @apply flex justify-between px-8 py-4; +} +.rw-main { + @apply mx-4 pb-4; +} +.rw-segment { + @apply w-full overflow-hidden rounded-lg border border-gray-200; + scrollbar-color: theme('colors.zinc.400') transparent; +} +.rw-segment::-webkit-scrollbar { + height: initial; +} +.rw-segment::-webkit-scrollbar-track { + @apply rounded-b-[10px] rounded-t-none border-0 border-t border-solid border-gray-200 bg-transparent p-[2px]; +} +.rw-segment::-webkit-scrollbar-thumb { + @apply rounded-full border-[3px] border-solid border-transparent bg-zinc-400 bg-clip-content; +} +.rw-segment-header { + @apply bg-gray-200 px-4 py-3 text-gray-700; +} +.rw-segment-main { + @apply bg-gray-100 p-4; +} +.rw-link { + @apply text-blue-400 underline; +} +.rw-link:hover { + @apply text-blue-500; +} +.rw-forgot-link { + @apply mt-1 text-right text-xs text-gray-400 underline; +} +.rw-forgot-link:hover { + @apply text-blue-500; +} +.rw-heading { + @apply font-semibold; +} +.rw-heading.rw-heading-primary { + @apply text-xl; +} +.rw-heading.rw-heading-secondary { + @apply text-sm; +} +.rw-heading .rw-link { + @apply text-gray-600 no-underline; +} +.rw-heading .rw-link:hover { + @apply text-gray-900 underline; +} +.rw-cell-error { + @apply text-sm font-semibold; +} +.rw-form-wrapper { + @apply -mt-4 text-sm; +} +.rw-cell-error, +.rw-form-error-wrapper { + @apply my-4 rounded border border-red-100 bg-red-50 p-4 text-red-600; +} +.rw-form-error-title { + @apply m-0 font-semibold; +} +.rw-form-error-list { + @apply mt-2 list-inside list-disc; +} +.rw-button { + @apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100; +} +.rw-button:hover { + @apply bg-gray-500 text-white; +} +.rw-button.rw-button-small { + @apply rounded-sm px-2 py-1 text-xs; +} +.rw-button.rw-button-green { + @apply bg-green-500 text-white; +} +.rw-button.rw-button-green:hover { + @apply bg-green-700; +} +.rw-button.rw-button-blue { + @apply bg-blue-500 text-white; +} +.rw-button.rw-button-blue:hover { + @apply bg-blue-700; +} +.rw-button.rw-button-red { + @apply bg-red-500 text-white; +} +.rw-button.rw-button-red:hover { + @apply bg-red-700 text-white; +} +.rw-button-icon { + @apply mr-1 text-xl leading-5; +} +.rw-button-group { + @apply mx-2 my-3 flex justify-center; +} +.rw-button-group .rw-button { + @apply mx-1; +} +.rw-form-wrapper .rw-button-group { + @apply mt-8; +} +.rw-label { + @apply mt-6 block text-left font-semibold text-gray-600; +} +.rw-label.rw-label-error { + @apply text-red-600; +} +.rw-input { + @apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none; +} +.rw-check-radio-items { + @apply flex justify-items-center; +} +.rw-check-radio-item-none { + @apply text-gray-600; +} +.rw-input[type='checkbox'], +.rw-input[type='radio'] { + @apply ml-0 mr-1 mt-1 inline w-4; +} +.rw-input:focus { + @apply border-gray-400; +} +.rw-input-error { + @apply border-red-600 text-red-600; +} +.rw-input-error:focus { + @apply border-red-600 outline-none; + box-shadow: 0 0 5px #c53030; +} +.rw-field-error { + @apply mt-1 block text-xs font-semibold uppercase text-red-600; +} +.rw-table-wrapper-responsive { + @apply overflow-x-auto; +} +.rw-table-wrapper-responsive .rw-table { + min-width: 48rem; +} +.rw-table { + @apply w-full text-sm; +} +.rw-table th, +.rw-table td { + @apply p-3; +} +.rw-table td { + @apply bg-white text-gray-900; +} +.rw-table tr:nth-child(odd) td, +.rw-table tr:nth-child(odd) th { + @apply bg-gray-50; +} +.rw-table thead tr { + @apply bg-gray-200 text-gray-600; +} +.rw-table th { + @apply text-left font-semibold; +} +.rw-table thead th { + @apply text-left; +} +.rw-table tbody th { + @apply text-right; +} +@media (min-width: 768px) { + .rw-table tbody th { + @apply w-1/5; + } +} +.rw-table tbody tr { + @apply border-t border-gray-200; +} +.rw-table input { + @apply ml-0; +} +.rw-table-actions { + @apply flex h-4 items-center justify-end pr-1; +} +.rw-table-actions .rw-button { + @apply bg-transparent; +} +.rw-table-actions .rw-button:hover { + @apply bg-gray-500 text-white; +} +.rw-table-actions .rw-button-blue { + @apply text-blue-500; +} +.rw-table-actions .rw-button-blue:hover { + @apply bg-blue-500 text-white; +} +.rw-table-actions .rw-button-red { + @apply text-red-600; +} +.rw-table-actions .rw-button-red:hover { + @apply bg-red-600 text-white; +} +.rw-text-center { + @apply text-center; +} +.rw-login-container { + @apply mx-auto my-16 flex w-96 flex-wrap items-center justify-center; +} +.rw-login-container .rw-form-wrapper { + @apply w-full text-center; +} +.rw-login-link { + @apply mt-4 w-full text-center text-sm text-gray-600; +} +.rw-webauthn-wrapper { + @apply mx-4 mt-6 leading-6; +} +.rw-webauthn-wrapper h2 { + @apply mb-4 text-xl font-bold; +} diff --git a/__fixtures__/fragment-test-project/web/tsconfig.json b/__fixtures__/fragment-test-project/web/tsconfig.json index 8b5649abe5a4..b6b53c03d1f4 100644 --- a/__fixtures__/fragment-test-project/web/tsconfig.json +++ b/__fixtures__/fragment-test-project/web/tsconfig.json @@ -25,12 +25,13 @@ "types/*": ["./types/*", "../types/*"], "@redwoodjs/testing": ["../node_modules/@redwoodjs/testing/web"] }, - "typeRoots": ["../node_modules/@types", "./node_modules/@types"], - "types": ["jest", "@testing-library/jest-dom"], + "typeRoots": ["../node_modules/@types", "./node_modules/@types", "../node_modules/@testing-library"], + "types": ["jest", "jest-dom"], "jsx": "preserve" }, "include": [ "src", + "config", "../.redwood/types/includes/all-*", "../.redwood/types/includes/web-*", "../types", diff --git a/__fixtures__/fragment-test-project/web/types/graphql.d.ts b/__fixtures__/fragment-test-project/web/types/graphql.d.ts index 04701b267972..382f3efb64bf 100644 --- a/__fixtures__/fragment-test-project/web/types/graphql.d.ts +++ b/__fixtures__/fragment-test-project/web/types/graphql.d.ts @@ -19,6 +19,51 @@ export type Scalars = { Time: string; }; +export type Contact = { + __typename?: 'Contact'; + createdAt: Scalars['DateTime']; + email: Scalars['String']; + id: Scalars['Int']; + message: Scalars['String']; + name: Scalars['String']; +}; + +export type CreateContactInput = { + email: Scalars['String']; + message: Scalars['String']; + name: Scalars['String']; +}; + +export type CreatePostInput = { + authorId: Scalars['Int']; + body: Scalars['String']; + title: Scalars['String']; +}; + +export type CreateProduceInput = { + isPickled?: InputMaybe; + isSeedless?: InputMaybe; + name: Scalars['String']; + nutrients?: InputMaybe; + price: Scalars['Int']; + quantity: Scalars['Int']; + region: Scalars['String']; + ripenessIndicators?: InputMaybe; + stallId: Scalars['String']; + vegetableFamily?: InputMaybe; +}; + +export type CreateStallInput = { + name: Scalars['String']; + stallNumber: Scalars['String']; +}; + +export type CreateUserInput = { + email: Scalars['String']; + fullName: Scalars['String']; + roles?: InputMaybe; +}; + export type Fruit = Grocery & { __typename?: 'Fruit'; id: Scalars['ID']; @@ -46,21 +91,140 @@ export type Grocery = { stall: Stall; }; +export type Mutation = { + __typename?: 'Mutation'; + createContact?: Maybe; + createPost: Post; + createProduce: Produce; + createStall: Stall; + deleteContact: Contact; + deletePost: Post; + deleteProduce: Produce; + deleteStall: Stall; + updateContact: Contact; + updatePost: Post; + updateProduce: Produce; + updateStall: Stall; +}; + + +export type MutationcreateContactArgs = { + input: CreateContactInput; +}; + + +export type MutationcreatePostArgs = { + input: CreatePostInput; +}; + + +export type MutationcreateProduceArgs = { + input: CreateProduceInput; +}; + + +export type MutationcreateStallArgs = { + input: CreateStallInput; +}; + + +export type MutationdeleteContactArgs = { + id: Scalars['Int']; +}; + + +export type MutationdeletePostArgs = { + id: Scalars['Int']; +}; + + +export type MutationdeleteProduceArgs = { + id: Scalars['String']; +}; + + +export type MutationdeleteStallArgs = { + id: Scalars['String']; +}; + + +export type MutationupdateContactArgs = { + id: Scalars['Int']; + input: UpdateContactInput; +}; + + +export type MutationupdatePostArgs = { + id: Scalars['Int']; + input: UpdatePostInput; +}; + + +export type MutationupdateProduceArgs = { + id: Scalars['String']; + input: UpdateProduceInput; +}; + + +export type MutationupdateStallArgs = { + id: Scalars['String']; + input: UpdateStallInput; +}; + +export type Post = { + __typename?: 'Post'; + author: User; + authorId: Scalars['Int']; + body: Scalars['String']; + createdAt: Scalars['DateTime']; + id: Scalars['Int']; + title: Scalars['String']; +}; + +export type Produce = { + __typename?: 'Produce'; + id: Scalars['String']; + isPickled?: Maybe; + isSeedless?: Maybe; + name: Scalars['String']; + nutrients?: Maybe; + price: Scalars['Int']; + quantity: Scalars['Int']; + region: Scalars['String']; + ripenessIndicators?: Maybe; + stall: Stall; + stallId: Scalars['String']; + vegetableFamily?: Maybe; +}; + /** About the Redwood queries. */ export type Query = { __typename?: 'Query'; + contact?: Maybe; + contacts: Array; fruitById?: Maybe; fruits: Array; groceries: Array; + post?: Maybe; + posts: Array; + produce?: Maybe; + produces: Array; /** Fetches the Redwood root schema. */ redwood?: Maybe; - stallById?: Maybe; + stall?: Maybe; stalls: Array; + user?: Maybe; vegetableById?: Maybe; vegetables: Array; }; +/** About the Redwood queries. */ +export type QuerycontactArgs = { + id: Scalars['Int']; +}; + + /** About the Redwood queries. */ export type QueryfruitByIdArgs = { id: Scalars['ID']; @@ -68,8 +232,26 @@ export type QueryfruitByIdArgs = { /** About the Redwood queries. */ -export type QuerystallByIdArgs = { - id: Scalars['ID']; +export type QuerypostArgs = { + id: Scalars['Int']; +}; + + +/** About the Redwood queries. */ +export type QueryproduceArgs = { + id: Scalars['String']; +}; + + +/** About the Redwood queries. */ +export type QuerystallArgs = { + id: Scalars['String']; +}; + + +/** About the Redwood queries. */ +export type QueryuserArgs = { + id: Scalars['Int']; }; @@ -95,11 +277,55 @@ export type Redwood = { export type Stall = { __typename?: 'Stall'; - fruits?: Maybe>>; - id: Scalars['ID']; + id: Scalars['String']; name: Scalars['String']; + produce: Array>; stallNumber: Scalars['String']; - vegetables?: Maybe>>; +}; + +export type UpdateContactInput = { + email?: InputMaybe; + message?: InputMaybe; + name?: InputMaybe; +}; + +export type UpdatePostInput = { + authorId?: InputMaybe; + body?: InputMaybe; + title?: InputMaybe; +}; + +export type UpdateProduceInput = { + isPickled?: InputMaybe; + isSeedless?: InputMaybe; + name?: InputMaybe; + nutrients?: InputMaybe; + price?: InputMaybe; + quantity?: InputMaybe; + region?: InputMaybe; + ripenessIndicators?: InputMaybe; + stallId?: InputMaybe; + vegetableFamily?: InputMaybe; +}; + +export type UpdateStallInput = { + name?: InputMaybe; + stallNumber?: InputMaybe; +}; + +export type UpdateUserInput = { + email?: InputMaybe; + fullName?: InputMaybe; + roles?: InputMaybe; +}; + +export type User = { + __typename?: 'User'; + email: Scalars['String']; + fullName: Scalars['String']; + id: Scalars['Int']; + posts: Array>; + roles?: Maybe; }; export type Vegetable = Grocery & { @@ -117,7 +343,118 @@ export type Vegetable = Grocery & { vegetableFamily?: Maybe; }; -export type GetGroceriesVariables = Exact<{ [key: string]: never; }>; +export type FindAuthorQueryVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type FindAuthorQuery = { __typename?: 'Query', author?: { __typename?: 'User', email: string, fullName: string } | null }; + +export type FindBlogPostQueryVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type FindBlogPostQuery = { __typename?: 'Query', blogPost?: { __typename?: 'Post', id: number, title: string, body: string, createdAt: string, author: { __typename?: 'User', email: string, fullName: string } } | null }; + +export type BlogPostsQueryVariables = Exact<{ [key: string]: never; }>; + + +export type BlogPostsQuery = { __typename?: 'Query', blogPosts: Array<{ __typename?: 'Post', id: number, title: string, body: string, createdAt: string, author: { __typename?: 'User', email: string, fullName: string } }> }; + +export type DeleteContactMutationVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type DeleteContactMutation = { __typename?: 'Mutation', deleteContact: { __typename?: 'Contact', id: number } }; + +export type FindContactByIdVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type FindContactById = { __typename?: 'Query', contact?: { __typename?: 'Contact', id: number, name: string, email: string, message: string, createdAt: string } | null }; + +export type FindContactsVariables = Exact<{ [key: string]: never; }>; + + +export type FindContacts = { __typename?: 'Query', contacts: Array<{ __typename?: 'Contact', id: number, name: string, email: string, message: string, createdAt: string }> }; + +export type EditContactByIdVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type EditContactById = { __typename?: 'Query', contact?: { __typename?: 'Contact', id: number, name: string, email: string, message: string, createdAt: string } | null }; + +export type UpdateContactMutationVariables = Exact<{ + id: Scalars['Int']; + input: UpdateContactInput; +}>; + + +export type UpdateContactMutation = { __typename?: 'Mutation', updateContact: { __typename?: 'Contact', id: number, name: string, email: string, message: string, createdAt: string } }; + +export type CreateContactMutationVariables = Exact<{ + input: CreateContactInput; +}>; + + +export type CreateContactMutation = { __typename?: 'Mutation', createContact?: { __typename?: 'Contact', id: number } | null }; + +export type Fruit_info = { __typename?: 'Fruit', id: string, name: string, isSeedless?: boolean | null, ripenessIndicators?: string | null, stall: { __typename?: 'Stall', id: string, name: string } }; + +export type EditPostByIdVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type EditPostById = { __typename?: 'Query', post?: { __typename?: 'Post', id: number, title: string, body: string, authorId: number, createdAt: string } | null }; + +export type UpdatePostMutationVariables = Exact<{ + id: Scalars['Int']; + input: UpdatePostInput; +}>; + + +export type UpdatePostMutation = { __typename?: 'Mutation', updatePost: { __typename?: 'Post', id: number, title: string, body: string, authorId: number, createdAt: string } }; + +export type CreatePostMutationVariables = Exact<{ + input: CreatePostInput; +}>; + + +export type CreatePostMutation = { __typename?: 'Mutation', createPost: { __typename?: 'Post', id: number } }; + +export type DeletePostMutationVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type DeletePostMutation = { __typename?: 'Mutation', deletePost: { __typename?: 'Post', id: number } }; + +export type FindPostByIdVariables = Exact<{ + id: Scalars['Int']; +}>; + + +export type FindPostById = { __typename?: 'Query', post?: { __typename?: 'Post', id: number, title: string, body: string, authorId: number, createdAt: string } | null }; + +export type FindPostsVariables = Exact<{ [key: string]: never; }>; + + +export type FindPosts = { __typename?: 'Query', posts: Array<{ __typename?: 'Post', id: number, title: string, body: string, authorId: number, createdAt: string }> }; + +export type Produce_info = { __typename?: 'Produce', id: string, name: string }; + +export type Stall_info = { __typename?: 'Stall', id: string, name: string }; + +export type Vegetable_info = { __typename?: 'Vegetable', id: string, name: string, vegetableFamily?: string | null, isPickled?: boolean | null, stall: { __typename?: 'Stall', id: string, name: string } }; + +export type FindWaterfallBlogPostQueryVariables = Exact<{ + id: Scalars['Int']; +}>; -export type GetGroceries = { __typename?: 'Query', groceries: Array<{ __typename?: 'Fruit', id: string, name: string, isSeedless?: boolean | null, ripenessIndicators?: string | null } | { __typename?: 'Vegetable', id: string, name: string, vegetableFamily?: string | null, isPickled?: boolean | null }> }; +export type FindWaterfallBlogPostQuery = { __typename?: 'Query', waterfallBlogPost?: { __typename?: 'Post', id: number, title: string, body: string, authorId: number, createdAt: string } | null }; diff --git a/__fixtures__/fragment-test-project/web/types/possible-types.ts b/__fixtures__/fragment-test-project/web/types/possible-types.ts deleted file mode 100644 index 79a376225982..000000000000 --- a/__fixtures__/fragment-test-project/web/types/possible-types.ts +++ /dev/null @@ -1,20 +0,0 @@ - - export interface PossibleTypesResultData { - possibleTypes: { - [key: string]: string[] - } - } - const result: PossibleTypesResultData = { - "possibleTypes": { - "Groceries": [ - "Fruit", - "Vegetable" - ], - "Grocery": [ - "Fruit", - "Vegetable" - ] - } -}; - export default result; - \ No newline at end of file diff --git a/__fixtures__/fragment-test-project/web/vite.config.ts b/__fixtures__/fragment-test-project/web/vite.config.ts index ddeb06d9a1cf..54799ce1aa28 100644 --- a/__fixtures__/fragment-test-project/web/vite.config.ts +++ b/__fixtures__/fragment-test-project/web/vite.config.ts @@ -1,15 +1,16 @@ -import dns from 'dns'; -import type { UserConfig } from 'vite'; -import { defineConfig } from 'vite'; - -// See: https://vitejs.dev/config/server-options.html#server-host -// So that Vite will load on local instead of 127.0.0.1 -dns.setDefaultResultOrder('verbatim'); -import redwood from '@redwoodjs/vite'; +import dns from 'dns' + +import type { UserConfig } from 'vite' +import { defineConfig } from 'vite' + +import redwood from '@redwoodjs/vite' + +// So that Vite will load on localhost instead of `127.0.0.1`. +// See: https://vitejs.dev/config/server-options.html#server-host. +dns.setDefaultResultOrder('verbatim') + const viteConfig: UserConfig = { plugins: [redwood()], - optimizeDeps: { - force: true - } -}; -export default defineConfig(viteConfig); \ No newline at end of file +} + +export default defineConfig(viteConfig) diff --git a/__fixtures__/test-project/scripts/seed.ts b/__fixtures__/test-project/scripts/seed.ts index 1b3aea0bf565..dcbfc7a9abe9 100644 --- a/__fixtures__/test-project/scripts/seed.ts +++ b/__fixtures__/test-project/scripts/seed.ts @@ -20,11 +20,11 @@ export default async () => { }, ] - await Promise.all( - users.map(async (user) => { - const newUser = await db.user.create({ data: user }) - }) - ) + if ((await db.user.count()) === 0) { + await Promise.all(users.map((user) => db.user.create({ data: user }))) + } else { + console.log('Users already seeded') + } } catch (error) { console.error(error) } @@ -48,13 +48,17 @@ export default async () => { }, ] - await Promise.all( - posts.map(async (post) => { - const newPost = await db.post.create({ data: post }) + if ((await db.post.count()) === 0) { + await Promise.all( + posts.map(async (post) => { + const newPost = await db.post.create({ data: post }) - console.log(newPost) - }) - ) + console.log(newPost) + }) + ) + } else { + console.log('Posts already seeded') + } } catch (error) { console.error(error) } diff --git a/package.json b/package.json index 40df68ed87aa..1b8ac389f7bd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "project:sync": "node ./tasks/framework-tools/frameworkSyncToProject.mjs", "project:tarsync": "node ./tasks/framework-tools/tarsync.mjs", "rebuild-test-project-fixture": "tsx ./tasks/test-project/rebuild-test-project-fixture.ts", + "rebuild-fragments-test-project-fixture": "tsx ./tasks/test-project/rebuild-fragments-test-project-fixture.ts", "release": "node ./tasks/release/release.mjs", "release:compare": "node ./tasks/release/compare/compare.mjs", "release:notes": "node ./tasks/release/generateReleaseNotes.mjs", diff --git a/packages/cli/src/commands/buildHandler.js b/packages/cli/src/commands/buildHandler.js index 78787ab4ce71..5c801f2dd2b4 100644 --- a/packages/cli/src/commands/buildHandler.js +++ b/packages/cli/src/commands/buildHandler.js @@ -8,6 +8,7 @@ import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' import { buildApi } from '@redwoodjs/internal/dist/build/api' +import { generate } from '@redwoodjs/internal/dist/generate/generate' import { loadAndValidateSdls } from '@redwoodjs/internal/dist/validateSchema' import { detectPrerenderRoutes } from '@redwoodjs/prerender/detection' import { timedTelemetry } from '@redwoodjs/telemetry' @@ -32,7 +33,11 @@ export const handler = async ({ prisma, prerender, }) + const rwjsPaths = getPaths() + const rwjsConfig = getConfig() + const useFragments = rwjsConfig.graphql?.fragments + const useTrustedDocuments = rwjsConfig.graphql?.trustedDocuments if (performance) { console.log('Measuring Web Build Performance...') @@ -75,6 +80,20 @@ export const handler = async ({ }) }, }, + // If using GraphQL Fragments or Trusted Documents, then we need to use + // codegen to generate the types needed for possible types and the + // trusted document store hashes + (useFragments || useTrustedDocuments) && { + title: `Generating types needed for ${[ + useFragments && 'GraphQL Fragments', + useTrustedDocuments && 'Trusted Documents', + ] + .filter(Boolean) + .join(' and ')} support...`, + task: async () => { + await generate() + }, + }, side.includes('api') && { title: 'Verifying graphql schema...', task: loadAndValidateSdls, diff --git a/packages/create-redwood-app/templates/js/web/src/App.jsx b/packages/create-redwood-app/templates/js/web/src/App.jsx index 9216dd846148..97fb5e02520d 100644 --- a/packages/create-redwood-app/templates/js/web/src/App.jsx +++ b/packages/create-redwood-app/templates/js/web/src/App.jsx @@ -1,7 +1,6 @@ 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' @@ -10,13 +9,7 @@ import './index.css' const App = () => ( - + diff --git a/packages/create-redwood-app/templates/js/web/src/graphql/possibleTypes.js b/packages/create-redwood-app/templates/js/web/src/graphql/possibleTypes.js deleted file mode 100644 index 366e19a8aeae..000000000000 --- a/packages/create-redwood-app/templates/js/web/src/graphql/possibleTypes.js +++ /dev/null @@ -1,5 +0,0 @@ -const result = { - possibleTypes: {}, -} - -export default result diff --git a/packages/create-redwood-app/templates/ts/web/src/App.tsx b/packages/create-redwood-app/templates/ts/web/src/App.tsx index 9216dd846148..97fb5e02520d 100644 --- a/packages/create-redwood-app/templates/ts/web/src/App.tsx +++ b/packages/create-redwood-app/templates/ts/web/src/App.tsx @@ -1,7 +1,6 @@ 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' @@ -10,13 +9,7 @@ import './index.css' const App = () => ( - + diff --git a/packages/create-redwood-app/templates/ts/web/src/graphql/possibleTypes.ts b/packages/create-redwood-app/templates/ts/web/src/graphql/possibleTypes.ts deleted file mode 100644 index a8d476e9029c..000000000000 --- a/packages/create-redwood-app/templates/ts/web/src/graphql/possibleTypes.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface PossibleTypesResultData { - possibleTypes: { - [key: string]: string[] - } -} - -const result: PossibleTypesResultData = { - possibleTypes: {}, -} - -export default result diff --git a/packages/create-redwood-app/tests/templates.test.js b/packages/create-redwood-app/tests/templates.test.js index f8ef6ddf4f6c..667011a2c6f8 100644 --- a/packages/create-redwood-app/tests/templates.test.js +++ b/packages/create-redwood-app/tests/templates.test.js @@ -70,8 +70,6 @@ describe('TS template', () => { "/web/src/components", "/web/src/components/.keep", "/web/src/entry.client.tsx", - "/web/src/graphql", - "/web/src/graphql/possibleTypes.ts", "/web/src/index.css", "/web/src/index.html", "/web/src/layouts", @@ -156,8 +154,6 @@ describe('JS template', () => { "/web/src/components", "/web/src/components/.keep", "/web/src/entry.client.jsx", - "/web/src/graphql", - "/web/src/graphql/possibleTypes.js", "/web/src/index.css", "/web/src/index.html", "/web/src/layouts", diff --git a/packages/internal/src/generate/clientPreset.ts b/packages/internal/src/generate/clientPreset.ts index 4b42b708ddd9..c50f24b14822 100644 --- a/packages/internal/src/generate/clientPreset.ts +++ b/packages/internal/src/generate/clientPreset.ts @@ -31,6 +31,7 @@ export const generateClientPreset = async () => { const config: CodegenConfig = { schema: getPaths().generated.schema, documents: documentsGlob, + silent: true, // Plays nicely with cli task output generates: { [`${getPaths().web.src}/graphql/`]: { preset: 'client', diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index 7580fabff23c..e636a7166df1 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -268,7 +268,6 @@ export default function redwoodPluginVite(): PluginOption[] { }, // We can remove when streaming is stable rwConfig.experimental.streamingSsr.enabled && swapApolloProvider(), - // ----------------- handleJsAsJsx(), // Remove the splash-page from the bundle. removeFromBundle([ diff --git a/packages/web/src/apollo/index.tsx b/packages/web/src/apollo/index.tsx index c5e3c9f17c32..4f303e22c8cc 100644 --- a/packages/web/src/apollo/index.tsx +++ b/packages/web/src/apollo/index.tsx @@ -9,6 +9,7 @@ import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries' import { getMainDefinition } from '@apollo/client/utilities' import { fetch as crossFetch } from '@whatwg-node/fetch' import { print } from 'graphql/language/printer' + // Note: Importing directly from `apollo/client` doesn't work properly in Storybook. const { ApolloProvider, @@ -364,6 +365,7 @@ export const RedwoodApolloProvider: React.FunctionComponent<{ const cache = new InMemoryCache({ fragments: fragmentRegistry, + possibleTypes: cacheConfig?.possibleTypes, ...cacheConfig, }).restore(globalThis?.__REDWOOD__APOLLO_STATE ?? {}) diff --git a/packages/web/src/components/cell/createCell.tsx b/packages/web/src/components/cell/createCell.tsx index 41ffd5d9dc34..85d7c0d95df5 100644 --- a/packages/web/src/components/cell/createCell.tsx +++ b/packages/web/src/components/cell/createCell.tsx @@ -1,3 +1,4 @@ +import { fragmentRegistry } from '../../apollo' import { getOperationName } from '../../graphql' /** * This is part of how we let users swap out their GraphQL client while staying compatible with Cells. @@ -67,6 +68,7 @@ function createNonSuspendingCell< /* eslint-disable-next-line react-hooks/rules-of-hooks */ const { queryCache } = useCellCacheContext() const operationName = getOperationName(query) + const transformedQuery = fragmentRegistry.transform(query) let cacheKey @@ -99,7 +101,7 @@ function createNonSuspendingCell< } else { queryCache[cacheKey] || (queryCache[cacheKey] = { - query, + query: transformedQuery, variables: options.variables, hasProcessed: false, }) diff --git a/tasks/smoke-tests/fragments-dev/playwright.config.ts b/tasks/smoke-tests/fragments-dev/playwright.config.ts new file mode 100644 index 000000000000..9ba51b028b88 --- /dev/null +++ b/tasks/smoke-tests/fragments-dev/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + timeout: 30_000 * 2, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood dev --no-generate --fwd="--no-open"', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + // We wait for the api server to be ready instead of the web server + // because web starts much faster with Vite. + url: 'http://localhost:8911/graphql?query={redwood{version}}', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/fragments-dev/tests/fragments.spec.ts b/tasks/smoke-tests/fragments-dev/tests/fragments.spec.ts new file mode 100644 index 000000000000..71eee331a19c --- /dev/null +++ b/tasks/smoke-tests/fragments-dev/tests/fragments.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test' + +test('Fragments', async ({ page }) => { + await page.goto('/groceries') + + const strawberryChild = page.locator('text="Fruit Name: Strawberries"') + const fruitCard = page.locator('div').filter({ has: strawberryChild }) + await expect(fruitCard.getByText('Fruit Name: Strawberries')).toBeVisible() + await expect(fruitCard.getByText('Stall Name: Pie Veggies')).toBeVisible() + + const lettuceChild = page.locator('text="Vegetable Name: Lettuce"') + const vegetableCard = page.locator('div', { has: lettuceChild }) + await expect(vegetableCard.getByText('Vegetable Name: Lettuce')).toBeVisible() + await expect( + vegetableCard.getByText('Stall Name: Salad Veggies') + ).toBeVisible() +}) diff --git a/tasks/smoke-tests/fragments-serve/playwright.config.ts b/tasks/smoke-tests/fragments-serve/playwright.config.ts new file mode 100644 index 000000000000..f3323ded09d4 --- /dev/null +++ b/tasks/smoke-tests/fragments-serve/playwright.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from '@playwright/test' + +import { basePlaywrightConfig } from '../basePlaywright.config' + +// See https://playwright.dev/docs/test-configuration#global-configuration +export default defineConfig({ + ...basePlaywrightConfig, + + use: { + baseURL: 'http://localhost:8910', + }, + + // Run your local dev server before starting the tests + webServer: { + command: 'yarn redwood serve', + cwd: process.env.REDWOOD_TEST_PROJECT_PATH, + url: 'http://localhost:8910', + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, +}) diff --git a/tasks/smoke-tests/fragments-serve/tests/fragments.spec.ts b/tasks/smoke-tests/fragments-serve/tests/fragments.spec.ts new file mode 100644 index 000000000000..71eee331a19c --- /dev/null +++ b/tasks/smoke-tests/fragments-serve/tests/fragments.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test' + +test('Fragments', async ({ page }) => { + await page.goto('/groceries') + + const strawberryChild = page.locator('text="Fruit Name: Strawberries"') + const fruitCard = page.locator('div').filter({ has: strawberryChild }) + await expect(fruitCard.getByText('Fruit Name: Strawberries')).toBeVisible() + await expect(fruitCard.getByText('Stall Name: Pie Veggies')).toBeVisible() + + const lettuceChild = page.locator('text="Vegetable Name: Lettuce"') + const vegetableCard = page.locator('div', { has: lettuceChild }) + await expect(vegetableCard.getByText('Vegetable Name: Lettuce')).toBeVisible() + await expect( + vegetableCard.getByText('Stall Name: Salad Veggies') + ).toBeVisible() +}) diff --git a/tasks/test-project/add-gql-fragments.ts b/tasks/test-project/add-gql-fragments.ts new file mode 100755 index 000000000000..79cddc339350 --- /dev/null +++ b/tasks/test-project/add-gql-fragments.ts @@ -0,0 +1,29 @@ +/* eslint-env node, es6*/ +import path from 'node:path' + +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import { fragmentsTasks } from './tasks.js' + +const args = yargs(hideBin(process.argv)) + .usage('Usage: $0 ') + .parseSync() + +/** + * This script takes a regular test-project, and adds some extra files/config + * so we can run e2e tests for fragments + */ +async function runCommand() { + const OUTPUT_PROJECT_PATH = path.resolve(String(args._)) + const tasks = await fragmentsTasks(OUTPUT_PROJECT_PATH, { + verbose: true, + }) + + tasks.run().catch((err: unknown) => { + console.error(err) + process.exit(1) + }) +} + +runCommand() diff --git a/tasks/test-project/codemods/groceriesPage.ts b/tasks/test-project/codemods/groceriesPage.ts new file mode 100644 index 000000000000..802c2a930ca7 --- /dev/null +++ b/tasks/test-project/codemods/groceriesPage.ts @@ -0,0 +1,208 @@ +import type { API, FileInfo } from 'jscodeshift' + +const componentBlock = `{ + const { data: groceryData, loading: groceryLoading } = + useQuery(GET_GROCERIES) + const { data: produceData, loading: produceLoading } = + useQuery(GET_PRODUCE) + + return ( +
+ + +
+ {!groceryLoading && + groceryData.groceries.map((fruit) => ( + + ))} + + {!groceryLoading && + groceryData.groceries.map((vegetable) => ( + + ))} + + {!produceLoading && + produceData.produces?.map((produce) => ( + + ))} +
+
+ ) +}` + +export default (file: FileInfo, api: API) => { + const j = api.jscodeshift + const root = j(file.source) + + // Replace + // import { Link, routes } from '@redwoodjs/router' + // with + // import type { GetGroceries, GetProduce } from 'types/graphql' + root + .find(j.ImportDeclaration, { + source: { + type: 'StringLiteral', + value: '@redwoodjs/router', + }, + }) + .replaceWith( + j.importDeclaration( + [ + j.importSpecifier(j.identifier('GetGroceries')), + j.importSpecifier(j.identifier('GetProduce')), + ], + j.stringLiteral('types/graphql'), + 'type' + ) + ) + + // Replace + // import { Metadata } from '@redwoodjs/web' + // with + // import { Metadata, useQuery } from '@redwoodjs/web' + root + .find(j.ImportDeclaration, { + source: { + type: 'StringLiteral', + value: '@redwoodjs/web', + }, + }) + .replaceWith((nodePath) => { + const { node } = nodePath + node.specifiers?.push(j.importSpecifier(j.identifier('useQuery'))) + return node + }) + + // Add + // import FruitInfo from 'src/components/FruitInfo' + // import ProduceInfo from 'src/components/ProduceInfo' + // import VegetableInfo from 'src/components/VegetableInfo' + // after + // import { Metadata, useQuery } from '@redwoodjs/web' + root + .find(j.ImportDeclaration, { + source: { + type: 'StringLiteral', + value: '@redwoodjs/web', + }, + }) + .insertAfter(() => { + return [ + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('FruitInfo'))], + j.stringLiteral('src/components/FruitInfo') + ), + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('ProduceInfo'))], + j.stringLiteral('src/components/ProduceInfo') + ), + j.importDeclaration( + [j.importDefaultSpecifier(j.identifier('VegetableInfo'))], + j.stringLiteral('src/components/VegetableInfo') + ), + ] + }) + + // Add + // const GET_GROCERIES = gql` + // query GetGroceries { + // groceries { + // ...Fruit_info + // ...Vegetable_info + // } + // } + // ` + // After + // import VegetableInfo from 'src/components/VegetableInfo' + const query = ` + query GetGroceries { + groceries { + ...Fruit_info + ...Vegetable_info + } + } +` + root + .find(j.ImportDeclaration, { + source: { + type: 'StringLiteral', + value: 'src/components/VegetableInfo', + }, + }) + .insertAfter(() => { + return j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier('GET_GROCERIES'), + j.taggedTemplateExpression( + j.identifier('gql'), + j.templateLiteral( + [j.templateElement({ raw: query, cooked: query }, true)], + [] + ) + ) + ), + ]) + }) + + // Add + // const GET_PRODUCE = gql` + // query GetProduce { + // produces { + // ...Produce_info + // } + // } + // ` + // After + // const GET_GROCERIES = ... + const produceQuery = ` + query GetProduce { + produces { + ...Produce_info + } + } +` + root + .find(j.VariableDeclaration, { + kind: 'const', + declarations: [ + { + id: { + type: 'Identifier', + name: 'GET_GROCERIES', + }, + }, + ], + }) + .insertAfter(() => { + return j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier('GET_PRODUCE'), + j.taggedTemplateExpression( + j.identifier('gql'), + j.templateLiteral( + [ + j.templateElement( + { raw: produceQuery, cooked: produceQuery }, + true + ), + ], + [] + ) + ) + ), + ]) + }) + + // Replace entire body of GroceriesPage component + root + .find(j.VariableDeclarator, { + id: { + type: 'Identifier', + name: 'GroceriesPage', + }, + }) + .find(j.BlockStatement) + .replaceWith(j.identifier(componentBlock)) + + return root.toSource() +} diff --git a/tasks/test-project/codemods/models.js b/tasks/test-project/codemods/models.js index 3dcf3b1a863d..46bb8ea12b4c 100644 --- a/tasks/test-project/codemods/models.js +++ b/tasks/test-project/codemods/models.js @@ -29,4 +29,30 @@ const user = `model User { posts Post[] }` -module.exports = { post, contact, user } +const produce = `model Produce { + id String @id @default(cuid()) + name String @unique + quantity Int + price Int + nutrients String? + region String + /// Available only for fruits + isSeedless Boolean? + /// Available only for fruits + ripenessIndicators String? + /// Available only for vegetables + vegetableFamily String? + /// Available only for vegetables + isPickled Boolean? + stall Stall @relation(fields: [stallId], references: [id], onDelete: Cascade) + stallId String +}` + +const stall = `model Stall { + id String @id @default(cuid()) + name String + stallNumber String @unique + produce Produce[] +}` + +module.exports = { post, contact, user, produce, stall } diff --git a/tasks/test-project/codemods/producesSdl.ts b/tasks/test-project/codemods/producesSdl.ts new file mode 100644 index 000000000000..c2ef310ce476 --- /dev/null +++ b/tasks/test-project/codemods/producesSdl.ts @@ -0,0 +1,5 @@ +import type { FileInfo } from 'jscodeshift' + +export default (file: FileInfo) => { + return file.source.replaceAll('@requireAuth', '@skipAuth') +} diff --git a/tasks/test-project/codemods/seed.js b/tasks/test-project/codemods/seed.js index 0f6970a1c432..58e3cca2d06e 100644 --- a/tasks/test-project/codemods/seed.js +++ b/tasks/test-project/codemods/seed.js @@ -17,11 +17,11 @@ const createPosts = ` } ] - await Promise.all( - users.map(async (user) => { - const newUser = await db.user.create({ data: user }) - }) - ) + if ((await db.user.count()) === 0) { + await Promise.all(users.map((user) => db.user.create({ data: user }))) + } else { + console.log('Users already seeded') + } } catch (error) { console.error(error) } @@ -45,13 +45,17 @@ const createPosts = ` }, ] - await Promise.all( - posts.map(async (post) => { - const newPost = await db.post.create({ data: post }) + if ((await db.post.count()) === 0) { + await Promise.all( + posts.map(async (post) => { + const newPost = await db.post.create({ data: post }) - console.log(newPost) - }) - ) + console.log(newPost) + }) + ) + } else { + console.log('Posts already seeded') + } } catch (error) { console.error(error) } diff --git a/tasks/test-project/codemods/seedFragments.ts b/tasks/test-project/codemods/seedFragments.ts new file mode 100644 index 000000000000..0d5abfa4ac8e --- /dev/null +++ b/tasks/test-project/codemods/seedFragments.ts @@ -0,0 +1,83 @@ +import type { API, FileInfo } from 'jscodeshift' + +const seedFragmentData = `try { + const stalls = [ + { + id: 'clr0zv6ow000012nvo6r09vog', + name: 'Salad Veggies', + stallNumber: '1', + }, + { + id: 'clr0zvne2000112nvyhzf1ifk', + name: 'Pie Veggies', + stallNumber: '2', + }, + { + id: 'clr0zvne3000212nv6boae9qw', + name: 'Root Veggies', + stallNumber: '3', + }, + ] + + if ((await db.stall.count()) === 0) { + await Promise.all( + stalls.map(async (stall) => { + const newStall = await db.stall.create({ data: stall }) + + console.log(newStall) + }) + ) + } else { + console.log('Stalls already seeded') + } + + const produce = [ + { + id: 'clr0zwyoq000312nvfsu1efcw', + name: 'Lettuce', + quantity: 10, + price: 2, + ripenessIndicators: null, + region: '', + isSeedless: false, + vegetableFamily: 'Asteraceae', + stallId: 'clr0zv6ow000012nvo6r09vog', + }, + { + id: 'clr0zy32x000412nvsya5g8q0', + name: 'Strawberries', + quantity: 24, + price: 3, + ripenessIndicators: 'Vitamin C', + region: 'California', + isSeedless: false, + vegetableFamily: 'Soft', + stallId: 'clr0zvne2000112nvyhzf1ifk', + }, + ] + + if ((await db.produce.count()) === 0) { + await Promise.all( + produce.map(async (produce) => { + const newProduce = await db.produce.create({ data: produce }) + + console.log(newProduce) + }) + ) + } else { + console.log('Produce already seeded') + } +} catch (error) { + console.error(error) +}` + +export default (file: FileInfo, api: API) => { + const j = api.jscodeshift + const root = j(file.source) + + return root + .find(j.TryStatement) + .at(-1) + .insertBefore(seedFragmentData) + .toSource() +} diff --git a/tasks/test-project/rebuild-fragments-test-project-fixture.ts b/tasks/test-project/rebuild-fragments-test-project-fixture.ts new file mode 100755 index 000000000000..acd94ef453b2 --- /dev/null +++ b/tasks/test-project/rebuild-fragments-test-project-fixture.ts @@ -0,0 +1,495 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import chalk from 'chalk' +import fse from 'fs-extra' +import { rimraf } from 'rimraf' +import { hideBin } from 'yargs/helpers' +import yargs from 'yargs/yargs' + +import { RedwoodTUI, ReactiveTUIContent, RedwoodStyling } from '@redwoodjs/tui' + +import { + addFrameworkDepsToProject, + copyFrameworkPackages, +} from './frameworkLinking' +import { webTasks, apiTasks, fragmentsTasks } from './tui-tasks' +import { isAwaitable } from './typing' +import type { TuiTaskDef } from './typing' +import { + getExecaOptions as utilGetExecaOptions, + updatePkgJsonScripts, + ExecaError, + exec, +} from './util' + +const args = yargs(hideBin(process.argv)) + .usage('Usage: $0 [option]') + .option('verbose', { + default: false, + type: 'boolean', + describe: 'Verbose output', + }) + .option('resume', { + default: false, + type: 'boolean', + describe: 'Resume rebuild of the latest unfinished fragment-test-project', + }) + .option('resumePath', { + type: 'string', + describe: 'Resume rebuild given the specified fragment-test-project path', + }) + .option('resumeStep', { + type: 'string', + describe: 'Resume rebuild from the given step', + }) + .help() + .parseSync() + +const { verbose, resume, resumePath, resumeStep } = args + +const RW_FRAMEWORK_PATH = path.join(__dirname, '../../') +const OUTPUT_PROJECT_PATH = resumePath + ? /* path.resolve(String(resumePath)) */ resumePath + : path.join( + os.tmpdir(), + 'redwood-fragment-test-project', + // ":" is problematic with paths + new Date().toISOString().split(':').join('-') + ) + +let startStep = resumeStep || '' + +if (!startStep) { + // Figure out what step to restart the rebuild from + try { + const stepTxt = fs.readFileSync( + path.join(OUTPUT_PROJECT_PATH, 'step.txt'), + 'utf-8' + ) + + if (stepTxt) { + startStep = stepTxt + } + } catch { + // No step.txt file found, start from the beginning + } +} + +const tui = new RedwoodTUI() + +function getExecaOptions(cwd: string) { + return { ...utilGetExecaOptions(cwd), stdio: 'pipe' } +} + +function beginStep(step: string) { + fs.mkdirSync(OUTPUT_PROJECT_PATH, { recursive: true }) + fs.writeFileSync(path.join(OUTPUT_PROJECT_PATH, 'step.txt'), '' + step) +} + +async function tuiTask({ step, title, content, task, parent }: TuiTaskDef) { + const stepId = (parent ? parent + '.' : '') + step + + const tuiContent = new ReactiveTUIContent({ + mode: 'text', + header: `${stepId}: ${title}`, + content, + spinner: { + enabled: true, + }, + }) + + tui.startReactive(tuiContent) + + beginStep(stepId) + + let skip = skipFn(startStep, stepId) + + if (skip) { + if (typeof skip === 'boolean' && skip) { + // if skip is just `true`, then we use the default skip message + skip = 'Skipping...' + } + + tuiContent.update({ + spinner: { + enabled: false, + }, + header: `${RedwoodStyling.green('✔')} ${step}. ${title}`, + content: ' '.repeat(stepId.length + 4) + RedwoodStyling.info(skip) + '\n', + }) + + tui.stopReactive() + + return + } + + let promise: void | Promise + + try { + promise = task() + } catch (e) { + // This code handles errors from synchronous tasks + + tui.stopReactive(true) + + if (e instanceof ExecaError) { + tui.displayError( + 'Failed ' + title.toLowerCase().replace('...', ''), + 'stdout:\n' + e.stdout + '\n\n' + 'stderr:\n' + e.stderr + ) + } else { + tui.displayError( + 'Failed ' + title.toLowerCase().replace('...', ''), + e.message + ) + } + + process.exit(e.exitCode) + } + + if (isAwaitable(promise)) { + const result = await promise.catch((e) => { + // This code handles errors from asynchronous tasks + + tui.stopReactive(true) + + if (e instanceof ExecaError) { + tui.displayError( + 'Failed ' + title.toLowerCase().replace('...', ''), + 'stdout:\n' + e.stdout + '\n\n' + 'stderr:\n' + e.stderr + ) + } else { + tui.displayError( + 'Failed ' + title.toLowerCase().replace('...', ''), + e.message + ) + } + + process.exit(e.exitCode) + }) + + if (Array.isArray(result)) { + const tuiTaskList = result + for (let i = 0; i < tuiTaskList.length; i++) { + // Recurse through all tasks + await tuiTask({ + step: i, + ...tuiTaskList[i], + parent: stepId, + }) + } + } + } + + tuiContent.update({ + spinner: { + enabled: false, + }, + header: `${RedwoodStyling.green('✔')} ${stepId}: ${title}`, + content: '', + }) + + tui.stopReactive() +} + +/** + * Function that returns a string to show when skipping the task, or just + * true|false to indicate whether the task should be skipped or not. + * + * @param {string} startStep + * @param {string} currentStep + */ +function skipFn(startStep, currentStep) { + const startStepNrs = startStep.split('.').map((s) => parseInt(s, 10)) + const currentStepNrs = currentStep.split('.').map((s) => parseInt(s, 10)) + + for (let i = 0; i < startStepNrs.length; i++) { + if (startStepNrs[i] > currentStepNrs[i]) { + return 'Skipping... Resuming from step ' + startStep + } + } + + return false +} + +if (resume) { + console.error( + chalk.red.bold( + '\n`resume` option is not supported yet. ' + + 'Please use `resumePath` instead.\n' + ) + ) + + process.exit(1) +} + +if (resumePath && !fs.existsSync(path.join(resumePath, 'redwood.toml'))) { + console.error( + chalk.red.bold( + ` + No redwood.toml file found at the given path: ${resumePath} + ` + ) + ) + process.exit(1) +} + +const createProject = () => { + const cmd = `yarn node ./packages/create-redwood-app/dist/create-redwood-app.js ${OUTPUT_PROJECT_PATH}` + + const subprocess = exec( + cmd, + // We create a ts project and convert using ts-to-js at the end if typescript flag is false + ['--no-yarn-install', '--typescript', '--overwrite', '--no-git'], + getExecaOptions(RW_FRAMEWORK_PATH) + ) + + return subprocess +} + +const copyProject = async () => { + const fixturePath = path.join( + RW_FRAMEWORK_PATH, + '__fixtures__/fragment-test-project' + ) + + // remove existing Fixture + await rimraf(fixturePath) + // copy from tempDir to Fixture dir + await fse.copy(OUTPUT_PROJECT_PATH, fixturePath) + // cleanup after ourselves + await rimraf(OUTPUT_PROJECT_PATH) +} + +async function runCommand() { + console.log() + console.log('Rebuilding test project fixture...') + console.log('Using temporary directory:', OUTPUT_PROJECT_PATH) + console.log() + + // Maybe we could add all of the tasks to an array and infer the `step` from + // the array index? + // I'd also want to be able to skip sub-tasks. Like both the "web" step and + // the "api" step both have a bunch of sub-tasks. So maybe the step.txt file + // should contain something like "9.2" to mean the third sub-task of the + // "api" step? And --resume-step would also accept stuff like "9.2"? + await tuiTask({ + step: 0, + title: 'Creating project', + content: 'Building fragment-test-project from scratch...', + task: createProject, + }) + + await tuiTask({ + step: 1, + title: '[link] Building Redwood framework', + content: 'yarn build:clean && yarn build', + task: async () => { + return exec( + 'yarn build:clean && yarn build', + [], + getExecaOptions(RW_FRAMEWORK_PATH) + ) + }, + }) + + await tuiTask({ + step: 2, + title: '[link] Adding framework dependencies to project', + content: 'Adding framework dependencies to project...', + task: () => { + return addFrameworkDepsToProject( + RW_FRAMEWORK_PATH, + OUTPUT_PROJECT_PATH, + 'pipe' // TODO: Remove this when everything is using @rwjs/tui + ) + }, + }) + + await tuiTask({ + step: 3, + title: 'Installing node_modules', + content: 'yarn install', + task: () => { + return exec('yarn install', getExecaOptions(OUTPUT_PROJECT_PATH)) + }, + }) + + await tuiTask({ + step: 4, + title: 'Updating ports in redwood.toml...', + task: () => { + // We do this, to make it easier to run multiple test projects in parallel + // But on different ports. If API_DEV_PORT or WEB_DEV_PORT aren't supplied, + // It just defaults to 8910 and 8911 + // This is helpful in playwright smoke tests to allow us to parallelize + const REDWOOD_TOML_PATH = path.join(OUTPUT_PROJECT_PATH, 'redwood.toml') + const redwoodToml = fs.readFileSync(REDWOOD_TOML_PATH).toString() + let newRedwoodToml = redwoodToml + + newRedwoodToml = newRedwoodToml.replace( + /\port = 8910/, + 'port = "${WEB_DEV_PORT:8910}"' + ) + + newRedwoodToml = newRedwoodToml.replace( + /\port = 8911/, + 'port = "${API_DEV_PORT:8911}"' + ) + + fs.writeFileSync(REDWOOD_TOML_PATH, newRedwoodToml) + }, + }) + + await tuiTask({ + step: 5, + title: '[link] Copying framework packages to project', + task: () => { + return copyFrameworkPackages( + RW_FRAMEWORK_PATH, + OUTPUT_PROJECT_PATH, + 'pipe' + ) + }, + }) + + // Note that we undo this at the end + await tuiTask({ + step: 6, + title: '[link] Add rwfw project:copy postinstall', + task: () => { + return updatePkgJsonScripts({ + projectPath: OUTPUT_PROJECT_PATH, + scripts: { + postinstall: 'yarn rwfw project:copy', + }, + }) + }, + }) + + await tuiTask({ + step: 7, + title: 'Apply web codemods', + task: () => { + return webTasks(OUTPUT_PROJECT_PATH, { + linkWithLatestFwBuild: true, + }) + }, + }) + + await tuiTask({ + step: 8, + title: 'Apply api codemods', + task: () => { + return apiTasks(OUTPUT_PROJECT_PATH, { + linkWithLatestFwBuild: true, + }) + }, + }) + + await tuiTask({ + step: 9, + title: 'Running prisma migrate reset', + task: () => { + return exec( + 'yarn rw prisma migrate reset', + ['--force'], + getExecaOptions(OUTPUT_PROJECT_PATH) + ) + }, + }) + + await tuiTask({ + step: 10, + title: 'Lint --fix all the things', + task: async () => { + try { + await exec('yarn rw lint --fix', [], { + shell: true, + stdio: 'pipe', + cleanup: true, + cwd: OUTPUT_PROJECT_PATH, + env: { + RW_PATH: path.join(__dirname, '../../'), + }, + }) + } catch (e) { + if ( + e instanceof ExecaError && + !e.stderr && + e.stdout.includes('13 problems (13 errors, 0 warnings)') + ) { + // This is unfortunate, but linting is expected to fail. + // This is the expected error message, so we just fall through + // If the expected error message changes you'll have to update the + // `includes` check above + } else { + // Unexpected error. Rethrow + throw e + } + } + }, + }) + + await tuiTask({ + step: 11, + title: 'Run fragments tasks', + task: () => { + return fragmentsTasks(OUTPUT_PROJECT_PATH) + }, + }) + + await tuiTask({ + step: 12, + title: 'Replace and Cleanup Fixture', + task: async () => { + // @TODO: This only works on UNIX, we should use path.join everywhere + // remove all .gitignore + await rimraf(`${OUTPUT_PROJECT_PATH}/.redwood/**/*`, { + glob: { + ignore: `${OUTPUT_PROJECT_PATH}/.redwood/README.md`, + }, + }) + await rimraf(`${OUTPUT_PROJECT_PATH}/api/db/dev.db`) + await rimraf(`${OUTPUT_PROJECT_PATH}/api/db/dev.db-journal`) + await rimraf(`${OUTPUT_PROJECT_PATH}/api/dist`) + await rimraf(`${OUTPUT_PROJECT_PATH}/node_modules`) + await rimraf(`${OUTPUT_PROJECT_PATH}/web/node_modules`) + await rimraf(`${OUTPUT_PROJECT_PATH}/.env`) + await rimraf(`${OUTPUT_PROJECT_PATH}/yarn.lock`) + await rimraf(`${OUTPUT_PROJECT_PATH}/step.txt`) + + // Copy over package.json from template, so we remove the extra dev dependencies, and rwfw postinstall script + // that we added in "Adding framework dependencies to project" + await rimraf(`${OUTPUT_PROJECT_PATH}/package.json`) + fs.copyFileSync( + path.join( + __dirname, + '../../packages/create-redwood-app/templates/ts/package.json' + ), + path.join(OUTPUT_PROJECT_PATH, 'package.json') + ) + + // removes existing Fixture and replaces with newly built project, + // then removes new Project temp directory + await copyProject() + }, + }) + + await tuiTask({ + step: 13, + title: 'All done!', + task: () => { + console.log('-'.repeat(30)) + console.log() + console.log('✅ Success! The test project fixture has been rebuilt') + console.log() + console.log('-'.repeat(30)) + }, + enabled: verbose, + }) +} + +runCommand() diff --git a/tasks/test-project/rebuild-test-project-fixture.ts b/tasks/test-project/rebuild-test-project-fixture.ts index 9035328331bb..de7ab33368e8 100755 --- a/tasks/test-project/rebuild-test-project-fixture.ts +++ b/tasks/test-project/rebuild-test-project-fixture.ts @@ -416,7 +416,7 @@ async function runCommand() { if ( e instanceof ExecaError && !e.stderr && - e.stdout.includes('14 problems (14 errors, 0 warnings)') + e.stdout.includes('13 problems (13 errors, 0 warnings)') ) { // This is unfortunate, but linting is expected to fail. // This is the expected error message, so we just fall through diff --git a/tasks/test-project/tasks.js b/tasks/test-project/tasks.js index a5a9676f6136..cf9240b5534f 100644 --- a/tasks/test-project/tasks.js +++ b/tasks/test-project/tasks.js @@ -9,6 +9,7 @@ const { getExecaOptions, applyCodemod, updatePkgJsonScripts, + exec, } = require('./util') // This variable gets used in other functions @@ -738,7 +739,6 @@ export default DoublePage` } /** - * * Separates the streaming-ssr related steps. These are all web tasks, * if we choose to move them later * @param {string} outputPath @@ -776,8 +776,128 @@ async function streamingTasks(outputPath, { verbose }) { }) } +/** + * Tasks to add GraphQL Fragments support to the test-project, and some queries + * to test fragments + */ +async function fragmentsTasks(outputPath, { verbose }) { + OUTPUT_PATH = outputPath + + const tasks = [ + { + title: 'Enable fragments', + task: async () => { + const redwoodTomlPath = path.join(outputPath, 'redwood.toml') + const redwoodToml = fs.readFileSync(redwoodTomlPath).toString() + const newRedwoodToml = redwoodToml + '\n[graphql]\n fragments = true\n' + fs.writeFileSync(redwoodTomlPath, newRedwoodToml) + }, + }, + { + title: 'Adding produce and stall models to prisma', + task: async () => { + // Need both here since they have a relation + const { produce, stall } = await import('./codemods/models.js') + + addModel(produce) + addModel(stall) + + return exec( + 'yarn rw prisma migrate dev --name create_produce_stall', + [], + getExecaOptions(outputPath) + ) + }, + }, + { + title: 'Seed fragments data', + task: async () => { + await applyCodemod( + 'seedFragments.ts', + fullPath('scripts/seed.ts', { addExtension: false }) + ) + + await exec('yarn rw prisma db seed', [], getExecaOptions(outputPath)) + }, + }, + { + title: 'Generate SDLs for produce and stall', + task: async () => { + const generateSdl = createBuilder('yarn redwood g sdl') + + await generateSdl('stall') + await generateSdl('produce') + + await applyCodemod( + 'producesSdl.ts', + fullPath('api/src/graphql/produces.sdl') + ) + }, + }, + { + title: 'Copy components from templates', + task: () => { + const templatesPath = path.join(__dirname, 'templates', 'web') + const componentsPath = path.join( + OUTPUT_PATH, + 'web', + 'src', + 'components' + ) + + for (const fileName of [ + 'Card.tsx', + 'FruitInfo.tsx', + 'ProduceInfo.tsx', + 'StallInfo.tsx', + 'VegetableInfo.tsx', + ]) { + const templatePath = path.join(templatesPath, fileName) + const componentPath = path.join(componentsPath, fileName) + + fs.writeFileSync(componentPath, fs.readFileSync(templatePath)) + } + }, + }, + { + title: 'Copy sdl and service for groceries from templates', + task: () => { + const templatesPath = path.join(__dirname, 'templates', 'api') + const graphqlPath = path.join(OUTPUT_PATH, 'api', 'src', 'graphql') + const servicesPath = path.join(OUTPUT_PATH, 'api', 'src', 'services') + + const sdlTemplatePath = path.join(templatesPath, 'groceries.sdl.ts') + const sdlPath = path.join(graphqlPath, 'groceries.sdl.ts') + const serviceTemplatePath = path.join(templatesPath, 'groceries.ts') + const servicePath = path.join(servicesPath, 'groceries.ts') + + fs.writeFileSync(sdlPath, fs.readFileSync(sdlTemplatePath)) + fs.writeFileSync(servicePath, fs.readFileSync(serviceTemplatePath)) + }, + }, + { + title: 'Creating Groceries page', + task: async () => { + await createPage('groceries') + + await applyCodemod( + 'groceriesPage.ts', + fullPath('web/src/pages/GroceriesPage/GroceriesPage') + ) + }, + }, + ] + + return new Listr(tasks, { + exitOnError: true, + renderer: verbose && 'verbose', + renderOptions: { collapseSubtasks: false }, + }) +} + module.exports = { apiTasks, webTasks, streamingTasks, + fragmentsTasks, } diff --git a/tasks/test-project/templates/api/groceries.sdl.ts b/tasks/test-project/templates/api/groceries.sdl.ts new file mode 100644 index 000000000000..0870d7daeb54 --- /dev/null +++ b/tasks/test-project/templates/api/groceries.sdl.ts @@ -0,0 +1,49 @@ +export const schema = gql` + interface Grocery { + id: ID! + name: String! + quantity: Int! + price: Int! + nutrients: String + stall: Stall! + region: String! + } + + type Fruit implements Grocery { + id: ID! + name: String! + quantity: Int! + price: Int! + nutrients: String + stall: Stall! + region: String! + "Seedless is only for fruits" + isSeedless: Boolean + "Ripeness is only for fruits" + ripenessIndicators: String + } + + type Vegetable implements Grocery { + id: ID! + name: String! + quantity: Int! + price: Int! + nutrients: String + stall: Stall! + region: String! + "Veggie Family is only for vegetables" + vegetableFamily: String + "Pickled is only for vegetables" + isPickled: Boolean + } + + union Groceries = Fruit | Vegetable + + type Query { + groceries: [Groceries!]! @skipAuth + fruits: [Fruit!]! @skipAuth + fruitById(id: ID!): Fruit @skipAuth + vegetables: [Vegetable!]! @skipAuth + vegetableById(id: ID!): Vegetable @skipAuth + } +` diff --git a/tasks/test-project/templates/api/groceries.ts b/tasks/test-project/templates/api/groceries.ts new file mode 100644 index 000000000000..09eb5de330ff --- /dev/null +++ b/tasks/test-project/templates/api/groceries.ts @@ -0,0 +1,32 @@ +import { Produce } from 'types/graphql' + +import { db } from 'src/lib/db' + +const isFruit = (grocery: Produce) => { + return grocery.isSeedless !== null && grocery.ripenessIndicators !== null +} + +export const groceries = async () => { + const result = await db.produce.findMany({ + include: { stall: true }, + orderBy: { name: 'asc' }, + }) + + const avail = result.map((grocery) => { + if (isFruit(grocery)) { + return { + ...grocery, + __typename: 'Fruit', + __resolveType: 'Fruit', + } + } else { + return { + ...grocery, + __typename: 'Vegetable', + __resolveType: 'Vegetable', + } + } + }) + + return avail +} diff --git a/tasks/test-project/templates/web/Card.tsx b/tasks/test-project/templates/web/Card.tsx new file mode 100644 index 000000000000..8894a447b29c --- /dev/null +++ b/tasks/test-project/templates/web/Card.tsx @@ -0,0 +1,9 @@ +const Card = ({ children }) => { + return ( +
+ {children} +
+ ) +} + +export default Card diff --git a/tasks/test-project/templates/web/FruitInfo.tsx b/tasks/test-project/templates/web/FruitInfo.tsx new file mode 100644 index 000000000000..95015ee57764 --- /dev/null +++ b/tasks/test-project/templates/web/FruitInfo.tsx @@ -0,0 +1,37 @@ +import type { Fruit } from 'types/graphql' + +import { registerFragment } from '@redwoodjs/web/apollo' + +import Card from 'src/components/Card' +import StallInfo from 'src/components/StallInfo' + +const { useRegisteredFragment } = registerFragment( + gql` + fragment Fruit_info on Fruit { + id + name + isSeedless + ripenessIndicators + stall { + ...Stall_info + } + } + ` +) + +const FruitInfo = ({ id }: { id: string }) => { + const { data: fruit, complete } = useRegisteredFragment(id) + + return ( + complete && ( + +

Fruit Name: {fruit.name}

+

Seeds? {fruit.isSeedless ? 'Yes' : 'No'}

+

Ripeness: {fruit.ripenessIndicators}

+ +
+ ) + ) +} + +export default FruitInfo diff --git a/tasks/test-project/templates/web/ProduceInfo.tsx b/tasks/test-project/templates/web/ProduceInfo.tsx new file mode 100644 index 000000000000..f06a68ad5e9d --- /dev/null +++ b/tasks/test-project/templates/web/ProduceInfo.tsx @@ -0,0 +1,28 @@ +import type { Produce } from 'types/graphql' + +import { registerFragment } from '@redwoodjs/web/apollo' + +import Card from 'src/components/Card' + +const { useRegisteredFragment } = registerFragment( + gql` + fragment Produce_info on Produce { + id + name + } + ` +) + +const ProduceInfo = ({ id }: { id: string }) => { + const { data, complete } = useRegisteredFragment(id) + + return ( + complete && ( + +

Produce Name: {data.name}

+
+ ) + ) +} + +export default ProduceInfo diff --git a/tasks/test-project/templates/web/StallInfo.tsx b/tasks/test-project/templates/web/StallInfo.tsx new file mode 100644 index 000000000000..24b2fbb58d35 --- /dev/null +++ b/tasks/test-project/templates/web/StallInfo.tsx @@ -0,0 +1,26 @@ +import type { Stall } from 'types/graphql' + +import { registerFragment } from '@redwoodjs/web/apollo' + +const { useRegisteredFragment } = registerFragment( + gql` + fragment Stall_info on Stall { + id + name + } + ` +) + +const StallInfo = ({ id }: { id: string }) => { + const { data, complete } = useRegisteredFragment(id) + + return ( + complete && ( +
+

Stall Name: {data.name}

+
+ ) + ) +} + +export default StallInfo diff --git a/tasks/test-project/templates/web/VegetableInfo.tsx b/tasks/test-project/templates/web/VegetableInfo.tsx new file mode 100644 index 000000000000..96f6208b19e9 --- /dev/null +++ b/tasks/test-project/templates/web/VegetableInfo.tsx @@ -0,0 +1,37 @@ +import type { Vegetable } from 'types/graphql' + +import { registerFragment } from '@redwoodjs/web/apollo' + +import Card from 'src/components/Card' +import StallInfo from 'src/components/StallInfo' + +const { useRegisteredFragment } = registerFragment( + gql` + fragment Vegetable_info on Vegetable { + id + name + vegetableFamily + isPickled + stall { + ...Stall_info + } + } + ` +) + +const VegetableInfo = ({ id }: { id: string }) => { + const { data: vegetable, complete } = useRegisteredFragment(id) + + return ( + complete && ( + +

Vegetable Name: {vegetable.name}

+

Pickled? {vegetable.isPickled ? 'Yes' : 'No'}

+

Family: {vegetable.vegetableFamily}

+ +
+ ) + ) +} + +export default VegetableInfo diff --git a/tasks/test-project/tui-tasks.js b/tasks/test-project/tui-tasks.js index 830ea8e0c46e..9b25db21dc84 100644 --- a/tasks/test-project/tui-tasks.js +++ b/tasks/test-project/tui-tasks.js @@ -887,7 +887,125 @@ export default DoublePage` return tuiTaskList } +/** + * Tasks to add GraphQL Fragments support to the test-project, and some queries + * to test fragments + */ +async function fragmentsTasks(outputPath) { + OUTPUT_PATH = outputPath + + /** @type import('./typing').TuiTaskList */ + const tuiTaskList = [ + { + title: 'Enable fragments', + task: async () => { + const redwoodTomlPath = path.join(outputPath, 'redwood.toml') + const redwoodToml = fs.readFileSync(redwoodTomlPath).toString() + const newRedwoodToml = redwoodToml + '\n[graphql]\n fragments = true\n' + fs.writeFileSync(redwoodTomlPath, newRedwoodToml) + }, + }, + { + title: 'Adding produce and stall models to prisma', + task: async () => { + // Need both here since they have a relation + const { produce, stall } = await import('./codemods/models.js') + + addModel(produce) + addModel(stall) + + return exec( + 'yarn rw prisma migrate dev --name create_produce_stall', + [], + getExecaOptions(outputPath) + ) + }, + }, + { + title: 'Seed fragments data', + task: async () => { + await applyCodemod( + 'seedFragments.ts', + fullPath('scripts/seed.ts', { addExtension: false }) + ) + + await exec('yarn rw prisma db seed', [], getExecaOptions(outputPath)) + }, + }, + { + title: 'Generate SDLs for produce and stall', + task: async () => { + const generateSdl = createBuilder('yarn redwood g sdl') + + await generateSdl('stall') + await generateSdl('produce') + + await applyCodemod( + 'producesSdl.ts', + fullPath('api/src/graphql/produces.sdl') + ) + }, + }, + { + title: 'Copy components from templates', + task: () => { + const templatesPath = path.join(__dirname, 'templates', 'web') + const componentsPath = path.join( + OUTPUT_PATH, + 'web', + 'src', + 'components' + ) + + for (const fileName of [ + 'Card.tsx', + 'FruitInfo.tsx', + 'ProduceInfo.tsx', + 'StallInfo.tsx', + 'VegetableInfo.tsx', + ]) { + const templatePath = path.join(templatesPath, fileName) + const componentPath = path.join(componentsPath, fileName) + + fs.writeFileSync(componentPath, fs.readFileSync(templatePath)) + } + }, + }, + { + title: 'Copy sdl and service for groceries from templates', + task: () => { + const templatesPath = path.join(__dirname, 'templates', 'api') + const graphqlPath = path.join(OUTPUT_PATH, 'api', 'src', 'graphql') + const servicesPath = path.join(OUTPUT_PATH, 'api', 'src', 'services') + + const sdlTemplatePath = path.join(templatesPath, 'groceries.sdl.ts') + const sdlPath = path.join(graphqlPath, 'groceries.sdl.ts') + const serviceTemplatePath = path.join(templatesPath, 'groceries.ts') + const servicePath = path.join(servicesPath, 'groceries.ts') + + fs.writeFileSync(sdlPath, fs.readFileSync(sdlTemplatePath)) + fs.writeFileSync(servicePath, fs.readFileSync(serviceTemplatePath)) + }, + }, + { + title: 'Creating Groceries page', + task: async () => { + const createPage = createBuilder('yarn redwood g page') + await createPage('groceries') + + await applyCodemod( + 'groceriesPage.ts', + fullPath('web/src/pages/GroceriesPage/GroceriesPage') + ) + }, + }, + ] + + return tuiTaskList +} + module.exports = { apiTasks, webTasks, + fragmentsTasks, }