Repository: saleor/saleor

Issue:
Create menu items in menuCreate mutation
### What I'm trying to achieve
It would be very handy if API user would be able to create the first level of menu items at the menu creation moment. This way we could reduce the number of database queries needed to create a menu and speed up the whole process. 

### Describe a proposed solution
Change `MenuInput` signature to
```
type MenuInput {
  name: String!
  items: [MenuItemCreate!]
}
```



Discussion:

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
@@ -3,22 +3,13 @@
 
 from ...menu import models
 from ..core.mutations import BaseMutation, ModelDeleteMutation, ModelMutation
+from ..product.types import Category, Collection
+from ..page.types import Page
 from .types import Menu
 
 
-class MenuInput(graphene.InputObjectType):
-    name = graphene.String(description='Name of the menu.')
-
-
 class MenuItemInput(graphene.InputObjectType):
-    menu = graphene.ID(
-        description='Menu to which item belongs to.', name='menu')
     name = graphene.String(description='Name of the menu item.')
-    parent = graphene.ID(
-        description='''
-        ID of the parent menu. If empty, menu will be top level
-        menu.''',
-        name='parent')
     url = graphene.String(description='URL of the pointed item.')
     category = graphene.ID(
         description='Category to which item points.', name='category')
@@ -28,9 +19,28 @@ class MenuItemInput(graphene.InputObjectType):
         description='Page to which item points.', name='page')
 
 
+class MenuItemCreateInput(MenuItemInput):
+    menu = graphene.ID(
+        description='Menu to which item belongs to.', name='menu')
+    parent = graphene.ID(
+        description='''
+        ID of the parent menu. If empty, menu will be top level
+        menu.''',
+        name='parent')
+
+
+class MenuInput(graphene.InputObjectType):
+    name = graphene.String(description='Name of the menu.')
+
+
+class MenuCreateInput(MenuInput):
+    items = graphene.List(
+        MenuItemInput, description='List of menu items.')
+
+
 class MenuCreate(ModelMutation):
     class Arguments:
