From 37ea4944f54aaaed314ab3ea9fe31e0564de2993 Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Fri, 15 Dec 2023 18:19:31 +0100
Subject: [PATCH 1/9] Introduced UIResourceComponent, clarified its API

---
 oarepo_ui/config.py               |  13 ++
 oarepo_ui/ext.py                  |   3 +
 oarepo_ui/resources/components.py | 252 +++++++++++++++++++++++++-----
 oarepo_ui/resources/config.py     |  14 +-
 oarepo_ui/resources/resource.py   |  92 +++++------
 5 files changed, 267 insertions(+), 107 deletions(-)

diff --git a/oarepo_ui/config.py b/oarepo_ui/config.py
index aaa93e80..2748ff4a 100644
--- a/oarepo_ui/config.py
+++ b/oarepo_ui/config.py
@@ -8,3 +8,16 @@
 THEME_HEADER_LOGIN_TEMPLATE = "oarepo_ui/header_login.html"
 
 OAREPO_UI_JINJAX_FILTERS = {}
+
+OAREPO_UI_RECORD_ACTIONS = [
+    "edit",
+    "new_version",
+    "manage",
+    "update_draft",
+    "read_files",
+    "review",
+    "view",
+    "delete_draft",
+    "manage_files",
+    "manage_record_access",
+]
diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py
index 578617b3..8e8d895b 100644
--- a/oarepo_ui/ext.py
+++ b/oarepo_ui/ext.py
@@ -144,6 +144,9 @@ def development_after_request(self, response: Response):
 
             return add_vite_tags(response)
 
+    @property
+    def record_actions(self):
+        return self.app.config["OAREPO_UI_RECORD_ACTIONS"]
 
 class OARepoUIExtension:
     def __init__(self, app=None):
diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py
index 3e02a0c1..c679c352 100644
--- a/oarepo_ui/resources/components.py
+++ b/oarepo_ui/resources/components.py
@@ -1,12 +1,180 @@
 from flask import current_app
+from flask_principal import Identity
 from invenio_i18n.ext import current_i18n
-from invenio_records_resources.services.records.components import ServiceComponent
+from invenio_records_resources.services.records.results import RecordItem
 from oarepo_runtime.datastreams.utils import get_file_service_for_record_service
+from typing import Dict, TYPE_CHECKING
 
+from ..proxies import current_oarepo_ui
 
-class BabelComponent(ServiceComponent):
+if TYPE_CHECKING:
+    from .resource import UIResource
+
+
+class UIResourceComponent:
+    """
+    Only the currently used methods and their parameters are in this interface.
+    Custom resources can add their own methods/parameters.
+
+    You are free to base your implementation on this class or base it directly on ServiceComponent.
+
+    Component gets the resource instance as a parameter in the constructor and can use .config property to access
+    the resource configuration.
+
+    Naming convention for parameters:
+        * record - the record being displayed, always is an instance of RecordItem
+        * data - data serialized by the API service serializer. A dictionary
+        * ui_data - UI serialization of the record as comes from the ui serializer. A dictionary
+        * empty_data - empty record data, compatible with the API service serializer. A dictionary
+    """
+
+    def __init__(self, resource: "UIResource"):
+        """
+        :param resource: the resource instance
+        """
+        self.resource = resource
+
+    @property
+    def config(self):
+        """The UI configuration."""
+        return self.resource.config
+
+    def empty_record(self, *, resource_requestctx, empty_data: Dict, **kwargs):
+        """
+        Called before an empty record data are returned.
+
+        :param resource_requestctx: invenio request context (see https://github.com/inveniosoftware/flask-resources/blob/master/flask_resources/context.py)
+        :param empty_data: empty record data
+        """
+
+    def fill_jinja_context(self, *, context: Dict, **kwargs):
+        """This method is called from flask/jinja context processor before the template starts rendering.
+           You can add your own variables to the context here.
+
+        :param context: the context dictionary that will be merged into the template's context
+        """
+
+    def before_ui_detail(
+        self,
+        *,
+            record: RecordItem,
+            ui_data: Dict,
+            identity: Identity,
+            args: Dict,
+            view_args: Dict,
+            ui_links: Dict,
+            extra_context: Dict,
+            **kwargs,
+    ):
+        """
+        Called before the detail page is rendered.
+
+        :param record: the record being displayed
+        :param ui_data: UI serialization of the record
+        :param identity: the current user identity
+        :param args: query parameters
+        :param view_args: view arguments
+        :param ui_links: UI links for the record, a dictionary of link name -> link url
+        :param extra_context: will be passed to the template as the "extra_context" variable
+        """
+    def before_ui_search(self, *,
+                         identity: Identity,
+                         search_options: Dict,
+                         args: Dict,
+                         view_args: Dict,
+                         ui_links: Dict,
+                         extra_context: Dict,
+                         **kwargs):
+        """
+        Called before the search page is rendered.
+        Note: search results are fetched via AJAX, so are not available in this method.
+        This method just provides the context for the jinjax template of the search page.
+
+        :param identity: the current user identity
+        :param search_options: dictionary of search options, containing api_config, identity, overrides.
+            It is fed to self.config.search_app_config as **search_options
+        :param args: query parameters
+        :param view_args: view arguments
+        :param ui_links: UI links for the search page, a dictionary of link name -> link url
+        :param extra_context: will be passed to the template as the "extra_context" variable
+        """
+
+    def form_config(self, *,
+    record: RecordItem,
+    data: Dict,
+    identity: Identity,
+    form_config: Dict,
+    args: Dict,
+    view_args: Dict,
+    ui_links: Dict,
+    extra_context: Dict,
+                    **kwargs):
+        """
+        Called to fill form_config for the create/edit page.
+
+        :param record: the record being edited. Can be None if creating a new record.
+        :param data: data serialized by the API service serializer. If a record is being edited,
+                     this is the serialized record data. If a new record is being created, this is empty_data
+                     after being processed by the empty_record method on registered UI components.
+        :param identity: the current user identity
+        :param form_config: form configuration dictionary
+        :param args: query parameters
+        :param view_args: view arguments
+        :param ui_links: UI links for the create/edit page, a dictionary of link name -> link url
+        :param extra_context: will be passed to the template as the "extra_context" variable
+        """
+
+    def before_ui_edit(self, *,
+                       record: RecordItem,
+                       data: Dict,
+                       ui_data: Dict,
+                       identity: Identity,
+                       form_config: Dict,
+                       args: Dict,
+                       view_args: Dict,
+                       ui_links: Dict,
+                       extra_context: Dict,
+                       **kwargs):
+        """
+        Called before the edit page is rendered, after form_config has been filled.
+
+        :param record: the record being edited
+        :param data: data serialized by the API service serializer. This is the serialized record data.
+        :param ui_data: UI serialization of the record (localized). The ui data can be used in the edit
+                        template to display, for example, the localized record title.
+        :param identity: the current user identity
+        :param form_config: form configuration dictionary
+        :param args: query parameters
+        :param view_args: view arguments
+        :param ui_links: UI links for the edit page, a dictionary of link name -> link url
+        :param extra_context: will be passed to the template as the "extra_context" variable
+        """
+
+    def before_ui_create(self, *,
+                         data: Dict,
+                         identity: Identity,
+                         form_config: Dict,
+                         args: Dict,
+                         view_args: Dict,
+                         ui_links: Dict,
+                         extra_context: Dict,
+                         **kwargs):
+        """
+        Called before the create page is rendered, after form_config has been filled
+
+        :param data: A dictionary with empty data (show just the structure of the record, with values replaced by None)
+        :param identity: the current user identity
+        :param form_config: form configuration dictionary
+        :param args: query parameters
+        :param view_args: view arguments
+        :param ui_links: UI links for the create page, a dictionary of link name -> link url
+        :param extra_context: will be passed to the template as the "extra_context" variable
+        """
+
+
+class BabelComponent(UIResourceComponent):
     def form_config(
-        self, *, form_config, resource, record, view_args, identity, **kwargs
+        self, *, form_config, **kwargs
     ):
         conf = current_app.config
         locales = []
