Implement the new wasp/server/crud API (#1695)

This commit is contained in:
Mihovil Ilakovac 2024-01-30 15:37:41 +01:00 committed by Filip Sodić
parent e2ac659526
commit cf961f52e2
15 changed files with 579 additions and 21 deletions

View File

@ -123,7 +123,8 @@
"./client/api": "./dist/api/index.js",
"./auth": "./dist/auth/index.js",
"./client/auth": "./dist/client/auth/index.js",
"./server/auth": "./dist/server/auth/index.js"
"./server/auth": "./dist/server/auth/index.js",
"./server/crud": "./dist/server/crud/index.js"
},
{=!
TypeScript doesn't care about the redirects we define above in "exports" field; those

View File

@ -34,13 +34,42 @@ import type {
type _WaspEntityTagged = _{= crud.entityUpper =}
type _WaspEntity = {= crud.entityUpper =}
/**
* PUBLIC API
*/
export namespace {= crud.name =} {
{=# crud.operations.GetAll =}
export type GetAllQuery<Input extends Payload, Output extends Payload> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=/ crud.operations.GetAll =}
{=# crud.operations.Get =}
export type GetQuery<Input extends Payload, Output extends Payload> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=/ crud.operations.Get =}
{=# crud.operations.Create =}
export type CreateAction<Input extends Payload, Output extends Payload>= {= actionType =}<[_WaspEntityTagged], Input, Output>
{=/ crud.operations.Create =}
{=# crud.operations.Update =}
export type UpdateAction<Input extends Payload, Output extends Payload> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=/ crud.operations.Update =}
{=# crud.operations.Delete =}
export type DeleteAction<Input extends Payload, Output extends Payload> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=/ crud.operations.Delete =}
}
/**
* PRIVATE API
*
* The types with the `Resolved` suffix are the types that are used internally by the Wasp client
* to implement full-stack type safety.
*/
{=# crud.operations.GetAll =}
// Get All query
export type GetAllQuery<Input extends Payload, Output extends Payload> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.GetAll.isDefined =}
type GetAllInput = {}
type GetAllOutput = _WaspEntity[]
export type GetAllQueryResolved = GetAllQuery<GetAllInput, GetAllOutput>
export type GetAllQueryResolved = {= crud.name =}.GetAllQuery<GetAllInput, GetAllOutput>
{=/ overrides.GetAll.isDefined =}
{=# overrides.GetAll.isDefined =}
const _waspGetAllQuery = {= overrides.GetAll.importIdentifier =}
@ -49,12 +78,10 @@ export type GetAllQueryResolved = typeof _waspGetAllQuery
{=/ crud.operations.GetAll =}
{=# crud.operations.Get =}
// Get query
export type GetQuery<Input extends Payload, Output extends Payload> = {= queryType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Get.isDefined =}
type GetInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
type GetOutput = _WaspEntity | null
export type GetQueryResolved = GetQuery<GetInput, GetOutput>
export type GetQueryResolved = {= crud.name =}.GetQuery<GetInput, GetOutput>
{=/ overrides.Get.isDefined =}
{=# overrides.Get.isDefined =}
const _waspGetQuery = {= overrides.Get.importIdentifier =}
@ -63,12 +90,10 @@ export type GetQueryResolved = typeof _waspGetQuery
{=/ crud.operations.Get =}
{=# crud.operations.Create =}
// Create action
export type CreateAction<Input extends Payload, Output extends Payload>= {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Create.isDefined =}
type CreateInput = Prisma.{= crud.entityUpper =}CreateInput
type CreateOutput = _WaspEntity
export type CreateActionResolved = CreateAction<CreateInput, CreateOutput>
export type CreateActionResolved = {= crud.name =}.CreateAction<CreateInput, CreateOutput>
{=/ overrides.Create.isDefined =}
{=# overrides.Create.isDefined =}
const _waspCreateAction = {= overrides.Create.importIdentifier =}
@ -77,12 +102,10 @@ export type CreateActionResolved = typeof _waspCreateAction
{=/ crud.operations.Create =}
{=# crud.operations.Update =}
// Update action
export type UpdateAction<Input extends Payload, Output extends Payload> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Update.isDefined =}
type UpdateInput = Prisma.{= crud.entityUpper =}UpdateInput & Prisma.{= crud.entityUpper =}WhereUniqueInput
type UpdateOutput = _WaspEntity
export type UpdateActionResolved = UpdateAction<UpdateInput, UpdateOutput>
export type UpdateActionResolved = {= crud.name =}.UpdateAction<UpdateInput, UpdateOutput>
{=/ overrides.Update.isDefined =}
{=# overrides.Update.isDefined =}
const _waspUpdateAction = {= overrides.Update.importIdentifier =}
@ -91,12 +114,10 @@ export type UpdateActionResolved = typeof _waspUpdateAction
{=/ crud.operations.Update =}
{=# crud.operations.Delete =}
// Delete action
export type DeleteAction<Input extends Payload, Output extends Payload> = {= actionType =}<[_WaspEntityTagged], Input, Output>
{=^ overrides.Delete.isDefined =}
type DeleteInput = Prisma.{= crud.entityUpper =}WhereUniqueInput
type DeleteOutput = _WaspEntity
export type DeleteActionResolved = DeleteAction<DeleteInput, DeleteOutput>
export type DeleteActionResolved = {= crud.name =}.DeleteAction<DeleteInput, DeleteOutput>
{=/ overrides.Delete.isDefined =}
{=# overrides.Delete.isDefined =}
const _waspDeleteAction = {= overrides.Delete.importIdentifier =}

View File

@ -0,0 +1,5 @@
{{={= =}=}}
{=# cruds =}
export type { {= name =} } from './{= name =}';
{=/ cruds =}

View File

@ -0,0 +1,96 @@
import { useState, createContext } from 'react'
import { createTheme } from '@stitches/react'
import { styled } from 'wasp/core/stitches.config'
import {
type State,
type CustomizationOptions,
type ErrorMessage,
type AdditionalSignupFields,
} from './types'
import { LoginSignupForm } from './internal/common/LoginSignupForm'
import { MessageError, MessageSuccess } from './internal/Message'
import { ForgotPasswordForm } from './internal/email/ForgotPasswordForm'
import { ResetPasswordForm } from './internal/email/ResetPasswordForm'
import { VerifyEmailForm } from './internal/email/VerifyEmailForm'
const logoStyle = {
height: '3rem'
}
const Container = styled('div', {
display: 'flex',
flexDirection: 'column',
})
const HeaderText = styled('h2', {
fontSize: '1.875rem',
fontWeight: '700',
marginTop: '1.5rem'
})
// PRIVATE API
export const AuthContext = createContext({
isLoading: false,
setIsLoading: (isLoading: boolean) => {},
setErrorMessage: (errorMessage: ErrorMessage | null) => {},
setSuccessMessage: (successMessage: string | null) => {},
})
function Auth ({ state, appearance, logo, socialLayout = 'horizontal', additionalSignupFields }: {
state: State;
} & CustomizationOptions & {
additionalSignupFields?: AdditionalSignupFields;
}) {
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// TODO(matija): this is called on every render, is it a problem?
// If we do it in useEffect(), then there is a glitch between the default color and the
// user provided one.
const customTheme = createTheme(appearance ?? {})
const titles: Record<State, string> = {
login: 'Log in to your account',
signup: 'Create a new account',
"forgot-password": "Forgot your password?",
"reset-password": "Reset your password",
"verify-email": "Email verification",
}
const title = titles[state]
const socialButtonsDirection = socialLayout === 'vertical' ? 'vertical' : 'horizontal'
return (
<Container className={customTheme}>
<div>
{logo && (<img style={logoStyle} src={logo} alt='Your Company' />)}
<HeaderText>{title}</HeaderText>
</div>
{errorMessage && (
<MessageError>
{errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
</MessageError>
)}
{successMessage && <MessageSuccess>{successMessage}</MessageSuccess>}
<AuthContext.Provider value={{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }}>
{(state === 'login' || state === 'signup') && (
<LoginSignupForm
state={state}
socialButtonsDirection={socialButtonsDirection}
additionalSignupFields={additionalSignupFields}
/>
)}
{state === 'forgot-password' && (<ForgotPasswordForm />)}
{state === 'reset-password' && (<ResetPasswordForm />)}
{state === 'verify-email' && (<VerifyEmailForm />)}
</AuthContext.Provider>
</Container>
)
}
// PRIVATE API
export default Auth;

View File

@ -0,0 +1,18 @@
import Auth from './Auth'
import { type CustomizationOptions, State } from './types'
// PUBLIC API
export function LoginForm({
appearance,
logo,
socialLayout,
}: CustomizationOptions) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Login}
/>
)
}

View File

@ -0,0 +1,24 @@
import Auth from './Auth'
import {
type CustomizationOptions,
type AdditionalSignupFields,
State,
} from './types'
// PUBLIC API
export function SignupForm({
appearance,
logo,
socialLayout,
additionalFields,
}: CustomizationOptions & { additionalFields?: AdditionalSignupFields; }) {
return (
<Auth
appearance={appearance}
logo={logo}
socialLayout={socialLayout}
state={State.Signup}
additionalSignupFields={additionalFields}
/>
)
}

View File

@ -0,0 +1,102 @@
import { styled } from 'wasp/core/stitches.config'
// PRIVATE API
export const Form = styled('form', {
marginTop: '1.5rem',
})
// PUBLIC API
export const FormItemGroup = styled('div', {
'& + div': {
marginTop: '1.5rem',
},
})
// PUBLIC API
export const FormLabel = styled('label', {
display: 'block',
fontSize: '$sm',
fontWeight: '500',
marginBottom: '0.5rem',
})
const commonInputStyles = {
display: 'block',
lineHeight: '1.5rem',
fontSize: '$sm',
borderWidth: '1px',
borderColor: '$gray600',
backgroundColor: '#f8f4ff',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'&:focus': {
borderWidth: '1px',
borderColor: '$gray700',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
borderRadius: '0.375rem',
width: '100%',
paddingTop: '0.375rem',
paddingBottom: '0.375rem',
paddingLeft: '0.75rem',
paddingRight: '0.75rem',
margin: 0,
}
// PUBLIC API
export const FormInput = styled('input', commonInputStyles)
// PUBLIC API
export const FormTextarea = styled('textarea', commonInputStyles)
// PUBLIC API
export const FormError = styled('div', {
display: 'block',
fontSize: '$sm',
fontWeight: '500',
color: '$formErrorText',
marginTop: '0.5rem',
})
// PRIVATE API
export const SubmitButton = styled('button', {
display: 'flex',
justifyContent: 'center',
width: '100%',
borderWidth: '1px',
borderColor: '$brand',
backgroundColor: '$brand',
color: '$submitButtonText',
padding: '0.5rem 0.75rem',
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
fontWeight: '600',
fontSize: '$sm',
lineHeight: '1.25rem',
borderRadius: '0.375rem',
// TODO(matija): extract this into separate BaseButton component and then inherit it.
'&:hover': {
backgroundColor: '$brandAccent',
borderColor: '$brandAccent',
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
backgroundColor: '$gray400',
borderColor: '$gray400',
color: '$gray500',
},
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionDuration: '100ms',
})

View File

@ -0,0 +1,21 @@
import { styled } from 'wasp/core/stitches.config'
// PRIVATE API
export const Message = styled('div', {
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
marginTop: '1rem',
background: '$gray400',
})
// PRIVATE API
export const MessageError = styled(Message, {
background: '$errorBackground',
color: '$errorText',
})
// PRIVATE API
export const MessageSuccess = styled(Message, {
background: '$successBackground',
color: '$successText',
})

View File

@ -0,0 +1,184 @@
import { useContext } from 'react'
import { useForm, UseFormReturn } from 'react-hook-form'
import { styled } from 'wasp/core/stitches.config'
import config from 'wasp/core/config'
import { AuthContext } from '../../Auth'
import {
Form,
FormInput,
FormItemGroup,
FormLabel,
FormError,
FormTextarea,
SubmitButton,
} from '../Form'
import type {
AdditionalSignupFields,
AdditionalSignupField,
AdditionalSignupFieldRenderFn,
FormState,
} from '../../types'
import { useHistory } from 'react-router-dom'
import { useEmail } from '../email/useEmail'
// PRIVATE API
export type LoginSignupFormFields = {
[key: string]: string;
}
// PRIVATE API
export const LoginSignupForm = ({
state,
socialButtonsDirection = 'horizontal',
additionalSignupFields,
}: {
state: 'login' | 'signup'
socialButtonsDirection?: 'horizontal' | 'vertical'
additionalSignupFields?: AdditionalSignupFields
}) => {
const {
isLoading,
setErrorMessage,
setSuccessMessage,
setIsLoading,
} = useContext(AuthContext)
const isLogin = state === 'login'
const cta = isLogin ? 'Log in' : 'Sign up';
const history = useHistory();
const onErrorHandler = (error) => {
setErrorMessage({ title: error.message, description: error.data?.data?.message })
};
const hookForm = useForm<LoginSignupFormFields>()
const { register, formState: { errors }, handleSubmit: hookFormHandleSubmit } = hookForm
const { handleSubmit } = useEmail({
isLogin,
onError: onErrorHandler,
showEmailVerificationPending() {
hookForm.reset()
setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
},
onLoginSuccess() {
history.push('/')
},
});
async function onSubmit (data) {
setIsLoading(true);
setErrorMessage(null);
setSuccessMessage(null);
try {
await handleSubmit(data);
} finally {
setIsLoading(false);
}
}
return (<>
<Form onSubmit={hookFormHandleSubmit(onSubmit)}>
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
{...register('email', {
required: 'Email is required',
})}
type="email"
disabled={isLoading}
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
</FormItemGroup>
<FormItemGroup>
<FormLabel>Password</FormLabel>
<FormInput
{...register('password', {
required: 'Password is required',
})}
type="password"
disabled={isLoading}
/>
{errors.password && <FormError>{errors.password.message}</FormError>}
</FormItemGroup>
<AdditionalFormFields
hookForm={hookForm}
formState={{ isLoading }}
additionalSignupFields={additionalSignupFields}
/>
<FormItemGroup>
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
</FormItemGroup>
</Form>
</>)
}
function AdditionalFormFields({
hookForm,
formState: { isLoading },
additionalSignupFields,
}: {
hookForm: UseFormReturn<LoginSignupFormFields>;
formState: FormState;
additionalSignupFields: AdditionalSignupFields;
}) {
const {
register,
formState: { errors },
} = hookForm;
function renderField<ComponentType extends React.JSXElementConstructor<any>>(
field: AdditionalSignupField,
// Ideally we would use ComponentType here, but it doesn't work with react-hook-form
Component: any,
props?: React.ComponentProps<ComponentType>
) {
return (
<FormItemGroup key={field.name}>
<FormLabel>{field.label}</FormLabel>
<Component
{...register(field.name, field.validations)}
{...props}
disabled={isLoading}
/>
{errors[field.name] && (
<FormError>{errors[field.name].message}</FormError>
)}
</FormItemGroup>
);
}
if (areAdditionalFieldsRenderFn(additionalSignupFields)) {
return additionalSignupFields(hookForm, { isLoading })
}
return (
additionalSignupFields &&
additionalSignupFields.map((field) => {
if (isFieldRenderFn(field)) {
return field(hookForm, { isLoading })
}
switch (field.type) {
case 'input':
return renderField<typeof FormInput>(field, FormInput, {
type: 'text',
})
case 'textarea':
return renderField<typeof FormTextarea>(field, FormTextarea)
default:
throw new Error(
`Unsupported additional signup field type: ${field.type}`
)
}
})
)
}
function isFieldRenderFn(
additionalSignupField: AdditionalSignupField | AdditionalSignupFieldRenderFn
): additionalSignupField is AdditionalSignupFieldRenderFn {
return typeof additionalSignupField === 'function'
}
function areAdditionalFieldsRenderFn(
additionalSignupFields: AdditionalSignupFields
): additionalSignupFields is AdditionalSignupFieldRenderFn {
return typeof additionalSignupFields === 'function'
}

View File

@ -0,0 +1,49 @@
import { createTheme } from '@stitches/react'
import { UseFormReturn, RegisterOptions } from 'react-hook-form'
import type { LoginSignupFormFields } from './internal/common/LoginSignupForm'
// PRIVATE API
export enum State {
Login = 'login',
Signup = 'signup',
ForgotPassword = 'forgot-password',
ResetPassword = 'reset-password',
VerifyEmail = 'verify-email',
}
// PUBLIC API
export type CustomizationOptions = {
logo?: string
socialLayout?: 'horizontal' | 'vertical'
appearance?: Parameters<typeof createTheme>[0]
}
// PRIVATE API
export type ErrorMessage = {
title: string
description?: string
}
// PRIVATE API
export type FormState = {
isLoading: boolean
}
// PRIVATE API
export type AdditionalSignupFieldRenderFn = (
hookForm: UseFormReturn<LoginSignupFormFields>,
formState: FormState
) => React.ReactNode
// PRIVATE API
export type AdditionalSignupField = {
name: string
label: string
type: 'input' | 'textarea'
validations?: RegisterOptions<LoginSignupFormFields>
}
// PRIVATE API
export type AdditionalSignupFields =
| (AdditionalSignupField | AdditionalSignupFieldRenderFn)[]
| AdditionalSignupFieldRenderFn

View File

@ -63,7 +63,8 @@
"./client/api": "./dist/api/index.js",
"./auth": "./dist/auth/index.js",
"./client/auth": "./dist/client/auth/index.js",
"./server/auth": "./dist/server/auth/index.js"
"./server/auth": "./dist/server/auth/index.js",
"./server/crud": "./dist/server/crud/index.js"
},
"typesVersions": {
"*": {

View File

@ -1,6 +1,6 @@
import { Task } from "wasp/entities";
import { GetAllQuery } from "wasp/server/crud/Tasks";
import { Task } from 'wasp/entities'
import { Tasks } from 'wasp/server/crud'
export const getAllQuery = ((args, context) => {
return context.entities.Task.findMany({});
}) satisfies GetAllQuery<{}, Task[]>;
return context.entities.Task.findMany({})
}) satisfies Tasks.GetAllQuery<{}, Task[]>

View File

@ -45,6 +45,7 @@ import Wasp.Generator.SdkGenerator.JobGenerator (genJobTypes)
import Wasp.Generator.SdkGenerator.RouterGenerator (genRouter)
import Wasp.Generator.SdkGenerator.RpcGenerator (genRpc)
import Wasp.Generator.SdkGenerator.Server.AuthG (genNewServerApi)
import Wasp.Generator.SdkGenerator.Server.CrudG (genNewServerCrudApi)
import Wasp.Generator.SdkGenerator.ServerApiG (genServerApi)
import Wasp.Generator.SdkGenerator.ServerOpsGenerator (genOperations)
import Wasp.Generator.SdkGenerator.WebSocketGenerator (depsRequiredByWebSockets, genWebSockets)
@ -117,6 +118,7 @@ genSdkReal spec =
-- New API
<++> genNewClientAuth spec
<++> genNewServerApi spec
<++> genNewServerCrudApi spec
where
genFileCopy = return . C.mkTmplFd

View File

@ -0,0 +1,33 @@
module Wasp.Generator.SdkGenerator.Server.CrudG
( genNewServerCrudApi,
)
where
import Data.Aeson (object, (.=))
import qualified Data.Aeson as Aeson
import StrongPath (relfile)
import Wasp.AppSpec (AppSpec, getCruds)
import qualified Wasp.AppSpec.Crud as AS.Crud
import Wasp.AppSpec.Valid (getIdFieldFromCrudEntity)
import Wasp.Generator.Crud (getCrudOperationJson)
import Wasp.Generator.FileDraft (FileDraft)
import Wasp.Generator.Monad (Generator)
import qualified Wasp.Generator.SdkGenerator.Common as C
genNewServerCrudApi :: AppSpec -> Generator [FileDraft]
genNewServerCrudApi spec =
if areThereAnyCruds
then sequence [genCrudIndex spec cruds]
else return []
where
cruds = getCruds spec
areThereAnyCruds = not $ null cruds
genCrudIndex :: AppSpec -> [(String, AS.Crud.Crud)] -> Generator FileDraft
genCrudIndex spec cruds = return $ C.mkTmplFdWithData [relfile|server/crud/index.ts|] tmplData
where
tmplData = object ["cruds" .= map getCrudOperationJsonFromCrud cruds]
getCrudOperationJsonFromCrud :: (String, AS.Crud.Crud) -> Aeson.Value
getCrudOperationJsonFromCrud (name, crud) = getCrudOperationJson name crud idField
where
idField = getIdFieldFromCrudEntity spec crud

View File

@ -310,6 +310,7 @@ library
Wasp.Generator.SdkGenerator.RouterGenerator
Wasp.Generator.SdkGenerator.Client.AuthG
Wasp.Generator.SdkGenerator.Server.AuthG
Wasp.Generator.SdkGenerator.Server.CrudG
Wasp.Generator.ServerGenerator
Wasp.Generator.ServerGenerator.JsImport
Wasp.Generator.ServerGenerator.ApiRoutesG