DEV: Rework reorder-categories modal (#25475)

This commit is contained in:
Jarek Radosz 2024-02-01 13:13:38 +01:00 committed by GitHub
parent dbc00d113f
commit f72ba754f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 269 additions and 306 deletions

View File

@ -1,6 +1,7 @@
<DModal <DModal
@title={{i18n "categories.reorder.title"}} @title={{i18n "categories.reorder.title"}}
@closeModal={{@closeModal}} @closeModal={{@closeModal}}
@inline={{@inline}}
class="reorder-categories" class="reorder-categories"
> >
<:body> <:body>
@ -10,11 +11,11 @@
<th>{{i18n "categories.reorder.position"}}</th> <th>{{i18n "categories.reorder.position"}}</th>
</thead> </thead>
<tbody> <tbody>
{{#each this.categoriesOrdered as |category|}} {{#each this.sortedEntries as |entry|}}
<tr data-category-id={{category.id}}> <tr data-category-id={{entry.category.id}}>
<td> <td>
<div class={{concat "reorder-categories-depth-" category.depth}}> <div class={{concat "reorder-categories-depth-" entry.depth}}>
{{category-badge category allowUncategorized="true"}} {{category-badge entry.category allowUncategorized="true"}}
</div> </div>
</td> </td>
@ -22,22 +23,22 @@
<div class="reorder-categories-actions"> <div class="reorder-categories-actions">
<input <input
{{on {{on
"input" "change"
(action (fn this.change category) value="target.value") (action (fn this.change entry) value="target.value")
}} }}
value={{category.position}} value={{entry.position}}
type="number" type="number"
min="0" min="0"
/> />
<DButton <DButton
@action={{fn this.move category -1}} @action={{fn this.move entry -1}}
@icon="arrow-up" @icon="arrow-up"
class="btn-default no-text" class="btn-default no-text move-up"
/> />
<DButton <DButton
@action={{fn this.move category 1}} @action={{fn this.move entry 1}}
@icon="arrow-down" @icon="arrow-down"
class="btn-default no-text" class="btn-default no-text move-down"
/> />
</div> </div>
</td> </td>
@ -51,6 +52,7 @@
<DButton <DButton
@action={{this.save}} @action={{this.save}}
@label="categories.reorder.save" @label="categories.reorder.save"
@disabled={{not this.changed}}
class="btn-primary" class="btn-primary"
/> />
</:footer> </:footer>

View File

@ -1,21 +1,38 @@
import Component from "@ember/component"; import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { sort } from "@ember/object/computed";
import { next } from "@ember/runloop";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
class Entry {
@tracked position;
constructor({ position, depth, category, descendantCount }) {
this.position = position;
this.depth = depth;
this.category = category;
this.descendantCount = descendantCount;
}
}
export default class ReorderCategories extends Component { export default class ReorderCategories extends Component {
@service site; @service site;
categoriesSorting = ["position"]; @tracked changed = false;
@tracked entries = this.reorder();
@sort("site.categories", "categoriesSorting") categoriesOrdered; get sortedEntries() {
return this.entries.sortBy("position");
}
init() { reorder(from) {
super.init(...arguments); from ??= this.site.categories.map((category) => ({
next(() => this.reorder()); category,
position: category.position,
}));
return this.createEntries([...from.sortBy("position")]);
} }
/** /**
@ -30,128 +47,109 @@ export default class ReorderCategories extends Component {
* other parent/c2/c1 * other parent/c2/c1
* parent/c2 other * parent/c2 other
**/ **/
reorder() { createEntries(from, position = 0, categoryId = null, depth = 0) {
this.reorderChildren(null, 0, 0); let result = [];
}
reorderChildren(categoryId, depth, index) { for (const entry of from) {
for (const category of this.categoriesOrdered) {
if ( if (
(categoryId === null && !category.get("parent_category_id")) || (categoryId === null && !entry.category.parent_category_id) ||
category.get("parent_category_id") === categoryId entry.category.parent_category_id === categoryId
) { ) {
category.setProperties({ depth, position: index++ }); const descendants = this.createEntries(
index = this.reorderChildren(category.get("id"), depth + 1, index); from,
position + result.length + 1,
entry.category.id,
depth + 1
);
result = [
...result,
new Entry({
position: position + result.length,
depth,
category: entry.category,
descendantCount: descendants.length,
}),
...descendants,
];
} }
} }
return index; return result;
}
countDescendants(category) {
if (!category.get("subcategories")) {
return 0;
}
return category
.get("subcategories")
.reduce(
(count, subcategory) => count + this.countDescendants(subcategory),
category.get("subcategories").length
);
} }
@action @action
move(category, direction) { move(entry, delta) {
let targetPosition = category.get("position") + direction; let targetPosition = entry.position + delta;
// Adjust target position for sub-categories // Adjust target position for sub-categories
if (direction > 0) { if (delta > 0) {
// Moving down (position gets larger) // Moving down (position gets larger)
if (category.get("isParent")) { if (entry.descendantCount) {
// This category has subcategories, adjust targetPosition to account for them // This category has subcategories, adjust targetPosition to account for them
let offset = this.countDescendants(category); if (entry.descendantCount >= delta) {
if (direction <= offset) {
// Only apply offset if target position is occupied by a subcategory // Only apply offset if target position is occupied by a subcategory
// Seems weird but fixes a UX quirk // Seems weird but fixes a UX quirk
targetPosition += offset; targetPosition += entry.descendantCount;
} }
} }
} else { } else {
// Moving up (position gets smaller) // Moving up (position gets smaller)
const otherCategory = this.categoriesOrdered.find( const ancestors = this.sortedEntries[targetPosition]?.category?.ancestors;
(c) => if (ancestors) {
// find category currently at targetPosition
c.get("position") === targetPosition
);
if (otherCategory && otherCategory.get("ancestors")) {
// Target category is a subcategory, adjust targetPosition to account for ancestors // Target category is a subcategory, adjust targetPosition to account for ancestors
const highestAncestor = otherCategory const highestAncestorEntry = this.sortedEntries.findBy(
.get("ancestors") "category.id",
.reduce((current, min) => ancestors[0].id
current.get("position") < min.get("position") ? current : min );
); targetPosition = highestAncestorEntry.position;
targetPosition = highestAncestor.get("position");
} }
} }
// Adjust target position for range bounds // Adjust target position for range bounds
if (targetPosition >= this.categoriesOrdered.length) { if (targetPosition >= this.entries.length) {
// Set to max // Set to max
targetPosition = this.categoriesOrdered.length - 1; targetPosition = this.entries.length - 1;
} else if (targetPosition < 0) { } else if (targetPosition < 0) {
// Set to min // Set to min
targetPosition = 0; targetPosition = 0;
} }
// Update other categories between current and target position // Update other categories between current and target position
for (const c of this.categoriesOrdered) { for (const e of this.sortedEntries) {
if (direction < 0) { if (delta > 0) {
// Moving up (position gets smaller) // Moving down (position gets larger)
if ( if (e.position > entry.position && e.position <= targetPosition) {
c.get("position") < category.get("position") && e.position -= 1;
c.get("position") >= targetPosition
) {
const newPosition = c.get("position") + 1;
c.set("position", newPosition);
} }
} else { } else {
// Moving down (position gets larger) // Moving up (position gets smaller)
if ( if (e.position < entry.position && e.position >= targetPosition) {
c.get("position") > category.get("position") && e.position += 1;
c.get("position") <= targetPosition
) {
const newPosition = c.get("position") - 1;
c.set("position", newPosition);
} }
} }
} }
// Update this category's position to target position // Update this category's position to target position
category.set("position", targetPosition); entry.position = targetPosition;
this.reorder(); this.entries = this.reorder(this.sortedEntries);
this.changed = true;
} }
@action @action
change(category, newPosition) { change(entry, newPosition) {
newPosition = parseInt(newPosition, 10); const delta = parseInt(newPosition, 10) - entry.position;
newPosition = this.move(entry, delta);
newPosition < category.get("position")
? Math.ceil(newPosition)
: Math.floor(newPosition);
const direction = newPosition - category.get("position");
this.move(category, direction);
} }
@action @action
async save() { async save() {
this.reorder(); const entries = this.reorder(this.sortedEntries);
const data = {}; const data = {};
for (const category of this.site.categories) { for (const { category, position } of entries) {
data[category.get("id")] = category.get("position"); data[category.id] = position;
} }
try { try {

View File

@ -0,0 +1,171 @@
import { getOwner } from "@ember/application";
import { click, fillIn, render } from "@ember/test-helpers";
import { module, test } from "qunit";
import ReorderCategories from "discourse/components/modal/reorder-categories";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
module("Integration | Component | ReorderCategories", function (hooks) {
setupRenderingTest(hooks);
test("shows categories in order", async function (assert) {
const store = getOwner(this).lookup("service:store");
const site = getOwner(this).lookup("service:site");
site.set("categories", [
store.createRecord("category", { id: 1, position: 0 }),
store.createRecord("category", { id: 2, position: 0 }),
store.createRecord("category", { id: 3, position: 0 }),
]);
await render(<template><ReorderCategories @inline={{true}} /></template>);
assert.dom("tr:nth-child(1)").hasAttribute("data-category-id", "1");
assert.dom("tr:nth-child(2)").hasAttribute("data-category-id", "2");
assert.dom("tr:nth-child(3)").hasAttribute("data-category-id", "3");
});
test("reorders subcategories after their parent categories, while maintaining the relative order", async function (assert) {
const store = getOwner(this).lookup("service:store");
const parent = store.createRecord("category", {
id: 1,
position: 1,
name: "parent",
});
const child1 = store.createRecord("category", {
id: 2,
position: 3,
name: "child1",
parent_category_id: 1,
});
const child2 = store.createRecord("category", {
id: 3,
position: 0,
name: "child2",
parent_category_id: 1,
});
const other = store.createRecord("category", {
id: 4,
position: 2,
name: "other",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [child2, parent, other, child1]);
await render(<template><ReorderCategories @inline={{true}} /></template>);
assert.dom("tr:nth-child(1) .badge-category__name").hasText("parent");
assert.dom("tr:nth-child(2) .badge-category__name").hasText("child2");
assert.dom("tr:nth-child(3) .badge-category__name").hasText("child1");
assert.dom("tr:nth-child(4) .badge-category__name").hasText("other");
});
test("changing the position number of a category should place it at given position", async function (assert) {
const store = getOwner(this).lookup("service:store");
const foo = store.createRecord("category", {
id: 1,
position: 0,
name: "foo",
});
const bar = store.createRecord("category", {
id: 2,
position: 1,
name: "bar",
});
const baz = store.createRecord("category", {
id: 3,
position: 2,
name: "baz",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [foo, bar, baz]);
await render(<template><ReorderCategories @inline={{true}} /></template>);
// Move category 'foo' from position 0 to position 2
await fillIn("tr:nth-child(1) input", "2");
assert.dom("tr:nth-child(1) .badge-category__name").hasText("bar");
assert.dom("tr:nth-child(2) .badge-category__name").hasText("baz");
assert.dom("tr:nth-child(3) .badge-category__name").hasText("foo");
});
test("changing the position number of a category should place it at given position and respect children", async function (assert) {
const store = getOwner(this).lookup("service:store");
const foo = store.createRecord("category", {
id: 1,
position: 0,
name: "foo",
});
const fooChild = store.createRecord("category", {
id: 4,
position: 1,
name: "foo-child",
parent_category_id: 1,
});
const bar = store.createRecord("category", {
id: 2,
position: 2,
name: "bar",
});
const baz = store.createRecord("category", {
id: 3,
position: 3,
name: "baz",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [foo, fooChild, bar, baz]);
await render(<template><ReorderCategories @inline={{true}} /></template>);
await fillIn("tr:nth-child(1) input", "3");
assert.dom("tr:nth-child(1) .badge-category__name").hasText("bar");
assert.dom("tr:nth-child(2) .badge-category__name").hasText("baz");
assert.dom("tr:nth-child(3) .badge-category__name").hasText("foo");
assert.dom("tr:nth-child(4) .badge-category__name").hasText("foo-child");
});
test("changing the position through click on arrow of a category should place it at given position and respect children", async function (assert) {
const store = getOwner(this).lookup("service:store");
const fooChildChild = store.createRecord("category", {
id: 105,
position: 2,
name: "foo-child-child",
parent_category_id: 104,
});
const fooChild = store.createRecord("category", {
id: 104,
position: 1,
name: "foo-child",
parent_category_id: 101,
subcategories: [fooChildChild],
});
const foo = store.createRecord("category", {
id: 101,
position: 0,
name: "foo",
subcategories: [fooChild],
});
const bar = store.createRecord("category", {
id: 102,
position: 3,
name: "bar",
});
const baz = store.createRecord("category", {
id: 103,
position: 4,
name: "baz",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [foo, fooChild, fooChildChild, bar, baz]);
await render(<template><ReorderCategories @inline={{true}} /></template>);
await click("tr:nth-child(1) button.move-down");
assert.dom("tr:nth-child(1) .badge-category__name").hasText("bar");
assert.dom("tr:nth-child(2) .badge-category__name").hasText("foo");
assert.dom("tr:nth-child(3) .badge-category__name").hasText("foo-child");
assert
.dom("tr:nth-child(4) .badge-category__name")
.hasText("foo-child-child");
assert.dom("tr:nth-child(5) .badge-category__name").hasText("baz");
});
});

View File

@ -1,205 +0,0 @@
import { getOwner } from "@ember/application";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
module("Unit | Component | reorder-categories", function (hooks) {
setupTest(hooks);
test("reorder set unique position number", function (assert) {
const component = this.owner
.factoryFor("component:modal/reorder-categories")
.create();
const store = getOwner(this).lookup("service:store");
const site = getOwner(this).lookup("service:site");
site.set("categories", [
store.createRecord("category", { id: 1, position: 0 }),
store.createRecord("category", { id: 2, position: 0 }),
store.createRecord("category", { id: 3, position: 0 }),
]);
component.reorder();
component.categoriesOrdered.forEach((category, index) => {
assert.strictEqual(category.get("position"), index);
});
});
test("reorder places subcategories after their parent categories, while maintaining the relative order", function (assert) {
const component = this.owner
.factoryFor("component:modal/reorder-categories")
.create();
const store = getOwner(this).lookup("service:store");
const parent = store.createRecord("category", {
id: 1,
position: 1,
slug: "parent",
});
const child1 = store.createRecord("category", {
id: 2,
position: 3,
slug: "child1",
parent_category_id: 1,
});
const child2 = store.createRecord("category", {
id: 3,
position: 0,
slug: "child2",
parent_category_id: 1,
});
const other = store.createRecord("category", {
id: 4,
position: 2,
slug: "other",
});
const expectedOrderSlugs = ["parent", "child2", "child1", "other"];
const site = getOwner(this).lookup("service:site");
site.set("categories", [child2, parent, other, child1]);
component.reorder();
assert.deepEqual(
component.categoriesOrdered.mapBy("slug"),
expectedOrderSlugs
);
});
test("changing the position number of a category should place it at given position", function (assert) {
const component = this.owner
.factoryFor("component:modal/reorder-categories")
.create();
const store = getOwner(this).lookup("service:store");
const elem1 = store.createRecord("category", {
id: 1,
position: 0,
slug: "foo",
});
const elem2 = store.createRecord("category", {
id: 2,
position: 1,
slug: "bar",
});
const elem3 = store.createRecord("category", {
id: 3,
position: 2,
slug: "test",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [elem1, elem2, elem3]);
// Move category 'foo' from position 0 to position 2
component.change(elem1, "2");
assert.deepEqual(component.categoriesOrdered.mapBy("slug"), [
"bar",
"test",
"foo",
]);
});
test("changing the position number of a category should place it at given position and respect children", function (assert) {
const component = this.owner
.factoryFor("component:modal/reorder-categories")
.create();
const store = getOwner(this).lookup("service:store");
const elem1 = store.createRecord("category", {
id: 1,
position: 0,
slug: "foo",
});
const child1 = store.createRecord("category", {
id: 4,
position: 1,
slug: "foo-child",
parent_category_id: 1,
});
const elem2 = store.createRecord("category", {
id: 2,
position: 2,
slug: "bar",
});
const elem3 = store.createRecord("category", {
id: 3,
position: 3,
slug: "test",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [elem1, child1, elem2, elem3]);
component.change(elem1, "3");
assert.deepEqual(component.categoriesOrdered.mapBy("slug"), [
"bar",
"test",
"foo",
"foo-child",
]);
});
test("changing the position through click on arrow of a category should place it at given position and respect children", function (assert) {
const component = this.owner
.factoryFor("component:modal/reorder-categories")
.create();
const store = getOwner(this).lookup("service:store");
const child2 = store.createRecord("category", {
id: 105,
position: 2,
slug: "foo-child-child",
parent_category_id: 104,
});
const child1 = store.createRecord("category", {
id: 104,
position: 1,
slug: "foo-child",
parent_category_id: 101,
subcategories: [child2],
});
const elem1 = store.createRecord("category", {
id: 101,
position: 0,
slug: "foo",
subcategories: [child1],
});
const elem2 = store.createRecord("category", {
id: 102,
position: 3,
slug: "bar",
});
const elem3 = store.createRecord("category", {
id: 103,
position: 4,
slug: "test",
});
const site = getOwner(this).lookup("service:site");
site.set("categories", [elem1, child1, child2, elem2, elem3]);
component.reorder();
component.move(elem1, 1);
assert.deepEqual(component.categoriesOrdered.mapBy("slug"), [
"bar",
"foo",
"foo-child",
"foo-child-child",
"test",
]);
});
});

View File

@ -4,16 +4,12 @@
padding-bottom: 0.5em; padding-bottom: 0.5em;
} }
} }
input[type="text"] {
margin: 0;
max-width: 2.5em;
padding: 0.35em;
text-align: center;
@include breakpoint(mobile-extra-large) { input[type="number"] {
width: 2em; margin: 0;
} max-width: 4em;
} }
table { table {
padding-bottom: 150px; padding-bottom: 150px;
margin: 0 0.667em; margin: 0 0.667em;
@ -25,6 +21,7 @@
} }
} }
} }
.badge-category__wrapper .badge-category { .badge-category__wrapper .badge-category {
max-width: 20em; max-width: 20em;
@include breakpoint(mobile-extra-large) { @include breakpoint(mobile-extra-large) {