Compare commits
10 Commits
128ed45802
...
ac15c86840
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac15c86840 | ||
| e180c93ada | |||
| 5d9a23422b | |||
|
|
1c2d6f2b4a | ||
|
|
03436e2156 | ||
|
|
9eb54f11cb | ||
|
|
24ec0c3188 | ||
|
|
7d27023ef6 | ||
|
|
4e7012899c | ||
|
|
43f0b7ebad |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.idea
|
||||
.DS_Store
|
||||
.env
|
||||
.netlify
|
||||
@@ -7,8 +8,13 @@ dist
|
||||
dist-babel
|
||||
node_modules
|
||||
yarn-error.log
|
||||
redwood/*
|
||||
web/public/mockServiceWorker.js
|
||||
web/types/graphql.d.ts
|
||||
api/types/graphql.d.ts
|
||||
web/public/mockServiceWorker.js
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Comment" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"name" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"postId" INTEGER NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Comment_postId_fkey" FOREIGN KEY ("postId") REFERENCES "Post" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
@@ -12,6 +12,7 @@ model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
body String
|
||||
comments Comment[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -32,3 +33,12 @@ model User {
|
||||
resetToken String?
|
||||
resetTokenExpiresAt DateTime?
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
body String
|
||||
post Post @relation(fields: [postId], references: [id])
|
||||
postId Int
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@redwoodjs/api": "^0.50.0",
|
||||
"@redwoodjs/graphql-server": "^0.50.0"
|
||||
"@redwoodjs/api": "1.1.1",
|
||||
"@redwoodjs/graphql-server": "1.1.1"
|
||||
}
|
||||
}
|
||||
31
api/src/graphql/comments.sdl.js
Normal file
31
api/src/graphql/comments.sdl.js
Normal file
@@ -0,0 +1,31 @@
|
||||
export const schema = gql`
|
||||
type Comment {
|
||||
id: Int!
|
||||
name: String!
|
||||
body: String!
|
||||
post: Post!
|
||||
postId: Int!
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type Query {
|
||||
comments: [Comment!]! @skipAuth
|
||||
}
|
||||
|
||||
input CreateCommentInput {
|
||||
name: String!
|
||||
body: String!
|
||||
postId: Int!
|
||||
}
|
||||
|
||||
input UpdateCommentInput {
|
||||
name: String
|
||||
body: String
|
||||
postId: Int
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createComment(input: CreateCommentInput!): Comment! @skipAuth
|
||||
deleteComment(id: Int!): Comment! @requireAuth
|
||||
}
|
||||
`
|
||||
28
api/src/services/comments/comments.js
Normal file
28
api/src/services/comments/comments.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
export const comments = () => {
|
||||
return db.comment.findMany()
|
||||
}
|
||||
|
||||
export const comment = ({ id }) => {
|
||||
return db.comment.findUnique({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
|
||||
export const Comment = {
|
||||
post: (_obj, { root }) =>
|
||||
db.comment.findUnique({ where: { id: root.id } }).post(),
|
||||
}
|
||||
|
||||
export const createComment = ({ input }) => {
|
||||
return db.comment.create({
|
||||
data: input,
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteComment = ({ id }) => {
|
||||
return db.comment.delete({
|
||||
where: { id },
|
||||
})
|
||||
}
|
||||
42
api/src/services/comments/comments.scenarios.js
Normal file
42
api/src/services/comments/comments.scenarios.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineScenario } from '@redwoodjs/testing/dist/api'
|
||||
|
||||
export const standard = defineScenario({
|
||||
comment: {
|
||||
one: {
|
||||
data: {
|
||||
name: 'Jane Doe',
|
||||
body: 'I like trees.',
|
||||
post: {
|
||||
create: {
|
||||
title: 'Redwood Leaves',
|
||||
body: 'The quick brown fox jumped over the lazy dog.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
two: {
|
||||
data: {
|
||||
name: 'John Doe',
|
||||
body: 'Hug a tree today',
|
||||
post: {
|
||||
create: {
|
||||
title: 'Root Systems',
|
||||
body: 'The five boxing wizards jump quickly',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const postOnly = defineScenario({
|
||||
post: {
|
||||
bark: {
|
||||
data: {
|
||||
title: 'Bark',
|
||||
body: "A tree's bark is worse than its bite",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
30
api/src/services/comments/comments.test.js
Normal file
30
api/src/services/comments/comments.test.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { comments, createComment } from './comments'
|
||||
|
||||
// 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-services
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('comments', () => {
|
||||
scenario('returns all comments', async (scenario) => {
|
||||
const result = await comments()
|
||||
|
||||
expect(result.length).toEqual(Object.keys(scenario.comment).length)
|
||||
})
|
||||
|
||||
scenario('postOnly', 'creates a new comment', async (scenario) => {
|
||||
const comment = await createComment({
|
||||
input: {
|
||||
name: 'Billy Bob',
|
||||
body: 'What is your favorite tree bark?',
|
||||
postId: scenario.post.bark.id,
|
||||
},
|
||||
})
|
||||
|
||||
expect(comment.name).toEqual('Billy Bob')
|
||||
expect(comment.body).toEqual('What is your favorite tree bark?')
|
||||
expect(comment.postId).toEqual(scenario.post.bark.id)
|
||||
expect(comment.createdAt).not.toEqual(null)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/core": "^0.50.0"
|
||||
"@redwoodjs/core": "1.1.1"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@redwoodjs/eslint-config"
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@redwoodjs/auth": "^0.50.0",
|
||||
"@redwoodjs/forms": "^0.50.0",
|
||||
"@redwoodjs/router": "^0.50.0",
|
||||
"@redwoodjs/web": "^0.50.0",
|
||||
"@redwoodjs/auth": "1.1.1",
|
||||
"@redwoodjs/forms": "1.1.1",
|
||||
"@redwoodjs/router": "1.1.1",
|
||||
"@redwoodjs/web": "1.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import CommentsCell from 'src/components/CommentsCell'
|
||||
|
||||
const Article = ({ article }) => {
|
||||
const truncate = (text, length) => {
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
const Article = ({ article, summary = false }) => {
|
||||
return (
|
||||
<article>
|
||||
<header>
|
||||
@@ -8,7 +13,14 @@ const Article = ({ article }) => {
|
||||
<Link to={routes.article({ id: article.id })}>{article.title}</Link>
|
||||
</h2>
|
||||
</header>
|
||||
<div className="mt-2 text-gray-900 font-light">{article.body}</div>
|
||||
<div className="mt-2 text-gray-900 font-light">
|
||||
{summary ? truncate(article.body, 100) : article.body}
|
||||
</div>
|
||||
{!summary && (
|
||||
<div className="mt-12">
|
||||
<CommentsCell />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import Article from './Article'
|
||||
|
||||
export const generated = () => {
|
||||
return (
|
||||
<Article
|
||||
article={{
|
||||
const ARTICLE = {
|
||||
id: 1,
|
||||
title: 'First Post',
|
||||
body:
|
||||
'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom.',
|
||||
createdAt: '2020-01-01T12:34:56Z',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
body: 'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom.',
|
||||
}
|
||||
|
||||
export const full = () => {
|
||||
return <Article article={ARTICLE} />
|
||||
}
|
||||
|
||||
export const summary = () => {
|
||||
return <Article article={ARTICLE} summary={true} />
|
||||
}
|
||||
|
||||
export default { title: 'Components/Article' }
|
||||
|
||||
@@ -1,18 +1,49 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { render, screen, waitFor } from '@redwoodjs/testing'
|
||||
import { standard } from 'src/components/CommentsCell/CommentsCell.mock'
|
||||
|
||||
import Article from './Article'
|
||||
|
||||
describe('Article', () => {
|
||||
it('renders a blog post', () => {
|
||||
const article = {
|
||||
const ARTICLE = {
|
||||
id: 1,
|
||||
title: 'First post',
|
||||
body: `Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Street art next level umami squid. Hammock hexagon glossier 8-bit banjo. Neutra la croix mixtape echo park four loko semiotics kitsch forage chambray. Semiotics salvia selfies jianbing hella shaman. Letterpress helvetica vaporware cronut, shaman butcher YOLO poke fixie hoodie gentrify woke heirloom.`,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
render(<Article article={article} />)
|
||||
|
||||
expect(screen.getByText(article.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(article.body)).toBeInTheDocument()
|
||||
describe('Article', () => {
|
||||
it('renders a blog post', () => {
|
||||
render(<Article article={ARTICLE} />)
|
||||
|
||||
expect(screen.getByText(ARTICLE.title)).toBeInTheDocument()
|
||||
expect(screen.getByText(ARTICLE.body)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders comments when displaying a full blog post', async () => {
|
||||
const comment = standard().comments[0]
|
||||
render(<Article article={ARTICLE} />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(comment.body)).toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a summary of a blog post', () => {
|
||||
render(<Article article={ARTICLE} summary={true} />)
|
||||
|
||||
expect(screen.getByText(ARTICLE.title)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Neutra tacos hot chicken prism raw denim, put a bird on it enamel pin post-ironic vape cred DIY. Str...'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render comments when displaying a summary', async () => {
|
||||
const comment = standard().comments[0]
|
||||
render(<Article article={ARTICLE} summary={true} />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(comment.body)).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Success = ({ articles }) => {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{articles.map((article) => (
|
||||
<Article article={article} key={article.id} />
|
||||
<Article article={article} key={article.id} summary={true} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@redwoodjs/testing'
|
||||
import { render, screen, within } from '@redwoodjs/testing'
|
||||
import { Loading, Empty, Failure, Success } from './ArticlesCell'
|
||||
import { standard } from './ArticlesCell.mock'
|
||||
|
||||
@@ -25,9 +25,15 @@ describe('ArticlesCell', () => {
|
||||
const articles = standard().articles
|
||||
render(<Success articles={articles} />)
|
||||
|
||||
expect(screen.getByText(articles[0].title)).toBeInTheDocument()
|
||||
expect(screen.getByText(articles[0].body)).toBeInTheDocument()
|
||||
expect(screen.getByText(articles[1].title)).toBeInTheDocument()
|
||||
expect(screen.getByText(articles[1].body)).toBeInTheDocument()
|
||||
articles.forEach((article) => {
|
||||
const truncatedBody = article.body.substring(0, 10)
|
||||
const matchedBody = screen.getByText(truncatedBody, { exact: false })
|
||||
const ellipsis = within(matchedBody).getByText('...', { exact: false })
|
||||
|
||||
expect(screen.getByText(article.title)).toBeInTheDocument()
|
||||
expect(screen.queryByText(article.body)).not.toBeInTheDocument()
|
||||
expect(matchedBody).toBeInTheDocument()
|
||||
expect(ellipsis).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
21
web/src/components/Comment/Comment.js
Normal file
21
web/src/components/Comment/Comment.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const formattedDate = (datetime) => {
|
||||
const parsedDate = new Date(datetime)
|
||||
const month = parsedDate.toLocaleString('default', { month: 'long' })
|
||||
return `${parsedDate.getDate()} ${month} ${parsedDate.getFullYear()}`
|
||||
}
|
||||
|
||||
const Comment = ({ comment }) => {
|
||||
return (
|
||||
<div className="bg-gray-200 p-8 rounded-lg">
|
||||
<header className="flex justify-between">
|
||||
<h2 className="font-semibold text-gray-700">{comment.name}</h2>
|
||||
<time className="text-xs text-gray-500" dateTime={comment.createdAt}>
|
||||
{formattedDate(comment.createdAt)}
|
||||
</time>
|
||||
</header>
|
||||
<p className="text-sm mt-2">{comment.body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Comment
|
||||
15
web/src/components/Comment/Comment.stories.js
Normal file
15
web/src/components/Comment/Comment.stories.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import Comment from './Comment'
|
||||
|
||||
export const generated = () => {
|
||||
return (
|
||||
<Comment
|
||||
comment={{
|
||||
name: 'Rob Cameron',
|
||||
body: 'This is the first comment!',
|
||||
createdAt: '2022-04-25T23:53:34Z',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default { title: 'Components/Comment' }
|
||||
23
web/src/components/Comment/Comment.test.js
Normal file
23
web/src/components/Comment/Comment.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@redwoodjs/testing/web'
|
||||
|
||||
import Comment from './Comment'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('Comment', () => {
|
||||
it('renders successfully', () => {
|
||||
const comment = {
|
||||
name: 'John Doe',
|
||||
body: 'This is my comment',
|
||||
createdAt: '2020-01-02T12:34:56Z',
|
||||
}
|
||||
render(<Comment comment={comment} />)
|
||||
expect(screen.getByText(comment.name)).toBeInTheDocument()
|
||||
expect(screen.getByText(comment.body)).toBeInTheDocument()
|
||||
const dateExpect = screen.getByText('2 January 2020')
|
||||
expect(dateExpect).toBeInTheDocument()
|
||||
expect(dateExpect.nodeName).toEqual('TIME')
|
||||
expect(dateExpect).toHaveAttribute('datetime', comment.createdAt)
|
||||
})
|
||||
})
|
||||
32
web/src/components/CommentsCell/CommentsCell.js
Normal file
32
web/src/components/CommentsCell/CommentsCell.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import Comment from 'src/components/Comment'
|
||||
export const QUERY = gql`
|
||||
query CommentsQuery {
|
||||
comments {
|
||||
id
|
||||
name
|
||||
body
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="text-center text-gray-500">No comments yet</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }) => (
|
||||
<div style={{ color: 'red' }}>Error: {error.message}</div>
|
||||
)
|
||||
|
||||
export const Success = ({ comments }) => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{comments.map((comment) => (
|
||||
// eslint-disable-next-line prettier/prettier
|
||||
<Comment key={comment.id} comment={comment} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
web/src/components/CommentsCell/CommentsCell.mock.js
Normal file
17
web/src/components/CommentsCell/CommentsCell.mock.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = () => ({
|
||||
comments: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Rob Cameron',
|
||||
body: 'First comment',
|
||||
createdAt: '2020-01-02T12:34:56Z',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'David Price',
|
||||
body: 'Second comment',
|
||||
createdAt: '2020-02-03T23:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
20
web/src/components/CommentsCell/CommentsCell.stories.js
Normal file
20
web/src/components/CommentsCell/CommentsCell.stories.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Loading, Empty, Failure, Success } from './CommentsCell'
|
||||
import { standard } from './CommentsCell.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/CommentsCell' }
|
||||
43
web/src/components/CommentsCell/CommentsCell.test.js
Normal file
43
web/src/components/CommentsCell/CommentsCell.test.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './CommentsCell'
|
||||
import { standard } from './CommentsCell.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('CommentsCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
render(<Empty />)
|
||||
expect(screen.getByText('No comments yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const comments = standard().comments
|
||||
render(<Success comments={comments} />)
|
||||
|
||||
comments.forEach((comment) => {
|
||||
expect(screen.getByText(comment.body)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -68,7 +68,7 @@ const ContactPage = () => {
|
||||
<FieldError name="name" className="block text-red-700" />
|
||||
|
||||
<Label
|
||||
name="name"
|
||||
name="email"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
@@ -89,7 +89,7 @@ const ContactPage = () => {
|
||||
<FieldError name="email" className="block text-red-700" />
|
||||
|
||||
<Label
|
||||
name="name"
|
||||
name="message"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user