Merge pull request #194 from jyapayne/add_categories

Add preliminary category support
This commit is contained in:
Dominik Picheta 2020-02-15 17:08:26 +00:00 • committed by GitHub
commit 42985fd2bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 532 additions and 39 deletions

View file

@ -63,6 +63,24 @@ test Runs tester
fasttest Runs tester without recompiling backend fasttest Runs tester without recompiling backend
``` ```
To get up and running:
```bash
git clone https://github.com/nim-lang/nimforum
cd nimforum
git submodule update --init --recursive
# Setup the db with user: admin, pass: admin and some other users
nimble devdb
# Run this again if frontend code changes
nimble frontend
# Will start a server at localhost:5000
nimble backend
```
Development typically involves running `nimble devdb` which sets up the Development typically involves running `nimble devdb` which sets up the
database for development and testing, then `nimble backend` database for development and testing, then `nimble backend`
which compiles and runs the forum's backend, and `nimble frontend` which compiles and runs the forum's backend, and `nimble frontend`

View file

@ -107,6 +107,24 @@ $logo-height: $navbar-height - 20px;
} }
} }
#category-selection {
.dropdown {
.btn {
margin-right: 0px;
}
}
.plus-btn {
margin-right: 0px;
i {
margin-right: 0px;
}
}
}
.category {
white-space: nowrap;
}
#new-thread { #new-thread {
.modal-container .modal-body { .modal-container .modal-body {
max-height: none; max-height: none;
@ -196,14 +214,12 @@ $threads-meta-color: #545d70;
} }
} }
.triangle { .category-color {
// TODO: Abstract this into a "category" class.
width: 0; width: 0;
height: 0; height: 0;
border-left: 0.3rem solid transparent; border: 0.3rem solid #98c766;
border-right: 0.3rem solid transparent;
border-bottom: 0.6rem solid #98c766;
display: inline-block; display: inline-block;
margin-right: 5px;
} }
.load-more-separator { .load-more-separator {
@ -714,14 +730,4 @@ hr {
// - Hide features that have not been implemented yet. // - Hide features that have not been implemented yet.
#main-buttons > section.navbar-section:nth-child(1) { #main-buttons > section.navbar-section:nth-child(1) {
display: none; display: none;
}
#threads-list.table {
tr > th:nth-child(2), tr > td:nth-child(2) {
display: none;
}
}
.category, div.flag-button {
display: none;
} }

View file

