Skip to content

Commit 7954a38

Browse files
zetashiftjyapayne
andauthored
Pinned Threads (#278)
* Added isSticky field to `Thread` and in the sql query making a Thread - Modified indices in `data` and `selectUser` to support `isSticky` - Add backend procs for initial sticky logic, modeled after locking threads - Fix indices in selectThread - Fixup posts.json's threadquery to match Thread with sticky field * Implement StickyButton for postbutton.nim and add it to postlist.nim * Fix sticky routes * Order sticky in a way that they actually appear at the top * Add border for isSticky on genThread * Rename stickies to pinned, so professional! * Add pinned tests - Add an id to pin button, and add first attempt at useful tests - Improve pin tests, refactored it into adminTests and userTests - Add an id to pin button, and add first attempt at useful tests - Improve pin tests, refactored it into adminTests and userTests * Make tests more reliable Co-authored-by: Joey Yakimowich-Payne <[email protected]>
1 parent 48c025a commit 7954a38

File tree

9 files changed

+170
-18
lines changed

9 files changed

+170
-18
lines changed

src/forum.nim

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,10 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
400400
id: threadRow[0].parseInt,
401401
topic: threadRow[1],
402402
category: Category(
403-
id: threadRow[5].parseInt,
404-
name: threadRow[6],
405-
description: threadRow[7],
406-
color: threadRow[8]
403+
id: threadRow[6].parseInt,
404+
name: threadRow[7],
405+
description: threadRow[8],
406+
color: threadRow[9]
407407
),
408408
users: @[],
409409
replies: posts[0].parseInt-1,
@@ -412,6 +412,7 @@ proc selectThread(threadRow: seq[string], author: User): Thread =
412412
creation: posts[1].parseInt,
413413
isLocked: threadRow[4] == "1",
414414
isSolved: false, # TODO: Add a field to `post` to identify the solution.
415+
isPinned: threadRow[5] == "1"
415416
)
416417

417418
# Gather the users list.
@@ -709,6 +710,13 @@ proc executeLockState(c: TForumData, threadId: int, locked: bool) =
709710
# Save the like.
710711
exec(db, crud(crUpdate, "thread", "isLocked"), locked.int, threadId)
711712

713+
proc executePinState(c: TForumData, threadId: int, pinned: bool) =
714+
if c.rank < Moderator:
715+
raise newForumError("You do not have permission to pin this thread.")
716+
717+
# (Un)pin this thread
718+
exec(db, crud(crUpdate, "thread", "isPinned"), pinned.int, threadId)
719+
712720
proc executeDeletePost(c: TForumData, postId: int) =
713721
# Verify that this post belongs to the user.
714722
const postQuery = sql"""
@@ -833,7 +841,7 @@ routes:
833841
categoryArgs.insert($categoryId, 0)
834842

835843
const threadsQuery =
836-
"""select t.id, t.name, views, strftime('%s', modified), isLocked,
844+
"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
837845
c.id, c.name, c.description, c.color,
838846
u.id, u.name, u.email, strftime('%s', u.lastOnline),
839847
strftime('%s', u.previousVisitAt), u.status, u.isDeleted
@@ -846,14 +854,14 @@ routes:
846854
order by p.author
847855
limit 1
848856
)
849-
order by modified desc limit ?, ?;"""
857+
order by isPinned desc, modified desc limit ?, ?;"""
850858

851859
let thrCount = getValue(db, countQuery, countArgs).parseInt()
852860
let moreCount = max(0, thrCount - (start + count))
853861

854862
var list = ThreadList(threads: @[], moreCount: moreCount)
855863
for data in getAllRows(db, sql(threadsQuery % categorySection), categoryArgs):
856-
let thread = selectThread(data[0 .. 8], selectUser(data[9 .. ^1]))
864+
let thread = selectThread(data[0 .. 9], selectUser(data[10 .. ^1]))
857865
list.threads.add(thread)
858866

859867
resp $(%list), "application/json"
@@ -868,7 +876,7 @@ routes:
868876
count = 10
869877

870878
const threadsQuery =
871-
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked,
879+
sql"""select t.id, t.name, views, strftime('%s', modified), isLocked, isPinned,
872880
c.id, c.name, c.description, c.color
873881
from thread t, category c
874882
where t.id = ? and isDeleted = 0 and category = c.id;"""
@@ -1339,6 +1347,33 @@ routes:
13391347
except ForumError as exc:
13401348
resp Http400, $(%exc.data), "application/json"
13411349

1350+
post re"/(pin|unpin)":
1351+
createTFD()
1352+
if not c.loggedIn():
1353+
let err = PostError(
1354+
errorFields: @[],
1355+
message: "Not logged in."
1356+
)
1357+
resp Http401, $(%err), "application/json"
1358+
1359+
let formData = request.formData
1360+
cond "id" in formData
1361+
1362+
let threadId = getInt(formData["id"].body, -1)
1363+
cond threadId != -1
1364+
1365+
try:
1366+
case request.path
1367+
of "/pin":
1368+
executePinState(c, threadId, true)
1369+
of "/unpin":
1370+
executePinState(c, threadId, false)
1371+
else:
1372+
assert false
1373+
resp Http200, "{}", "application/json"
1374+
except ForumError as exc:
1375+
resp Http400, $(%exc.data), "application/json"
1376+
13421377
post re"/delete(Post|Thread)":
13431378
createTFD()
13441379
if not c.loggedIn():

