diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py index af9cfa82..0734f318 100644 --- a/oarepo_ui/ext.py +++ b/oarepo_ui/ext.py @@ -2,12 +2,15 @@ import json from pathlib import Path +import deepmerge +import yaml from flask import Response, current_app from importlib_metadata import entry_points from invenio_base.utils import obj_or_import_string import oarepo_ui.cli # noqa from oarepo_ui.resources.templating.catalog import OarepoCatalog as Catalog +from oarepo_ui.utils import extract_priority class OARepoUIState: @@ -16,6 +19,7 @@ def __init__(self, app): self._resources = [] self.init_builder_plugin() self._catalog = None + self._ui_overrides = None def reinitialize_catalog(self): self._catalog = None @@ -31,6 +35,40 @@ def catalog(self): self._catalog = Catalog() return self._catalog_config(self._catalog, self.app.jinja_env) + @functools.cached_property + def ui_overrides(self) -> dict: + overrides = [] + eps = entry_points(group="oarepo.ui_overrides") + for ep in eps: + path = Path(obj_or_import_string(ep.module).__file__).parent / ep.attr + with path.open() as f: + overrides.append((ep.name, yaml.safe_load(f))) + + merger = deepmerge.Merger( + [(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])], + # next, choose the fallback strategies, + # applied to all other types: + ["override"], + # finally, choose the strategies in + # the case where the types conflict: + ["override"], + ) + prioritized_overrides = sorted(overrides, key=lambda name: extract_priority(name[0])[1]) + + ret = {} + for po in prioritized_overrides: + ret = merger.merge(ret, po[1]) + + return ret + + @property + def jinja_overrides(self) -> dict: + return self.ui_overrides.get('jinja', {}) + + @property + def react_overrides(self) -> dict: + return self.ui_overrides.get('react', {}) + def _catalog_config(self, catalog, env): context = {} env.policies.setdefault("json.dumps_kwargs", {}).setdefault("default", str) @@ -51,6 +89,9 @@ def _catalog_config(self, catalog, env): return catalog + def lookup_jinja_component(self, component_name: str) -> str: + return self.jinja_overrides.get(component_name, component_name) + def register_resource(self, ui_resource): self._resources.append(ui_resource) diff --git a/oarepo_ui/loader.py b/oarepo_ui/loader.py new file mode 100644 index 00000000..af1b9908 --- /dev/null +++ b/oarepo_ui/loader.py @@ -0,0 +1,22 @@ +from invenio_app.helpers import ThemeJinjaLoader + +from oarepo_ui.proxies import current_oarepo_ui + + +class OverridableThemeJinjaLoader(ThemeJinjaLoader): + """Overridable theme template loader. + + This loader acts as a wrapper for any type of Jinja loader. Before doing a + template lookup, the loader consults the ui_overrides configuration to determine + which template should be used. + """ + + def __init__(self, app, loader): + """Initialize loader. + """ + super().__init__(app, loader) + + def load(self, environment, name, globals=None): + name = current_oarepo_ui.lookup_jinja_component(name) + + return super().load(environment, name, globals) diff --git a/oarepo_ui/resources/templating/catalog.py b/oarepo_ui/resources/templating/catalog.py index e86e8c3c..70d220d8 100644 --- a/oarepo_ui/resources/templating/catalog.py +++ b/oarepo_ui/resources/templating/catalog.py @@ -14,6 +14,9 @@ from jinjax.exceptions import ComponentNotFound from jinjax.jinjax import JinjaX +from oarepo_ui.proxies import current_oarepo_ui +from oarepo_ui.utils import extract_priority + DEFAULT_URL_ROOT = "/static/components/" ALLOWED_EXTENSIONS = (".css", ".js") DEFAULT_PREFIX = "" @@ -158,7 +161,7 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: if hasattr(self, "_component_paths"): return self._component_paths - paths: Dict[str, Tuple[Path, Path, int]] = {} + paths: Dict[str, Tuple[Path, Path]] = {} for ( template_name, @@ -166,21 +169,18 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: relative_template_path, priority, ) in self.list_templates(): + # TODO: this is incorrect, doesn't work against e.g. components.EditForm split_template_name = template_name.split(DELIMITER) for idx in range(0, len(split_template_name)): partial_template_name = DELIMITER.join(split_template_name[idx:]) - partial_priority = priority - idx * 10 - - # if the priority is greater, replace the path + print(template_name, partial_template_name, idx, flush=True) if ( partial_template_name not in paths - or partial_priority > paths[partial_template_name][2] ): paths[partial_template_name] = ( absolute_template_path, relative_template_path, - partial_priority, ) self._component_paths = {k: (v[0], v[1]) for k, v in paths.items()} @@ -190,21 +190,10 @@ def component_paths(self) -> Dict[str, Tuple[Path, Path]]: def component_paths(self): self._component_paths = {} - 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_component_path( self, prefix: str, name: str, file_ext: "TFileExt" = "" ) -> "tuple[Path, Path]": name = name.replace(SLASH, DELIMITER) - paths = self.component_paths if name in paths: return paths[name] @@ -236,7 +225,7 @@ def list_templates(self): # extract priority split_name = list(template_name.rsplit(DELIMITER, 1)) - split_name[-1], priority = self._extract_priority(split_name[-1]) + split_name[-1], priority = extract_priority(split_name[-1]) template_name = DELIMITER.join(split_name) if stripped: @@ -254,6 +243,7 @@ def list_templates(self): def _get_from_source( self, *, name: str, url_prefix: str, source: str ) -> "Component": + name = current_oarepo_ui.lookup_jinja_component(name) return KeepGlobalContextComponent( self, super()._get_from_source(name=name, url_prefix=url_prefix, source=source), @@ -262,6 +252,7 @@ def _get_from_source( def _get_from_cache( self, *, prefix: str, name: str, url_prefix: str, file_ext: str ) -> "Component": + name = current_oarepo_ui.lookup_jinja_component(name) return KeepGlobalContextComponent( self, super()._get_from_cache( @@ -272,6 +263,7 @@ def _get_from_cache( def _get_from_file( self, *, prefix: str, name: str, url_prefix: str, file_ext: str ) -> "Component": + name = current_oarepo_ui.lookup_jinja_component(name) return KeepGlobalContextComponent( self, super()._get_from_file( diff --git a/oarepo_ui/templates/oarepo_ui/base_page.html b/oarepo_ui/templates/oarepo_ui/base_page.html index dd49cb80..4bafe035 100644 --- a/oarepo_ui/templates/oarepo_ui/base_page.html +++ b/oarepo_ui/templates/oarepo_ui/base_page.html @@ -15,6 +15,7 @@ {%- block page_footer %} {% if not embedded %}{{ super() }}{% endif %} + {% endblock page_footer %} {%- block css%} diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/hooks.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/hooks.js index ac133ad5..a14c73c8 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/hooks.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/hooks.js @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { registerLocale } from "react-datepicker"; +import { getInputFromDOM } from "./util"; export const useLoadLocaleObjects = (localesArray = ["cs", "en-US"]) => { const [componentRendered, setComponentRendered] = useState(false); diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/overridable-registry.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/overridable-registry.js index 73406867..79234a5b 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/overridable-registry.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/overridable-registry.js @@ -1,33 +1,75 @@ +import React, { lazy } from 'react'; import { overrideStore } from "react-overridable"; +import { getInputFromDOM } from "./util"; +import { importTemplate } from "@js/invenio_theme/templates"; +import PropTypes from 'prop-types' -// get all files below /templates/overridableRegistry that end with mapping.js. -// The files shall be in a subfolder, in order to prevent clashing between mapping.js -// from different libraries. each mapping.js file shall have a default export -// that is an object with signature {"component-id": Component} the files -// will be prioritized by leading prefix (e.g. 10-mapping.js will be processed -// before 20-mapping.js). mapping.js without prefix will have lowest priority. - -const requireMappingFiles = require.context( - "/templates/overridableRegistry/", - true, - /mapping.js$/ -); - -requireMappingFiles - .keys() - .map((fileName) => { - const match = fileName.match(/\/(\d+)-mapping.js$/); - const priority = match ? parseInt(match[1], 10) : 0; - return { fileName, priority }; - }) - .sort((a, b) => a.priority - b.priority) - .forEach(({ fileName }) => { - const module = requireMappingFiles(fileName); - if (!module.default) { - console.error(`Mapping file ${fileName} does not have a default export.`); +function parseImportString (importString) { + if (importString.includes(':')) { + const [moduleName, exportName] = importString.split(':') + return { importType: 'module', moduleName, exportName } } else { - for (const [key, value] of Object.entries(module.default)) { - overrideStore.add(key, value); - } + const path = !(importString.endsWith('jsx') || importString.endsWith('js')) + ? `${importString}.jsx` + : importString + return { importType: 'template', path: importString } } - }); +} + +function lazyOverridable (importString) { + function LazyOverride ({ children, ...props }) { + const { importType, ...importSpec } = parseImportString(importString) + const [OverrideComponent, setOverrideComponent] = React.useState(() => () => null); + + // Lazily load a Component on mount, thanks to: https://stackoverflow.com/a/77028157 + // TODO: try if React.lazy could be somehow used in this scenario where we load either template or + // dynamic import (which React.lazy is supposed to only support) + React.useEffect(() => { + async function loadOverrideComponent () { + let Component; + + if (importType === 'module') { + const { moduleName, exportName } = importSpec + // TODO: call dynamic webpack import here + } else if (importType === 'template') { + const { path } = importSpec + Component = await importTemplate(path); + console.log('Imported ', {Component}) + } else { + throw new Error(`Import type not supported for ${importString}`) + } + + if (Component) { + const name = Component.displayName || Component.name; + Component.displayName = `LazyOverride(${name})`; + Component.propTypes = { + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + }; + Component.defaultProps = { + children: null, + }; + + setOverrideComponent(() => Component) + return Component + } + } + + loadOverrideComponent().catch(err => console.error(err)) + }, []) + + return OverrideComponent && {children} || Loading... + } + + LazyOverride.displayName = `LazyOverridable(${importString})`; + + return LazyOverride +} + + +const reactOverrides = getInputFromDOM("react-overrides"); +Object.entries(reactOverrides).forEach( + ([overridableId, importString]) => overrideStore.add( + overridableId, lazyOverridable(importString) + )); + +console.debug("Global React component overrides:", overrideStore.getAll()) diff --git a/oarepo_ui/utils.py b/oarepo_ui/utils.py index 3bbff477..0f2e3a7d 100644 --- a/oarepo_ui/utils.py +++ b/oarepo_ui/utils.py @@ -1,3 +1,5 @@ +import re + from marshmallow import Schema, fields from marshmallow.schema import SchemaMeta from marshmallow_utils.fields import NestedAttribute @@ -28,3 +30,14 @@ def dump_empty(schema_or_field): if isinstance(schema_or_field, fields.Dict): return {} return None + + +def extract_priority(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 diff --git a/oarepo_ui/views.py b/oarepo_ui/views.py index ddf43f85..ec5440dd 100644 --- a/oarepo_ui/views.py +++ b/oarepo_ui/views.py @@ -1,6 +1,8 @@ from flask import Blueprint from invenio_base.utils import obj_or_import_string +from .loader import OverridableThemeJinjaLoader + def create_blueprint(app): blueprint = Blueprint("oarepo_ui", __name__, template_folder="templates") @@ -24,7 +26,10 @@ def add_jinja_filters(state): for k, v in app.config["OAREPO_UI_JINJAX_GLOBALS"].items() } ) + env.globals.update({'react_overrides': ext.react_overrides}) env.policies.setdefault("json.dumps_kwargs", {}).setdefault("default", str) + app.jinja_env = env.overlay(loader=OverridableThemeJinjaLoader(app, env.loader)) + # the catalogue should not have been used at this point but if it was, we need to reinitialize it ext.reinitialize_catalog()