mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-12-26 10:35:04 +03:00
Merge branch 'main' into release-with-main-2
This commit is contained in:
commit
b1eee23f41
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,3 +9,4 @@
|
||||
|
||||
# macOS related
|
||||
.DS_Store
|
||||
.vscode/
|
||||
|
62
README.md
62
README.md
@ -2,67 +2,77 @@
|
||||
<img height="80px" src="https://user-images.githubusercontent.com/1536647/77317442-78625700-6d0b-11ea-9822-0fb21e557e87.png"/>
|
||||
</p>
|
||||
<p align=center>
|
||||
A programming language that understands what a web app is.
|
||||
The fastest way to develop full-stack web apps with React & Node.js.
|
||||
</p>
|
||||
<br>
|
||||
<p align=center>
|
||||
<a href="https://github.com/wasp-lang/wasp/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/github/license/wasp-lang/wasp"></a>
|
||||
<a href="https://github.com/wasp-lang/wasp/search?l=haskell"><img alt="language" src="https://img.shields.io/badge/language-Haskell-purple.svg"></a>
|
||||
<a href="https://github.com/wasp-lang/wasp/actions"><img alt="build status" src="https://img.shields.io/github/workflow/status/wasp-lang/wasp/CI"/></a>
|
||||
<a href="https://github.com/wasp-lang/wasp/releases/latest"><img alt="latest release" src="https://img.shields.io/github/v/release/wasp-lang/wasp"/></a>
|
||||
<a href="https://discord.gg/rzdnErX"><img alt="discord" src="https://img.shields.io/discord/686873244791210014?label=chat%20@%20discord"/></a>
|
||||
</p>
|
||||
|
||||
------
|
||||
|
||||
<p align="center">
|
||||
{= <a href="https://wasp-lang.notion.site/Wasp-Careers-59fd1682c80d446f92be5fa65cc17672">We are hiring!</a> =}
|
||||
<a href="https://wasp-lang.dev">Web page</a> | <a href="https://wasp-lang.dev/docs">Docs</a>
|
||||
</p>
|
||||
|
||||
- [**Project page**](https://wasp-lang.dev)
|
||||
- [**Docs**](https://wasp-lang.dev/docs)
|
||||
|
||||
<br>
|
||||
|
||||
Wasp (**W**eb **A**pplication **Sp**ecification Language) is a declarative DSL (domain-specific language) for developing, building and deploying modern full-stack web apps with less code.
|
||||
Wasp (**W**eb **A**pplication **Sp**ecification) is a Rails-like framework for React, Node.js and Prisma.
|
||||
Build your app in a day and deploy it with a single CLI command!
|
||||
|
||||
Concepts such as *app*, *page*, *user*, *login*, *frontend*, *production*, etc. are baked into the language, bringing a new level of expressiveness and allowing you to get more work done with fewer lines of code.
|
||||
### Why is Wasp awesome
|
||||
- 🚀 **Quick start**: Due to its expressiveness, you can create and deploy a production-ready web app from scratch with very few lines of concise, consistent, declarative code.
|
||||
- 😌 **No boilerplate**: By abstracting away complex full-stack features, there is less boilerplate code. That means less code to maintain and understand! It also means easier upgrades.
|
||||
- 🔓 **No lock-in**: You can deploy Wasp app anywhere you like. There is no lock-in into specific providers, you have full control over the code (and can actually check it out in .wasp/ dir if you are interested ).
|
||||
|
||||
While describing high-level features with Wasp, you still write the rest of your logic in your favorite technologies (currently React, NodeJS, Prisma).
|
||||
### Features
|
||||
🔒 Full-stack Auth, 🖇️ RPC (Client <-> Server), 🚀 Simple Deployment, ⚙ ️Jobs, ✉️ Email Sending, 🛟 Full-stack Type Safety, ...
|
||||
|
||||
### Code example
|
||||
|
||||
Simple Wasp config file in which you describe the high-level details of your web app:
|
||||
```js
|
||||
// file: main.wasp
|
||||
|
||||
app TodoApp {
|
||||
title: "Todo App",
|
||||
wasp: { version: "^0.7.0" }
|
||||
app todoApp {
|
||||
title: "ToDo App", // visible in the browser tab
|
||||
wasp: { version: "^0.10.0" },
|
||||
auth: { // full-stack auth out-of-the-box
|
||||
userEntity: User, methods: { email: {...} }
|
||||
}
|
||||
}
|
||||
|
||||
route RootRoute { path: "/", to: MainPage }
|
||||
page MainPage {
|
||||
component: import Main from "@ext/pages/Main.jsx" // Importing React component.
|
||||
authRequired: true, // Limit access to logged in users.
|
||||
component: import Main from "@client/Main.tsx" // Your React code.
|
||||
}
|
||||
|
||||
query getTasks {
|
||||
fn: import { getTasks } from "@ext/queries.js", // Importing NodeJS code.
|
||||
entities: [Task]
|
||||
fn: import { getTasks } from "@server/tasks.js", // Your Node.js code.
|
||||
entities: [Task] // Automatic cache invalidation.
|
||||
}
|
||||
|
||||
entity Task {=psl
|
||||
entity Task {=psl // Your Prisma data model.
|
||||
id Int @id @default(autoincrement())
|
||||
description String
|
||||
isDone Boolean @default(false)
|
||||
psl=}
|
||||
```
|
||||
|
||||
Source files (`.wasp`, `.js(x)`, `.css`, ...) are compiled by `waspc` (Wasp compiler) into the web technology stack of your choice (currently React + NodeJS/ExpressJS + Prisma).
|
||||
The rest of the code you write in React / Node.js / Prisma and just reference it from the .wasp file.
|
||||
|
||||
:arrow_forward: Check out [TodoApp example](/examples/tutorials/TodoApp) for the complete code example. :arrow_backward:
|
||||
|
||||
Why is Wasp awesome:
|
||||
- **Quick start**: Due to its expressiveness, you can create and deploy a production-ready web app from scratch with very few lines of concise, consistent, declarative code.
|
||||
- **Flexible**: When you need more control than Wasp offers, you can write code in existing technologies such as js/html/css/... and combine it with Wasp code!
|
||||
- **No lock-in**: If Wasp becomes too limiting for you, simply eject and continue with the generated source code, which is human-readable.
|
||||
### How it works
|
||||
|
||||
<img width="800px" src="https://user-images.githubusercontent.com/1536647/231472285-126679e5-ecce-4cbb-8579-eb3cd9ba95bf.png"/>
|
||||
|
||||
Given a simple .wasp configuration file that describes the high-level details of your web app, and .js(x)/.css/..., source files with your unique logic, Wasp compiler generates the full source of your web app in the target stack: front-end, back-end and deployment.
|
||||
|
||||
This unique approach is what makes Wasp "smart" and gives it its super powers!
|
||||
|
||||
For more information about Wasp, check [**docs**](https://wasp-lang.dev/docs).
|
||||
|
||||
@ -72,9 +82,9 @@ Run
|
||||
```
|
||||
curl -sSL https://get.wasp-lang.dev/installer.sh | sh
|
||||
```
|
||||
to install Wasp on OSX/Linux. From there, just follow the instructions to run your first app in less than a minute!
|
||||
to install Wasp on OSX/Linux/WSL(Win). From there, just follow the instructions to run your first app in less than a minute!
|
||||
|
||||
For more details (including installing on Windows) check out [the docs](https://wasp-lang.dev/docs).
|
||||
For more details check out [the docs](https://wasp-lang.dev/docs).
|
||||
|
||||
# This repository
|
||||
|
||||
@ -86,7 +96,7 @@ Currently, Wasp is in beta, with most features flushed out and working well.
|
||||
However, there are still a lot of improvements and additions that we have in mind for the future, and we are working on them constantly, so you can expect a lot of changes and improvements in the future.
|
||||
As Wasp grows further, it should allow the development of web apps of increasing complexity!
|
||||
|
||||
While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma. We might yet change that as time goes on, taking trends into account, but for now, this is serving us well to develop compiler and language.
|
||||
While the idea is to support multiple web tech stacks in the future, right now we are focusing on the specific stack: React + react-query, NodeJS + ExpressJS, Prisma. We might yet change that as time goes on, taking trends into account, but for now, this is serving us well to develop Wasp.
|
||||
|
||||
# Contributing
|
||||
|
||||
@ -94,6 +104,8 @@ Any way you want to contribute is a good way :)!
|
||||
|
||||
The best place to start is to check out [waspc/](waspc/), where you can find detailed steps for the first time contributors + technical details about the Wasp compiler.
|
||||
|
||||
Core of Wasp is built in Haskell, but there is also a lot of non-Haskell parts of Wasp, so you will certainly be able to find something for you!
|
||||
|
||||
Even if you don't plan to submit any code, just joining the discussion on discord [![Discord](https://img.shields.io/discord/686873244791210014?label=chat%20on%20discord)](https://discord.gg/rzdnErX) and giving your feedback is already great and helps a lot (motivates us and helps us figure out how to shape Wasp)!
|
||||
|
||||
You can also:
|
||||
|
@ -8,15 +8,9 @@ Here, we implement it in Wasp, by following their [specification](https://realwo
|
||||
This app is deployed at https://wasp-rwa.netlify.app/ .
|
||||
|
||||
# Development
|
||||
|
||||
### Database
|
||||
Wasp needs postgre database running - provide it with database connection URL via env var `DATABASE_URL` - best to do it via .env file.
|
||||
|
||||
Easy way to get going with postgresql database: run db with `docker run --rm --publish 5432:5432 -v postgresql-data:/var/lib/postgresql/data --env POSTGRES_PASSWORD=devpass postgres`.
|
||||
`DATABASE_URL` in this case is `postgresql://postgres:devpass@localhost:5432/postgres`.
|
||||
|
||||
### Running
|
||||
`wasp start`
|
||||
`wasp start` to run the client and the server.
|
||||
`wasp start db` to run the dev database.
|
||||
|
||||
## TODO
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
app Conduit {
|
||||
wasp: {
|
||||
version: "^0.8.0"
|
||||
version: "^0.10.0"
|
||||
},
|
||||
|
||||
title: "Conduit",
|
||||
@ -17,7 +17,10 @@ app Conduit {
|
||||
onAuthFailedRedirectTo: "/login"
|
||||
},
|
||||
|
||||
db: { system: PostgreSQL },
|
||||
db: {
|
||||
system: PostgreSQL,
|
||||
seeds: [ import { devSeedSimple } from "@server/seeds.js" ]
|
||||
},
|
||||
|
||||
dependencies: [
|
||||
("prop-types", "15.7.2"),
|
||||
@ -30,8 +33,6 @@ app Conduit {
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ----------------- Pages ------------------ //
|
||||
|
||||
route RootRoute { path: "/", to: MainPage }
|
||||
|
@ -11,7 +11,7 @@ import Chip from '@material-ui/core/Chip'
|
||||
import Paper from '@material-ui/core/Paper';
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
import useAuth from '@wasp/auth/useAuth.js'
|
||||
import useAuth from '@wasp/auth/useAuth'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
import getTags from '@wasp/queries/getTags'
|
||||
|
@ -7,7 +7,7 @@ import Button from '@material-ui/core/Button'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
import useAuth from '@wasp/auth/useAuth.js'
|
||||
import useAuth from '@wasp/auth/useAuth'
|
||||
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
|
@ -17,7 +17,7 @@ import Chip from '@material-ui/core/Chip'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
import useAuth from '@wasp/auth/useAuth.js'
|
||||
import useAuth from '@wasp/auth/useAuth'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
import getArticle from '@wasp/queries/getArticle'
|
||||
|
@ -9,7 +9,7 @@ import Box from '@material-ui/core/Box'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
import useAuth from '@wasp/auth/useAuth.js'
|
||||
import useAuth from '@wasp/auth/useAuth'
|
||||
import { useQuery } from '@wasp/queries'
|
||||
|
||||
import getUser from '@wasp/queries/getUser'
|
||||
|
@ -7,7 +7,7 @@ import TextField from '@material-ui/core/TextField'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
import logout from '@wasp/auth/logout.js'
|
||||
import logout from '@wasp/auth/logout'
|
||||
|
||||
import updateUser from '@wasp/actions/updateUser'
|
||||
|
||||
|
29
examples/realworld/src/server/seeds.js
Normal file
29
examples/realworld/src/server/seeds.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { createArticle } from './article/actions.js'
|
||||
|
||||
export const devSeedSimple = async (prismaClient) => {
|
||||
const user = await createUser(prismaClient, {
|
||||
username: "RiuTheDog",
|
||||
password: "bark1234",
|
||||
email: "riu@wasp-lang.dev"
|
||||
})
|
||||
|
||||
await createArticle(
|
||||
{
|
||||
title: "The Art of Barking",
|
||||
description: "Elevate your barking to another level by following advice of a master barkerer Riu",
|
||||
markdownContent: (
|
||||
"# Barking - what is it really?"
|
||||
+ "\n\nThe fact that wolves don't bark but dogs do already tells us a lot about barking: "
|
||||
+ "it can't be studied without also studying humans and their influence on dogs."
|
||||
+ "\n\nTODO: bark bark bark"
|
||||
),
|
||||
tags: [{ name: 'barking' }, { name: 'dogs' }]
|
||||
},
|
||||
{ user, entities: { Article: prismaClient.article } }
|
||||
)
|
||||
}
|
||||
|
||||
async function createUser (prismaClient, data) {
|
||||
const { password, ...newUser } = await prismaClient.user.create({ data })
|
||||
return newUser
|
||||
}
|
@ -1,20 +1,21 @@
|
||||
app Thoughts {
|
||||
wasp: {
|
||||
version: "^0.8.0"
|
||||
version: "^0.10.0"
|
||||
},
|
||||
title: "Thoughts",
|
||||
db: { system: PostgreSQL },
|
||||
db: {
|
||||
system: PostgreSQL,
|
||||
seeds: [ import { devSeedBasic } from "@server/seeds.js" ]
|
||||
},
|
||||
auth: {
|
||||
userEntity: User,
|
||||
methods: {
|
||||
usernameAndPassword: {}
|
||||
},
|
||||
methods: { usernameAndPassword: {} },
|
||||
onAuthFailedRedirectTo: "/login"
|
||||
},
|
||||
dependencies: [
|
||||
("react-markdown", "6.0.1"),
|
||||
("color-hash", "2.0.1")
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
route MainRoute { path: "/", to: MainPage }
|
||||
|
@ -1,17 +1,19 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import LoginForm from '@wasp/auth/forms/Login'
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
import addWaspSourceHeader from './addWaspSourceHeader'
|
||||
|
||||
const LoginPage = (props) => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
<div style={{maxWidth: "400px", margin: "0 auto"}}>
|
||||
<LoginForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -1,17 +1,19 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import SignupForm from '@wasp/auth/forms/Signup'
|
||||
import { SignupForm } from '@wasp/auth/forms/Signup'
|
||||
import addWaspSourceHeader from './addWaspSourceHeader'
|
||||
|
||||
const SignupPage = (props) => {
|
||||
return (
|
||||
<>
|
||||
<SignupForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
<div style={{maxWidth: "400px", margin: "0 auto"}}>
|
||||
<SignupForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
18
examples/thoughts/src/server/seeds.js
Normal file
18
examples/thoughts/src/server/seeds.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { createThought } from './actions.js'
|
||||
|
||||
export const devSeedBasic = async (prismaClient) => {
|
||||
const user = await createUser(prismaClient, {
|
||||
username: "RiuTheDog",
|
||||
password: "bark1234"
|
||||
})
|
||||
|
||||
await createThought(
|
||||
{ tagNames: ["animals.cats"], textMarkdown: "## My favorite cats\n - Kira\n - Garfield" },
|
||||
{ user, entities: { Thought: prismaClient.thought } }
|
||||
)
|
||||
}
|
||||
|
||||
async function createUser (prismaClient, data) {
|
||||
const { password, ...newUser } = await prismaClient.user.create({ data })
|
||||
return newUser
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
app TodoTypescript {
|
||||
wasp: {
|
||||
version: "^0.8.2"
|
||||
version: "^0.9.0"
|
||||
},
|
||||
title: "ToDo TypeScript",
|
||||
|
||||
|
@ -35,16 +35,16 @@ export function MainPage() {
|
||||
};
|
||||
|
||||
function Todo({ id, isDone, description }: Task) {
|
||||
const handleIsDoneChange = async (event: FormEventHandler<HTMLInputElement>) => {
|
||||
const handleIsDoneChange: FormEventHandler<HTMLInputElement> = async (event) => {
|
||||
try {
|
||||
await updateTask({
|
||||
taskId: id,
|
||||
id,
|
||||
isDone: event.currentTarget.checked,
|
||||
});
|
||||
} catch (err: any) {
|
||||
window.alert('Error while updating task ' + err?.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
@ -58,8 +58,8 @@ function TasksList({tasks}: { tasks: Task[] }) {
|
||||
if (tasks.length === 0) return <p>No tasks yet.</p>;
|
||||
return (
|
||||
<ol className='tasklist'>
|
||||
{tasks.map((tsk, idx) => (
|
||||
<Todo {...tsk} key={idx} />
|
||||
{tasks.map((task, idx) => (
|
||||
<Todo {...task} key={idx} />
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
|
@ -16,7 +16,6 @@ export async function createTask({ description }: CreateArgs, context: Context)
|
||||
});
|
||||
};
|
||||
|
||||
// type UpdateArgs = { taskId: Task['id']; isDone: Task['isDone'] };
|
||||
type UpdateArgs = Pick<Task, 'id' | 'isDone'>;
|
||||
|
||||
export async function updateTask({ id, isDone }: UpdateArgs, context: Context) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
app TodoApp {
|
||||
wasp: {
|
||||
version: "^0.8.0"
|
||||
version: "^0.10.0"
|
||||
},
|
||||
|
||||
title: "Todo app",
|
||||
|
@ -1,17 +1,19 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import LoginForm from '@wasp/auth/forms/Login'
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
|
||||
const LoginPage = () => {
|
||||
return (
|
||||
<>
|
||||
<LoginForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
<div style={{maxWidth: "400px", margin: "0 auto"}}>
|
||||
<LoginForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I don't have an account yet (<Link to="/signup">go to signup</Link>).
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginPage
|
||||
export default LoginPage
|
@ -1,17 +1,19 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import SignupForm from '@wasp/auth/forms/Signup'
|
||||
import { SignupForm } from '@wasp/auth/forms/Signup'
|
||||
|
||||
const SignupPage = () => {
|
||||
return (
|
||||
<>
|
||||
<SignupForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
<div style={{maxWidth: "400px", margin: "0 auto"}}>
|
||||
<SignupForm/>
|
||||
<br/>
|
||||
<span>
|
||||
I already have an account (<Link to="/login">go to login</Link>).
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignupPage
|
||||
export default SignupPage
|
@ -1,6 +1,6 @@
|
||||
app waspleau {
|
||||
wasp: {
|
||||
version: "^0.8.0"
|
||||
version: "^0.10.0"
|
||||
},
|
||||
|
||||
title: "Waspleau",
|
||||
|
@ -1,5 +1,233 @@
|
||||
# Changelog
|
||||
|
||||
## v0.10.0
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- We changed `LoginForm` and `SignupForm` to use a named export instead of a default export, this means you must use them like this:
|
||||
- `import { LoginForm } from '@wasp/auth/forms/Login'`
|
||||
- `import { SignupForm } from '@wasp/auth/Signup'`
|
||||
- We renamed `useAuth.js` to `useAuth.ts` and you should import it like this: `import useAuth from '@wasp/auth/useAuth'` (without the `.js` extension)
|
||||
- We changed the type arguments for `useQuery` and `useAction` hooks. They now take two arguments (the `Error` type argument was removed):
|
||||
- `Input` - This type argument specifies the type for the **request's payload**.
|
||||
- `Output` - This type argument specifies the type for the **resposne's payload**.
|
||||
|
||||
### Full-stack type safety for Operations
|
||||
Frontend code can now infer correct payload/response types for Queries and Actions from their definitions on the server.
|
||||
|
||||
Define a Query on the server:
|
||||
```typescript
|
||||
export const getTask: GetTaskInfo<Pick<Task, "id">, Task> =
|
||||
async ({ id }, context) => {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Get properly typed functions and data on the frontend:
|
||||
```typescript
|
||||
import { useQuery } from "@wasp/queries"
|
||||
// Wasp knows the type of `getTask` thanks to your backend definition.
|
||||
import getTask from "@wasp/queries/getTask"
|
||||
|
||||
export const TaskInfo = () => {
|
||||
const {
|
||||
// TypeScript knows `task` is a `Task | undefined` thanks to the
|
||||
// backend definition.
|
||||
data: task,
|
||||
// TypeScript knows `isError` is a `boolean`.
|
||||
isError,
|
||||
// TypeScript knows `error` is of type `Error`.
|
||||
error,
|
||||
// TypeScript knows the second argument must be a `Pick<Task, "id">` thanks
|
||||
// to the backend definition.
|
||||
} = useQuery(getTask, { id: 1 })
|
||||
|
||||
if (isError) {
|
||||
return <div> Error during fetching tasks: {error.message || "unknown"}</div>
|
||||
}
|
||||
|
||||
// TypeScript forces you to perform this check.
|
||||
return taskInfo === undefined ? (
|
||||
<div>Waiting for info...</div>
|
||||
) : (
|
||||
<div>{taskInfo}</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
The same feature is available for Actions.
|
||||
|
||||
### Payloads compatible with Superjson
|
||||
Client and the server can now communicate with richer payloads.
|
||||
|
||||
Return a Superjson-compatible object from your Operation:
|
||||
```typescript
|
||||
type FooInfo = { foos: Foo[], message: string, queriedAt: Date }
|
||||
|
||||
const getFoos: GetFoo<void, FooInfo> = (_args, context) => {
|
||||
const foos = context.entities.Foo.findMany()
|
||||
return {
|
||||
foos,
|
||||
message: "Here are some foos!",
|
||||
queriedAt: new Date(),
|
||||
}
|
||||
}
|
||||
```
|
||||
And seamlessly use it on the frontend:
|
||||
|
||||
```typescript
|
||||
import getfoos from "@wasp/queries/getTask"
|
||||
|
||||
const { data } = useQuery(getfoos)
|
||||
const { foos, message, queriedAt } = data
|
||||
// foos: Foo[]
|
||||
// message: string
|
||||
// queriedAt: Date
|
||||
```
|
||||
|
||||
### E-mail authentication
|
||||
|
||||
You can now use e-mail authentication in your Wasp app! This means that users can sign up and log in using their e-mail address. You get e-mail verification and password reset out of the box.
|
||||
|
||||
```c
|
||||
app MyApp {
|
||||
// ...
|
||||
auth: {
|
||||
// ...
|
||||
email: {
|
||||
fromField: {
|
||||
name: "ToDO App",
|
||||
email: "hello@itsme.com"
|
||||
},
|
||||
emailVerification: {
|
||||
allowUnverifiedLogin: false,
|
||||
getEmailContentFn: import { getVerificationEmailContent } from "@server/auth/email.js",
|
||||
clientRoute: EmailVerificationRoute,
|
||||
},
|
||||
passwordReset: {
|
||||
getEmailContentFn: import { getPasswordResetEmailContent } from "@server/auth/email.js",
|
||||
clientRoute: PasswordResetRoute
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can only use one of e-mail or username & password authentication in your app. You can't use both at the same time.
|
||||
|
||||
### Auth UI components
|
||||
|
||||
Wasp now provides a set of UI components for authentication. You can use them to quickly build a login and signup page for your app. The UI changes dynamically based on your Wasp config.
|
||||
|
||||
We provide `LoginForm`, `SignupForm`, `ForgotPassworForm`, `ResetPasswordForm` and`VerifyEmailForm` components. You can import them from `@wasp/auth/forms` like:
|
||||
|
||||
```js
|
||||
import { LoginForm } from '@wasp/auth/forms/Login'
|
||||
import { SignupForm } from '@wasp/auth/forms/Signup'
|
||||
import { ForgotPasswordForm } from '@wasp/auth/forms/ForgotPassword'
|
||||
import { ResetPasswordForm } from '@wasp/auth/forms/ResetPassword'
|
||||
import { VerifyEmailForm } from '@wasp/auth/forms/VerifyEmail'
|
||||
```
|
||||
|
||||
### Database seeding
|
||||
You can now define JS/TS functions for seeding the database!
|
||||
|
||||
```c
|
||||
app MyApp {
|
||||
// ...
|
||||
db: {
|
||||
seeds: [
|
||||
import { devSeedSimple } from "@server/dbSeeds.js",
|
||||
import { prodSeed } from "@server/dbSeeds.js",
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
import { createTask } from './actions.js'
|
||||
|
||||
export const devSeedSimple = async (prismaClient) => {
|
||||
const { password, ...newUser } = await prismaClient.user.create({
|
||||
username: "RiuTheDog", password: "bark1234"
|
||||
})
|
||||
await createTask(
|
||||
{ description: "Chase the cat" },
|
||||
{ user: newUser, entities: { Task: prismaClient.task } }
|
||||
)
|
||||
}
|
||||
|
||||
//...
|
||||
```
|
||||
|
||||
Run `wasp db seed` to run database seeding. If there is only one seed, it will run that one, or it will interactively ask you to pick one.
|
||||
You can also do `wasp db seed <name>` to run a seed with specific name: for example, for the case above, you could do `wasp db seed prodSeed`.
|
||||
|
||||
|
||||
### The `api` keyword for defining an arbitrary endpoint and URL
|
||||
Need a specific endpoint, like `/healthcheck` or `/foo/callback`? Or need complete control of the response? Use an `api` to define one by tying a JS function to any HTTP method and path! For example:
|
||||
```ts
|
||||
// main.wasp
|
||||
api fooBar {
|
||||
fn: import { foo } from "@server/apis.js",
|
||||
entities: [Task],
|
||||
httpRoute: (GET, "/foo/callback")
|
||||
}
|
||||
|
||||
// server/api.ts
|
||||
import { FooBar } from '@wasp/apis/types'
|
||||
|
||||
export const fooBar : FooBar = (req, res, context) => {
|
||||
res.set('Access-Control-Allow-Origin', '*') // Example of modifying headers to override Wasp default CORS middleware.
|
||||
res.json({ msg: `Hello, ${context.user?.username || "stranger"}!` })
|
||||
}
|
||||
```
|
||||
|
||||
### E-mail sending support
|
||||
|
||||
Wasp now supports sending e-mails! You can use the `emailSender` app property to configure the e-mail provider and optionally the `defaultFrom` address. Then, you can use the `send` function in your backend code to send e-mails.
|
||||
|
||||
```ts
|
||||
// main.wasp
|
||||
app MyApp {
|
||||
emailSender: {
|
||||
provider: SendGrid,
|
||||
defaultFrom: {
|
||||
name: "My App",
|
||||
email: "myapp@domain.com"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// server/actions.ts
|
||||
import { emailSender } from '@wasp/email/index.js'
|
||||
|
||||
// In some action handler...
|
||||
const info = await emailSender.send({
|
||||
to: 'user@domain.com',
|
||||
subject: 'Saying hello',
|
||||
text: 'Hello world',
|
||||
html: 'Hello <strong>world</strong>'
|
||||
})
|
||||
```
|
||||
|
||||
### `wasp start db` -> Wasp can now run your dev database for you with a single command
|
||||
|
||||
Moving from SQLite to PostgreSQL with Wasp can feel like increase in complexity, because suddenly you have to care about running your PostgreSQL database, providing connection URL for it via env var, and if you checkout somebody's else Wasp project, or your old Wasp project that you have no memory of any more, you also have to figure all that out.
|
||||
|
||||
To help with that, we now added `wasp start db`, which runs a development database for you!
|
||||
That it, all you need to do is run `wasp start db` and you are good to go. No env var setting, no remembering how to run the db.
|
||||
|
||||
NOTE: Requires `docker` to be installed and in `PATH`, and docker daemon to be running.
|
||||
|
||||
### `wasp test client` -> Wasp can now test your web app code
|
||||
By leveraging Vitest and some supporting libraries, Wasp now makes it super easy to add unit tests and React component tests to your frontend codebase.
|
||||
|
||||
### `pg-boss` upgraded to latest version (8.4.2)
|
||||
This `pg-boss` release fixes an issue where the node server would exit due to an unhandled exception when the DB connection was lost.
|
||||
|
||||
### Bug fixes
|
||||
- Starts the process of removing the coupling between `usernameAndPassword` and social logins. Now, your `userEntity` no longer requires a `username` or `password` field if you only want to use Google/GitHub for auth.
|
||||
|
||||
## v0.9.0
|
||||
|
||||
### BREAKING CHANGES
|
||||
@ -10,7 +238,7 @@
|
||||
We moved away from using Create React App for the client app. This means that dev startup time will be much faster and we are following the latest best practices for building web apps with React.
|
||||
|
||||
### Express `app` and http `server` available in server `setupFn`
|
||||
- Wasp now passes in a context to the server `setupFn` that contains Express `app` and http `server` objects. This can be used as an escape hatch for things like custom routes or WebSocket support.
|
||||
Wasp now passes in a context to the server `setupFn` that contains Express `app` and http `server` objects. This can be used as an escape hatch for things like custom routes or WebSocket support.
|
||||
|
||||
## v0.8.2
|
||||
|
||||
|
@ -20,4 +20,4 @@ jobs: $ncpus
|
||||
test-show-details: direct
|
||||
|
||||
-- WARNING: Run cabal update if your local package index is older than this date.
|
||||
index-state: 2022-03-22T14:16:26Z
|
||||
index-state: 2023-03-09T09:15:49Z
|
||||
|
@ -5,6 +5,7 @@ import qualified Control.Concurrent.Async as Async
|
||||
import qualified Control.Exception as E
|
||||
import Control.Monad (void)
|
||||
import Data.Char (isSpace)
|
||||
import Data.List (intercalate)
|
||||
import Main.Utf8 (withUtf8)
|
||||
import System.Environment (getArgs)
|
||||
import Wasp.Cli.Command (runCommand)
|
||||
@ -14,14 +15,19 @@ import qualified Wasp.Cli.Command.Call as Command.Call
|
||||
import Wasp.Cli.Command.Clean (clean)
|
||||
import Wasp.Cli.Command.Compile (compile)
|
||||
import Wasp.Cli.Command.CreateNewProject (createNewProject)
|
||||
import Wasp.Cli.Command.Db (runDbCommand, studio)
|
||||
import Wasp.Cli.Command.Db (runDbCommand)
|
||||
import qualified Wasp.Cli.Command.Db.Migrate as Command.Db.Migrate
|
||||
import qualified Wasp.Cli.Command.Db.Reset as Command.Db.Reset
|
||||
import qualified Wasp.Cli.Command.Db.Seed as Command.Db.Seed
|
||||
import qualified Wasp.Cli.Command.Db.Studio as Command.Db.Studio
|
||||
import Wasp.Cli.Command.Deploy (deploy)
|
||||
import Wasp.Cli.Command.Deps (deps)
|
||||
import Wasp.Cli.Command.Dockerfile (printDockerfile)
|
||||
import Wasp.Cli.Command.Info (info)
|
||||
import Wasp.Cli.Command.Start (start)
|
||||
import qualified Wasp.Cli.Command.Start.Db as Command.Start.Db
|
||||
import qualified Wasp.Cli.Command.Telemetry as Telemetry
|
||||
import Wasp.Cli.Command.Test (test)
|
||||
import Wasp.Cli.Command.Uninstall (uninstall)
|
||||
import Wasp.Cli.Command.WaspLS (runWaspLS)
|
||||
import Wasp.Cli.Terminal (title)
|
||||
@ -33,8 +39,9 @@ main :: IO ()
|
||||
main = withUtf8 . (`E.catch` handleInternalErrors) $ do
|
||||
args <- getArgs
|
||||
let commandCall = case args of
|
||||
["new", projectName] -> Command.Call.New projectName
|
||||
("new" : projectName : newArgs) -> Command.Call.New projectName newArgs
|
||||
["start"] -> Command.Call.Start
|
||||
["start", "db"] -> Command.Call.StartDb
|
||||
["clean"] -> Command.Call.Clean
|
||||
["compile"] -> Command.Call.Compile
|
||||
("db" : dbArgs) -> Command.Call.Db dbArgs
|
||||
@ -50,13 +57,15 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
|
||||
["completion:list"] -> Command.Call.BashCompletionListCommands
|
||||
("waspls" : _) -> Command.Call.WaspLS
|
||||
("deploy" : deployArgs) -> Command.Call.Deploy deployArgs
|
||||
("test" : testArgs) -> Command.Call.Test testArgs
|
||||
_ -> Command.Call.Unknown args
|
||||
|
||||
telemetryThread <- Async.async $ runCommand $ Telemetry.considerSendingData commandCall
|
||||
|
||||
case commandCall of
|
||||
Command.Call.New projectName -> runCommand $ createNewProject projectName
|
||||
Command.Call.New projectName newArgs -> runCommand $ createNewProject projectName newArgs
|
||||
Command.Call.Start -> runCommand start
|
||||
Command.Call.StartDb -> runCommand Command.Start.Db.start
|
||||
Command.Call.Clean -> runCommand clean
|
||||
Command.Call.Compile -> runCommand compile
|
||||
Command.Call.Db dbArgs -> dbCli dbArgs
|
||||
@ -73,6 +82,7 @@ main = withUtf8 . (`E.catch` handleInternalErrors) $ do
|
||||
Command.Call.Unknown _ -> printUsage
|
||||
Command.Call.WaspLS -> runWaspLS
|
||||
Command.Call.Deploy deployArgs -> runCommand $ deploy deployArgs
|
||||
Command.Call.Test testArgs -> runCommand $ test testArgs
|
||||
|
||||
-- If sending of telemetry data is still not done 1 second since commmand finished, abort it.
|
||||
-- We also make sure here to catch all errors that might get thrown and silence them.
|
||||
@ -89,36 +99,46 @@ printUsage :: IO ()
|
||||
printUsage =
|
||||
putStrLn $
|
||||
unlines
|
||||
{- ORMOLU_DISABLE -}
|
||||
[ title "USAGE",
|
||||
" wasp <command> [command-args]",
|
||||
"",
|
||||
" wasp <command> [command-args]",
|
||||
"",
|
||||
title "COMMANDS",
|
||||
title " GENERAL",
|
||||
cmd " new <project-name> Creates new Wasp project.",
|
||||
cmd " version Prints current version of CLI.",
|
||||
cmd " waspls Run Wasp Language Server. Add --help to get more info.",
|
||||
cmd " completion Prints help on bash completion.",
|
||||
cmd " uninstall Removes Wasp from your system.",
|
||||
cmd " new <name> [args] Creates a new Wasp project.",
|
||||
" OPTIONS:",
|
||||
" -t|--template <template-name>",
|
||||
" Check out the templates list here: https://github.com/wasp-lang/starters",
|
||||
"",
|
||||
cmd " version Prints current version of CLI.",
|
||||
cmd " waspls Run Wasp Language Server. Add --help to get more info.",
|
||||
cmd " completion Prints help on bash completion.",
|
||||
cmd " uninstall Removes Wasp from your system.",
|
||||
title " IN PROJECT",
|
||||
cmd " start Runs Wasp app in development mode, watching for file changes.",
|
||||
cmd " db <db-cmd> [args] Executes a database command. Run 'wasp db' for more info.",
|
||||
cmd " clean Deletes all generated code and other cached artifacts. Wasp equivalent of 'have you tried closing and opening it again?'.",
|
||||
cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.",
|
||||
cmd " deploy Deploys your Wasp app to cloud hosting providers.",
|
||||
cmd " telemetry Prints telemetry status.",
|
||||
cmd " deps Prints the dependencies that Wasp uses in your project.",
|
||||
cmd " dockerfile Prints the contents of the Wasp generated Dockerfile.",
|
||||
cmd " info Prints basic information about current Wasp project.",
|
||||
"",
|
||||
cmd " start Runs Wasp app in development mode, watching for file changes.",
|
||||
cmd " start db Starts managed development database for you.",
|
||||
cmd " db <db-cmd> [args] Executes a database command. Run 'wasp db' for more info.",
|
||||
cmd " clean Deletes all generated code and other cached artifacts.",
|
||||
" Wasp equivalent of 'have you tried closing and opening it again?'.",
|
||||
cmd " build Generates full web app code, ready for deployment. Use when deploying or ejecting.",
|
||||
cmd " deploy Deploys your Wasp app to cloud hosting providers.",
|
||||
cmd " telemetry Prints telemetry status.",
|
||||
cmd " deps Prints the dependencies that Wasp uses in your project.",
|
||||
cmd " dockerfile Prints the contents of the Wasp generated Dockerfile.",
|
||||
cmd " info Prints basic information about current Wasp project.",
|
||||
cmd " test Executes tests in your project.",
|
||||
"",
|
||||
title "EXAMPLES",
|
||||
" wasp new MyApp",
|
||||
" wasp start",
|
||||
" wasp db migrate-dev",
|
||||
"",
|
||||
Term.applyStyles [Term.Green] "Docs:" ++ " https://wasp-lang.dev/docs",
|
||||
" wasp new MyApp",
|
||||
" wasp new MyApp -t waspello",
|
||||
" wasp start",
|
||||
" wasp db migrate-dev",
|
||||
"",
|
||||
Term.applyStyles [Term.Green] "Docs:" ++ " https://wasp-lang.dev/docs",
|
||||
Term.applyStyles [Term.Magenta] "Discord (chat):" ++ " https://discord.gg/rzdnErX",
|
||||
Term.applyStyles [Term.Cyan] "Newsletter:" ++ " https://wasp-lang.dev/#signup"
|
||||
Term.applyStyles [Term.Cyan] "Newsletter:" ++ " https://wasp-lang.dev/#signup"
|
||||
]
|
||||
{- ORMOLU_ENABLE -}
|
||||
|
||||
printVersion :: IO ()
|
||||
printVersion = do
|
||||
@ -128,44 +148,56 @@ printVersion = do
|
||||
"",
|
||||
"If you wish to install/switch to the latest version of Wasp, do:",
|
||||
" curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s",
|
||||
"or do",
|
||||
" curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v x.y.z",
|
||||
"if you want specific x.y.z version of Wasp.",
|
||||
"",
|
||||
"Check https://github.com/wasp-lang/wasp/releases for the list of valid versions, include the latest one."
|
||||
"If you want specific x.y.z version of Wasp, do:",
|
||||
" curl -sSL https://get.wasp-lang.dev/installer.sh | sh -s -- -v x.y.z",
|
||||
"",
|
||||
"Check https://github.com/wasp-lang/wasp/releases for the list of valid versions, including the latest one."
|
||||
]
|
||||
|
||||
-- TODO(matija): maybe extract to a separate module, e.g. DbCli.hs?
|
||||
dbCli :: [String] -> IO ()
|
||||
dbCli args = case args of
|
||||
["start"] -> runCommand Command.Start.Db.start
|
||||
"migrate-dev" : optionalMigrateArgs -> runDbCommand $ Command.Db.Migrate.migrateDev optionalMigrateArgs
|
||||
["studio"] -> runDbCommand studio
|
||||
["reset"] -> runDbCommand Command.Db.Reset.reset
|
||||
["seed"] -> runDbCommand $ Command.Db.Seed.seed Nothing
|
||||
["seed", seedName] -> runDbCommand $ Command.Db.Seed.seed $ Just seedName
|
||||
["studio"] -> runDbCommand Command.Db.Studio.studio
|
||||
_ -> printDbUsage
|
||||
|
||||
printDbUsage :: IO ()
|
||||
printDbUsage =
|
||||
putStrLn $
|
||||
unlines
|
||||
{- ORMOLU_DISABLE -}
|
||||
[ title "USAGE",
|
||||
" wasp db <command> [command-args]",
|
||||
"",
|
||||
" wasp db <command> [command-args]",
|
||||
"",
|
||||
title "COMMANDS",
|
||||
cmd
|
||||
( " migrate-dev Ensures dev database corresponds to the current state of schema(entities):\n"
|
||||
<> " - Generates a new migration if there are changes in the schema.\n"
|
||||
<> " - Applies any pending migrations to the database either using the supplied migration name or asking for one.\n"
|
||||
<> "\nOPTIONS:\n"
|
||||
<> " --name [migration-name]\n"
|
||||
<> " --create-only\n"
|
||||
),
|
||||
cmd " studio GUI for inspecting your database.",
|
||||
"",
|
||||
cmd " start Alias for `wasp start db`.",
|
||||
cmd " reset Drops all data and tables from development database and re-applies all migrations.",
|
||||
cmd " seed [name] Executes a db seed function (specified via app.db.seeds).",
|
||||
cmd " If there are multiple seeds, you can specify a seed to execute by providing its name,",
|
||||
cmd " or if not then you will be asked to provide the name interactively.",
|
||||
cmd $ intercalate "\n" [
|
||||
" migrate-dev Ensures dev database corresponds to the current state of schema(entities):",
|
||||
" - Generates a new migration if there are changes in the schema.",
|
||||
" - Applies any pending migrations to the database either using the",
|
||||
" supplied migration name or asking for one.",
|
||||
" OPTIONS:",
|
||||
" --name [migration-name]",
|
||||
" --create-only"
|
||||
],
|
||||
cmd " studio GUI for inspecting your database.",
|
||||
"",
|
||||
title "EXAMPLES",
|
||||
" wasp db migrate-dev",
|
||||
" wasp db migrate-dev --name \"Added User entity\"",
|
||||
" wasp db migrate-dev --create-only",
|
||||
" wasp db studio"
|
||||
" wasp db migrate-dev",
|
||||
" wasp db migrate-dev --name \"Added User entity\"",
|
||||
" wasp db migrate-dev --create-only",
|
||||
" wasp db studio"
|
||||
]
|
||||
{- ORMOLU_ENABLE -}
|
||||
|
||||
cmd :: String -> String
|
||||
cmd = mapFirstWord (Term.applyStyles [Term.Yellow, Term.Bold])
|
||||
|
@ -21,10 +21,10 @@ import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Cli.Message (cliSendMessage)
|
||||
import Wasp.CompileOptions (CompileOptions (..))
|
||||
import qualified Wasp.Generator
|
||||
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
|
||||
import Wasp.Lib (CompileError, CompileWarning)
|
||||
import qualified Wasp.Lib
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project (CompileError, CompileWarning)
|
||||
|
||||
-- | Builds Wasp project that the current working directory is part of.
|
||||
-- Does all the steps, from analysis to generation, and at the end writes generated code
|
||||
@ -61,7 +61,7 @@ build = do
|
||||
|
||||
buildIO ::
|
||||
Path' Abs (Dir Common.WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
|
||||
Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
|
||||
IO ([CompileWarning], [CompileError])
|
||||
buildIO waspProjectDir buildDir = compileIOWithOptions options waspProjectDir buildDir
|
||||
where
|
||||
|
@ -1,12 +1,13 @@
|
||||
module Wasp.Cli.Command.Call where
|
||||
|
||||
data Call
|
||||
= New String -- project name
|
||||
= New ProjectName Arguments -- project name, new args
|
||||
| Start
|
||||
| StartDb
|
||||
| Clean
|
||||
| Uninstall
|
||||
| Compile
|
||||
| Db [String] -- db args
|
||||
| Db Arguments -- db args
|
||||
| Build
|
||||
| Version
|
||||
| Telemetry
|
||||
@ -17,5 +18,10 @@ data Call
|
||||
| GenerateBashCompletionScript
|
||||
| BashCompletionListCommands
|
||||
| WaspLS
|
||||
| Deploy [String] -- deploy cmd passthrough args
|
||||
| Unknown [String] -- all args
|
||||
| Deploy Arguments -- deploy cmd passthrough args
|
||||
| Test Arguments -- "client" | "server", then test cmd passthrough args
|
||||
| Unknown Arguments -- all args
|
||||
|
||||
type ProjectName = String
|
||||
|
||||
type Arguments = [String]
|
||||
|
@ -1,24 +1,25 @@
|
||||
module Wasp.Cli.Command.Common
|
||||
( findWaspProjectRootDirFromCwd,
|
||||
findWaspProjectRoot,
|
||||
readWaspCompileInfo,
|
||||
throwIfExeIsNotAvailable,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (unless, when)
|
||||
import Control.Monad.Except (throwError)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Control.Monad.Except
|
||||
import qualified Control.Monad.Except as E
|
||||
import Data.Maybe (fromJust)
|
||||
import StrongPath (Abs, Dir, Path')
|
||||
import qualified StrongPath as SP
|
||||
import System.Directory
|
||||
( doesFileExist,
|
||||
doesPathExist,
|
||||
getCurrentDirectory,
|
||||
)
|
||||
import StrongPath.Operations
|
||||
import System.Directory (doesFileExist, doesPathExist, findExecutable, getCurrentDirectory)
|
||||
import qualified System.FilePath as FP
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Cli.Common (dotWaspRootFileInWaspProjectDir)
|
||||
import Wasp.Common (WaspProjectDir)
|
||||
import qualified Wasp.Cli.Common as Cli.Common
|
||||
import Wasp.Project (WaspProjectDir)
|
||||
import Wasp.Util (ifM)
|
||||
import qualified Wasp.Util.IO as IOUtil
|
||||
|
||||
findWaspProjectRoot :: Path' Abs (Dir ()) -> Command (Path' Abs (Dir WaspProjectDir))
|
||||
findWaspProjectRoot currentDir = do
|
||||
@ -45,3 +46,23 @@ findWaspProjectRootDirFromCwd :: Command (Path' Abs (Dir WaspProjectDir))
|
||||
findWaspProjectRootDirFromCwd = do
|
||||
absCurrentDir <- liftIO getCurrentDirectory
|
||||
findWaspProjectRoot (fromJust $ SP.parseAbsDir absCurrentDir)
|
||||
|
||||
readWaspCompileInfo :: Path' Abs (Dir WaspProjectDir) -> IO String
|
||||
readWaspCompileInfo waspDir =
|
||||
ifM
|
||||
(IOUtil.doesFileExist dotWaspInfoFile)
|
||||
(IOUtil.readFile dotWaspInfoFile)
|
||||
(return "No compile information found")
|
||||
where
|
||||
dotWaspInfoFile =
|
||||
waspDir </> Cli.Common.dotWaspDirInWaspProjectDir
|
||||
</> Cli.Common.generatedCodeDirInDotWaspDir
|
||||
</> Cli.Common.dotWaspInfoFileInGeneratedCodeDir
|
||||
|
||||
throwIfExeIsNotAvailable :: String -> String -> Command ()
|
||||
throwIfExeIsNotAvailable exeName explanationMsg = do
|
||||
liftIO (findExecutable exeName) >>= \case
|
||||
Just _ -> return ()
|
||||
Nothing ->
|
||||
E.throwError $
|
||||
CommandError ("Couldn't find `" <> exeName <> "` executable") explanationMsg
|
||||
|
@ -6,6 +6,8 @@ module Wasp.Cli.Command.Compile
|
||||
defaultCompileOptions,
|
||||
printCompilationResult,
|
||||
printWarningsAndErrorsIfAny,
|
||||
analyze,
|
||||
analyzeWithOptions,
|
||||
)
|
||||
where
|
||||
|
||||
@ -14,6 +16,7 @@ import Control.Monad.Except (throwError)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.List (intercalate)
|
||||
import StrongPath (Abs, Dir, Path', (</>))
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Cli.Command.Common
|
||||
( findWaspProjectRootDirFromCwd,
|
||||
@ -21,11 +24,11 @@ import Wasp.Cli.Command.Common
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Cli.Message (cliSendMessage)
|
||||
import Wasp.Common (WaspProjectDir)
|
||||
import Wasp.CompileOptions (CompileOptions (..))
|
||||
import Wasp.Lib (CompileError, CompileWarning)
|
||||
import qualified Wasp.Lib
|
||||
import qualified Wasp.Generator
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
|
||||
import qualified Wasp.Project
|
||||
|
||||
-- | Same like 'compileWithOptions', but with default compile options.
|
||||
compile :: Command [CompileWarning]
|
||||
@ -98,18 +101,18 @@ formatErrorOrWarningMessages = intercalate "\n" . map ("- " ++)
|
||||
-- in given outDir directory.
|
||||
compileIO ::
|
||||
Path' Abs (Dir WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
|
||||
Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
|
||||
IO ([CompileWarning], [CompileError])
|
||||
compileIO waspProjectDir outDir =
|
||||
compileIOWithOptions (defaultCompileOptions waspProjectDir) waspProjectDir outDir
|
||||
|
||||
compileIOWithOptions ::
|
||||
CompileOptions ->
|
||||
Path' Abs (Dir Common.WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
|
||||
Path' Abs (Dir WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
|
||||
IO ([CompileWarning], [CompileError])
|
||||
compileIOWithOptions options waspProjectDir outDir =
|
||||
Wasp.Lib.compile waspProjectDir outDir options
|
||||
Wasp.Project.compile waspProjectDir outDir options
|
||||
|
||||
defaultCompileOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions
|
||||
defaultCompileOptions waspProjectDir =
|
||||
@ -121,3 +124,19 @@ defaultCompileOptions waspProjectDir =
|
||||
sendMessage = cliSendMessage,
|
||||
generatorWarningsFilter = id
|
||||
}
|
||||
|
||||
analyze :: Path' Abs (Dir WaspProjectDir) -> Command AS.AppSpec
|
||||
analyze waspProjectDir = do
|
||||
analyzeWithOptions waspProjectDir $ defaultCompileOptions waspProjectDir
|
||||
|
||||
-- | Analyzes Wasp project that the current working directory is a part of and returns
|
||||
-- AppSpec. So same like compilation, but it stops before any code generation.
|
||||
-- Throws if there were any compilation errors.
|
||||
analyzeWithOptions :: Path' Abs (Dir WaspProjectDir) -> CompileOptions -> Command AS.AppSpec
|
||||
analyzeWithOptions waspProjectDir options = do
|
||||
liftIO (Wasp.Project.analyzeWaspProject waspProjectDir options) >>= \case
|
||||
Left errors ->
|
||||
throwError $
|
||||
CommandError "Analyzing wasp project failed" $
|
||||
show (length errors) <> " errors found:\n" <> formatErrorOrWarningMessages errors
|
||||
Right spec -> return spec
|
||||
|
@ -3,50 +3,63 @@ module Wasp.Cli.Command.CreateNewProject
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (when)
|
||||
import Control.Monad.Except (throwError)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.List (intercalate)
|
||||
import Data.Maybe (isJust)
|
||||
import qualified Data.Text as T
|
||||
import Path.IO (copyDirRecur, doesDirExist)
|
||||
import StrongPath (Abs, Dir, Path, Path', System, parseAbsDir, reldir, relfile, (</>))
|
||||
import StrongPath.Path (toPathAbsDir)
|
||||
import System.Directory (getCurrentDirectory)
|
||||
import qualified System.FilePath as FP
|
||||
import System.Process (callCommand)
|
||||
import Text.Printf (printf)
|
||||
import UnliftIO.Exception (SomeException, try)
|
||||
import Wasp.Analyzer.Parser (isValidWaspIdentifier)
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Common (WaspProjectDir)
|
||||
import qualified Wasp.Common as Common (WaspProjectDir)
|
||||
import Wasp.Cli.Command.Call (Arguments, ProjectName)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Data as Data
|
||||
import Wasp.Util (indent, kebabToCamelCase)
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project (WaspProjectDir)
|
||||
import qualified Wasp.SemanticVersion as SV
|
||||
import Wasp.Util (indent, kebabToCamelCase, whenM)
|
||||
import qualified Wasp.Util.IO as IOUtil
|
||||
import qualified Wasp.Util.Terminal as Term
|
||||
import qualified Wasp.Version as WV
|
||||
|
||||
data ProjectInfo = ProjectInfo
|
||||
{ _projectName :: String,
|
||||
_appName :: String
|
||||
_appName :: String,
|
||||
_templateName :: Maybe String
|
||||
}
|
||||
|
||||
createNewProject :: String -> Command ()
|
||||
createNewProject projectNameCandidate = do
|
||||
projectInfo <- parseProjectInfo projectNameCandidate
|
||||
createNewProject :: ProjectName -> Arguments -> Command ()
|
||||
createNewProject projectName newArgs = do
|
||||
projectInfo <- parseProjectInfo projectName newArgs
|
||||
createWaspProjectDir projectInfo
|
||||
liftIO $ printGettingStartedInstructions $ _projectName projectInfo
|
||||
where
|
||||
printGettingStartedInstructions :: String -> IO ()
|
||||
printGettingStartedInstructions projectName = do
|
||||
putStrLn $ Term.applyStyles [Term.Green] ("Created new Wasp app in ./" ++ projectName ++ " directory!")
|
||||
printGettingStartedInstructions :: ProjectName -> IO ()
|
||||
printGettingStartedInstructions projectFolder = do
|
||||
putStrLn $ Term.applyStyles [Term.Green] ("Created new Wasp app in ./" ++ projectFolder ++ " directory!")
|
||||
putStrLn "To run it, do:"
|
||||
putStrLn ""
|
||||
putStrLn $ Term.applyStyles [Term.Bold] (" cd " ++ projectName)
|
||||
putStrLn $ Term.applyStyles [Term.Bold] (" cd " ++ projectFolder)
|
||||
putStrLn $ Term.applyStyles [Term.Bold] " wasp start"
|
||||
|
||||
-- Takes a project name String
|
||||
-- Returns either the ProjectInfo type that contains both the Project name
|
||||
-- and the App name (which might be the same), or an error describing why the name is invalid
|
||||
parseProjectInfo :: String -> Command ProjectInfo
|
||||
parseProjectInfo name
|
||||
| isValidWaspIdentifier appName = return $ ProjectInfo name appName
|
||||
parseProjectInfo :: ProjectName -> Arguments -> Command ProjectInfo
|
||||
parseProjectInfo projectName newArgs = case newArgs of
|
||||
[] -> createProjectInfo projectName Nothing
|
||||
[templateFlag, templateName] | templateFlag `elem` ["--template", "-t"] -> createProjectInfo projectName (Just templateName)
|
||||
[templateFlag] | templateFlag `elem` ["--template", "-t"] -> throwProjectCreationError "You must provide a template name."
|
||||
_anyOtherArgs -> throwProjectCreationError "Invalid arguments for 'wasp new' command."
|
||||
|
||||
createProjectInfo :: ProjectName -> Maybe String -> Command ProjectInfo
|
||||
createProjectInfo name templateName
|
||||
| isValidWaspIdentifier appName = return $ ProjectInfo {_projectName = name, _appName = appName, _templateName = templateName}
|
||||
| otherwise =
|
||||
throwProjectCreationError $
|
||||
intercalate
|
||||
@ -60,17 +73,25 @@ parseProjectInfo name
|
||||
appName = kebabToCamelCase name
|
||||
|
||||
createWaspProjectDir :: ProjectInfo -> Command ()
|
||||
createWaspProjectDir projectInfo = do
|
||||
createWaspProjectDir projectInfo@ProjectInfo {_templateName = template} = do
|
||||
absWaspProjectDir <- getAbsoluteWaspProjectDir projectInfo
|
||||
dirExists <- doesDirExist $ toPathAbsDir absWaspProjectDir
|
||||
if dirExists
|
||||
then throwProjectCreationError $ show absWaspProjectDir ++ " is an existing directory"
|
||||
else liftIO $ do
|
||||
initializeProjectFromSkeleton absWaspProjectDir
|
||||
writeMainWaspFile absWaspProjectDir projectInfo
|
||||
|
||||
when dirExists $
|
||||
throwProjectCreationError $
|
||||
show absWaspProjectDir ++ " is an existing directory"
|
||||
|
||||
createProjectFromProjectInfo absWaspProjectDir
|
||||
where
|
||||
createProjectFromProjectInfo absWaspProjectDir = do
|
||||
if isJust template
|
||||
then createProjectFromTemplate absWaspProjectDir projectInfo
|
||||
else liftIO $ do
|
||||
initializeProjectFromSkeleton absWaspProjectDir
|
||||
writeMainWaspFile absWaspProjectDir projectInfo
|
||||
|
||||
getAbsoluteWaspProjectDir :: ProjectInfo -> Command (Path System Abs (Dir WaspProjectDir))
|
||||
getAbsoluteWaspProjectDir (ProjectInfo projectName _) = do
|
||||
getAbsoluteWaspProjectDir (ProjectInfo projectName _ _) = do
|
||||
absCwd <- liftIO getCurrentDirectory
|
||||
case parseAbsDir $ absCwd FP.</> projectName of
|
||||
Right sp -> return sp
|
||||
@ -79,21 +100,21 @@ getAbsoluteWaspProjectDir (ProjectInfo projectName _) = do
|
||||
"Failed to parse absolute path to wasp project dir: " ++ show err
|
||||
|
||||
-- Copies prepared files to the new project directory.
|
||||
initializeProjectFromSkeleton :: Path' Abs (Dir Common.WaspProjectDir) -> IO ()
|
||||
initializeProjectFromSkeleton :: Path' Abs (Dir WaspProjectDir) -> IO ()
|
||||
initializeProjectFromSkeleton absWaspProjectDir = do
|
||||
dataDir <- Data.getAbsDataDirPath
|
||||
let absSkeletonDir = dataDir </> [reldir|Cli/templates/new|]
|
||||
copyDirRecur (toPathAbsDir absSkeletonDir) (toPathAbsDir absWaspProjectDir)
|
||||
|
||||
writeMainWaspFile :: Path System Abs (Dir WaspProjectDir) -> ProjectInfo -> IO ()
|
||||
writeMainWaspFile waspProjectDir (ProjectInfo projectName appName) = IOUtil.writeFile absMainWaspFile mainWaspFileContent
|
||||
writeMainWaspFile waspProjectDir (ProjectInfo projectName appName _) = IOUtil.writeFile absMainWaspFile mainWaspFileContent
|
||||
where
|
||||
absMainWaspFile = waspProjectDir </> [relfile|main.wasp|]
|
||||
mainWaspFileContent =
|
||||
unlines
|
||||
[ "app %s {" `printf` appName,
|
||||
" wasp: {",
|
||||
" version: \"^%s\"" `printf` show WV.waspVersion,
|
||||
" version: \"%s\"" `printf` waspVersionBounds,
|
||||
" },",
|
||||
" title: \"%s\"" `printf` projectName,
|
||||
"}",
|
||||
@ -104,5 +125,63 @@ writeMainWaspFile waspProjectDir (ProjectInfo projectName appName) = IOUtil.writ
|
||||
"}"
|
||||
]
|
||||
|
||||
createProjectFromTemplate :: Path System Abs (Dir WaspProjectDir) -> ProjectInfo -> Command ()
|
||||
createProjectFromTemplate absWaspProjectDir ProjectInfo {_appName = appName, _projectName = projectName, _templateName = maybeTemplateName} = do
|
||||
cliSendMessageC $ Msg.Start "Creating project from template..."
|
||||
|
||||
templatePath <- getPathToRemoteTemplate maybeTemplateName
|
||||
|
||||
let projectDir = projectName
|
||||
|
||||
fetchTemplate templatePath projectDir
|
||||
ensureTemplateWasFetched
|
||||
|
||||
replacePlaceholdersInWaspFile
|
||||
where
|
||||
getPathToRemoteTemplate :: Maybe String -> Command String
|
||||
getPathToRemoteTemplate = \case
|
||||
Just templateName -> return $ waspTemplatesRepo ++ "/" ++ templateName
|
||||
Nothing -> throwProjectCreationError "Template name is not provided."
|
||||
where
|
||||
-- gh: prefix means Github repo
|
||||
waspTemplatesRepo = "gh:wasp-lang/starters"
|
||||
|
||||
fetchTemplate :: String -> String -> Command ()
|
||||
fetchTemplate templatePath projectDir = do
|
||||
liftIO (try executeCmd) >>= \case
|
||||
Left (e :: SomeException) -> throwProjectCreationError $ "Failed to create project from template: " ++ show e
|
||||
Right _ -> return ()
|
||||
where
|
||||
executeCmd = callCommand $ unwords command
|
||||
command = ["npx", "giget@latest", templatePath, projectDir]
|
||||
|
||||
-- gitget doesn't fail if the template dir doesn't exist in the repo, so we need to check if the directory exists.
|
||||
ensureTemplateWasFetched :: Command ()
|
||||
ensureTemplateWasFetched =
|
||||
whenM (liftIO $ IOUtil.isDirectoryEmpty absWaspProjectDir) $
|
||||
throwProjectCreationError "Are you sure that the template exists? 🤔 Check the list of templates here: https://github.com/wasp-lang/starters"
|
||||
|
||||
replacePlaceholdersInWaspFile :: Command ()
|
||||
replacePlaceholdersInWaspFile = liftIO $ do
|
||||
mainWaspFileContent <- IOUtil.readFileStrict absMainWaspFile
|
||||
|
||||
let replacedContent =
|
||||
foldl
|
||||
(\acc (placeholder, value) -> T.replace (T.pack placeholder) (T.pack value) acc)
|
||||
mainWaspFileContent
|
||||
replacements
|
||||
|
||||
IOUtil.writeFileFromText absMainWaspFile replacedContent
|
||||
where
|
||||
absMainWaspFile = absWaspProjectDir </> [relfile|main.wasp|]
|
||||
replacements =
|
||||
[ ("__waspAppName__", appName),
|
||||
("__waspProjectName__", projectName),
|
||||
("__waspVersion__", waspVersionBounds)
|
||||
]
|
||||
|
||||
waspVersionBounds :: String
|
||||
waspVersionBounds = show (SV.backwardsCompatibleWith WV.waspVersion)
|
||||
|
||||
throwProjectCreationError :: String -> Command a
|
||||
throwProjectCreationError = throwError . CommandError "Project creation failed"
|
||||
|
@ -1,23 +1,13 @@
|
||||
module Wasp.Cli.Command.Db
|
||||
( runDbCommand,
|
||||
studio,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Concurrent (newChan)
|
||||
import Control.Concurrent.Async (concurrently)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import StrongPath ((</>))
|
||||
import Wasp.Cli.Command (Command, runCommand)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Compile (compileWithOptions, defaultCompileOptions)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.CompileOptions (CompileOptions (generatorWarningsFilter))
|
||||
import Wasp.Generator.DbGenerator.Jobs (runStudio)
|
||||
import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed)
|
||||
import Wasp.Generator.Monad (GeneratorWarning (GeneratorNeedsMigrationWarning))
|
||||
import qualified Wasp.Message as Msg
|
||||
|
||||
runDbCommand :: Command a -> IO ()
|
||||
runDbCommand = runCommand . makeDbCommand
|
||||
@ -44,17 +34,3 @@ makeDbCommand cmd = do
|
||||
_ -> True
|
||||
)
|
||||
}
|
||||
|
||||
-- TODO(matija): should we extract this into a separate file, like we did for migrate?
|
||||
studio :: Command ()
|
||||
studio = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
let genProjectDir =
|
||||
waspProjectDir </> Common.dotWaspDirInWaspProjectDir
|
||||
</> Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
cliSendMessageC $ Msg.Start "Running studio..."
|
||||
chan <- liftIO newChan
|
||||
|
||||
_ <- liftIO $ concurrently (readJobMessagesAndPrintThemPrefixed chan) (runStudio genProjectDir chan)
|
||||
error "This should never happen, studio should never stop."
|
||||
|
@ -13,12 +13,11 @@ import Wasp.Cli.Command.Common
|
||||
)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Cli.Common
|
||||
import Wasp.Common (DbMigrationsDir)
|
||||
import qualified Wasp.Common
|
||||
import Wasp.Generator.Common (ProjectRootDir)
|
||||
import Wasp.Generator.DbGenerator.Common (MigrateArgs (..), defaultMigrateArgs)
|
||||
import qualified Wasp.Generator.DbGenerator.Operations as DbOps
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project.Db.Migrations (DbMigrationsDir, dbMigrationsDirInWaspProjectDir)
|
||||
|
||||
-- | NOTE(shayne): Performs database schema migration (based on current schema) in the generated project.
|
||||
-- This assumes the wasp project migrations dir was copied from wasp source project by a previous compile.
|
||||
@ -26,14 +25,13 @@ import qualified Wasp.Message as Msg
|
||||
migrateDev :: [String] -> Command ()
|
||||
migrateDev optionalMigrateArgs = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
let waspDbMigrationsDir = waspProjectDir </> Wasp.Common.dbMigrationsDirInWaspProjectDir
|
||||
let waspDbMigrationsDir = waspProjectDir </> dbMigrationsDirInWaspProjectDir
|
||||
let projectRootDir =
|
||||
waspProjectDir
|
||||
</> Cli.Common.dotWaspDirInWaspProjectDir
|
||||
</> Cli.Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
migrateDatabase optionalMigrateArgs projectRootDir waspDbMigrationsDir
|
||||
generatePrismaClients projectRootDir
|
||||
|
||||
migrateDatabase :: [String] -> Path' Abs (Dir ProjectRootDir) -> Path' Abs (Dir DbMigrationsDir) -> Command ()
|
||||
migrateDatabase optionalMigrateArgs projectRootDir dbMigrationsDir = do
|
||||
@ -46,14 +44,6 @@ migrateDatabase optionalMigrateArgs projectRootDir dbMigrationsDir = do
|
||||
migrateArgs <- liftEither $ parseMigrateArgs optionalMigrateArgs
|
||||
ExceptT $ DbOps.migrateDevAndCopyToSource dbMigrationsDir projectRootDir migrateArgs
|
||||
|
||||
generatePrismaClients :: Path' Abs (Dir ProjectRootDir) -> Command ()
|
||||
generatePrismaClients projectRootDir = do
|
||||
cliSendMessageC $ Msg.Start "Generating prisma clients..."
|
||||
generatePrismaClientsResult <- liftIO $ DbOps.generatePrismaClients projectRootDir
|
||||
case generatePrismaClientsResult of
|
||||
Left err -> throwError $ CommandError "Could not generate Prisma clients" err
|
||||
Right () -> cliSendMessageC $ Msg.Success "Prisma clients successfully generated."
|
||||
|
||||
-- | Basic parsing of db-migrate args. In the future, we could use a smarter parser
|
||||
-- for this (and all other CLI arg parsing).
|
||||
parseMigrateArgs :: [String] -> Either String MigrateArgs
|
||||
|
25
waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
Normal file
25
waspc/cli/src/Wasp/Cli/Command/Db/Reset.hs
Normal file
@ -0,0 +1,25 @@
|
||||
module Wasp.Cli.Command.Db.Reset
|
||||
( reset,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import StrongPath ((</>))
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Generator.DbGenerator.Operations (dbReset)
|
||||
import qualified Wasp.Message as Msg
|
||||
|
||||
reset :: Command ()
|
||||
reset = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
let genProjectDir =
|
||||
waspProjectDir </> Common.dotWaspDirInWaspProjectDir </> Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
cliSendMessageC $ Msg.Start "Resetting the database..."
|
||||
|
||||
liftIO (dbReset genProjectDir) >>= \case
|
||||
Left errorMsg -> cliSendMessageC $ Msg.Failure "Database reset failed" errorMsg
|
||||
Right () -> cliSendMessageC $ Msg.Success "Database reset successfully!"
|
93
waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
Normal file
93
waspc/cli/src/Wasp/Cli/Command/Db/Seed.hs
Normal file
@ -0,0 +1,93 @@
|
||||
module Wasp.Cli.Command.Db.Seed
|
||||
( seed,
|
||||
)
|
||||
where
|
||||
|
||||
import qualified Control.Monad.Except as E
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.List (intercalate)
|
||||
import Data.List.NonEmpty (NonEmpty ((:|)))
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
import StrongPath ((</>))
|
||||
import Text.Printf (printf)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App.Db as AS.Db
|
||||
import qualified Wasp.AppSpec.ExtImport as AS.ExtImport
|
||||
import qualified Wasp.AppSpec.Valid as ASV
|
||||
import Wasp.Cli.Command (Command, CommandError (CommandError))
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Compile (analyze)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Generator.DbGenerator.Operations (dbSeed)
|
||||
import qualified Wasp.Message as Msg
|
||||
|
||||
seed :: Maybe String -> Command ()
|
||||
seed maybeUserProvidedSeedName = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
let genProjectDir =
|
||||
waspProjectDir </> Common.dotWaspDirInWaspProjectDir </> Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
appSpec <- analyze waspProjectDir
|
||||
|
||||
nameOfSeedToRun <- obtainNameOfExistingSeedToRun maybeUserProvidedSeedName appSpec
|
||||
|
||||
cliSendMessageC $ Msg.Start $ "Running database seed " <> nameOfSeedToRun <> "..."
|
||||
|
||||
liftIO (dbSeed genProjectDir nameOfSeedToRun) >>= \case
|
||||
Left errorMsg -> cliSendMessageC $ Msg.Failure "Database seeding failed" errorMsg
|
||||
Right () -> cliSendMessageC $ Msg.Success "Database seeded successfully!"
|
||||
|
||||
obtainNameOfExistingSeedToRun :: Maybe String -> AS.AppSpec -> Command String
|
||||
obtainNameOfExistingSeedToRun maybeUserProvidedSeedName spec = do
|
||||
seedNames <- getSeedNames <$> getSeedsFromAppSpecOrThrowIfNone
|
||||
case maybeUserProvidedSeedName of
|
||||
Just name -> parseUserProvidedSeedName name seedNames
|
||||
Nothing -> case seedNames of
|
||||
seedName :| [] -> return seedName
|
||||
_seedNames -> liftIO $ askUserToChooseFromSeedNames seedNames
|
||||
where
|
||||
askUserToChooseFromSeedNames :: NE.NonEmpty String -> IO String
|
||||
askUserToChooseFromSeedNames seedNames = do
|
||||
putStrLn "Choose a seed to run:"
|
||||
mapM_ (\(i, n) -> putStrLn $ printf " [%d] %s" i n) $ zip [1 :: Int ..] (NE.toList seedNames)
|
||||
putStrLn "Type a number (e.g. 1 or 2):"
|
||||
chosenNumber <- getLine
|
||||
case parseNumberInRange (1, length seedNames) chosenNumber of
|
||||
Right idx -> return $ seedNames NE.!! (idx - 1)
|
||||
Left errMsg -> do
|
||||
putStrLn $ "Invalid number (" <> errMsg <> "), please try again.\n"
|
||||
askUserToChooseFromSeedNames seedNames
|
||||
|
||||
parseNumberInRange :: (Int, Int) -> String -> Either String Int
|
||||
parseNumberInRange (minNum, maxNum) strNum =
|
||||
case reads strNum of
|
||||
[(num, _)] ->
|
||||
if num >= minNum && num <= maxNum
|
||||
then Right num
|
||||
else Left "number out of range"
|
||||
_notANum -> Left "not a number"
|
||||
|
||||
parseUserProvidedSeedName :: String -> NE.NonEmpty String -> Command String
|
||||
parseUserProvidedSeedName userProvidedSeedName seedNames =
|
||||
if userProvidedSeedName `elem` seedNames
|
||||
then return userProvidedSeedName
|
||||
else
|
||||
(E.throwError . CommandError "Invalid seed name") $
|
||||
"There is no seed with the name " <> userProvidedSeedName <> "."
|
||||
<> ("\nValid seed names are: " <> intercalate ", " (NE.toList seedNames) <> ".")
|
||||
|
||||
getSeedsFromAppSpecOrThrowIfNone :: Command (NE.NonEmpty AS.ExtImport.ExtImport)
|
||||
getSeedsFromAppSpecOrThrowIfNone = case getDbSeeds spec of
|
||||
Just seeds@(_ : _) -> return $ NE.fromList seeds
|
||||
_noSeeds ->
|
||||
(E.throwError . CommandError "No seeds defined") $
|
||||
"You haven't defined any database seeding functions, so there is nothing to run!\n"
|
||||
<> "To do so, define seeding functions via app.db.seeds in your Wasp config."
|
||||
|
||||
getSeedNames :: (Functor f) => f AS.ExtImport.ExtImport -> f String
|
||||
getSeedNames seeds = AS.ExtImport.importIdentifier <$> seeds
|
||||
|
||||
getDbSeeds :: AS.AppSpec -> Maybe [AS.ExtImport.ExtImport]
|
||||
getDbSeeds spec = AS.Db.seeds =<< AS.App.db (snd $ ASV.getApp spec)
|
29
waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
Normal file
29
waspc/cli/src/Wasp/Cli/Command/Db/Studio.hs
Normal file
@ -0,0 +1,29 @@
|
||||
module Wasp.Cli.Command.Db.Studio
|
||||
( studio,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Concurrent (newChan)
|
||||
import Control.Concurrent.Async (concurrently)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import StrongPath ((</>))
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Generator.DbGenerator.Jobs (runStudio)
|
||||
import Wasp.Generator.Job.IO (readJobMessagesAndPrintThemPrefixed)
|
||||
import qualified Wasp.Message as Msg
|
||||
|
||||
studio :: Command ()
|
||||
studio = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
let genProjectDir =
|
||||
waspProjectDir </> Common.dotWaspDirInWaspProjectDir </> Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
cliSendMessageC $ Msg.Start "Running studio..."
|
||||
|
||||
chan <- liftIO newChan
|
||||
_ <- liftIO $ readJobMessagesAndPrintThemPrefixed chan `concurrently` runStudio genProjectDir chan
|
||||
|
||||
error "This should never happen, studio should never stop."
|
@ -7,13 +7,15 @@ import Control.Monad.IO.Class (liftIO)
|
||||
import System.Environment (getExecutablePath)
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import qualified Wasp.Lib as Lib
|
||||
import qualified Wasp.Project.Deployment
|
||||
|
||||
deploy :: [String] -> Command ()
|
||||
deploy cmdArgs = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
liftIO $ do
|
||||
-- `getExecutablePath` has some caveats: https://frasertweedale.github.io/blog-fp/posts/2022-05-10-improved-executable-path-queries.html
|
||||
-- Once we upgrade to GHC 9.4 we should change to `executablePath`, but this should be ok for our purposes.
|
||||
-- `getExecutablePath` has some caveats:
|
||||
-- https://frasertweedale.github.io/blog-fp/posts/2022-05-10-improved-executable-path-queries.html
|
||||
-- Once we upgrade to GHC 9.4 we should change to `executablePath`, but this should be ok for
|
||||
-- our purposes.
|
||||
waspExePath <- getExecutablePath
|
||||
Lib.deploy waspExePath waspProjectDir cmdArgs
|
||||
Wasp.Project.Deployment.deploy waspExePath waspProjectDir cmdArgs
|
||||
|
@ -14,7 +14,7 @@ import Wasp.Cli.Terminal (title)
|
||||
import qualified Wasp.Generator.NpmDependencies as N
|
||||
import qualified Wasp.Generator.ServerGenerator as ServerGenerator
|
||||
import qualified Wasp.Generator.WebAppGenerator as WebAppGenerator
|
||||
import Wasp.Lib (analyzeWaspProject)
|
||||
import Wasp.Project (analyzeWaspProject)
|
||||
import qualified Wasp.Util.Terminal as Term
|
||||
|
||||
deps :: Command ()
|
||||
|
@ -9,7 +9,7 @@ import qualified Data.Text.IO as T.IO
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Compile (defaultCompileOptions)
|
||||
import Wasp.Lib (compileAndRenderDockerfile)
|
||||
import Wasp.Project (compileAndRenderDockerfile)
|
||||
|
||||
printDockerfile :: Command ()
|
||||
printDockerfile = do
|
||||
|
@ -5,45 +5,43 @@ module Wasp.Cli.Command.Info
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Arrow
|
||||
import Control.Monad.Except
|
||||
import Control.Arrow ()
|
||||
import Control.Monad.Except (MonadIO (liftIO))
|
||||
import StrongPath (Abs, Dir, Path', fromRelFile)
|
||||
import StrongPath.Operations
|
||||
import StrongPath.Operations ()
|
||||
import System.Directory (getFileSize)
|
||||
import qualified Wasp.Analyzer as Analyzer
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.Core.Decl as AS (Decl, takeDecls)
|
||||
import qualified Wasp.AppSpec.Valid as ASV
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd, readWaspCompileInfo)
|
||||
import Wasp.Cli.Command.Compile (analyze)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import qualified Wasp.Cli.Common as Cli.Common
|
||||
import Wasp.Cli.Command.Start.Db (getDbSystem)
|
||||
import Wasp.Cli.Terminal (title)
|
||||
import Wasp.Common (WaspProjectDir)
|
||||
import Wasp.Error (showCompilerErrorForTerminal)
|
||||
import Wasp.Lib (findWaspFile)
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Util (ifM)
|
||||
import Wasp.Project (WaspProjectDir)
|
||||
import qualified Wasp.Util.IO as IOUtil
|
||||
import qualified Wasp.Util.Terminal as Term
|
||||
|
||||
info :: Command ()
|
||||
info = do
|
||||
waspDir <- findWaspProjectRootDirFromCwd
|
||||
compileInfo <- liftIO $ readCompileInformation waspDir
|
||||
|
||||
compileInfo <- liftIO $ readWaspCompileInfo waspDir
|
||||
projectSize <- liftIO $ readDirectorySizeMB waspDir
|
||||
declsOrError <- liftIO $ parseWaspFile waspDir
|
||||
case declsOrError of
|
||||
Left err -> cliSendMessageC $ Msg.Failure "Info failed" err
|
||||
Right decls -> do
|
||||
cliSendMessageC $
|
||||
Msg.Info $
|
||||
unlines
|
||||
[ "",
|
||||
title "Project information",
|
||||
printInfo "Name" (fst $ head $ AS.takeDecls @AS.App.App decls),
|
||||
printInfo "Last compile" compileInfo,
|
||||
printInfo "Project size" projectSize
|
||||
]
|
||||
|
||||
appSpec <- analyze waspDir
|
||||
let (appName, app) = ASV.getApp appSpec
|
||||
|
||||
cliSendMessageC $
|
||||
Msg.Info $
|
||||
unlines
|
||||
[ "",
|
||||
title "Project information",
|
||||
printInfo "Name" appName,
|
||||
printInfo "Database system" $ show $ getDbSystem app,
|
||||
printInfo "Last compile" compileInfo,
|
||||
printInfo "Project dir size" projectSize
|
||||
]
|
||||
|
||||
printInfo :: String -> String -> String
|
||||
printInfo key value = Term.applyStyles [Term.Cyan] key ++ ": " <> Term.applyStyles [Term.White] value
|
||||
@ -52,26 +50,3 @@ readDirectorySizeMB :: Path' Abs (Dir WaspProjectDir) -> IO String
|
||||
readDirectorySizeMB path = (++ " MB") . show . (`div` 1000000) . sum <$> allFileSizes
|
||||
where
|
||||
allFileSizes = IOUtil.listDirectoryDeep path >>= mapM (getFileSize . fromRelFile)
|
||||
|
||||
readCompileInformation :: Path' Abs (Dir WaspProjectDir) -> IO String
|
||||
readCompileInformation waspDir =
|
||||
ifM
|
||||
(IOUtil.doesFileExist dotWaspInfoFile)
|
||||
(IOUtil.readFile dotWaspInfoFile)
|
||||
(return "No compile information found")
|
||||
where
|
||||
dotWaspInfoFile =
|
||||
waspDir </> Cli.Common.dotWaspDirInWaspProjectDir
|
||||
</> Cli.Common.generatedCodeDirInDotWaspDir
|
||||
</> Cli.Common.dotWaspInfoFileInGeneratedCodeDir
|
||||
|
||||
parseWaspFile :: Path' Abs (Dir WaspProjectDir) -> IO (Either String [AS.Decl])
|
||||
parseWaspFile waspDir = runExceptT $ do
|
||||
waspFile <- ExceptT $ findWaspFile waspDir
|
||||
waspStr <- liftIO $ IOUtil.readFile waspFile
|
||||
liftEither $ left (annotateErrorForCli waspFile waspStr) $ Analyzer.analyze waspStr
|
||||
where
|
||||
annotateErrorForCli waspFile waspStr =
|
||||
("Couldn't parse .wasp file:\n" ++)
|
||||
. showCompilerErrorForTerminal (waspFile, waspStr)
|
||||
. Analyzer.getErrorMessageAndCtx
|
||||
|
@ -15,9 +15,9 @@ import Wasp.Cli.Command.Compile (compile, printWarningsAndErrorsIfAny)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import Wasp.Cli.Command.Watch (watch)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Lib (CompileError, CompileWarning)
|
||||
import qualified Wasp.Lib
|
||||
import qualified Wasp.Generator
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project (CompileError, CompileWarning)
|
||||
|
||||
-- | Does initial compile of wasp code and then runs the generated project.
|
||||
-- It also listens for any file changes and recompiles and restarts generated project accordingly.
|
||||
@ -41,7 +41,7 @@ start = do
|
||||
-- 'watch') once jobs from 'start' quiet down a bit.
|
||||
ongoingCompilationResultMVar <- newMVar (warnings, [])
|
||||
let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
|
||||
let startGeneratedWebApp = Wasp.Lib.start outDir (onJobsQuietDown ongoingCompilationResultMVar)
|
||||
let startGeneratedWebApp = Wasp.Generator.start outDir (onJobsQuietDown ongoingCompilationResultMVar)
|
||||
-- In parallel:
|
||||
-- 1. watch for any changes in the Wasp project, be it users wasp code or users JS/HTML/...
|
||||
-- code. On any change, Wasp is recompiled (and generated code is re-generated).
|
||||
|
177
waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
Normal file
177
waspc/cli/src/Wasp/Cli/Command/Start/Db.hs
Normal file
@ -0,0 +1,177 @@
|
||||
module Wasp.Cli.Command.Start.Db
|
||||
( start,
|
||||
getDbSystem,
|
||||
waspDevDbDockerVolumePrefix,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Monad (when)
|
||||
import qualified Control.Monad.Except as E
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Data.Maybe (fromMaybe, isJust)
|
||||
import StrongPath (Abs, Dir, File', Path', Rel, fromRelFile)
|
||||
import System.Environment (lookupEnv)
|
||||
import System.Process (callCommand)
|
||||
import Text.Printf (printf)
|
||||
import qualified Wasp.AppSpec as AS
|
||||
import qualified Wasp.AppSpec.App as AS.App
|
||||
import qualified Wasp.AppSpec.App.Db as AS.App.Db
|
||||
import qualified Wasp.AppSpec.Valid as ASV
|
||||
import Wasp.Cli.Command (Command, CommandError (CommandError))
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd, throwIfExeIsNotAvailable)
|
||||
import Wasp.Cli.Command.Compile (analyze)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import Wasp.Cli.Common (WaspProjectDir)
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project.Db (databaseUrlEnvVarName)
|
||||
import Wasp.Project.Db.Dev (makeDevDbUniqueId)
|
||||
import qualified Wasp.Project.Db.Dev.Postgres as Dev.Postgres
|
||||
import Wasp.Project.Env (dotEnvServer)
|
||||
import Wasp.Util (whenM)
|
||||
import qualified Wasp.Util.Network.Socket as Socket
|
||||
|
||||
-- | Starts a "managed" dev database, where "managed" means that
|
||||
-- Wasp creates it and connects the Wasp app with it.
|
||||
-- Wasp is smart while doing this so it checks which database is specified
|
||||
-- in Wasp configuration and spins up a database of appropriate type.
|
||||
start :: Command ()
|
||||
start = do
|
||||
waspProjectDir <- findWaspProjectRootDirFromCwd
|
||||
appSpec <- analyze waspProjectDir
|
||||
|
||||
throwIfCustomDbAlreadyInUse appSpec
|
||||
|
||||
let (appName, app) = ASV.getApp appSpec
|
||||
case getDbSystem app of
|
||||
AS.App.Db.SQLite -> noteSQLiteDoesntNeedStart
|
||||
AS.App.Db.PostgreSQL -> startPostgreDevDb waspProjectDir appName
|
||||
where
|
||||
noteSQLiteDoesntNeedStart =
|
||||
cliSendMessageC . Msg.Info $
|
||||
"Nothing to do! You are all good, you are using SQLite which doesn't need to be started."
|
||||
|
||||
throwIfCustomDbAlreadyInUse :: AS.AppSpec -> Command ()
|
||||
throwIfCustomDbAlreadyInUse spec = do
|
||||
throwIfDbUrlInEnv
|
||||
throwIfDbUrlInServerDotEnv spec
|
||||
where
|
||||
throwIfDbUrlInEnv :: Command ()
|
||||
throwIfDbUrlInEnv = do
|
||||
dbUrl <- liftIO $ lookupEnv databaseUrlEnvVarName
|
||||
when (isJust dbUrl) $
|
||||
throwCustomDbAlreadyInUseError
|
||||
( "Wasp has detected existing " <> databaseUrlEnvVarName <> " var in your environment.\n"
|
||||
<> "To have Wasp run the dev database for you, make sure you remove that env var first."
|
||||
)
|
||||
|
||||
throwIfDbUrlInServerDotEnv :: AS.AppSpec -> Command ()
|
||||
throwIfDbUrlInServerDotEnv appSpec =
|
||||
when (isThereDbUrlInServerDotEnv appSpec) $
|
||||
throwCustomDbAlreadyInUseError
|
||||
( printf
|
||||
( "Wasp has detected that you have defined %s env var in your %s file.\n"
|
||||
<> "To have Wasp run the dev database for you, make sure you remove that env var first."
|
||||
)
|
||||
databaseUrlEnvVarName
|
||||
(fromRelFile (dotEnvServer :: Path' (Rel WaspProjectDir) File'))
|
||||
)
|
||||
where
|
||||
isThereDbUrlInServerDotEnv = any ((== databaseUrlEnvVarName) . fst) . AS.devEnvVarsServer
|
||||
|
||||
throwCustomDbAlreadyInUseError :: String -> Command ()
|
||||
throwCustomDbAlreadyInUseError msg =
|
||||
E.throwError $ CommandError "You are using custom database already" msg
|
||||
|
||||
getDbSystem :: AS.App.App -> AS.App.Db.DbSystem
|
||||
getDbSystem app =
|
||||
fromMaybe AS.App.Db.SQLite (AS.App.db app >>= AS.App.Db.system)
|
||||
|
||||
startPostgreDevDb :: Path' Abs (Dir WaspProjectDir) -> String -> Command ()
|
||||
startPostgreDevDb waspProjectDir appName = do
|
||||
throwIfExeIsNotAvailable
|
||||
"docker"
|
||||
"To run PostgreSQL dev database, Wasp needs `docker` installed and in PATH."
|
||||
throwIfDevDbPortIsAlreadyInUse
|
||||
|
||||
cliSendMessageC . Msg.Info $
|
||||
unlines
|
||||
[ "✨ Starting a PostgreSQL dev database (based on your Wasp config) ✨",
|
||||
"",
|
||||
"Additional info:",
|
||||
" ℹ Connection URL, in case you might want to connect with external tools:",
|
||||
" " <> connectionUrl,
|
||||
" ℹ Database data is persisted in a docker volume with the following name"
|
||||
<> " (useful to know if you will want to delete it at some point):",
|
||||
" " <> dockerVolumeName
|
||||
]
|
||||
|
||||
cliSendMessageC $ Msg.Info "..."
|
||||
|
||||
-- NOTE: POSTGRES_PASSWORD, POSTGRES_USER, POSTGRES_DB below are really used by the docker image
|
||||
-- only when initializing the database -> if it already exists, they will be ignored.
|
||||
-- This is how the postgres Docker image works.
|
||||
let command =
|
||||
printf
|
||||
( unwords
|
||||
[ "docker run",
|
||||
"--name %s",
|
||||
"--rm",
|
||||
"--publish %d:5432",
|
||||
"-v %s:/var/lib/postgresql/data",
|
||||
"--env POSTGRES_PASSWORD=%s",
|
||||
"--env POSTGRES_USER=%s",
|
||||
"--env POSTGRES_DB=%s",
|
||||
"postgres"
|
||||
]
|
||||
)
|
||||
dockerContainerName
|
||||
Dev.Postgres.defaultDevPort
|
||||
dockerVolumeName
|
||||
Dev.Postgres.defaultDevPass
|
||||
Dev.Postgres.defaultDevUser
|
||||
dbName
|
||||
liftIO $ callCommand command
|
||||
where
|
||||
dockerVolumeName = makeWaspDevDbDockerVolumeName waspProjectDir appName
|
||||
dockerContainerName = makeWaspDevDbDockerContainerName waspProjectDir appName
|
||||
dbName = Dev.Postgres.makeDevDbName waspProjectDir appName
|
||||
connectionUrl = Dev.Postgres.makeDevConnectionUrl waspProjectDir appName
|
||||
|
||||
throwIfDevDbPortIsAlreadyInUse :: Command ()
|
||||
throwIfDevDbPortIsAlreadyInUse = do
|
||||
-- I am checking both conditions because of Docker having virtual network on Mac which
|
||||
-- always gives precedence to native ports so checking only if we can open the port is
|
||||
-- not enough because we can open it even if Docker container is already bound to that port.
|
||||
whenM (liftIO $ Socket.checkIfPortIsInUse devDbSocketAddress) throwPortAlreadyInUseError
|
||||
whenM (liftIO $ Socket.checkIfPortIsAcceptingConnections devDbSocketAddress) throwPortAlreadyInUseError
|
||||
where
|
||||
devDbSocketAddress = Socket.makeLocalHostSocketAddress $ fromIntegral Dev.Postgres.defaultDevPort
|
||||
throwPortAlreadyInUseError =
|
||||
E.throwError $
|
||||
CommandError
|
||||
"Port already in use"
|
||||
( printf
|
||||
"Wasp can't run PostgreSQL dev database for you since port %d is already in use."
|
||||
Dev.Postgres.defaultDevPort
|
||||
)
|
||||
|
||||
-- | Docker volume name unique for the Wasp project with specified path and name.
|
||||
makeWaspDevDbDockerVolumeName :: Path' Abs (Dir WaspProjectDir) -> String -> String
|
||||
makeWaspDevDbDockerVolumeName waspProjectDir appName =
|
||||
take maxDockerVolumeNameLength $
|
||||
waspDevDbDockerVolumePrefix <> "-" <> makeDevDbUniqueId waspProjectDir appName
|
||||
|
||||
waspDevDbDockerVolumePrefix :: String
|
||||
waspDevDbDockerVolumePrefix = "wasp-dev-db"
|
||||
|
||||
maxDockerVolumeNameLength :: Int
|
||||
maxDockerVolumeNameLength = 255
|
||||
|
||||
-- | Docker container name unique for the Wasp project with specified path and name.
|
||||
makeWaspDevDbDockerContainerName :: Path' Abs (Dir WaspProjectDir) -> String -> String
|
||||
makeWaspDevDbDockerContainerName waspProjectDir appName =
|
||||
take maxDockerContainerNameLength $
|
||||
waspDevDbDockerVolumePrefix <> "-" <> makeDevDbUniqueId waspProjectDir appName
|
||||
|
||||
maxDockerContainerNameLength :: Int
|
||||
maxDockerContainerNameLength = 63
|
48
waspc/cli/src/Wasp/Cli/Command/Test.hs
Normal file
48
waspc/cli/src/Wasp/Cli/Command/Test.hs
Normal file
@ -0,0 +1,48 @@
|
||||
module Wasp.Cli.Command.Test
|
||||
( test,
|
||||
)
|
||||
where
|
||||
|
||||
import Control.Concurrent.Async (race)
|
||||
import Control.Concurrent.MVar (newMVar)
|
||||
import Control.Monad.Except (throwError)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import StrongPath (Abs, Dir, (</>))
|
||||
import StrongPath.Types (Path')
|
||||
import Wasp.Cli.Command (Command, CommandError (..))
|
||||
import Wasp.Cli.Command.Common (findWaspProjectRootDirFromCwd)
|
||||
import Wasp.Cli.Command.Compile (compile)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import Wasp.Cli.Command.Watch (watch)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import qualified Wasp.Generator
|
||||
import Wasp.Generator.Common (ProjectRootDir)
|
||||
import qualified Wasp.Message as Msg
|
||||
|
||||
test :: [String] -> Command ()
|
||||
test [] = throwError $ CommandError "Not enough arguments" "Expected: wasp test client <args>"
|
||||
test ("client" : args) = watchAndTest $ Wasp.Generator.testWebApp args
|
||||
test ("server" : _args) = throwError $ CommandError "Invalid arguments" "Server testing not yet implemented."
|
||||
test _ = throwError $ CommandError "Invalid arguments" "Expected: wasp test client <args>"
|
||||
|
||||
watchAndTest :: (Path' Abs (Dir ProjectRootDir) -> IO (Either String ())) -> Command ()
|
||||
watchAndTest testRunner = do
|
||||
waspRoot <- findWaspProjectRootDirFromCwd
|
||||
let outDir = waspRoot </> Common.dotWaspDirInWaspProjectDir </> Common.generatedCodeDirInDotWaspDir
|
||||
|
||||
cliSendMessageC $ Msg.Start "Starting compilation and setup phase. Hold tight..."
|
||||
|
||||
warnings <- compile
|
||||
|
||||
cliSendMessageC $ Msg.Start "Watching for file changes and running tests ..."
|
||||
|
||||
watchOrStartResult <- liftIO $ do
|
||||
ongoingCompilationResultMVar <- newMVar (warnings, [])
|
||||
let watchWaspProjectSource = watch waspRoot outDir ongoingCompilationResultMVar
|
||||
watchWaspProjectSource `race` testRunner outDir
|
||||
|
||||
case watchOrStartResult of
|
||||
Left () -> error "This should never happen, listening for file changes should never end but it did."
|
||||
Right startResult -> case startResult of
|
||||
Left testError -> throwError $ CommandError "Test failed" testError
|
||||
Right () -> return ()
|
@ -9,6 +9,7 @@ import StrongPath (fromAbsDir, fromAbsFile, (</>))
|
||||
import System.Exit (die)
|
||||
import Wasp.Cli.Command (Command)
|
||||
import Wasp.Cli.Command.Message (cliSendMessageC)
|
||||
import Wasp.Cli.Command.Start.Db (waspDevDbDockerVolumePrefix)
|
||||
import Wasp.Cli.FileSystem
|
||||
( getHomeDir,
|
||||
getUserCacheDir,
|
||||
@ -24,7 +25,12 @@ uninstall :: Command ()
|
||||
uninstall = do
|
||||
cliSendMessageC $ Msg.Start "Uninstalling Wasp ..."
|
||||
liftIO removeWaspFiles
|
||||
cliSendMessageC $ Msg.Success "Uninstalled Wasp"
|
||||
cliSendMessageC $ Msg.Success "Uninstalled Wasp."
|
||||
cliSendMessageC $
|
||||
Msg.Info $
|
||||
"If you have used Wasp to run dev database for you, you might want to make sure you also"
|
||||
<> " deleted all the docker volumes it might have created."
|
||||
<> (" You can easily list them by doing `docker volume ls | grep " <> waspDevDbDockerVolumePrefix <> "`.")
|
||||
|
||||
removeWaspFiles :: IO ()
|
||||
removeWaspFiles = do
|
||||
|
@ -17,9 +17,9 @@ import qualified System.FilePath as FP
|
||||
import Wasp.Cli.Command.Compile (compileIO, printCompilationResult)
|
||||
import qualified Wasp.Cli.Common as Common
|
||||
import Wasp.Cli.Message (cliSendMessage)
|
||||
import Wasp.Lib (CompileError, CompileWarning)
|
||||
import qualified Wasp.Lib
|
||||
import qualified Wasp.Generator.Common as Wasp.Generator
|
||||
import qualified Wasp.Message as Msg
|
||||
import Wasp.Project (CompileError, CompileWarning, WaspProjectDir)
|
||||
|
||||
-- TODO: Idea: Read .gitignore file, and ignore everything from it. This will then also cover the
|
||||
-- .wasp dir, and users can easily add any custom stuff they want ignored. But, we also have to
|
||||
@ -32,8 +32,8 @@ import qualified Wasp.Message as Msg
|
||||
-- (warnings, errors) of the latest (re)compile whenever it happens. If there is already
|
||||
-- something in the MVar, it will get overwritten.
|
||||
watch ::
|
||||
Path' Abs (Dir Common.WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Lib.ProjectRootDir) ->
|
||||
Path' Abs (Dir WaspProjectDir) ->
|
||||
Path' Abs (Dir Wasp.Generator.ProjectRootDir) ->
|
||||
MVar ([CompileWarning], [CompileError]) ->
|
||||
IO ()
|
||||
watch waspProjectDir outDir ongoingCompilationResultMVar = FSN.withManager $ \mgr -> do
|
||||
|
@ -18,8 +18,8 @@ where
|
||||
|
||||
import StrongPath (Dir, File', Path', Rel, reldir, relfile)
|
||||
import Wasp.AppSpec.ExternalCode (SourceExternalCodeDir)
|
||||
import Wasp.Common (WaspProjectDir)
|
||||
import qualified Wasp.Generator.Common
|
||||
import Wasp.Project (WaspProjectDir)
|
||||
import qualified Wasp.Util.Terminal as Term
|
||||
|
||||
data DotWaspDir -- Here we put everything that wasp generates.
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 6.2 KiB |
Binary file not shown.
Before Width: | Height: | Size: 16 KiB |
@ -1,7 +0,0 @@
|
||||
{{={= =}=}}
|
||||
import { createAction } from './core'
|
||||
|
||||
export default createAction(
|
||||
'{= actionRoute =}',
|
||||
{=& entitiesArray =},
|
||||
)
|
@ -0,0 +1,10 @@
|
||||
{{={= =}=}}
|
||||
import { createAction } from './core'
|
||||
{=& operationTypeImportStmt =}
|
||||
|
||||
const action = createAction<{= operationTypeName =}>(
|
||||
'{= actionRoute =}',
|
||||
{=& entitiesArray =},
|
||||
)
|
||||
|
||||
export default action
|
@ -1,3 +1,13 @@
|
||||
import { Action } from '.'
|
||||
import { type Action } from '.'
|
||||
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
|
||||
|
||||
export function createAction<Input, Output>(actionRoute: string, entitiesUsed: unknown[]): Action<Input, Output>
|
||||
export function createAction<BackendAction extends GenericBackendAction>(
|
||||
actionRoute: string,
|
||||
entitiesUsed: unknown[]
|
||||
): ActionFor<BackendAction>
|
||||
|
||||
type ActionFor<BackendAction extends GenericBackendAction> = Expand<
|
||||
Action<Parameters<BackendAction>[0], _Awaited<_ReturnType<BackendAction>>>
|
||||
>
|
||||
|
||||
type GenericBackendAction = (args: never, context: any) => unknown
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { callOperation } from '../operations'
|
||||
import { callOperation, makeOperationRoute } from '../operations'
|
||||
import {
|
||||
registerActionInProgress,
|
||||
registerActionDone,
|
||||
} from '../operations/resources'
|
||||
|
||||
export function createAction(actionRoute, entitiesUsed) {
|
||||
export function createAction(relativeActionRoute, entitiesUsed) {
|
||||
const actionRoute = makeOperationRoute(relativeActionRoute)
|
||||
|
||||
async function internalAction(args, specificOptimisticUpdateDefinitions) {
|
||||
registerActionInProgress(specificOptimisticUpdateDefinitions)
|
||||
try {
|
||||
|
@ -5,9 +5,12 @@ import {
|
||||
UseMutationOptions,
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { Query } from '../queries';
|
||||
import { type Query } from '../queries';
|
||||
|
||||
export type Action<Input, Output> = (args?: Input) => Promise<Output>;
|
||||
export type Action<Input, Output> =
|
||||
[Input] extends [never] ?
|
||||
(args?: unknown) => Promise<Output> :
|
||||
(args: Input) => Promise<Output>
|
||||
|
||||
/**
|
||||
* An options object passed into the `useAction` hook and used to enhance the
|
||||
|
@ -1,44 +1,39 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import config from './config'
|
||||
import { storage } from './storage'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: config.apiUrl,
|
||||
})
|
||||
|
||||
const WASP_APP_AUTH_TOKEN_NAME = "authToken"
|
||||
const WASP_APP_AUTH_TOKEN_NAME = 'authToken'
|
||||
|
||||
let authToken = null
|
||||
if (window.localStorage) {
|
||||
authToken = window.localStorage.getItem(WASP_APP_AUTH_TOKEN_NAME)
|
||||
}
|
||||
let authToken = storage.get(WASP_APP_AUTH_TOKEN_NAME) as string | undefined
|
||||
|
||||
export const setAuthToken = (token) => {
|
||||
if (typeof token !== 'string') {
|
||||
throw Error(`Token must be a string, but it was: {${typeof token}} ${token}.`)
|
||||
}
|
||||
export function setAuthToken (token: string): void {
|
||||
authToken = token
|
||||
window.localStorage && window.localStorage.setItem(WASP_APP_AUTH_TOKEN_NAME, token)
|
||||
storage.set(WASP_APP_AUTH_TOKEN_NAME, token)
|
||||
}
|
||||
|
||||
export const clearAuthToken = () => {
|
||||
export function clearAuthToken(): void {
|
||||
authToken = undefined
|
||||
window.localStorage && window.localStorage.removeItem(WASP_APP_AUTH_TOKEN_NAME)
|
||||
storage.remove(WASP_APP_AUTH_TOKEN_NAME)
|
||||
}
|
||||
|
||||
export const clearLocalStorage = () => {
|
||||
export function removeLocalUserData(): void {
|
||||
authToken = undefined
|
||||
|
||||
window.localStorage && window.localStorage.clear()
|
||||
storage.clear()
|
||||
}
|
||||
|
||||
api.interceptors.request.use(request => {
|
||||
api.interceptors.request.use((request) => {
|
||||
if (authToken) {
|
||||
request.headers['Authorization'] = `Bearer ${authToken}`
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
api.interceptors.response.use(undefined, error => {
|
||||
api.interceptors.response.use(undefined, (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
clearAuthToken()
|
||||
}
|
||||
@ -50,7 +45,7 @@ api.interceptors.response.use(undefined, error => {
|
||||
* standard format to be further used by the client. It is also assumed that given API
|
||||
* error has been formatted as implemented by HttpError on the server.
|
||||
*/
|
||||
export const handleApiError = (error) => {
|
||||
export function handleApiError(error: AxiosError<{ message?: string, data?: unknown }>): void {
|
||||
if (error?.response) {
|
||||
// If error came from HTTP response, we capture most informative message
|
||||
// and also add .statusCode information to it.
|
||||
@ -61,14 +56,23 @@ export const handleApiError = (error) => {
|
||||
// That would require copying HttpError code to web-app also and using it here.
|
||||
const responseJson = error.response?.data
|
||||
const responseStatusCode = error.response.status
|
||||
const e = new Error(responseJson?.message || error.message)
|
||||
e.statusCode = responseStatusCode
|
||||
e.data = responseJson?.data
|
||||
throw e
|
||||
throw new WaspHttpError(responseStatusCode, responseJson?.message ?? error.message, responseJson)
|
||||
} else {
|
||||
// If any other error, we just propagate it.
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
class WaspHttpError extends Error {
|
||||
statusCode: number
|
||||
|
||||
data: unknown
|
||||
|
||||
constructor (statusCode: number, message: string, data: unknown) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
@ -0,0 +1,12 @@
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../../../api';
|
||||
import { initSession } from '../../helpers/user';
|
||||
|
||||
export async function login(data: { email: string; password: string }): Promise<void> {
|
||||
try {
|
||||
const response = await api.post('{= loginPath =}', data);
|
||||
await initSession(response.data.token);
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../../../api';
|
||||
|
||||
export async function requestPasswordReset(data: { email: string; }): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await api.post('{= requestPasswordResetPath =}', data);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetPassword(data: { token: string; password: string; }): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await api.post('{= resetPasswordPath =}', data);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../../../api';
|
||||
|
||||
export async function signup(data: { email: string; password: string }): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await api.post('{= signupPath =}', data);
|
||||
return response.data;
|
||||
} catch (e) {
|
||||
handleApiError(e);
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../../../api'
|
||||
|
||||
export async function verifyEmail(data: {
|
||||
token: string
|
||||
}): Promise<{ success: boolean; reason?: string }> {
|
||||
try {
|
||||
const response = await api.post('{= verifyEmailPath =}', data)
|
||||
return response.data
|
||||
} catch (e) {
|
||||
handleApiError(e)
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
export { login } from './actions/login';
|
||||
export { signup } from './actions/signup';
|
||||
export { requestPasswordReset } from './actions/passwordReset';
|
||||
export { resetPassword } from './actions/passwordReset';
|
||||
export { verifyEmail } from './actions/verifyEmail';
|
631
waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx
Normal file
631
waspc/data/Generator/templates/react-app/src/auth/forms/Auth.tsx
Normal file
@ -0,0 +1,631 @@
|
||||
{{={= =}=}}
|
||||
import { useState, FormEvent, useEffect } from 'react'
|
||||
import { useHistory, useLocation } from 'react-router-dom'
|
||||
import { createTheme } from '@stitches/react'
|
||||
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
import signup from '../signup.js'
|
||||
import login from '../login.js'
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
import { signup } from '../email/actions/signup.js'
|
||||
import { login } from '../email/actions/login.js'
|
||||
import { requestPasswordReset, resetPassword } from '../email/actions/passwordReset.js'
|
||||
import { verifyEmail } from '../email/actions/verifyEmail.js'
|
||||
{=/ isEmailAuthEnabled =}
|
||||
{=# isExternalAuthEnabled =}
|
||||
import * as SocialIcons from './SocialIcons'
|
||||
import { SocialButton } from './SocialButton';
|
||||
{=/ isExternalAuthEnabled =}
|
||||
|
||||
import config from '../../config.js'
|
||||
import { styled } from '../../stitches.config'
|
||||
import { State, CustomizationOptions } from './types'
|
||||
|
||||
type ErrorMessage = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const logoStyle = {
|
||||
height: '3rem'
|
||||
}
|
||||
|
||||
const Container = styled('div', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})
|
||||
|
||||
const HeaderText = styled('h2', {
|
||||
fontSize: '1.875rem',
|
||||
fontWeight: '700',
|
||||
marginTop: '1.5rem'
|
||||
})
|
||||
|
||||
const SocialAuth = styled('div', {
|
||||
marginTop: '1.5rem'
|
||||
})
|
||||
|
||||
const SocialAuthLabel = styled('div', {
|
||||
fontWeight: '500',
|
||||
fontSize: '$sm'
|
||||
})
|
||||
|
||||
const SocialAuthButtons = styled('div', {
|
||||
marginTop: '0.5rem',
|
||||
display: 'flex',
|
||||
|
||||
variants: {
|
||||
direction: {
|
||||
horizontal: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(48px, 1fr))',
|
||||
},
|
||||
vertical: {
|
||||
flexDirection: 'column',
|
||||
margin: '8px 0',
|
||||
}
|
||||
},
|
||||
gap: {
|
||||
small: {
|
||||
gap: '4px',
|
||||
},
|
||||
medium: {
|
||||
gap: '8px',
|
||||
},
|
||||
large: {
|
||||
gap: '16px',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const OrContinueWith = styled('div', {
|
||||
position: 'relative',
|
||||
marginTop: '1.5rem'
|
||||
})
|
||||
|
||||
const OrContinueWithLineContainer = styled('div', {
|
||||
position: 'absolute',
|
||||
inset: '0px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
})
|
||||
|
||||
const OrContinueWithLine = styled('div', {
|
||||
width: '100%',
|
||||
borderTopWidth: '1px',
|
||||
borderColor: '$gray500'
|
||||
})
|
||||
|
||||
const OrContinueWithTextContainer = styled('div', {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
fontSize: '$sm'
|
||||
})
|
||||
|
||||
const OrContinueWithText = styled('span', {
|
||||
backgroundColor: 'white',
|
||||
paddingLeft: '0.5rem',
|
||||
paddingRight: '0.5rem'
|
||||
})
|
||||
|
||||
// Email/username form
|
||||
|
||||
const UserPassForm = styled('form', {
|
||||
marginTop: '1.5rem'
|
||||
})
|
||||
|
||||
const FormItemGroup = styled('div', {
|
||||
'& + div': {
|
||||
marginTop: '1.5rem'
|
||||
}
|
||||
})
|
||||
|
||||
const FormLabel = styled('label', {
|
||||
display: 'block',
|
||||
fontSize: '$sm',
|
||||
fontWeight: '500'
|
||||
})
|
||||
|
||||
const FormInput = styled('input', {
|
||||
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',
|
||||
|
||||
marginTop: '0.5rem'
|
||||
})
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
const Message = styled('div', {
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.375rem',
|
||||
marginTop: '1rem',
|
||||
background: '$gray400',
|
||||
})
|
||||
|
||||
const ErrorMessage = styled(Message, {
|
||||
background: '$errorBackground',
|
||||
color: '$errorText',
|
||||
})
|
||||
|
||||
const SuccessMessage = styled(Message, {
|
||||
background: '$successBackground',
|
||||
color: '$successText',
|
||||
})
|
||||
|
||||
{=# isGoogleAuthEnabled =}
|
||||
const googleSignInUrl = `${config.apiUrl}{= googleSignInPath =}`
|
||||
{=/ isGoogleAuthEnabled =}
|
||||
{=# isGitHubAuthEnabled =}
|
||||
const gitHubSignInUrl = `${config.apiUrl}{= gitHubSignInPath =}`
|
||||
{=/ isGitHubAuthEnabled =}
|
||||
|
||||
function Auth ({ state, appearance, logo, socialLayout = 'horizontal' }: {
|
||||
state: State;
|
||||
} & CustomizationOptions) {
|
||||
const isLogin = state === "login";
|
||||
const [errorMessage, setErrorMessage] = useState<ErrorMessage | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
const history = useHistory();
|
||||
const onErrorHandler = (error) => {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
};
|
||||
{=/ isAnyPasswordBasedAuthEnabled =}
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
const { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal } = useUsernameAndPassword({
|
||||
isLogin,
|
||||
onError: onErrorHandler,
|
||||
onSuccess() {
|
||||
// Redirect to configured page, defaults to /.
|
||||
history.push('{= onAuthSucceededRedirectTo =}')
|
||||
},
|
||||
});
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
const { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal } = useEmail({
|
||||
isLogin,
|
||||
onError: onErrorHandler,
|
||||
showEmailVerificationPending() {
|
||||
setSuccessMessage(`You've signed up successfully! Check your email for the confirmation link.`)
|
||||
},
|
||||
onLoginSuccess() {
|
||||
// Redirect to configured page, defaults to /.
|
||||
history.push('{= onAuthSucceededRedirectTo =}')
|
||||
}
|
||||
});
|
||||
{=/ isEmailAuthEnabled =}
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
async function onSubmit (event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
await handleSubmit();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
{=/ isAnyPasswordBasedAuthEnabled =}
|
||||
|
||||
// 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 cta = isLogin ? 'Log in' : 'Sign up'
|
||||
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'
|
||||
|
||||
const loginSignupForm = (<>
|
||||
{=# isExternalAuthEnabled =}
|
||||
<SocialAuth>
|
||||
<SocialAuthLabel>{cta} with</SocialAuthLabel>
|
||||
<SocialAuthButtons gap='large' direction={socialButtonsDirection}>
|
||||
{=# isGoogleAuthEnabled =}
|
||||
<SocialButton href={googleSignInUrl}><SocialIcons.Google/></SocialButton>
|
||||
{=/ isGoogleAuthEnabled =}
|
||||
|
||||
{=# isGitHubAuthEnabled =}
|
||||
<SocialButton href={gitHubSignInUrl}><SocialIcons.GitHub/></SocialButton>
|
||||
{=/ isGitHubAuthEnabled =}
|
||||
</SocialAuthButtons>
|
||||
</SocialAuth>
|
||||
{=/ isExternalAuthEnabled =}
|
||||
|
||||
{=# areBothSocialAndPasswordBasedAuthEnabled =}
|
||||
<OrContinueWith>
|
||||
<OrContinueWithLineContainer>
|
||||
<OrContinueWithLine/>
|
||||
</OrContinueWithLineContainer>
|
||||
<OrContinueWithTextContainer>
|
||||
<OrContinueWithText>Or continue with</OrContinueWithText>
|
||||
</OrContinueWithTextContainer>
|
||||
</OrContinueWith>
|
||||
{=/ areBothSocialAndPasswordBasedAuthEnabled =}
|
||||
{=# isAnyPasswordBasedAuthEnabled =}
|
||||
<UserPassForm onSubmit={onSubmit}>
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormInput
|
||||
type="text"
|
||||
required
|
||||
value={usernameFieldVal}
|
||||
onChange={e => setUsernameFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<FormInput
|
||||
type="email"
|
||||
required
|
||||
value={emailFieldVal}
|
||||
onChange={e => setEmailFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
{=/ isEmailAuthEnabled =}
|
||||
<FormItemGroup>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormInput
|
||||
type="password"
|
||||
required
|
||||
value={passwordFieldVal}
|
||||
onChange={e => setPasswordFieldVal(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>{cta}</SubmitButton>
|
||||
</FormItemGroup>
|
||||
</UserPassForm>
|
||||
{=/ isAnyPasswordBasedAuthEnabled =}
|
||||
</>)
|
||||
|
||||
return (
|
||||
<Container className={customTheme}>
|
||||
<div>
|
||||
{logo && (
|
||||
<img style={logoStyle} src={logo} alt='Your Company' />
|
||||
)}
|
||||
<HeaderText>{title}</HeaderText>
|
||||
</div>
|
||||
|
||||
{errorMessage && (<ErrorMessage>
|
||||
{errorMessage.title}{errorMessage.description && ': '}{errorMessage.description}
|
||||
</ErrorMessage>)}
|
||||
{successMessage && <SuccessMessage>{successMessage}</SuccessMessage>}
|
||||
{(state === 'login' || state === 'signup') && loginSignupForm}
|
||||
{=# isEmailAuthEnabled =}
|
||||
{state === 'forgot-password' && (<ForgotPasswordForm
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
setErrorMessage={setErrorMessage}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>)}
|
||||
{state === 'reset-password' && (<ResetPasswordForm
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
setErrorMessage={setErrorMessage}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>)}
|
||||
{state === 'verify-email' && (<VerifyEmailForm
|
||||
isLoading={isLoading}
|
||||
setIsLoading={setIsLoading}
|
||||
setErrorMessage={setErrorMessage}
|
||||
setSuccessMessage={setSuccessMessage}
|
||||
/>)}
|
||||
{=/ isEmailAuthEnabled =}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default Auth;
|
||||
|
||||
{=# isEmailAuthEnabled =}
|
||||
const ForgotPasswordForm = (
|
||||
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
|
||||
setSuccessMessage: (successMessage: string | null) => void;
|
||||
},
|
||||
) => {
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
try {
|
||||
await requestPasswordReset({ email })
|
||||
setSuccessMessage('Check your email for a password reset link.')
|
||||
setEmail('')
|
||||
} catch (error) {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserPassForm onSubmit={onSubmit}>
|
||||
<FormItemGroup>
|
||||
<FormLabel>E-mail</FormLabel>
|
||||
<FormInput
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>Send password reset email</SubmitButton>
|
||||
</FormItemGroup>
|
||||
</UserPassForm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ResetPasswordForm = (
|
||||
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
|
||||
setSuccessMessage: (successMessage: string | null) => void;
|
||||
},
|
||||
) => {
|
||||
const location = useLocation()
|
||||
const token = new URLSearchParams(location.search).get('token')
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!token) {
|
||||
setErrorMessage({ title: 'The token is missing from the URL. Please check the link you received in your email.' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!password || password !== passwordConfirmation) {
|
||||
setErrorMessage({ title: `Passwords don't match!` })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
try {
|
||||
await resetPassword({ password, token })
|
||||
setSuccessMessage('Your password has been reset.')
|
||||
setPassword('')
|
||||
setPasswordConfirmation('')
|
||||
} catch (error) {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserPassForm onSubmit={onSubmit}>
|
||||
<FormItemGroup>
|
||||
<FormLabel>New password</FormLabel>
|
||||
<FormInput
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<FormLabel>Confirm new password</FormLabel>
|
||||
<FormInput
|
||||
type="password"
|
||||
required
|
||||
value={passwordConfirmation}
|
||||
onChange={e => setPasswordConfirmation(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormItemGroup>
|
||||
<FormItemGroup>
|
||||
<SubmitButton type="submit" disabled={isLoading}>Reset password</SubmitButton>
|
||||
</FormItemGroup>
|
||||
</UserPassForm>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const VerifyEmailForm = (
|
||||
{ isLoading, setIsLoading, setErrorMessage, setSuccessMessage }: {
|
||||
isLoading: boolean;
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
setErrorMessage: (errorMessage: ErrorMessage | null) => void;
|
||||
setSuccessMessage: (successMessage: string | null) => void;
|
||||
},
|
||||
) => {
|
||||
const location = useLocation()
|
||||
const token = new URLSearchParams(location.search).get('token')
|
||||
|
||||
async function submitForm() {
|
||||
if (!token) {
|
||||
setErrorMessage('The token is missing from the URL. Please check the link you received in your email.')
|
||||
return
|
||||
}
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
try {
|
||||
await verifyEmail({ token })
|
||||
setSuccessMessage('Your email has been verified. You can now log in.')
|
||||
} catch (error) {
|
||||
setErrorMessage({ title: error.message, description: error.data?.data?.message })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
submitForm()
|
||||
}, [location])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <Message>Verifying email...</Message>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{=/ isEmailAuthEnabled =}
|
||||
|
||||
{=# isUsernameAndPasswordAuthEnabled =}
|
||||
function useUsernameAndPassword({
|
||||
onError,
|
||||
onSuccess,
|
||||
isLogin,
|
||||
}: {
|
||||
onError: (error: Error) => void;
|
||||
onSuccess: () => void;
|
||||
isLogin: boolean;
|
||||
}) {
|
||||
const [usernameFieldVal, setUsernameFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
async function handleSubmit () {
|
||||
try {
|
||||
if (!isLogin) {
|
||||
await signup({ username: usernameFieldVal, password: passwordFieldVal })
|
||||
}
|
||||
await login (usernameFieldVal, passwordFieldVal)
|
||||
|
||||
setUsernameFieldVal('')
|
||||
setPasswordFieldVal('')
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
onError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return { handleSubmit, usernameFieldVal, passwordFieldVal, setUsernameFieldVal, setPasswordFieldVal }
|
||||
}
|
||||
{=/ isUsernameAndPasswordAuthEnabled =}
|
||||
{=# isEmailAuthEnabled =}
|
||||
function useEmail({
|
||||
onError,
|
||||
showEmailVerificationPending,
|
||||
onLoginSuccess,
|
||||
isLogin,
|
||||
}: {
|
||||
onError: (error: Error) => void;
|
||||
showEmailVerificationPending: () => void;
|
||||
onLoginSuccess: () => void;
|
||||
isLogin: boolean;
|
||||
}) {
|
||||
const [emailFieldVal, setEmailFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
async function handleSubmit () {
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login({ email: emailFieldVal, password: passwordFieldVal })
|
||||
onLoginSuccess()
|
||||
} else {
|
||||
await signup({ email: emailFieldVal, password: passwordFieldVal })
|
||||
{=# isEmailVerificationRequired =}
|
||||
showEmailVerificationPending()
|
||||
{=/ isEmailVerificationRequired =}
|
||||
{=^ isEmailVerificationRequired =}
|
||||
await login ({ email: emailFieldVal, password: passwordFieldVal})
|
||||
onLoginSuccess()
|
||||
{=/ isEmailVerificationRequired =}
|
||||
}
|
||||
|
||||
setEmailFieldVal('')
|
||||
setPasswordFieldVal('')
|
||||
} catch (err: unknown) {
|
||||
onError(err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return { handleSubmit, emailFieldVal, passwordFieldVal, setEmailFieldVal, setPasswordFieldVal }
|
||||
}
|
||||
{=/ isEmailAuthEnabled =}
|
@ -0,0 +1,13 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function ForgotPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.ForgotPassword}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
{{={= =}=}}
|
||||
import React, { useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import login from '../login.js'
|
||||
import { errorMessage } from '../../utils.js'
|
||||
|
||||
const LoginForm = () => {
|
||||
const history = useHistory()
|
||||
|
||||
const [usernameFieldVal, setUsernameFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
await login(usernameFieldVal, passwordFieldVal)
|
||||
// Redirect to configured page, defaults to /.
|
||||
history.push('{= onAuthSucceededRedirectTo =}')
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
window.alert(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLogin} className="login-form auth-form">
|
||||
<h2>Username</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={usernameFieldVal}
|
||||
onChange={e => setUsernameFieldVal(e.target.value)}
|
||||
/>
|
||||
<h2>Password</h2>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordFieldVal}
|
||||
onChange={e => setPasswordFieldVal(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<input type="submit" value="Log in"/>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginForm
|
@ -0,0 +1,13 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function LoginForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.Login}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function ResetPasswordForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.ResetPassword}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
{{={= =}=}}
|
||||
import React, { useState } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import signup from '../signup.js'
|
||||
import login from '../login.js'
|
||||
import { errorMessage } from '../../utils.js'
|
||||
|
||||
const SignupForm = () => {
|
||||
const history = useHistory()
|
||||
|
||||
const [usernameFieldVal, setUsernameFieldVal] = useState('')
|
||||
const [passwordFieldVal, setPasswordFieldVal] = useState('')
|
||||
|
||||
const handleSignup = async (event) => {
|
||||
event.preventDefault()
|
||||
try {
|
||||
await signup({ username: usernameFieldVal, password: passwordFieldVal })
|
||||
await login (usernameFieldVal, passwordFieldVal)
|
||||
|
||||
setUsernameFieldVal('')
|
||||
setPasswordFieldVal('')
|
||||
|
||||
// Redirect to configured page, defaults to /.
|
||||
history.push('{= onAuthSucceededRedirectTo =}')
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
window.alert(errorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSignup} className='signup-form auth-form'>
|
||||
<h2>Username</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={usernameFieldVal}
|
||||
onChange={e => setUsernameFieldVal(e.target.value)}
|
||||
/>
|
||||
<h2>Password</h2>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordFieldVal}
|
||||
onChange={e => setPasswordFieldVal(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<input type="submit" value="Sign up"/>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default SignupForm
|
@ -0,0 +1,13 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function SignupForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.Signup}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import { styled } from '../../stitches.config'
|
||||
|
||||
export const SocialButton = styled('a', {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
|
||||
cursor: 'pointer',
|
||||
// NOTE(matija): icon is otherwise blue, since that
|
||||
// is link's default font color.
|
||||
color: 'inherit',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '0.375rem',
|
||||
borderWidth: '1px',
|
||||
borderColor: '$gray600',
|
||||
fontSize: '13px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
boxShadow: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
'&:visited': {
|
||||
color: 'inherit',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: '$gray500',
|
||||
color: 'inherit',
|
||||
},
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
transitionDuration: '100ms'
|
||||
})
|
@ -0,0 +1,34 @@
|
||||
import { css } from '@stitches/react'
|
||||
|
||||
const defaultStyles = css({
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
})
|
||||
|
||||
export const Google = () => (
|
||||
<svg
|
||||
className={defaultStyles()}
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<g id="brand" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<g id="google" fill="#000000" fillRule="nonzero">
|
||||
<path d="M11.99,13.9 L11.99,10.18 L21.35,10.18 C21.49,10.81 21.6,11.4 21.6,12.23 C21.6,17.94 17.77,22 12,22 C6.48,22 2,17.52 2,12 C2,6.48 6.48,2 12,2 C14.7,2 16.96,2.99 18.69,4.61 L15.85,7.37 C15.13,6.69 13.87,5.89 12,5.89 C8.69,5.89 5.99,8.64 5.99,12.01 C5.99,15.38 8.69,18.13 12,18.13 C15.83,18.13 17.24,15.48 17.5,13.91 L11.99,13.91 L11.99,13.9 Z" id="Shape">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const GitHub = () => (
|
||||
<svg
|
||||
className={defaultStyles()}
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
@ -0,0 +1,13 @@
|
||||
import Auth from './Auth'
|
||||
import { type CustomizationOptions, State } from './types'
|
||||
|
||||
export function VerifyEmailForm ({ appearance, logo, socialLayout }: CustomizationOptions) {
|
||||
return (
|
||||
<Auth
|
||||
appearance={appearance}
|
||||
logo={logo}
|
||||
socialLayout={socialLayout}
|
||||
state={State.VerifyEmail}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import { createTheme } from '@stitches/react'
|
||||
|
||||
export enum State {
|
||||
Login = "login",
|
||||
Signup = "signup",
|
||||
ForgotPassword = "forgot-password",
|
||||
ResetPassword = "reset-password",
|
||||
VerifyEmail = "verify-email",
|
||||
}
|
||||
|
||||
export type CustomizationOptions = {
|
||||
logo?: string;
|
||||
socialLayout?: "horizontal" | "vertical";
|
||||
appearance?: Parameters<typeof createTheme>[0];
|
||||
}
|
@ -1,45 +1,15 @@
|
||||
{{={= =}=}}
|
||||
|
||||
import config from '../../config.js'
|
||||
import { SocialButton } from '../forms/SocialButton'
|
||||
import * as SocialIcons from '../forms/SocialIcons'
|
||||
|
||||
export const signInUrl = `${config.apiUrl}{= signInPath =}`
|
||||
export const logoUrl = '/images/{= iconName =}'
|
||||
|
||||
const containerStyle = {
|
||||
border: '2px solid #cbd5e1',
|
||||
margin: 0,
|
||||
cursor: 'pointer',
|
||||
borderRadius: '.375rem',
|
||||
backgroundColor: '#f8fafc',
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
|
||||
outline: '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}
|
||||
|
||||
const linkStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
textDecoration: 'none',
|
||||
color: '#1e293b',
|
||||
paddingLeft: '1.5rem',
|
||||
paddingRight: '1.5rem',
|
||||
paddingTop: '.75rem',
|
||||
paddingBottom: '.75rem',
|
||||
}
|
||||
|
||||
const logoStyle = {
|
||||
maxHeight: '24px',
|
||||
marginRight: '0.75rem'
|
||||
}
|
||||
|
||||
export function SignInButton() {
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<a href={signInUrl} style={linkStyle}>
|
||||
<img alt="{= displayName =} Icon" src={logoUrl} style={logoStyle} />
|
||||
<span>Log in with {= displayName =}</span>
|
||||
</a>
|
||||
</div>
|
||||
<SocialButton href={signInUrl}>
|
||||
<SocialIcons.{= displayName =} />
|
||||
</SocialButton>
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { setAuthToken } from '../../api.js'
|
||||
import { setAuthToken } from '../../api'
|
||||
import { invalidateAndRemoveQueries } from '../../operations/resources'
|
||||
|
||||
export async function initSession(token: string): Promise<void> {
|
||||
|
@ -1,10 +1,11 @@
|
||||
import api, { handleApiError } from '../api.js'
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../api'
|
||||
import { initSession } from './helpers/user'
|
||||
|
||||
export default async function login(username, password) {
|
||||
try {
|
||||
const args = { username, password }
|
||||
const response = await api.post('/auth/login', args)
|
||||
const response = await api.post('{= loginPath =}', args)
|
||||
|
||||
await initSession(response.data.token)
|
||||
} catch (error) {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { clearLocalStorage } from '../api.js'
|
||||
import { removeLocalUserData } from '../api'
|
||||
import { invalidateAndRemoveQueries } from '../operations/resources'
|
||||
|
||||
export default async function logout() {
|
||||
clearLocalStorage()
|
||||
removeLocalUserData()
|
||||
// TODO(filip): We are currently invalidating and removing all the queries, but
|
||||
// we should remove only the non-public, user-dependent ones.
|
||||
await invalidateAndRemoveQueries()
|
||||
|
@ -3,7 +3,7 @@ import React, { useEffect } from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
|
||||
import config from '../../config.js'
|
||||
import api from '../../api.js'
|
||||
import api from '../../api'
|
||||
import { initSession } from '../helpers/user'
|
||||
|
||||
// After a user authenticates via an Oauth 2.0 provider, this is the page that
|
||||
|
@ -2,7 +2,7 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Redirect } from 'react-router-dom'
|
||||
import useAuth from '../useAuth.js'
|
||||
import useAuth from '../useAuth'
|
||||
|
||||
|
||||
const createAuthRequiredPage = (Page) => {
|
||||
|
@ -1,8 +1,9 @@
|
||||
import api, { handleApiError } from '../api.js'
|
||||
{{={= =}=}}
|
||||
import api, { handleApiError } from '../api'
|
||||
|
||||
export default async function signup(userFields) {
|
||||
try {
|
||||
await api.post('/auth/signup', userFields)
|
||||
await api.post('{= signupPath =}', userFields)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
|
@ -1,21 +0,0 @@
|
||||
import { useQuery } from '../queries'
|
||||
import api, { handleApiError } from '../api.js'
|
||||
|
||||
export default function useAuth(queryFnArgs, config) {
|
||||
return useQuery(getMe, queryFnArgs, config)
|
||||
}
|
||||
async function getMe() {
|
||||
try {
|
||||
const response = await api.get('/auth/me')
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
return null
|
||||
} else {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getMe.queryCacheKey = ['auth/me']
|
40
waspc/data/Generator/templates/react-app/src/auth/useAuth.ts
Normal file
40
waspc/data/Generator/templates/react-app/src/auth/useAuth.ts
Normal file
@ -0,0 +1,40 @@
|
||||
{{={= =}=}}
|
||||
import { deserialize as superjsonDeserialize } from 'superjson'
|
||||
import { useQuery } from '../queries'
|
||||
import api, { handleApiError } from '../api'
|
||||
import { HttpMethod } from '../types'
|
||||
// todo(filip): turn into a proper import
|
||||
import { type SanitizedUser as User } from '../../../server/src/_types/'
|
||||
import { addMetadataToQuery } from '../queries/core'
|
||||
|
||||
export const getMe = createUserGetter()
|
||||
|
||||
export default function useAuth(queryFnArgs?: unknown, config?: any) {
|
||||
return useQuery(getMe, queryFnArgs, config)
|
||||
}
|
||||
|
||||
function createUserGetter() {
|
||||
const getMeRelativePath = 'auth/me'
|
||||
const getMeRoute = { method: HttpMethod.Get, path: `/${getMeRelativePath}` }
|
||||
async function getMe(): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.get(getMeRoute.path)
|
||||
|
||||
return superjsonDeserialize(response.data)
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
return null
|
||||
} else {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addMetadataToQuery(getMe, {
|
||||
relativeQueryPath: getMeRelativePath,
|
||||
queryRoute: getMeRoute,
|
||||
entitiesUsed: {=& entitiesGetMeDependsOn =},
|
||||
})
|
||||
|
||||
return getMe
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import api, { handleApiError } from '../api.js'
|
||||
|
||||
export async function callOperation(operationRoute, args) {
|
||||
try {
|
||||
const response = await api.post(`/${operationRoute}`, args)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,22 @@
|
||||
import api, { handleApiError } from '../api'
|
||||
import { HttpMethod } from '../types'
|
||||
import {
|
||||
serialize as superjsonSerialize,
|
||||
deserialize as superjsonDeserialize,
|
||||
} from 'superjson'
|
||||
|
||||
export type OperationRoute = { method: HttpMethod, path: string }
|
||||
|
||||
export async function callOperation(operationRoute: OperationRoute & { method: HttpMethod.Post }, args: any) {
|
||||
try {
|
||||
const superjsonArgs = superjsonSerialize(args)
|
||||
const response = await api.post(operationRoute.path, superjsonArgs)
|
||||
return superjsonDeserialize(response.data)
|
||||
} catch (error) {
|
||||
handleApiError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export function makeOperationRoute(relativeOperationRoute: string): OperationRoute {
|
||||
return { method: HttpMethod.Post, path: `/${relativeOperationRoute}` }
|
||||
}
|
@ -11,7 +11,7 @@ const updateHandlers = makeUpdateHandlersMap(hashQueryKey)
|
||||
/**
|
||||
* Remembers that specified query is using specified resources.
|
||||
* If called multiple times for same query, resources are added, not reset.
|
||||
* @param {string} queryCacheKey - Unique key under used to identify query in the cache.
|
||||
* @param {string[]} queryCacheKey - Unique key under used to identify query in the cache.
|
||||
* @param {string[]} resources - Names of resources that query is using.
|
||||
*/
|
||||
export function addResourcesUsedByQuery(queryCacheKey, resources) {
|
||||
|
@ -1,7 +0,0 @@
|
||||
{{={= =}=}}
|
||||
import { createQuery } from './core'
|
||||
|
||||
export default createQuery(
|
||||
'{= queryRoute =}',
|
||||
{=& entitiesArray =},
|
||||
)
|
@ -0,0 +1,11 @@
|
||||
{{={= =}=}}
|
||||
import { createQuery } from './core'
|
||||
{=& operationTypeImportStmt =}
|
||||
|
||||
|
||||
const query = createQuery<{= operationTypeName =}>(
|
||||
'{= queryRoute =}',
|
||||
{=& entitiesArray =},
|
||||
)
|
||||
|
||||
export default query
|
@ -1,3 +1,23 @@
|
||||
export type Query<Input, Output> = (args: Input) => Promise<Output>
|
||||
import { type Query } from '.'
|
||||
import { Route } from '../types';
|
||||
import type { Expand, _Awaited, _ReturnType } from '../universal/types'
|
||||
|
||||
export function createQuery<Input, Output>(queryRoute: string, entitiesUsed: any[]): Query<Input, Output>
|
||||
export function createQuery<BackendQuery extends GenericBackendQuery>(
|
||||
queryRoute: string,
|
||||
entitiesUsed: any[]
|
||||
): QueryFor<BackendQuery>
|
||||
|
||||
export function addMetadataToQuery(
|
||||
query: (...args: any[]) => Promise<unknown>,
|
||||
metadata: {
|
||||
relativeQueryPath: string;
|
||||
queryRoute: Route;
|
||||
entitiesUsed: string[];
|
||||
},
|
||||
): void
|
||||
|
||||
type QueryFor<BackendQuery extends GenericBackendQuery> = Expand<
|
||||
Query<Parameters<BackendQuery>[0], _Awaited<_ReturnType<BackendQuery>>>
|
||||
>
|
||||
|
||||
type GenericBackendQuery = (args: never, context: any) => unknown
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { callOperation } from '../operations'
|
||||
import { callOperation, makeOperationRoute } from '../operations'
|
||||
import {
|
||||
addResourcesUsedByQuery,
|
||||
getActiveOptimisticUpdates,
|
||||
} from '../operations/resources'
|
||||
|
||||
export function createQuery(queryRoute, entitiesUsed) {
|
||||
export function createQuery(relativeQueryPath, entitiesUsed) {
|
||||
const queryRoute = makeOperationRoute(relativeQueryPath)
|
||||
|
||||
async function query(queryKey, queryArgs) {
|
||||
const serverResult = await callOperation(queryRoute, queryArgs)
|
||||
return getActiveOptimisticUpdates(queryKey).reduce(
|
||||
@ -13,8 +15,13 @@ export function createQuery(queryRoute, entitiesUsed) {
|
||||
)
|
||||
}
|
||||
|
||||
query.queryCacheKey = [queryRoute]
|
||||
addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
|
||||
addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed })
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
export function addMetadataToQuery(query, { relativeQueryPath, queryRoute, entitiesUsed }) {
|
||||
query.queryCacheKey = [relativeQueryPath]
|
||||
query.route = queryRoute
|
||||
addResourcesUsedByQuery(query.queryCacheKey, entitiesUsed)
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { UseQueryResult } from "@tanstack/react-query";
|
||||
|
||||
export type Query<Input, Output> = (args: Input) => Promise<Output>
|
||||
export type Query<Input, Output> = {
|
||||
(queryCacheKey: string[], args: Input): Promise<Output>
|
||||
}
|
||||
|
||||
export function useQuery<Input, Output, Error = unknown>(
|
||||
export function useQuery<Input, Output>(
|
||||
queryFn: Query<Input, Output>,
|
||||
queryFnArgs?: Input, options?: any
|
||||
): UseQueryResult<Output, Error>
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { createStitches } from '@stitches/react'
|
||||
|
||||
export const {
|
||||
styled,
|
||||
css
|
||||
} = createStitches({
|
||||
theme: {
|
||||
colors: {
|
||||
waspYellow: '#ffcc00',
|
||||
gray700: '#a1a5ab',
|
||||
gray600: '#d1d5db',
|
||||
gray500: 'gainsboro',
|
||||
gray400: '#f0f0f0',
|
||||
red: '#FED7D7',
|
||||
green: '#C6F6D5',
|
||||
|
||||
brand: '$waspYellow',
|
||||
brandAccent: '#ffdb46',
|
||||
errorBackground: '$red',
|
||||
errorText: '#2D3748',
|
||||
successBackground: '$green',
|
||||
successText: '#2D3748',
|
||||
|
||||
submitButtonText: 'black',
|
||||
|
||||
},
|
||||
fontSizes: {
|
||||
sm: '0.875rem'
|
||||
}
|
||||
}
|
||||
})
|
44
waspc/data/Generator/templates/react-app/src/storage.ts
Normal file
44
waspc/data/Generator/templates/react-app/src/storage.ts
Normal file
@ -0,0 +1,44 @@
|
||||
export type DataStore = {
|
||||
set(key: string, value: unknown): void;
|
||||
get(key: string): unknown;
|
||||
remove(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
|
||||
function createLocalStorageDataStore(prefix: string): DataStore {
|
||||
return {
|
||||
set(key, value) {
|
||||
ensureLocalStorageIsAvailable();
|
||||
localStorage.setItem(`${prefix}:${key}`, JSON.stringify(value));
|
||||
},
|
||||
get(key) {
|
||||
ensureLocalStorageIsAvailable();
|
||||
const value = localStorage.getItem(`${prefix}:${key}`);
|
||||
try {
|
||||
return value ? JSON.parse(value) : undefined;
|
||||
} catch (e: any) {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
ensureLocalStorageIsAvailable();
|
||||
localStorage.removeItem(`${prefix}:${key}`);
|
||||
},
|
||||
clear() {
|
||||
ensureLocalStorageIsAvailable();
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (key.startsWith(prefix)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const storage = createLocalStorageDataStore('wasp');
|
||||
|
||||
function ensureLocalStorageIsAvailable(): void {
|
||||
if (!window.localStorage) {
|
||||
throw new Error('Local storage is not available.');
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { renderInContext, mockServer } from './vitest/helpers'
|
@ -0,0 +1,93 @@
|
||||
import { ReactElement } from 'react'
|
||||
import { serialize as superjsonSerialize } from 'superjson'
|
||||
import { rest, type ResponseResolver, type RestContext } from 'msw'
|
||||
import { setupServer, type SetupServer } from 'msw/node'
|
||||
import { BrowserRouter as Router } from 'react-router-dom'
|
||||
import { render, RenderResult, cleanup } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { beforeAll, afterEach, afterAll } from 'vitest'
|
||||
import { Query } from '../../queries'
|
||||
import config from '../../config'
|
||||
import { HttpMethod, Route } from '../../types'
|
||||
|
||||
export { Route } from '../../types'
|
||||
|
||||
export type MockQuery = <Input, Output, MockOutput extends Output>(
|
||||
query: Query<Input, Output>,
|
||||
resJson: MockOutput
|
||||
) => void
|
||||
|
||||
export type MockApi = (route: Route, resJson: unknown) => void
|
||||
|
||||
// Inspired by the Tanstack React Query helper:
|
||||
// https://github.com/TanStack/query/blob/4ae99561ca3383d6de3f4aad656a49ba4a17b57a/packages/react-query/src/__tests__/utils.tsx#L7-L26
|
||||
export function renderInContext(ui: ReactElement): RenderResult {
|
||||
const client = new QueryClient()
|
||||
const { rerender, ...result } = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<Router>{ui}</Router>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
rerender: (rerenderUi: ReactElement) =>
|
||||
rerender(
|
||||
<QueryClientProvider client={client}>
|
||||
<Router>{rerenderUi}</Router>
|
||||
</QueryClientProvider>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function mockServer(): {
|
||||
server: SetupServer
|
||||
mockQuery: MockQuery
|
||||
mockApi: MockApi
|
||||
} {
|
||||
const server: SetupServer = setupServer()
|
||||
|
||||
beforeAll(() => server.listen())
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
cleanup()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
const mockQuery: MockQuery = (query, mockData) => {
|
||||
const route = (query as unknown as { route: Route }).route
|
||||
mockRoute(server, route, (_req, res, ctx) =>
|
||||
res(ctx.json(superjsonSerialize(mockData)))
|
||||
)
|
||||
}
|
||||
|
||||
const mockApi: MockApi = (route, mockData) => {
|
||||
mockRoute(server, route, (_req, res, ctx) => res(ctx.json(mockData)))
|
||||
}
|
||||
|
||||
return { server, mockQuery, mockApi }
|
||||
}
|
||||
|
||||
function mockRoute(
|
||||
server: SetupServer,
|
||||
route: Route,
|
||||
responseHandler: ResponseResolver<any, RestContext, any>
|
||||
) {
|
||||
if (!Object.values(HttpMethod).includes(route.method)) {
|
||||
throw new Error(
|
||||
`Unsupported query method for mocking: ${
|
||||
route.method
|
||||
}. Supported method strings are: ${Object.values(HttpMethod).join(', ')}.`
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${config.apiUrl}${route.path}`
|
||||
|
||||
const handlers: Record<HttpMethod, Parameters<typeof server.use>[0]> = {
|
||||
[HttpMethod.Get]: rest.get(url, responseHandler),
|
||||
[HttpMethod.Post]: rest.post(url, responseHandler),
|
||||
[HttpMethod.Put]: rest.put(url, responseHandler),
|
||||
[HttpMethod.Delete]: rest.delete(url, responseHandler),
|
||||
}
|
||||
|
||||
server.use(handlers[route.method])
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
import matchers from '@testing-library/jest-dom/matchers'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
expect.extend(matchers)
|
9
waspc/data/Generator/templates/react-app/src/types.ts
Normal file
9
waspc/data/Generator/templates/react-app/src/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// NOTE: This is enough to cover Operations and our APIs (src/Wasp/AppSpec/Api.hs).
|
||||
export enum HttpMethod {
|
||||
Get = 'GET',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
Delete = 'DELETE',
|
||||
}
|
||||
|
||||
export type Route = { method: HttpMethod; path: string }
|
@ -2,6 +2,7 @@
|
||||
"extends": "@tsconfig/vite-react/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// Temporary loosen the type checking until we can address all the errors.
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"strict": false
|
||||
},
|
||||
|
@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
@ -6,9 +7,15 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
open: true,
|
||||
},
|
||||
envPrefix: 'REACT_APP_',
|
||||
build: {
|
||||
outDir: 'build',
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/vitest/setup.ts'],
|
||||
},
|
||||
})
|
||||
|
@ -10,8 +10,8 @@
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"watch": "nodemon --exec 'npm run build-and-start || exit 1'",
|
||||
"validate-env": "node -r dotenv/config ./scripts/validate-env.mjs",
|
||||
"db-seed": "npm run build && NODE_PATH=dist node -r dotenv/config dist/dbSeed.js",
|
||||
"db-migrate-prod": "prisma migrate deploy --schema=../db/schema.prisma",
|
||||
"db-migrate-dev": "prisma migrate dev --schema=../db/schema.prisma",
|
||||
"start-production": "{=& startProductionScript =}",
|
||||
"standard": "standard",
|
||||
"postinstall": "patch-package"
|
||||
@ -30,6 +30,7 @@
|
||||
"engines": {
|
||||
"node": "{=& nodeVersionRange =}"
|
||||
},
|
||||
"prisma": {=& prisma =},
|
||||
{=& depsChunk =},
|
||||
{=& devDepsChunk =}
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
{{={= =}=}}
|
||||
import { type Expand } from "../universal/types.js";
|
||||
import { type Request, type Response } from 'express'
|
||||
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
|
||||
import prisma from "../dbClient.js"
|
||||
{=# isAuthEnabled =}
|
||||
import { type {= userEntityName =} } from "../entities"
|
||||
@ -20,24 +23,40 @@ export type AuthenticatedAction<Entities extends _Entity[], Input, Output> =
|
||||
|
||||
type AuthenticatedOperation<Entities extends _Entity[], Input, Output> = (
|
||||
args: Input,
|
||||
context: Expand<OperationContext<Entities> & {
|
||||
// TODO: This type must match the logic in core/auth.js (if we remove the
|
||||
// password field from the object there, we must do the same here). Ideally,
|
||||
// these two things would live in the same place:
|
||||
// https://github.com/wasp-lang/wasp/issues/965
|
||||
{= userFieldName =}: Omit<{= userEntityName =}, 'password'>
|
||||
}>,
|
||||
) => Promise<Output>
|
||||
context: ContextWithUser<Entities>,
|
||||
) => Output | Promise<Output>
|
||||
|
||||
export type AuthenticatedApi<
|
||||
Entities extends _Entity[],
|
||||
Params extends ExpressParams,
|
||||
ResBody,
|
||||
ReqBody,
|
||||
ReqQuery extends ExpressQuery,
|
||||
Locals extends Record<string, any>
|
||||
> = (
|
||||
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
res: Response<ResBody, Locals>,
|
||||
context: ContextWithUser<Entities>,
|
||||
) => void
|
||||
|
||||
{=/ isAuthEnabled =}
|
||||
type Operation<Entities extends _Entity[], Input, Output> = (
|
||||
args: Input,
|
||||
context: Expand<OperationContext<Entities>>,
|
||||
) => Promise<Output>
|
||||
context: Context<Entities>,
|
||||
) => Output | Promise<Output>
|
||||
|
||||
type OperationContext<Entities extends _Entity[]> = {
|
||||
entities: Expand<EntityMap<Entities>>
|
||||
}
|
||||
export type Api<
|
||||
Entities extends _Entity[],
|
||||
Params extends ExpressParams,
|
||||
ResBody,
|
||||
ReqBody,
|
||||
ReqQuery extends ExpressQuery,
|
||||
Locals extends Record<string, any>
|
||||
> = (
|
||||
req: Request<Params, ResBody, ReqBody, ReqQuery, Locals>,
|
||||
res: Response<ResBody, Locals>,
|
||||
context: Context<Entities>,
|
||||
) => void
|
||||
|
||||
type EntityMap<Entities extends _Entity[]> = {
|
||||
[EntityName in Entities[number]["_entityName"]]: PrismaDelegate[EntityName]
|
||||
@ -49,9 +68,16 @@ type PrismaDelegate = {
|
||||
{=/ entities =}
|
||||
}
|
||||
|
||||
// This is a helper type used exclusively for DX purposes. It's a No-op for the
|
||||
// compiler, but expands the type's representatoin in IDEs (i.e., inlines all
|
||||
// type constructors) to make it more readable for the user.
|
||||
//
|
||||
// Check this SO answer for details: https://stackoverflow.com/a/57683652
|
||||
type Expand<T extends object> = T extends infer O ? { [K in keyof O]: O[K] } : never
|
||||
type Context<Entities extends _Entity[]> = Expand<{
|
||||
entities: Expand<EntityMap<Entities>>
|
||||
}>
|
||||
|
||||
{=# isAuthEnabled =}
|
||||
type ContextWithUser<Entities extends _Entity[]> = Expand<Context<Entities> & { user?: SanitizedUser}>
|
||||
|
||||
// TODO: This type must match the logic in core/auth.js (if we remove the
|
||||
// password field from the object there, we must do the same here). Ideally,
|
||||
// these two things would live in the same place:
|
||||
// https://github.com/wasp-lang/wasp/issues/965
|
||||
export type SanitizedUser = Omit<{= userEntityName =}, 'password'>
|
||||
{=/ isAuthEnabled =}
|
||||
|
@ -1,13 +1,13 @@
|
||||
{{={= =}=}}
|
||||
import prisma from '../dbClient.js'
|
||||
|
||||
{=& jsFnImportStatement =}
|
||||
{=& jsFn.importStatement =}
|
||||
|
||||
{=! TODO: This template is exactly the same at the moment as one for queries,
|
||||
consider in the future if it is worth removing this duplication. =}
|
||||
|
||||
export default async function (args, context) {
|
||||
return {= jsFnIdentifier =}(args, {
|
||||
return ({= jsFn.importIdentifier =} as any)(args, {
|
||||
...context,
|
||||
entities: {
|
||||
{=# entities =}
|
||||
@ -16,3 +16,5 @@ export default async function (args, context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type {= operationTypeName =} = typeof {= jsFn.importIdentifier =}
|
45
waspc/data/Generator/templates/server/src/apis/types.ts
Normal file
45
waspc/data/Generator/templates/server/src/apis/types.ts
Normal file
@ -0,0 +1,45 @@
|
||||
{{={= =}=}}
|
||||
|
||||
import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core'
|
||||
|
||||
export { ParamsDictionary as ExpressParams, Query as ExpressQuery } from 'express-serve-static-core'
|
||||
|
||||
import {
|
||||
{=# allEntities =}
|
||||
type {= internalTypeName =},
|
||||
{=/ allEntities =}
|
||||
{=# shouldImportNonAuthenticatedApi =}
|
||||
type Api,
|
||||
{=/ shouldImportNonAuthenticatedApi =}
|
||||
{=# shouldImportAuthenticatedApi =}
|
||||
type AuthenticatedApi,
|
||||
{=/ shouldImportAuthenticatedApi =}
|
||||
} from '../_types'
|
||||
|
||||
{=# apiRoutes =}
|
||||
export type {= typeName =}<
|
||||
P extends ExpressParams = ExpressParams,
|
||||
ResBody = any,
|
||||
ReqBody = any,
|
||||
ReqQuery extends ExpressQuery = ExpressQuery,
|
||||
Locals extends Record<string, any> = Record<string, any>
|
||||
> =
|
||||
{=# usesAuth =}
|
||||
AuthenticatedApi<
|
||||
{=/ usesAuth =}
|
||||
{=^ usesAuth =}
|
||||
Api<
|
||||
{=/ usesAuth =}
|
||||
[
|
||||
{=# entities =}
|
||||
{= internalTypeName =},
|
||||
{=/ entities =}
|
||||
],
|
||||
P,
|
||||
ResBody,
|
||||
ReqBody,
|
||||
ReqQuery,
|
||||
Locals
|
||||
>
|
||||
|
||||
{=/ apiRoutes =}
|
@ -16,7 +16,7 @@ const app = express()
|
||||
app.use(helmet())
|
||||
app.use(cors({
|
||||
// TODO: Consider allowing users to provide an ENV variable or function to further configure CORS setup.
|
||||
origin: config.frontendUrl,
|
||||
origin: config.allowedCORSOrigins,
|
||||
}))
|
||||
app.use(logger('dev'))
|
||||
app.use(express.json())
|
||||
|
@ -0,0 +1,42 @@
|
||||
{{={= =}=}}
|
||||
|
||||
import { createRouter } from "../oauth/createRouter.js";
|
||||
import { makeOAuthInit } from "../oauth/init.js";
|
||||
|
||||
import type { ProviderConfig } from "../types.js";
|
||||
import type { OAuthConfig } from "../oauth/types.js";
|
||||
|
||||
{=# userFieldsFn.isDefined =}
|
||||
{=& userFieldsFn.importStatement =}
|
||||
const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =}
|
||||
{=/ userFieldsFn.isDefined =}
|
||||
{=^ userFieldsFn.isDefined =}
|
||||
import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js'
|
||||
{=/ userFieldsFn.isDefined =}
|
||||
{=# configFn.isDefined =}
|
||||
{=& configFn.importStatement =}
|
||||
const _waspUserDefinedConfigFn = {= configFn.importIdentifier =}
|
||||
{=/ configFn.isDefined =}
|
||||
{=^ configFn.isDefined =}
|
||||
const _waspUserDefinedConfigFn = undefined
|
||||
{=/ configFn.isDefined =}
|
||||
|
||||
const _waspOAuthConfig: OAuthConfig = {
|
||||
{=# oAuthConfigProps =}
|
||||
{= key =}: {=& value =},
|
||||
{=/ oAuthConfigProps =}
|
||||
};
|
||||
|
||||
const _waspConfig: ProviderConfig = {
|
||||
id: "{= providerId =}",
|
||||
displayName: "{= displayName =}",
|
||||
init: makeOAuthInit({
|
||||
npmPackage: '{= npmPackage =}',
|
||||
getUserFieldsFn: _waspGetUserFieldsFn,
|
||||
userDefinedConfigFn: _waspUserDefinedConfigFn,
|
||||
oAuthConfig: _waspOAuthConfig,
|
||||
}),
|
||||
createRouter,
|
||||
}
|
||||
|
||||
export default _waspConfig;
|
@ -0,0 +1,82 @@
|
||||
{{={= =}=}}
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
|
||||
import { ProviderConfig } from "../types.js";
|
||||
import type { EmailFromField } from '../../../email/core/types.js';
|
||||
|
||||
import { getLoginRoute } from "../email/login.js";
|
||||
import { getSignupRoute } from "../email/signup.js";
|
||||
import { getRequestPasswordResetRoute } from "../email/requestPasswordReset.js";
|
||||
import { resetPassword } from "../email/resetPassword.js";
|
||||
import { verifyEmail } from "../email/verifyEmail.js";
|
||||
import { GetVerificationEmailContentFn, GetPasswordResetEmailContentFn } from "../email/types.js";
|
||||
import { handleRejection } from "../../../utils.js";
|
||||
|
||||
{=# getVerificationEmailContent.isDefined =}
|
||||
{=& getVerificationEmailContent.importStatement =}
|
||||
const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = {= getVerificationEmailContent.importIdentifier =};
|
||||
{=/ getVerificationEmailContent.isDefined =}
|
||||
{=# getPasswordResetEmailContent.isDefined =}
|
||||
{=& getPasswordResetEmailContent.importStatement =}
|
||||
const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = {= getPasswordResetEmailContent.importIdentifier =};
|
||||
{=/ getPasswordResetEmailContent.isDefined =}
|
||||
|
||||
{=^ getVerificationEmailContent.isDefined =}
|
||||
const _waspGetVerificationEmailContent: GetVerificationEmailContentFn = ({ verificationLink }) => ({
|
||||
subject: 'Verify your email',
|
||||
text: `Click the link below to verify your email: ${verificationLink}`,
|
||||
html: `
|
||||
<p>Click the link below to verify your email</p>
|
||||
<a href="${verificationLink}">Verify email</a>
|
||||
`,
|
||||
});
|
||||
{=/ getVerificationEmailContent.isDefined =}
|
||||
{=^ getPasswordResetEmailContent.isDefined =}
|
||||
const _waspGetPasswordResetEmailContent: GetPasswordResetEmailContentFn = ({ passwordResetLink }) => ({
|
||||
subject: 'Reset your password',
|
||||
text: `Click the link below to reset your password: ${passwordResetLink}`,
|
||||
html: `
|
||||
<p>Click the link below to reset your password</p>
|
||||
<a href="${passwordResetLink}">Reset password</a>
|
||||
`,
|
||||
});
|
||||
{=/ getPasswordResetEmailContent.isDefined =}
|
||||
|
||||
const fromField: EmailFromField = {
|
||||
name: '{= fromField.name =}',
|
||||
email: '{= fromField.email =}',
|
||||
};
|
||||
|
||||
const config: ProviderConfig = {
|
||||
id: "{= providerId =}",
|
||||
displayName: "{= displayName =}",
|
||||
createRouter() {
|
||||
const router = Router();
|
||||
|
||||
const loginRoute = handleRejection(getLoginRoute({
|
||||
allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =},
|
||||
}));
|
||||
router.post('/login', loginRoute);
|
||||
|
||||
const signupRoute = handleRejection(getSignupRoute({
|
||||
fromField,
|
||||
clientRoute: '{= emailVerificationClientRoute =}',
|
||||
getVerificationEmailContent: _waspGetVerificationEmailContent,
|
||||
}));
|
||||
router.post('/signup', signupRoute);
|
||||
|
||||
const requestPasswordResetRoute = handleRejection(getRequestPasswordResetRoute({
|
||||
fromField,
|
||||
clientRoute: '{= passwordResetClientRoute =}',
|
||||
getPasswordResetEmailContent: _waspGetPasswordResetEmailContent,
|
||||
}));
|
||||
router.post('/request-password-reset', requestPasswordResetRoute);
|
||||
|
||||
router.post('/reset-password', handleRejection(resetPassword));
|
||||
router.post('/verify-email', handleRejection(verifyEmail));
|
||||
|
||||
return router;
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user