src/frontend/post.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,4 @@ when defined(js):
6464
renderPostUrl(thread.id, post.id)
6565

6666
proc renderPostUrl*(link: PostLink): string =
67-
renderPostUrl(link.threadId, link.postId)
67+
renderPostUrl(link.threadId, link.postId)

src/frontend/postbutton.nim

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,61 @@ when defined(js):
201201
text " Unlock Thread"
202202
else:
203203
italic(class="fas fa-lock")
204-
text " Lock Thread"
204+
text " Lock Thread"
205+
206+
type
207+
PinButton* = ref object
208+
error: Option[PostError]
209+
loading: bool
210+
211+
proc newPinButton*(): PinButton =
212+
PinButton()
213+
214+
proc onPost(httpStatus: int, response: kstring, state: PinButton,
215+
thread: var Thread) =
216+
postFinished:
217+
thread.isPinned = not thread.isPinned
218+
219+
proc onPinClick(ev: Event, n: VNode, state: PinButton, thread: var Thread) =
220+
if state.loading: return
221+
222+
state.loading = true
223+
state.error = none[PostError]()
224+
225+
# Same as LockButton so the following is still a hack and karax should support this.
226+
var formData = newFormData()
227+
formData.append("id", $thread.id)
228+
let uri =
229+
if thread.isPinned:
230+
makeUri("/unpin")
231+
else:
232+
makeUri("/pin")
233+
ajaxPost(uri, @[], formData.to(cstring),
234+
(s: int, r: kstring) => onPost(s, r, state, thread))
235+
236+
ev.preventDefault()
237+
238+
proc render*(state: PinButton, thread: var Thread,
239+
currentUser: Option[User]): VNode =
240+
if currentUser.isNone() or
241+
currentUser.get().rank < Moderator:
242+
return buildHtml(tdiv())
243+
244+
let tooltip =
245+
if state.error.isSome(): state.error.get().message
246+
else: ""
247+
248+
result = buildHtml():
249+
button(class="btn btn-secondary", id="pin-btn",
250+
onClick=(e: Event, n: VNode) =>
251+
onPinClick(e, n, state, thread),
252+
"data-tooltip"=tooltip,
253+
onmouseleave=(e: Event, n: VNode) =>
254+
(state.error = none[PostError]())):
255+
if thread.isPinned:
256+
italic(class="fas fa-thumbtack")
257+
text " Unpin Thread"
258+
else:
259+
italic(class="fas fa-thumbtack")
260+
text " Pin Thread"
261+

src/frontend/postlist.nim

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ when defined(js):
3636
likeButton: LikeButton
3737
deleteModal: DeleteModal
3838
lockButton: LockButton
39+
pinButton: PinButton
3940
categoryPicker: CategoryPicker
4041

4142
proc onReplyPosted(id: int)
@@ -56,6 +57,7 @@ when defined(js):
5657
likeButton: newLikeButton(),
5758
deleteModal: newDeleteModal(onDeletePost, onDeleteThread, nil),
5859
lockButton: newLockButton(),
60+
pinButton: newPinButton(),
5961
categoryPicker: newCategoryPicker(onCategoryChanged)
6062
)
6163

@@ -411,6 +413,7 @@ when defined(js):
411413
text " Reply"
412414

413415
render(state.lockButton, list.thread, currentUser)
416+
render(state.pinButton, list.thread, currentUser)
414417

415418
render(state.replyBox, list.thread, state.replyingTo, false)
416419

src/frontend/threadlist.nim

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type
1515
creation*: int64 ## Unix timestamp
1616
isLocked*: bool
1717
isSolved*: bool
18+
isPinned*: bool
1819

1920
ThreadList* = ref object
2021
threads*: seq[Thread]
@@ -96,15 +97,18 @@ when defined(js):
9697
else:
9798
return $duration.inSeconds & "s"
9899

