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> " % (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={},