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

Mirekys/overridable bundle project #266

Merged
merged 24 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dbdb240
feat(jinja): webpack_optional global helper
mirekys Jan 21, 2025
271dbd0
chore: fix indent
mirekys Jan 21, 2025
9208b1d
feat(webpack): OverridableBundleProject wip
mirekys Jan 21, 2025
9a72ba4
fix(webpack): fix assets project path resolution
mirekys Jan 22, 2025
f02c5b9
feat(webpack): generate overrides js mappings
mirekys Jan 22, 2025
c85c9fc
chore(tests): remove junk
mirekys Jan 22, 2025
21a8a3a
chore(tests): fix sonar
mirekys Jan 22, 2025
9afe956
chore(tests): make webpack tests pass
mirekys Jan 22, 2025
521cf83
feat(webpack): add generated_paths prop to webpack project
mirekys Jan 22, 2025
e8fc38a
feat(tests): add missing generated_path
mirekys Jan 22, 2025
0a65d5e
feat(webpack): only single generate_path suffices
mirekys Jan 22, 2025
5b9af0e
fix(webpack): generated paths needs to be set sooner
mirekys Jan 23, 2025
b2b8309
feat(webpack): add newlines after each import in generated overrides js
mirekys Jan 23, 2025
56191c5
feat(webpack): support named imports in overrides spec
mirekys Jan 23, 2025
31471a2
chore(tests): update webpack tests
mirekys Jan 23, 2025
520811e
fix(tests): fix webpack tests
mirekys Jan 23, 2025
d2b7e77
feat(components): add disabled noop component
mirekys Jan 24, 2025
5785890
feat(components): refactor using UIComponent class
mirekys Jan 27, 2025
550e28d
fix(tests): update tests with UIComponent
mirekys Jan 27, 2025
99c2368
fix(tests): update tests with UIComponent
mirekys Jan 27, 2025
c86b3b8
fix(tests): add missing import
mirekys Jan 27, 2025
6dbec30
chore(cleanup): remove now deprecated overridable-registry
mirekys Jan 29, 2025
c9807c5
refactor(components): json dumps instead of repr
mirekys Jan 29, 2025
8860504
fix: remove overridable registry entry
mirekys Jan 29, 2025
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
12 changes: 12 additions & 0 deletions oarepo_ui/ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"<!-- Warn: {e} -->")

def reinitialize_catalog(self):
self._catalog = None
try:
Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions oarepo_ui/proxies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
5 changes: 2 additions & 3 deletions oarepo_ui/templates/oarepo_ui/javascript.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{% include "invenio_theme/javascript.html" %}

{{ webpack['oarepo-overridable-registry.js'] }}
{{ webpack['oarepo_ui_theme.js'] }}
{{ webpack['oarepo_ui_components.js'] }}
{{ webpack['oarepo-overridable-registry.js'] }}


{{ webpack_optional('overrides-' ~ request.endpoint ~ '.js') }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import * as React from 'react';

export const Disabled = () => null;

export default Disabled;
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import "./burgermenu";
import "./clipboard";
import "./filepreview";
import "./filepreview";
export * from "./Disabled";
50 changes: 50 additions & 0 deletions oarepo_ui/ui/component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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}: {repr(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")
119 changes: 119 additions & 0 deletions oarepo_ui/webpack.py
Original file line number Diff line number Diff line change
@@ -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"),
)
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
68 changes: 68 additions & 0 deletions tests/test_ui_webpack.py
Original file line number Diff line number Diff line change
@@ -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
Loading