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()