FIX: reliably reorder link in custom sections (#24188)

Two changes were introduced:
1. Reorder links on sidebar section is removed. Clicking and holding the mouse for 250ms was unintuitive;
2. Fixed bugs when reorder is done in edit modal.
This commit is contained in:
Krzysztof Kotlarek 2023-11-02 08:46:45 +11:00 committed by GitHub
parent 1d96b0a99a
commit 1c395e1a01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 34 additions and 232 deletions

View File

@ -51,6 +51,7 @@
@link={{link}} @link={{link}}
@deleteLink={{this.deleteLink}} @deleteLink={{this.deleteLink}}
@reorderCallback={{this.reorder}} @reorderCallback={{this.reorder}}
@setDraggedLinkCallback={{this.setDraggedLink}}
/> />
{{/each}} {{/each}}
@ -71,6 +72,7 @@
@link={{link}} @link={{link}}
@deleteLink={{this.deleteLink}} @deleteLink={{this.deleteLink}}
@reorderCallback={{this.reorder}} @reorderCallback={{this.reorder}}
@setDraggedLinkCallback={{this.setDraggedLink}}
/> />
{{/each}} {{/each}}
<DButton <DButton

View File

@ -384,38 +384,36 @@ export default class SidebarSectionForm extends Component {
} }
@bind @bind
reorder(linkFromId, linkTo, above) { setDraggedLink(link) {
if (linkFromId === linkTo.objectId) { this.draggedLink = link;
}
@bind
reorder(targetLink, above) {
if (this.draggedLink === targetLink) {
return; return;
} }
let linkFrom = this.transformedModel.links.find(
(link) => link.objectId === linkFromId
);
if (!linkFrom) {
linkFrom = this.transformedModel.secondaryLinks.find(
(link) => link.objectId === linkFromId
);
}
if (linkFrom.isPrimary) { if (this.draggedLink.isPrimary) {
this.transformedModel.links.removeObject(linkFrom); this.transformedModel.links.removeObject(this.draggedLink);
} else { } else {
this.transformedModel.secondaryLinks?.removeObject(linkFrom); this.transformedModel.secondaryLinks?.removeObject(this.draggedLink);
} }
if (linkTo.isPrimary) { if (targetLink.isPrimary) {
const toPosition = this.transformedModel.links.indexOf(linkTo); const toPosition = this.transformedModel.links.indexOf(targetLink);
linkFrom.segment = "primary"; this.draggedLink.segment = "primary";
this.transformedModel.links.insertAt( this.transformedModel.links.insertAt(
above ? toPosition : toPosition + 1, above ? toPosition : toPosition + 1,
linkFrom this.draggedLink
); );
} else { } else {
linkFrom.segment = "secondary"; this.draggedLink.segment = "secondary";
const toPosition = this.transformedModel.secondaryLinks.indexOf(linkTo); const toPosition =
this.transformedModel.secondaryLinks.indexOf(targetLink);
this.transformedModel.secondaryLinks.insertAt( this.transformedModel.secondaryLinks.insertAt(
above ? toPosition : toPosition + 1, above ? toPosition : toPosition + 1,
linkFrom this.draggedLink
); );
} }
} }

View File

@ -19,15 +19,6 @@
@fullReload={{link.fullReload}} @fullReload={{link.fullReload}}
@href={{link.value}} @href={{link.value}}
@class={{link.linkDragCss}} @class={{link.linkDragCss}}
{{(if
this.section.reorderable
(modifier
"draggable"
didStartDrag=link.didStartDrag
didEndDrag=link.didEndDrag
dragMove=link.dragMove
)
)}}
/> />
{{else}} {{else}}
<Sidebar::SectionLink <Sidebar::SectionLink
@ -48,15 +39,6 @@
@suffixType={{link.suffixType}} @suffixType={{link.suffixType}}
@currentWhen={{link.currentWhen}} @currentWhen={{link.currentWhen}}
@class={{link.linkDragCss}} @class={{link.linkDragCss}}
{{(if
this.section.reorderable
(modifier
"draggable"
didStartDrag=link.didStartDrag
didEndDrag=link.didEndDrag
dragMove=link.dragMove
)
)}}
/> />
{{/if}} {{/if}}
{{/each}} {{/each}}

View File

