diff --git a/oarepo_ui/ext.py b/oarepo_ui/ext.py index 90920cbe..2bc03740 100644 --- a/oarepo_ui/ext.py +++ b/oarepo_ui/ext.py @@ -3,9 +3,14 @@ from pathlib import Path from flask import Response, current_app +from flask_webpackext import current_manifest +from flask_webpackext.errors import ManifestKeyNotFoundError from importlib_metadata import entry_points from invenio_base.utils import obj_or_import_string from flask_login import user_logged_in, user_logged_out +from markupsafe import Markup + +from .proxies import current_optional_manifest from .utils import clear_view_deposit_page_permission_from_session @@ -20,6 +25,12 @@ def __init__(self, app): self.init_builder_plugin() self._catalog = None + def optional_manifest(self, key): + try: + return current_manifest[key] + except ManifestKeyNotFoundError as e: + return Markup(f"") + def reinitialize_catalog(self): self._catalog = None try: @@ -95,6 +106,7 @@ def init_app(self, app): app.extensions["oarepo_ui"] = OARepoUIState(app) user_logged_in.connect(clear_view_deposit_page_permission_from_session) user_logged_out.connect(clear_view_deposit_page_permission_from_session) + app.add_template_global(current_optional_manifest, name="webpack_optional") def init_config(self, app): """Initialize configuration.""" diff --git a/oarepo_ui/proxies.py b/oarepo_ui/proxies.py index c0a34008..a9bc8d1a 100644 --- a/oarepo_ui/proxies.py +++ b/oarepo_ui/proxies.py @@ -3,3 +3,6 @@ current_oarepo_ui = LocalProxy(lambda: current_app.extensions["oarepo_ui"]) """Proxy to the oarepo_ui state.""" + +current_optional_manifest = LocalProxy(lambda: current_oarepo_ui.optional_manifest) +"""Proxy to current optional webpack manifest.""" diff --git a/oarepo_ui/templates/oarepo_ui/javascript.html b/oarepo_ui/templates/oarepo_ui/javascript.html index b47fe1a4..23cddd27 100644 --- a/oarepo_ui/templates/oarepo_ui/javascript.html +++ b/oarepo_ui/templates/oarepo_ui/javascript.html @@ -1,7 +1,5 @@ {% include "invenio_theme/javascript.html" %} - {{ webpack['oarepo_ui_theme.js'] }} {{ webpack['oarepo_ui_components.js'] }} -{{ webpack['oarepo-overridable-registry.js'] }} - +{{ webpack_optional('overrides-' ~ request.endpoint ~ '.js') }} diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/Disabled.jsx b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/Disabled.jsx new file mode 100644 index 00000000..60254c22 --- /dev/null +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/Disabled.jsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export const Disabled = () => null; + +export default Disabled; diff --git a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/index.js b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/index.js index f11433fc..e3591d58 100644 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/index.js +++ b/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/components/index.js @@ -1,3 +1,4 @@ import "./burgermenu"; import "./clipboard"; -import "./filepreview"; \ No newline at end of file +import "./filepreview"; +export * from "./Disabled"; \ No newline at end of file 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 deleted file mode 100644 index 73406867..00000000 --- a/oarepo_ui/theme/assets/semantic-ui/js/oarepo_ui/overridable-registry.js +++ /dev/null @@ -1,33 +0,0 @@ -import { overrideStore } from "react-overridable"; - -// 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.`); - } else { - for (const [key, value] of Object.entries(module.default)) { - overrideStore.add(key, value); - } - } - }); diff --git a/oarepo_ui/theme/webpack.py b/oarepo_ui/theme/webpack.py index da93a3bd..82b9cf85 100644 --- a/oarepo_ui/theme/webpack.py +++ b/oarepo_ui/theme/webpack.py @@ -78,10 +78,6 @@ "oarepo_ui_components": "./js/oarepo_ui/custom-components.js", "copy_to_clipboard": "./js/oarepo_ui/components/clipboard.js", "record_export": "./js/oarepo_ui/components/record-export.js", - # there is already overridable-registry entry point in RDM which - # we are inheriting after installing RDM as a dependency - # so this avoids name clash - "oarepo-overridable-registry": "./js/oarepo_ui/overridable-registry.js", }, dependencies=dependencies, devDependencies={"eslint-plugin-i18next": "^6.0.3"}, diff --git a/oarepo_ui/ui/__init__.py b/oarepo_ui/ui/__init__.py new file mode 100644 index 00000000..2d5e4ad8 --- /dev/null +++ b/oarepo_ui/ui/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2025 CESNET +# +# This software is released under the MIT License. +# https://opensource.org/licenses/MIT + +"""OARepo UI package.""" diff --git a/oarepo_ui/ui/component.py b/oarepo_ui/ui/component.py new file mode 100644 index 00000000..1bad98c7 --- /dev/null +++ b/oarepo_ui/ui/component.py @@ -0,0 +1,51 @@ +import json +from typing import Optional, Dict + + +class UIComponent: + """Represents a UI component specification used to override existing UI components. + + Attributes: + import_name (str): + The name of the component. + import_path (str): + JS module path where the component is imported from. + import_mode (str): + The mode of import, either 'default' or 'named'. + props (dict, optional): + Additional key-value string properties used to parametrize + the component before registering it to overrides store. + """ + + def __init__(self, import_name: str, import_path: str, import_mode: str = 'named', + props: Optional[Dict[str, str]] = None): + """Initialize a UIComponentOverride instance.""" + self.import_name = import_name + self.import_path = import_path + self.import_mode = import_mode + self.props = props + + @property + def name(self) -> str: + """Name of the component.""" + if self.props: + return f"{self.import_name}WithProps" + + return self.import_name + + @property + def import_statement(self) -> str: + """JS import statement string to import the component.""" + import_name = self.import_name if self.import_mode == 'default' else f"{{ {self.import_name} }}" + + return f"import {import_name} from '{self.import_path}';" + + @property + def parametrize_statement(self) -> str | None: + """JS statement to parametrize the component with props.""" + if self.props: + js_props = ", ".join(f"{key}: {json.dumps(value)}" for key, value in self.props.items()) + return f"const {self.name} = parametrize({self.import_name}, {{ {js_props} }});" + + +DisabledComponent = UIComponent("Disabled", "@js/oarepo_ui/components/Disabled") diff --git a/oarepo_ui/webpack.py b/oarepo_ui/webpack.py new file mode 100644 index 00000000..21e7441f --- /dev/null +++ b/oarepo_ui/webpack.py @@ -0,0 +1,119 @@ +import os + +from flask import current_app +from flask_webpackext import WebpackBundleProject +from pywebpack import bundles_from_entry_point +from pywebpack.helpers import cached + +overrides_js_template = ''' +import { overrideStore, parametrize } from 'react-overridable'; + +{% for key, component in overrides.items() -%} +{{ component.import_statement | safe }} +{% endfor %} + +{%- for key, component in overrides.items() -%} +{%- if component.props -%}{{ component.parametrize_statement | safe }}{%- endif -%} +{%- endfor %} + +{% for key, component in overrides.items() -%} +overrideStore.add('{{ key }}', {{ component.name }}); +{% endfor %} +''' + + +class OverridableBundleProject(WebpackBundleProject): + def __init__( + self, import_name, project_folder=None, bundles=None, + config=None, config_path=None, overrides_bundle_path="_overrides" + ): + """Initialize templated folder. + + :param import_name: Name of the module where the WebpackBundleProject + class is instantiated. It is used to determine the absolute path + to the ``project_folder``. + :param project_folder: Relative path to the Webpack project which is + going to aggregate all the ``bundles``. + :param bundles: List of + :class:`flask_webpackext.bundle.WebpackBundle`. This list can be + statically defined if the bundles are known before hand, or + dinamically generated using + :func:`pywebpack.helpers.bundles_from_entry_point` so the bundles + are discovered from the defined Webpack entrypoints exposed by + other modules. + :param config: Dictionary which overrides the ``config.json`` file + generated by Flask-WebpackExt. Use carefuly and only if you know + what you are doing since ``config.json`` is the file that holds the + key information to integrate Flask with Webpack. + :param config_path: Path where Flask-WebpackExt is going to write the + ``config.json``, this file is generated by + :func:`flask_webpackext.project.flask_config`. + :param overrides_bundle_path: Path where special bundle for UI overrides + if going to be generated. + """ + # Following is needed to correctly resolve paths to etc. source package.json from invenio_assets + _import_name = 'invenio_assets.webpack' + super(OverridableBundleProject, self).__init__( + _import_name, + project_folder=project_folder, bundles=bundles, config=config, config_path=config_path) + self._overrides_bundle_path = overrides_bundle_path + self._generated_paths = [] + + @property + def overrides_bundle_asset_path(self): + return f'js/{self._overrides_bundle_path}' + + @property + def overrides_bundle_path(self): + return os.path.join(self.project_path, self.overrides_bundle_asset_path) + + @property + def generated_paths(self): + # Mark everything under overrides_bundle_path as generated & managed by this bundle project + return [self.overrides_bundle_path] + + @property + @cached + def entry(self): + """Get webpack entry points.""" + bundle_entries = super().entry + if 'UI_OVERRIDES' in current_app.config: + for bp_name, overrides in current_app.config['UI_OVERRIDES'].items(): + bundle_entries[f"overrides-{bp_name}"] = f"./{self.overrides_bundle_asset_path}/{bp_name}.js" + return bundle_entries + + def create(self, force=None): + """Create webpack project from a template. + + This command collects all asset files from the bundles. + It generates a new package.json by merging the package.json + dependencies of each bundle. Additionally, it generates a special + bundle for any UI overrides. + """ + super().create(force) + + if 'UI_OVERRIDES' in current_app.config: + # Generate special bundle for configured UI overrides + if not os.path.exists(self.overrides_bundle_path): + os.mkdir(self.overrides_bundle_path) + + for bp_name, overrides in current_app.config['UI_OVERRIDES'].items(): + template = current_app.jinja_env.from_string(overrides_js_template) + overrides_js_content = template.render({'overrides': overrides}) + overrides_js_path = os.path.join(self.overrides_bundle_path, f'{bp_name}.js') + + with open(overrides_js_path, 'w+') as f: + f.write(overrides_js_content) + + def clean(self): + """Clean created webpack project.""" + super().clean() + self.generated_paths.clear() + + +project = OverridableBundleProject( + __name__, + project_folder="assets", + config_path="build/config.json", + bundles=bundles_from_entry_point("invenio_assets.webpack"), +) diff --git a/setup.cfg b/setup.cfg index 144b7687..9d66cc24 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = oarepo-ui -version = 5.2.32 +version = 5.2.33 description = UI module for invenio 3.5+ long_description = file: README.md long_description_content_type = text/markdown diff --git a/tests/conftest.py b/tests/conftest.py index 87675d51..55fbe3d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,6 +61,8 @@ def app_config(app_config): {"section": "B", "fields": [{"field": "bbb", "ui_widget": "Input"}]} ] + app_config["WEBPACKEXT_PROJECT"] = "oarepo_ui.webpack:project" + return app_config diff --git a/tests/test_ui_webpack.py b/tests/test_ui_webpack.py new file mode 100644 index 00000000..49a3e52a --- /dev/null +++ b/tests/test_ui_webpack.py @@ -0,0 +1,68 @@ +import os + +from oarepo_ui.ui.component import UIComponent +from oarepo_ui.webpack import OverridableBundleProject, project + + +def test_overridable_bundle_project_init(app): + proj = OverridableBundleProject( + import_name='test_app', + project_folder='assets', + config_path='test_build/config.json', + bundles=[] + ) + with app.app_context(): + assert proj._project_template_dir.endswith('invenio_assets/assets') + assert proj.config_path.endswith('test_build/config.json') + assert proj.overrides_bundle_path == os.path.join(proj.project_path, 'js/_overrides') + assert os.path.exists(proj.package_json_source_path) + + +def test_overridable_bundle_project_entry(app): + app.config['UI_OVERRIDES'] = { + 'test_bp': {'componentA': UIComponent('ComponentA', 'components/ComponentA')} + } + with app.app_context(): + entry_points = project.entry + assert 'overrides-test_bp' in entry_points + assert entry_points['overrides-test_bp'] == './js/_overrides/test_bp.js' + + +def test_overridable_bundle_project_entry_file(app, fake_manifest): + app.config['UI_OVERRIDES'] = { + 'test_bp': {'componentA.item': UIComponent('ComponentA', 'components/ComponentA'), + 'componentB.item': UIComponent('DefaultComponent', 'components/DefaultComponent', 'default')} + } + with app.app_context(): + project.create() + assert os.path.exists(project.package_json_source_path) + + assert os.path.isdir(project.overrides_bundle_path) + + overrides_file_path = os.path.join(project.overrides_bundle_path, 'test_bp.js') + assert os.path.exists(overrides_file_path) + with open(overrides_file_path) as f: + overrides_file_path_content = f.read() + assert overrides_file_path_content == ''' +import { overrideStore, parametrize } from 'react-overridable'; + +import { ComponentA } from 'components/ComponentA'; +import DefaultComponent from 'components/DefaultComponent'; + + +overrideStore.add('componentA.item', ComponentA); +overrideStore.add('componentB.item', DefaultComponent); +''' + + +def test_overridable_bundle_project_generated_paths(app, fake_manifest): + app.config['UI_OVERRIDES'] = { + 'test_bp1': {'componentA.item': UIComponent('ComponentA', 'components/ComponentA')}, + 'test_bp2': {'componentA.item': UIComponent('ComponentB', 'components/ComponentB')} + } + + project.clean() + project.create() + + assert len(project.generated_paths) == 1 + assert project.overrides_bundle_path in project.generated_paths