-        input = MenuInput(
+        input = MenuCreateInput(
             required=True,
             description='Fields required to create a menu.')
 
@@ -42,6 +52,45 @@ class Meta:
     def user_is_allowed(cls, user, input):
         return user.has_perm('menu.manage_menus')
 
+    @classmethod
+    def clean_input(cls, info, instance, input, errors):
+        cleaned_input = super().clean_input(info, instance, input, errors)
+        items = []
+        for item in cleaned_input.get('items', []):
+            category = item.get('category')
+            collection = item.get('collection')
+            page = item.get('page')
+            url = item.get('url')
+            if len([i for i in [category, collection, page, url] if i]) > 1:
+                cls.add_error(
+                    errors, 'items', 'More than one item provided.')
+            else:
+                if category:
+                    category = cls.get_node_or_error(
+                        info, category, errors, 'items', only_type=Category)
+                    item['category'] = category
+                elif collection:
+                    collection = cls.get_node_or_error(
+                        info, collection, errors, 'items',
+                        only_type=Collection)
+                    item['collection'] = collection
+                elif page:
+                    page = cls.get_node_or_error(
+                        info, page, errors, 'items', only_type=Page)
+                    item['page'] = page
+                elif not url:
+                    cls.add_error(errors, 'items', 'No menu item provided.')
+                items.append(item)
+        cleaned_input['items'] = items
+        return cleaned_input
+
+    @classmethod
+    def _save_m2m(cls, info, instance, cleaned_data):
+        super()._save_m2m(info, instance, cleaned_data)
+        items = cleaned_data.get('items', [])
+        for item in items:
+            instance.items.create(**item)
+
 
 class MenuUpdate(ModelMutation):
     class Arguments:
@@ -76,7 +125,7 @@ def user_is_allowed(cls, user, input):
 
 class MenuItemCreate(ModelMutation):
     class Arguments:
-        input = MenuItemInput(
+        input = MenuItemCreateInput(
             required=True,
             description="""Fields required to update a menu item.
             Only one of 'url', 'category', 'page', 'collection' is allowed


Test patch:
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
@@ -111,17 +111,39 @@ def test_menu_items_query(user_api_client, menu_item, collection):
     assert data['children']['totalCount'] == menu_item.children.count()
 
 
-def test_create_menu(admin_api_client):
+def test_create_menu(admin_api_client, collection, category, page):
     query = """
-    mutation mc($name: String!){
-        menuCreate(input: {name: $name}) {
+    mutation mc($name: String!, $collection: ID, $category: ID, $page: ID, $url: String) {
+        menuCreate(input: {
+            name: $name,
+            items: [
+                {name: "Collection item", collection: $collection},
+                {name: "Page item", page: $page},
+                {name: "Category item", category: $category},
+                {name: "Url item", url: $url}]
+        }) {
             menu {
                 name
+                items {
+                    edges {
+                        node {
+                            id
+                        }
+                    }
+                }
             }
         }
     }
     """
-    variables = json.dumps({'name': 'test-menu'})
+
+    category_id = graphene.Node.to_global_id('Category', category.pk)
+    collection_id = graphene.Node.to_global_id('Collection', collection.pk)
+    page_id = graphene.Node.to_global_id('Page', page.pk)
+    url = 'http://www.example.com'
+
+    variables = json.dumps({
+        'name': 'test-menu', 'collection': collection_id,
+        'category': category_id, 'page': page_id, 'url': url })
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)
@@ -199,8 +221,8 @@ def test_create_menu_item(admin_api_client, menu):
 
 def test_update_menu_item(admin_api_client, menu, menu_item, page):
     query = """
-    mutation updateMenuItem($id: ID!, $menu_id: ID!, $page: ID) {
-        menuItemUpdate(id: $id, input: {menu: $menu_id, page: $page}) {
+    mutation updateMenuItem($id: ID!, $page: ID) {
+        menuItemUpdate(id: $id, input: {page: $page}) {
             menuItem {
                 url
             }
@@ -212,9 +234,7 @@ def test_update_menu_item(admin_api_client, menu, menu_item, page):
     assert not menu_item.page
     menu_item_id = graphene.Node.to_global_id('MenuItem', menu_item.pk)
     page_id = graphene.Node.to_global_id('Page', page.pk)
-    menu_id = graphene.Node.to_global_id('Menu', menu.pk)
-    variables = json.dumps(
-        {'id': menu_item_id, 'page': page_id, 'menu_id': menu_id})
+    variables = json.dumps({'id': menu_item_id, 'page': page_id})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)
@@ -247,9 +267,9 @@ def test_delete_menu_item(admin_api_client, menu_item):
 
 def test_add_more_than_one_item(admin_api_client, menu, menu_item, page):
     query = """
-    mutation updateMenuItem($id: ID!, $menu_id: ID!, $page: ID, $url: String) {
+    mutation updateMenuItem($id: ID!, $page: ID, $url: String) {
         menuItemUpdate(id: $id,
-        input: {menu: $menu_id, page: $page, url: $url}) {
+        input: {page: $page, url: $url}) {
         errors {
             field
             message
@@ -263,9 +283,7 @@ def test_add_more_than_one_item(admin_api_client, menu, menu_item, page):
     url = 'http://www.example.com'
     menu_item_id = graphene.Node.to_global_id('MenuItem', menu_item.pk)
     page_id = graphene.Node.to_global_id('Page', page.pk)
-    menu_id = graphene.Node.to_global_id('Menu', menu.pk)
-    variables = json.dumps(
-        {'id': menu_item_id, 'page': page_id, 'menu_id': menu_id, 'url': url})
+    variables = json.dumps({'id': menu_item_id, 'page': page_id, 'url': url})
     response = admin_api_client.post(
         reverse('api'), {'query': query, 'variables': variables})
     content = get_graphql_content(response)