@ -1,6 +1,7 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import discourseLater from "discourse-common/lib/later";
export default class SectionFormLink extends Component { export default class SectionFormLink extends Component {
@tracked dragCssClass; @tracked dragCssClass;
@ -17,14 +18,14 @@ export default class SectionFormLink extends Component {
@action @action
dragHasStarted(event) { dragHasStarted(event) {
event.dataTransfer.effectAllowed = "move"; event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("linkId", this.args.link.objectId); this.args.setDraggedLinkCallback(this.args.link);
this.dragCssClass = "dragging"; this.dragCssClass = "dragging";
} }
@action @action
dragOver(event) { dragOver(event) {
event.preventDefault(); event.preventDefault();
if (!this.dragCssClass) { if (this.dragCssClass !== "dragging") {
if (this.isAboveElement(event)) { if (this.isAboveElement(event)) {
this.dragCssClass = "drag-above"; this.dragCssClass = "drag-above";
} else { } else {
@ -44,25 +45,23 @@ export default class SectionFormLink extends Component {
this.dragCount === 0 && this.dragCount === 0 &&
(this.dragCssClass === "drag-above" || this.dragCssClass === "drag-below") (this.dragCssClass === "drag-above" || this.dragCssClass === "drag-below")
) { ) {
this.dragCssClass = null; discourseLater(() => {
this.dragCssClass = null;
}, 10);
} }
} }
@action @action
dropItem(event) { dropItem(event) {
event.stopPropagation(); event.stopPropagation();
this.dragCounter = 0; this.dragCount = 0;
this.args.reorderCallback( this.args.reorderCallback(this.args.link, this.isAboveElement(event));
parseInt(event.dataTransfer.getData("linkId"), 10),
this.args.link,
this.isAboveElement(event)
);
this.dragCssClass = null; this.dragCssClass = null;
} }
@action @action
dragEnd() { dragEnd() {
this.dragCounter = 0; this.dragCount = 0;
this.dragCssClass = null; this.dragCssClass = null;
} }
} }

View File

@ -41,7 +41,6 @@ export default class CommunitySection {
@tracked links; @tracked links;
@tracked moreLinks; @tracked moreLinks;
reorderable = false;
hideSectionHeader = true; hideSectionHeader = true;
constructor({ section, owner }) { constructor({ section, owner }) {

View File

@ -1,11 +1,6 @@
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper"; import RouteInfoHelper from "discourse/lib/sidebar/route-info-helper";
import { defaultHomepage } from "discourse/lib/utilities"; import { defaultHomepage } from "discourse/lib/utilities";
import discourseLater from "discourse-common/lib/later";
import { bind } from "discourse-common/utils/decorators";
const TOUCH_SCREEN_DELAY = 300;
const MOUSE_DELAY = 250;
export default class SectionLink { export default class SectionLink {
@tracked linkDragCss; @tracked linkDragCss;
@ -46,100 +41,4 @@ export default class SectionLink {
get externalOrFullReload() { get externalOrFullReload() {
return this.external || this.fullReload || this.withAnchor; return this.external || this.fullReload || this.withAnchor;
} }
@bind
didStartDrag(event) {
// 0 represents left button of the mouse
if (event.button === 0 || event.targetTouches) {
this.startMouseY = this.#calcMouseY(event);
this.willDrag = true;
discourseLater(
() => {
this.delayedStart(event);
},
event.targetTouches ? TOUCH_SCREEN_DELAY : MOUSE_DELAY
);
}
}
delayedStart(event) {
if (this.willDrag) {
const currentMouseY = this.#calcMouseY(event);
if (currentMouseY === this.startMouseY) {
event.stopPropagation();
event.preventDefault();
this.mouseY = this.#calcMouseY(event);
this.linkDragCss = "drag";
this.section.disable();
this.drag = true;
}
}
}
@bind
didEndDrag() {
this.linkDragCss = null;
this.mouseY = null;
this.section.enable();
this.section.reorder();
this.willDrag = false;
this.drag = false;
}
@bind
dragMove(event) {
const moveMouseY = this.#calcMouseY(event);
if (this.willDrag && moveMouseY !== this.startMouseY && !this.drag) {
/**
* If mouse position is different, it means that it is a scroll and not drag and drop action.
* In that case, we want to do nothing and keep original behaviour.
*/
this.willDrag = false;
return;
} else {
/**
* Otherwise, event propagation should be stopped as we have our own handler for drag and drop.
*/
event.stopPropagation();
event.preventDefault();
}
this.startMouseY = moveMouseY;
if (!this.drag) {
return;
}
const currentMouseY = this.#calcMouseY(event);
const distance = currentMouseY - this.mouseY;
if (!this.linkHeight) {
this.linkHeight = document.getElementsByClassName(
"sidebar-section-link-wrapper"
)[0].clientHeight;
}
if (distance >= this.linkHeight) {
if (this.section.links.indexOf(this) !== this.section.links.length - 1) {
this.section.moveLinkDown(this);
this.mouseY = currentMouseY;
}
}
if (distance <= -this.linkHeight) {
if (this.section.links.indexOf(this) !== 0) {
this.section.moveLinkUp(this);
this.mouseY = currentMouseY;
}
}
}
#calcMouseY(event) {
return Math.round(
event.targetTouches ? event.targetTouches[0].clientY : event.y
);
}
} }

View File

@ -15,8 +15,6 @@ export default class Section {
@tracked dragCss; @tracked dragCss;
@tracked links; @tracked links;
reorderable = true;
constructor({ section, owner }) { constructor({ section, owner }) {
setOwner(this, owner); setOwner(this, owner);

View File

@ -83,17 +83,6 @@ class SidebarSectionsController < ApplicationController
render_serialized(sidebar_section, SidebarSectionSerializer) render_serialized(sidebar_section, SidebarSectionSerializer)
end end
def reorder
sidebar_section = SidebarSection.find_by(id: reorder_params["sidebar_section_id"])
@guardian.ensure_can_edit!(sidebar_section)
order = reorder_params["links_order"].map(&:to_i).each_with_index.to_h
set_order(sidebar_section, order)
render_serialized(sidebar_section, SidebarSectionSerializer)
rescue Discourse::InvalidAccess
render json: failed_json, status: 403
end
def destroy def destroy
sidebar_section = SidebarSection.find_by(id: section_params["id"]) sidebar_section = SidebarSection.find_by(id: section_params["id"])
@guardian.ensure_can_delete!(sidebar_section) @guardian.ensure_can_delete!(sidebar_section)

View File

@ -1607,7 +1607,6 @@ Discourse::Application.routes.draw do
delete "user-status" => "user_status#clear" delete "user-status" => "user_status#clear"
resources :sidebar_sections, only: %i[index create update destroy] resources :sidebar_sections, only: %i[index create update destroy]
post "/sidebar_sections/reorder" => "sidebar_sections#reorder"
put "/sidebar_sections/reset/:id" => "sidebar_sections#reset" put "/sidebar_sections/reset/:id" => "sidebar_sections#reset"
get "*url", to: "permalinks#show", constraints: PermalinkConstraint.new get "*url", to: "permalinks#show", constraints: PermalinkConstraint.new

View File

@ -370,72 +370,6 @@ RSpec.describe SidebarSectionsController do
end end
end end
describe "#reorder" do
fab!(:user2) { Fabricate(:user) }
fab!(:sidebar_section) { Fabricate(:sidebar_section, user: user) }
fab!(:sidebar_url_1) { Fabricate(:sidebar_url, name: "tags", value: "/tags") }
fab!(:sidebar_url_2) { Fabricate(:sidebar_url, name: "categories", value: "/categories") }
fab!(:sidebar_url_3) { Fabricate(:sidebar_url, name: "topic", value: "/t/1") }
fab!(:section_link_1) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_1)
end
fab!(:section_link_2) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_2)
end
fab!(:section_link_3) do
Fabricate(:sidebar_section_link, sidebar_section: sidebar_section, linkable: sidebar_url_3)
end
it "sorts links" do
expect(sidebar_section.sidebar_urls.pluck(:id)).to eq(
[sidebar_url_1.id, sidebar_url_2.id, sidebar_url_3.id],
)
sign_in(user)
post "/sidebar_sections/reorder.json",
params: {
sidebar_section_id: sidebar_section.id,
links_order: [sidebar_url_2.id, sidebar_url_3.id, sidebar_url_1.id],
}
expect(response.status).to eq(200)
expect(sidebar_section.reload.sidebar_urls.pluck(:id)).to eq(
[sidebar_url_2.id, sidebar_url_3.id, sidebar_url_1.id],
)
end
it "returns 403 when a user tries to reorder a section that doesn't belong to them" do
sign_in(user2)
post "/sidebar_sections/reorder.json",
params: {
sidebar_section_id: sidebar_section.id,
links_order: [sidebar_url_2.id, sidebar_url_3.id, sidebar_url_1.id],
}
expect(response.status).to eq(403)
expect(sidebar_section.reload.sidebar_urls.pluck(:id)).to eq(
[sidebar_url_1.id, sidebar_url_2.id, sidebar_url_3.id],
)
end
it "returns 403 for an non user" do
post "/sidebar_sections/reorder.json",
params: {
sidebar_section_id: sidebar_section.id,
links_order: [sidebar_url_2.id, sidebar_url_3.id, sidebar_url_1.id],
}
expect(response.status).to eql(403)
end
end
describe "#destroy" do describe "#destroy" do
fab!(:sidebar_section) { Fabricate(:sidebar_section, user: user) } fab!(:sidebar_section) { Fabricate(:sidebar_section, user: user) }

View File

@ -178,9 +178,12 @@ describe "Custom sidebar sections", type: :system do
["Sidebar Tags", "Sidebar Categories", "Sidebar Latest"], ["Sidebar Tags", "Sidebar Categories", "Sidebar Latest"],
) )
tags_link = find(".sidebar-section-link[data-link-name='Sidebar Tags']") sidebar.edit_custom_section("My section")
latest_link = find(".sidebar-section-link[data-link-name='Sidebar Latest']")
tags_link = find(".draggable[data-link-name='Sidebar Tags']")
latest_link = find(".draggable[data-link-name='Sidebar Latest']")
tags_link.drag_to(latest_link, html5: true, delay: 0.4) tags_link.drag_to(latest_link, html5: true, delay: 0.4)
section_modal.save
expect(sidebar.primary_section_links("my-section")).to eq( expect(sidebar.primary_section_links("my-section")).to eq(
["Sidebar Categories", "Sidebar Tags", "Sidebar Latest"], ["Sidebar Categories", "Sidebar Tags", "Sidebar Latest"],