From 19c5c16a68db7b61d57a647cf10883029cb79ecc Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 07:36:54 +0100 Subject: [PATCH 01/18] Router: Use a single RouterContext (#9792) --- packages/router/src/router-context.tsx | 38 ++++++++------------------ 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/router/src/router-context.tsx b/packages/router/src/router-context.tsx index 74a421773e98..4c753e2425b8 100644 --- a/packages/router/src/router-context.tsx +++ b/packages/router/src/router-context.tsx @@ -1,4 +1,4 @@ -import React, { useReducer, createContext, useContext } from 'react' +import React, { createContext, useContext, useMemo } from 'react' import type { AuthContextInterface } from '@redwoodjs/auth' import { useNoAuth } from '@redwoodjs/auth' @@ -29,19 +29,6 @@ export interface RouterState { const RouterStateContext = createContext(undefined) -export interface RouterSetContextProps { - setState: (newState: Partial) => void -} - -const RouterSetContext = createContext< - React.Dispatch> | undefined ->(undefined) - -/** - * This file splits the context into getter and setter contexts. - * This was originally meant to optimize the number of redraws - * See https://kentcdodds.com/blog/how-to-optimize-your-context-value - */ export interface RouterContextProviderProps extends Omit { useAuth?: UseAuth @@ -50,10 +37,6 @@ export interface RouterContextProviderProps children: React.ReactNode } -function stateReducer(state: RouterState, newState: Partial) { - return { ...state, ...newState } -} - export const RouterContextProvider: React.FC = ({ useAuth, paramTypes, @@ -61,18 +44,19 @@ export const RouterContextProvider: React.FC = ({ activeRouteName, children, }) => { - const [state, setState] = useReducer(stateReducer, { - useAuth: useAuth || useNoAuth, - paramTypes, - routes, - activeRouteName, - }) + const state = useMemo( + () => ({ + useAuth: useAuth || useNoAuth, + paramTypes, + routes, + activeRouteName, + }), + [useAuth, paramTypes, routes, activeRouteName] + ) return ( - - {children} - + {children} ) } From f0537299984c0ca4eb40a996513c39a1d85f893b Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 08:07:14 +0100 Subject: [PATCH 02/18] useRoutePath(): Get the path for the current route by default (#9790) --- docs/docs/router.md | 18 ++++++++++--- .../src/__tests__/useRoutePaths.test.tsx | 25 ++++++++++++++++--- packages/router/src/useRoutePaths.ts | 12 +++++++-- 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/docs/docs/router.md b/docs/docs/router.md index fc0ddbda938e..16783e872491 100644 --- a/docs/docs/router.md +++ b/docs/docs/router.md @@ -478,14 +478,24 @@ Example output: ## useRoutePath -This is a convenience hook for when you only want the path for a single route. +Use this hook when you only want the path for a single route. By default it +will give you the path for the current route ```jsx -const aboutPath = useRoutePath('about') // returns "/about" +// returns "/about" if you're currently on https://example.org/about +const aboutPath = useRoutePath() ``` -is the same as + +You can also pass in the name of a route and get the path for that route +```jsx +// returns "/about" +const aboutPath = useRoutePath('about') +``` + +Note that the above is the same as ```jsx const routePaths = useRoutePaths() -const aboutPath = routePaths.about // Also returns "/about" +// returns "/about" +const aboutPath = routePaths.about ``` ## useRouteName diff --git a/packages/router/src/__tests__/useRoutePaths.test.tsx b/packages/router/src/__tests__/useRoutePaths.test.tsx index a456a8bf1e70..f744213f3389 100644 --- a/packages/router/src/__tests__/useRoutePaths.test.tsx +++ b/packages/router/src/__tests__/useRoutePaths.test.tsx @@ -2,7 +2,9 @@ import React from 'react' import { render } from '@testing-library/react' +import { act } from 'react-dom/test-utils' +import { navigate } from '../history' import { Route, Router } from '../router' import { Set } from '../Set' import { useRoutePaths, useRoutePath } from '../useRoutePaths' @@ -27,17 +29,25 @@ test('useRoutePaths and useRoutePath', async () => { children: React.ReactNode } - const Layout = ({ children }: LayoutProps) => <>{children} + const Layout = ({ children }: LayoutProps) => { + // No name means current route + const routePath = useRoutePath() + + return ( + <> +

Current route path: "{routePath}"

+ {children} + + ) + } const Page = () =>

Page