@ -527,10 +527,20 @@ proc updatePost(c: TForumData, postId: int, content: string,
if row[0] == $postId: if row[0] == $postId:
exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId) exec(db, crud(crUpdate, "thread", "name"), subject.get(), threadId)
proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) = proc updateThread(c: TForumData, threadId: string, queryKeys: seq[string], queryValues: seq[string]) =
let threadAuthor = selectThreadAuthor(threadId.parseInt)
# Verify that the current user has permissions to edit the specified thread.
let canEdit = c.rank == Admin or c.userid == threadAuthor.name
if not canEdit:
raise newForumError("You cannot edit this thread")
exec(db, crud(crUpdate, "thread", queryKeys), queryValues)
proc executeNewThread(c: TForumData, subject, msg, categoryID: string): (int64, int64) =
const const
query = sql""" query = sql"""
insert into thread(name, views, modified) values (?, 0, DATETIME('now')) insert into thread(name, views, modified, category) values (?, 0, DATETIME('now'), ?)
""" """
assert c.loggedIn() assert c.loggedIn()
@ -550,13 +560,17 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
if msg.len == 0: if msg.len == 0:
raise newForumError("Message is empty", @["msg"]) raise newForumError("Message is empty", @["msg"])
let catID = getInt(categoryID, -1)
if catID == -1:
raise newForumError("CategoryID is invalid", @["categoryId"])
if not validateRst(c, msg): if not validateRst(c, msg):
raise newForumError("Message needs to be valid RST", @["msg"]) raise newForumError("Message needs to be valid RST", @["msg"])
if rateLimitCheck(c): if rateLimitCheck(c):
raise newForumError("You're posting too fast!") raise newForumError("You're posting too fast!")
result[0] = tryInsertID(db, query, subject).int result[0] = tryInsertID(db, query, subject, categoryID).int
if result[0] < 0: if result[0] < 0:
raise newForumError("Subject already exists", @["subject"]) raise newForumError("Subject already exists", @["subject"])
@ -658,6 +672,18 @@ proc executeLike(c: TForumData, postId: int) =
# Save the like. # Save the like.
exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId) exec(db, crud(crCreate, "like", "author", "post"), c.userid, postId)
proc executeNewCategory(c: TForumData, name, color, description: string): int64 =
let canAdd = c.rank == Admin
if not canAdd:
raise newForumError("You do not have permissions to add a category.")
if name.len == 0:
raise newForumError("Category name must not be empty!", @["name"])
result = insertID(db, crud(crCreate, "category", "name", "color", "description"), name, color, description)
proc executeUnlike(c: TForumData, postId: int) = proc executeUnlike(c: TForumData, postId: int) =
# Verify the post and like exists for the current user. # Verify the post and like exists for the current user.
const likeQuery = sql""" const likeQuery = sql"""
@ -763,6 +789,19 @@ settings:
routes: routes:
get "/categories.json":
# TODO: Limit this query in the case of many many categories
const categoriesQuery = sql"""select * from category;"""
var list = CategoryList(categories: @[])
for data in getAllRows(db, categoriesQuery):
let category = Category(
id: data[0].getInt, name: data[1], description: data[2], color: data[3]
)
list.categories.add(category)
resp $(%list), "application/json"
get "/threads.json": get "/threads.json":
var var
start = getInt(@"start", 0) start = getInt(@"start", 0)
@ -1032,6 +1071,21 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post "/createCategory":
createTFD()
let formData = request.formData
let name = formData["name"].body
let color = formData["color"].body.replace("#", "")
let description = formData["description"].body
try:
let id = executeNewCategory(c, name, color, description)
let category = Category(id: id.int, name: name, color: color, description: description)
resp Http200, $(%category), "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
get "/status.json": get "/status.json":
createTFD() createTFD()
@ -1143,6 +1197,45 @@ routes:
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"
post "/updateThread":
# TODO: Add some way of keeping track of modifications for historical
# purposes
createTFD()
if not c.loggedIn():
let err = PostError(
errorFields: @[],
message: "Not logged in."
)
resp Http401, $(%err), "application/json"
let formData = request.formData
cond "threadId" in formData
let threadId = formData["threadId"].body
# TODO: might want to add more properties here under a tighter permissions
# model
let keys = ["name", "category", "solution"]
# optional parameters
var
queryValues: seq[string] = @[]
queryKeys: seq[string] = @[]
for key in keys:
if key in formData:
queryKeys.add(key)
queryValues.add(formData[key].body)
if queryKeys.len() > 0:
queryValues.add(threadId)
try:
updateThread(c, threadId, queryKeys, queryValues)
resp Http200, "{}", "application/json"
except ForumError as exc:
resp Http400, $(%exc.data), "application/json"
post "/newthread": post "/newthread":
createTFD() createTFD()
if not c.loggedIn(): if not c.loggedIn():
@ -1155,13 +1248,14 @@ routes:
let formData = request.formData let formData = request.formData
cond "msg" in formData cond "msg" in formData
cond "subject" in formData cond "subject" in formData
cond "categoryId" in formData
let msg = formData["msg"].body let msg = formData["msg"].body
let subject = formData["subject"].body let subject = formData["subject"].body
# TODO: category let categoryID = formData["categoryId"].body
try: try:
let res = executeNewThread(c, subject, msg) let res = executeNewThread(c, subject, msg, categoryID)
resp Http200, $(%[res[0], res[1]]), "application/json" resp Http200, $(%[res[0], res[1]]), "application/json"
except ForumError as exc: except ForumError as exc:
resp Http400, $(%exc.data), "application/json" resp Http400, $(%exc.data), "application/json"

View file