99-
proc genThread(thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
100+
proc genThread(pos: int, thread: Thread, isNew: bool, noBorder: bool, displayCategory=true): VNode =
100101
let isOld = (getTime() - thread.creation.fromUnix).inWeeks > 2
101102
let isBanned = thread.author.rank.isBanned()
102103
result = buildHtml():
103-
tr(class=class({"no-border": noBorder, "banned": isBanned})):
104+
tr(class=class({"no-border": noBorder, "banned": isBanned, "pinned": thread.isPinned, "thread-" & $pos: true})):
104105
td(class="thread-title"):
105106
if thread.isLocked:
106107
italic(class="fas fa-lock fa-xs",
107108
title="Thread cannot be replied to")
109+
if thread.isPinned:
110+
italic(class="fas fa-thumbtack fa-xs",
111+
title="Pinned post")
108112
if isBanned:
109113
italic(class="fas fa-ban fa-xs",
110114
title="Thread author is banned")
@@ -223,7 +227,7 @@ when defined(js):
223227

224228
let isLastThread = i+1 == list.threads.len
225229
let (isLastUnseen, isNew) = getInfo(list.threads, i, currentUser)
226-
genThread(thread, isNew,
230+
genThread(i+1, thread, isNew,
227231
noBorder=isLastUnseen or isLastThread,
228232
displayCategory=displayCategory)
229233
if isLastUnseen and (not isLastThread):

src/setup_nimforum.nim

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ proc initialiseDb(admin: tuple[username, password, email: string],
8181
isLocked boolean not null default 0,
8282
solution integer,
8383
isDeleted boolean not null default 0,
84+
isPinned boolean not null default 0,
8485
8586
foreign key (category) references category(id),
8687
foreign key (solution) references post(id)

tests/browsertests/common.nim

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ proc elementIsSome(element: Option[Element]): bool =
3030
proc elementIsNone(element: Option[Element]): bool =
3131
return element.isNone
3232

33-
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50, waitCondition=elementIsSome): Option[Element]
33+
proc waitForElement*(session: Session, selector: string, strategy=CssSelector, timeout=20000, pollTime=50,
34+
waitCondition: proc(element: Option[Element]): bool = elementIsSome): Option[Element]
3435

3536
proc click*(session: Session, element: string, strategy=CssSelector) =
3637
let el = session.waitForElement(element, strategy)
@@ -71,14 +72,14 @@ proc setColor*(session: Session, element, color: string, strategy=CssSelector) =
7172
proc checkIsNone*(session: Session, element: string, strategy=CssSelector) =
7273
discard session.waitForElement(element, strategy, waitCondition=elementIsNone)
7374

74-
proc checkText*(session: Session, element, expectedValue: string) =
75+
template checkText*(session: Session, element, expectedValue: string) =
7576
let el = session.waitForElement(element)
7677
check el.get().getText() == expectedValue
7778

7879
proc waitForElement*(
7980
session: Session, selector: string, strategy=CssSelector,
8081
timeout=20000, pollTime=50,
81-
waitCondition=elementIsSome
82+
waitCondition: proc(element: Option[Element]): bool = elementIsSome
8283
): Option[Element] =
8384
var waitTime = 0
8485

tests/browsertests/scenario1.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,4 @@ proc test*(session: Session, baseUrl: string) =
4040
register "TEst1", "test1", verify = false
4141

4242
ensureExists "#signup-form .has-error"
43-
navigate baseUrl
43+
navigate baseUrl

tests/browsertests/threads.nim

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,22 @@ proc userTests(session: Session, baseUrl: string) =
5858
# Make sure the forum post is gone
5959
checkIsNone "To be deleted", LinkTextSelector
6060

61+
test "cannot (un)pin thread":
62+
with session:
63+
navigate(baseUrl)
64+
65+
click "#new-thread-btn"
66+
67+
sendKeys "#thread-title", "Unpinnable"
68+
sendKeys "#reply-textarea", "Cannot (un)pin as an user"
69+
70+
click "#create-thread-btn"
71+
72+
checkIsNone "#pin-btn"
73+
6174
session.logout()
6275

6376
proc anonymousTests(session: Session, baseUrl: string) =
64-
6577
suite "anonymous user tests":
6678
with session:
6779
navigate baseUrl
@@ -161,6 +173,45 @@ proc adminTests(session: Session, baseUrl: string) =
161173
# Make sure the forum post is gone
162174
checkIsNone adminTitleStr, LinkTextSelector
163175

176+
test "Can pin a thread":
177+
with session:
178+
click "#new-thread-btn"
179+
sendKeys "#thread-title", "Pinned post"
180+
sendKeys "#reply-textarea", "A pinned post"
181+
click "#create-thread-btn"
182+
183+
navigate(baseUrl)
184+
click "#new-thread-btn"
185+
sendKeys "#thread-title", "Normal post"
186+
sendKeys "#reply-textarea", "A normal post"
187+
click "#create-thread-btn"
188+
189+
navigate(baseUrl)
190+
click "Pinned post", LinkTextSelector
191+
click "#pin-btn"
192+
checkText "#pin-btn", "Unpin Thread"
193+
194+
navigate(baseUrl)
195+
196+
# Make sure pin exists
197+
ensureExists "#threads-list .thread-1 .thread-title i"
198+
199+
checkText "#threads-list .thread-1 .thread-title a", "Pinned post"
200+
checkText "#threads-list .thread-2 .thread-title a", "Normal post"
201+
202+
test "Can unpin a thread":
203+
with session:
204+
click "Pinned post", LinkTextSelector
205+
click "#pin-btn"
206+
checkText "#pin-btn", "Pin Thread"
207+
208+
navigate(baseUrl)
209+
210+
checkIsNone "#threads-list .thread-2 .thread-title i"
211+
212+
checkText "#threads-list .thread-1 .thread-title a", "Normal post"
213+
checkText "#threads-list .thread-2 .thread-title a", "Pinned post"
214+
164215
session.logout()
165216

166217
proc test*(session: Session, baseUrl: string) =

0 commit comments

Comments
 (0)