Progress. WIP.

This commit is contained in:
Martin Sosic 2020-11-20 18:22:49 +01:00
parent 601ac3185b
commit d0044dd320
11 changed files with 539 additions and 2 deletions

View File

@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { Link, useHistory } from 'react-router-dom'
import useAuth from '@wasp/auth/useAuth.js'
import logout from '@wasp/auth/logout.js'
import createArticle from '@wasp/actions/createArticle'
import updateArticle from '@wasp/actions/updateArticle'
import { useQuery } from '@wasp/queries'
import getArticle from '@wasp/queries/getArticle'
import Navbar from './Navbar'
const ArticleEditorPage = (props) => {
const { data: user, isError } = useAuth({ keepPreviousData: true })
// TODO: Here, as in some other places, it feels tricky to figure out what is happening regarding the state.
// When is article null, when not, should I look into combination of article and articleId, then
// there is this 'enabled' which I need on the other hand -> uff. And what if I get error? humpf!
const articleId = parseInt(props.match.params.articleId)
const { data: article, error: articleError } = useQuery(getArticle, { id: articleId }, { enabled: articleId })
// TODO: Instead of this logic here, I wish I could use ACL via Wasp and just
// receive user via props instead of useAuth().
if (!user || isError) {
return <span> Please <Link to='/login'>log in</Link>. </span>
}
return articleError
? articleError.message || articleError
: (
<div>
<Navbar />
<ArticleEditor user={user} article={article} />
</div>
)
}
const ArticleEditor = (props) => {
const user = props.user
const article = props.article
const history = useHistory()
const [title, setTitle] = useState(article?.title || '')
const [description, setDescription] = useState(article?.description || '')
const [markdownContent, setMarkdownContent] = useState(article?.markdownContent || '')
const [submitError, setSubmitError] = useState(null)
const handleSubmit = async (event) => {
event.preventDefault()
setSubmitError(null)
try {
let articleId
if (article?.id) {
await updateArticle({
id: article.id,
title,
description,
markdownContent
})
articleId = article.id
} else {
const newArticle = await createArticle({
title,
description,
markdownContent
})
articleId = newArticle.id
}
history.push(`/article/${articleId}`)
} catch (err) {
setSubmitError(err)
}
}
return (
<div>
{ submitError && (
<p>
{ submitError.message || submitError }
</p>
) }
<form onSubmit={handleSubmit}>
<h2>Article title</h2>
<input
type='text'
value={title}
onChange={e => setTitle(e.target.value)}
/>
<h2>What's this article about?</h2>
<input
type='text'
value={description}
onChange={e => setDescription(e.target.value)}
/>
<h2>Markdown content</h2>
<input
type='text'
value={markdownContent}
onChange={e => setMarkdownContent(e.target.value)}
/>
<div>
<input type='submit' value='Publish Article' />
</div>
</form>
</div>
)
}
export default ArticleEditorPage

View File

@ -0,0 +1,65 @@
import React from 'react'
import { useHistory } from 'react-router-dom'
import useAuth from '@wasp/auth/useAuth.js'
import { useQuery } from '@wasp/queries'
import getArticle from '@wasp/queries/getArticle'
import deleteArticle from '@wasp/actions/deleteArticle'
import Navbar from './Navbar'
const ArticleViewPage = (props) => {
const history = useHistory()
const { data: user } = useAuth({ keepPreviousData: true })
const articleId = parseInt(props.match.params.articleId)
const { data: article } = useQuery(getArticle, { id: articleId })
// TODO: If there is no such article, we get null here under `article`,
// and we don't handle that properly, we just return blank screen (return null).
// How should we detect this and handle it?
// Should we modify action to return error instead of null?
// Or should we check for (!isLoading && !article)?
// What do we even do in such situation?
// Or maybe we should make it so that every operations returns an object always,
// and that object contains article then -> then it is very clear if something got returned,
// or if it is that initial null.
const isMyArticle = user?.id && (user?.id === article?.userId)
const handleEditArticle = () => {
history.push(`/editor/${article.id}`)
}
const handleDeleteArticle = async () => {
if (!window.confirm('Are you sure you want to delete the article?')) return
try {
await deleteArticle({ id: article.id })
} catch (err) {
console.log(err)
window.alert('Failed to delete article: ' + err)
}
}
return article ? (
<div>
<Navbar />
<div>
<p> { article.title } </p>
<p> { article.description } </p>
<p> { article.markdownContent } </p>
</div>
{ isMyArticle && (
<div>
<button onClick={handleEditArticle}> Edit Article </button>
<button onClick={handleDeleteArticle}> Delete Article </button>
</div>
)}
</div>
) : null
}
export default ArticleViewPage

View File

