Merge pull request #194 from jyapayne/add_categories
Add preliminary category support
This commit is contained in:
commit
42985fd2bc
13 changed files with 532 additions and 39 deletions
18
README.md
18
README.md
|
|
@ -63,6 +63,24 @@ test Runs tester
|
|||
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
|
||||
database for development and testing, then `nimble backend`
|
||||
which compiles and runs the forum's backend, and `nimble frontend`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
.modal-container .modal-body {
|
||||
max-height: none;
|
||||
|
|
@ -196,14 +214,12 @@ $threads-meta-color: #545d70;
|
|||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
// TODO: Abstract this into a "category" class.
|
||||
.category-color {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 0.3rem solid transparent;
|
||||
border-right: 0.3rem solid transparent;
|
||||
border-bottom: 0.6rem solid #98c766;
|
||||
border: 0.3rem solid #98c766;
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.load-more-separator {
|
||||
|
|
@ -714,14 +730,4 @@ hr {
|
|||
// - Hide features that have not been implemented yet.
|
||||
#main-buttons > section.navbar-section:nth-child(1) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#threads-list.table {
|
||||
tr > th:nth-child(2), tr > td:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.category, div.flag-button {
|
||||
display: none;
|
||||
}
|
||||
104
src/forum.nim
104
src/forum.nim
|
|
@ -527,10 +527,20 @@ proc updatePost(c: TForumData, postId: int, content: string,
|
|||
if row[0] == $postId:
|
||||
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
|
||||
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()
|
||||
|
|
@ -550,13 +560,17 @@ proc executeNewThread(c: TForumData, subject, msg: string): (int64, int64) =
|
|||
if msg.len == 0:
|
||||
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):
|
||||
raise newForumError("Message needs to be valid RST", @["msg"])
|
||||
|
||||
if rateLimitCheck(c):
|
||||
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:
|
||||
raise newForumError("Subject already exists", @["subject"])
|
||||
|
||||
|
|
@ -658,6 +672,18 @@ proc executeLike(c: TForumData, postId: int) =
|
|||
# Save the like.
|
||||
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) =
|
||||
# Verify the post and like exists for the current user.
|
||||
const likeQuery = sql"""
|
||||
|
|
@ -763,6 +789,19 @@ settings:
|
|||
|
||||
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":
|
||||
var
|
||||
start = getInt(@"start", 0)
|
||||
|
|
@ -1032,6 +1071,21 @@ routes:
|
|||
except ForumError as exc:
|
||||
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":
|
||||
createTFD()
|
||||
|
||||
|
|
@ -1143,6 +1197,45 @@ routes:
|
|||
except ForumError as exc:
|
||||
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":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
|
|
@ -1155,13 +1248,14 @@ routes:
|
|||
let formData = request.formData
|
||||
cond "msg" in formData
|
||||
cond "subject" in formData
|
||||
cond "categoryId" in formData
|
||||
|
||||
let msg = formData["msg"].body
|
||||
let subject = formData["subject"].body
|
||||
# TODO: category
|
||||
let categoryID = formData["categoryId"].body
|
||||
|
||||
try:
|
||||
let res = executeNewThread(c, subject, msg)
|
||||
let res = executeNewThread(c, subject, msg, categoryID)
|
||||
resp Http200, $(%[res[0], res[1]]), "application/json"
|
||||
except ForumError as exc:
|
||||
resp Http400, $(%exc.data), "application/json"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ type
|
|||
description*: string
|
||||
color*: string
|
||||
|
||||
CategoryList* = ref object
|
||||
categories*: seq[Category]
|
||||
|
||||
proc cmpNames*(cat1: Category, cat2: Category): int =
|
||||
cat1.name.cmp(cat2.name)
|
||||
|
||||
when defined(js):
|
||||
include karax/prelude
|
||||
|
|
@ -17,12 +22,13 @@ when defined(js):
|
|||
result = buildHtml():
|
||||
if category.name.len >= 0:
|
||||
tdiv(class="category",
|
||||
title=category.description,
|
||||
"data-color"="#" & category.color):
|
||||
tdiv(class="triangle",
|
||||
tdiv(class="category-color",
|
||||
style=style(
|
||||
(StyleAttr.borderBottom,
|
||||
kstring"0.6rem solid #" & category.color)
|
||||
(StyleAttr.border,
|
||||
kstring"0.3rem solid #" & category.color)
|
||||
))
|
||||
text category.name
|
||||
else:
|
||||
span()
|
||||
span()
|
||||
|
|
|
|||
193
src/frontend/categorypicker.nim
Normal file
193
src/frontend/categorypicker.nim
Normal 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)
|
||||
|
|
@ -83,7 +83,7 @@ proc render(): VNode =
|
|||
route([
|
||||
r("/newthread",
|
||||
(params: Params) =>
|
||||
(render(state.newThread))
|
||||
(render(state.newThread, getLoggedInUser()))
|
||||
),
|
||||
r("/profile/@username",
|
||||
(params: Params) =>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
when defined(js):
|
||||
import sugar, httpcore, options, json
|
||||
import dom except Event
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [kajax, kdom]
|
||||
|
||||
import error, replybox, threadlist, post
|
||||
import karaxutils
|
||||
import error, replybox, threadlist, post, category, user
|
||||
import karaxutils, categorypicker
|
||||
|
||||
type
|
||||
NewThread* = ref object
|
||||
|
|
@ -14,11 +15,13 @@ when defined(js):
|
|||
error: Option[PostError]
|
||||
replyBox: ReplyBox
|
||||
subject: kstring
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc newNewThread*(): NewThread =
|
||||
NewThread(
|
||||
replyBox: newReplyBox(nil),
|
||||
subject: ""
|
||||
subject: "",
|
||||
categoryPicker: newCategoryPicker()
|
||||
)
|
||||
|
||||
proc onSubjectChange(e: Event, n: VNode, state: NewThread) =
|
||||
|
|
@ -37,12 +40,16 @@ when defined(js):
|
|||
let uri = makeUri("newthread")
|
||||
# TODO: This is a hack, karax should support this.
|
||||
let formData = newFormData()
|
||||
let categoryID = state.categoryPicker.selectedCategoryID
|
||||
|
||||
formData.append("subject", state.subject)
|
||||
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))
|
||||
|
||||
proc render*(state: NewThread): VNode =
|
||||
proc render*(state: NewThread, currentUser: Option[User]): VNode =
|
||||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
tdiv(id="new-thread"):
|
||||
|
|
@ -55,6 +62,10 @@ when defined(js):
|
|||
if state.error.isSome():
|
||||
p(class="text-error"):
|
||||
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]())
|
||||
tdiv(class="footer"):
|
||||
|
||||
|
|
|
|||
|
|
@ -15,17 +15,20 @@ type
|
|||
|
||||
when defined(js):
|
||||
from dom import document
|
||||
import jsffi except `&`
|
||||
|
||||
include karax/prelude
|
||||
import karax / [vstyles, kajax, kdom]
|
||||
|
||||
import karaxutils, error, replybox, editbox, postbutton, delete
|
||||
import categorypicker
|
||||
|
||||
type
|
||||
State = ref object
|
||||
list: Option[PostList]
|
||||
loading: bool
|
||||
status: HttpCode
|
||||
error: Option[PostError]
|
||||
replyingTo: Option[Post]
|
||||
replyBox: ReplyBox
|
||||
editing: Option[Post] ## If in edit mode, this contains the post.
|
||||
|
|
@ -33,8 +36,10 @@ when defined(js):
|
|||
likeButton: LikeButton
|
||||
deleteModal: DeleteModal
|
||||
lockButton: LockButton
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc onReplyPosted(id: int)
|
||||
proc onCategoryChanged(oldCategory: Category, newCategory: Category)
|
||||
proc onEditPosted(id: int, content: string, subject: Option[string])
|
||||
proc onEditCancelled()
|
||||
proc onDeletePost(post: Post)
|
||||
|
|
@ -44,17 +49,37 @@ when defined(js):
|
|||
list: none[PostList](),
|
||||
loading: false,
|
||||
status: Http200,
|
||||
error: none[PostError](),
|
||||
replyingTo: none[Post](),
|
||||
replyBox: newReplyBox(onReplyPosted),
|
||||
editBox: newEditBox(onEditPosted, onEditCancelled),
|
||||
likeButton: newLikeButton(),
|
||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||
lockButton: newLockButton()
|
||||
lockButton: newLockButton(),
|
||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||
)
|
||||
|
||||
var
|
||||
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]) =
|
||||
state.loading = false
|
||||
state.status = httpStatus.HttpCode
|
||||
|
|
@ -66,6 +91,7 @@ when defined(js):
|
|||
state.list = some(list)
|
||||
|
||||
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.
|
||||
if postId.isSome():
|
||||
|
|
@ -179,6 +205,20 @@ when defined(js):
|
|||
span(class="more-post-count"):
|
||||
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 =
|
||||
let loggedIn = currentUser.isSome()
|
||||
let authoredByUser =
|
||||
|
|
@ -330,7 +370,10 @@ when defined(js):
|
|||
result = buildHtml():
|
||||
section(class="container grid-xl"):
|
||||
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:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
title="Thread cannot be replied to")
|
||||
|
|
@ -343,7 +386,7 @@ when defined(js):
|
|||
italic(class="fas fa-check-square fa-xs",
|
||||
title="Thread has a solution")
|
||||
text "Solved"
|
||||
render(list.thread.category)
|
||||
genCategories(list.thread, currentUser)
|
||||
tdiv(class="posts"):
|
||||
var prevPost: Option[Post] = none[Post]()
|
||||
for i, post in list.posts:
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
|
||||
db.exec(sql"""
|
||||
insert into category (id, name, description, color)
|
||||
values (0, 'Default', '', '');
|
||||
values (0, 'Default', 'The default category', '');
|
||||
""")
|
||||
|
||||
# -- Thread
|
||||
|
|
@ -115,7 +115,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
# Create default user.
|
||||
db.createUser(admin, Admin)
|
||||
|
||||
# Create test users if test or development
|
||||
# Create some test data for development
|
||||
if isTest or isDev:
|
||||
for rank in Spammer..Moderator:
|
||||
let rankLower = toLowerAscii($rank)
|
||||
|
|
@ -124,6 +124,14 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
email: $rankLower & "@localhost.local")
|
||||
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
|
||||
|
||||
db.exec(sql"""
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ template withBackend(body: untyped): untyped =
|
|||
|
||||
body
|
||||
|
||||
import browsertests/[scenario1, threads, issue181]
|
||||
import browsertests/[scenario1, threads, issue181, categories]
|
||||
|
||||
proc main() =
|
||||
spawn runProcess("geckodriver -p 4444 --log config")
|
||||
|
|
@ -64,6 +64,7 @@ proc main() =
|
|||
withBackend:
|
||||
scenario1.test(session, baseUrl)
|
||||
threads.test(session, baseUrl)
|
||||
categories.test(session, baseUrl)
|
||||
issue181.test(session, baseUrl)
|
||||
|
||||
session.close()
|
||||
|
|
|
|||
105
tests/browsertests/categories.nim
Normal file
105
tests/browsertests/categories.nim
Normal 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()
|
||||
|
|
@ -149,5 +149,5 @@ proc createThread*(session: Session, title, content: string) =
|
|||
click "#create-thread-btn"
|
||||
wait()
|
||||
|
||||
checkText "#thread-title", title
|
||||
checkText "#thread-title .title-text", title
|
||||
checkText ".original-post div.post-content", content
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ proc banUser(session: Session, baseUrl: string) =
|
|||
setUserRank baseUrl, "user", "banned"
|
||||
logout()
|
||||
|
||||
proc unBanUser(session: Session, baseUrl: string) =
|
||||
with session:
|
||||
login "admin", "admin"
|
||||
setUserRank baseUrl, "user", "user"
|
||||
logout()
|
||||
|
||||
proc userTests(session: Session, baseUrl: string) =
|
||||
suite "user thread tests":
|
||||
session.login("user", "user")
|
||||
|
|
@ -34,7 +40,7 @@ proc userTests(session: Session, baseUrl: string) =
|
|||
click "#create-thread-btn"
|
||||
wait()
|
||||
|
||||
checkText "#thread-title", userTitleStr
|
||||
checkText "#thread-title .title-text", userTitleStr
|
||||
checkText ".original-post div.post-content", userContentStr
|
||||
|
||||
session.logout()
|
||||
|
|
@ -99,7 +105,7 @@ proc adminTests(session: Session, baseUrl: string) =
|
|||
click "#create-thread-btn"
|
||||
wait()
|
||||
|
||||
checkText "#thread-title", adminTitleStr
|
||||
checkText "#thread-title .title-text", adminTitleStr
|
||||
checkText ".original-post div.post-content", adminContentStr
|
||||
|
||||
test "try create duplicate thread":
|
||||
|
|
@ -172,5 +178,7 @@ proc test*(session: Session, baseUrl: string) =
|
|||
anonymousTests(session, baseUrl)
|
||||
adminTests(session, baseUrl)
|
||||
|
||||
unBanUser(session, baseUrl)
|
||||
|
||||
session.navigate(baseUrl)
|
||||
session.wait()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue