FEATURE: Bookmark pinning (#12431)

Users can now pin bookmarks from their bookmark list. This will anchor the bookmark to the top of the list, and show a pin icon next to it. This also applies in the nav bookmarks panel. If there are multiple pinned bookmarks they sort by last updated order.
This commit is contained in:
Martin Brennan
2021-03-22 09:50:22 +10:00
committed by GitHub
parent 56a573ab4b
commit 49f4c548ef
16 changed files with 189 additions and 26 deletions

View File

@@ -1,7 +1,12 @@
import { action, computed } from "@ember/object"; import { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
import I18n from "I18n"; import I18n from "I18n";
const ACTION_REMOVE = "remove";
const ACTION_EDIT = "edit";
const ACTION_PIN = "pin";
export default DropdownSelectBoxComponent.extend({ export default DropdownSelectBoxComponent.extend({
classNames: ["bookmark-actions-dropdown"], classNames: ["bookmark-actions-dropdown"],
pluginApiIdentifiers: ["bookmark-actions-dropdown"], pluginApiIdentifiers: ["bookmark-actions-dropdown"],
@@ -11,10 +16,11 @@ export default DropdownSelectBoxComponent.extend({
showFullTitle: true, showFullTitle: true,
}, },
content: computed(() => { @discourseComputed("bookmark")
content(bookmark) {
return [ return [
{ {
id: "remove", id: ACTION_REMOVE,
icon: "trash-alt", icon: "trash-alt",
name: I18n.t("post.bookmarks.actions.delete_bookmark.name"), name: I18n.t("post.bookmarks.actions.delete_bookmark.name"),
description: I18n.t( description: I18n.t(
@@ -22,20 +28,32 @@ export default DropdownSelectBoxComponent.extend({
), ),
}, },
{ {
id: "edit", id: ACTION_EDIT,
icon: "pencil-alt", icon: "pencil-alt",
name: I18n.t("post.bookmarks.actions.edit_bookmark.name"), name: I18n.t("post.bookmarks.actions.edit_bookmark.name"),
description: I18n.t("post.bookmarks.actions.edit_bookmark.description"), description: I18n.t("post.bookmarks.actions.edit_bookmark.description"),
}, },
{
id: ACTION_PIN,
icon: "thumbtack",
name: I18n.t(
`post.bookmarks.actions.${bookmark.pinAction()}_bookmark.name`
),
description: I18n.t(
`post.bookmarks.actions.${bookmark.pinAction()}_bookmark.description`
),
},
]; ];
}), },
@action @action
onChange(selectedAction) { onChange(selectedAction) {
if (selectedAction === "remove") { if (selectedAction === ACTION_REMOVE) {
this.removeBookmark(this.bookmark); this.removeBookmark(this.bookmark);
} else if (selectedAction === "edit") { } else if (selectedAction === ACTION_EDIT) {
this.editBookmark(this.bookmark); this.editBookmark(this.bookmark);
} else if (selectedAction === ACTION_PIN) {
this.togglePinBookmark(this.bookmark);
} }
}, },
}); });

View File

@@ -61,7 +61,12 @@ export default Component.extend({
title: "post.bookmarks.edit", title: "post.bookmarks.edit",
modalClass: "bookmark-with-reminder", modalClass: "bookmark-with-reminder",
}); });
controller.set("afterSave", () => this.reload()); controller.set("afterSave", this.reload);
},
@action
togglePinBookmark(bookmark) {
bookmark.togglePin().then(this.reload);
}, },
_removeBookmarkFromList(bookmark) { _removeBookmarkFromList(bookmark) {

View File

@@ -2,7 +2,7 @@ import Controller, { inject } from "@ember/controller";
import Bookmark from "discourse/models/bookmark"; import Bookmark from "discourse/models/bookmark";
import I18n from "I18n"; import I18n from "I18n";
import { Promise } from "rsvp"; import { Promise } from "rsvp";
import { action } from "@ember/object"; import EmberObject, { action } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators"; import discourseComputed from "discourse-common/utils/decorators";
export default Controller.extend({ export default Controller.extend({
@@ -100,9 +100,19 @@ export default Controller.extend({
this.model.more_bookmarks_url = response.more_bookmarks_url; this.model.more_bookmarks_url = response.more_bookmarks_url;
if (response.bookmarks) { if (response.bookmarks) {
this.content.pushObjects( const bookmarkModels = response.bookmarks.map((bookmark) => {
response.bookmarks.map((bookmark) => Bookmark.create(bookmark)) const bookmarkModel = Bookmark.create(bookmark);
); bookmarkModel.topicStatus = EmberObject.create({
closed: bookmark.closed,
archived: bookmark.archived,
is_warning: bookmark.is_warning,
pinned: false,
unpinned: false,
invisible: bookmark.invisible,
});
return bookmarkModel;
});
this.content.pushObjects(bookmarkModels);
} }
}, },
}); });

View File

@@ -36,6 +36,20 @@ const Bookmark = RestModel.extend({
}); });
}, },
togglePin() {
if (this.newBookmark) {
return Promise.resolve();
}
return ajax(this.url + "/toggle_pin", {
type: "PUT",
});
},
pinAction() {
return this.pinned ? "unpin" : "pin";
},
@discourseComputed("highest_post_number", "url") @discourseComputed("highest_post_number", "url")
lastPostUrl(highestPostNumber) { lastPostUrl(highestPostNumber) {
return this.urlForPostNumber(highestPostNumber); return this.urlForPostNumber(highestPostNumber);

View File

@@ -41,8 +41,13 @@
{{/if}} {{/if}}
</div> </div>
{{topic-status topic=bookmark}} <div class="bookmark-status-with-link">
{{topic-link bookmark}} {{#if bookmark.pinned}}
{{d-icon "thumbtack" class="bookmark-pinned"}}
{{/if}}
{{topic-status topic=bookmark.topicStatus}}
{{topic-link bookmark}}
</div>
</span> </span>
{{#if bookmark.excerpt}} {{#if bookmark.excerpt}}
{{!-- template-lint-disable --}} {{!-- template-lint-disable --}}
@@ -69,6 +74,7 @@
bookmark=bookmark bookmark=bookmark
removeBookmark=(action "removeBookmark") removeBookmark=(action "removeBookmark")
editBookmark=(action "editBookmark") editBookmark=(action "editBookmark")
togglePinBookmark=(action "togglePinBookmark")
}} }}
</td> </td>
</tr> </tr>

View File

@@ -32,6 +32,17 @@ $mobile-breakpoint: 700px;
padding-right: 0.5em; padding-right: 0.5em;
} }
} }
.main-link {
.bookmark-status-with-link {
a.title {
padding: 0;
}
}
}
.d-icon.bookmark-pinned {
font-size: $font-down-2;
margin-right: 0.2em;
}
.bookmark-metadata { .bookmark-metadata {
font-size: $font-down-2; font-size: $font-down-2;
display: flex; display: flex;
@@ -58,4 +69,13 @@ $mobile-breakpoint: 700px;
} }
} }
} }
.bookmark-status-with-link {
display: flex;
flex-direction: row;
align-items: center;
.topic-statuses {
float: none;
}
}
} }

View File

@@ -54,4 +54,17 @@ class BookmarksController < ApplicationController
render json: failed_json.merge(errors: bookmark_manager.errors.full_messages), status: 400 render json: failed_json.merge(errors: bookmark_manager.errors.full_messages), status: 400
end end
def toggle_pin
params.require(:bookmark_id)
bookmark_manager = BookmarkManager.new(current_user)
bookmark_manager.toggle_pin(bookmark_id: params[:bookmark_id])
if bookmark_manager.errors.empty?
return render json: success_json
end
render json: failed_json.merge(errors: bookmark_manager.errors.full_messages), status: 400
end
end end

View File

@@ -143,6 +143,7 @@ end
# reminder_last_sent_at :datetime # reminder_last_sent_at :datetime
# reminder_set_at :datetime # reminder_set_at :datetime
# auto_delete_preference :integer default(0), not null # auto_delete_preference :integer default(0), not null
# pinned :boolean default(FALSE)
# #
# Indexes # Indexes
# #

View File

@@ -14,6 +14,7 @@ class UserBookmarkSerializer < ApplicationSerializer
:post_id, :post_id,
:name, :name,
:reminder_at, :reminder_at,
:pinned,
:title, :title,
:deleted, :deleted,
:hidden, :hidden,

View File

@@ -3068,6 +3068,12 @@ en:
edit_bookmark: edit_bookmark:
name: "Edit bookmark" name: "Edit bookmark"
description: "Edit the bookmark name or change the reminder date and time" description: "Edit the bookmark name or change the reminder date and time"
pin_bookmark:
name: "Pin bookmark"
description: "Pin the bookmark. This will make it appear at the top of your bookmarks list."
unpin_bookmark:
name: "Unpin bookmark"
description: "Unpin the bookmark. It will no longer appear at the top of your bookmarks list."
filtered_replies: filtered_replies:
viewing_posts_by: "Viewing %{post_count} posts by" viewing_posts_by: "Viewing %{post_count} posts by"

View File

@@ -623,7 +623,9 @@ Discourse::Application.routes.draw do
end end
end end
resources :bookmarks, only: %i[create destroy update] resources :bookmarks, only: %i[create destroy update] do
put "toggle_pin"
end
resources :notifications, except: :show do resources :notifications, except: :show do
collection do collection do

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPinnedColumnToBookmarks < ActiveRecord::Migration[6.0]
def change
add_column :bookmarks, :pinned, :boolean, default: false
end
end

View File

@@ -40,10 +40,7 @@ class BookmarkManager
end end
def destroy(bookmark_id) def destroy(bookmark_id)
bookmark = Bookmark.find_by(id: bookmark_id) bookmark = find_bookmark_and_check_access(bookmark_id)
raise Discourse::NotFound if bookmark.blank?
raise Discourse::InvalidAccess.new if !Guardian.new(@user).can_delete?(bookmark)
bookmark.destroy bookmark.destroy
@@ -72,10 +69,7 @@ class BookmarkManager
end end
def update(bookmark_id:, name:, reminder_type:, reminder_at:, options: {}) def update(bookmark_id:, name:, reminder_type:, reminder_at:, options: {})
bookmark = Bookmark.find_by(id: bookmark_id) bookmark = find_bookmark_and_check_access(bookmark_id)
raise Discourse::NotFound if bookmark.blank?
raise Discourse::InvalidAccess.new if !Guardian.new(@user).can_edit?(bookmark)
reminder_type = parse_reminder_type(reminder_type) reminder_type = parse_reminder_type(reminder_type)
@@ -95,8 +89,27 @@ class BookmarkManager
success success
end end
def toggle_pin(bookmark_id:)
bookmark = find_bookmark_and_check_access(bookmark_id)
bookmark.pinned = !bookmark.pinned
success = bookmark.save
if bookmark.errors.any?
return add_errors_from(bookmark)
end
success
end
private private
def find_bookmark_and_check_access(bookmark_id)
bookmark = Bookmark.find_by(id: bookmark_id)
raise Discourse::NotFound if !bookmark
raise Discourse::InvalidAccess.new if !Guardian.new(@user).can_edit?(bookmark)
bookmark
end
def update_topic_user_bookmarked(topic, opts = {}) def update_topic_user_bookmarked(topic, opts = {})
# PostCreator can specify whether auto_track is enabled or not, don't want to # PostCreator can specify whether auto_track is enabled or not, don't want to
# create a TopicUser in that case # create a TopicUser in that case

View File

@@ -27,7 +27,9 @@ class BookmarkQuery
end end
def list_all def list_all
results = user_bookmarks.order('bookmarks.updated_at DESC') results = user_bookmarks.order(
'(CASE WHEN bookmarks.pinned THEN 0 ELSE 1 END), bookmarks.updated_at DESC'
)
topics = Topic.listable_topics.secured(@guardian) topics = Topic.listable_topics.secured(@guardian)
pms = Topic.private_messages_for_user(@user) pms = Topic.private_messages_for_user(@user)

View File

@@ -159,7 +159,7 @@ RSpec.describe BookmarkManager do
end end
context "if the bookmark no longer exists" do context "if the bookmark no longer exists" do
it "raises an invalid access error" do it "raises a not found error" do
expect { subject.destroy(9999) }.to raise_error(Discourse::NotFound) expect { subject.destroy(9999) }.to raise_error(Discourse::NotFound)
end end
end end
@@ -220,7 +220,7 @@ RSpec.describe BookmarkManager do
before do before do
bookmark.destroy! bookmark.destroy!
end end
it "raises an invalid access error" do it "raises a not found error" do
expect { update_bookmark }.to raise_error(Discourse::NotFound) expect { update_bookmark }.to raise_error(Discourse::NotFound)
end end
end end
@@ -293,4 +293,36 @@ RSpec.describe BookmarkManager do
Notification.where(notification_type: Notification.types[:bookmark_reminder], user_id: bookmark.user.id) Notification.where(notification_type: Notification.types[:bookmark_reminder], user_id: bookmark.user.id)
end end
end end
describe ".toggle_pin" do
let!(:bookmark) { Fabricate(:bookmark, user: user) }
it "sets pinned to false if it is true" do
bookmark.update(pinned: true)
subject.toggle_pin(bookmark_id: bookmark.id)
expect(bookmark.reload.pinned).to eq(false)
end
it "sets pinned to true if it is false" do
bookmark.update(pinned: false)
subject.toggle_pin(bookmark_id: bookmark.id)
expect(bookmark.reload.pinned).to eq(true)
end
context "if the bookmark is belonging to some other user" do
let!(:bookmark) { Fabricate(:bookmark, user: Fabricate(:admin)) }
it "raises an invalid access error" do
expect { subject.toggle_pin(bookmark_id: bookmark.id) }.to raise_error(Discourse::InvalidAccess)
end
end
context "if the bookmark no longer exists" do
before do
bookmark.destroy!
end
it "raises a not found error" do
expect { subject.toggle_pin(bookmark_id: bookmark.id) }.to raise_error(Discourse::NotFound)
end
end
end
end end

View File

@@ -173,6 +173,7 @@ RSpec.describe BookmarkQuery do
let!(:bookmark3) { Fabricate(:bookmark, user: user, updated_at: 6.days.ago) } let!(:bookmark3) { Fabricate(:bookmark, user: user, updated_at: 6.days.ago) }
let!(:bookmark4) { Fabricate(:bookmark, user: user, updated_at: 4.days.ago) } let!(:bookmark4) { Fabricate(:bookmark, user: user, updated_at: 4.days.ago) }
let!(:bookmark5) { Fabricate(:bookmark, user: user, updated_at: 3.days.ago) } let!(:bookmark5) { Fabricate(:bookmark, user: user, updated_at: 3.days.ago) }
it "orders by updated_at" do it "orders by updated_at" do
expect(bookmark_query.list_all.map(&:id)).to eq([ expect(bookmark_query.list_all.map(&:id)).to eq([
bookmark1.id, bookmark1.id,
@@ -182,5 +183,17 @@ RSpec.describe BookmarkQuery do
bookmark3.id bookmark3.id
]) ])
end end
it "puts pinned bookmarks first, in updated at order, then the rest in updated at order" do
bookmark3.update_column(:pinned, true)
bookmark4.update_column(:pinned, true)
expect(bookmark_query.list_all.map(&:id)).to eq([
bookmark4.id,
bookmark3.id,
bookmark1.id,
bookmark2.id,
bookmark5.id
])
end
end end
end end