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 %}
{% endblock %}
+
{% block record_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
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..7e2c400f 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
@@ -144,6 +147,10 @@ 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/catalog.py b/oarepo_ui/resources/catalog.py
index 4560e7a3..58b31687 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,52 +31,91 @@ 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
+ }
+
+ 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
+
+ for root_path_rec in root_paths:
+ component_path = root_path_rec["component_path"]
+ root_path = Path(root_path_rec["root_path"])
+
+ for file_absolute_folder, _folders, files in os.walk(
+ component_path, topdown=False, followlinks=True
+ ):
+ namespace = os.path.relpath(
+ file_absolute_folder, component_path
+ ).strip(".")
+ for filename in files:
+ yield root_path, namespace, Path(
+ file_absolute_folder
+ ) / filename
+
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,
- )
-
- raise ComponentNotFound(
- f"Unable to find a file named {name}{file_ext} "
- f"or one following the pattern {name_dot}*{file_ext}"
- )
+ 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}")
def _get_from_file(
self, *, prefix: str, name: str, url_prefix: str, file_ext: str
@@ -91,30 +132,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/components.py b/oarepo_ui/resources/components.py
index 3e02a0c1..c848cded 100644
--- a/oarepo_ui/resources/components.py
+++ b/oarepo_ui/resources/components.py
@@ -1,13 +1,195 @@
+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.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 ..proxies import current_oarepo_ui
+
+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:
+ * 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
+ * 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,
+ *,
+ 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.
+
+ :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
+ :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
+ """
-class BabelComponent(ServiceComponent):
def form_config(
- self, *, form_config, resource, record, view_args, identity, **kwargs
+ 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.
+
+ :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.
+ :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,
+ *,
+ 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.
+
+ :param api_record: the API record being edited
+ :param data: data serialized by the API service serializer. This is the serialized record data.
+ :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
+ :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, **kwargs):
conf = current_app.config
locales = []
for l in current_i18n.get_locales():
@@ -23,62 +205,67 @@ def form_config(
form_config.setdefault("locales", locales)
-class PermissionsComponent(ServiceComponent):
- def get_record_permissions(self, actions, service, identity, record=None):
+class PermissionsComponent(UIResourceComponent):
+ 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, *, 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)
+
+ def before_ui_search(self, *, extra_context, identity, search_options, **kwargs):
+ from .resource import RecordsUIResource
+
+ if not isinstance(self.resource, RecordsUIResource):
+ return
+
+ extra_context["permissions"] = {
+ "can_create": self.resource.api_service.check_permission(identity, "create")
+ }
+
+ 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 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=record)
+ f"can_{action}": service.check_permission(
+ identity, action, record=data or {}
+ )
for action in actions
}
- def before_ui_detail(self, *, resource, record, extra_context, identity, **kwargs):
- self.fill_permissions(resource, record, extra_context, identity)
-
- def before_ui_edit(self, *, resource, record, extra_context, identity, **kwargs):
- self.fill_permissions(resource, record, extra_context, identity)
+ def fill_permissions(self, data, extra_context, identity):
+ from .resource import RecordsUIResource
- def before_ui_create(self, *, resource, record, extra_context, identity, **kwargs):
- self.fill_permissions(resource, record, extra_context, identity)
+ if not isinstance(self.resource, RecordsUIResource):
+ return
- def before_ui_search(
- self, *, resource, extra_context, identity, search_options, **kwargs
- ):
extra_context["permissions"] = self.get_record_permissions(
- ["create"], resource.api_service, identity
+ current_oarepo_ui.record_actions,
+ self.resource.api_service,
+ identity,
+ data,
)
- search_options["permissions"] = extra_context["permissions"]
- def form_config(
- self, *, form_config, resource, record, view_args, identity, **kwargs
- ):
- self.fill_permissions(resource, record, form_config, identity)
- 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,
- identity,
- record,
- )
+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
-class FilesComponent(ServiceComponent):
- def before_ui_edit(self, *, record, resource, extra_context, identity, **kwargs):
file_service = get_file_service_for_record_service(
- 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/config.py b/oarepo_ui/resources/config.py
index 233a1dce..78223248 100644
--- a/oarepo_ui/resources/config.py
+++ b/oarepo_ui/resources/config.py
@@ -60,17 +60,16 @@ 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": {
- "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..1c87e750 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 TYPE_CHECKING, Iterator
import deepmerge
from flask import abort, g, redirect, request
@@ -24,11 +25,13 @@
from oarepo_ui.utils import dump_empty
+if TYPE_CHECKING:
+ from .components import UIResourceComponent
+
#
# Resource
#
from ..proxies import current_oarepo_ui
-from .catalog import get_jinja_template
from .config import RecordsUIResourceConfig, UIResourceConfig
request_export_args = request_parser(
@@ -56,7 +59,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,94 +99,95 @@ 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
@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
- serialized_record = 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 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 = self.config.ui_serializer.dump_obj(record.to_dict())
+ 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"
- layout = current_oarepo_ui.get_layout(self.get_layout_name())
- _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)
-
- serialized_record["extra_links"] = {
- "ui_links": ui_links,
- "export_path": export_path,
- "search_link": self.config.url_prefix,
- }
self.run_components(
"before_ui_detail",
- resource=self,
- record=serialized_record,
+ api_record=record,
+ record=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))
- return _catalog.render(
- "detail",
- __source=source,
+ metadata = dict(ui_data.get("metadata", ui_data))
+ return current_oarepo_ui.catalog.render(
+ self.get_jinjax_macro(
+ "detail",
+ identity=g.identity,
+ args=resource_requestctx.args,
+ view_args=resource_requestctx.view_args,
+ ),
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,
+ api_record=record,
extra_context=extra_context,
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 = (
@@ -204,21 +208,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)
-
- 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,36 +228,32 @@ 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,
)
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_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,
- layout=layout,
ui_links=ui_links,
extra_context=extra_context,
)
@@ -300,10 +285,11 @@ def export(self):
}
return (exported_record, 200, headers)
- def get_layout_name(self):
- return self.config.layout
-
- 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
@@ -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_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)
)
@@ -324,22 +309,20 @@ def edit(self):
self.run_components(
"form_config",
- layout=layout,
- resource=self,
- record=record,
- data=record,
+ api_record=record,
+ data=data,
+ ui_data=ui_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,
+ api_record=record,
+ ui_data=ui_data,
data=data,
form_config=form_config,
args=resource_requestctx.args,
@@ -348,20 +331,21 @@ 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_data["extra_links"] = {
"ui_links": ui_links,
"search_link": self.config.url_prefix,
}
- return _catalog.render(
- "edit",
- __source=source,
- record=serialized_record,
+ return current_oarepo_ui.catalog.render(
+ 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,
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,8 @@ def create(self):
self.run_components(
"form_config",
- layout=layout,
- resource=self,
- record=empty_record,
+ api_record=None,
+ record=None,
data=empty_record,
form_config=form_config,
args=resource_requestctx.args,
@@ -395,27 +377,25 @@ def create(self):
)
self.run_components(
"before_ui_create",
- layout=layout,
- resource=self,
- record=empty_record,
data=empty_record,
+ record=None,
+ api_record=None,
form_config=form_config,
args=resource_requestctx.args,
view_args=resource_requestctx.view_args,
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_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,
extra_context=extra_context,
ui_links={},
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/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/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/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 92f76875..ba70a309 100644
--- a/tests/templates/TestDetail.jinja
+++ b/tests/templates/TestDetail.jinja
@@ -1,6 +1,3 @@
{#def record, extra_context #}
-{%- for link_name, link in record.extra_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
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):
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={},