From d511944a992a948bb3c86925d975cae45c873ca3 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Mon, 14 Oct 2024 22:02:02 +0200 Subject: [PATCH 1/4] Cont working on the new menu in the backend --- backend/infrahub/api/menu.py | 226 +----------------- backend/infrahub/core/initialization.py | 12 +- backend/infrahub/core/protocols.py | 1 + .../infrahub/core/schema/definitions/core.py | 3 +- backend/infrahub/core/schema/schema_branch.py | 23 -- backend/infrahub/menu/generator.py | 18 +- backend/infrahub/menu/menu.py | 188 +++++++++++---- backend/infrahub/menu/models.py | 8 +- backend/infrahub/menu/utils.py | 12 + backend/tests/benchmark/test_get_menu.py | 12 +- .../test_schema_missing_menu_placement.py | 30 --- backend/tests/unit/api/test_menu.py | 25 +- .../schema_manager/test_manager_schema.py | 109 --------- backend/tests/unit/menu/test_generator.py | 40 +++- .../src/components/search/search-actions.tsx | 6 +- .../src/components/search/search-nodes.tsx | 10 +- frontend/app/src/config/config.ts | 2 +- frontend/app/src/infraops.d.ts | 83 +------ .../components/menu-section-internal.tsx | 14 +- .../components/menu-section-object.tsx | 18 +- .../components/object-avatar.tsx | 2 +- frontend/app/src/screens/layout/sidebar.tsx | 1 + .../object-items/object-items-paginated.tsx | 1 - .../src/screens/schema/schema-help-menu.tsx | 2 +- frontend/app/src/state/atoms/schema.atom.ts | 2 +- ...bject-create-update-diff-and-merge.spec.ts | 6 +- .../tutorial-4_integration-with-git.spec.ts | 6 +- frontend/app/tests/fixtures/menu.json | 84 +++---- .../integrations/screens/app-init.cy.tsx | 2 +- models/base/dcim.yml | 9 +- models/base/location.yml | 3 - models/base/organization.yml | 3 - models/base/routing.yml | 2 - models/base/service.yml | 1 - models/base_menu.yml | 162 +++++++++++++ python_sdk | 2 +- tasks/infra_ops.py | 11 +- 37 files changed, 473 insertions(+), 666 deletions(-) create mode 100644 backend/infrahub/menu/utils.py delete mode 100644 backend/tests/integration/schema_lifecycle/test_schema_missing_menu_placement.py create mode 100644 models/base_menu.yml diff --git a/backend/infrahub/api/menu.py b/backend/infrahub/api/menu.py index e622c61114..705d01ed62 100644 --- a/backend/infrahub/api/menu.py +++ b/backend/infrahub/api/menu.py @@ -3,21 +3,17 @@ from typing import TYPE_CHECKING from fastapi import APIRouter, Depends -from pydantic import BaseModel, Field from infrahub.api.dependencies import get_branch_dep, get_current_user, get_db from infrahub.core import registry from infrahub.core.branch import Branch # noqa: TCH001 -from infrahub.core.constants import InfrahubKind from infrahub.core.protocols import CoreMenuItem -from infrahub.core.schema import NodeSchema from infrahub.log import get_logger from infrahub.menu.generator import generate_menu from infrahub.menu.models import Menu # noqa: TCH001 if TYPE_CHECKING: from infrahub.auth import AccountSession - from infrahub.core.schema import MainSchemaTypes from infrahub.database import InfrahubDatabase @@ -25,229 +21,17 @@ router = APIRouter(prefix="/menu") -class InterfaceMenu(BaseModel): - title: str = Field(..., description="Title of the menu item") - path: str = Field(default="", description="URL endpoint if applicable") - icon: str = Field(default="", description="The icon to show for the current view") - children: list[InterfaceMenu] = Field(default_factory=list, description="Child objects") - kind: str = Field(default="") - - def __lt__(self, other: object) -> bool: - if not isinstance(other, InterfaceMenu): - raise NotImplementedError - return self.title < other.title - - def list_title(self) -> str: - return f"All {self.title}(s)" - - -def add_to_menu(structure: dict[str, list[InterfaceMenu]], menu_item: InterfaceMenu) -> None: - all_items = InterfaceMenu(title=menu_item.list_title(), path=menu_item.path, icon=menu_item.icon) - menu_item.path = "" - menu_item.icon = "" - for child in structure[menu_item.kind]: - menu_item.children.append(child) - if child.kind in structure: - add_to_menu(structure, child) - menu_item.children.sort() - menu_item.children.insert(0, all_items) - - -def _extract_node_icon(model: MainSchemaTypes) -> str: - if not model.icon: - return "" - return model.icon - - @router.get("") -async def get_menu(branch: Branch = Depends(get_branch_dep)) -> list[InterfaceMenu]: - log.info("menu_request", branch=branch.name) - - full_schema = registry.schema.get_full(branch=branch, duplicate=False) - objects = InterfaceMenu(title="Objects", children=[]) - - structure: dict[str, list[InterfaceMenu]] = {} - - ipam = InterfaceMenu( - title="IPAM", - children=[ - InterfaceMenu( - title="Namespaces", - path=f"/objects/{InfrahubKind.IPNAMESPACE}", - icon=_extract_node_icon(full_schema[InfrahubKind.IPNAMESPACE]), - ), - InterfaceMenu( - title="IP Prefixes", path="/ipam/prefixes", icon=_extract_node_icon(full_schema[InfrahubKind.IPPREFIX]) - ), - InterfaceMenu( - title="IP Addresses", - path="/ipam/addresses?ipam-tab=ip-details", - icon=_extract_node_icon(full_schema[InfrahubKind.IPADDRESS]), - ), - ], - ) - - has_ipam = False - - for key in full_schema.keys(): - model = full_schema[key] - - if isinstance(model, NodeSchema) and ( - InfrahubKind.IPADDRESS in model.inherit_from or InfrahubKind.IPPREFIX in model.inherit_from - ): - has_ipam = True - - if not model.include_in_menu: - continue - - menu_name = model.menu_placement or "base" - if menu_name not in structure: - structure[menu_name] = [] - - structure[menu_name].append( - InterfaceMenu(title=model.menu_title, path=f"/objects/{model.kind}", icon=model.icon or "", kind=model.kind) - ) - - for menu_item in structure["base"]: - if menu_item.kind in structure: - add_to_menu(structure, menu_item) - - objects.children.append(menu_item) - - objects.children.sort() - groups = InterfaceMenu( - title="Object Management", - children=[ - InterfaceMenu( - title="All Groups", - path=f"/objects/{InfrahubKind.GENERICGROUP}", - icon=_extract_node_icon(full_schema[InfrahubKind.GENERICGROUP]), - ), - InterfaceMenu( - title="All Profiles", - path=f"/objects/{InfrahubKind.PROFILE}", - icon=_extract_node_icon(full_schema[InfrahubKind.PROFILE]), - ), - InterfaceMenu( - title="Resource Manager", - path="/resource-manager", - icon=_extract_node_icon(full_schema[InfrahubKind.RESOURCEPOOL]), - ), - ], - ) - - unified_storage = InterfaceMenu( - title="Unified Storage", - children=[ - InterfaceMenu(title="Schema", path="/schema", icon="mdi:file-code"), - InterfaceMenu( - title="Repository", - path=f"/objects/{InfrahubKind.GENERICREPOSITORY}", - icon=_extract_node_icon(full_schema[InfrahubKind.GENERICREPOSITORY]), - ), - InterfaceMenu( - title="GraphQL Query", - path=f"/objects/{InfrahubKind.GRAPHQLQUERY}", - icon=_extract_node_icon(full_schema[InfrahubKind.GRAPHQLQUERY]), - ), - ], - ) - change_control = InterfaceMenu( - title="Change Control", - children=[ - InterfaceMenu(title="Branches", path="/branches", icon="mdi:layers-triple"), - InterfaceMenu( - title="Proposed Changes", - path="/proposed-changes", - icon=_extract_node_icon(full_schema[InfrahubKind.PROPOSEDCHANGE]), - ), - InterfaceMenu( - title="Check Definition", - path=f"/objects/{InfrahubKind.CHECKDEFINITION}", - icon=_extract_node_icon(full_schema[InfrahubKind.CHECKDEFINITION]), - ), - InterfaceMenu(title="Tasks", path="/tasks", icon="mdi:shield-check"), - ], - ) - deployment = InterfaceMenu( - title="Deployment", - children=[ - InterfaceMenu( - title="Artifact", - path=f"/objects/{InfrahubKind.ARTIFACT}", - icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACT]), - ), - InterfaceMenu( - title="Artifact Definition", - path=f"/objects/{InfrahubKind.ARTIFACTDEFINITION}", - icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACTDEFINITION]), - ), - InterfaceMenu( - title="Generator Definition", - path=f"/objects/{InfrahubKind.GENERATORDEFINITION}", - icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORDEFINITION]), - ), - InterfaceMenu( - title="Generator Instance", - path=f"/objects/{InfrahubKind.GENERATORINSTANCE}", - icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORINSTANCE]), - ), - InterfaceMenu( - title="Transformation", - path=f"/objects/{InfrahubKind.TRANSFORM}", - icon=_extract_node_icon(full_schema[InfrahubKind.TRANSFORM]), - ), - ], - ) - - admin = InterfaceMenu( - title="Admin", - children=[ - InterfaceMenu( - title="Role Management", - path="/role-management", - icon=_extract_node_icon(full_schema[InfrahubKind.BASEPERMISSION]), - ), - InterfaceMenu( - title="Credentials", - path=f"/objects/{InfrahubKind.CREDENTIAL}", - icon=_extract_node_icon(full_schema[InfrahubKind.CREDENTIAL]), - ), - InterfaceMenu( - title="Webhooks", - children=[ - InterfaceMenu( - title="Webhook", - path=f"/objects/{InfrahubKind.STANDARDWEBHOOK}", - icon=_extract_node_icon(full_schema[InfrahubKind.STANDARDWEBHOOK]), - ), - InterfaceMenu( - title="Custom Webhook", - path=f"/objects/{InfrahubKind.CUSTOMWEBHOOK}", - icon=_extract_node_icon(full_schema[InfrahubKind.CUSTOMWEBHOOK]), - ), - ], - ), - ], - ) - - menu_items = [objects] - if has_ipam: - menu_items.append(ipam) - menu_items.extend([groups, unified_storage, change_control, deployment, admin]) - - return menu_items - - -@router.get("/new") -async def get_new_menu( +async def get_menu( db: InfrahubDatabase = Depends(get_db), branch: Branch = Depends(get_branch_dep), account_session: AccountSession = Depends(get_current_user), ) -> Menu: - log.info("new_menu_request", branch=branch.name) + log.info("menu_request", branch=branch.name) - menu_items = await registry.manager.query(db=db, schema=CoreMenuItem, branch=branch, prefetch_relationships=True) + menu_items = await registry.manager.query( + db=db, schema=CoreMenuItem, branch=branch + ) # , prefetch_relationships=True) menu = await generate_menu(db=db, branch=branch, account=account_session, menu_items=menu_items) return menu.to_rest() diff --git a/backend/infrahub/core/initialization.py b/backend/infrahub/core/initialization.py index eef11ef412..d2b99d908a 100644 --- a/backend/infrahub/core/initialization.py +++ b/backend/infrahub/core/initialization.py @@ -20,7 +20,7 @@ from infrahub.core.node.resource_manager.ip_address_pool import CoreIPAddressPool from infrahub.core.node.resource_manager.ip_prefix_pool import CoreIPPrefixPool from infrahub.core.node.resource_manager.number_pool import CoreNumberPool -from infrahub.core.protocols import CoreAccount, CoreMenuItem +from infrahub.core.protocols import CoreAccount from infrahub.core.root import Root from infrahub.core.schema import SchemaRoot, core_models, internal_schema from infrahub.core.schema.manager import SchemaManager @@ -28,7 +28,7 @@ from infrahub.exceptions import DatabaseError from infrahub.log import get_logger from infrahub.menu.menu import default_menu -from infrahub.menu.models import MenuItemDefinition +from infrahub.menu.utils import create_menu_children from infrahub.permissions import PermissionBackend from infrahub.storage import InfrahubObjectStorage from infrahub.utils import format_label @@ -307,14 +307,6 @@ async def create_initial_permission(db: InfrahubDatabase) -> Node: return permission -async def create_menu_children(db: InfrahubDatabase, parent: CoreMenuItem, children: list[MenuItemDefinition]) -> None: - for child in children: - obj = await child.to_node(db=db, parent=parent) - await obj.save(db=db) - if child.children: - await create_menu_children(db=db, parent=obj, children=child.children) - - async def create_default_menu(db: InfrahubDatabase) -> None: for item in default_menu: obj = await item.to_node(db=db) diff --git a/backend/infrahub/core/protocols.py b/backend/infrahub/core/protocols.py index a49de3ff52..f9976574af 100644 --- a/backend/infrahub/core/protocols.py +++ b/backend/infrahub/core/protocols.py @@ -137,6 +137,7 @@ class CoreMenu(CoreNode): namespace: String name: String label: StringOptional + kind: StringOptional path: StringOptional description: StringOptional icon: StringOptional diff --git a/backend/infrahub/core/schema/definitions/core.py b/backend/infrahub/core/schema/definitions/core.py index c839c1ccf6..319830e417 100644 --- a/backend/infrahub/core/schema/definitions/core.py +++ b/backend/infrahub/core/schema/definitions/core.py @@ -67,11 +67,12 @@ "description": "Base node for the menu", "label": "Menu Item", "hierarchical": True, - "uniqueness_constraints": [["namespace__value", "name__value"]], + "human_friendly_id": ["namespace__value", "name__value"], "attributes": [ {"name": "namespace", "kind": "Text", "regex": NAMESPACE_REGEX, "order_weight": 1000}, {"name": "name", "kind": "Text", "order_weight": 1000}, {"name": "label", "kind": "Text", "optional": True, "order_weight": 2000}, + {"name": "kind", "kind": "Text", "optional": True, "order_weight": 2500}, {"name": "path", "kind": "Text", "optional": True, "order_weight": 2500}, {"name": "description", "kind": "Text", "optional": True, "order_weight": 3000}, {"name": "icon", "kind": "Text", "optional": True, "order_weight": 4000}, diff --git a/backend/infrahub/core/schema/schema_branch.py b/backend/infrahub/core/schema/schema_branch.py index 116cf5ca42..5d52e13dad 100644 --- a/backend/infrahub/core/schema/schema_branch.py +++ b/backend/infrahub/core/schema/schema_branch.py @@ -486,7 +486,6 @@ def process_pre_validation(self) -> None: def process_validate(self) -> None: self.validate_names() - self.validate_menu_placements() self.validate_kinds() self.validate_default_values() self.validate_count_against_cardinality() @@ -899,28 +898,6 @@ def validate_names(self) -> None: ): raise ValueError(f"{node.kind}: {rel.name} isn't allowed as a relationship name.") - def validate_menu_placements(self) -> None: - menu_placements: dict[str, str] = {} - - for name in list(self.nodes.keys()) + list(self.generics.keys()): - node = self.get(name=name, duplicate=False) - if node.menu_placement: - try: - placement_node = self.get(name=node.menu_placement, duplicate=False) - except SchemaNotFoundError as exc: - raise SchemaNotFoundError( - branch_name=self.name, - identifier=node.menu_placement, - message=f"{node.kind} refers to an invalid menu placement node: {node.menu_placement}.", - ) from exc - if node == placement_node: - raise ValueError(f"{node.kind}: cannot be placed under itself in the menu") from None - - if menu_placements.get(placement_node.kind) == node.kind: - raise ValueError(f"{node.kind}: cyclic menu placement with {placement_node.kind}") from None - - menu_placements[node.kind] = placement_node.kind - def validate_kinds(self) -> None: for name in list(self.nodes.keys()): node = self.get_node(name=name, duplicate=False) diff --git a/backend/infrahub/menu/generator.py b/backend/infrahub/menu/generator.py index 485d3dbf52..49645bba7f 100644 --- a/backend/infrahub/menu/generator.py +++ b/backend/infrahub/menu/generator.py @@ -32,31 +32,29 @@ async def generate_menu( full_schema = registry.schema.get_full(branch=branch, duplicate=False) already_processed = [] - havent_been_processed = [] # Process the parent first for item in menu_items: full_name = get_full_name(item) - parent = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) - if parent: - havent_been_processed.append(full_name) + parent1 = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) + if parent1: continue structure.data[full_name] = MenuItemDict.from_node(obj=item) already_processed.append(full_name) # Process the children - havent_been_processed = [] + havent_been_processed: list[str] = [] for item in menu_items: full_name = get_full_name(item) if full_name in already_processed: continue - parent = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) - if not parent: + parent2 = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) + if not parent2: havent_been_processed.append(full_name) continue - parent_full_name = get_full_name(parent) + parent_full_name = get_full_name(parent2) menu_item = structure.find_item(name=parent_full_name) if menu_item: child_item = MenuItemDict.from_node(obj=item) @@ -65,8 +63,8 @@ async def generate_menu( log.warning( "new_menu_request: unable to find the parent menu item", branch=branch.name, - menu_item=item.name.value, - parent_item=parent.name.value, + menu_item=child_item.identifier, + parent_item=parent_full_name, ) default_menu = structure.find_item(name=FULL_DEFAULT_MENU) diff --git a/backend/infrahub/menu/menu.py b/backend/infrahub/menu/menu.py index cbc4576594..d35e9bcb9d 100644 --- a/backend/infrahub/menu/menu.py +++ b/backend/infrahub/menu/menu.py @@ -27,7 +27,62 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: name=DEFAULT_MENU, label=DEFAULT_MENU.title(), protected=True, + icon="mdi:cube-outline", + section=MenuSection.OBJECT, + order_weight=10000, + ), + MenuItemDefinition( + namespace="Builtin", + name="IPAM", + label="IPAM", + protected=True, section=MenuSection.OBJECT, + icon="mdi:ip-network", + order_weight=9500, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="IPPrefix", + label="IP Prefixes", + kind=InfrahubKind.IPPREFIX, + path="/ipam/prefixes", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.IPPREFIX)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="IPAddress", + label="IP Addresses", + kind=InfrahubKind.IPPREFIX, + path="/ipam/addresses?ipam-tab=ip-details", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.IPADDRESS)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Namespaces", + label="Namespaces", + kind=InfrahubKind.IPNAMESPACE, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.IPNAMESPACE)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="ProposedChanges", + label="Proposed Changes", + path="/proposed-changes", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.PROPOSEDCHANGE)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, ), MenuItemDefinition( namespace="Builtin", @@ -36,7 +91,7 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: icon="mdi:cube-outline", protected=True, section=MenuSection.INTERNAL, - order_weight=1000, + order_weight=1500, children=[ MenuItemDefinition( namespace="Builtin", @@ -74,7 +129,7 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: namespace="Builtin", name="ChangeControl", label="Change Control", - icon="mdi:compare-vertical", + icon="mdi:source-branch", protected=True, section=MenuSection.INTERNAL, order_weight=2000, @@ -89,16 +144,6 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: section=MenuSection.INTERNAL, order_weight=1000, ), - MenuItemDefinition( - namespace="Builtin", - name="ProposedChanges", - label="Proposed Changes", - path="/proposed-changes", - icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.PROPOSEDCHANGE)), - protected=True, - section=MenuSection.INTERNAL, - order_weight=2000, - ), MenuItemDefinition( namespace="Builtin", name="CheckDefinition", @@ -121,11 +166,92 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: ), ], ), + MenuItemDefinition( + namespace="Builtin", + name="Deployment", + label="Deployment", + icon="mdi:rocket-launch", + protected=True, + section=MenuSection.INTERNAL, + order_weight=2500, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="ArtifactMenu", + label="Artifact", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="Artifact", + label="Artifact", + kind=InfrahubKind.ARTIFACT, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.ARTIFACT)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="ArtifactDefinition", + label="Artifact Definition", + kind=InfrahubKind.ARTIFACTDEFINITION, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.ARTIFACTDEFINITION)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="GeneratorMenu", + label="Generator", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="GeneratorInstance", + label="Generator Instance", + kind=InfrahubKind.GENERATORINSTANCE, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GENERATORINSTANCE)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="GeneratorDefinition", + label="Generator Definition", + kind=InfrahubKind.GENERATORDEFINITION, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GENERATORDEFINITION)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="Transformation", + label="Transformation", + kind=InfrahubKind.TRANSFORM, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.TRANSFORM)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + ], + ), MenuItemDefinition( namespace="Builtin", name="UnifiedStorage", label="Unified Storage", - icon="mdi:archive-arrow-down-outline", + icon="mdi:nas", protected=True, section=MenuSection.INTERNAL, order_weight=3000, @@ -142,7 +268,7 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: ), MenuItemDefinition( namespace="Builtin", - name="Repository", + name="Git Repository", label="Repository", kind=InfrahubKind.GENERICREPOSITORY, icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GENERICREPOSITORY)), @@ -169,7 +295,7 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: icon="mdi:settings-outline", protected=True, section=MenuSection.INTERNAL, - order_weight=3000, + order_weight=10000, children=[ MenuItemDefinition( namespace="Builtin", @@ -225,35 +351,3 @@ def _extract_node_icon(model: MainSchemaTypes) -> str: ], ), ] - - -# deployment = InterfaceMenu( -# title="Deployment", -# children=[ -# InterfaceMenu( -# title="Artifact", -# kind=InfrahubKind.ARTIFACT}", -# icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACT]), -# ), -# InterfaceMenu( -# title="Artifact Definition", -# kind=InfrahubKind.ARTIFACTDEFINITION}", -# icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACTDEFINITION]), -# ), -# InterfaceMenu( -# title="Generator Definition", -# kind=InfrahubKind.GENERATORDEFINITION}", -# icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORDEFINITION]), -# ), -# InterfaceMenu( -# title="Generator Instance", -# kind=InfrahubKind.GENERATORINSTANCE}", -# icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORINSTANCE]), -# ), -# InterfaceMenu( -# title="Transformation", -# kind=InfrahubKind.TRANSFORM}", -# icon=_extract_node_icon(full_schema[InfrahubKind.TRANSFORM]), -# ), -# ], -# ) diff --git a/backend/infrahub/menu/models.py b/backend/infrahub/menu/models.py index f5e2ac94ba..db0b17ce1e 100644 --- a/backend/infrahub/menu/models.py +++ b/backend/infrahub/menu/models.py @@ -72,7 +72,7 @@ class Menu: class MenuItem(BaseModel): identifier: str = Field(..., description="Unique identifier for this menu item") - title: str = Field(..., description="Title of the menu item") + label: str = Field(..., description="Title of the menu item") path: str = Field(default="", description="URL endpoint if applicable") icon: str = Field(default="", description="The icon to show for the current view") kind: str = Field(default="", description="Kind of the model associated with this menuitem if applicable") @@ -83,11 +83,11 @@ class MenuItem(BaseModel): def from_node(cls, obj: CoreMenuItem) -> Self: return cls( identifier=get_full_name(obj), - title=obj.label.value or "", + label=obj.label.value or "", icon=obj.icon.value or "", order_weight=obj.order_weight.value, path=obj.path.value or "", - kind=obj.get_kind(), + kind=obj.kind.value or "", section=obj.section.value, ) @@ -95,7 +95,7 @@ def from_node(cls, obj: CoreMenuItem) -> Self: def from_schema(cls, model: NodeSchema | GenericSchema | ProfileSchema) -> Self: return cls( identifier=get_full_name(model), - title=model.label or model.kind, + label=model.label or model.kind, path=f"/objects/{model.kind}", icon=model.icon or "", kind=model.kind, diff --git a/backend/infrahub/menu/utils.py b/backend/infrahub/menu/utils.py new file mode 100644 index 0000000000..da00d11bac --- /dev/null +++ b/backend/infrahub/menu/utils.py @@ -0,0 +1,12 @@ +from infrahub.core.protocols import CoreMenuItem +from infrahub.database import InfrahubDatabase + +from .models import MenuItemDefinition + + +async def create_menu_children(db: InfrahubDatabase, parent: CoreMenuItem, children: list[MenuItemDefinition]) -> None: + for child in children: + obj = await child.to_node(db=db, parent=parent) + await obj.save(db=db) + if child.children: + await create_menu_children(db=db, parent=obj, children=child.children) diff --git a/backend/tests/benchmark/test_get_menu.py b/backend/tests/benchmark/test_get_menu.py index c4aea5013b..c4c944300b 100644 --- a/backend/tests/benchmark/test_get_menu.py +++ b/backend/tests/benchmark/test_get_menu.py @@ -1,6 +1,14 @@ +import pytest + from infrahub.api.menu import get_menu +from infrahub.core.initialization import create_default_menu from infrahub.database import InfrahubDatabase -def test_get_menu(aio_benchmark, db: InfrahubDatabase, default_branch, register_core_models_schema): - aio_benchmark(get_menu, branch=default_branch) +@pytest.fixture +async def init_menu(db: InfrahubDatabase, default_branch, register_core_models_schema): + await create_default_menu(db=db) + + +def test_get_menu(aio_benchmark, db: InfrahubDatabase, default_branch, register_core_models_schema, init_menu): + aio_benchmark(get_menu, db=db, branch=default_branch, account_session=None) diff --git a/backend/tests/integration/schema_lifecycle/test_schema_missing_menu_placement.py b/backend/tests/integration/schema_lifecycle/test_schema_missing_menu_placement.py deleted file mode 100644 index 076ea39112..0000000000 --- a/backend/tests/integration/schema_lifecycle/test_schema_missing_menu_placement.py +++ /dev/null @@ -1,30 +0,0 @@ -from infrahub_sdk import InfrahubClient - -from .shared import ( - TestSchemaLifecycleBase, -) - - -class TestSchemaMissingMenuPlacement(TestSchemaLifecycleBase): - async def test_schema_missing_menu_placement(self, client: InfrahubClient): - schema = { - "version": "1.0", - "nodes": [ - { - "name": "BNode", - "namespace": "Infra", - "menu_placement": "UnexistingNode", - "label": "BNode", - "display_labels": ["name__value"], - "attributes": [{"name": "name", "kind": "Text", "unique": True}], - } - ], - } - - response = await client.schema.load(schemas=[schema], branch="main") - assert response.schema_updated is False - assert response.errors["errors"][0]["extensions"]["code"] == 422 - assert ( - response.errors["errors"][0]["message"] - == "InfraBNode refers to an invalid menu placement node: UnexistingNode." - ) diff --git a/backend/tests/unit/api/test_menu.py b/backend/tests/unit/api/test_menu.py index 4d37efaf70..17157946b9 100644 --- a/backend/tests/unit/api/test_menu.py +++ b/backend/tests/unit/api/test_menu.py @@ -1,4 +1,3 @@ -from infrahub.api.menu import InterfaceMenu from infrahub.core.branch import Branch from infrahub.core.initialization import create_default_menu from infrahub.core.schema import SchemaRoot @@ -12,34 +11,12 @@ async def test_get_menu( default_branch: Branch, car_person_schema_generics: SchemaRoot, car_person_data_generic, -): - with client: - response = client.get( - "/api/menu", - headers=client_headers, - ) - - assert response.status_code == 200 - assert response.json() is not None - - menu = [InterfaceMenu(**menu_item) for menu_item in response.json()] - assert menu[0].title == "Objects" - assert menu[0].children[0].title == "Car" - - -async def test_get_new_menu( - db: InfrahubDatabase, - client, - client_headers, - default_branch: Branch, - car_person_schema_generics: SchemaRoot, - car_person_data_generic, ): await create_default_menu(db=db) with client: response = client.get( - "/api/menu/new", + "/api/menu", headers=client_headers, ) diff --git a/backend/tests/unit/core/schema_manager/test_manager_schema.py b/backend/tests/unit/core/schema_manager/test_manager_schema.py index 23ad98c2f1..0179f7938e 100644 --- a/backend/tests/unit/core/schema_manager/test_manager_schema.py +++ b/backend/tests/unit/core/schema_manager/test_manager_schema.py @@ -920,115 +920,6 @@ async def test_schema_branch_validate_kinds_core(register_core_models_schema: Sc register_core_models_schema.validate_kinds() -async def test_schema_branch_validate_menu_placement(): - """Validate that menu placements points to objects that exists in the schema.""" - FULL_SCHEMA = { - "version": "1.0", - "nodes": [ - { - "name": "Criticality", - "namespace": "Test", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - { - "name": "SubObject", - "namespace": "Test", - "menu_placement": "NoSuchObject", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - ], - } - - schema = SchemaBranch(cache={}) - schema.load_schema(schema=SchemaRoot(**FULL_SCHEMA)) - - with pytest.raises(SchemaNotFoundError) as exc: - schema.validate_menu_placements() - - assert exc.value.message == "TestSubObject refers to an invalid menu placement node: NoSuchObject." - - -async def test_schema_branch_validate_same_node_menu_placement(): - """Validate that menu placements points to objects that exists in the schema.""" - FULL_SCHEMA = { - "version": "1.0", - "nodes": [ - { - "name": "Criticality", - "namespace": "Test", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - { - "name": "SubObject", - "namespace": "Test", - "menu_placement": "TestSubObject", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - ], - } - - schema = SchemaBranch(cache={}) - schema.load_schema(schema=SchemaRoot(**FULL_SCHEMA)) - - with pytest.raises(ValueError) as exc: - schema.validate_menu_placements() - - assert str(exc.value) == "TestSubObject: cannot be placed under itself in the menu" - - -async def test_schema_branch_validate_cyclic_menu_placement(): - """Validate that menu placements points to objects that exists in the schema.""" - FULL_SCHEMA = { - "version": "1.0", - "nodes": [ - { - "name": "Criticality", - "namespace": "Test", - "menu_placement": "TestSubObject", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - { - "name": "SubObject", - "namespace": "Test", - "menu_placement": "TestCriticality", - "default_filter": "name__value", - "branch": BranchSupportType.AWARE.value, - "attributes": [ - {"name": "name", "kind": "Text", "unique": True}, - ], - }, - ], - } - - schema = SchemaBranch(cache={}) - schema.load_schema(schema=SchemaRoot(**FULL_SCHEMA)) - - with pytest.raises(ValueError) as exc: - schema.validate_menu_placements() - - assert str(exc.value) == "TestSubObject: cyclic menu placement with TestCriticality" - - @pytest.mark.parametrize( "uniqueness_constraints", [ diff --git a/backend/tests/unit/menu/test_generator.py b/backend/tests/unit/menu/test_generator.py index 1781ae4c02..7d18927e26 100644 --- a/backend/tests/unit/menu/test_generator.py +++ b/backend/tests/unit/menu/test_generator.py @@ -7,12 +7,37 @@ from infrahub.menu.constants import MenuSection from infrahub.menu.generator import generate_menu from infrahub.menu.models import MenuItemDefinition +from infrahub.menu.utils import create_menu_children + + +def generate_menu_fixtures(prefix: str = "Menu", depth: int = 1, nbr_item: int = 10) -> list[MenuItemDefinition]: + max_depth = 3 + next_level_item: int = 5 + + menu: list[MenuItemDefinition] = [] + + for idx in range(nbr_item): + item = MenuItemDefinition( + namespace="Test", + name=f"{prefix}{idx}", + label=f"{prefix}{idx}", + section=MenuSection.OBJECT, + order_weight=(idx + 1) * 1000, + ) + + if depth <= max_depth: + item.children = generate_menu_fixtures(prefix=f"{prefix}{idx}", depth=depth + 1, nbr_item=next_level_item) + + menu.append(item) + + return menu async def test_generate_menu( db: InfrahubDatabase, default_branch: Branch, car_person_schema_generics: SchemaRoot, + helper, ): schema_branch = registry.schema.get_schema_branch(name=default_branch.name) @@ -22,20 +47,13 @@ async def test_generate_menu( await create_default_menu(db=db) - new_menu_items = [ - MenuItemDefinition( - namespace="Test", - name="CarGaz", - label="Car Gaz", - kind="TestCarGaz", - section=MenuSection.OBJECT, - order_weight=1500, - ) - ] + new_menu_items = generate_menu_fixtures(nbr_item=5) for item in new_menu_items: obj = await item.to_node(db=db) await obj.save(db=db) + if item.children: + await create_menu_children(db=db, parent=obj, children=item.children) menu_items = await registry.manager.query( db=db, schema=CoreMenuItem, branch=default_branch, prefetch_relationships=True @@ -43,4 +61,4 @@ async def test_generate_menu( menu = await generate_menu(db=db, branch=default_branch, menu_items=menu_items) assert menu - assert "Test:CarGaz" in menu.data.keys() + assert "Test:Menu0" in menu.data.keys() diff --git a/frontend/app/src/components/search/search-actions.tsx b/frontend/app/src/components/search/search-actions.tsx index b0a852735d..ee99d22e92 100644 --- a/frontend/app/src/components/search/search-actions.tsx +++ b/frontend/app/src/components/search/search-actions.tsx @@ -17,8 +17,8 @@ export const SearchActions = ({ query }: SearchProps) => { const menuItems = useAtomValue(menuFlatAtom); const queryLowerCased = query.toLowerCase(); - const resultsMenu = menuItems.filter(({ title }) => - title.toLowerCase().includes(queryLowerCased) + const resultsMenu = menuItems.filter(({ label }) => + label.toLowerCase().includes(queryLowerCased) ); const resultsSchema = models.filter( ({ kind, label, description }) => @@ -55,7 +55,7 @@ const ActionOnMenu = ({ menuItem }: ActionOnMenuProps) => { Menu - {menuItem.title} + {menuItem.label} ); }; diff --git a/frontend/app/src/components/search/search-nodes.tsx b/frontend/app/src/components/search/search-nodes.tsx index 9bebaa0374..9edc5faa86 100644 --- a/frontend/app/src/components/search/search-nodes.tsx +++ b/frontend/app/src/components/search/search-nodes.tsx @@ -59,13 +59,21 @@ type NodesOptionsProps = { const NodesOptions = ({ node }: NodesOptionsProps) => { const { schema } = useSchema(node.kind); - const { data, loading } = useObjectDetails(schema!, node.id); + const { data, loading, error } = useObjectDetails(schema!, node.id); if (!schema) return null; const columns = getSchemaObjectColumns({ schema, forListView: true, limit: 7 }); if (loading) return ; + if (error) { + return ( +
+ Error loading object details for {node.kind} (id: {node.id}) +
+ ); + } + const objectDetailsData = schema && data?.[node.kind]?.edges[0]?.node; if (!objectDetailsData) return
No data found for this object
; diff --git a/frontend/app/src/config/config.ts b/frontend/app/src/config/config.ts index 36403c2fce..f56e76bbe2 100644 --- a/frontend/app/src/config/config.ts +++ b/frontend/app/src/config/config.ts @@ -51,7 +51,7 @@ export const CONFIG = { `${INFRAHUB_API_SERVER_URL}/api/file/${repositoryId}/${encodeURIComponent(location)}`, STORAGE_DETAILS_URL: (id: string) => `${INFRAHUB_API_SERVER_URL}/api/storage/object/${id}`, MENU_URL: (branch?: string) => - `${INFRAHUB_API_SERVER_URL}/api/menu/new${branch ? `?branch=${branch}` : ""}`, + `${INFRAHUB_API_SERVER_URL}/api/menu${branch ? `?branch=${branch}` : ""}`, MENU_URL_OLD: (branch?: string) => `${INFRAHUB_API_SERVER_URL}/api/menu${branch ? `?branch=${branch}` : ""}`, }; diff --git a/frontend/app/src/infraops.d.ts b/frontend/app/src/infraops.d.ts index 41f0909ae1..e1254d974e 100644 --- a/frontend/app/src/infraops.d.ts +++ b/frontend/app/src/infraops.d.ts @@ -228,23 +228,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/menu/new": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get New Menu */ - get: operations["get_new_menu_api_menu_new_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/oauth2/{provider_name}/authorize": { parameters: { query?: never; @@ -1535,36 +1518,6 @@ export interface components { /** Version */ version: string; }; - /** InterfaceMenu */ - InterfaceMenu: { - /** - * Title - * @description Title of the menu item - */ - title: string; - /** - * Path - * @description URL endpoint if applicable - * @default - */ - path: string; - /** - * Icon - * @description The icon to show for the current view - * @default - */ - icon: string; - /** - * Children - * @description Child objects - */ - children?: components["schemas"]["InterfaceMenu"][]; - /** - * Kind - * @default - */ - kind: string; - }; /** JSONSchema */ JSONSchema: { /** @@ -1672,10 +1625,10 @@ export interface components { */ identifier: string; /** - * Title + * Label * @description Title of the menu item */ - title: string; + label: string; /** * Path * @description URL endpoint if applicable @@ -2569,38 +2522,6 @@ export interface operations { }; }; get_menu_api_menu_get: { - parameters: { - query?: { - /** @description Name of the branch to use for the query */ - branch?: string | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["InterfaceMenu"][]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_new_menu_api_menu_new_get: { parameters: { query?: { /** @description Name of the branch to use for the query */ diff --git a/frontend/app/src/screens/layout/menu-navigation/components/menu-section-internal.tsx b/frontend/app/src/screens/layout/menu-navigation/components/menu-section-internal.tsx index 7499a534c5..f18847d497 100644 --- a/frontend/app/src/screens/layout/menu-navigation/components/menu-section-internal.tsx +++ b/frontend/app/src/screens/layout/menu-navigation/components/menu-section-internal.tsx @@ -30,7 +30,7 @@ export function MenuSectionInternal({ items, isCollapsed }: MenuSectionInternalP @@ -39,7 +39,7 @@ export function MenuSectionInternal({ items, isCollapsed }: MenuSectionInternalP return ( - {item.title} + {item.label} ); } @@ -50,11 +50,11 @@ export function MenuSectionInternal({ items, isCollapsed }: MenuSectionInternalP className={classNames(menuNavigationItemStyle, isCollapsed && "p-0")} > {isCollapsed ? ( - + ) : ( <> - {item.title} + {item.label} - {child.title} + {child.label} ); } return ( - {item.title} + {item.label} {child.children.map((grandchild) => { return ( - {grandchild.title} + {grandchild.label} ); })} diff --git a/frontend/app/src/screens/layout/menu-navigation/components/menu-section-object.tsx b/frontend/app/src/screens/layout/menu-navigation/components/menu-section-object.tsx index b644f591df..b07bf0c550 100644 --- a/frontend/app/src/screens/layout/menu-navigation/components/menu-section-object.tsx +++ b/frontend/app/src/screens/layout/menu-navigation/components/menu-section-object.tsx @@ -31,23 +31,23 @@ export function MenuSectionObject({ isCollapsed, items }: MenuSectionObjectsProp {item.icon ? ( ) : ( - + )} - {item.title} + {item.label} ); } return ( - + {item.icon ? ( ) : ( - + )} - {item.title} + {item.label} @@ -57,14 +57,14 @@ export function MenuSectionObject({ isCollapsed, items }: MenuSectionObjectsProp sideOffset={12} className="h-[calc(100vh-57px)] mt-[57px] min-w-[224px] px-4 py-5 bg-white border rounded-r-lg rounded-l-none shadow-none relative -top-px overflow-auto data-[side=right]:slide-in-from-left-[100px]" > -