@ -11,6 +11,7 @@ const Navbar = () => {
return (
<div>
<Link to='/'> Home </Link>
<Link to='/editor'> New Article </Link>
<Link to='/settings'> Settings </Link>
<a href={`/@${user.username}`}> { user.username } </a>
</div>

View File

@ -5,6 +5,7 @@ import useAuth from '@wasp/auth/useAuth.js'
import logout from '@wasp/auth/logout.js'
import updateUser from '@wasp/actions/updateUser'
import getUser from '@wasp/queries/getUser'
import getArticlesByUser from '@wasp/queries/getArticlesByUser'
import { useQuery } from '@wasp/queries'
import Navbar from './Navbar'
@ -36,10 +37,37 @@ const UserProfilePage = (props) => {
<p> { user.username } </p>
<p> { user.bio } </p>
<div>
{ /* TODO: Show this link only if user is logged in. */ }
<Link to='/settings'>Edit Profile Settings</Link>
</div>
<Articles user={user} />
</div>
) : null
}
const Articles = (props) => {
// TODO: Should I have pagination here, probably I should?
const { data: articles } = useQuery(getArticlesByUser, { username: props.user.username })
return articles ? (
<div>
{ articles.map(article => <Article article={article} key={article.id} />) }
</div>
) : null
}
const Article = (props) => {
const article = props.article
return (
<div>
<Link to={`/article/${article.id}`}>
<h2> { article.title } </h2>
</Link>
<p> { article.description } </p>
</div>
)
}
export default UserProfilePage

View File

@ -15,11 +15,14 @@ export const signup = async ({ username, email, password }, context) => {
}
export const updateUser = async ({ email, username, bio, profilePictureUrl, newPassword }, context) => {
if (!context.user) { throw new HttpError(403) }
// TODO: Nicer error handling! Right now everything is returned as 500 while it could be instead
// useful error message about username being taken / not unique, and other validation errors.
await context.entities.User.update({
where: { email },
where: { id: context.user.id },
data: {
email,
username,
bio,
profilePictureUrl,
@ -27,3 +30,43 @@ export const updateUser = async ({ email, username, bio, profilePictureUrl, newP
}
})
}
export const createArticle = async ({ title, description, markdownContent }, context) => {
if (!context.user) { throw new HttpError(403) }
// TODO: Nicer error handling! Right now everything is returned as 500 while it could be instead
// useful error message about username being taken / not unique, and other validation errors.
return await context.entities.Article.create({
data: {
title,
description,
markdownContent,
user: { connect: { id: context.user.id } }
}
})
}
export const updateArticle = async ({ id, title, description, markdownContent }, context) => {
if (!context.user) { throw new HttpError(403) }
// TODO: Nicer error handling! Right now everything is returned as 500 while it could be instead
// useful error message about username being taken / not unique, and other validation errors.
await context.entities.Article.updateMany({
where: { id, user: { id: context.user.id }}, // TODO: This line is not fun to write.
data: {
title,
description,
markdownContent
}
})
}
export const deleteArticle = async ({ id }, context) => {
if (!context.user) { throw new HttpError(403) }
// TODO: Nicer error handling! Right now everything is returned as 500 while it could be instead
// useful error message about username being taken / not unique, and other validation errors.
await context.entities.Article.deleteMany({
where: { id, user: { id: context.user.id }} // TODO: This line is not fun to write.
})
}

View File

@ -6,3 +6,21 @@ export const getUser = async ({ username }, context) => {
if (!user) throw new HttpError(404, 'No user with username ' + username)
return user
}
export const getArticlesByUser = async ({ username }, context) => {
// TODO: Do some error handling?
const articles = await context.entities.Article.findMany({
where: {
user: { username }
}
})
return articles
}
export const getArticle = async ({ id }, context) => {
// TODO: Do some error handling?
const article = await context.entities.Article.findOne({
where: { id }
})
return article
}

View File

@ -27,6 +27,19 @@ page UserProfile {
component: import UserProfile from "@ext/UserProfilePage.js"
}
route "/editor/:articleId" -> page ArticleEditor
page ArticleEditor {
component: import ArticleEditor from "@ext/ArticleEditorPage.js"
}
// TODO: Instead of articleId, we should some combination of its title and
// a random string, if I got it right. Figure out better what is the spec
// and implement it correctly.
route "/article/:articleId" -> page ArticleView
page ArticleView {
component: import ArticleView from "@ext/ArticleViewPage.js"
}
entity User {=psl
id Int @id @default(autoincrement())
username String @unique
@ -34,6 +47,18 @@ entity User {=psl
password String
bio String?
profilePictureUrl String?
articles Article[]
psl=}
// TODO: Add tags.
// TODO: Add creation and update times.
entity Article {=psl
id Int @id @default(autoincrement())
title String
description String
markdownContent String
user User @relation(fields: [userId], references: [id])
userId Int
psl=}
auth {
@ -54,4 +79,29 @@ action updateUser {
query getUser {
fn: import { getUser } from "@ext/queries.js",
entities: [User]
}
query getArticlesByUser {
fn: import { getArticlesByUser } from "@ext/queries.js",
entities: [Article]
}
query getArticle {
fn: import { getArticle } from "@ext/queries.js",
entities: [Article]
}
action createArticle {
fn: import { createArticle } from "@ext/actions.js",
entities: [Article]
}
action updateArticle {
fn: import { updateArticle } from "@ext/actions.js",
entities: [Article]
}
action deleteArticle {
fn: import { deleteArticle } from "@ext/actions.js",
entities: [Article]
}

View File

@ -0,0 +1,53 @@
# Migration `20201119144622-added-article-entity`
This migration has been generated by Martin Sosic at 11/19/2020, 3:46:22 PM.
You can check out the [state of the schema](./schema.prisma) after the migration.
## Database Steps
```sql
CREATE TABLE "Article" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"markdownContent" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
)
```
## Changes
```diff
diff --git schema.prisma schema.prisma
migration 20201117145436-added-bio-and-picture-to-user..20201119144622-added-article-entity
--- datamodel.dml
+++ datamodel.dml
@@ -1,8 +1,8 @@
datasource db {
provider = "sqlite"
- url = "***"
+ url = "***"
}
generator client {
provider = "prisma-client-js"
@@ -15,6 +15,16 @@
email String @unique
password String
bio String?
profilePictureUrl String?
+ articles Article[]
}
+model Article {
+ id Int @id @default(autoincrement())
+ title String
+ description String
+ markdownContent String
+ user User @relation(fields: [userId], references: [id])
+ userId Int
+}
+
```

View File

@ -0,0 +1,30 @@
datasource db {
provider = "sqlite"
url = "***"
}
generator client {
provider = "prisma-client-js"
output = "../server/node_modules/.prisma/client"
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
password String
bio String?
profilePictureUrl String?
articles Article[]
}
model Article {
id Int @id @default(autoincrement())
title String
description String
markdownContent String
user User @relation(fields: [userId], references: [id])
userId Int
}

View File

@ -0,0 +1,133 @@
{
"version": "0.3.14-fixed",
"steps": [
{
"tag": "CreateModel",
"model": "Article"
},
{
"tag": "CreateField",
"model": "Article",
"field": "id",
"type": "Int",
"arity": "Required"
},
{
"tag": "CreateDirective",
"location": {
"path": {
"tag": "Field",
"model": "Article",
"field": "id"
},
"directive": "id"
}
},
{
"tag": "CreateDirective",
"location": {
"path": {
"tag": "Field",
"model": "Article",
"field": "id"
},
"directive": "default"
}
},
{
"tag": "CreateArgument",
"location": {
"tag": "Directive",
"path": {
"tag": "Field",
"model": "Article",
"field": "id"
},
"directive": "default"
},
"argument": "",
"value": "autoincrement()"
},
{
"tag": "CreateField",
"model": "Article",
"field": "title",
"type": "String",
"arity": "Required"
},
{
"tag": "CreateField",
"model": "Article",
"field": "description",
"type": "String",
"arity": "Required"
},
{
"tag": "CreateField",
"model": "Article",
"field": "markdownContent",
"type": "String",
"arity": "Required"
},
{
"tag": "CreateField",
"model": "Article",
"field": "user",
"type": "User",
"arity": "Required"
},
{
"tag": "CreateDirective",
"location": {
"path": {
"tag": "Field",
"model": "Article",
"field": "user"
},
"directive": "relation"
}
},
{
"tag": "CreateArgument",
"location": {
"tag": "Directive",
"path": {
"tag": "Field",
"model": "Article",
"field": "user"
},
"directive": "relation"
},
"argument": "fields",
"value": "[userId]"
},
{
"tag": "CreateArgument",
"location": {
"tag": "Directive",
"path": {
"tag": "Field",
"model": "Article",
"field": "user"
},
"directive": "relation"
},
"argument": "references",
"value": "[id]"
},
{
"tag": "CreateField",
"model": "Article",
"field": "userId",
"type": "Int",
"arity": "Required"
},
{
"tag": "CreateField",
"model": "User",
"field": "articles",
"type": "Article",
"arity": "List"
}
]
}

View File

@ -2,4 +2,5 @@
20201030161549-user
20201030185724-fixed-user
20201117145436-added-bio-and-picture-to-user
20201117145436-added-bio-and-picture-to-user
20201119144622-added-article-entity