chapter 2
This commit is contained in:
@@ -4,6 +4,7 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
|
||||
import FatalErrorPage from 'src/pages/FatalErrorPage'
|
||||
import Routes from 'src/Routes'
|
||||
|
||||
import './scaffold.css'
|
||||
import './index.css'
|
||||
|
||||
const App = () => (
|
||||
|
||||
@@ -7,12 +7,24 @@
|
||||
// 'src/pages/HomePage/HomePage.js' -> HomePage
|
||||
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
|
||||
|
||||
import { Router, Route } from '@redwoodjs/router'
|
||||
import { Router, Route, Set } from '@redwoodjs/router'
|
||||
import PostsLayout from 'src/layouts/PostsLayout'
|
||||
import BlogLayout from './layouts/BlogLayout/BlogLayout'
|
||||
|
||||
const Routes = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Route notfound page={NotFoundPage} />
|
||||
<Set wrap={PostsLayout}>
|
||||
<Route path="/posts/new" page={PostNewPostPage} name="newPost" />
|
||||
<Route path="/posts/{id:Int}/edit" page={PostEditPostPage} name="editPost" />
|
||||
<Route path="/posts/{id:Int}" page={PostPostPage} name="post" />
|
||||
<Route path="/posts" page={PostPostsPage} name="posts" />
|
||||
</Set>
|
||||
<Set wrap={BlogLayout}>
|
||||
<Route path="/about" page={AboutPage} name="about" />
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
</Set>
|
||||
<Route notfound page={NotFoundPage} />
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
34
web/src/components/ArticlesCell/ArticlesCell.js
Normal file
34
web/src/components/ArticlesCell/ArticlesCell.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export const QUERY = gql`
|
||||
query ArticlesQuery {
|
||||
articles: posts {
|
||||
id
|
||||
title
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => <div>Empty</div>
|
||||
|
||||
export const Failure = ({ error }) => (
|
||||
<div style={{ color: 'red' }}>Error: {error.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ articles }) => {
|
||||
return (
|
||||
<ul>
|
||||
{articles.map((article) => (
|
||||
<article key={article.id}>
|
||||
<header>
|
||||
<h2>{article.title}</h2>
|
||||
</header>
|
||||
<p>{article.body}</p>
|
||||
<div>Created at: {article.createdAt}</div>
|
||||
</article>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
4
web/src/components/ArticlesCell/ArticlesCell.mock.js
Normal file
4
web/src/components/ArticlesCell/ArticlesCell.mock.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = () => ({
|
||||
articles: [{ id: 42 }, { id: 43 }, { id: 44 }],
|
||||
})
|
||||
20
web/src/components/ArticlesCell/ArticlesCell.stories.js
Normal file
20
web/src/components/ArticlesCell/ArticlesCell.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './ArticlesCell'
|
||||
import { standard } from './ArticlesCell.mock'
|
||||
|
||||
export const loading = () => {
|
||||
return Loading ? <Loading /> : null
|
||||
}
|
||||
|
||||
export const empty = () => {
|
||||
return Empty ? <Empty /> : null
|
||||
}
|
||||
|
||||
export const failure = () => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} /> : null
|
||||
}
|
||||
|
||||
export const success = () => {
|
||||
return Success ? <Success {...standard()} /> : null
|
||||
}
|
||||
|
||||
export default { title: 'Cells/ArticlesCell' }
|
||||
41
web/src/components/ArticlesCell/ArticlesCell.test.js
Normal file
41
web/src/components/ArticlesCell/ArticlesCell.test.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './ArticlesCell'
|
||||
import { standard } from './ArticlesCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('ArticlesCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success articles={standard().articles} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
59
web/src/components/Post/EditPostCell/EditPostCell.js
Normal file
59
web/src/components/Post/EditPostCell/EditPostCell.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
|
||||
import PostForm from 'src/components/Post/PostForm'
|
||||
|
||||
export const QUERY = gql`
|
||||
query EditPostById($id: Int!) {
|
||||
post: post(id: $id) {
|
||||
id
|
||||
title
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
const UPDATE_POST_MUTATION = gql`
|
||||
mutation UpdatePostMutation($id: Int!, $input: UpdatePostInput!) {
|
||||
updatePost(id: $id, input: $input) {
|
||||
id
|
||||
title
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Failure = ({ error }) => (
|
||||
<div className="rw-cell-error">{error.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ post }) => {
|
||||
const [updatePost, { loading, error }] = useMutation(UPDATE_POST_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Post updated')
|
||||
navigate(routes.posts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onSave = (input, id) => {
|
||||
updatePost({ variables: { id, input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">Edit Post {post.id}</h2>
|
||||
</header>
|
||||
<div className="rw-segment-main">
|
||||
<PostForm post={post} onSave={onSave} error={error} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
web/src/components/Post/NewPost/NewPost.js
Normal file
41
web/src/components/Post/NewPost/NewPost.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { navigate, routes } from '@redwoodjs/router'
|
||||
import PostForm from 'src/components/Post/PostForm'
|
||||
|
||||
const CREATE_POST_MUTATION = gql`
|
||||
mutation CreatePostMutation($input: CreatePostInput!) {
|
||||
createPost(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const NewPost = () => {
|
||||
const [createPost, { loading, error }] = useMutation(CREATE_POST_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Post created')
|
||||
navigate(routes.posts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onSave = (input) => {
|
||||
createPost({ variables: { input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">New Post</h2>
|
||||
</header>
|
||||
<div className="rw-segment-main">
|
||||
<PostForm onSave={onSave} loading={loading} error={error} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewPost
|
||||
113
web/src/components/Post/Post/Post.js
Normal file
113
web/src/components/Post/Post/Post.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import humanize from 'humanize-string'
|
||||
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { Link, routes, navigate } from '@redwoodjs/router'
|
||||
|
||||
const DELETE_POST_MUTATION = gql`
|
||||
mutation DeletePostMutation($id: Int!) {
|
||||
deletePost(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const formatEnum = (values) => {
|
||||
if (values) {
|
||||
if (Array.isArray(values)) {
|
||||
const humanizedValues = values.map((value) => humanize(value))
|
||||
return humanizedValues.join(', ')
|
||||
} else {
|
||||
return humanize(values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jsonDisplay = (obj) => {
|
||||
return (
|
||||
<pre>
|
||||
<code>{JSON.stringify(obj, null, 2)}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
datetime && (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const Post = ({ post }) => {
|
||||
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Post deleted')
|
||||
navigate(routes.posts())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (confirm('Are you sure you want to delete post ' + id + '?')) {
|
||||
deletePost({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rw-segment">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
Post {post.id} Detail
|
||||
</h2>
|
||||
</header>
|
||||
<table className="rw-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<td>{post.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{post.title}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Body</th>
|
||||
<td>{post.body}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created at</th>
|
||||
<td>{timeTag(post.createdAt)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<nav className="rw-button-group">
|
||||
<Link
|
||||
to={routes.editPost({ id: post.id })}
|
||||
className="rw-button rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="rw-button rw-button-red"
|
||||
onClick={() => onDeleteClick(post.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Post
|
||||
24
web/src/components/Post/PostCell/PostCell.js
Normal file
24
web/src/components/Post/PostCell/PostCell.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import Post from 'src/components/Post/Post'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FindPostById($id: Int!) {
|
||||
post: post(id: $id) {
|
||||
id
|
||||
title
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => <div>Post not found</div>
|
||||
|
||||
export const Failure = ({ error }) => (
|
||||
<div className="rw-cell-error">{error.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ post }) => {
|
||||
return <Post post={post} />
|
||||
}
|
||||
71
web/src/components/Post/PostForm/PostForm.js
Normal file
71
web/src/components/Post/PostForm/PostForm.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Form,
|
||||
FormError,
|
||||
FieldError,
|
||||
Label,
|
||||
TextField,
|
||||
Submit,
|
||||
} from '@redwoodjs/forms'
|
||||
|
||||
const PostForm = (props) => {
|
||||
const onSubmit = (data) => {
|
||||
props.onSave(data, props?.post?.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-form-wrapper">
|
||||
<Form onSubmit={onSubmit} error={props.error}>
|
||||
<FormError
|
||||
error={props.error}
|
||||
wrapperClassName="rw-form-error-wrapper"
|
||||
titleClassName="rw-form-error-title"
|
||||
listClassName="rw-form-error-list"
|
||||
/>
|
||||
|
||||
<Label
|
||||
name="title"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Title
|
||||
</Label>
|
||||
|
||||
<TextField
|
||||
name="title"
|
||||
defaultValue={props.post?.title}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
|
||||
<FieldError name="title" className="rw-field-error" />
|
||||
|
||||
<Label
|
||||
name="body"
|
||||
className="rw-label"
|
||||
errorClassName="rw-label rw-label-error"
|
||||
>
|
||||
Body
|
||||
</Label>
|
||||
|
||||
<TextField
|
||||
name="body"
|
||||
defaultValue={props.post?.body}
|
||||
className="rw-input"
|
||||
errorClassName="rw-input rw-input-error"
|
||||
validation={{ required: true }}
|
||||
/>
|
||||
|
||||
<FieldError name="body" className="rw-field-error" />
|
||||
|
||||
<div className="rw-button-group">
|
||||
<Submit disabled={props.loading} className="rw-button rw-button-blue">
|
||||
Save
|
||||
</Submit>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostForm
|
||||
130
web/src/components/Post/Posts/Posts.js
Normal file
130
web/src/components/Post/Posts/Posts.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import humanize from 'humanize-string'
|
||||
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { QUERY } from 'src/components/Post/PostsCell'
|
||||
|
||||
const DELETE_POST_MUTATION = gql`
|
||||
mutation DeletePostMutation($id: Int!) {
|
||||
deletePost(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MAX_STRING_LENGTH = 150
|
||||
|
||||
const formatEnum = (values) => {
|
||||
if (values) {
|
||||
if (Array.isArray(values)) {
|
||||
const humanizedValues = values.map((value) => humanize(value))
|
||||
return humanizedValues.join(', ')
|
||||
} else {
|
||||
return humanize(values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const truncate = (text) => {
|
||||
let output = text
|
||||
if (text && text.length > MAX_STRING_LENGTH) {
|
||||
output = output.substring(0, MAX_STRING_LENGTH) + '...'
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const jsonTruncate = (obj) => {
|
||||
return truncate(JSON.stringify(obj, null, 2))
|
||||
}
|
||||
|
||||
const timeTag = (datetime) => {
|
||||
return (
|
||||
datetime && (
|
||||
<time dateTime={datetime} title={datetime}>
|
||||
{new Date(datetime).toUTCString()}
|
||||
</time>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const checkboxInputTag = (checked) => {
|
||||
return <input type="checkbox" checked={checked} disabled />
|
||||
}
|
||||
|
||||
const PostsList = ({ posts }) => {
|
||||
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
|
||||
onCompleted: () => {
|
||||
toast.success('Post deleted')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
// This refetches the query on the list page. Read more about other ways to
|
||||
// update the cache over here:
|
||||
// https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates
|
||||
refetchQueries: [{ query: QUERY }],
|
||||
awaitRefetchQueries: true,
|
||||
})
|
||||
|
||||
const onDeleteClick = (id) => {
|
||||
if (confirm('Are you sure you want to delete post ' + id + '?')) {
|
||||
deletePost({ variables: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rw-segment rw-table-wrapper-responsive">
|
||||
<table className="rw-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>Title</th>
|
||||
<th>Body</th>
|
||||
<th>Created at</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id}>
|
||||
<td>{truncate(post.id)}</td>
|
||||
<td>{truncate(post.title)}</td>
|
||||
<td>{truncate(post.body)}</td>
|
||||
<td>{timeTag(post.createdAt)}</td>
|
||||
<td>
|
||||
<nav className="rw-table-actions">
|
||||
<Link
|
||||
to={routes.post({ id: post.id })}
|
||||
title={'Show post ' + post.id + ' detail'}
|
||||
className="rw-button rw-button-small"
|
||||
>
|
||||
Show
|
||||
</Link>
|
||||
<Link
|
||||
to={routes.editPost({ id: post.id })}
|
||||
title={'Edit post ' + post.id}
|
||||
className="rw-button rw-button-small rw-button-blue"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
title={'Delete post ' + post.id}
|
||||
className="rw-button rw-button-small rw-button-red"
|
||||
onClick={() => onDeleteClick(post.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</nav>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostsList
|
||||
35
web/src/components/Post/PostsCell/PostsCell.js
Normal file
35
web/src/components/Post/PostsCell/PostsCell.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import Posts from 'src/components/Post/Posts'
|
||||
|
||||
export const QUERY = gql`
|
||||
query FindPosts {
|
||||
posts {
|
||||
id
|
||||
title
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center">
|
||||
{'No posts yet. '}
|
||||
<Link to={routes.newPost()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Failure = ({ error }) => (
|
||||
<div className="rw-cell-error">{error.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ posts }) => {
|
||||
return <Posts posts={posts} />
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
}
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 1rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
display: inline-block;
|
||||
margin: 0 1rem 0 0 ;
|
||||
}
|
||||
h1 > a {
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
button, input, label, textarea {
|
||||
display: block;
|
||||
outline: none;
|
||||
}
|
||||
label {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
input.error, textarea.error {
|
||||
border: 1px solid red;
|
||||
}
|
||||
.form-error {
|
||||
color: red;
|
||||
background-color: lavenderblush;
|
||||
padding: 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.form-error ul {
|
||||
list-style-type: disc;
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.form-error li {
|
||||
display: list-item;
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flex-between button {
|
||||
display: inline;
|
||||
}
|
||||
23
web/src/layouts/BlogLayout/BlogLayout.js
Normal file
23
web/src/layouts/BlogLayout/BlogLayout.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Link, routes } from '@redwoodjs/router';
|
||||
|
||||
const BlogLayout = ({ children }) => {
|
||||
return <>
|
||||
<header>
|
||||
<h1>
|
||||
<Link to={routes.home()}>Colin's Redwood Blog</Link>
|
||||
</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to={routes.home()}>Home</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to={routes.about()}>About</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main>{children}</main></>
|
||||
}
|
||||
|
||||
export default BlogLayout
|
||||
7
web/src/layouts/BlogLayout/BlogLayout.stories.js
Normal file
7
web/src/layouts/BlogLayout/BlogLayout.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import BlogLayout from './BlogLayout'
|
||||
|
||||
export const generated = () => {
|
||||
return <BlogLayout />
|
||||
}
|
||||
|
||||
export default { title: 'Layouts/BlogLayout' }
|
||||
14
web/src/layouts/BlogLayout/BlogLayout.test.js
Normal file
14
web/src/layouts/BlogLayout/BlogLayout.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import BlogLayout from './BlogLayout'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('BlogLayout', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<BlogLayout />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
23
web/src/layouts/PostsLayout/PostsLayout.js
Normal file
23
web/src/layouts/PostsLayout/PostsLayout.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { Toaster } from '@redwoodjs/web/toast'
|
||||
|
||||
const PostsLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="rw-scaffold">
|
||||
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
|
||||
<header className="rw-header">
|
||||
<h1 className="rw-heading rw-heading-primary">
|
||||
<Link to={routes.posts()} className="rw-link">
|
||||
Posts
|
||||
</Link>
|
||||
</h1>
|
||||
<Link to={routes.newPost()} className="rw-button rw-button-green">
|
||||
<div className="rw-button-icon">+</div> New Post
|
||||
</Link>
|
||||
</header>
|
||||
<main className="rw-main">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostsLayout
|
||||
14
web/src/pages/AboutPage/AboutPage.js
Normal file
14
web/src/pages/AboutPage/AboutPage.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
|
||||
const AboutPage = () => {
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="About" description="About page" />
|
||||
<p>
|
||||
This site was created to demonstrate my mastery of Redwood: Look on my works, ye mighty, and despair!
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage
|
||||
7
web/src/pages/AboutPage/AboutPage.stories.js
Normal file
7
web/src/pages/AboutPage/AboutPage.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import AboutPage from './AboutPage'
|
||||
|
||||
export const generated = () => {
|
||||
return <AboutPage />
|
||||
}
|
||||
|
||||
export default { title: 'Pages/AboutPage' }
|
||||
14
web/src/pages/AboutPage/AboutPage.test.js
Normal file
14
web/src/pages/AboutPage/AboutPage.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import AboutPage from './AboutPage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('AboutPage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<AboutPage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
13
web/src/pages/HomePage/HomePage.js
Normal file
13
web/src/pages/HomePage/HomePage.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
import ArticlesCell from 'src/components/ArticlesCell'
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="Home" description="Home page" />
|
||||
<ArticlesCell />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomePage
|
||||
7
web/src/pages/HomePage/HomePage.stories.js
Normal file
7
web/src/pages/HomePage/HomePage.stories.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import HomePage from './HomePage'
|
||||
|
||||
export const generated = () => {
|
||||
return <HomePage />
|
||||
}
|
||||
|
||||
export default { title: 'Pages/HomePage' }
|
||||
14
web/src/pages/HomePage/HomePage.test.js
Normal file
14
web/src/pages/HomePage/HomePage.test.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import HomePage from './HomePage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<HomePage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
7
web/src/pages/Post/EditPostPage/EditPostPage.js
Normal file
7
web/src/pages/Post/EditPostPage/EditPostPage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import EditPostCell from 'src/components/Post/EditPostCell'
|
||||
|
||||
const EditPostPage = ({ id }) => {
|
||||
return <EditPostCell id={id} />
|
||||
}
|
||||
|
||||
export default EditPostPage
|
||||
7
web/src/pages/Post/NewPostPage/NewPostPage.js
Normal file
7
web/src/pages/Post/NewPostPage/NewPostPage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import NewPost from 'src/components/Post/NewPost'
|
||||
|
||||
const NewPostPage = () => {
|
||||
return <NewPost />
|
||||
}
|
||||
|
||||
export default NewPostPage
|
||||
7
web/src/pages/Post/PostPage/PostPage.js
Normal file
7
web/src/pages/Post/PostPage/PostPage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import PostCell from 'src/components/Post/PostCell'
|
||||
|
||||
const PostPage = ({ id }) => {
|
||||
return <PostCell id={id} />
|
||||
}
|
||||
|
||||
export default PostPage
|
||||
7
web/src/pages/Post/PostsPage/PostsPage.js
Normal file
7
web/src/pages/Post/PostsPage/PostsPage.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import PostsCell from 'src/components/Post/PostsCell'
|
||||
|
||||
const PostsPage = () => {
|
||||
return <PostsCell />
|
||||
}
|
||||
|
||||
export default PostsPage
|
||||
366
web/src/scaffold.css
Normal file
366
web/src/scaffold.css
Normal file
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
|
||||
*/
|
||||
|
||||
.rw-scaffold *,
|
||||
.rw-scaffold ::after,
|
||||
.rw-scaffold ::before {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
.rw-scaffold main {
|
||||
color: #4a5568;
|
||||
display: block;
|
||||
}
|
||||
.rw-scaffold h1,
|
||||
.rw-scaffold h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.rw-scaffold a {
|
||||
background-color: transparent;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.rw-scaffold input {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold input::-ms-input-placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.rw-scaffold table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
/*
|
||||
Style
|
||||
*/
|
||||
|
||||
.rw-scaffold,
|
||||
.rw-toast {
|
||||
background-color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
.rw-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem 1rem 2rem;
|
||||
}
|
||||
.rw-main {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
.rw-segment {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
.rw-segment-header {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.rw-segment-main {
|
||||
background-color: #f7fafc;
|
||||
padding: 1rem;
|
||||
}
|
||||
.rw-link {
|
||||
color: #4299e1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rw-link:hover {
|
||||
color: #2b6cb0;
|
||||
}
|
||||
.rw-forgot-link {
|
||||
font-size: 0.75rem;
|
||||
color: #a0aec0;
|
||||
text-align: right;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.rw-forgot-link:hover {
|
||||
font-size: 0.75rem;
|
||||
color: #4299e1;
|
||||
}
|
||||
.rw-heading {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-heading.rw-heading-primary {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.rw-heading.rw-heading-secondary {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.rw-heading .rw-link {
|
||||
color: #4a5568;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rw-heading .rw-link:hover {
|
||||
color: #1a202c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rw-cell-error {
|
||||
font-size: 90%;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-form-wrapper {
|
||||
box-sizing: border-box;
|
||||
font-size: 0.875rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
.rw-cell-error,
|
||||
.rw-form-error-wrapper {
|
||||
padding: 1rem;
|
||||
background-color: #fff5f5;
|
||||
color: #c53030;
|
||||
border-width: 1px;
|
||||
border-color: #feb2b2;
|
||||
border-radius: 0.25rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.rw-form-error-title {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-form-error-list {
|
||||
margin-top: 0.5rem;
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
.rw-button {
|
||||
border: none;
|
||||
color: #718096;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 1rem;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.025em;
|
||||
border-radius: 0.25rem;
|
||||
line-height: 2;
|
||||
border: 0;
|
||||
}
|
||||
.rw-button:hover {
|
||||
background-color: #718096;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-small {
|
||||
font-size: 0.75rem;
|
||||
border-radius: 0.125rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
line-height: inherit;
|
||||
}
|
||||
.rw-button.rw-button-green {
|
||||
background-color: #48bb78;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-green:hover {
|
||||
background-color: #38a169;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-blue {
|
||||
background-color: #3182ce;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-blue:hover {
|
||||
background-color: #2b6cb0;
|
||||
}
|
||||
.rw-button.rw-button-red {
|
||||
background-color: #e53e3e;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-button.rw-button-red:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
.rw-button-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.rw-button-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.75rem 0.5rem;
|
||||
}
|
||||
.rw-button-group .rw-button {
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
.rw-form-wrapper .rw-button-group {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.rw-label {
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
color: #4a5568;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rw-label.rw-label-error {
|
||||
color: #c53030;
|
||||
}
|
||||
.rw-input {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border-radius: 0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
.rw-check-radio-item-none {
|
||||
color: #4a5568;
|
||||
}
|
||||
.rw-check-radio-items {
|
||||
display: flex;
|
||||
justify-items: center;
|
||||
}
|
||||
.rw-input[type='checkbox'] {
|
||||
display: inline;
|
||||
width: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rw-input[type='radio'] {
|
||||
display: inline;
|
||||
width: 1rem;
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.rw-input:focus {
|
||||
border-color: #a0aec0;
|
||||
}
|
||||
.rw-input-error {
|
||||
border-color: #c53030;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.rw-input-error:focus {
|
||||
outline: none;
|
||||
border-color: #c53030;
|
||||
box-shadow: 0 0 5px #c53030;
|
||||
}
|
||||
|
||||
.rw-field-error {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #c53030;
|
||||
}
|
||||
.rw-table-wrapper-responsive {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
.rw-table-wrapper-responsive .rw-table {
|
||||
min-width: 48rem;
|
||||
}
|
||||
.rw-table {
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.rw-table th,
|
||||
.rw-table td {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.rw-table td {
|
||||
background-color: #ffffff;
|
||||
color: #1a202c;
|
||||
}
|
||||
.rw-table thead tr {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
.rw-table th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.rw-table thead th {
|
||||
text-align: left;
|
||||
}
|
||||
.rw-table tbody th {
|
||||
text-align: right;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.rw-table tbody th {
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
.rw-table tbody tr {
|
||||
background-color: #f7fafc;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
.rw-table tbody tr:nth-child(even) {
|
||||
background-color: #fff;
|
||||
}
|
||||
.rw-table input {
|
||||
margin-left: 0;
|
||||
}
|
||||
.rw-table-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 17px;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
.rw-table-actions .rw-button {
|
||||
background-color: transparent;
|
||||
}
|
||||
.rw-table-actions .rw-button:hover {
|
||||
background-color: #718096;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue {
|
||||
color: #3182ce;
|
||||
}
|
||||
.rw-table-actions .rw-button-blue:hover {
|
||||
background-color: #3182ce;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-table-actions .rw-button-red {
|
||||
color: #e53e3e;
|
||||
}
|
||||
.rw-table-actions .rw-button-red:hover {
|
||||
background-color: #e53e3e;
|
||||
color: #fff;
|
||||
}
|
||||
.rw-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.rw-login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24rem;
|
||||
margin: 4rem auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rw-login-container .rw-form-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
.rw-login-link {
|
||||
margin-top: 1rem;
|
||||
color: #4a5568;
|
||||
font-size: 90%;
|
||||
text-align: center;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
Reference in New Issue
Block a user