const TestRouter = () => ( - + - - @@ -48,4 +58,11 @@ test('useRoutePaths and useRoutePath', async () => { await screen.findByText('Home Page') await screen.findByText(/^My path is\s+\/$/) await screen.findByText(/^All paths:\s+\/,\/one,\/two\/\{id:Int\}$/) + await screen.findByText('Current route path: "/"') + + act(() => navigate('/one')) + await screen.findByText('Current route path: "/one"') + + act(() => navigate('/two/123')) + await screen.findByText('Current route path: "/two/{id:Int}"') }) diff --git a/packages/router/src/useRoutePaths.ts b/packages/router/src/useRoutePaths.ts index e3f269e1270d..3b5a7efd2c90 100644 --- a/packages/router/src/useRoutePaths.ts +++ b/packages/router/src/useRoutePaths.ts @@ -1,4 +1,5 @@ import { useRouterState } from './router-context' +import { useRouteName } from './useRouteName' import type { GeneratedRoutesMap } from './util' import type { AvailableRoutes } from '.' @@ -20,8 +21,15 @@ export function useRoutePaths() { return routePaths } -export function useRoutePath(routeName: keyof AvailableRoutes) { +export function useRoutePath(routeName?: keyof AvailableRoutes) { + const currentRouteName = useRouteName() const routePaths = useRoutePaths() - return routePaths[routeName] + const name = routeName || currentRouteName + + if (!name) { + return undefined + } + + return routePaths[name] } From 660f03311c043f955fe58544d504ec033437e8e0 Mon Sep 17 00:00:00 2001 From: Josh GM Walker <56300765+Josh-Walker-GM@users.noreply.github.com> Date: Wed, 3 Jan 2024 11:34:32 +0000 Subject: [PATCH 03/18] fix(fastify): Prevent duplicate `@fastify/url-data` registration (#9794) --- packages/api-server/src/plugins/withFunctions.ts | 4 +++- packages/api-server/src/plugins/withWebServer.ts | 4 +++- packages/fastify/src/api.ts | 4 +++- packages/fastify/src/graphql.ts | 4 +++- packages/fastify/src/web.ts | 4 +++- packages/web-server/src/web.ts | 4 +++- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/api-server/src/plugins/withFunctions.ts b/packages/api-server/src/plugins/withFunctions.ts index d985fcbb0697..dd0a2006bb79 100644 --- a/packages/api-server/src/plugins/withFunctions.ts +++ b/packages/api-server/src/plugins/withFunctions.ts @@ -13,7 +13,9 @@ const withFunctions = async ( ) => { const { apiRootPath } = options // Add extra fastify plugins - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } // Fastify v4 must await the fastifyRawBody plugin // registration to ensure the plugin is ready diff --git a/packages/api-server/src/plugins/withWebServer.ts b/packages/api-server/src/plugins/withWebServer.ts index 98c5b9d8ce6c..d265c428159f 100644 --- a/packages/api-server/src/plugins/withWebServer.ts +++ b/packages/api-server/src/plugins/withWebServer.ts @@ -28,7 +28,9 @@ const withWebServer = async ( fastify: FastifyInstance, options: WebServerArgs ) => { - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } const prerenderedFiles = findPrerenderedHtml() const indexPath = getFallbackIndexPath() diff --git a/packages/fastify/src/api.ts b/packages/fastify/src/api.ts index 47e5205d01e0..d060edbd3656 100644 --- a/packages/fastify/src/api.ts +++ b/packages/fastify/src/api.ts @@ -14,7 +14,9 @@ export async function redwoodFastifyAPI( opts: RedwoodFastifyAPIOptions, done: HookHandlerDoneFunction ) { - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } await fastify.register(fastifyRawBody) // TODO: This should be refactored to only be defined once and it might not live here diff --git a/packages/fastify/src/graphql.ts b/packages/fastify/src/graphql.ts index bf564f33aace..b3d1ef5d06b1 100644 --- a/packages/fastify/src/graphql.ts +++ b/packages/fastify/src/graphql.ts @@ -34,7 +34,9 @@ export async function redwoodFastifyGraphQLServer( // These two plugins are needed to transform a Fastify Request to a Lambda event // which is used by the RedwoodGraphQLContext and mimics the behavior of the // api-server withFunction plugin - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } await fastify.register(fastifyRawBody) try { diff --git a/packages/fastify/src/web.ts b/packages/fastify/src/web.ts index 93bd0cc89e7e..b8d1bbac26e7 100644 --- a/packages/fastify/src/web.ts +++ b/packages/fastify/src/web.ts @@ -21,7 +21,9 @@ export async function redwoodFastifyWeb( opts: RedwoodFastifyWebOptions, done: HookHandlerDoneFunction ) { - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } const prerenderedFiles = findPrerenderedHtml() // Serve prerendered HTML directly, instead of the index. diff --git a/packages/web-server/src/web.ts b/packages/web-server/src/web.ts index 8b115880335a..1a60ee88efb2 100644 --- a/packages/web-server/src/web.ts +++ b/packages/web-server/src/web.ts @@ -27,7 +27,9 @@ export async function redwoodFastifyWeb( opts: RedwoodFastifyWebOptions, done: HookHandlerDoneFunction ) { - fastify.register(fastifyUrlData) + if (!fastify.hasPlugin('@fastify/url-data')) { + await fastify.register(fastifyUrlData) + } const prerenderedFiles = findPrerenderedHtml() // Serve prerendered HTML directly, instead of the index. From c77ebee0fec7fc98dd6ca21210816512912e3f63 Mon Sep 17 00:00:00 2001 From: Tobbe Lundberg Date: Wed, 3 Jan 2024 13:04:29 +0100 Subject: [PATCH 04/18] Add routeParams to useMatch (#9793) Make it possible to specify route param values that need to match. If this is your route: `` And you want to only match posts from 2001 you can now do this: `useMatch('/blog/{year}/{month}/{day}', { routeParams: { year: '2001' } })` This is **finally** a solution to matching route paths. The work started in #7469, but we were never able to come up with an api/dx that we really liked. This PR and #9755 together however provides a solution that we're much more happy with, and that also supports the use case outlined in that original PR. Here's the example from #7469 as it could be solved with the code in this PR ```jsx const Navbar () => { const { project } = useParams() const routePaths = useRoutePaths() const modes = [ { name: "Info", route: routes.info({ project }), match: useMatch(routePaths.info), // using the hook together with routePaths }, { name: "Compare", route: routes.compare({ project, id: "1" }), match: useMatch(useRoutePath('compare')), // alternative to the above }, // ... ] return ( <> {modes.map((x) => + + + ) +} + +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, }