Compare commits
16 commits
github_act
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c2011323 | ||
|
|
4a181e9cb1 | ||
|
|
4f00ea5942 | ||
|
|
9994fc7840 | ||
|
|
ceb04561cd | ||
|
|
40d7b1cf02 |
||
|
|
c4684155f5 | ||
|
|
f940f81861 | ||
|
|
a1601b4600 |
||
|
|
35e0de7b91 |
||
|
|
7954a38601 |
||
|
|
48c025ae78 |
||
|
|
8782dff349 | ||
|
|
0055a12fc1 | ||
|
|
8cd5c45cda | ||
|
|
5b7b271627 |
11 changed files with 225 additions and 25 deletions
|
|
@ -276,7 +276,7 @@ template createTFD() =
|
|||
new(c)
|
||||
init(c)
|
||||
c.req = request
|
||||
if request.cookies.len > 0:
|
||||
if cookies(request).len > 0:
|
||||
checkLoggedIn(c)
|
||||
|
||||
#[ DB functions. TODO: Move to another module? ]#
|
||||
|
|
@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
id: threadRow[0].parseInt,
|
||||
topic: threadRow[1],
|
||||
category: Category(
|
||||
id: threadRow[5].parseInt,
|
||||
name: threadRow[6],
|
||||
description: threadRow[7],
|
||||
color: threadRow[8]
|
||||
id: threadRow[6].parseInt,
|
||||
name: threadRow[7],
|
||||
description: threadRow[8],
|
||||
color: threadRow[9]
|
||||
),
|
||||
users: @[],
|
||||
replies: posts[0].parseInt-1,
|
||||
|
|
@ -412,6 +412,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
|
|||
creation: posts[1].parseInt,
|
||||
isLocked: threadRow[4] == "1",
|
||||
isSolved: false, # TODO: Add a field to `post` to identify the solution.
|
||||
isPinned: threadRow[5] == "1"
|
||||
)
|
||||
|
||||
# Gather the users list.
|
||||
|
|
@ -709,6 +710,13 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
|
|||
# Save the like.
|
||||
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
|
||||
|
||||
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
|
||||
if c.rank < Moderator:
|
||||
raise newForumError("You do not have permission to pin this thread.")
|
||||
|
||||
# (Un)pin this thread
|
||||
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
|
||||
|
||||
proc executeDeletePost(c: TForumData, postId: int) =
|
||||
# Verify that this post belongs to the user.
|
||||
const postQuery = sql"""
|
||||
|
|
@ -833,7 +841,7 @@ routes:
|
|||
categoryArgs.insert($categoryId, 0)
|
||||
|
||||
const threadsQuery =
|
||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
c.id, c.name, c.description, c.color,
|
||||
u.id, u.name, u.email, strftime('%s', u.lastOnline),
|
||||
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
|
||||
|
|
@ -846,14 +854,14 @@ routes:
|
|||
order by p.author
|
||||
limit 1
|
||||
)
|
||||
order by modified desc limit ?, ?;"""
|
||||
order by isPinned desc, modified desc limit ?, ?;"""
|
||||
|
||||
let thrCount = getValue(db, countQuery, countArgs).parseInt()
|
||||
let moreCount = max(0, thrCount - (start + count))
|
||||
|
||||
var list = ThreadList(threads: @[], moreCount: moreCount)
|
||||
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
|
||||
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
|
||||
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
|
||||
list.threads.add(thread)
|
||||
|
||||
resp $(%list), "application/json"
|
||||
|
|
@ -868,12 +876,17 @@ routes:
|
|||
count = 10
|
||||
|
||||
const threadsQuery =
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
|
||||
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
|
||||
c.id, c.name, c.description, c.color
|
||||
from thread t, category c
|
||||
where t.id = ? and isDeleted = 0 and category = c.id;"""
|
||||
|
||||
let threadRow = getRow(db, threadsQuery, id)
|
||||
if threadRow[0].len == 0:
|
||||
let err = PostError(
|
||||
message: "Specified thread does not exist"
|
||||
)
|
||||
resp Http404, $(%err), "application/json"
|
||||
let thread = selectThread(threadRow, selectThreadAuthor(id))
|
||||
|
||||
let postsQuery =
|
||||
|
|
@ -919,9 +932,14 @@ routes:
|
|||
|
||||
get "/specific_posts.json":
|
||||
createTFD()
|
||||
var
|
||||
var ids: JsonNode
|
||||
try:
|
||||
ids = parseJson(@"ids")
|
||||
|
||||
except JsonParsingError:
|
||||
let err = PostError(
|
||||
message: "Invalid JSON in the `ids` parameter"
|
||||
)
|
||||
resp Http400, $(%err), "application/json"
|
||||
cond ids.kind == JArray
|
||||
let intIDs = ids.elems.map(x => x.getInt())
|
||||
let postsQuery = sql("""
|
||||
|
|
@ -1339,6 +1357,33 @@ routes:
|
|||
except ForumError as exc:
|
||||
resp Http400, $(%exc.data), "application/json"
|
||||
|
||||
post re"/(pin|unpin)":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
let err = PostError(
|
||||
errorFields: @[],
|
||||
message: "Not logged in."
|
||||
)
|
||||
resp Http401, $(%err), "application/json"
|
||||
|
||||
let formData = request.formData
|
||||
cond "id" in formData
|
||||
|
||||
let threadId = getInt(formData["id"].body, -1)
|
||||
cond threadId != -1
|
||||
|
||||
try:
|
||||
case request.path
|
||||
of "/pin":
|
||||
executePinState(c, threadId, true)
|
||||
of "/unpin":
|
||||
executePinState(c, threadId, false)
|
||||
else:
|
||||
assert false
|
||||
resp Http200, "{}", "application/json"
|
||||
except ForumError as exc:
|
||||
resp Http400, $(%exc.data), "application/json"
|
||||
|
||||
post re"/delete(Post|Thread)":
|
||||
createTFD()
|
||||
if not c.loggedIn():
|
||||
|
|
|
|||
|
|
@ -96,8 +96,8 @@ when defined(js):
|
|||
section(class="navbar-section"):
|
||||
tdiv(class="input-group input-inline"):
|
||||
input(class="search-input input-sm",
|
||||
`type`="text", placeholder="search",
|
||||
id="search-box",
|
||||
`type`="search", placeholder="Search",
|
||||
id="search-box", required="required",
|
||||
onKeyDown=onKeyDown)
|
||||
if state.loading:
|
||||
tdiv(class="loading")
|
||||
|
|
|
|||
|
|
@ -64,4 +64,4 @@ when defined(js):
|
|||
renderPostUrl(thread.id, post.id)
|
||||
|
||||
proc renderPostUrl*(link: PostLink): string =
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
renderPostUrl(link.threadId, link.postId)
|
||||
|
|
|
|||
|
|
@ -190,7 +190,7 @@ when defined(js):
|
|||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
button(class="btn btn-secondary",
|
||||
button(class="btn btn-secondary", id="lock-btn",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onLockClick(e, n, state, thread),
|
||||
"data-tooltip"=tooltip,
|
||||
|
|
@ -201,4 +201,61 @@ when defined(js):
|
|||
text " Unlock Thread"
|
||||
else:
|
||||
italic(class="fas fa-lock")
|
||||
text " Lock Thread"
|
||||
text " Lock Thread"
|
||||
|
||||
type
|
||||
PinButton* = ref object
|
||||
error: Option[PostError]
|
||||
loading: bool
|
||||
|
||||
proc newPinButton*(): PinButton =
|
||||
PinButton()
|
||||
|
||||
proc onPost(httpStatus: int, response: kstring, state: PinButton,
|
||||
thread: var Thread) =
|
||||
postFinished:
|
||||
thread.isPinned = not thread.isPinned
|
||||
|
||||
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
|
||||
if state.loading: return
|
||||
|
||||
state.loading = true
|
||||
state.error = none[PostError]()
|
||||
|
||||
# Same as LockButton so the following is still a hack and karax should support this.
|
||||
var formData = newFormData()
|
||||
formData.append("id", $thread.id)
|
||||
let uri =
|
||||
if thread.isPinned:
|
||||
makeUri("/unpin")
|
||||
else:
|
||||
makeUri("/pin")
|
||||
ajaxPost(uri, @[], formData.to(cstring),
|
||||
(s: int, r: kstring) => onPost(s, r, state, thread))
|
||||
|
||||
ev.preventDefault()
|
||||
|
||||
proc render*(state: PinButton, thread: var Thread,
|
||||
currentUser: Option[User]): VNode =
|
||||
if currentUser.isNone() or
|
||||
currentUser.get().rank < Moderator:
|
||||
return buildHtml(tdiv())
|
||||
|
||||
let tooltip =
|
||||
if state.error.isSome(): state.error.get().message
|
||||
else: ""
|
||||
|
||||
result = buildHtml():
|
||||
button(class="btn btn-secondary", id="pin-btn",
|
||||
onClick=(e: Event, n: VNode) =>
|
||||
onPinClick(e, n, state, thread),
|
||||
"data-tooltip"=tooltip,
|
||||
onmouseleave=(e: Event, n: VNode) =>
|
||||
(state.error = none[PostError]())):
|
||||
if thread.isPinned:
|
||||
italic(class="fas fa-thumbtack")
|
||||
text " Unpin Thread"
|
||||
else:
|
||||
italic(class="fas fa-thumbtack")
|
||||
text " Pin Thread"
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ when defined(js):
|
|||
likeButton: LikeButton
|
||||
deleteModal: DeleteModal
|
||||
lockButton: LockButton
|
||||
pinButton: PinButton
|
||||
categoryPicker: CategoryPicker
|
||||
|
||||
proc onReplyPosted(id: int)
|
||||
|
|
@ -56,6 +57,7 @@ when defined(js):
|
|||
likeButton: newLikeButton(),
|
||||
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
|
||||
lockButton: newLockButton(),
|
||||
pinButton: newPinButton(),
|
||||
categoryPicker: newCategoryPicker(onCategoryChanged)
|
||||
)
|
||||
|
||||
|
|
@ -411,6 +413,7 @@ when defined(js):
|
|||
text " Reply"
|
||||
|
||||
render(state.lockButton, list.thread, currentUser)
|
||||
render(state.pinButton, list.thread, currentUser)
|
||||
|
||||
render(state.replyBox, list.thread, state.replyingTo, false)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ type
|
|||
creation*: int64 ## Unix timestamp
|
||||
isLocked*: bool
|
||||
isSolved*: bool
|
||||
isPinned*: bool
|
||||
|
||||
ThreadList* = ref object
|
||||
threads*: seq[Thread]
|
||||
|
|
@ -96,15 +97,18 @@ when defined(js):
|
|||
else:
|
||||
return $duration.inSeconds & "s"
|
||||
|
||||
proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
|
||||
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
|
||||
let isBanned = thread.author.rank.isBanned()
|
||||
result = buildHtml():
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned})):
|
||||
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
|
||||
td(class="thread-title"):
|
||||
if thread.isLocked:
|
||||
italic(class="fas fa-lock fa-xs",
|
||||
title="Thread cannot be replied to")
|
||||
if thread.isPinned:
|
||||
italic(class="fas fa-thumbtack fa-xs",
|
||||
title="Pinned post")
|
||||
if isBanned:
|
||||
italic(class="fas fa-ban fa-xs",
|
||||
title="Thread author is banned")
|
||||
|
|
@ -223,7 +227,7 @@ when defined(js):
|
|||
|
||||
let isLastThread = i+1 == list.threads.len
|
||||
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
|
||||
genThread(thread, isNew,
|
||||
genThread(i+1, thread, isNew,
|
||||
noBorder=isLastUnseen or isLastThread,
|
||||
displayCategory=displayCategory)
|
||||
if isLastUnseen and (not isLastThread):
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ SELECT
|
|||
post_id,
|
||||
post_content,
|
||||
cdate,
|
||||
person.id,
|
||||
person.name AS author,
|
||||
person.email AS email,
|
||||
strftime('%s', person.lastOnline) AS lastOnline,
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
|
|||
isLocked boolean not null default 0,
|
||||
solution integer,
|
||||
isDeleted boolean not null default 0,
|
||||
isPinned boolean not null default 0,
|
||||
|
||||
foreign key (category) references category(id),
|
||||
foreign key (solution) references post(id)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ proc elementIsSome(element: Option[Element]): bool =
|
|||
proc elementIsNone(element: Option[Element]): bool =
|
||||
return element.isNone
|
||||
|
||||
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
|
||||
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
|
||||
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
|
||||
|
||||
proc click*(session: Session, element: string, strategy=CssSelector) =
|
||||
let el = session.waitForElement(element, strategy)
|
||||
|
|
@ -71,14 +72,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
|
|||
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
|
||||
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
|
||||
|
||||
proc checkText*(session: Session, element, expectedValue: string) =
|
||||
template checkText*(session: Session, element, expectedValue: string) =
|
||||
let el = session.waitForElement(element)
|
||||
check el.get().getText() == expectedValue
|
||||
|
||||
proc waitForElement*(
|
||||
session: Session, selector: string, strategy=CssSelector,
|
||||
timeout=20000, pollTime=50,
|
||||
waitCondition=elementIsSome
|
||||
waitCondition: proc(element: Option[Element]): bool = elementIsSome
|
||||
): Option[Element] =
|
||||
var waitTime = 0
|
||||
|
||||
|
|
|
|||
|
|
@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
|
|||
register "TEst1", "test1", verify = false
|
||||
|
||||
ensureExists "#signup-form .has-error"
|
||||
navigate baseUrl
|
||||
navigate baseUrl
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import unittest, common
|
||||
|
||||
import webdriver
|
||||
|
||||
let
|
||||
|
|
@ -58,10 +57,35 @@ proc userTests(session: Session, baseUrl: string) =
|
|||
# Make sure the forum post is gone
|
||||
checkIsNone "To be deleted", LinkTextSelector
|
||||
|
||||
test "cannot (un)pin thread":
|
||||
with session:
|
||||
navigate(baseUrl)
|
||||
|
||||
click "#new-thread-btn"
|
||||
|
||||
sendKeys "#thread-title", "Unpinnable"
|
||||
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
||||
checkIsNone "#pin-btn"
|
||||
|
||||
test "cannot lock threads":
|
||||
with session:
|
||||
navigate(baseUrl)
|
||||
|
||||
click "#new-thread-btn"
|
||||
|
||||
sendKeys "#thread-title", "Locking"
|
||||
sendkeys "#reply-textarea", "Cannot lock as an user"
|
||||
|
||||
click "#create-thread-btn"
|
||||
|
||||
checkIsNone "#lock-btn"
|
||||
|
||||
session.logout()
|
||||
|
||||
proc anonymousTests(session: Session, baseUrl: string) =
|
||||
|
||||
suite "anonymous user tests":
|
||||
with session:
|
||||
navigate baseUrl
|
||||
|
|
@ -161,6 +185,70 @@ proc adminTests(session: Session, baseUrl: string) =
|
|||
# Make sure the forum post is gone
|
||||
checkIsNone adminTitleStr, LinkTextSelector
|
||||
|
||||
test "can pin a thread":
|
||||
with session:
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "Pinned post"
|
||||
sendKeys "#reply-textarea", "A pinned post"
|
||||
click "#create-thread-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "Normal post"
|
||||
sendKeys "#reply-textarea", "A normal post"
|
||||
click "#create-thread-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
click "Pinned post", LinkTextSelector
|
||||
click "#pin-btn"
|
||||
checkText "#pin-btn", "Unpin Thread"
|
||||
|
||||
navigate(baseUrl)
|
||||
|
||||
# Make sure pin exists
|
||||
ensureExists "#threads-list .thread-1 .thread-title i"
|
||||
|
||||
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
|
||||
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
|
||||
|
||||
test "can unpin a thread":
|
||||
with session:
|
||||
click "Pinned post", LinkTextSelector
|
||||
click "#pin-btn"
|
||||
checkText "#pin-btn", "Pin Thread"
|
||||
|
||||
navigate(baseUrl)
|
||||
|
||||
checkIsNone "#threads-list .thread-2 .thread-title i"
|
||||
|
||||
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
|
||||
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
|
||||
|
||||
test "can lock a thread":
|
||||
with session:
|
||||
click "Locking", LinkTextSelector
|
||||
click "#lock-btn"
|
||||
|
||||
ensureExists "#thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
test "locked thread appears on frontpage":
|
||||
with session:
|
||||
click "#new-thread-btn"
|
||||
sendKeys "#thread-title", "A new locked thread"
|
||||
sendKeys "#reply-textarea", "This thread should appear locked on the frontpage"
|
||||
click "#create-thread-btn"
|
||||
click "#lock-btn"
|
||||
|
||||
navigate(baseUrl)
|
||||
ensureExists "#threads-list .thread-1 .thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
test "can unlock a thread":
|
||||
with session:
|
||||
click "Locking", LinkTextSelector
|
||||
click "#lock-btn"
|
||||
|
||||
checkIsNone "#thread-title i.fas.fa-lock.fa-xs"
|
||||
|
||||
session.logout()
|
||||
|
||||
proc test*(session: Session, baseUrl: string) =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue