Merge branch 'main' into release-with-main-2

This commit is contained in:
Mihovil Ilakovac 2023-04-12 16:03:28 +02:00
commit b1eee23f41
547 changed files with 13353 additions and 3541 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@
# macOS related
.DS_Store
.vscode/

View File

@ -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:

View File

@ -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

View File

@ -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 }

View File

@ -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'

View File

@ -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) => ({

View File

@ -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'

View File

@ -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'

View File

@ -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'

View 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
}

View File

@ -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 }

View File

@ -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>
</>
)
}

View File

@ -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>
</>
)
}

View 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
}

View File

@ -1,6 +1,6 @@
app TodoTypescript {
wasp: {
version: "^0.8.2"
version: "^0.9.0"
},
title: "ToDo TypeScript",

View File

@ -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>
);

View File

@ -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) {

View File

@ -1,6 +1,6 @@
app TodoApp {
wasp: {
version: "^0.8.0"
version: "^0.10.0"
},
title: "Todo app",

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
app waspleau {
wasp: {
version: "^0.8.0"
version: "^0.10.0"
},
title: "Waspleau",

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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."

View File

@ -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

View 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!"

View 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)

View 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."

View File

@ -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

View File

@ -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 ()

View File

@ -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

View File

@ -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

View File

@ -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).

View 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

View 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 ()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -1,7 +0,0 @@
{{={= =}=}}
import { createAction } from './core'
export default createAction(
'{= actionRoute =}',
{=& entitiesArray =},
)

View File

@ -0,0 +1,10 @@
{{={= =}=}}
import { createAction } from './core'
{=& operationTypeImportStmt =}
const action = createAction<{= operationTypeName =}>(
'{= actionRoute =}',
{=& entitiesArray =},
)
export default action

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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)
}
}

View File

@ -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';

View 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 =}

View File

@ -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}
/>
)
}

View File

@ -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

View File

@ -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}
/>
)
}

View File

@ -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}
/>
)
}

View File

@ -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

View File

@ -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}
/>
)
}

View File

@ -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'
})

View File

@ -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>
)

View File

@ -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}
/>
)
}

View File

@ -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];
}

View File

@ -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>
)
}

View File

@ -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> {

View File

@ -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) {

View File

@ -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()

View File

@ -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

View File

@ -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) => {

View File

@ -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)
}

View File

@ -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']

View 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
}

View File

@ -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)
}
}

View File

@ -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}` }
}

View File

@ -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) {

View File

@ -1,7 +0,0 @@
{{={= =}=}}
import { createQuery } from './core'
export default createQuery(
'{= queryRoute =}',
{=& entitiesArray =},
)

View File

@ -0,0 +1,11 @@
{{={= =}=}}
import { createQuery } from './core'
{=& operationTypeImportStmt =}
const query = createQuery<{= operationTypeName =}>(
'{= queryRoute =}',
{=& entitiesArray =},
)
export default query

View File

@ -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

View File

@ -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)
}

View File

@ -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>

View File

@ -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'
}
}
})

View 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.');
}
}

View File

@ -0,0 +1 @@
export { renderInContext, mockServer } from './vitest/helpers'

View File

@ -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])
}

View File

@ -0,0 +1,4 @@
import matchers from '@testing-library/jest-dom/matchers'
import { expect } from 'vitest'
expect.extend(matchers)

View 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 }

View File

@ -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
},

View File

@ -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'],
},
})

View File

@ -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 =}
}

View File

@ -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 =}

View File

@ -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 =}

View 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 =}

View File

@ -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())

View File

@ -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;

View File

@ -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