client+server/tag-categories: add ordering feature

This commit is contained in:
Shyam Sunder 2020-09-24 13:47:39 -04:00
commit a896c1a5a7
17 changed files with 113 additions and 14 deletions

View file

@ -7,6 +7,7 @@
<tr> <tr>
<th class='name'>Category name</th> <th class='name'>Category name</th>
<th class='color'>CSS color</th> <th class='color'>CSS color</th>
<th class='order'>Order</th>
<th class='usages'>Usages</th> <th class='usages'>Usages</th>
</tr> </tr>
</thead> </thead>
@ -21,7 +22,7 @@
<div class='messages'></div> <div class='messages'></div>
<% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %> <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canEditOrder || ctx.canDelete) { %>
<div class='buttons'> <div class='buttons'>
<input type='submit' class='save' value='Save changes'> <input type='submit' class='save' value='Save changes'>
</div> </div>

View file

@ -17,6 +17,13 @@
<%- ctx.tagCategory.color %> <%- ctx.tagCategory.color %>
<% } %> <% } %>
</td> </td>
<td class='order'>
<% if (ctx.canEditOrder) { %>
<%= ctx.makeNumericInput({value: ctx.tagCategory.order}) %>
<% } else { %>
<%- ctx.tagCategory.order %>
<% } %>
</td>
<td class='usages'> <td class='usages'>
<% if (ctx.tagCategory.name) { %> <% if (ctx.tagCategory.name) { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'> <a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'>

View file

@ -26,6 +26,7 @@ class TagCategoriesController {
tagCategories: this._tagCategories, tagCategories: this._tagCategories,
canEditName: api.hasPrivilege("tagCategories:edit:name"), canEditName: api.hasPrivilege("tagCategories:edit:name"),
canEditColor: api.hasPrivilege("tagCategories:edit:color"), canEditColor: api.hasPrivilege("tagCategories:edit:color"),
canEditOrder: api.hasPrivilege("tagCategories:edit:order"),
canDelete: api.hasPrivilege("tagCategories:delete"), canDelete: api.hasPrivilege("tagCategories:delete"),
canCreate: api.hasPrivilege("tagCategories:create"), canCreate: api.hasPrivilege("tagCategories:create"),
canSetDefault: api.hasPrivilege( canSetDefault: api.hasPrivilege(

View file

@ -9,10 +9,12 @@ class TagCategory extends events.EventTarget {
super(); super();
this._name = ""; this._name = "";
this._color = "#000000"; this._color = "#000000";
this._order = 1;
this._tagCount = 0; this._tagCount = 0;
this._isDefault = false; this._isDefault = false;
this._origName = null; this._origName = null;
this._origColor = null; this._origColor = null;
this._origOrder = null;
} }
get name() { get name() {
@ -23,6 +25,10 @@ class TagCategory extends events.EventTarget {
return this._color; return this._color;
} }
get order() {
return this._order;
}
get tagCount() { get tagCount() {
return this._tagCount; return this._tagCount;
} }
@ -43,6 +49,10 @@ class TagCategory extends events.EventTarget {
this._color = value; this._color = value;
} }
set order(value) {
this._order = value;
}
static fromResponse(response) { static fromResponse(response) {
const ret = new TagCategory(); const ret = new TagCategory();
ret._updateFromResponse(response); ret._updateFromResponse(response);
@ -58,6 +68,9 @@ class TagCategory extends events.EventTarget {
if (this.color !== this._origColor) { if (this.color !== this._origColor) {
detail.color = this.color; detail.color = this.color;
} }
if (this.order !== this._origOrder) {
detail.order = this.order;
}
if (!Object.keys(detail).length) { if (!Object.keys(detail).length) {
return Promise.resolve(); return Promise.resolve();
@ -104,10 +117,12 @@ class TagCategory extends events.EventTarget {
this._version = response.version; this._version = response.version;
this._name = response.name; this._name = response.name;
this._color = response.color; this._color = response.color;
this._order = response.order;
this._isDefault = response.default; this._isDefault = response.default;
this._tagCount = response.usages; this._tagCount = response.usages;
this._origName = this.name; this._origName = this.name;
this._origColor = this.color; this._origColor = this.color;
this._origOrder = this.order;
} }
} }

View file

@ -100,6 +100,13 @@ class TagCategoriesView extends events.EventTarget {
); );
} }
const orderInput = rowNode.querySelector(".order input");
if (orderInput) {
orderInput.addEventListener("change", (e) =>
this._evtOrderChange(e, rowNode)
);
}
const removeLinkNode = rowNode.querySelector(".remove a"); const removeLinkNode = rowNode.querySelector(".remove a");
if (removeLinkNode) { if (removeLinkNode) {
removeLinkNode.addEventListener("click", (e) => removeLinkNode.addEventListener("click", (e) =>
@ -147,6 +154,10 @@ class TagCategoriesView extends events.EventTarget {
rowNode._tagCategory.color = e.target.value; rowNode._tagCategory.color = e.target.value;
} }
_evtOrderChange(e, rowNode) {
rowNode._tagCategory.order = e.target.value;
}
_evtDeleteButtonClick(e, rowNode, link) { _evtDeleteButtonClick(e, rowNode, link) {
e.preventDefault(); e.preventDefault();
if (e.target.classList.contains("inactive")) { if (e.target.classList.contains("inactive")) {

View file

@ -321,7 +321,8 @@ data.
```json5 ```json5
{ {
"name": <name>, "name": <name>,
"color": <color> "color": <color>,
"order": <order> // optional
} }
``` ```
@ -354,6 +355,7 @@ data.
"version": <version>, "version": <version>,
"name": <name>, // optional "name": <name>, // optional
"color": <color>, // optional "color": <color>, // optional
"order": <order> // optional
} }
``` ```
@ -2288,7 +2290,8 @@ experience.
"version": <version>, "version": <version>,
"name": <name>, "name": <name>,
"color": <color>, "color": <color>,
"usages": <usages> "usages": <usages>,
"order": <order>,
"default": <is-default> "default": <is-default>
} }
``` ```
@ -2299,6 +2302,7 @@ experience.
- `<name>`: the category name. - `<name>`: the category name.
- `<color>`: the category color. - `<color>`: the category color.
- `<usages>`: how many tags is the given category used with. - `<usages>`: how many tags is the given category used with.
- `<order>`: the order in which tags with this category are displayed, ascending.
- `<is-default>`: whether the tag category is the default one. - `<is-default>`: whether the tag category is the default one.
## Tag ## Tag
@ -2498,7 +2502,7 @@ experience.
"version": <version>, "version": <version>,
"name": <name>, "name": <name>,
"color": <color>, "color": <color>,
"usages": <usages> "usages": <usages>,
"default": <is-default> "default": <is-default>
} }
``` ```
@ -2712,7 +2716,7 @@ dictionaries as created by creation snapshots, which is described below.
}, },
"primitive-property": "primitive-property":
{ {
"type": "primitive change": "type": "primitive change",
"old-value": "<primitive>", "old-value": "<primitive>",
"new-value": "<primitive>" "new-value": "<primitive>"
}, },

View file

@ -130,6 +130,7 @@ privileges:
'tag_categories:create': moderator 'tag_categories:create': moderator
'tag_categories:edit:name': moderator 'tag_categories:edit:name': moderator
'tag_categories:edit:color': moderator 'tag_categories:edit:color': moderator
'tag_categories:edit:order': moderator
'tag_categories:list': anonymous 'tag_categories:list': anonymous
'tag_categories:view': anonymous 'tag_categories:view': anonymous
'tag_categories:delete': moderator 'tag_categories:delete': moderator

View file

@ -37,7 +37,8 @@ def create_tag_category(
auth.verify_privilege(ctx.user, "tag_categories:create") auth.verify_privilege(ctx.user, "tag_categories:create")
name = ctx.get_param_as_string("name") name = ctx.get_param_as_string("name")
color = ctx.get_param_as_string("color") color = ctx.get_param_as_string("color")
category = tag_categories.create_category(name, color) order = ctx.get_param_as_int("order")
category = tag_categories.create_category(name, color, order)
ctx.session.add(category) ctx.session.add(category)
ctx.session.flush() ctx.session.flush()
snapshots.create(category, ctx.user) snapshots.create(category, ctx.user)
@ -73,6 +74,11 @@ def update_tag_category(
tag_categories.update_category_color( tag_categories.update_category_color(
category, ctx.get_param_as_string("color") category, ctx.get_param_as_string("color")
) )
if ctx.has_param("order"):
auth.verify_privilege(ctx.user, "tag_categories:edit:order")
tag_categories.update_category_order(
category, ctx.get_param_as_int("order")
)
ctx.session.flush() ctx.session.flush()
snapshots.modify(category, ctx.user) snapshots.modify(category, ctx.user)
ctx.session.commit() ctx.session.commit()

View file

@ -48,6 +48,7 @@ class TagCategorySerializer(serialization.BaseSerializer):
"color": self.serialize_color, "color": self.serialize_color,
"usages": self.serialize_usages, "usages": self.serialize_usages,
"default": self.serialize_default, "default": self.serialize_default,
"order": self.serialize_order,
} }
def serialize_name(self) -> Any: def serialize_name(self) -> Any:
@ -65,6 +66,9 @@ class TagCategorySerializer(serialization.BaseSerializer):
def serialize_default(self) -> Any: def serialize_default(self) -> Any:
return self.category.default return self.category.default
def serialize_order(self) -> Any:
return self.category.order
def serialize_category( def serialize_category(
category: Optional[model.TagCategory], options: List[str] = [] category: Optional[model.TagCategory], options: List[str] = []
@ -74,10 +78,11 @@ def serialize_category(
return TagCategorySerializer(category).serialize(options) return TagCategorySerializer(category).serialize(options)
def create_category(name: str, color: str) -> model.TagCategory: def create_category(name: str, color: str, order: int) -> model.TagCategory:
category = model.TagCategory() category = model.TagCategory()
update_category_name(category, name) update_category_name(category, name)
update_category_color(category, color) update_category_color(category, color)
update_category_order(category, order)
if not get_all_categories(): if not get_all_categories():
category.default = True category.default = True
return category return category
@ -117,6 +122,11 @@ def update_category_color(category: model.TagCategory, color: str) -> None:
category.color = color category.color = color
def update_category_order(category: model.TagCategory, order: int) -> None:
assert category
category.order = order
def try_get_category_by_name( def try_get_category_by_name(
name: str, lock: bool = False name: str, lock: bool = False
) -> Optional[model.TagCategory]: ) -> Optional[model.TagCategory]:

View file

@ -67,6 +67,7 @@ def sort_tags(tags: List[model.Tag]) -> List[model.Tag]:
return sorted( return sorted(
tags, tags,
key=lambda tag: ( key=lambda tag: (
tag.category.order,
default_category_name == tag.category.name, default_category_name == tag.category.name,
tag.category.name, tag.category.name,
tag.names[0].name, tag.names[0].name,

View file

@ -0,0 +1,28 @@
"""
Add order column to tag categories.
Revision ID: c97dc1bf184a
Created at: 2020-09-19 17:08:03.225667
"""
import sqlalchemy as sa
from alembic import op
revision = "c97dc1bf184a"
down_revision = "54de8acc6cef"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"tag_category", sa.Column("order", sa.Integer, nullable=True)
)
op.execute(
sa.table("tag_category", sa.column("order")).update().values(order=1)
)
op.alter_column("tag_category", "order", nullable=False)
def downgrade():
op.drop_column("tag_category", "order")

View file

@ -16,6 +16,7 @@ class TagCategory(Base):
"color", sa.Unicode(32), nullable=False, default="#000000" "color", sa.Unicode(32), nullable=False, default="#000000"
) )
default = sa.Column("default", sa.Boolean, nullable=False, default=False) default = sa.Column("default", sa.Boolean, nullable=False, default=False)
order = sa.Column("order", sa.Integer, nullable=False, default=1)
def __init__(self, name: Optional[str] = None) -> None: def __init__(self, name: Optional[str] = None) -> None:
self.name = name self.name = name

View file

@ -36,11 +36,14 @@ def test_creating_category(
tag_categories.serialize_category.return_value = "serialized category" tag_categories.serialize_category.return_value = "serialized category"
result = api.tag_category_api.create_tag_category( result = api.tag_category_api.create_tag_category(
context_factory( context_factory(
params={"name": "meta", "color": "black"}, user=auth_user params={"name": "meta", "color": "black", "order": 0},
user=auth_user,
) )
) )
assert result == "serialized category" assert result == "serialized category"
tag_categories.create_category.assert_called_once_with("meta", "black") tag_categories.create_category.assert_called_once_with(
"meta", "black", 0
)
snapshots.create.assert_called_once_with(category, auth_user) snapshots.create.assert_called_once_with(category, auth_user)

View file

@ -46,6 +46,7 @@ def test_retrieving_single(
"color": "dummy", "color": "dummy",
"usages": 0, "usages": 0,
"default": False, "default": False,
"order": 1,
"version": 1, "version": 1,
} }

View file

@ -17,6 +17,7 @@ def inject_config(config_injector):
"privileges": { "privileges": {
"tag_categories:edit:name": model.User.RANK_REGULAR, "tag_categories:edit:name": model.User.RANK_REGULAR,
"tag_categories:edit:color": model.User.RANK_REGULAR, "tag_categories:edit:color": model.User.RANK_REGULAR,
"tag_categories:edit:order": model.User.RANK_REGULAR,
"tag_categories:set_default": model.User.RANK_REGULAR, "tag_categories:set_default": model.User.RANK_REGULAR,
}, },
} }

View file

@ -126,10 +126,11 @@ def user_token_factory(user_factory):
@pytest.fixture @pytest.fixture
def tag_category_factory(): def tag_category_factory():
def factory(name=None, color="dummy", default=False): def factory(name=None, color="dummy", order=1, default=False):
category = model.TagCategory() category = model.TagCategory()
category.name = name or get_unique_name() category.name = name or get_unique_name()
category.color = color category.color = color
category.order = order
category.default = default category.default = default
return category return category

View file

@ -29,6 +29,7 @@ def test_serialize_category(tag_category_factory, tag_factory):
"color": "color", "color": "color",
"default": True, "default": True,
"version": 1, "version": 1,
"order": 1,
"usages": 2, "usages": 2,
} }
@ -36,8 +37,8 @@ def test_serialize_category(tag_category_factory, tag_factory):
def test_create_category_when_first(): def test_create_category_when_first():
with patch("szurubooru.func.tag_categories.update_category_name"), patch( with patch("szurubooru.func.tag_categories.update_category_name"), patch(
"szurubooru.func.tag_categories.update_category_color" "szurubooru.func.tag_categories.update_category_color"
): ), patch("szurubooru.func.tag_categories.update_category_order"):
category = tag_categories.create_category("name", "color") category = tag_categories.create_category("name", "color", 7)
assert category.default assert category.default
tag_categories.update_category_name.assert_called_once_with( tag_categories.update_category_name.assert_called_once_with(
category, "name" category, "name"
@ -45,6 +46,9 @@ def test_create_category_when_first():
tag_categories.update_category_color.assert_called_once_with( tag_categories.update_category_color.assert_called_once_with(
category, "color" category, "color"
) )
tag_categories.update_category_order.assert_called_once_with(
category, 7
)
def test_create_category_when_subsequent(tag_category_factory): def test_create_category_when_subsequent(tag_category_factory):
@ -52,8 +56,8 @@ def test_create_category_when_subsequent(tag_category_factory):
db.session.flush() db.session.flush()
with patch("szurubooru.func.tag_categories.update_category_name"), patch( with patch("szurubooru.func.tag_categories.update_category_name"), patch(
"szurubooru.func.tag_categories.update_category_color" "szurubooru.func.tag_categories.update_category_color"
): ), patch("szurubooru.func.tag_categories.update_category_order"):
category = tag_categories.create_category("name", "color") category = tag_categories.create_category("name", "color", 7)
assert not category.default assert not category.default
tag_categories.update_category_name.assert_called_once_with( tag_categories.update_category_name.assert_called_once_with(
category, "name" category, "name"
@ -61,6 +65,9 @@ def test_create_category_when_subsequent(tag_category_factory):
tag_categories.update_category_color.assert_called_once_with( tag_categories.update_category_color.assert_called_once_with(
category, "color" category, "color"
) )
tag_categories.update_category_order.assert_called_once_with(
category, 7
)
def test_update_category_name_with_empty_string(tag_category_factory): def test_update_category_name_with_empty_string(tag_category_factory):