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
```
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`

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 {
.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;
}

View file

@ -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"

View file

@ -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()

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([
r("/newthread",
(params: Params) =>
(render(state.newThread))
(render(state.newThread, getLoggedInUser()))
),
r("/profile/@username",
(params: Params) =>

View file

@ -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"):

View file

@ -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:

View file

@ -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"""

View file

@ -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()

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"
wait()
checkText "#thread-title", title
checkText "#thread-title .title-text", title
checkText ".original-post div.post-content", content

View file

@ -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()