Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(overrides): implement centralised overrides catalog, overrides t… #252

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions oarepo_ui/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand Down
22 changes: 22 additions & 0 deletions oarepo_ui/loader.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 10 additions & 18 deletions oarepo_ui/resources/templating/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -158,29 +161,26 @@ 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,
absolute_template_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()}
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand All @@ -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),
Expand All @@ -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(
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions oarepo_ui/templates/oarepo_ui/base_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

{%- block page_footer %}
{% if not embedded %}{{ super() }}{% endif %}
<input type="hidden" name="react-overrides" value='{{react_overrides | tojson }}'>
{% endblock page_footer %}

{%- block css%}
Expand Down
1 change: 1 addition & 0 deletions oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/hooks.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 && <OverrideComponent {...props}>{children}</OverrideComponent> || <span>Loading...</span>
}

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())
13 changes: 13 additions & 0 deletions oarepo_ui/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from marshmallow import Schema, fields
from marshmallow.schema import SchemaMeta
from marshmallow_utils.fields import NestedAttribute
Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions oarepo_ui/views.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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()
Expand Down
Loading