Repository: saleor/saleor

Issue:
API for navigation management in Dashboard 2.0
Shortly we'll be implementing a new Dashboard 2.0 section for managing storefront navigation menus. We need to make sure that the API that we have currently allow implementing this UI:

![image](https://user-images.githubusercontent.com/5421321/55486115-0ab9a880-562c-11e9-98aa-4a40d352ef7e.png)

We already have some mutations to add menus and menu items, but I'm not sure if they allow convenient support for all the reordering and drag-drop features that this UI assumes (shape of the API should be discussed with @dominik-zeglen). 

@patrys once suggested if we shouldn't keep the entire menu structure as JSON in the database, instead of MPTT models. In the old storefront, we serialize menus to JSON anyway and cache it in a request to save some database queries. We should do some research whether it is more convenient for us to fully migrate to JSON for menus or keep the current model. We have to remember that menus and menu items are translatable and we should support that.


Discussion:
For reordering the menu items, we could pass the menu item IDs and their `sort_order` number. When the user saves, we send the new sorting orders and commit them.
```gql
mutation reorderMenu($menuID: ID!, $items: [ID]!, $order: [Int]!) {
  ...
}
```

Would this solution be responding to this issue?
Please let me know if that design is correctly responding to the issue, so I can start working on this ASAP @maarcingebala. I believe that's all that must be done, unless I'm mistaking.
After discussion we decided to do like this:

```gql
mutation reorderMenu($menuID: ID!, $items: [ID]!) {
  ...
}
```

And the below statement:
> @patrys once suggested if we shouldn't keep the entire menu structure as JSON in the database, instead of MPTT models. In the old storefront, we serialize menus to JSON anyway and cache it in a request to save some database queries. We should do some research whether it is more convenient for us to fully migrate to JSON for menus or keep the current model. We have to remember that menus and menu items are translatable and we should support that.

Will be moved to another issue for further look and discussion.
During the implementation, we should also keep in my mind that we may implement the same feature to the categories' MPTT tree.

We should also approach the menu reorder in another way as we didn't take into account the fact that the menu is actually a nested list.

A solution would be to mutate one item at once, with its parent (optional) and it order:
```gql
input MenuItemPosition {
  parent: ID
  sortOrder: Int!
}

mutation reorderMenuItem(id: ID!, input: MenuItemPosition!) {
  ...
}
```

Resolving patch:
diff --git a/saleor/graphql/menu/mutations.py b/saleor/graphql/menu/mutations.py
--- a/saleor/graphql/menu/mutations.py
+++ b/saleor/graphql/menu/mutations.py
@@ -1,14 +1,17 @@
+from collections import namedtuple
 from textwrap import dedent
+from typing import List
 
 import graphene
 from django.core.exceptions import ValidationError
+from graphql_relay import from_global_id
 
 from ...menu import models
 from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
 from ..page.types import Page
 from ..product.types import Category, Collection
 from .enums import NavigationType
-from .types import Menu
+from .types import Menu, MenuItem, MenuItemMoveInput
 
 
 class MenuItemInput(graphene.InputObjectType):
@@ -196,6 +199,114 @@ def user_is_allowed(cls, user):
         return user.has_perm('menu.manage_menus')
 
 
+_MenuMoveOperation = namedtuple(
+    '_MenuMoveOperation', ('menu_item', 'parent', 'sort_order'))
+
+
+class MenuItemMove(BaseMutation):
+    menu = graphene.Field(
+        Menu, description='Assigned menu to move within.')
+
+    class Arguments:
+        menu = graphene.ID(
+            required=True, description='ID of the menu.')
+        moves = graphene.List(
+            MenuItemMoveInput,
+            required=True, description='The menu position data')
+
+    class Meta:
+        description = 'Moves items of menus'
+
+    @staticmethod
+    def clean_move(move):
+        """Validate if the given move could be possibly possible."""
+        if move.parent_id:
+            if move.item_id == move.parent_id:
+                raise ValidationError({
+                    'parent': 'Cannot assign a node to itself.'})
+
+    @staticmethod
+    def clean_operation(operation: _MenuMoveOperation):
+        """Validate if the given move will be actually possible."""
+
+        if operation.parent:
+            if operation.menu_item.is_ancestor_of(operation.parent):
+                raise ValidationError({
+                    'parent': (
+                        'Cannot assign a node as child of '
+                        'one of its descendants.')})
+
+    @classmethod
+    def get_operation(
+            cls, info, menu_id: int, move) -> _MenuMoveOperation:
+        parent_node = None
+
+        _type, menu_item_id = from_global_id(move.item_id)  # type: str, int
+        assert _type == 'MenuItem', \
+            'The menu item node must be of type MenuItem.'
+
+        menu_item = models.MenuItem.objects.get(
+            pk=menu_item_id, menu_id=menu_id)
+
+        if move.parent_id is not None:
+            parent_node = cls.get_node_or_error(
+                info, move.parent_id,
+                field='parent_id', only_type=MenuItem)
+
+        return _MenuMoveOperation(
+            menu_item=menu_item,
+            parent=parent_node,
+            sort_order=move.sort_order)
+
+    @classmethod
+    def clean_moves(
+            cls,
+            info,
+            menu_id: int,
+            move_operations: List) -> List[_MenuMoveOperation]:
+
+        operations = []
+        for move in move_operations:
+            cls.clean_move(move)
+            operation = cls.get_operation(info, menu_id, move)
+            cls.clean_operation(operation)
+            operations.append(operation)
+        return operations
+
+    @classmethod
+    def user_is_allowed(cls, user):
+        return user.has_perm('menu.manage_menus')
+
+    @staticmethod
+    def perform_operation(operation: _MenuMoveOperation):
+        menu_item = operation.menu_item  # type: models.MenuItem
+
+        # Move the parent if provided
+        if operation.parent:
+            menu_item.move_to(operation.parent)
+        # Remove the menu item's parent if was set to none (root node)
+        elif menu_item.parent_id:
+            menu_item.parent_id = None
+
+        # Move the menu item
+        if operation.sort_order is not None:
+            menu_item.sort_order = operation.sort_order
+
+        menu_item.save()
+
+    @classmethod
+    def perform_mutation(cls, root, info, menu, moves):
+        _type, menu_id = from_global_id(menu)  # type: str, int
+        assert _type == 'Menu', 'Expected a menu of type Menu'
+
+        operations = cls.clean_moves(info, menu_id, moves)
+
+        for operation in operations:
+            cls.perform_operation(operation)
+
+        return cls(menu=models.Menu.objects.get(pk=menu_id))
+
+
 class AssignNavigation(BaseMutation):
     menu = graphene.Field(Menu, description='Assigned navigation menu.')
 
diff --git a/saleor/graphql/menu/schema.py b/saleor/graphql/menu/schema.py
--- a/saleor/graphql/menu/schema.py
+++ b/saleor/graphql/menu/schema.py
@@ -6,7 +6,7 @@
 from .bulk_mutations import MenuBulkDelete, MenuItemBulkDelete
 from .mutations import (
     AssignNavigation, MenuCreate, MenuDelete, MenuItemCreate, MenuItemDelete,
-    MenuItemUpdate, MenuUpdate)
+    MenuItemMove, MenuItemUpdate, MenuUpdate)
 from .resolvers import resolve_menu, resolve_menu_items, resolve_menus
 from .types import Menu, MenuItem
 