@@ -23,60 +191,62 @@ def form_config(
         form_config.setdefault("locales", locales)
 
 
-class PermissionsComponent(ServiceComponent):
-    def get_record_permissions(self, actions, service, identity, record=None):
-        """Helper for generating (default) record action permissions."""
-        return {
-            f"can_{action}": service.check_permission(identity, action, record=record)
-            for action in actions
-        }
-
-    def before_ui_detail(self, *, resource, record, extra_context, identity, **kwargs):
-        self.fill_permissions(resource, record, extra_context, identity)
+class PermissionsComponent(UIResourceComponent):
+    def before_ui_detail(self, *, record, extra_context, identity, **kwargs):
+        self.fill_permissions(record.data, extra_context, identity)
 
-    def before_ui_edit(self, *, resource, record, extra_context, identity, **kwargs):
-        self.fill_permissions(resource, record, extra_context, identity)
+    def before_ui_edit(self, *, record, extra_context, identity, **kwargs):
+        self.fill_permissions(record.data, extra_context, identity)
 
-    def before_ui_create(self, *, resource, record, extra_context, identity, **kwargs):
-        self.fill_permissions(resource, record, extra_context, identity)
+    def before_ui_create(self, *, extra_context, identity, **kwargs):
+        self.fill_permissions(None, extra_context, identity)
 
     def before_ui_search(
-        self, *, resource, extra_context, identity, search_options, **kwargs
+        self, *, extra_context, identity, search_options, **kwargs
     ):
-        extra_context["permissions"] = self.get_record_permissions(
-            ["create"], resource.api_service, identity
-        )
+        from .resource import RecordsUIResource
+        if not isinstance(self.resource, RecordsUIResource):
+            return
+
+        extra_context["permissions"] = {
+            f"can_create": self.resource.api_service.check_permission(identity, "create")
+        }
+
         search_options["permissions"] = extra_context["permissions"]
 
     def form_config(
-        self, *, form_config, resource, record, view_args, identity, **kwargs
+        self, *, form_config, record, identity, **kwargs
     ):
-        self.fill_permissions(resource, record, form_config, identity)
+        self.fill_permissions(record, form_config, identity)
+
+    def get_record_permissions(self, actions, service, identity, data):
+        """Helper for generating (default) record action permissions."""
+        return {
+            f"can_{action}": service.check_permission(identity, action, record=data)
+            for action in actions
+        }
+
+    def fill_permissions(self, data, extra_context, identity):
+        from .resource import RecordsUIResource
+        if not isinstance(self.resource, RecordsUIResource):
+            return
 
-    def fill_permissions(self, resource, record, extra_context, identity):
         extra_context["permissions"] = self.get_record_permissions(
-            [
-                "edit",
-                "new_version",
-                "manage",
-                "update_draft",
-                "read_files",
-                "review",
-                "view",
-                "delete_draft",
-                "manage_files",
-                "manage_record_access",
-            ],
-            resource.api_service,
+            current_oarepo_ui.record_actions,
+            self.resource.api_service,
             identity,
-            record,
+            data,
         )
 
 
-class FilesComponent(ServiceComponent):
-    def before_ui_edit(self, *, record, resource, extra_context, identity, **kwargs):
+class FilesComponent(UIResourceComponent):
+    def before_ui_edit(self, *, record, extra_context, identity, **kwargs):
+        from .resource import RecordsUIResource
+        if not isinstance(self.resource, RecordsUIResource):
+            return
+
         file_service = get_file_service_for_record_service(
-            resource.api_service, record=record
+            self.resource.api_service, record=record
         )
         files = file_service.list_files(identity, record["id"])
         extra_context["files"] = files.to_dict()
diff --git a/oarepo_ui/resources/config.py b/oarepo_ui/resources/config.py
index 233a1dce..d20eec71 100644
--- a/oarepo_ui/resources/config.py
+++ b/oarepo_ui/resources/config.py
@@ -61,16 +61,12 @@ class RecordsUIResourceConfig(UIResourceConfig):
     """Name of the API service as registered inside the service registry"""
 
     templates = {
-        "detail": {
-            "layout": "oarepo_ui/detail.html",
-        },
-        "search": {
-            "layout": "oarepo_ui/search.html",
-        },
-        "edit": {"layout": "oarepo_ui/form.html"},
-        "create": {"layout": "oarepo_ui/form.html"},
+        "detail": None,
+        "search": None,
+        "edit": None,
+        "create": None,
     }
-    layout = "sample"
+    """Templates used for rendering the UI. It is a name of a jinjax macro that renders the UI"""
 
     empty_record = {}
 
diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index 52a91789..50f38f65 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -1,5 +1,6 @@
 import copy
 from functools import partial
+from typing import Iterator, TYPE_CHECKING
 
 import deepmerge
 from flask import abort, g, redirect, request
@@ -24,6 +25,9 @@
 
 from oarepo_ui.utils import dump_empty
 
+if TYPE_CHECKING:
+    from .components import UIResourceComponent
+
 #
 # Resource
 #
@@ -56,7 +60,7 @@ def as_blueprint(self, **options):
     # Pluggable components
     #
     @property
-    def components(self):
+    def components(self) -> Iterator["UIResourceComponent"]:
         """Return initialized service components."""
         return (c(self) for c in self.config.components or [])
 
@@ -96,27 +100,27 @@ def create_url_rules(self):
 
     def empty_record(self, resource_requestctx, **kwargs):
         """Create an empty record with default values."""
-        record = dump_empty(self.api_config.schema)
+        empty_data = dump_empty(self.api_config.schema)
         files_field = getattr(self.api_config.record_cls, "files", None)
         if files_field and isinstance(files_field, FilesField):
-            record["files"] = {"enabled": False}
-        record = deepmerge.always_merger.merge(
-            record, copy.deepcopy(self.config.empty_record)
+            empty_data["files"] = {"enabled": False}
+        empty_data = deepmerge.always_merger.merge(
+            empty_data, copy.deepcopy(self.config.empty_record)
         )
         self.run_components(
-            "empty_record", resource_requestctx=resource_requestctx, record=record
+            "empty_record", resource_requestctx=resource_requestctx, empty_data=empty_data
         )
-        return record
+        return empty_data
 
     def as_blueprint(self, **options):
         blueprint = super().as_blueprint(**options)
-        blueprint.app_context_processor(lambda: self.register_context_processor())
+        blueprint.app_context_processor(lambda: self.fill_jinja_context())
         return blueprint
 
-    def register_context_processor(self):
+    def fill_jinja_context(self):
         """function providing flask template app context processors"""
         ret = {}
-        self.run_components("register_context_processor", context_processors=ret)
+        self.run_components("fill_jinja_context", context=ret)
         return ret
 
     @request_read_args
@@ -126,32 +130,31 @@ def detail(self):
         """Returns item detail page."""
         record = self._get_record(resource_requestctx, allow_draft=False)
         # TODO: handle permissions UI way - better response than generic error
-        serialized_record = self.config.ui_serializer.dump_obj(record.to_dict())
+        ui_data = self.config.ui_serializer.dump_obj(record.to_dict())
         # make links absolute
-        if "links" in serialized_record:
-            for k, v in list(serialized_record["links"].items()):
+        if "links" in ui_data:
+            for k, v in list(ui_data["links"].items()):
                 if not isinstance(v, str):
                     continue
                 if not v.startswith("/") and not v.startswith("https://"):
                     v = f"/api{self.api_service.config.url_prefix}{v}"
-                    serialized_record["links"][k] = v
+                    ui_data["links"][k] = v
 
         export_path = request.path.split("?")[0]
         if not export_path.endswith("/"):
             export_path += "/"
         export_path += "export"
 
-        layout = current_oarepo_ui.get_layout(self.get_layout_name())
         _catalog = current_oarepo_ui.catalog
 
-        template_def = self.get_template_def("detail")
+        template_def =
         fields = ["metadata", "ui", "layout", "record", "extra_context"]
         source = get_jinja_template(_catalog, template_def, fields)
 
         extra_context = dict()
         ui_links = self.expand_detail_links(identity=g.identity, record=record)
 
-        serialized_record["extra_links"] = {
+        ui_data["extra_links"] = {
             "ui_links": ui_links,
             "export_path": export_path,
             "search_link": self.config.url_prefix,
@@ -159,27 +162,22 @@ def detail(self):
 
         self.run_components(
             "before_ui_detail",
-            resource=self,
-            record=serialized_record,
+            record=record,
+            ui_data=ui_data,
             identity=g.identity,
             extra_context=extra_context,
             args=resource_requestctx.args,
             view_args=resource_requestctx.view_args,
             ui_links=ui_links,
-            ui_config=self.config,
-            ui_resource=self,
-            layout=layout,
-            component_key="search",
         )
-
-        metadata = dict(serialized_record.get("metadata", serialized_record))
+        metadata = dict(ui_data.get("metadata", ui_data))
         return _catalog.render(
             "detail",
+            # TODO: Alzbeta: why is this here? It does not seem to be used anywhere inside the library
             __source=source,
             metadata=metadata,
-            ui=dict(serialized_record.get("ui", serialized_record)),
-            layout=dict(layout),
-            record=serialized_record,
+            ui=dict(ui_data.get("ui", ui_data)),
+            record=ui_data,
             extra_context=extra_context,
             ui_links=ui_links,
         )
@@ -217,8 +215,6 @@ def search(self):
         ]
         source = get_jinja_template(_catalog, template_def, fields)
 
-        layout = current_oarepo_ui.get_layout(self.get_layout_name())
-
         page = resource_requestctx.args.get("page", 1)
         size = resource_requestctx.args.get("size", 10)
         pagination = Pagination(
@@ -239,22 +235,15 @@ def search(self):
         )
 
         extra_context = dict()
-        links = self.expand_search_links(
-            g.identity, pagination, resource_requestctx.args
-        )
 
         self.run_components(
             "before_ui_search",
-            resource=self,
             identity=g.identity,
             search_options=search_options,
             args=resource_requestctx.args,
             view_args=resource_requestctx.view_args,
             ui_config=self.config,
-            ui_resource=self,
             ui_links=ui_links,
-            layout=layout,
-            component_key="search",
             extra_context=extra_context,
         )
 
@@ -268,7 +257,6 @@ def search(self):
             search_app_config=search_app_config,
             ui_config=self.config,
             ui_resource=self,
-            layout=layout,
             ui_links=ui_links,
             extra_context=extra_context,
         )
@@ -300,8 +288,6 @@ def export(self):
         }
         return (exported_record, 200, headers)
 
-    def get_layout_name(self):
-        return self.config.layout
 
     def get_template_def(self, template_type):
         return self.config.templates[template_type]
@@ -312,8 +298,7 @@ def get_template_def(self, template_type):
     def edit(self):
         record = self._get_record(resource_requestctx, allow_draft=True)
         data = record.to_dict()
-        serialized_record = self.config.ui_serializer.dump_obj(record.to_dict())
-        layout = current_oarepo_ui.get_layout(self.get_layout_name())
+        ui_record = self.config.ui_serializer.dump_obj(record.to_dict())
         form_config = self.config.form_config(
             identity=g.identity, updateUrl=record.links.get("self", None)
         )
@@ -324,22 +309,19 @@ def edit(self):
 
         self.run_components(
             "form_config",
-            layout=layout,
-            resource=self,
             record=record,
-            data=record,
+            data=data,
+            identity=g.identity,
             form_config=form_config,
             args=resource_requestctx.args,
             view_args=resource_requestctx.view_args,
-            identity=g.identity,
             ui_links=ui_links,
             extra_context=extra_context,
         )
         self.run_components(
             "before_ui_edit",
-            layout=layout,
             resource=self,
-            record=serialized_record,
+            record=ui_record,
             data=data,
             form_config=form_config,
             args=resource_requestctx.args,
@@ -348,12 +330,14 @@ def edit(self):
             identity=g.identity,
             extra_context=extra_context,
         )
+
         template_def = self.get_template_def("edit")
+
         _catalog = current_oarepo_ui.catalog
         source = get_jinja_template(
             _catalog, template_def, ["record", "extra_context", "form_config", "data"]
         )
-        serialized_record["extra_links"] = {
+        ui_record["extra_links"] = {
             "ui_links": ui_links,
             "search_link": self.config.url_prefix,
         }
@@ -361,7 +345,7 @@ def edit(self):
         return _catalog.render(
             "edit",
             __source=source,
-            record=serialized_record,
+            record=ui_record,
             form_config=form_config,
             extra_context=extra_context,
             ui_links=ui_links,
@@ -373,7 +357,6 @@ def edit(self):
     @request_view_args
     def create(self):
         empty_record = self.empty_record(resource_requestctx)
-        layout = current_oarepo_ui.get_layout(self.get_layout_name())
         form_config = self.config.form_config(
             identity=g.identity,
             # TODO: use api service create link when available
@@ -383,9 +366,7 @@ def create(self):
 
         self.run_components(
             "form_config",
-            layout=layout,
-            resource=self,
-            record=empty_record,
+            record=None,
             data=empty_record,
             form_config=form_config,
             args=resource_requestctx.args,
@@ -395,9 +376,6 @@ def create(self):
         )
         self.run_components(
             "before_ui_create",
-            layout=layout,
-            resource=self,
-            record=empty_record,
             data=empty_record,
             form_config=form_config,
             args=resource_requestctx.args,

From a9978128dd622ace1c530fc4318351ae5f04261f Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Fri, 15 Dec 2023 18:20:06 +0100
Subject: [PATCH 2/9] typo

---
 oarepo_ui/resources/resource.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index 50f38f65..e274f0f5 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -147,7 +147,7 @@ def detail(self):
 
         _catalog = current_oarepo_ui.catalog
 
-        template_def =
+        template_def = self.get_template_def("detail")
         fields = ["metadata", "ui", "layout", "record", "extra_context"]
         source = get_jinja_template(_catalog, template_def, fields)
 

From c1c9733a309f31abe8b103370ef26e1f39cff6d4 Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Fri, 15 Dec 2023 19:22:36 +0100
Subject: [PATCH 3/9] Template optimizations (layout + blocks removed as they
 can be done in client code if needed)

---
 oarepo_ui/ext.py                 |  5 +-
 oarepo_ui/resources/catalog.py   | 24 ---------
 oarepo_ui/resources/config.py    |  3 ++
 oarepo_ui/resources/resource.py  | 90 +++++++++++---------------------
 tests/model.py                   |  4 +-
 tests/templates/TestDetail.jinja |  3 +-
 6 files changed, 41 insertions(+), 88 deletions(-)

diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py
index 8e8d895b..5387b899 100644
--- a/oarepo_ui/ext.py
+++ b/oarepo_ui/ext.py
@@ -101,7 +101,10 @@ def _catalog_config(self, catalog, env):
         env.policies.setdefault("json.dumps_kwargs", {}).setdefault("default", str)
         self.app.update_template_context(context)
         catalog.jinja_env.loader = env.loader
-        catalog.jinja_env.autoescape = env.autoescape
+
+        # autoescape everything (this catalogue is used just for html jinjax components, so can do that) ...
+        catalog.jinja_env.autoescape = True
+
         context.update(catalog.jinja_env.globals)
         context.update(env.globals)
         catalog.jinja_env.globals = context
diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py
index 4560e7a3..d58bb4d4 100644
--- a/oarepo_ui/resources/catalog.py
+++ b/oarepo_ui/resources/catalog.py
@@ -91,30 +91,6 @@ def _get_from_file(
         return component
 
 
-def get_jinja_template(_catalog, template_def, fields=None):
-    if fields is None:
-        fields = []
-    jinja_content = None
-    for component in _catalog.jinja_env.loader.searchpath:
-        if component["component_file"].endswith(template_def["layout"]):
-            with open(component["component_file"], "r") as file:
-                jinja_content = file.read()
-    if not jinja_content:
-        raise Exception("%s was not found" % (template_def["layout"]))
-    assembled_template = [jinja_content]
-    if "blocks" in template_def:
-        for blk_name, blk in template_def["blocks"].items():
-            component_content = ""
-            for field in fields:
-                component_content = component_content + "%s={%s} " % (field, field)
-            component_str = "<%s %s> </%s>" % (blk, component_content, blk)
-            assembled_template.append(
-                "{%% block %s %%}%s{%% endblock %%}" % (blk_name, component_str)
-            )
-    assembled_template = "\n".join(assembled_template)
-    return assembled_template
-
-
 def lazy_string_encoder(obj):
     if isinstance(obj, list):
         return [lazy_string_encoder(item) for item in obj]
diff --git a/oarepo_ui/resources/config.py b/oarepo_ui/resources/config.py
index d20eec71..78223248 100644
--- a/oarepo_ui/resources/config.py
+++ b/oarepo_ui/resources/config.py
@@ -60,6 +60,9 @@ class RecordsUIResourceConfig(UIResourceConfig):
     api_service = None
     """Name of the API service as registered inside the service registry"""
 
+    search_app_id = None
+    """ID of the app used for rendering the search config"""
+
     templates = {
         "detail": None,
         "search": None,
diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index e274f0f5..0fe808c8 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -32,7 +32,6 @@
 # Resource
 #
 from ..proxies import current_oarepo_ui
-from .catalog import get_jinja_template
 from .config import RecordsUIResourceConfig, UIResourceConfig
 
 request_export_args = request_parser(
@@ -127,38 +126,30 @@ def fill_jinja_context(self):
     @request_view_args
     def detail(self):
         """Returns item detail page."""
-        """Returns item detail page."""
+
         record = self._get_record(resource_requestctx, allow_draft=False)
+
         # TODO: handle permissions UI way - better response than generic error
         ui_data = self.config.ui_serializer.dump_obj(record.to_dict())
-        # make links absolute
-        if "links" in ui_data:
-            for k, v in list(ui_data["links"].items()):
-                if not isinstance(v, str):
-                    continue
-                if not v.startswith("/") and not v.startswith("https://"):
-                    v = f"/api{self.api_service.config.url_prefix}{v}"
-                    ui_data["links"][k] = v
+        ui_data.setdefault("links", {})
 
+        ui_links = self.expand_detail_links(identity=g.identity, record=record)
         export_path = request.path.split("?")[0]
         if not export_path.endswith("/"):
             export_path += "/"
         export_path += "export"
 
-        _catalog = current_oarepo_ui.catalog
+        ui_data["links"].update(
+            {
+                "ui_links": ui_links,
+                "export_path": export_path,
+                "search_link": self.config.url_prefix,
+            }
+        )
 
-        template_def = self.get_template_def("detail")
-        fields = ["metadata", "ui", "layout", "record", "extra_context"]
-        source = get_jinja_template(_catalog, template_def, fields)
+        self.make_links_absolute(ui_data["links"], self.api_service.config.url_prefix)
 
         extra_context = dict()
-        ui_links = self.expand_detail_links(identity=g.identity, record=record)
-
-        ui_data["extra_links"] = {
-            "ui_links": ui_links,
-            "export_path": export_path,
-            "search_link": self.config.url_prefix,
-        }
 
         self.run_components(
             "before_ui_detail",
@@ -171,10 +162,8 @@ def detail(self):
             ui_links=ui_links,
         )
         metadata = dict(ui_data.get("metadata", ui_data))
-        return _catalog.render(
-            "detail",
-            # TODO: Alzbeta: why is this here? It does not seem to be used anywhere inside the library
-            __source=source,
+        return current_oarepo_ui.catalog.render(
+            self.get_template_def("detail"),
             metadata=metadata,
             ui=dict(ui_data.get("ui", ui_data)),
             record=ui_data,
@@ -182,6 +171,15 @@ def detail(self):
             ui_links=ui_links,
         )
 
+    def make_links_absolute(self, links, api_prefix):
+        # make links absolute
+        for k, v in list(links.items()):
+            if not isinstance(v, str):
+                continue
+            if not v.startswith("/") and not v.startswith("https://"):
+                v = f"/api{api_prefix}{v}"
+                links[k] = v
+
     def _get_record(self, resource_requestctx, allow_draft=False):
         if allow_draft:
             read_method = (
@@ -202,19 +200,6 @@ def search_without_slash(self):
 
     @request_search_args
     def search(self):
-        _catalog = current_oarepo_ui.catalog
-
-        template_def = self.get_template_def("search")
-        app_id = template_def["app_id"]
-        fields = [
-            "search_app_config",
-            "ui_layout",
-            "layout",
-            "ui_links",
-            "extra_content",
-        ]
-        source = get_jinja_template(_catalog, template_def, fields)
-
         page = resource_requestctx.args.get("page", 1)
         size = resource_requestctx.args.get("size", 10)
         pagination = Pagination(
@@ -249,11 +234,10 @@ def search(self):
 
         search_config = partial(self.config.search_app_config, **search_options)
 
-        search_app_config = search_config(app_id=app_id)
+        search_app_config = search_config(app_id=self.config.search_app_id)
 
-        return _catalog.render(
-            "search",
-            __source=source,
+        return current_oarepo_ui.catalog.render(
+            self.get_template_def("search"),
             search_app_config=search_app_config,
             ui_config=self.config,
             ui_resource=self,
@@ -331,20 +315,13 @@ def edit(self):
             extra_context=extra_context,
         )
 
-        template_def = self.get_template_def("edit")
-
-        _catalog = current_oarepo_ui.catalog
-        source = get_jinja_template(
-            _catalog, template_def, ["record", "extra_context", "form_config", "data"]
-        )
         ui_record["extra_links"] = {
             "ui_links": ui_links,
             "search_link": self.config.url_prefix,
         }
 
-        return _catalog.render(
-            "edit",
-            __source=source,
+        return current_oarepo_ui.catalog.render(
+            self.get_template_def("edit"),
             record=ui_record,
             form_config=form_config,
             extra_context=extra_context,
@@ -383,16 +360,9 @@ def create(self):
             identity=g.identity,
             extra_context=extra_context,
         )
-        template_def = self.get_template_def("create")
-        _catalog = current_oarepo_ui.catalog
-
-        source = get_jinja_template(
-            _catalog, template_def, ["record", "extra_context", "form_config", "data"]
-        )
 
-        return _catalog.render(
-            "create",
-            __source=source,
+        return current_oarepo_ui.catalog.render(
+            self.get_template_def("create"),
             record=empty_record,
             form_config=form_config,
             extra_context=extra_context,
diff --git a/tests/model.py b/tests/model.py
index dd6dcd9d..5de3f400 100644
--- a/tests/model.py
+++ b/tests/model.py
@@ -87,8 +87,8 @@ class ModelUIResourceConfig(RecordsUIResourceConfig):
     ui_serializer_class = ModelUISerializer
     templates = {
         **RecordsUIResourceConfig.templates,
-        "detail": {"layout": "TestDetail.jinja", "blocks": {}},
-        "search": {"layout": "TestSearch.jinja", "app_id": "SimpleModel.Search"},
+        "detail": "TestDetail",
+        "search": "TestSearch",
     }
 
     components = [BabelComponent, PermissionsComponent]
diff --git a/tests/templates/TestDetail.jinja b/tests/templates/TestDetail.jinja
index 92f76875..e5dc3c36 100644
--- a/tests/templates/TestDetail.jinja
+++ b/tests/templates/TestDetail.jinja
@@ -1,5 +1,6 @@
 {#def record, extra_context #}
-{%- for link_name, link in record.extra_links.ui_links.items() -%}
+
+{%- for link_name, link in record.links.ui_links.items() -%}
   {{link_name}}:{{link}}
 {% endfor-%}
 permissions={{ extra_context.permissions }}

From 54f2e296dededeed4dcf1e11654a4140032826cc Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Fri, 15 Dec 2023 20:18:42 +0100
Subject: [PATCH 4/9] Caching catalogue paths, extra test for priorities on
 templates

---
 oarepo_ui/resources/catalog.py       | 116 ++++++++++++++++++---------
 setup.cfg                            |   2 +-
 tests/templates/100-TestDetail.jinja |   7 ++
 tests/templates/TestDetail.jinja     |   6 +-
 tests/templates/test_detail.html     |   5 --
 5 files changed, 86 insertions(+), 50 deletions(-)
 create mode 100644 tests/templates/100-TestDetail.jinja
 delete mode 100644 tests/templates/test_detail.html

diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py
index d58bb4d4..e868f555 100644
--- a/oarepo_ui/resources/catalog.py
+++ b/oarepo_ui/resources/catalog.py
@@ -1,6 +1,8 @@
 import os
 import re
+from functools import cached_property
 from pathlib import Path
+from typing import Dict, Tuple
 
 import jinja2
 from jinjax import Catalog
@@ -29,53 +31,89 @@ def get_source(self, cname: str, file_ext: "TFileExt" = "") -> str:
         _root_path, path = self._get_component_path(prefix, name, file_ext=file_ext)
         return Path(path).read_text()
 
+    @cached_property
+    def component_paths(self) -> Dict[str, Tuple[Path, Path]]:
+        """
+        Returns a cache of component-name => (root_path, component_path).
+        To invalidate the cache, call `del self.component_paths`.
+        """
+        paths: Dict[str, Tuple[Path, Path, int]] = {}
+
+        # paths known by the current jinja environment
+        search_paths = {
+            Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath
+        }
+
+        # iterate all the files inside prefixes
+        for prefix in self.prefixes:
+            root_paths = self.prefixes[prefix].searchpath
+
+            for root_path_rec in root_paths:
+                component_path = root_path_rec["component_path"]
+                root_path = Path(root_path_rec["root_path"])
+
+                for curr_folder, _folders, files in os.walk(
+                    component_path, topdown=False, followlinks=True
+                ):
+                    # the file might be in a subfolder of the component_path, so get the subfolder name
+                    relfolder = os.path.relpath(curr_folder, component_path).strip(".")
+
+                    for filename in files:
+
+                        # if the file is known to the current jinja environment,
+                        # get the priority and add it to known components
+                        absolute_filepath = Path(curr_folder) / filename
+                        if absolute_filepath in search_paths:
+
+                            # check if there is a priority on the file, if not, take default 0
+                            prefix_pattern = re.compile(r"^\d{3}-")
+                            priority = 0
+                            if prefix_pattern.match(filename):
+                                # Remove the priority from the filename
+                                priority = int(filename[:3])
+                                filename = filename[4:]
+
+                            if relfolder:
+                                relative_filepath = f"{relfolder}/{filename}"
+                            else:
+                                relative_filepath = filename
+
+                            # if the priority is greater, replace the path
+                            if relative_filepath not in paths or priority > paths[relative_filepath][2]:
+                                paths[relative_filepath] = (
+                                    root_path,
+                                    absolute_filepath,
+                                    priority)
+        return {
+            k: (v[0], v[1]) for k, v in paths.items()
+        }
+
     def _get_component_path(
         self, prefix: str, name: str, file_ext: "TFileExt" = ""
     ) -> "tuple[Path, Path]":
-        name = name.replace(DELIMITER, SLASH)
-        name_dot = f"{name}."
+
         file_ext = file_ext or self.file_ext
-        root_paths = self.prefixes[prefix].searchpath
-
-        for root_path in root_paths:
-            component_path = root_path["component_path"]
-            for curr_folder, _folders, files in os.walk(
-                component_path, topdown=False, followlinks=True
-            ):
-                relfolder = os.path.relpath(curr_folder, component_path).strip(".")
-                if relfolder and not name_dot.startswith(relfolder):
-                    continue
-
-                for filename in files:
-                    _filepath = curr_folder + "/" + filename
-                    in_searchpath = False
-                    for searchpath in self.jinja_env.loader.searchpath:
-                        if _filepath == searchpath["component_file"]:
-                            in_searchpath = True
-                            break
-                    if in_searchpath:
-                        prefix_pattern = re.compile(r"^\d{3}-")
-                        without_prefix_filename = filename
-                        if prefix_pattern.match(filename):
-                            # Remove the prefix
-                            without_prefix_filename = prefix_pattern.sub("", filename)
-                        if relfolder:
-                            filepath = f"{relfolder}/{without_prefix_filename}"
-                        else:
-                            filepath = without_prefix_filename
-                        if filepath.startswith(name_dot) and filepath.endswith(
-                            file_ext
-                        ):
-                            return (
-                                Path(root_path["root_path"]),
-                                Path(curr_folder) / filename,
-                            )
+        if not file_ext.startswith("."):
+            file_ext = "." + file_ext
+        name = name.replace(SLASH, DELIMITER) + file_ext
+
+        paths = self.component_paths
+        if name in paths:
+            return paths[name]
+
+        if self.jinja_env.auto_reload:
+            # clear cache
+            del self.component_paths
+
+            paths = self.component_paths
+            if name in paths:
+                return paths[name]
 
         raise ComponentNotFound(
-            f"Unable to find a file named {name}{file_ext} "
-            f"or one following the pattern {name_dot}*{file_ext}"
+            f"Unable to find a file named {name}"
         )
 
+
     def _get_from_file(
         self, *, prefix: str, name: str, url_prefix: str, file_ext: str
     ) -> "Component":
diff --git a/setup.cfg b/setup.cfg
index ead2e8f5..4b71ff4f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = oarepo-ui
-version = 5.0.89
+version = 5.0.90
 description = UI module for invenio 3.5+
 long_description = file: README.md
 long_description_content_type = text/markdown
diff --git a/tests/templates/100-TestDetail.jinja b/tests/templates/100-TestDetail.jinja
new file mode 100644
index 00000000..e5dc3c36
--- /dev/null
+++ b/tests/templates/100-TestDetail.jinja
@@ -0,0 +1,7 @@
+{#def record, extra_context #}
+
+{%- for link_name, link in record.links.ui_links.items() -%}
+  {{link_name}}:{{link}}
+{% endfor-%}
+permissions={{ extra_context.permissions }}
+{{ 1|dummy }}
\ No newline at end of file
diff --git a/tests/templates/TestDetail.jinja b/tests/templates/TestDetail.jinja
index e5dc3c36..ba70a309 100644
--- a/tests/templates/TestDetail.jinja
+++ b/tests/templates/TestDetail.jinja
@@ -1,7 +1,3 @@
 {#def record, extra_context #}
 
-{%- for link_name, link in record.links.ui_links.items() -%}
-  {{link_name}}:{{link}}
-{% endfor-%}
-permissions={{ extra_context.permissions }}
-{{ 1|dummy }}
\ No newline at end of file
+This component should never be used as it is overriden by 100-TestDetail.jinja
diff --git a/tests/templates/test_detail.html b/tests/templates/test_detail.html
deleted file mode 100644
index 7ce00d81..00000000
--- a/tests/templates/test_detail.html
+++ /dev/null
@@ -1,5 +0,0 @@
-{%- for link_name, link in ui_links.items() -%}
-  {{link_name}}:{{link}}
-{% endfor-%}
-permissions={{ permissions }}
-{{ 1|dummy }}
\ No newline at end of file

From e720601701384cb2092d892dfd9cb6039ef92fcd Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Sat, 16 Dec 2023 11:17:02 +0100
Subject: [PATCH 5/9] Unified edit template params

---
 oarepo_ui/resources/components.py | 46 ++++++++++++++++---------------
 oarepo_ui/resources/resource.py   | 23 ++++++++++------
 tests/test_ui_resource_config.py  |  1 +
 3 files changed, 40 insertions(+), 30 deletions(-)

diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py
index c679c352..08f091d3 100644
--- a/oarepo_ui/resources/components.py
+++ b/oarepo_ui/resources/components.py
@@ -22,9 +22,9 @@ class UIResourceComponent:
     the resource configuration.
 
     Naming convention for parameters:
-        * record - the record being displayed, always is an instance of RecordItem
+        * api_record - the record being displayed, always is an instance of RecordItem
+        * record - UI serialization of the record as comes from the ui serializer. A dictionary
         * data - data serialized by the API service serializer. A dictionary
-        * ui_data - UI serialization of the record as comes from the ui serializer. A dictionary
         * empty_data - empty record data, compatible with the API service serializer. A dictionary
     """
 
@@ -57,8 +57,8 @@ def fill_jinja_context(self, *, context: Dict, **kwargs):
     def before_ui_detail(
         self,
         *,
-            record: RecordItem,
-            ui_data: Dict,
+            api_record: RecordItem,
+            record: Dict,
             identity: Identity,
             args: Dict,
             view_args: Dict,
@@ -69,8 +69,8 @@ def before_ui_detail(
         """
         Called before the detail page is rendered.
 
-        :param record: the record being displayed
-        :param ui_data: UI serialization of the record
+        :param api_record: the record being displayed
+        :param record: UI serialization of the record
         :param identity: the current user identity
         :param args: query parameters
         :param view_args: view arguments
@@ -100,7 +100,8 @@ def before_ui_search(self, *,
         """
 
     def form_config(self, *,
-    record: RecordItem,
+    api_record: RecordItem,
+    record: Dict,
     data: Dict,
     identity: Identity,
     form_config: Dict,
@@ -112,7 +113,8 @@ def form_config(self, *,
         """
         Called to fill form_config for the create/edit page.
 
-        :param record: the record being edited. Can be None if creating a new record.
+        :param api_record: the record being edited. Can be None if creating a new record.
+        :param record: UI serialization of the record
         :param data: data serialized by the API service serializer. If a record is being edited,
                      this is the serialized record data. If a new record is being created, this is empty_data
                      after being processed by the empty_record method on registered UI components.
@@ -125,9 +127,9 @@ def form_config(self, *,
         """
 
     def before_ui_edit(self, *,
-                       record: RecordItem,
+                       api_record: RecordItem,
+                       record: Dict,
                        data: Dict,
-                       ui_data: Dict,
                        identity: Identity,
                        form_config: Dict,
                        args: Dict,
@@ -138,9 +140,9 @@ def before_ui_edit(self, *,
         """
         Called before the edit page is rendered, after form_config has been filled.
 
-        :param record: the record being edited
+        :param api_record: the API record being edited
         :param data: data serialized by the API service serializer. This is the serialized record data.
-        :param ui_data: UI serialization of the record (localized). The ui data can be used in the edit
+        :param record: UI serialization of the record (localized). The ui data can be used in the edit
                         template to display, for example, the localized record title.
         :param identity: the current user identity
         :param form_config: form configuration dictionary
@@ -192,11 +194,11 @@ def form_config(
 
 
 class PermissionsComponent(UIResourceComponent):
-    def before_ui_detail(self, *, record, extra_context, identity, **kwargs):
-        self.fill_permissions(record.data, extra_context, identity)
+    def before_ui_detail(self, *, api_record, extra_context, identity, **kwargs):
+        self.fill_permissions(api_record.data, extra_context, identity)
 
-    def before_ui_edit(self, *, record, extra_context, identity, **kwargs):
-        self.fill_permissions(record.data, extra_context, identity)
+    def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs):
+        self.fill_permissions(api_record.data, extra_context, identity)
 
     def before_ui_create(self, *, extra_context, identity, **kwargs):
         self.fill_permissions(None, extra_context, identity)
@@ -215,14 +217,14 @@ def before_ui_search(
         search_options["permissions"] = extra_context["permissions"]
 
     def form_config(
-        self, *, form_config, record, identity, **kwargs
+        self, *, form_config, api_record, identity, **kwargs
     ):
-        self.fill_permissions(record, form_config, identity)
+        self.fill_permissions(api_record.data if api_record else None, form_config, identity)
 
     def get_record_permissions(self, actions, service, identity, data):
         """Helper for generating (default) record action permissions."""
         return {
-            f"can_{action}": service.check_permission(identity, action, record=data)
+            f"can_{action}": service.check_permission(identity, action, record=data or {})
             for action in actions
         }
 
@@ -240,15 +242,15 @@ def fill_permissions(self, data, extra_context, identity):
 
 
 class FilesComponent(UIResourceComponent):
-    def before_ui_edit(self, *, record, extra_context, identity, **kwargs):
+    def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs):
         from .resource import RecordsUIResource
         if not isinstance(self.resource, RecordsUIResource):
             return
 
         file_service = get_file_service_for_record_service(
-            self.resource.api_service, record=record
+            self.resource.api_service, record=api_record
         )
-        files = file_service.list_files(identity, record["id"])
+        files = file_service.list_files(identity, api_record["id"])
         extra_context["files"] = files.to_dict()
 
     def before_ui_detail(self, **kwargs):
diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index 0fe808c8..95cec12c 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -153,8 +153,8 @@ def detail(self):
 
         self.run_components(
             "before_ui_detail",
-            record=record,
-            ui_data=ui_data,
+            api_record=record,
+            record=ui_data,
             identity=g.identity,
             extra_context=extra_context,
             args=resource_requestctx.args,
@@ -167,6 +167,7 @@ def detail(self):
             metadata=metadata,
             ui=dict(ui_data.get("ui", ui_data)),
             record=ui_data,
+            api_record=record,
             extra_context=extra_context,
             ui_links=ui_links,
         )
@@ -282,7 +283,7 @@ def get_template_def(self, template_type):
     def edit(self):
         record = self._get_record(resource_requestctx, allow_draft=True)
         data = record.to_dict()
-        ui_record = self.config.ui_serializer.dump_obj(record.to_dict())
+        ui_data = self.config.ui_serializer.dump_obj(record.to_dict())
         form_config = self.config.form_config(
             identity=g.identity, updateUrl=record.links.get("self", None)
         )
@@ -293,8 +294,9 @@ def edit(self):
 
         self.run_components(
             "form_config",
-            record=record,
+            api_record=record,
             data=data,
+            ui_data=ui_data,
             identity=g.identity,
             form_config=form_config,
             args=resource_requestctx.args,
@@ -304,8 +306,8 @@ def edit(self):
         )
         self.run_components(
             "before_ui_edit",
-            resource=self,
-            record=ui_record,
+            api_record=record,
+            ui_data=ui_data,
             data=data,
             form_config=form_config,
             args=resource_requestctx.args,
@@ -315,14 +317,15 @@ def edit(self):
             extra_context=extra_context,
         )
 
-        ui_record["extra_links"] = {
+        ui_data["extra_links"] = {
             "ui_links": ui_links,
             "search_link": self.config.url_prefix,
         }
 
         return current_oarepo_ui.catalog.render(
             self.get_template_def("edit"),
-            record=ui_record,
+            record=ui_data,
+            api_record=record,
             form_config=form_config,
             extra_context=extra_context,
             ui_links=ui_links,
@@ -343,6 +346,7 @@ def create(self):
 
         self.run_components(
             "form_config",
+            api_record=None,
             record=None,
             data=empty_record,
             form_config=form_config,
@@ -354,6 +358,8 @@ def create(self):
         self.run_components(
             "before_ui_create",
             data=empty_record,
+            record=None,
+            api_record=None,
             form_config=form_config,
             args=resource_requestctx.args,
             view_args=resource_requestctx.view_args,
@@ -364,6 +370,7 @@ def create(self):
         return current_oarepo_ui.catalog.render(
             self.get_template_def("create"),
             record=empty_record,
+            api_record=None,
             form_config=form_config,
             extra_context=extra_context,
             ui_links={},
diff --git a/tests/test_ui_resource_config.py b/tests/test_ui_resource_config.py
index 12bc907d..400237d3 100644
--- a/tests/test_ui_resource_config.py
+++ b/tests/test_ui_resource_config.py
@@ -12,6 +12,7 @@ def test_ui_resource_form_config(app, record_ui_resource):
         form_config=fc,
         layout="",
         resource=record_ui_resource,
+        api_record=None,
         record={},
         data={},
         args={},

From 6270f4c14511c3f40b278e3905b234468ad63365 Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Sat, 16 Dec 2023 11:43:32 +0100
Subject: [PATCH 6/9] documentation

---
 README.md | 34 ++++++++++++++++++++--------------
 1 file changed, 20 insertions(+), 14 deletions(-)

diff --git a/README.md b/README.md
index 7bc9546d..fa030c3c 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,7 @@ functions to render layouts from model configuration.
 
 See also [JinjaX documentation](https://jinjax.scaletti.dev/).
 
-Oarepo supports the use of components within Jinja templates using the JinjaX library.
+Oarepo builds its static UI pages on top of the JinjaX library.
 To load a Jinja application, a JinjaX component is expected on the input. 
 The relative path to the component is taken from the configuration
 
@@ -27,20 +27,14 @@ To work with parameters within components, you need to define them in the templa
 
 Example of component specification in config:
 
-```json
+```python
 templates = {
-        "detail": {
-            "layout": "docs_app/DetailRoot.html.jinja",
-            "blocks": {
-                "record_main_content": "Main",
-                "record_sidebar": "Sidebar",
-            },
-        },
-        "search": {"layout": "docs_app/search.html"},
+        "detail": "DetailPage",
+        "search": "SearchPage"
     }
 ```
 
-Example of possible contents of the DetailRoot component:
+Example of possible contents of the DetailPage component, contained inside `templates/DetailPage.jinja`
 
 ```json
 {#def metadata, ui, layout #}
@@ -53,18 +47,17 @@ Example of possible contents of the DetailRoot component:
 {{ webpack['docs_app_components.css']}}
 
 {%- endblock %}
-```
 
-Based on the definition from the config, the block content is then automatically added to the component content:
-```json
 {% block record_main_content %}
     <Main metadata={{metadata}}></Main>
 {% endblock %}
+
 {% block record_sidebar %}
     <Sidebar metadata={{metadata}}></Sidebar>
 {% endblock %}
 ```
 
+
 Sample of possible contents of Main component:
 ```json
 {#def metadata, ui, layout #}
@@ -74,9 +67,22 @@ Sample of possible contents of Main component:
 
 ```
 
+You can also namespace your ui components, by using dot notation:
+
+```python
+templates = {
+        "detail": "myrepo.DetailPage",
+        "search": "myrepo.SearchPage"
+    }
+```
+
+Then, the component will be loaded from the `templates/myrepo/DetailPage.jinja` file.
+
+
 #### JinjaX components
 
 Within the Oarepo-ui library, basic components are defined in the `templates` folder.
+
 ### React
 
 To render a custom layout in a React app (e. g. records search result page), this package provides the `useLayout` hook and an entrypoint

From 2bc66c25e210188b61ca45cdcb3ff1e38ab44926 Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Sat, 16 Dec 2023 12:00:00 +0100
Subject: [PATCH 7/9] refactored to simplify component_paths method

---
 oarepo_ui/resources/catalog.py    | 72 +++++++++++++++++--------------
 oarepo_ui/resources/components.py |  2 +-
 2 files changed, 40 insertions(+), 34 deletions(-)

diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py
index e868f555..bcdcf41d 100644
--- a/oarepo_ui/resources/catalog.py
+++ b/oarepo_ui/resources/catalog.py
@@ -44,7 +44,41 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]:
             Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath
         }
 
-        # iterate all the files inside prefixes
+        for root_path, namespace, template_path in self._get_all_template_files():
+
+            # if the file is known to the current jinja environment,
+            # get the priority and add it to known components
+            if template_path in search_paths:
+                template_filename = os.path.basename(template_path)
+                template_filename, priority = self._extract_priority(template_filename)
+
+                if namespace:
+                    relative_filepath = f"{namespace}/{template_filename}"
+                else:
+                    relative_filepath = template_filename
+
+                # if the priority is greater, replace the path
+                if relative_filepath not in paths or priority > paths[relative_filepath][2]:
+                    paths[relative_filepath] = (
+                        root_path,
+                        template_path,
+                        priority)
+
+        return {
+            k: (v[0], v[1]) for k, v in paths.items()
+        }
+
+    def _extract_priority(self, filename):
+        # check if there is a priority on the file, if not, take default 0
+        prefix_pattern = re.compile(r"^\d{3}-")
+        priority = 0
+        if prefix_pattern.match(filename):
+            # Remove the priority from the filename
+            priority = int(filename[:3])
+            filename = filename[4:]
+        return filename, priority
+
+    def _get_all_template_files(self):
         for prefix in self.prefixes:
             root_paths = self.prefixes[prefix].searchpath
 
@@ -52,41 +86,13 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]:
                 component_path = root_path_rec["component_path"]
                 root_path = Path(root_path_rec["root_path"])
 
-                for curr_folder, _folders, files in os.walk(
-                    component_path, topdown=False, followlinks=True
+                for file_absolute_folder, _folders, files in os.walk(
+                        component_path, topdown=False, followlinks=True
                 ):
-                    # the file might be in a subfolder of the component_path, so get the subfolder name
-                    relfolder = os.path.relpath(curr_folder, component_path).strip(".")
-
+                    namespace = os.path.relpath(file_absolute_folder, component_path).strip(".")
                     for filename in files:
+                        yield root_path, namespace, Path(file_absolute_folder) / filename
 
-                        # if the file is known to the current jinja environment,
-                        # get the priority and add it to known components
-                        absolute_filepath = Path(curr_folder) / filename
-                        if absolute_filepath in search_paths:
-
-                            # check if there is a priority on the file, if not, take default 0
-                            prefix_pattern = re.compile(r"^\d{3}-")
-                            priority = 0
-                            if prefix_pattern.match(filename):
-                                # Remove the priority from the filename
-                                priority = int(filename[:3])
-                                filename = filename[4:]
-
-                            if relfolder:
-                                relative_filepath = f"{relfolder}/{filename}"
-                            else:
-                                relative_filepath = filename
-
-                            # if the priority is greater, replace the path
-                            if relative_filepath not in paths or priority > paths[relative_filepath][2]:
-                                paths[relative_filepath] = (
-                                    root_path,
-                                    absolute_filepath,
-                                    priority)
-        return {
-            k: (v[0], v[1]) for k, v in paths.items()
-        }
 
     def _get_component_path(
         self, prefix: str, name: str, file_ext: "TFileExt" = ""
diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py
index 08f091d3..9f84cf82 100644
--- a/oarepo_ui/resources/components.py
+++ b/oarepo_ui/resources/components.py
@@ -211,7 +211,7 @@ def before_ui_search(
             return
 
         extra_context["permissions"] = {
-            f"can_create": self.resource.api_service.check_permission(identity, "create")
+            "can_create": self.resource.api_service.check_permission(identity, "create")
         }
 
         search_options["permissions"] = extra_context["permissions"]

From b9285ca7492f069a674ce29cc71a581e9800e094 Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Sat, 16 Dec 2023 12:03:58 +0100
Subject: [PATCH 8/9] Formatted sources

---
 oarepo_ui/ext.py                  |   1 +
 oarepo_ui/resources/catalog.py    |  37 ++++-----
 oarepo_ui/resources/components.py | 133 +++++++++++++++++-------------
 oarepo_ui/resources/resource.py   |   7 +-
 oarepo_ui/theme/webpack.py        |   1 -
 tests/test_ui_resource.py         |   2 +-
 6 files changed, 97 insertions(+), 84 deletions(-)

diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py
index 5387b899..7e2c400f 100644
--- a/oarepo_ui/ext.py
+++ b/oarepo_ui/ext.py
@@ -151,6 +151,7 @@ def development_after_request(self, response: Response):
     def record_actions(self):
         return self.app.config["OAREPO_UI_RECORD_ACTIONS"]
 
+
 class OARepoUIExtension:
     def __init__(self, app=None):
         if app:
diff --git a/oarepo_ui/resources/catalog.py b/oarepo_ui/resources/catalog.py
index bcdcf41d..58b31687 100644
--- a/oarepo_ui/resources/catalog.py
+++ b/oarepo_ui/resources/catalog.py
@@ -41,11 +41,11 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]:
 
         # paths known by the current jinja environment
         search_paths = {
-            Path(searchpath["component_file"]) for searchpath in self.jinja_env.loader.searchpath
+            Path(searchpath["component_file"])
+            for searchpath in self.jinja_env.loader.searchpath
         }
 
         for root_path, namespace, template_path in self._get_all_template_files():
-
             # if the file is known to the current jinja environment,
             # get the priority and add it to known components
             if template_path in search_paths:
@@ -58,15 +58,13 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]:
                     relative_filepath = template_filename
 
                 # if the priority is greater, replace the path
-                if relative_filepath not in paths or priority > paths[relative_filepath][2]:
-                    paths[relative_filepath] = (
-                        root_path,
-                        template_path,
-                        priority)
-
-        return {
-            k: (v[0], v[1]) for k, v in paths.items()
-        }
+                if (
+                    relative_filepath not in paths
+                    or priority > paths[relative_filepath][2]
+                ):
+                    paths[relative_filepath] = (root_path, template_path, priority)
+
+        return {k: (v[0], v[1]) for k, v in paths.items()}
 
     def _extract_priority(self, filename):
         # check if there is a priority on the file, if not, take default 0
@@ -87,17 +85,19 @@ def _get_all_template_files(self):
                 root_path = Path(root_path_rec["root_path"])
 
                 for file_absolute_folder, _folders, files in os.walk(
-                        component_path, topdown=False, followlinks=True
+                    component_path, topdown=False, followlinks=True
                 ):
-                    namespace = os.path.relpath(file_absolute_folder, component_path).strip(".")
+                    namespace = os.path.relpath(
+                        file_absolute_folder, component_path
+                    ).strip(".")
                     for filename in files:
-                        yield root_path, namespace, Path(file_absolute_folder) / filename
-
+                        yield root_path, namespace, Path(
+                            file_absolute_folder
+                        ) / filename
 
     def _get_component_path(
         self, prefix: str, name: str, file_ext: "TFileExt" = ""
     ) -> "tuple[Path, Path]":
-
         file_ext = file_ext or self.file_ext
         if not file_ext.startswith("."):
             file_ext = "." + file_ext
@@ -115,10 +115,7 @@ def _get_component_path(
             if name in paths:
                 return paths[name]
 
-        raise ComponentNotFound(
-            f"Unable to find a file named {name}"
-        )
-
+        raise ComponentNotFound(f"Unable to find a file named {name}")
 
     def _get_from_file(
         self, *, prefix: str, name: str, url_prefix: str, file_ext: str
diff --git a/oarepo_ui/resources/components.py b/oarepo_ui/resources/components.py
index 9f84cf82..c848cded 100644
--- a/oarepo_ui/resources/components.py
+++ b/oarepo_ui/resources/components.py
@@ -1,9 +1,10 @@
+from typing import TYPE_CHECKING, Dict
+
 from flask import current_app
 from flask_principal import Identity
 from invenio_i18n.ext import current_i18n
 from invenio_records_resources.services.records.results import RecordItem
 from oarepo_runtime.datastreams.utils import get_file_service_for_record_service
-from typing import Dict, TYPE_CHECKING
 
 from ..proxies import current_oarepo_ui
 
@@ -57,14 +58,14 @@ def fill_jinja_context(self, *, context: Dict, **kwargs):
     def before_ui_detail(
         self,
         *,
-            api_record: RecordItem,
-            record: Dict,
-            identity: Identity,
-            args: Dict,
-            view_args: Dict,
-            ui_links: Dict,
-            extra_context: Dict,
-            **kwargs,
+        api_record: RecordItem,
+        record: Dict,
+        identity: Identity,
+        args: Dict,
+        view_args: Dict,
+        ui_links: Dict,
+        extra_context: Dict,
+        **kwargs,
     ):
         """
         Called before the detail page is rendered.
@@ -77,14 +78,18 @@ def before_ui_detail(
         :param ui_links: UI links for the record, a dictionary of link name -> link url
         :param extra_context: will be passed to the template as the "extra_context" variable
         """
-    def before_ui_search(self, *,
-                         identity: Identity,
-                         search_options: Dict,
-                         args: Dict,
-                         view_args: Dict,
-                         ui_links: Dict,
-                         extra_context: Dict,
-                         **kwargs):
+
+    def before_ui_search(
+        self,
+        *,
+        identity: Identity,
+        search_options: Dict,
+        args: Dict,
+        view_args: Dict,
+        ui_links: Dict,
+        extra_context: Dict,
+        **kwargs,
+    ):
         """
         Called before the search page is rendered.
         Note: search results are fetched via AJAX, so are not available in this method.
@@ -99,17 +104,20 @@ def before_ui_search(self, *,
         :param extra_context: will be passed to the template as the "extra_context" variable
         """
 
-    def form_config(self, *,
-    api_record: RecordItem,
-    record: Dict,
-    data: Dict,
-    identity: Identity,
-    form_config: Dict,
-    args: Dict,
-    view_args: Dict,
-    ui_links: Dict,
-    extra_context: Dict,
-                    **kwargs):
+    def form_config(
+        self,
+        *,
+        api_record: RecordItem,
+        record: Dict,
+        data: Dict,
+        identity: Identity,
+        form_config: Dict,
+        args: Dict,
+        view_args: Dict,
+        ui_links: Dict,
+        extra_context: Dict,
+        **kwargs,
+    ):
         """
         Called to fill form_config for the create/edit page.
 
@@ -126,17 +134,20 @@ def form_config(self, *,
         :param extra_context: will be passed to the template as the "extra_context" variable
         """
 
-    def before_ui_edit(self, *,
-                       api_record: RecordItem,
-                       record: Dict,
-                       data: Dict,
-                       identity: Identity,
-                       form_config: Dict,
-                       args: Dict,
-                       view_args: Dict,
-                       ui_links: Dict,
-                       extra_context: Dict,
-                       **kwargs):
+    def before_ui_edit(
+        self,
+        *,
+        api_record: RecordItem,
+        record: Dict,
+        data: Dict,
+        identity: Identity,
+        form_config: Dict,
+        args: Dict,
+        view_args: Dict,
+        ui_links: Dict,
+        extra_context: Dict,
+        **kwargs,
+    ):
         """
         Called before the edit page is rendered, after form_config has been filled.
 
@@ -152,15 +163,18 @@ def before_ui_edit(self, *,
         :param extra_context: will be passed to the template as the "extra_context" variable
         """
 
-    def before_ui_create(self, *,
-                         data: Dict,
-                         identity: Identity,
-                         form_config: Dict,
-                         args: Dict,
-                         view_args: Dict,
-                         ui_links: Dict,
-                         extra_context: Dict,
-                         **kwargs):
+    def before_ui_create(
+        self,
+        *,
+        data: Dict,
+        identity: Identity,
+        form_config: Dict,
+        args: Dict,
+        view_args: Dict,
+        ui_links: Dict,
+        extra_context: Dict,
+        **kwargs,
+    ):
         """
         Called before the create page is rendered, after form_config has been filled
 
@@ -175,9 +189,7 @@ def before_ui_create(self, *,
 
 
 class BabelComponent(UIResourceComponent):
-    def form_config(
-        self, *, form_config, **kwargs
-    ):
+    def form_config(self, *, form_config, **kwargs):
         conf = current_app.config
         locales = []
         for l in current_i18n.get_locales():
@@ -203,10 +215,9 @@ def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs):
     def before_ui_create(self, *, extra_context, identity, **kwargs):
         self.fill_permissions(None, extra_context, identity)
 
-    def before_ui_search(
-        self, *, extra_context, identity, search_options, **kwargs
-    ):
+    def before_ui_search(self, *, extra_context, identity, search_options, **kwargs):
         from .resource import RecordsUIResource
+
         if not isinstance(self.resource, RecordsUIResource):
             return
 
@@ -216,20 +227,23 @@ def before_ui_search(
 
         search_options["permissions"] = extra_context["permissions"]
 
-    def form_config(
-        self, *, form_config, api_record, identity, **kwargs
-    ):
-        self.fill_permissions(api_record.data if api_record else None, form_config, identity)
+    def form_config(self, *, form_config, api_record, identity, **kwargs):
+        self.fill_permissions(
+            api_record.data if api_record else None, form_config, identity
+        )
 
     def get_record_permissions(self, actions, service, identity, data):
         """Helper for generating (default) record action permissions."""
         return {
-            f"can_{action}": service.check_permission(identity, action, record=data or {})
+            f"can_{action}": service.check_permission(
+                identity, action, record=data or {}
+            )
             for action in actions
         }
 
     def fill_permissions(self, data, extra_context, identity):
         from .resource import RecordsUIResource
+
         if not isinstance(self.resource, RecordsUIResource):
             return
 
@@ -244,6 +258,7 @@ def fill_permissions(self, data, extra_context, identity):
 class FilesComponent(UIResourceComponent):
     def before_ui_edit(self, *, api_record, extra_context, identity, **kwargs):
         from .resource import RecordsUIResource
+
         if not isinstance(self.resource, RecordsUIResource):
             return
 
diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index 95cec12c..c96e2ef7 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -1,6 +1,6 @@
 import copy
 from functools import partial
-from typing import Iterator, TYPE_CHECKING
+from typing import TYPE_CHECKING, Iterator
 
 import deepmerge
 from flask import abort, g, redirect, request
@@ -107,7 +107,9 @@ def empty_record(self, resource_requestctx, **kwargs):
             empty_data, copy.deepcopy(self.config.empty_record)
         )
         self.run_components(
-            "empty_record", resource_requestctx=resource_requestctx, empty_data=empty_data
+            "empty_record",
+            resource_requestctx=resource_requestctx,
+            empty_data=empty_data,
         )
         return empty_data
 
@@ -273,7 +275,6 @@ def export(self):
         }
         return (exported_record, 200, headers)
 
-
     def get_template_def(self, template_type):
         return self.config.templates[template_type]
 
diff --git a/oarepo_ui/theme/webpack.py b/oarepo_ui/theme/webpack.py
index 85b8238b..006e9173 100644
--- a/oarepo_ui/theme/webpack.py
+++ b/oarepo_ui/theme/webpack.py
@@ -31,7 +31,6 @@
                 "oarepo_ui_forms": "./js/oarepo_ui/forms/index.js",
                 "oarepo_ui_theme": "./js/oarepo_ui/theme.js",
                 "oarepo_ui_components": "./js/custom-components.js",
-
             },
             dependencies={
                 "@tanstack/react-query": "^4.32.0",
diff --git a/tests/test_ui_resource.py b/tests/test_ui_resource.py
index 1751c7f2..00118805 100644
--- a/tests/test_ui_resource.py
+++ b/tests/test_ui_resource.py
@@ -2,7 +2,7 @@
 
 
 def test_ui_resource_create_new(app, record_ui_resource, record_service):
-    assert record_ui_resource.empty_record(None) == {"title": ''}
+    assert record_ui_resource.empty_record(None) == {"title": ""}
 
 
 def test_ui_resource_form_config(app, record_ui_resource):

From 3fd45e88d36fac417914858307797cf95af1b20b Mon Sep 17 00:00:00 2001
From: Mirek Simek <miroslav.simek@gmail.com>
Date: Sat, 16 Dec 2023 12:09:29 +0100
Subject: [PATCH 9/9] Renamed get_template_def to get_jinjax_macro

---
 oarepo_ui/resources/resource.py | 34 ++++++++++++++++++++++++++++-----
 1 file changed, 29 insertions(+), 5 deletions(-)

diff --git a/oarepo_ui/resources/resource.py b/oarepo_ui/resources/resource.py
index c96e2ef7..1c87e750 100644
--- a/oarepo_ui/resources/resource.py
+++ b/oarepo_ui/resources/resource.py
@@ -165,7 +165,12 @@ def detail(self):
         )
         metadata = dict(ui_data.get("metadata", ui_data))
         return current_oarepo_ui.catalog.render(
-            self.get_template_def("detail"),
+            self.get_jinjax_macro(
+                "detail",
+                identity=g.identity,
+                args=resource_requestctx.args,
+                view_args=resource_requestctx.view_args,
+            ),
             metadata=metadata,
             ui=dict(ui_data.get("ui", ui_data)),
             record=ui_data,
@@ -240,7 +245,12 @@ def search(self):
         search_app_config = search_config(app_id=self.config.search_app_id)
 
         return current_oarepo_ui.catalog.render(
-            self.get_template_def("search"),
+            self.get_jinjax_macro(
+                "search",
+                identity=g.identity,
+                args=resource_requestctx.args,
+                view_args=resource_requestctx.view_args,
+            ),
             search_app_config=search_app_config,
             ui_config=self.config,
             ui_resource=self,
@@ -275,7 +285,11 @@ def export(self):
         }
         return (exported_record, 200, headers)
 
-    def get_template_def(self, template_type):
+    def get_jinjax_macro(self, template_type, identity=None, args=None, view_args=None):
+        """
+        Returns which jinjax macro (name of the macro, including optional namespace in the form of "namespace.Macro")
+        should be used for rendering the template.
+        """
         return self.config.templates[template_type]
 
     @login_required
@@ -324,7 +338,12 @@ def edit(self):
         }
 
         return current_oarepo_ui.catalog.render(
-            self.get_template_def("edit"),
+            self.get_jinjax_macro(
+                "edit",
+                identity=g.identity,
+                args=resource_requestctx.args,
+                view_args=resource_requestctx.view_args,
+            ),
             record=ui_data,
             api_record=record,
             form_config=form_config,
@@ -369,7 +388,12 @@ def create(self):
         )
 
         return current_oarepo_ui.catalog.render(
-            self.get_template_def("create"),
+            self.get_jinjax_macro(
+                "create",
+                identity=g.identity,
+                args=resource_requestctx.args,
+                view_args=resource_requestctx.view_args,
+            ),
             record=empty_record,
             api_record=None,
             form_config=form_config,