{item.title}

+

{item.label}

{item.children.map((child) => { if (!child.children || child.children.length === 0) { return ( - {child.title} + {child.label} ); @@ -73,12 +73,12 @@ export function MenuSectionObject({ isCollapsed, items }: MenuSectionObjectsProp return ( - {child.title} + {child.label} - {child.title} + {child.label} diff --git a/frontend/app/src/screens/layout/menu-navigation/components/object-avatar.tsx b/frontend/app/src/screens/layout/menu-navigation/components/object-avatar.tsx index 4e33aebbc7..7a5da232bc 100644 --- a/frontend/app/src/screens/layout/menu-navigation/components/object-avatar.tsx +++ b/frontend/app/src/screens/layout/menu-navigation/components/object-avatar.tsx @@ -10,7 +10,7 @@ const STYLES = [ "bg-blue-50 text-blue-600", ]; -export function ObjectAvatar({ name }: { name: string }) { +export function ObjectAvatar({ name = "" }: { name: string }) { const firstLetter = name[0]; if (!firstLetter) { return
; diff --git a/frontend/app/src/screens/layout/sidebar.tsx b/frontend/app/src/screens/layout/sidebar.tsx index 24972d345d..9b574812cf 100644 --- a/frontend/app/src/screens/layout/sidebar.tsx +++ b/frontend/app/src/screens/layout/sidebar.tsx @@ -22,6 +22,7 @@ export default function Sidebar() { "group/sidebar transition-all", booleanCollapsed && "w-[72px] px-2 items-center" )} + data-testid="sidebar" >