@@ -53,3 +53,4 @@ class MenuMutations(graphene.ObjectType):
     menu_item_bulk_delete = MenuItemBulkDelete.Field()
     menu_item_update = MenuItemUpdate.Field()
     menu_item_translate = MenuItemTranslate.Field()
+    menu_item_move = MenuItemMove.Field()
diff --git a/saleor/graphql/menu/types.py b/saleor/graphql/menu/types.py
--- a/saleor/graphql/menu/types.py
+++ b/saleor/graphql/menu/types.py
@@ -59,8 +59,18 @@ class Meta:
         interfaces = [relay.Node]
         only_fields = [
             'category', 'collection', 'id', 'level', 'menu', 'name', 'page',
-            'parent']
+            'parent', 'sort_order']
         model = models.MenuItem
 
     def resolve_children(self, _info, **_kwargs):
         return self.children.all()
+
+
+class MenuItemMoveInput(graphene.InputObjectType):
+    item_id = graphene.ID(
+        description='The menu item ID to move.', required=True)
+    parent_id = graphene.ID(
+        description=(
+            'ID of the parent menu. If empty, menu will be top level menu.'))
+    sort_order = graphene.Int(
+        description='Sorting position of the menu item (from 0 to x).')


Test patch:
diff --git a/tests/api/test_bulk_delete.py b/tests/api/test_bulk_delete.py
--- a/tests/api/test_bulk_delete.py
+++ b/tests/api/test_bulk_delete.py
@@ -67,14 +67,6 @@ def menu_list():
     return menu_1, menu_2, menu_3
 
 
-@pytest.fixture
-def menu_item_list(menu):
-    menu_item_1 = MenuItem.objects.create(menu=menu, name='Link 1')
-    menu_item_2 = MenuItem.objects.create(menu=menu, name='Link 2')
-    menu_item_3 = MenuItem.objects.create(menu=menu, name='Link 3')
-    return menu_item_1, menu_item_2, menu_item_3
-
-
 @pytest.fixture
 def product_type_list():
     product_type_1 = ProductType.objects.create(name='Type 1')