@ -6,6 +6,11 @@ type
description*: string description*: string
color*: string color*: string
CategoryList* = ref object
categories*: seq[Category]
proc cmpNames*(cat1: Category, cat2: Category): int =
cat1.name.cmp(cat2.name)
when defined(js): when defined(js):
include karax/prelude include karax/prelude
@ -17,12 +22,13 @@ when defined(js):
result = buildHtml(): result = buildHtml():
if category.name.len >= 0: if category.name.len >= 0:
tdiv(class="category", tdiv(class="category",
title=category.description,
"data-color"="#" & category.color): "data-color"="#" & category.color):
tdiv(class="triangle", tdiv(class="category-color",
style=style( style=style(
(StyleAttr.borderBottom, (StyleAttr.border,
kstring"0.6rem solid #" & category.color) kstring"0.3rem solid #" & category.color)
)) ))
text category.name text category.name
else: else:
span() span()

View file

@ -0,0 +1,193 @@
when defined(js):
import sugar, httpcore, options, json, strutils, algorithm
import dom except Event
include karax/prelude
import karax / [kajax, kdom, vstyles, vdom]
import error, replybox, threadlist, post, category, user
import category, karaxutils
type
CategoryPicker* = ref object of VComponent
list: Option[CategoryList]
selectedCategoryID*: int
loading: bool
modalShown: bool
addEnabled: bool
status: HttpCode
error: Option[PostError]
onCategoryChange: proc (oldCategory: Category, newCategory: Category)
onAddCategory: proc (category: Category)
proc slug(name: string): string =
name.strip().replace(" ", "-").toLowerAscii
proc onCategoryLoad(state: CategoryPicker): proc (httpStatus: int, response: kstring) =
return
proc (httpStatus: int, response: kstring) =
state.loading = false
state.status = httpStatus.HttpCode
if state.status != Http200: return
let parsed = parseJson($response)
let list = parsed.to(CategoryList)
list.categories.sort(cmpNames)
if state.list.isSome:
state.list.get().categories.add(list.categories)
else:
state.list = some(list)
if state.selectedCategoryID > state.list.get().categories.len():
state.selectedCategoryID = 0
proc loadCategories(state: CategoryPicker) =
if not state.loading:
state.loading = true
ajaxGet(makeUri("categories.json"), @[], onCategoryLoad(state))
proc `[]`*(state: CategoryPicker, id: int): Category =
for cat in state.list.get().categories:
if cat.id == id:
return cat
raise newException(IndexError, "Category at " & $id & " not found!")
proc nullAddCategory(category: Category) = discard
proc nullCategoryChange(oldCategory: Category, newCategory: Category) = discard
proc newCategoryPicker*(
onCategoryChange: proc(oldCategory: Category, newCategory: Category) = nullCategoryChange,
onAddCategory: proc(category: Category) = nullAddCategory
): CategoryPicker =
result = CategoryPicker(
list: none[CategoryList](),
selectedCategoryID: 0,
loading: false,
modalShown: false,
addEnabled: false,
status: Http200,
error: none[PostError](),
onCategoryChange: onCategoryChange,
onAddCategory: onAddCategory
)
proc setAddEnabled*(state: CategoryPicker, enabled: bool) =
state.addEnabled = enabled
proc select*(state: CategoryPicker, id: int) =
state.selectedCategoryID = id
state.markDirty()
proc onCategoryClick(state: CategoryPicker, category: Category): proc (ev: Event, n: VNode) =
# this is necessary to capture the right value
let cat = category
return
proc (ev: Event, n: VNode) =
let oldCategory = state[state.selectedCategoryID]
state.select(cat.id)
state.onCategoryChange(oldCategory, cat)
proc onAddCategoryPost(httpStatus: int, response: kstring, state: CategoryPicker) =
postFinished:
state.modalShown = false
let j = parseJson($response)
let category = j.to(Category)
state.list.get().categories.add(category)
state.list.get().categories.sort(cmpNames)
state.select(category.id)
state.onAddCategory(category)
proc onAddCategoryClick(state: CategoryPicker) =
state.loading = true
state.error = none[PostError]()
let uri = makeUri("createCategory")
let form = dom.document.getElementById("add-category-form")
let formData = newFormData(form)
ajaxPost(uri, @[], cast[cstring](formData),
(s: int, r: kstring) => onAddCategoryPost(s, r, state))
proc onClose(ev: Event, n: VNode, state: CategoryPicker) =
state.modalShown = false
state.markDirty()
ev.preventDefault()
proc genAddCategory(state: CategoryPicker): VNode =
result = buildHtml():
tdiv(id="add-category"):
button(class="plus-btn btn btn-link",
onClick=(ev: Event, n: VNode) => (
state.modalShown = true;
state.markDirty()
)):
italic(class="fas fa-plus")
tdiv(class=class({"active": state.modalShown}, "modal modal-sm")):
a(href="", class="modal-overlay", "aria-label"="close",
onClick=(ev: Event, n: VNode) => onClose(ev, n, state))
tdiv(class="modal-container"):
tdiv(class="modal-header"):
tdiv(class="card-title h5"):
text "Add New Category"
tdiv(class="modal-body"):
form(id="add-category-form"):
genFormField(
state.error, "name", "Name", "text", false,
placeholder="Category Name")
genFormField(
state.error, "color", "Color", "color", false,
placeholder="#XXYYZZ"
)
genFormField(
state.error,
"description",
"Description",
"text",
true,
placeholder="Description"
)
tdiv(class="modal-footer"):
button(
id="add-category-btn",
class="btn btn-primary",
onClick=(ev: Event, n: VNode) =>
state.onAddCategoryClick()):
text "Add"
proc render*(state: CategoryPicker, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let currentAdmin =
loggedIn and currentUser.get().rank == Admin
if currentAdmin:
state.setAddEnabled(true)
if state.status != Http200:
return renderError("Couldn't retrieve categories.", state.status)
if state.list.isNone:
state.loadCategories()
return buildHtml(tdiv(class="loading loading-lg"))
let list = state.list.get().categories
let selectedCategory = state[state.selectedCategoryID]
result = buildHtml():
tdiv(id="category-selection", class="input-group"):
tdiv(class="dropdown"):
a(class="btn btn-link dropdown-toggle", tabindex="0"):
tdiv(class="selected-category d-inline-block"):
render(selectedCategory)
text " "
italic(class="fas fa-caret-down")
ul(class="menu"):
for category in list:
li(class="menu-item"):
a(class="category-" & $category.id & " " & category.name.slug,
onClick=onCategoryClick(state, category)):
render(category)
if state.addEnabled:
genAddCategory(state)

View file

@ -83,7 +83,7 @@ proc render(): VNode =
route([ route([
r("/newthread", r("/newthread",
(params: Params) => (params: Params) =>
(render(state.newThread)) (render(state.newThread, getLoggedInUser()))
), ),
r("/profile/@username", r("/profile/@username",
(params: Params) => (params: Params) =>

View file

@ -1,12 +1,13 @@
when defined(js): when defined(js):
import sugar, httpcore, options, json import sugar, httpcore, options, json
import dom except Event import dom except Event
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [kajax, kdom] import karax / [kajax, kdom]
import error, replybox, threadlist, post import error, replybox, threadlist, post, category, user
import karaxutils import karaxutils, categorypicker
type type
NewThread* = ref object NewThread* = ref object
@ -14,11 +15,13 @@ when defined(js):
error: Option[PostError] error: Option[PostError]
replyBox: ReplyBox replyBox: ReplyBox
subject: kstring subject: kstring
categoryPicker: CategoryPicker
proc newNewThread*(): NewThread = proc newNewThread*(): NewThread =
NewThread( NewThread(
replyBox: newReplyBox(nil), replyBox: newReplyBox(nil),
subject: "" subject: "",
categoryPicker: newCategoryPicker()
) )
proc onSubjectChange(e: Event, n: VNode, state: NewThread) = proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
@ -37,12 +40,16 @@ when defined(js):
let uri = makeUri("newthread") let uri = makeUri("newthread")
# TODO: This is a hack, karax should support this. # TODO: This is a hack, karax should support this.
let formData = newFormData() let formData = newFormData()
let categoryID = state.categoryPicker.selectedCategoryID
formData.append("subject", state.subject) formData.append("subject", state.subject)
formData.append("msg", state.replyBox.getText()) formData.append("msg", state.replyBox.getText())
ajaxPost(uri, @[], cast[cstring](formData), formData.append("categoryId", $categoryID)
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCreatePost(s, r, state)) (s: int, r: kstring) => onCreatePost(s, r, state))
proc render*(state: NewThread): VNode = proc render*(state: NewThread, currentUser: Option[User]): VNode =
result = buildHtml(): result = buildHtml():
section(class="container grid-xl"): section(class="container grid-xl"):
tdiv(id="new-thread"): tdiv(id="new-thread"):
@ -55,6 +62,10 @@ when defined(js):
if state.error.isSome(): if state.error.isSome():
p(class="text-error"): p(class="text-error"):
text state.error.get().message text state.error.get().message
tdiv():
label(class="d-inline-block form-label"):
text "Category"
render(state.categoryPicker, currentUser)
renderContent(state.replyBox, none[Thread](), none[Post]()) renderContent(state.replyBox, none[Thread](), none[Post]())
tdiv(class="footer"): tdiv(class="footer"):

View file

@ -15,17 +15,20 @@ type
when defined(js): when defined(js):
from dom import document from dom import document
import jsffi except `&`
include karax/prelude include karax/prelude
import karax / [vstyles, kajax, kdom] import karax / [vstyles, kajax, kdom]
import karaxutils, error, replybox, editbox, postbutton, delete import karaxutils, error, replybox, editbox, postbutton, delete
import categorypicker
type type
State = ref object State = ref object
list: Option[PostList] list: Option[PostList]
loading: bool loading: bool
status: HttpCode status: HttpCode
error: Option[PostError]
replyingTo: Option[Post] replyingTo: Option[Post]
replyBox: ReplyBox replyBox: ReplyBox
editing: Option[Post] ## If in edit mode, this contains the post. editing: Option[Post] ## If in edit mode, this contains the post.
@ -33,8 +36,10 @@ when defined(js):
likeButton: LikeButton likeButton: LikeButton
deleteModal: DeleteModal deleteModal: DeleteModal
lockButton: LockButton lockButton: LockButton
categoryPicker: CategoryPicker
proc onReplyPosted(id: int) proc onReplyPosted(id: int)
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
proc onEditPosted(id: int, content: string, subject: Option[string]) proc onEditPosted(id: int, content: string, subject: Option[string])
proc onEditCancelled() proc onEditCancelled()
proc onDeletePost(post: Post) proc onDeletePost(post: Post)
@ -44,17 +49,37 @@ when defined(js):
list: none[PostList](), list: none[PostList](),
loading: false, loading: false,
status: Http200, status: Http200,
error: none[PostError](),
replyingTo: none[Post](), replyingTo: none[Post](),
replyBox: newReplyBox(onReplyPosted), replyBox: newReplyBox(onReplyPosted),
editBox: newEditBox(onEditPosted, onEditCancelled), editBox: newEditBox(onEditPosted, onEditCancelled),
likeButton: newLikeButton(), likeButton: newLikeButton(),
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil), deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
lockButton: newLockButton() lockButton: newLockButton(),
categoryPicker: newCategoryPicker(onCategoryChanged)
) )
var var
state = newState() state = newState()
proc onCategoryPost(httpStatus: int, response: kstring, state: State) =
state.loading = false
postFinished:
discard
# TODO: show success message
proc onCategoryChanged(oldCategory: Category, newCategory: Category) =
let uri = makeUri("/updateThread")
let formData = newFormData()
formData.append("threadId", $state.list.get().thread.id)
formData.append("category", $newCategory.id)
state.loading = true
ajaxPost(uri, @[], formData.to(cstring),
(s: int, r: kstring) => onCategoryPost(s, r, state))
proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) = proc onPostList(httpStatus: int, response: kstring, postId: Option[int]) =
state.loading = false state.loading = false
state.status = httpStatus.HttpCode state.status = httpStatus.HttpCode
@ -66,6 +91,7 @@ when defined(js):
state.list = some(list) state.list = some(list)
dom.document.title = list.thread.topic & " - " & dom.document.title dom.document.title = list.thread.topic & " - " & dom.document.title
state.categoryPicker.select(list.thread.category.id)
# The anchor should be jumped to once all the posts have been loaded. # The anchor should be jumped to once all the posts have been loaded.
if postId.isSome(): if postId.isSome():
@ -179,6 +205,20 @@ when defined(js):
span(class="more-post-count"): span(class="more-post-count"):
text "(" & $post.moreBefore.len & ")" text "(" & $post.moreBefore.len & ")"
proc genCategories(thread: Thread, currentUser: Option[User]): VNode =
let loggedIn = currentUser.isSome()
let authoredByUser =
loggedIn and currentUser.get().name == thread.author.name
let currentAdmin =
currentUser.isSome() and currentUser.get().rank == Admin
result = buildHtml():
tdiv():
if authoredByUser or currentAdmin:
render(state.categoryPicker, currentUser)
else:
render(thread.category)
proc genPostButtons(post: Post, currentUser: Option[User]): Vnode = proc genPostButtons(post: Post, currentUser: Option[User]): Vnode =
let loggedIn = currentUser.isSome() let loggedIn = currentUser.isSome()
let authoredByUser = let authoredByUser =
@ -330,7 +370,10 @@ when defined(js):
result = buildHtml(): result = buildHtml():
section(class="container grid-xl"): section(class="container grid-xl"):
tdiv(id="thread-title", class="title"): tdiv(id="thread-title", class="title"):
p(): text list.thread.topic if state.error.isSome():
span(class="text-error"):
text state.error.get().message
p(class="title-text"): text list.thread.topic
if list.thread.isLocked: if list.thread.isLocked:
italic(class="fas fa-lock fa-xs", italic(class="fas fa-lock fa-xs",
title="Thread cannot be replied to") title="Thread cannot be replied to")
@ -343,7 +386,7 @@ when defined(js):
italic(class="fas fa-check-square fa-xs", italic(class="fas fa-check-square fa-xs",
title="Thread has a solution") title="Thread has a solution")
text "Solved" text "Solved"
render(list.thread.category) genCategories(list.thread, currentUser)
tdiv(class="posts"): tdiv(class="posts"):
var prevPost: Option[Post] = none[Post]() var prevPost: Option[Post] = none[Post]()
for i, post in list.posts: for i, post in list.posts:

View file

@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
db.exec(sql""" db.exec(sql"""
insert into category (id, name, description, color) insert into category (id, name, description, color)
values (0, 'Default', '', ''); values (0, 'Default', 'The default category', '');
""") """)
# -- Thread # -- Thread
@ -115,7 +115,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
# Create default user. # Create default user.
db.createUser(admin, Admin) db.createUser(admin, Admin)
# Create test users if test or development # Create some test data for development
if isTest or isDev: if isTest or isDev:
for rank in Spammer..Moderator: for rank in Spammer..Moderator:
let rankLower = toLowerAscii($rank) let rankLower = toLowerAscii($rank)
@ -124,6 +124,14 @@ proc initialiseDb(admin: tuple[username, password, email: string],
email: $rankLower & "@localhost.local") email: $rankLower & "@localhost.local")
db.createUser(user, rank) db.createUser(user, rank)
db.exec(sql"""
insert into category (name, description, color)
values ('Libraries', 'Libraries and library development', '0198E1'),
('Announcements', 'Announcements by Nim core devs', 'FFEB3B'),
('Fun', 'Posts that are just for fun', '00897B'),
('Potential Issues', 'Potential Nim compiler issues', 'E53935');
""")
# -- Post # -- Post
db.exec(sql""" db.exec(sql"""

View file

@ -43,7 +43,7 @@ template withBackend(body: untyped): untyped =
body body
import browsertests/[scenario1, threads, issue181] import browsertests/[scenario1, threads, issue181, categories]
proc main() = proc main() =
spawn runProcess("geckodriver -p 4444 --log config") spawn runProcess("geckodriver -p 4444 --log config")
@ -64,6 +64,7 @@ proc main() =
withBackend: withBackend:
scenario1.test(session, baseUrl) scenario1.test(session, baseUrl)
threads.test(session, baseUrl) threads.test(session, baseUrl)
categories.test(session, baseUrl)
issue181.test(session, baseUrl) issue181.test(session, baseUrl)
session.close() session.close()

View file

@ -0,0 +1,105 @@
import unittest, options, os, common
import webdriver
proc selectCategory(session: Session, name: string) =
with session:
click "#category-selection .dropdown-toggle"
click "#category-selection ." & name
proc categoriesUserTests(session: Session, baseUrl: string) =
let
title = "Category Test"
content = "Choosing category test"
suite "user tests":
with session:
navigate baseUrl
wait()
login "user", "user"
setup:
with session:
navigate baseUrl
wait()
test "no category add available":
with session:
click "#new-thread-btn"
wait()
checkIsNone "#add-category"
test "can create category thread":
with session:
click "#new-thread-btn"
wait()
sendKeys "#thread-title", title
selectCategory "fun"
sendKeys "#reply-textarea", content
click "#create-thread-btn"
wait()
checkText "#thread-title .category", "Fun"
navigate baseUrl
wait()
ensureExists title, LinkTextSelector
session.logout()
proc categoriesAdminTests(session: Session, baseUrl: string) =
let
name = "Category Test"
color = "Creating category test"
description = "This is a description"
suite "admin tests":
with session:
navigate baseUrl
wait()
login "admin", "admin"
test "can create category":
with session:
click "#new-thread-btn"
wait()
ensureExists "#add-category"
click "#add-category .plus-btn"
wait()
clear "#add-category input[name='name']"
clear "#add-category input[name='color']"
clear "#add-category input[name='description']"
sendKeys "#add-category input[name='name']", name
sendKeys "#add-category input[name='color']", color
sendKeys "#add-category input[name='description']", description
click "#add-category #add-category-btn"
wait()
checkText "#category-selection .selected-category", name
session.logout()
proc test*(session: Session, baseUrl: string) =
session.navigate(baseUrl)
session.wait()
categoriesUserTests(session, baseUrl)
categoriesAdminTests(session, baseUrl)
session.navigate(baseUrl)
session.wait()

View file

@ -149,5 +149,5 @@ proc createThread*(session: Session, title, content: string) =
click "#create-thread-btn" click "#create-thread-btn"
wait() wait()
checkText "#thread-title", title checkText "#thread-title .title-text", title
checkText ".original-post div.post-content", content checkText ".original-post div.post-content", content

View file

@ -15,6 +15,12 @@ proc banUser(session: Session, baseUrl: string) =
setUserRank baseUrl, "user", "banned" setUserRank baseUrl, "user", "banned"
logout() logout()
proc unBanUser(session: Session, baseUrl: string) =
with session:
login "admin", "admin"
setUserRank baseUrl, "user", "user"
logout()
proc userTests(session: Session, baseUrl: string) = proc userTests(session: Session, baseUrl: string) =
suite "user thread tests": suite "user thread tests":
session.login("user", "user") session.login("user", "user")
@ -34,7 +40,7 @@ proc userTests(session: Session, baseUrl: string) =
click "#create-thread-btn" click "#create-thread-btn"
wait() wait()
checkText "#thread-title", userTitleStr checkText "#thread-title .title-text", userTitleStr
checkText ".original-post div.post-content", userContentStr checkText ".original-post div.post-content", userContentStr
session.logout() session.logout()
@ -99,7 +105,7 @@ proc adminTests(session: Session, baseUrl: string) =
click "#create-thread-btn" click "#create-thread-btn"
wait() wait()
checkText "#thread-title", adminTitleStr checkText "#thread-title .title-text", adminTitleStr
checkText ".original-post div.post-content", adminContentStr checkText ".original-post div.post-content", adminContentStr
test "try create duplicate thread": test "try create duplicate thread":
@ -172,5 +178,7 @@ proc test*(session: Session, baseUrl: string) =
anonymousTests(session, baseUrl) anonymousTests(session, baseUrl)
adminTests(session, baseUrl) adminTests(session, baseUrl)
unBanUser(session, baseUrl)
session.navigate(baseUrl) session.navigate(baseUrl)
session.wait() session.wait()