diff --git a/tests/api/test_menu.py b/tests/api/test_menu.py
--- a/tests/api/test_menu.py
+++ b/tests/api/test_menu.py
@@ -1,8 +1,13 @@
+import json
+from random import shuffle
+from typing import Dict, List
+
 import graphene
 import pytest
+from graphql_relay import from_global_id
 
 from saleor.graphql.menu.mutations import NavigationType
-from saleor.menu.models import MenuItem
+from saleor.menu.models import MenuItem, Menu
 from tests.api.utils import get_graphql_content
 
 from .utils import assert_no_permission
@@ -370,3 +375,301 @@ def test_assign_menu(
     assert not content['data']['assignNavigation']['menu']
     site_settings.refresh_from_db()
     assert site_settings.top_menu is None
+
+
+QUERY_REORDER_MENU = '''
+mutation menuItemMove($menu: ID!, $moves: [MenuItemMoveInput]!) {
+  menuItemMove(menu: $menu, moves: $moves) {
+    errors {
+      field
+      message
+    }
+
+    menu {
+      id
+      items {
+        id
+        sortOrder
+        parent {
+          id
+        }
+        children {
+          id
+          sortOrder
+          parent {
+            id
+          }
+        }
+      }
+    }
+  }
+}
+'''
+
+
+def _assert_menu_is_exactly(
+        menu_id: str,
+        actual_data: dict, expected_data: List[Dict]):
+
+    menu = actual_data.get('menu')
+    assert menu, 'Expected to receive a valid menu to compare from'
+
+    # Check the returned menu is actually the one we requested to change
+    assert menu['id'] == menu_id
+
+    actual_menu_items = menu.get('items')
+    assert actual_menu_items, 'Expected the menu to have items'
+
+    for expected_item in expected_data:
+        the_item_was_found = False
+        expected_sort_order = expected_item.get('sortOrder')
+        expected_parent_id = expected_item.get('parentId')
+        expected_item_id = from_global_id(expected_item['itemId'])[1]
+
+        for actual_item in actual_menu_items:
+            # Get the item that we want to compare, we retrieve by the database
+            # entry ID as the node id may have changed between the initial
+            # request and the one returned by the mutation
+            actual_item_id = from_global_id(actual_item['id'])[1]
+            if actual_item_id != expected_item_id:
+                # Append the children, to check, if any
+                children = actual_item.get('children')
+                if children:
+                    actual_menu_items += children
+
+                # Skip this node as it is not the one we are looking for
+                continue
+
+            the_item_was_found = True
+
+            if expected_sort_order:
+                assert actual_item['sortOrder'] == expected_sort_order, \
+                    'The menu item did not have the expected sorting order'
+
+            if 'parentId' in expected_item:
+                if expected_parent_id:
+                    assert actual_item['parent'], \
+                        'Expected the menu item to have a parent'
+                    assert actual_item['parent']['id'] == expected_parent_id, \
+                        'The menu item did not have the expected parent'
+                else:
+                    assert not actual_item['parent'], \
+                        'Expected the menu item to not have a parent'
+
+        assert the_item_was_found, \
+            'The expected menu item was not found in the response'
+
+
+def test_menu_reorder(
+        staff_api_client, permission_manage_menus, menu_item_list):
+
+    menu_item_list = list(menu_item_list)
+    menu_id = graphene.Node.to_global_id('Menu', menu_item_list[0].menu_id)
+
+    # Randomize the menu ordering
+    shuffle(menu_item_list)
+
+    moves = [{
+        "itemId": graphene.Node.to_global_id('MenuItem', item.pk),
+        "parentId": None, "sortOrder": pos
+    } for pos, item in enumerate(menu_item_list)]
+
+    response = get_graphql_content(staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus])
+    )['data']['menuItemMove']
+    assert not response.get('errors')
+
+    # Ensure the order is right
+    _assert_menu_is_exactly(menu_id, response, moves)
+
+
+def test_menu_reorder_assign_parent(
+        staff_api_client, permission_manage_menus, menu_item_list):
+
+    menu_item_list = list(menu_item_list)
+    menu_id = graphene.Node.to_global_id('Menu', menu_item_list[1].menu_id)
+
+    root = menu_item_list[0]
+    moves = [{
+        "itemId": graphene.Node.to_global_id('MenuItem', item.pk),
+        "parentId": graphene.Node.to_global_id('MenuItem', root.pk),
+        "sortOrder": None
+    } for item in menu_item_list[1:]]
+
+    response = get_graphql_content(staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    ))['data']['menuItemMove']
+    assert not response.get('errors')
+
+    # Ensure the parent were assigned correctly
+    _assert_menu_is_exactly(menu_id, response, moves)
+
+
+def test_menu_reorder_assign_parent_to_top_level(
+        staff_api_client, permission_manage_menus, menu_item_list):
+
+    menu_item_list = list(menu_item_list)
+    menu_id = graphene.Node.to_global_id('Menu', menu_item_list[0].menu_id)
+
+    root = menu_item_list[0]
+    root_node_id = graphene.Node.to_global_id('MenuItem', root.pk)
+
+    # Give to the item menu a parent
+    root.move_to(menu_item_list[1])
+    root.save()
+
+    assert root.parent
+
+    moves = [{
+        "itemId": root_node_id,
+        "parentId": None,
+        "sortOrder": None
+    }]
+
+    response = get_graphql_content(staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    ))['data']['menuItemMove']
+    assert not response.get('errors')
+
+    # Ensure the order is right
+    _assert_menu_is_exactly(menu_id, response, moves)
+
+
+def test_menu_reorder_cannot_assign_to_ancestor(
+        staff_api_client, permission_manage_menus, menu_item_list):
+
+    menu_item_list = list(menu_item_list)
+    menu_id = graphene.Node.to_global_id('Menu', menu_item_list[0].menu_id)
+
+    root = menu_item_list[0]
+    root_node_id = graphene.Node.to_global_id('MenuItem', root.pk)
+
+    child = menu_item_list[2]
+    child_node_id = graphene.Node.to_global_id('MenuItem', child.pk)
+
+    # Give the child an ancestor
+    child.move_to(root)
+    child.save()
+
+    # Give the child an ancestor
+    child.move_to(root)
+    child.save()
+
+    assert not root.parent
+    assert child.parent
+
+    moves = [{
+        "itemId": root_node_id,
+        "parentId": child_node_id,
+        "sortOrder": None
+    }]
+
+    response = get_graphql_content(staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    ))['data']['menuItemMove']
+
+    assert response['errors'] == [
+        {'field': 'parent',
+         'message': 'Cannot assign a node as child of '
+                    'one of its descendants.'}]
+
+
+def test_menu_reorder_cannot_assign_to_itself(
+        staff_api_client, permission_manage_menus, menu_item):
+
+    menu_id = graphene.Node.to_global_id('Menu', menu_item.menu_id)
+    node_id = graphene.Node.to_global_id('MenuItem', menu_item.pk)
+    moves = [{
+        "itemId": node_id,
+        "parentId": node_id,
+        "sortOrder": None
+    }]
+
+    response = get_graphql_content(staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    ))['data']['menuItemMove']
+
+    assert response['errors'] == [
+        {'field': 'parent',
+         'message': 'Cannot assign a node to itself.'}]
+
+
+def test_menu_cannot_get_menu_item_not_from_same_menu(
+        staff_api_client, permission_manage_menus, menu_item):
+    """You shouldn't be able to edit menu items that are not from the menu
+    you are actually editing"""
+
+    menu_without_items = Menu.objects.create(name='this menu has no items')
+
+    menu_id = graphene.Node.to_global_id('Menu', menu_without_items.id)
+    node_id = graphene.Node.to_global_id('MenuItem', menu_item.pk)
+    moves = [{
+        "itemId": node_id
+    }]
+
+    response = staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    )
+
+    assert (
+        json.loads(
+            response.content.decode('utf8'))['errors'][0]['message'] == (
+                'MenuItem matching query does not exist.'))
+
+
+def test_menu_cannot_pass_an_invalid_menu_item_node_type(
+        staff_api_client, staff_user, permission_manage_menus, menu_item):
+    """You shouldn't be able to pass a menu item node
+    that is not an actual MenuType."""
+
+    menu_without_items = Menu.objects.create(name='this menu has no items')
+
+    menu_id = graphene.Node.to_global_id('Menu', menu_without_items.id)
+    node_id = graphene.Node.to_global_id('User', staff_user.pk)
+    moves = [{
+        "itemId": node_id
+    }]
+
+    response = staff_api_client.post_graphql(
+        QUERY_REORDER_MENU,
+        {
+            'moves': moves,
+            'menu': menu_id
+        },
+        [permission_manage_menus]
+    )
+
+    assert (
+        json.loads(
+            response.content.decode('utf8'))['errors'][0]['message'] == (
+                'The menu item node must be of type MenuItem.'))
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -834,6 +834,14 @@ def menu_item(menu):
         url='http://example.com/')
 
 
+@pytest.fixture
+def menu_item_list(menu):
+    menu_item_1 = MenuItem.objects.create(menu=menu, name='Link 1')
+    menu_item_2 = MenuItem.objects.create(menu=menu, name='Link 2')
+    menu_item_3 = MenuItem.objects.create(menu=menu, name='Link 3')
+    return menu_item_1, menu_item_2, menu_item_3
+
+
 @pytest.fixture
 def menu_with_items(menu, category, collection):
     menu.items.create(name='Link 1', url='http://example.com/')