Compare commits
9 Commits
e90e7b01dd
...
AddingNRTW
| Author | SHA1 | Date | |
|---|---|---|---|
| a406440126 | |||
| ef88d2925e | |||
| 94214125fd | |||
| 17b08be077 | |||
| db56363b1f | |||
| 3bd503d4d2 | |||
| 292a477298 | |||
| eb8d6a99a2 | |||
| 765c715d63 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -8,11 +8,19 @@ htmlcov
|
|||||||
.venv
|
.venv
|
||||||
tests/settings_from_unit_testing.json
|
tests/settings_from_unit_testing.json
|
||||||
tests/TestDBEngineRoot
|
tests/TestDBEngineRoot
|
||||||
|
tests/*.png
|
||||||
|
src/*.png
|
||||||
|
tests/*.html
|
||||||
|
tests/*.txt
|
||||||
|
test-results
|
||||||
.sesskey
|
.sesskey
|
||||||
tools.db
|
tools.db
|
||||||
.mytools_db
|
.mytools_db
|
||||||
.idea/MyManagingTools.iml
|
.idea/MyManagingTools.iml
|
||||||
.idea/misc.xml
|
.idea/misc.xml
|
||||||
|
.idea/dataSources.xml
|
||||||
|
.idea/sqldialects.xml
|
||||||
|
.idea_bak
|
||||||
**/*.prof
|
**/*.prof
|
||||||
|
|
||||||
# Created by .ignore support plugin (hsz.mobi)
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
|
|||||||
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
6
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="PyInitNewSignatureInspection" enabled="false" level="WARNING" enabled_by_default="false" />
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
34
Makefile
34
Makefile
@@ -1,4 +1,5 @@
|
|||||||
.PHONY: test
|
.PHONY: test install-playwright install-playwright-deps test-e2e test-e2e-headed test-regression
|
||||||
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest
|
pytest
|
||||||
@@ -7,6 +8,34 @@ coverage:
|
|||||||
coverage run --source=src -m pytest
|
coverage run --source=src -m pytest
|
||||||
coverage html
|
coverage html
|
||||||
|
|
||||||
|
install-playwright:
|
||||||
|
@echo "Installing Playwright and pytest-playwright via pip..."
|
||||||
|
pip install playwright pytest-playwright
|
||||||
|
@echo "Installing Playwright browsers..."
|
||||||
|
playwright install
|
||||||
|
@echo "Installing system dependencies for Ubuntu/WSL2..."
|
||||||
|
playwright install-deps
|
||||||
|
@echo "Playwright installation complete!"
|
||||||
|
|
||||||
|
install-playwright-deps:
|
||||||
|
@echo "Installing system dependencies for Playwright on Ubuntu/WSL2..."
|
||||||
|
playwright install-deps
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
pytest tests/test_e2e_regression.py -m "e2e" --browser chromium
|
||||||
|
|
||||||
|
test-e2e-headed:
|
||||||
|
pytest -m "e2e" --browser chromium --headed
|
||||||
|
|
||||||
|
test-e2e-headed-slowmo:
|
||||||
|
pytest -m "priority1" --browser chromium --headed --slowmo=2000
|
||||||
|
|
||||||
|
test-regression:
|
||||||
|
pytest tests/test_e2e_regression.py -m "regression" --browser chromium
|
||||||
|
|
||||||
|
test-all-browsers:
|
||||||
|
pytest tests/test_e2e_regression.py -m "e2e" --browser chromium --browser firefox --browser webkit
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf build
|
rm -rf build
|
||||||
rm -rf htmlcov
|
rm -rf htmlcov
|
||||||
@@ -20,6 +49,9 @@ clean:
|
|||||||
rm -rf src/tools.db
|
rm -rf src/tools.db
|
||||||
rm -rf src/*.out
|
rm -rf src/*.out
|
||||||
rm -rf src/*.prof
|
rm -rf src/*.prof
|
||||||
|
rm -rf tests/debug*.txt
|
||||||
|
rm -rf tests/debug*.html
|
||||||
|
rm -rf tests/debug*.png
|
||||||
find . -name '.sesskey' -exec rm -rf {} +
|
find . -name '.sesskey' -exec rm -rf {} +
|
||||||
find . -name '.pytest_cache' -exec rm -rf {} +
|
find . -name '.pytest_cache' -exec rm -rf {} +
|
||||||
find . -name '__pycache__' -exec rm -rf {} +
|
find . -name '__pycache__' -exec rm -rf {} +
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -42,3 +42,17 @@ cd src
|
|||||||
python -m cProfile -o profile.out main.py
|
python -m cProfile -o profile.out main.py
|
||||||
snakeviz profile.out # 'pip install snakeviz' if snakeviz is not installed
|
snakeviz profile.out # 'pip install snakeviz' if snakeviz is not installed
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# End to end testing
|
||||||
|
```shell
|
||||||
|
make install-playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can install Playwright and pytest-playwright via pip:
|
||||||
|
```shell
|
||||||
|
pip install playwright pytest-playwright
|
||||||
|
playwright install
|
||||||
|
playwright install-deps # may be required on Linux
|
||||||
|
playwright --version
|
||||||
|
```
|
||||||
|
|||||||
11
pytest.ini
Normal file
11
pytest.ini
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[pytest]
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
testpaths = tests
|
||||||
|
pythonpath = src tests
|
||||||
|
addopts = --tb=short -v
|
||||||
|
markers =
|
||||||
|
e2e: marks tests as end-to-end tests
|
||||||
|
smoke: marks tests as smoke tests
|
||||||
|
regression: marks tests as regression tests
|
||||||
@@ -10,6 +10,8 @@ click==8.1.7
|
|||||||
et-xmlfile==1.1.0
|
et-xmlfile==1.1.0
|
||||||
fastcore==1.8.5
|
fastcore==1.8.5
|
||||||
fastlite==0.2.1
|
fastlite==0.2.1
|
||||||
|
gprof2dot==2025.4.14
|
||||||
|
greenlet==3.2.4
|
||||||
h11==0.14.0
|
h11==0.14.0
|
||||||
httpcore==1.0.5
|
httpcore==1.0.5
|
||||||
httptools==0.6.1
|
httptools==0.6.1
|
||||||
@@ -26,28 +28,37 @@ oauthlib==3.2.2
|
|||||||
openpyxl==3.1.5
|
openpyxl==3.1.5
|
||||||
packaging==24.1
|
packaging==24.1
|
||||||
pandas==2.2.3
|
pandas==2.2.3
|
||||||
|
pandas-stubs==2.3.2.250827
|
||||||
|
playwright==1.55.0
|
||||||
pluggy==1.5.0
|
pluggy==1.5.0
|
||||||
pydantic==2.11.5
|
pydantic==2.11.5
|
||||||
pydantic-settings==2.9.1
|
pydantic-settings==2.9.1
|
||||||
pydantic_core==2.33.2
|
pydantic_core==2.33.2
|
||||||
|
pyee==13.0.0
|
||||||
Pygments==2.19.1
|
Pygments==2.19.1
|
||||||
pytest==8.3.3
|
pytest==8.3.3
|
||||||
|
pytest-base-url==2.1.0
|
||||||
pytest-mock==3.14.1
|
pytest-mock==3.14.1
|
||||||
|
pytest-playwright==0.7.0
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
python-fasthtml==0.12.21
|
python-fasthtml==0.12.21
|
||||||
python-multipart==0.0.10
|
python-multipart==0.0.10
|
||||||
|
python-slugify==8.0.4
|
||||||
pytz==2024.2
|
pytz==2024.2
|
||||||
PyYAML==6.0.2
|
PyYAML==6.0.2
|
||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
rich==14.0.0
|
rich==14.0.0
|
||||||
shellingham==1.5.4
|
shellingham==1.5.4
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
snakeviz==2.2.2
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
soupsieve==2.6
|
soupsieve==2.6
|
||||||
sqlite-minutils==3.37.0.post3
|
sqlite-minutils==3.37.0.post3
|
||||||
sse-starlette==2.3.6
|
sse-starlette==2.3.6
|
||||||
starlette==0.38.5
|
starlette==0.38.5
|
||||||
|
text-unidecode==1.3
|
||||||
|
tornado==6.5.2
|
||||||
typer==0.16.0
|
typer==0.16.0
|
||||||
typing-inspection==0.4.1
|
typing-inspection==0.4.1
|
||||||
typing_extensions==4.13.2
|
typing_extensions==4.13.2
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class AuthManager:
|
|||||||
session["username"] = user_data["username"]
|
session["username"] = user_data["username"]
|
||||||
session["user_email"] = user_data["email"]
|
session["user_email"] = user_data["email"]
|
||||||
session["is_admin"] = bool(user_data["is_admin"])
|
session["is_admin"] = bool(user_data["is_admin"])
|
||||||
|
session["roles"] = UserDAO.get_user_roles_by_id(user_data["id"])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def logout_user(session) -> None:
|
def logout_user(session) -> None:
|
||||||
@@ -130,4 +131,3 @@ class AuthManager:
|
|||||||
"user_email": "admin@mmt.com",
|
"user_email": "admin@mmt.com",
|
||||||
"is_admin": True
|
"is_admin": True
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6,9 +6,10 @@ class BaseComponent:
|
|||||||
Base class for all components that need to have a session and an id
|
Base class for all components that need to have a session and an id
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, _id=None, **kwargs):
|
def __init__(self, session, _id=None, role_name=None, **kwargs):
|
||||||
self._session = session
|
self._session = session
|
||||||
self._id = _id or self.create_component_id(session)
|
self._id = _id or self.create_component_id(session)
|
||||||
|
self._role_name = role_name
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
return self._id
|
return self._id
|
||||||
@@ -35,6 +36,17 @@ class BaseComponent:
|
|||||||
def create_component_id(session):
|
def create_component_id(session):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
if (not self._session["is_admin"] and
|
||||||
|
self._role_name is not None and
|
||||||
|
self._role_name not in self._session["roles"]):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.render()
|
||||||
|
|
||||||
|
|
||||||
class BaseComponentSingleton(BaseComponent):
|
class BaseComponentSingleton(BaseComponent):
|
||||||
"""
|
"""
|
||||||
@@ -43,8 +55,8 @@ class BaseComponentSingleton(BaseComponent):
|
|||||||
|
|
||||||
COMPONENT_INSTANCE_ID = None
|
COMPONENT_INSTANCE_ID = None
|
||||||
|
|
||||||
def __init__(self, session, _id=None, settings_manager=None, tabs_manager=None, **kwargs):
|
def __init__(self, session, _id=None, role_name=None, settings_manager=None, tabs_manager=None, **kwargs):
|
||||||
super().__init__(session, _id, **kwargs)
|
super().__init__(session, _id, role_name,**kwargs)
|
||||||
self._settings_manager = settings_manager
|
self._settings_manager = settings_manager
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from components.BaseComponent import BaseComponent
|
from components.BaseComponent import BaseComponent
|
||||||
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID
|
from components.addstuff.constants import ADD_STUFF_INSTANCE_ID, ADD_STUFF_ROLE
|
||||||
from components.repositories.components.Repositories import Repositories
|
from components.repositories.components.Repositories import Repositories
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
|
|
||||||
|
|
||||||
class AddStuffMenu(BaseComponent):
|
class AddStuffMenu(BaseComponent):
|
||||||
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, ADD_STUFF_ROLE)
|
||||||
self.tabs_manager = tabs_manager # MyTabs component id
|
self.tabs_manager = tabs_manager # MyTabs component id
|
||||||
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
|
self.repositories = InstanceManager.get(session, Repositories.create_component_id(session), Repositories)
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div("Add stuff...", tabindex="0"),
|
Div("Add stuff...", tabindex="0"),
|
||||||
Ul(
|
Ul(
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
|
ADD_STUFF_INSTANCE_ID = "__AddStuff__"
|
||||||
|
ADD_STUFF_ROLE = "add_stuff"
|
||||||
@@ -8,7 +8,7 @@ from components.admin.assets.icons import icon_jira
|
|||||||
from components.admin.commands import AdminCommandManager
|
from components.admin.commands import AdminCommandManager
|
||||||
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm, AdminButton, AdminMessageType
|
from components.admin.components.AdminForm import AdminFormItem, AdminFormType, AdminForm, AdminButton, AdminMessageType
|
||||||
from components.admin.components.ImportHolidays import ImportHolidays
|
from components.admin.components.ImportHolidays import ImportHolidays
|
||||||
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID
|
from components.admin.constants import ADMIN_INSTANCE_ID, ADMIN_AI_BUDDY_INSTANCE_ID, ADMIN_JIRA_INSTANCE_ID, ADMIN_ROLE
|
||||||
from components.aibuddy.assets.icons import icon_brain_ok
|
from components.aibuddy.assets.icons import icon_brain_ok
|
||||||
from components.hoildays.assets.icons import icon_holidays
|
from components.hoildays.assets.icons import icon_holidays
|
||||||
from components.tabs.components.MyTabs import MyTabs
|
from components.tabs.components.MyTabs import MyTabs
|
||||||
@@ -19,7 +19,7 @@ from core.jira import Jira
|
|||||||
|
|
||||||
class Admin(BaseComponent):
|
class Admin(BaseComponent):
|
||||||
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager: MyTabs = None):
|
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager: MyTabs = None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, ADMIN_ROLE)
|
||||||
self.settings_manager = settings_manager
|
self.settings_manager = settings_manager
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
self.commands = AdminCommandManager(self)
|
self.commands = AdminCommandManager(self)
|
||||||
@@ -117,7 +117,7 @@ class Admin(BaseComponent):
|
|||||||
form.set_message(f"Error {res.status_code} - {res.text}", AdminMessageType.ERROR)
|
form.set_message(f"Error {res.status_code} - {res.text}", AdminMessageType.ERROR)
|
||||||
return self.tabs_manager.render()
|
return self.tabs_manager.render()
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
Div(mk_ellipsis("Admin", cls="text-sm font-medium mb-1 mr-3")),
|
Div(mk_ellipsis("Admin", cls="text-sm font-medium mb-1 mr-3")),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
ADMIN_INSTANCE_ID = "__Admin__"
|
ADMIN_INSTANCE_ID = "__Admin__"
|
||||||
|
ADMIN_ROLE = "admin"
|
||||||
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
|
ADMIN_AI_BUDDY_INSTANCE_ID = "__AdminAIBuddy__"
|
||||||
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
|
ADMIN_IMPORT_HOLIDAYS_INSTANCE_ID = "__AdminImportHolidays__"
|
||||||
ADMIN_JIRA_INSTANCE_ID = "__AdminJira__"
|
ADMIN_JIRA_INSTANCE_ID = "__AdminJira__"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from core.settings_management import GenericDbManager
|
|||||||
|
|
||||||
class AIBuddy(BaseComponent):
|
class AIBuddy(BaseComponent):
|
||||||
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager=None):
|
def __init__(self, session, _id: str = None, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, AI_BUDDY_ROLE)
|
||||||
self.settings_manager = settings_manager
|
self.settings_manager = settings_manager
|
||||||
self.db = GenericDbManager(session, settings_manager, AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings)
|
self.db = GenericDbManager(session, settings_manager, AI_BUDDY_SETTINGS_ENTRY, AIBuddySettings)
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
@@ -153,7 +153,7 @@ class AIBuddy(BaseComponent):
|
|||||||
for name, tool in available_tools.items()
|
for name, tool in available_tools.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
Div(
|
Div(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
AI_BUDDY_INSTANCE_ID = "__AIBuddy__"
|
AI_BUDDY_INSTANCE_ID = "__AIBuddy__"
|
||||||
|
AI_BUDDY_ROLE = "ai_buddy"
|
||||||
ROUTE_ROOT = "/ai"
|
ROUTE_ROOT = "/ai"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from fasthtml.components import *
|
|||||||
|
|
||||||
from components.BaseComponent import BaseComponent
|
from components.BaseComponent import BaseComponent
|
||||||
from components.applications.commands import Commands
|
from components.applications.commands import Commands
|
||||||
from components.applications.constants import APPLICATION_INSTANCE_ID
|
from components.applications.constants import APPLICATION_INSTANCE_ID, APPLICATION_ROLE
|
||||||
from components.hoildays.assets.icons import icon_holidays
|
from components.hoildays.assets.icons import icon_holidays
|
||||||
from components.hoildays.components.HolidaysViewer import HolidaysViewer
|
from components.hoildays.components.HolidaysViewer import HolidaysViewer
|
||||||
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
|
from components.hoildays.constants import HOLIDAYS_VIEWER_INSTANCE_ID
|
||||||
@@ -12,7 +12,7 @@ from core.instance_manager import InstanceManager
|
|||||||
|
|
||||||
class Applications(BaseComponent):
|
class Applications(BaseComponent):
|
||||||
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, APPLICATION_ROLE)
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
self.settings_manager = settings_manager
|
self.settings_manager = settings_manager
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
@@ -30,7 +30,7 @@ class Applications(BaseComponent):
|
|||||||
|
|
||||||
raise NotImplementedError(app_name)
|
raise NotImplementedError(app_name)
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
APPLICATION_INSTANCE_ID = "__Applications__"
|
APPLICATION_INSTANCE_ID = "__Applications__"
|
||||||
|
APPLICATION_ROLE = "applications"
|
||||||
ROUTE_ROOT = "/apps"
|
ROUTE_ROOT = "/apps"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from components.aibuddy.components.AIBuddy import AIBuddy
|
|||||||
from components.debugger.assets.icons import icon_dbengine
|
from components.debugger.assets.icons import icon_dbengine
|
||||||
from components.debugger.commands import Commands
|
from components.debugger.commands import Commands
|
||||||
from components.debugger.components.JsonViewer import JsonViewer
|
from components.debugger.components.JsonViewer import JsonViewer
|
||||||
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID
|
from components.debugger.constants import DBENGINE_DEBUGGER_INSTANCE_ID, DEBUGGER_ROLE
|
||||||
from components_helpers import mk_ellipsis, mk_accordion_section
|
from components_helpers import mk_ellipsis, mk_accordion_section
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
from core.utils import get_unique_id
|
from core.utils import get_unique_id
|
||||||
@@ -20,7 +20,7 @@ logger = logging.getLogger("Debugger")
|
|||||||
|
|
||||||
class Debugger(BaseComponent):
|
class Debugger(BaseComponent):
|
||||||
def __init__(self, session, _id, settings_manager, tabs_manager):
|
def __init__(self, session, _id, settings_manager, tabs_manager):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, DEBUGGER_ROLE)
|
||||||
self.settings_manager = settings_manager
|
self.settings_manager = settings_manager
|
||||||
self.db_engine = settings_manager.get_db_engine()
|
self.db_engine = settings_manager.get_db_engine()
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
@@ -104,7 +104,7 @@ class Debugger(BaseComponent):
|
|||||||
self.tabs_manager.add_tab(title, content, key=tab_key)
|
self.tabs_manager.add_tab(title, content, key=tab_key)
|
||||||
return self.tabs_manager.render()
|
return self.tabs_manager.render()
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
|
mk_ellipsis("Debugger", cls="text-sm font-medium mb-1"),
|
||||||
|
|||||||
@@ -315,8 +315,6 @@ class JsonViewer(BaseComponent):
|
|||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
return hash(self._key) if self._key is not None else super().__hash__()
|
return hash(self._key) if self._key is not None else super().__hash__()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_quotes(value: str):
|
def add_quotes(value: str):
|
||||||
if '"' in value and "'" in value:
|
if '"' in value and "'" in value:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
|
DBENGINE_DEBUGGER_INSTANCE_ID = "debugger"
|
||||||
|
DEBUGGER_ROLE = "debugger"
|
||||||
ROUTE_ROOT = "/debugger"
|
ROUTE_ROOT = "/debugger"
|
||||||
|
|
||||||
INDENT_SIZE = 20
|
INDENT_SIZE = 20
|
||||||
|
|||||||
18
src/components/jsonviewer/JsonViewerApp.py
Normal file
18
src/components/jsonviewer/JsonViewerApp.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from fasthtml.fastapp import fast_app
|
||||||
|
|
||||||
|
from components.jsonviewer.constants import Routes
|
||||||
|
from core.instance_manager import debug_session, InstanceManager
|
||||||
|
|
||||||
|
jsonviwer_app, rt = fast_app()
|
||||||
|
|
||||||
|
logger = logging.getLogger("JsonViewer")
|
||||||
|
|
||||||
|
|
||||||
|
@rt(Routes.Fold)
|
||||||
|
def post(session, _id: str, node_id: str, folding: str):
|
||||||
|
logger.debug(f"Entering {Routes.Fold} with args {debug_session(session)}, {_id=}, {node_id=}, {folding=}")
|
||||||
|
instance = InstanceManager.get(session, _id)
|
||||||
|
instance.set_node_folding(node_id, folding)
|
||||||
|
return instance.render_node(node_id)
|
||||||
449
src/components/jsonviewer/Readme.md
Normal file
449
src/components/jsonviewer/Readme.md
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
# JsonViewer Hooks System - Technical Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The JsonViewer Hooks System provides a flexible, event-driven mechanism to customize the behavior and rendering of JSON nodes. Using a fluent builder pattern, developers can define conditions and actions that trigger during specific events in the JsonViewer lifecycle.
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Hook Architecture
|
||||||
|
|
||||||
|
A **Hook** consists of three components:
|
||||||
|
- **Event Type**: When the hook should trigger (`on_render`, `on_click`, etc.)
|
||||||
|
- **Conditions**: What criteria must be met for the hook to execute
|
||||||
|
- **Executor**: The function that runs when conditions are met
|
||||||
|
|
||||||
|
### HookContext
|
||||||
|
|
||||||
|
The `HookContext` object provides rich information about the current node being processed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class HookContext:
|
||||||
|
key: Any # The key of the current node
|
||||||
|
node: Any # The node object itself
|
||||||
|
helper: Any # JsonViewer helper utilities
|
||||||
|
jsonviewer: Any # Reference to the parent JsonViewer instance
|
||||||
|
json_path: str # Full JSON path (e.g., "users.0.name")
|
||||||
|
parent_node: Any # Reference to the parent node
|
||||||
|
metadata: dict # Additional metadata storage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Utility Methods:**
|
||||||
|
- `get_node_type()`: Returns the string representation of the node type
|
||||||
|
- `get_value()`: Gets the actual value from the node
|
||||||
|
- `is_leaf_node()`: Checks if the node has no children
|
||||||
|
|
||||||
|
## HookBuilder API
|
||||||
|
|
||||||
|
### Creating a Hook
|
||||||
|
|
||||||
|
Use the `HookBuilder` class with method chaining to create hooks:
|
||||||
|
|
||||||
|
```python
|
||||||
|
hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_long_text(100)
|
||||||
|
.execute(my_custom_renderer))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Types
|
||||||
|
|
||||||
|
#### `on_render()`
|
||||||
|
Triggers during node rendering, allowing custom rendering logic.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def custom_text_renderer(context):
|
||||||
|
value = context.get_value()
|
||||||
|
return Span(f"Custom: {value}", cls="custom-text")
|
||||||
|
|
||||||
|
text_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type(str)
|
||||||
|
.execute(custom_text_renderer))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `on_click()`
|
||||||
|
Triggers when a node is clicked.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def handle_click(context):
|
||||||
|
print(f"Clicked on: {context.json_path}")
|
||||||
|
return None # No rendering change
|
||||||
|
|
||||||
|
click_hook = (HookBuilder()
|
||||||
|
.on_click()
|
||||||
|
.when_editable()
|
||||||
|
.requires_modification()
|
||||||
|
.execute(handle_click))
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `on_hover()` / `on_focus()`
|
||||||
|
Triggers on hover or focus events respectively.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def show_tooltip(context):
|
||||||
|
return Div(f"Path: {context.json_path}", cls="tooltip")
|
||||||
|
|
||||||
|
hover_hook = (HookBuilder()
|
||||||
|
.on_hover()
|
||||||
|
.when_type(str)
|
||||||
|
.execute(show_tooltip))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditions
|
||||||
|
|
||||||
|
Conditions determine when a hook should execute. Multiple conditions can be chained, and all must be satisfied.
|
||||||
|
|
||||||
|
### `when_type(target_type)`
|
||||||
|
Matches nodes with values of a specific type.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Hook for string values only
|
||||||
|
string_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type(str)
|
||||||
|
.execute(string_formatter))
|
||||||
|
|
||||||
|
# Hook for numeric values
|
||||||
|
number_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type((int, float)) # Accepts tuple of types
|
||||||
|
.execute(number_formatter))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_key(key_pattern)`
|
||||||
|
Matches nodes based on their key.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Exact key match
|
||||||
|
email_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_key("email")
|
||||||
|
.execute(email_formatter))
|
||||||
|
|
||||||
|
# Function-based key matching
|
||||||
|
def is_id_key(key):
|
||||||
|
return str(key).endswith("_id")
|
||||||
|
|
||||||
|
id_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_key(is_id_key)
|
||||||
|
.execute(id_formatter))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_value(target_value=None, predicate=None)`
|
||||||
|
Matches nodes based on their actual value.
|
||||||
|
|
||||||
|
**Exact value matching:**
|
||||||
|
```python
|
||||||
|
# Highlight error status
|
||||||
|
error_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_value("ERROR")
|
||||||
|
.execute(lambda ctx: Span(ctx.get_value(), cls="error-status")))
|
||||||
|
|
||||||
|
# Special handling for null values
|
||||||
|
null_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_value(None)
|
||||||
|
.execute(lambda ctx: Span("N/A", cls="null-value")))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Predicate-based matching:**
|
||||||
|
```python
|
||||||
|
# URLs as clickable links
|
||||||
|
url_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith("http"))
|
||||||
|
.execute(lambda ctx: A(ctx.get_value(), href=ctx.get_value(), target="_blank")))
|
||||||
|
|
||||||
|
# Large numbers formatting
|
||||||
|
large_number_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_value(predicate=lambda x: isinstance(x, (int, float)) and x > 1000)
|
||||||
|
.execute(lambda ctx: Span(f"{x:,}", cls="large-number")))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_path(path_pattern)`
|
||||||
|
Matches nodes based on their JSON path using regex.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Match all user names
|
||||||
|
user_name_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_path(r"users\.\d+\.name")
|
||||||
|
.execute(user_name_formatter))
|
||||||
|
|
||||||
|
# Match any nested configuration
|
||||||
|
config_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_path(r".*\.config\..*")
|
||||||
|
.execute(config_formatter))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_long_text(threshold=100)`
|
||||||
|
Matches string values longer than the specified threshold.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def text_truncator(context):
|
||||||
|
value = context.get_value()
|
||||||
|
truncated = value[:100] + "..."
|
||||||
|
return Div(
|
||||||
|
Span(truncated, cls="truncated-text"),
|
||||||
|
Button("Show more", cls="expand-btn"),
|
||||||
|
cls="long-text-container"
|
||||||
|
)
|
||||||
|
|
||||||
|
long_text_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_long_text(100)
|
||||||
|
.execute(text_truncator))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_editable(editable_paths=None, editable_types=None)`
|
||||||
|
Matches nodes that should be editable.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def inline_editor(context):
|
||||||
|
value = context.get_value()
|
||||||
|
return Input(
|
||||||
|
value=str(value),
|
||||||
|
type="text" if isinstance(value, str) else "number",
|
||||||
|
cls="inline-editor",
|
||||||
|
**{"data-path": context.json_path}
|
||||||
|
)
|
||||||
|
|
||||||
|
editable_hook = (HookBuilder()
|
||||||
|
.on_click()
|
||||||
|
.when_editable(
|
||||||
|
editable_paths=["user.name", "user.email"],
|
||||||
|
editable_types=[str, int, float]
|
||||||
|
)
|
||||||
|
.requires_modification()
|
||||||
|
.execute(inline_editor))
|
||||||
|
```
|
||||||
|
|
||||||
|
### `when_custom(condition)`
|
||||||
|
Use custom condition objects or callable predicates for complex logic.
|
||||||
|
|
||||||
|
The `when_custom()` method accepts either:
|
||||||
|
- **Condition instances**: Objects that inherit from the `Condition` base class
|
||||||
|
- **Callable predicates**: Functions that take a `HookContext` parameter and return a boolean
|
||||||
|
|
||||||
|
When a callable is provided, it's automatically wrapped in a `PredicateCondition` class internally.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class BusinessLogicCondition(Condition):
|
||||||
|
def evaluate(self, context):
|
||||||
|
# Complex business logic here
|
||||||
|
return (context.key == "status" and
|
||||||
|
context.get_value() in ["pending", "processing"])
|
||||||
|
|
||||||
|
custom_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_custom(BusinessLogicCondition())
|
||||||
|
.execute(status_renderer))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining Conditions
|
||||||
|
|
||||||
|
### Multiple Conditions (AND Logic)
|
||||||
|
Chain multiple conditions - all must be satisfied:
|
||||||
|
|
||||||
|
```python
|
||||||
|
complex_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type(str)
|
||||||
|
.when_key("description")
|
||||||
|
.when_long_text(50)
|
||||||
|
.execute(description_formatter))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Composite Conditions
|
||||||
|
Use `when_all()` and `when_any()` for explicit logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# AND logic
|
||||||
|
strict_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_all([
|
||||||
|
WhenType(str),
|
||||||
|
WhenLongText(100),
|
||||||
|
WhenKey("content")
|
||||||
|
])
|
||||||
|
.execute(content_formatter))
|
||||||
|
|
||||||
|
# OR logic
|
||||||
|
flexible_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_any([
|
||||||
|
WhenKey("title"),
|
||||||
|
WhenKey("name"),
|
||||||
|
WhenKey("label")
|
||||||
|
])
|
||||||
|
.execute(title_formatter))
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Modification
|
||||||
|
|
||||||
|
Use `requires_modification()` to indicate that the hook will modify the application state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def save_edit(context):
|
||||||
|
new_value = get_new_value_from_ui() # Implementation specific
|
||||||
|
# Update the actual data
|
||||||
|
context.jsonviewer.update_value(context.json_path, new_value)
|
||||||
|
return success_indicator()
|
||||||
|
|
||||||
|
edit_hook = (HookBuilder()
|
||||||
|
.on_click()
|
||||||
|
.when_editable()
|
||||||
|
.requires_modification()
|
||||||
|
.execute(save_edit))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Examples
|
||||||
|
|
||||||
|
### Example 1: Enhanced Text Display
|
||||||
|
|
||||||
|
```python
|
||||||
|
def enhanced_text_renderer(context):
|
||||||
|
value = context.get_value()
|
||||||
|
|
||||||
|
# Truncate long text
|
||||||
|
if len(value) > 100:
|
||||||
|
display_value = value[:100] + "..."
|
||||||
|
tooltip = value # Full text as tooltip
|
||||||
|
else:
|
||||||
|
display_value = value
|
||||||
|
tooltip = None
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
display_value,
|
||||||
|
cls="enhanced-text",
|
||||||
|
title=tooltip,
|
||||||
|
**{"data-full-text": value}
|
||||||
|
)
|
||||||
|
|
||||||
|
text_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type(str)
|
||||||
|
.when_value(predicate=lambda x: len(x) > 20)
|
||||||
|
.execute(enhanced_text_renderer))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Interactive Email Fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
def email_renderer(context):
|
||||||
|
email = context.get_value()
|
||||||
|
return Div(
|
||||||
|
A(f"mailto:{email}", href=f"mailto:{email}", cls="email-link"),
|
||||||
|
Button("Copy", cls="copy-btn", **{"data-clipboard": email}),
|
||||||
|
cls="email-container"
|
||||||
|
)
|
||||||
|
|
||||||
|
email_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_key("email")
|
||||||
|
.when_value(predicate=lambda x: "@" in str(x))
|
||||||
|
.execute(email_renderer))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Status Badge System
|
||||||
|
|
||||||
|
```python
|
||||||
|
def status_badge(context):
|
||||||
|
status = context.get_value().lower()
|
||||||
|
|
||||||
|
badge_classes = {
|
||||||
|
"active": "badge-success",
|
||||||
|
"pending": "badge-warning",
|
||||||
|
"error": "badge-danger",
|
||||||
|
"inactive": "badge-secondary"
|
||||||
|
}
|
||||||
|
|
||||||
|
css_class = badge_classes.get(status, "badge-default")
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
status.title(),
|
||||||
|
cls=f"status-badge {css_class}"
|
||||||
|
)
|
||||||
|
|
||||||
|
status_hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_key("status")
|
||||||
|
.when_value(predicate=lambda x: str(x).lower() in ["active", "pending", "error", "inactive"])
|
||||||
|
.execute(status_badge))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with JsonViewer
|
||||||
|
|
||||||
|
### Adding Hooks to JsonViewer
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create your hooks
|
||||||
|
hooks = [
|
||||||
|
text_hook,
|
||||||
|
email_hook,
|
||||||
|
status_hook
|
||||||
|
]
|
||||||
|
|
||||||
|
# Initialize JsonViewer with hooks
|
||||||
|
viewer = JsonViewer(
|
||||||
|
session=session,
|
||||||
|
_id="my-viewer",
|
||||||
|
data=my_json_data,
|
||||||
|
hooks=hooks
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Factory Functions
|
||||||
|
|
||||||
|
Create reusable hook factories for common patterns:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def create_url_link_hook():
|
||||||
|
"""Factory for URL link rendering"""
|
||||||
|
def url_renderer(context):
|
||||||
|
url = context.get_value()
|
||||||
|
return A(url, href=url, target="_blank", cls="url-link")
|
||||||
|
|
||||||
|
return (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_value(predicate=lambda x: isinstance(x, str) and x.startswith(("http://", "https://")))
|
||||||
|
.execute(url_renderer))
|
||||||
|
|
||||||
|
def create_currency_formatter_hook(currency_symbol="$"):
|
||||||
|
"""Factory for currency formatting"""
|
||||||
|
def currency_renderer(context):
|
||||||
|
amount = context.get_value()
|
||||||
|
return Span(f"{currency_symbol}{amount:,.2f}", cls="currency-amount")
|
||||||
|
|
||||||
|
return (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_type((int, float))
|
||||||
|
.when_key(lambda k: "price" in str(k).lower() or "amount" in str(k).lower())
|
||||||
|
.execute(currency_renderer))
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
hooks = [
|
||||||
|
create_url_link_hook(),
|
||||||
|
create_currency_formatter_hook("€"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Specific Conditions**: Use the most specific conditions possible to avoid unintended matches
|
||||||
|
2. **Performance**: Avoid complex predicates in `when_value()` for large datasets
|
||||||
|
3. **Error Handling**: Include error handling in your executor functions
|
||||||
|
4. **Reusability**: Create factory functions for common hook patterns
|
||||||
|
5. **Testing**: Test hooks with various data structures to ensure they work as expected
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Hooks are evaluated in the order they are added to the JsonViewer
|
||||||
|
- Only the first matching hook for each event type will execute per node
|
||||||
|
- Use simple conditions when possible to minimize evaluation time
|
||||||
|
- Consider the size of your JSON data when using regex in `when_path()`
|
||||||
0
src/components/jsonviewer/__init__.py
Normal file
0
src/components/jsonviewer/__init__.py
Normal file
0
src/components/jsonviewer/assets/__init__.py
Normal file
0
src/components/jsonviewer/assets/__init__.py
Normal file
27
src/components/jsonviewer/assets/icons.py
Normal file
27
src/components/jsonviewer/assets/icons.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from fastcore.basics import NotStr
|
||||||
|
|
||||||
|
# Fluent CaretRight20Filled
|
||||||
|
icon_collapsed = NotStr("""<svg name="collapsed" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M7 14.204a1 1 0 0 0 1.628.778l4.723-3.815a1.5 1.5 0 0 0 0-2.334L8.628 5.02A1 1 0 0 0 7 5.797v8.407z" fill="currentColor">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</svg>""")
|
||||||
|
|
||||||
|
# Fluent CaretDown20Filled
|
||||||
|
icon_expanded = NotStr("""<svg name="expanded" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 20 20">
|
||||||
|
<g fill="none">
|
||||||
|
<path d="M5.797 7a1 1 0 0 0-.778 1.628l3.814 4.723a1.5 1.5 0 0 0 2.334 0l3.815-4.723A1 1 0 0 0 14.204 7H5.797z" fill="currentColor">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
</svg>""")
|
||||||
|
|
||||||
|
icon_class = NotStr("""
|
||||||
|
<svg name="expanded" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g fill="none" stroke="currentColor" stroke-width="1.5" >
|
||||||
|
<polygon points="5,2 2,8 8,8" />
|
||||||
|
<rect x="12" y="2" width="6" height="6"/>
|
||||||
|
<circle cx="5" cy="15" r="3" />
|
||||||
|
<polygon points="11.5,15 15,11.5 18.5,15 15,18.5" />
|
||||||
|
</g>
|
||||||
|
</svg>""")
|
||||||
23
src/components/jsonviewer/commands.py
Normal file
23
src/components/jsonviewer/commands.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from components.jsonviewer.constants import ROUTE_ROOT, Routes
|
||||||
|
|
||||||
|
|
||||||
|
class JsonViewerCommands:
|
||||||
|
def __init__(self, owner):
|
||||||
|
self._owner = owner
|
||||||
|
self._id = owner.get_id()
|
||||||
|
|
||||||
|
def fold(self, node_id: str, folding: str):
|
||||||
|
return {
|
||||||
|
"hx-post": f"{ROUTE_ROOT}{Routes.Fold}",
|
||||||
|
"hx-target": f"#{node_id}",
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
"hx-vals": f'{{"_id": "{self._id}", "node_id": "{node_id}", "folding": "{folding}"}}',
|
||||||
|
}
|
||||||
|
|
||||||
|
def open_digest(self, user_id, digest):
|
||||||
|
return {
|
||||||
|
"hx-post": f"{ROUTE_ROOT}{Routes.DbEngineDigest}",
|
||||||
|
"hx-target": f"#{self._owner.get_owner().tabs_manager.get_id()}",
|
||||||
|
"hx-swap": "outerHTML",
|
||||||
|
"hx-vals": f'{{"_id": "{self._id}", "user_id": "{user_id}", "digest": "{digest}"}}',
|
||||||
|
}
|
||||||
544
src/components/jsonviewer/components/JsonViewer.py
Normal file
544
src/components/jsonviewer/components/JsonViewer.py
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from fasthtml.components import *
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from components.BaseComponent import BaseComponentMultipleInstance
|
||||||
|
from components.datagrid_new.components.DataGrid import DataGrid
|
||||||
|
from components.jsonviewer.assets.icons import icon_expanded, icon_collapsed, icon_class
|
||||||
|
from components.jsonviewer.commands import JsonViewerCommands
|
||||||
|
from components.jsonviewer.constants import NODES_KEYS_TO_NOT_EXPAND, NODE_OBJECT, INDENT_SIZE, MAX_TEXT_LENGTH
|
||||||
|
from components.jsonviewer.hooks import HookManager, HookContext, EventType, Hook
|
||||||
|
from components_helpers import apply_boundaries
|
||||||
|
from core.serializer import TAG_OBJECT
|
||||||
|
|
||||||
|
|
||||||
|
class FoldingMode:
|
||||||
|
COLLAPSE = "collapse"
|
||||||
|
EXPAND = "expand"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Node:
|
||||||
|
value: Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValueNode(Node):
|
||||||
|
hint: str = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListNode(Node):
|
||||||
|
node_id: str
|
||||||
|
level: int
|
||||||
|
children: list[Node] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DictNode(Node):
|
||||||
|
node_id: str
|
||||||
|
level: int
|
||||||
|
children: dict[str, Node] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class NodeIdGenerator:
|
||||||
|
"""Manages unique node ID generation"""
|
||||||
|
|
||||||
|
def __init__(self, base_id: str):
|
||||||
|
self.base_id = base_id
|
||||||
|
self._counter = -1
|
||||||
|
|
||||||
|
def generate(self) -> str:
|
||||||
|
self._counter += 1
|
||||||
|
return f"{self.base_id}-{self._counter}"
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._counter = -1
|
||||||
|
|
||||||
|
|
||||||
|
class FoldingManager:
|
||||||
|
"""Manages folding/unfolding state of nodes"""
|
||||||
|
|
||||||
|
# A little explanation on how the folding / unfolding work
|
||||||
|
# all the nodes are either fold or unfold... except when there are not !
|
||||||
|
# self._folding_mode keeps the current value (it's FoldingMode.COLLAPSE or FoldingMode.EXPAND
|
||||||
|
# self._nodes_to_track keeps track of the exceptions
|
||||||
|
# The idea is to minimize the memory usage
|
||||||
|
|
||||||
|
def __init__(self, default_mode: str = FoldingMode.COLLAPSE):
|
||||||
|
self._folding_mode = default_mode
|
||||||
|
self._nodes_to_track = set() # exceptions to the default mode
|
||||||
|
|
||||||
|
def set_folding_mode(self, mode: str):
|
||||||
|
"""Changes the global folding mode and clears exceptions"""
|
||||||
|
self._folding_mode = mode
|
||||||
|
self._nodes_to_track.clear()
|
||||||
|
|
||||||
|
def set_node_folding(self, node_id: str, folding: str):
|
||||||
|
"""Sets specific folding state for a node"""
|
||||||
|
if folding == self._folding_mode:
|
||||||
|
self._nodes_to_track.discard(node_id)
|
||||||
|
else:
|
||||||
|
self._nodes_to_track.add(node_id)
|
||||||
|
|
||||||
|
def must_expand(self, node: Node) -> Optional[bool]:
|
||||||
|
"""Determines if a node should be expanded"""
|
||||||
|
if not isinstance(node, (ListNode, DictNode)):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._folding_mode == FoldingMode.COLLAPSE:
|
||||||
|
return node.node_id in self._nodes_to_track
|
||||||
|
else:
|
||||||
|
return node.node_id not in self._nodes_to_track
|
||||||
|
|
||||||
|
def get_folding_mode(self) -> str:
|
||||||
|
return self._folding_mode
|
||||||
|
|
||||||
|
def get_nodes_to_track(self) -> set[str]:
|
||||||
|
return self._nodes_to_track
|
||||||
|
|
||||||
|
|
||||||
|
class NodeFactory:
|
||||||
|
"""Factory for creating nodes from data with JSON path tracking"""
|
||||||
|
|
||||||
|
def __init__(self, id_generator: NodeIdGenerator, folding_manager: FoldingManager):
|
||||||
|
self.id_generator = id_generator
|
||||||
|
self.folding_manager = folding_manager
|
||||||
|
self._nodes_by_id = {}
|
||||||
|
self._node_paths = {} # node_id -> json_path mapping
|
||||||
|
self._node_parents = {} # node_id -> parent_node mapping
|
||||||
|
|
||||||
|
def create_node(self, key: Any, data: Any, level: int = 0, json_path: str = "", parent_node: Any = None) -> Node:
|
||||||
|
"""Creates appropriate node type based on data with path tracking"""
|
||||||
|
if isinstance(data, list):
|
||||||
|
return self._create_list_node(key, data, level, json_path, parent_node)
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
return self._create_dict_node(key, data, level, json_path, parent_node)
|
||||||
|
else:
|
||||||
|
return self._create_value_node(key, data, json_path, parent_node)
|
||||||
|
|
||||||
|
def _create_list_node(self, key: Any, data: list, level: int, json_path: str, parent_node: Any) -> ListNode:
|
||||||
|
node_id = self.id_generator.generate()
|
||||||
|
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
|
||||||
|
self.folding_manager._nodes_to_track.add(node_id)
|
||||||
|
|
||||||
|
node = ListNode(data, node_id, level)
|
||||||
|
self._nodes_by_id[node_id] = (key, node)
|
||||||
|
self._node_paths[node_id] = json_path
|
||||||
|
self._node_parents[node_id] = parent_node
|
||||||
|
|
||||||
|
for index, item in enumerate(data):
|
||||||
|
child_path = f"{json_path}[{index}]" if json_path else f"[{index}]"
|
||||||
|
node.children.append(self.create_node(index, item, level + 1, child_path, node))
|
||||||
|
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _create_dict_node(self, key: Any, data: dict, level: int, json_path: str, parent_node: Any) -> DictNode:
|
||||||
|
node_id = self.id_generator.generate()
|
||||||
|
if level <= 1 and key not in NODES_KEYS_TO_NOT_EXPAND:
|
||||||
|
self.folding_manager._nodes_to_track.add(node_id)
|
||||||
|
|
||||||
|
node = DictNode(data, node_id, level)
|
||||||
|
self._nodes_by_id[node_id] = (key, node)
|
||||||
|
self._node_paths[node_id] = json_path
|
||||||
|
self._node_parents[node_id] = parent_node
|
||||||
|
|
||||||
|
for child_key, value in data.items():
|
||||||
|
child_path = f"{json_path}.{child_key}" if json_path else str(child_key)
|
||||||
|
node.children[child_key] = self.create_node(child_key, value, level + 1, child_path, node)
|
||||||
|
|
||||||
|
return node
|
||||||
|
|
||||||
|
def _create_value_node(self, key: Any, data: Any, json_path: str, parent_node: Any) -> ValueNode:
|
||||||
|
hint = NODE_OBJECT if key == TAG_OBJECT else None
|
||||||
|
node = ValueNode(data, hint)
|
||||||
|
# Value nodes don't have node_id, but we can still track their path for hooks
|
||||||
|
return node
|
||||||
|
|
||||||
|
def get_node_by_id(self, node_id: str) -> tuple[Any, Node]:
|
||||||
|
return self._nodes_by_id[node_id]
|
||||||
|
|
||||||
|
def get_node_path(self, node_id: str) -> str:
|
||||||
|
return self._node_paths.get(node_id, "")
|
||||||
|
|
||||||
|
def get_node_parent(self, node_id: str) -> Any:
|
||||||
|
return self._node_parents.get(node_id, None)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clears all stored nodes"""
|
||||||
|
self._nodes_by_id.clear()
|
||||||
|
self._node_paths.clear()
|
||||||
|
self._node_parents.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class JsonViewerHelper:
|
||||||
|
class_string = f"mmt-jsonviewer-string"
|
||||||
|
class_bool = f"mmt-jsonviewer-bool"
|
||||||
|
class_number = f"mmt-jsonviewer-number"
|
||||||
|
class_null = f"mmt-jsonviewer-null"
|
||||||
|
class_digest = f"mmt-jsonviewer-digest"
|
||||||
|
class_object = f"mmt-jsonviewer-object"
|
||||||
|
class_dataframe = f"mmt-jsonviewer-dataframe"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_sha256(_value):
|
||||||
|
return (isinstance(_value, str) and
|
||||||
|
len(_value) == 64 and
|
||||||
|
all(c in '0123456789abcdefABCDEF' for c in _value))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_quotes(value: str) -> str:
|
||||||
|
if '"' in value and "'" in value:
|
||||||
|
return f'"{value.replace("\"", "\\\"")}"'
|
||||||
|
elif '"' in value:
|
||||||
|
return f"'{value}'"
|
||||||
|
else:
|
||||||
|
return f'"{value}"'
|
||||||
|
|
||||||
|
|
||||||
|
class NodeRenderer:
|
||||||
|
"""Single class handling all node rendering with new hook system"""
|
||||||
|
|
||||||
|
def __init__(self, session,
|
||||||
|
jsonviewer_instance,
|
||||||
|
folding_manager: FoldingManager,
|
||||||
|
commands: JsonViewerCommands,
|
||||||
|
helper: JsonViewerHelper,
|
||||||
|
hook_manager: HookManager,
|
||||||
|
node_factory: NodeFactory):
|
||||||
|
self.session = session
|
||||||
|
self.jsonviewer = jsonviewer_instance
|
||||||
|
self.folding_manager = folding_manager
|
||||||
|
self.commands = commands
|
||||||
|
self.helper = helper
|
||||||
|
self.hook_manager = hook_manager
|
||||||
|
self.node_factory = node_factory
|
||||||
|
|
||||||
|
def render(self, key: Any, node: Node, json_path: str = "", parent_node: Any = None) -> Div:
|
||||||
|
"""Main rendering method for any node"""
|
||||||
|
must_expand = self.folding_manager.must_expand(node)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
self._create_folding_icon(node, must_expand),
|
||||||
|
Span(f'{key} : ') if key is not None else None,
|
||||||
|
self._render_value(key, node, must_expand, json_path, parent_node),
|
||||||
|
style=f"margin-left: {INDENT_SIZE}px;",
|
||||||
|
id=getattr(node, "node_id", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_folding_icon(self, node: Node, must_expand: Optional[bool]) -> Optional[Span]:
|
||||||
|
"""Creates folding/unfolding icon"""
|
||||||
|
if must_expand is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
icon_expanded if must_expand else icon_collapsed,
|
||||||
|
cls="icon-16-inline mmt-jsonviewer-folding",
|
||||||
|
style=f"margin-left: -{INDENT_SIZE}px;",
|
||||||
|
**self.commands.fold(
|
||||||
|
node.node_id,
|
||||||
|
FoldingMode.COLLAPSE if must_expand else FoldingMode.EXPAND
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_value(self, key: Any,
|
||||||
|
node: Node,
|
||||||
|
must_expand: Optional[bool],
|
||||||
|
json_path: str = "",
|
||||||
|
parent_node: Any = None):
|
||||||
|
"""Renders the value part of a node with new hook system"""
|
||||||
|
if must_expand is False:
|
||||||
|
return self._render_collapsed_indicator(node)
|
||||||
|
|
||||||
|
# Create hook context
|
||||||
|
context = HookContext(
|
||||||
|
key=key,
|
||||||
|
node=node,
|
||||||
|
helper=self.helper,
|
||||||
|
jsonviewer=self.jsonviewer,
|
||||||
|
json_path=json_path,
|
||||||
|
parent_node=parent_node
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute render hooks and check for results
|
||||||
|
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, context)
|
||||||
|
|
||||||
|
# If any hook returned a result, use the first one
|
||||||
|
if hook_results:
|
||||||
|
# Filter out None results
|
||||||
|
valid_results = [result for result in hook_results if result is not None]
|
||||||
|
if valid_results:
|
||||||
|
return valid_results[0]
|
||||||
|
|
||||||
|
# No hooks matched or returned results, use default rendering
|
||||||
|
if isinstance(node, DictNode):
|
||||||
|
return self._render_dict_node(key, node)
|
||||||
|
elif isinstance(node, ListNode):
|
||||||
|
return self._render_list_node(key, node)
|
||||||
|
else:
|
||||||
|
return self._render_value_node(key, node)
|
||||||
|
|
||||||
|
def _render_collapsed_indicator(self, node: Node) -> Span:
|
||||||
|
"""Renders collapsed indicator"""
|
||||||
|
indicator = "[...]" if isinstance(node, ListNode) else "{...}"
|
||||||
|
return Span(
|
||||||
|
indicator,
|
||||||
|
id=node.node_id,
|
||||||
|
**self.commands.fold(node.node_id, FoldingMode.EXPAND)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_dict_node(self, key: Any, node: DictNode) -> Span:
|
||||||
|
"""Renders dictionary node"""
|
||||||
|
children_elements = []
|
||||||
|
base_path = self.node_factory.get_node_path(node.node_id)
|
||||||
|
|
||||||
|
for child_key, child_node in node.children.items():
|
||||||
|
child_path = f"{base_path}.{child_key}" if base_path else str(child_key)
|
||||||
|
children_elements.append(self.render(child_key, child_node, child_path, node))
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
"{",
|
||||||
|
*children_elements,
|
||||||
|
Div("}"),
|
||||||
|
id=node.node_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_list_node(self, key: Any, node: ListNode) -> Span:
|
||||||
|
"""Renders list node"""
|
||||||
|
if self._should_render_list_as_grid(key, node):
|
||||||
|
return self._render_list_as_grid(key, node)
|
||||||
|
else:
|
||||||
|
return self._render_list_as_array(key, node)
|
||||||
|
|
||||||
|
def _should_render_list_as_grid(self, key: Any, node: ListNode) -> bool:
|
||||||
|
"""Determines if list should be rendered as grid"""
|
||||||
|
if len(node.children) == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
sample_node = node.children[0]
|
||||||
|
sample_value = sample_node.value
|
||||||
|
|
||||||
|
if sample_value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
type_ = type(sample_value)
|
||||||
|
if type_ in (int, float, str, bool, list, dict, ValueNode):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if hooks handle this type (simplified check)
|
||||||
|
sample_context = HookContext(
|
||||||
|
key=key,
|
||||||
|
node=sample_node,
|
||||||
|
helper=self.helper,
|
||||||
|
jsonviewer=self.jsonviewer
|
||||||
|
)
|
||||||
|
hook_results = self.hook_manager.execute_hooks(EventType.RENDER, sample_context)
|
||||||
|
if hook_results and any(result is not None for result in hook_results):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return all(type(item.value) == type_ for item in node.children)
|
||||||
|
|
||||||
|
def _render_list_as_grid(self, key: Any, node: ListNode) -> Span:
|
||||||
|
"""Renders list as grid"""
|
||||||
|
type_ = type(node.children[0].value)
|
||||||
|
icon = icon_class
|
||||||
|
str_value = type_.__name__.split(".")[-1]
|
||||||
|
|
||||||
|
data = [child.value.__dict__ for child in node.children]
|
||||||
|
df = DataFrame(data)
|
||||||
|
dg = DataGrid(self.session)
|
||||||
|
dg.init_from_dataframe(df)
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
Span(
|
||||||
|
Span(icon, cls="icon-16-inline mr-1"),
|
||||||
|
Span(str_value),
|
||||||
|
cls="mmt-jsonviewer-object"
|
||||||
|
),
|
||||||
|
dg,
|
||||||
|
id=node.node_id
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_list_as_array(self, key: Any, node: ListNode) -> Span:
|
||||||
|
"""Renders list as array"""
|
||||||
|
children_elements = []
|
||||||
|
base_path = self.node_factory.get_node_path(node.node_id)
|
||||||
|
|
||||||
|
for index, child_node in enumerate(node.children):
|
||||||
|
child_path = f"{base_path}[{index}]" if base_path else f"[{index}]"
|
||||||
|
children_elements.append(self.render(index, child_node, child_path, node))
|
||||||
|
|
||||||
|
return Span(
|
||||||
|
"[",
|
||||||
|
*children_elements,
|
||||||
|
Div("]"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_value_node(self, key: Any, node: ValueNode) -> Span:
|
||||||
|
"""Renders value node"""
|
||||||
|
data_tooltip = None
|
||||||
|
htmx_params = {}
|
||||||
|
icon = None
|
||||||
|
|
||||||
|
if isinstance(node.value, bool): # order is important bool is an int in Python !
|
||||||
|
str_value = "true" if node.value else "false"
|
||||||
|
data_class = "bool"
|
||||||
|
elif isinstance(node.value, (int, float)):
|
||||||
|
str_value = str(node.value)
|
||||||
|
data_class = "number"
|
||||||
|
elif node.value is None:
|
||||||
|
str_value = "null"
|
||||||
|
data_class = "null"
|
||||||
|
elif self.helper.is_sha256(node.value):
|
||||||
|
str_value = str(node.value)
|
||||||
|
data_class = "digest"
|
||||||
|
htmx_params = self.commands.open_digest(self.jsonviewer.user_id, node.value)
|
||||||
|
elif node.hint == NODE_OBJECT:
|
||||||
|
icon = icon_class
|
||||||
|
str_value = node.value.split(".")[-1]
|
||||||
|
data_class = "object"
|
||||||
|
elif isinstance(node.value, DataFrame):
|
||||||
|
return self._render_dataframe_value(node.value)
|
||||||
|
else:
|
||||||
|
str_value, data_tooltip = self._format_string_value(node.value)
|
||||||
|
data_class = "string"
|
||||||
|
|
||||||
|
return self._create_value_span(str_value, data_class, icon, data_tooltip, htmx_params)
|
||||||
|
|
||||||
|
def _render_dataframe_value(self, dataframe: DataFrame) -> Any:
|
||||||
|
"""Renders DataFrame value"""
|
||||||
|
dg = DataGrid(self.session)
|
||||||
|
dg.init_from_dataframe(dataframe)
|
||||||
|
return dg
|
||||||
|
|
||||||
|
def _format_string_value(self, value: Any) -> tuple[str, Optional[str]]:
|
||||||
|
"""Formats string value with tooltip if too long"""
|
||||||
|
as_str = str(value)
|
||||||
|
if len(as_str) > MAX_TEXT_LENGTH:
|
||||||
|
return as_str[:MAX_TEXT_LENGTH] + "...", as_str
|
||||||
|
else:
|
||||||
|
return self.helper.add_quotes(as_str), None
|
||||||
|
|
||||||
|
def _create_value_span(self, str_value: str, data_class: str, icon: Any,
|
||||||
|
data_tooltip: Optional[str], htmx_params: dict) -> Span:
|
||||||
|
"""Creates the final Span element for a value"""
|
||||||
|
css_class = f"mmt-jsonviewer-{data_class}"
|
||||||
|
if data_tooltip:
|
||||||
|
css_class += " mmt-tooltip"
|
||||||
|
|
||||||
|
if icon:
|
||||||
|
return Span(
|
||||||
|
Span(icon, cls="icon-16-inline mr-1"),
|
||||||
|
Span(str_value, data_tooltip=data_tooltip, **htmx_params),
|
||||||
|
cls=css_class
|
||||||
|
)
|
||||||
|
|
||||||
|
return Span(str_value, cls=css_class, data_tooltip=data_tooltip, **htmx_params)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonViewer(BaseComponentMultipleInstance):
|
||||||
|
"""Main JsonViewer component with new hook system"""
|
||||||
|
|
||||||
|
COMPONENT_INSTANCE_ID = "Jsonviewer"
|
||||||
|
|
||||||
|
def __init__(self, session, _id, data=None, hooks: list[Hook] = None, key=None, boundaries=None):
|
||||||
|
super().__init__(session, _id)
|
||||||
|
self._key = key
|
||||||
|
self.data = data
|
||||||
|
self._boundaries = boundaries if boundaries else {"height": "600"}
|
||||||
|
self._commands = JsonViewerCommands(self)
|
||||||
|
|
||||||
|
# Initialize hook system (transparent to user)
|
||||||
|
self._hook_manager = HookManager()
|
||||||
|
if hooks:
|
||||||
|
self._hook_manager.add_hooks(hooks)
|
||||||
|
|
||||||
|
# Initialize helper components
|
||||||
|
self._helper = JsonViewerHelper()
|
||||||
|
self._id_generator = NodeIdGenerator(_id)
|
||||||
|
self._folding_manager = FoldingManager()
|
||||||
|
self._node_factory = NodeFactory(self._id_generator, self._folding_manager)
|
||||||
|
|
||||||
|
# Initialize renderer with hook manager
|
||||||
|
self._node_renderer = NodeRenderer(
|
||||||
|
session, self, self._folding_manager,
|
||||||
|
self._commands, self._helper, self._hook_manager, self._node_factory
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the initial node tree
|
||||||
|
self.node = self._node_factory.create_node(None, data)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> str:
|
||||||
|
"""Gets user_id from session or returns default"""
|
||||||
|
return getattr(self, '_user_id', getattr(self._session, 'user_id', 'default_user'))
|
||||||
|
|
||||||
|
def set_data(self, data):
|
||||||
|
"""Updates the data and recreates the node tree"""
|
||||||
|
self.data = data
|
||||||
|
self._id_generator.reset()
|
||||||
|
self._node_factory.clear()
|
||||||
|
self.node = self._node_factory.create_node(None, data)
|
||||||
|
|
||||||
|
def add_hook(self, hook: Hook):
|
||||||
|
"""Adds a single hook to the viewer"""
|
||||||
|
self._hook_manager.add_hook(hook)
|
||||||
|
|
||||||
|
def add_hooks(self, hooks: list[Hook]):
|
||||||
|
"""Adds multiple hooks to the viewer"""
|
||||||
|
self._hook_manager.add_hooks(hooks)
|
||||||
|
|
||||||
|
def clear_hooks(self):
|
||||||
|
"""Removes all hooks from the viewer"""
|
||||||
|
self._hook_manager.clear_hooks()
|
||||||
|
|
||||||
|
def set_node_folding(self, node_id: str, folding: str):
|
||||||
|
"""Sets folding state for a specific node"""
|
||||||
|
self._folding_manager.set_node_folding(node_id, folding)
|
||||||
|
|
||||||
|
def render_node(self, node_id: str):
|
||||||
|
"""Renders a specific node by ID"""
|
||||||
|
key, node = self._node_factory.get_node_by_id(node_id)
|
||||||
|
json_path = self._node_factory.get_node_path(node_id)
|
||||||
|
parent_node = self._node_factory.get_node_parent(node_id)
|
||||||
|
return self._node_renderer.render(key, node, json_path, parent_node)
|
||||||
|
|
||||||
|
def set_folding_mode(self, folding_mode: str):
|
||||||
|
"""Sets global folding mode"""
|
||||||
|
self._folding_manager.set_folding_mode(folding_mode)
|
||||||
|
|
||||||
|
def get_folding_mode(self) -> str:
|
||||||
|
"""Gets current folding mode"""
|
||||||
|
return self._folding_manager.get_folding_mode()
|
||||||
|
|
||||||
|
def open_digest(self, user_id: str, digest: str):
|
||||||
|
"""Opens digest - preserves original method"""
|
||||||
|
return self._owner.db_engine_headers(user_id, digest)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
"""FastHTML rendering method"""
|
||||||
|
if self.node is None:
|
||||||
|
return Div("No data to display", cls="mmt-jsonviewer", id=f"{self._id}")
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
self._node_renderer.render(None, self.node, "", None),
|
||||||
|
id=f"{self._id}-root",
|
||||||
|
style="margin-left: 0px;"
|
||||||
|
),
|
||||||
|
cls="mmt-jsonviewer",
|
||||||
|
id=f"{self._id}",
|
||||||
|
**apply_boundaries(self._boundaries)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
"""Equality comparison"""
|
||||||
|
if type(other) is type(self):
|
||||||
|
return self._key is not None and self._key == other._key
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
"""Hash method"""
|
||||||
|
return hash(self._key) if self._key is not None else super().__hash__()
|
||||||
0
src/components/jsonviewer/components/__init__.py
Normal file
0
src/components/jsonviewer/components/__init__.py
Normal file
10
src/components/jsonviewer/constants.py
Normal file
10
src/components/jsonviewer/constants.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
ROUTE_ROOT = "/jsonviewer"
|
||||||
|
|
||||||
|
INDENT_SIZE = 20
|
||||||
|
MAX_TEXT_LENGTH = 50
|
||||||
|
|
||||||
|
NODE_OBJECT = "Object"
|
||||||
|
NODES_KEYS_TO_NOT_EXPAND = ["Dataframe", "__parent__"]
|
||||||
|
|
||||||
|
class Routes:
|
||||||
|
Fold = "/fold"
|
||||||
386
src/components/jsonviewer/hooks.py
Normal file
386
src/components/jsonviewer/hooks.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class EventType(Enum):
|
||||||
|
RENDER = "render"
|
||||||
|
CLICK = "click"
|
||||||
|
HOVER = "hover"
|
||||||
|
FOCUS = "focus"
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultEditableTypes:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HookContext:
|
||||||
|
"""Enhanced context object passed to hook executors"""
|
||||||
|
|
||||||
|
def __init__(self, key: Any, node: Any, helper: Any, jsonviewer: Any,
|
||||||
|
json_path: str = None, parent_node: Any = None):
|
||||||
|
self.key = key
|
||||||
|
self.node = node
|
||||||
|
self.helper = helper
|
||||||
|
self.jsonviewer = jsonviewer
|
||||||
|
self.json_path = json_path or ""
|
||||||
|
self.parent_node = parent_node
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
|
def get_node_type(self) -> str:
|
||||||
|
"""Returns string representation of node type"""
|
||||||
|
if hasattr(self.node, '__class__'):
|
||||||
|
return self.node.__class__.__name__
|
||||||
|
return type(self.node.value).__name__ if hasattr(self.node, 'value') else "unknown"
|
||||||
|
|
||||||
|
def get_value(self) -> Any:
|
||||||
|
"""Gets the actual value from the node"""
|
||||||
|
return getattr(self.node, 'value', self.node)
|
||||||
|
|
||||||
|
def is_leaf_node(self) -> bool:
|
||||||
|
"""Checks if this is a leaf node (no children)"""
|
||||||
|
return not hasattr(self.node, 'children') or not self.node.children
|
||||||
|
|
||||||
|
|
||||||
|
class Condition(ABC):
|
||||||
|
"""Base class for all conditions"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WhenLongText(Condition):
|
||||||
|
"""Condition: text length > threshold"""
|
||||||
|
|
||||||
|
def __init__(self, threshold: int = 100):
|
||||||
|
self.threshold = threshold
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
value = context.get_value()
|
||||||
|
return isinstance(value, str) and len(value) > self.threshold
|
||||||
|
|
||||||
|
|
||||||
|
class WhenEditable(Condition):
|
||||||
|
"""Condition: node is editable (configurable logic)"""
|
||||||
|
|
||||||
|
def __init__(self, editable_paths: list[str] = None, editable_types: list[type] = DefaultEditableTypes):
|
||||||
|
self.editable_paths = set(editable_paths or [])
|
||||||
|
if editable_types is None:
|
||||||
|
self.editable_types = set()
|
||||||
|
else:
|
||||||
|
self.editable_types = set([str, int, float, bool] if editable_types is DefaultEditableTypes else editable_types)
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
# Check if path is in editable paths
|
||||||
|
if self.editable_paths and context.json_path in self.editable_paths:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if type is editable
|
||||||
|
value = context.get_value()
|
||||||
|
return type(value) in self.editable_types and context.is_leaf_node()
|
||||||
|
|
||||||
|
|
||||||
|
class WhenType(Condition):
|
||||||
|
"""Condition: node value is of specific type"""
|
||||||
|
|
||||||
|
def __init__(self, target_type: type):
|
||||||
|
self.target_type = target_type
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
value = context.get_value()
|
||||||
|
return isinstance(value, self.target_type)
|
||||||
|
|
||||||
|
|
||||||
|
class WhenKey(Condition):
|
||||||
|
"""Condition: node key matches pattern"""
|
||||||
|
|
||||||
|
def __init__(self, key_pattern: Any):
|
||||||
|
self.key_pattern = key_pattern
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
if callable(self.key_pattern):
|
||||||
|
return self.key_pattern(context.key)
|
||||||
|
return context.key == self.key_pattern
|
||||||
|
|
||||||
|
|
||||||
|
class WhenPath(Condition):
|
||||||
|
"""Condition: JSON path matches pattern"""
|
||||||
|
|
||||||
|
def __init__(self, path_pattern: str):
|
||||||
|
self.path_pattern = path_pattern
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
import re
|
||||||
|
return bool(re.match(self.path_pattern, context.json_path))
|
||||||
|
|
||||||
|
|
||||||
|
class WhenValue(Condition):
|
||||||
|
"""Condition: node value matches specific value or predicate"""
|
||||||
|
|
||||||
|
def __init__(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
|
||||||
|
if target_value is not None and predicate is not None:
|
||||||
|
raise ValueError("Cannot specify both target_value and predicate")
|
||||||
|
if target_value is None and predicate is None:
|
||||||
|
raise ValueError("Must specify either target_value or predicate")
|
||||||
|
|
||||||
|
self.target_value = target_value
|
||||||
|
self.predicate = predicate
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
value = context.get_value()
|
||||||
|
|
||||||
|
if self.predicate:
|
||||||
|
return self.predicate(value)
|
||||||
|
else:
|
||||||
|
return value == self.target_value
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeCondition(Condition):
|
||||||
|
"""Allows combining conditions with AND/OR logic"""
|
||||||
|
|
||||||
|
def __init__(self, conditions: list[Condition], operator: str = "AND"):
|
||||||
|
self.conditions = conditions
|
||||||
|
self.operator = operator.upper()
|
||||||
|
|
||||||
|
def evaluate(self, context: HookContext) -> bool:
|
||||||
|
if not self.conditions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
results = [condition.evaluate(context) for condition in self.conditions]
|
||||||
|
|
||||||
|
if self.operator == "AND":
|
||||||
|
return all(results)
|
||||||
|
elif self.operator == "OR":
|
||||||
|
return any(results)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown operator: {self.operator}")
|
||||||
|
|
||||||
|
|
||||||
|
class Hook:
|
||||||
|
"""Represents a complete hook with event, conditions, and executor"""
|
||||||
|
|
||||||
|
def __init__(self, event_type: EventType, conditions: list[Condition],
|
||||||
|
executor: Callable, requires_modification: bool = False):
|
||||||
|
self.event_type = event_type
|
||||||
|
self.conditions = conditions
|
||||||
|
self.executor = executor
|
||||||
|
self.requires_modification = requires_modification
|
||||||
|
|
||||||
|
def matches(self, event_type: EventType, context: HookContext) -> bool:
|
||||||
|
"""Checks if this hook should be executed for given event and context"""
|
||||||
|
if self.event_type != event_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return all(condition.evaluate(context) for condition in self.conditions)
|
||||||
|
|
||||||
|
def execute(self, context: HookContext) -> Any:
|
||||||
|
"""Executes the hook with given context"""
|
||||||
|
return self.executor(context)
|
||||||
|
|
||||||
|
|
||||||
|
class HookBuilder:
|
||||||
|
"""Builder class for creating hooks with fluent interface"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._event_type: Optional[EventType] = None
|
||||||
|
self._conditions: list[Condition] = []
|
||||||
|
self._executor: Optional[Callable] = None
|
||||||
|
self._requires_modification: bool = False
|
||||||
|
|
||||||
|
# Event specification methods
|
||||||
|
def on_render(self):
|
||||||
|
"""Hook will be triggered on render event"""
|
||||||
|
self._event_type = EventType.RENDER
|
||||||
|
return self
|
||||||
|
|
||||||
|
def on_click(self):
|
||||||
|
"""Hook will be triggered on click event"""
|
||||||
|
self._event_type = EventType.CLICK
|
||||||
|
return self
|
||||||
|
|
||||||
|
def on_hover(self):
|
||||||
|
"""Hook will be triggered on hover event"""
|
||||||
|
self._event_type = EventType.HOVER
|
||||||
|
return self
|
||||||
|
|
||||||
|
def on_focus(self):
|
||||||
|
"""Hook will be triggered on focus event"""
|
||||||
|
self._event_type = EventType.FOCUS
|
||||||
|
return self
|
||||||
|
|
||||||
|
# Condition methods
|
||||||
|
def when_long_text(self, threshold: int = 100):
|
||||||
|
"""Add condition: text length > threshold"""
|
||||||
|
self._conditions.append(WhenLongText(threshold))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_editable(self, editable_paths: list[str] = None, editable_types: list[type] = None):
|
||||||
|
"""Add condition: node is editable"""
|
||||||
|
self._conditions.append(WhenEditable(editable_paths, editable_types))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_type(self, target_type: type):
|
||||||
|
"""Add condition: node value is of specific type"""
|
||||||
|
self._conditions.append(WhenType(target_type))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_key(self, key_pattern: Any):
|
||||||
|
"""Add condition: node key matches pattern"""
|
||||||
|
self._conditions.append(WhenKey(key_pattern))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_path(self, path_pattern: str):
|
||||||
|
"""Add condition: JSON path matches pattern"""
|
||||||
|
self._conditions.append(WhenPath(path_pattern))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_value(self, target_value: Any = None, predicate: Callable[[Any], bool] = None):
|
||||||
|
"""Add condition: node value matches specific value or predicate"""
|
||||||
|
self._conditions.append(WhenValue(target_value, predicate))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_custom(self, condition):
|
||||||
|
"""Add custom condition (supports both Condition instances and predicate functions)."""
|
||||||
|
if callable(condition) and not isinstance(condition, Condition):
|
||||||
|
# Wrap the predicate function in a Condition class
|
||||||
|
class PredicateCondition(Condition):
|
||||||
|
def __init__(self, predicate):
|
||||||
|
self.predicate = predicate
|
||||||
|
|
||||||
|
def evaluate(self, context):
|
||||||
|
return self.predicate(context)
|
||||||
|
|
||||||
|
condition = PredicateCondition(condition) # Pass the function to the wrapper
|
||||||
|
|
||||||
|
elif not isinstance(condition, Condition):
|
||||||
|
raise ValueError("when_custom expects a Condition instance or a callable predicate.")
|
||||||
|
|
||||||
|
self._conditions.append(condition)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_all(self, conditions: list[Condition]):
|
||||||
|
"""Add composite condition with AND logic"""
|
||||||
|
self._conditions.append(CompositeCondition(conditions, "AND"))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def when_any(self, conditions: list[Condition]):
|
||||||
|
"""Add composite condition with OR logic"""
|
||||||
|
self._conditions.append(CompositeCondition(conditions, "OR"))
|
||||||
|
return self
|
||||||
|
|
||||||
|
# Modification flag
|
||||||
|
def requires_modification(self):
|
||||||
|
"""Indicates this hook will modify the state"""
|
||||||
|
self._requires_modification = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
# Execution
|
||||||
|
def execute(self, executor: Callable) -> Hook:
|
||||||
|
"""Sets the executor function and builds the hook"""
|
||||||
|
if not self._event_type:
|
||||||
|
raise ValueError("Event type must be specified (use on_render(), on_click(), etc.)")
|
||||||
|
|
||||||
|
if not executor:
|
||||||
|
raise ValueError("Executor function must be provided")
|
||||||
|
|
||||||
|
self._executor = executor
|
||||||
|
|
||||||
|
return Hook(
|
||||||
|
event_type=self._event_type,
|
||||||
|
conditions=self._conditions,
|
||||||
|
executor=self._executor,
|
||||||
|
requires_modification=self._requires_modification
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HookManager:
|
||||||
|
"""Manages and executes hooks for JsonViewer"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.hooks: list[Hook] = []
|
||||||
|
|
||||||
|
def add_hook(self, hook: Hook):
|
||||||
|
"""Adds a hook to the manager"""
|
||||||
|
self.hooks.append(hook)
|
||||||
|
|
||||||
|
def add_hooks(self, hooks: list[Hook]):
|
||||||
|
"""Adds multiple hooks to the manager"""
|
||||||
|
self.hooks.extend(hooks)
|
||||||
|
|
||||||
|
def find_matching_hooks(self, event_type: EventType, context: HookContext) -> list[Hook]:
|
||||||
|
"""Finds all hooks that match the event and context"""
|
||||||
|
return [hook for hook in self.hooks if hook.matches(event_type, context)]
|
||||||
|
|
||||||
|
def execute_hooks(self, event_type: EventType, context: HookContext) -> list[Any]:
|
||||||
|
"""Executes all matching hooks and returns results"""
|
||||||
|
matching_hooks = self.find_matching_hooks(event_type, context)
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for hook in matching_hooks:
|
||||||
|
try:
|
||||||
|
result = hook.execute(context)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# If this hook requires modification, we might want to stop here
|
||||||
|
# or handle the modification differently
|
||||||
|
if hook.requires_modification:
|
||||||
|
# Could add callback to parent component here
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with other hooks
|
||||||
|
print(f"Hook execution error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def clear_hooks(self):
|
||||||
|
"""Removes all hooks"""
|
||||||
|
self.hooks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage and factory functions
|
||||||
|
def create_long_text_viewer_hook(threshold: int = 100) -> Hook:
|
||||||
|
"""Factory function for common long text viewer hook"""
|
||||||
|
|
||||||
|
def text_viewer_component(context: HookContext):
|
||||||
|
from fasthtml.components import Div, Span
|
||||||
|
|
||||||
|
value = context.get_value()
|
||||||
|
truncated = value[:threshold] + "..."
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Span(truncated, cls="text-truncated"),
|
||||||
|
Span("Click to expand", cls="expand-hint"),
|
||||||
|
cls="long-text-viewer"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_long_text(threshold)
|
||||||
|
.execute(text_viewer_component))
|
||||||
|
|
||||||
|
|
||||||
|
def create_inline_editor_hook(editable_paths: list[str] = None) -> Hook:
|
||||||
|
"""Factory function for common inline editor hook"""
|
||||||
|
|
||||||
|
def inline_editor_component(context: HookContext):
|
||||||
|
from fasthtml.components import Input, Div
|
||||||
|
|
||||||
|
value = context.get_value()
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Input(
|
||||||
|
value=str(value),
|
||||||
|
type="text" if isinstance(value, str) else "number",
|
||||||
|
cls="inline-editor"
|
||||||
|
),
|
||||||
|
cls="editable-field"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (HookBuilder()
|
||||||
|
.on_click()
|
||||||
|
.when_editable(editable_paths)
|
||||||
|
.requires_modification()
|
||||||
|
.execute(inline_editor_component))
|
||||||
@@ -8,7 +8,7 @@ from components.datagrid_new.components.DataGrid import DataGrid
|
|||||||
from components.form.components.MyForm import MyForm, FormField
|
from components.form.components.MyForm import MyForm, FormField
|
||||||
from components.repositories.assets.icons import icon_database, icon_table
|
from components.repositories.assets.icons import icon_database, icon_table
|
||||||
from components.repositories.commands import Commands
|
from components.repositories.commands import Commands
|
||||||
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes
|
from components.repositories.constants import REPOSITORIES_INSTANCE_ID, ROUTE_ROOT, Routes, REPOSITORIES_ROLE
|
||||||
from components.repositories.db_management import RepositoriesDbManager, Repository
|
from components.repositories.db_management import RepositoriesDbManager, Repository
|
||||||
from components_helpers import mk_icon, mk_ellipsis
|
from components_helpers import mk_icon, mk_ellipsis
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
@@ -19,7 +19,7 @@ logger = logging.getLogger("Repositories")
|
|||||||
|
|
||||||
class Repositories(BaseComponent):
|
class Repositories(BaseComponent):
|
||||||
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
def __init__(self, session: dict, _id: str, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id)
|
super().__init__(session, _id, REPOSITORIES_ROLE)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.tabs_manager = tabs_manager
|
self.tabs_manager = tabs_manager
|
||||||
self.db = RepositoriesDbManager(session, settings_manager)
|
self.db = RepositoriesDbManager(session, settings_manager)
|
||||||
@@ -121,7 +121,7 @@ class Repositories(BaseComponent):
|
|||||||
def refresh(self):
|
def refresh(self):
|
||||||
return self._mk_repositories(oob=True)
|
return self._mk_repositories(oob=True)
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),
|
mk_ellipsis("Repositories", cls="text-sm font-medium mb-1"),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
REPOSITORIES_INSTANCE_ID = "__Repositories__"
|
REPOSITORIES_INSTANCE_ID = "__Repositories__"
|
||||||
|
REPOSITORIES_ROLE = "repositories"
|
||||||
ROUTE_ROOT = "/repositories"
|
ROUTE_ROOT = "/repositories"
|
||||||
USERS_REPOSITORY_NAME = "__USERS___"
|
USERS_REPOSITORY_NAME = "__USERS___"
|
||||||
HOLIDAYS_TABLE_NAME = "__HOLIDAYS__"
|
HOLIDAYS_TABLE_NAME = "__HOLIDAYS__"
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class UndoRedo(BaseComponentSingleton):
|
|||||||
COMPONENT_INSTANCE_ID = UNDO_REDO_INSTANCE_ID
|
COMPONENT_INSTANCE_ID = UNDO_REDO_INSTANCE_ID
|
||||||
|
|
||||||
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
|
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id, settings_manager, tabs_manager)
|
super().__init__(session, _id, None, settings_manager, tabs_manager)
|
||||||
self.index = -1
|
self.index = -1
|
||||||
self.history = []
|
self.history = []
|
||||||
self._commands = UndoRedoCommandManager(self)
|
self._commands = UndoRedoCommandManager(self)
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_properties(self, oob=False):
|
def _mk_properties(self, oob=False):
|
||||||
return self.properties
|
return self.properties.__ft__(oob)
|
||||||
|
|
||||||
def _mk_jira_processor_details(self, component):
|
def _mk_jira_processor_details(self, component):
|
||||||
def _mk_option(name):
|
def _mk_option(name):
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from fasthtml.common import *
|
from fasthtml.common import *
|
||||||
|
|
||||||
from components.BaseComponent import BaseComponent
|
from components.BaseComponent import BaseComponent
|
||||||
from components.debugger.components.JsonViewer import JsonViewer
|
|
||||||
from components.entryselector.components.EntrySelector import EntrySelector
|
from components.entryselector.components.EntrySelector import EntrySelector
|
||||||
|
from components.jsonviewer.components.JsonViewer import JsonViewer
|
||||||
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
|
from components.workflows.constants import COMPONENT_TYPES, PROCESSOR_TYPES
|
||||||
from components_helpers import mk_dialog_buttons
|
from components_helpers import mk_dialog_buttons
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
@@ -33,15 +33,9 @@ class WorkflowDesignerProperties(BaseComponent):
|
|||||||
hooks={
|
hooks={
|
||||||
"on_entry_selected": self.on_entry_selector_changed})
|
"on_entry_selected": self.on_entry_selector_changed})
|
||||||
self._input_jsonviewer: JsonViewer = InstanceManager.new(self._session,
|
self._input_jsonviewer: JsonViewer = InstanceManager.new(self._session,
|
||||||
JsonViewer,
|
JsonViewer)
|
||||||
owner=self,
|
|
||||||
user_id=None,
|
|
||||||
data=None)
|
|
||||||
self._output_jsonviewer: JsonViewer = InstanceManager.new(self._session,
|
self._output_jsonviewer: JsonViewer = InstanceManager.new(self._session,
|
||||||
JsonViewer,
|
JsonViewer)
|
||||||
owner=self,
|
|
||||||
user_id=None,
|
|
||||||
data=None)
|
|
||||||
|
|
||||||
def set_entry_selector_data(self, data):
|
def set_entry_selector_data(self, data):
|
||||||
self.entry_selector.set_data(data)
|
self.entry_selector.set_data(data)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from components.BaseComponent import BaseComponentSingleton
|
|||||||
from components.form.components.MyForm import MyForm, FormField
|
from components.form.components.MyForm import MyForm, FormField
|
||||||
from components.workflows.commands import WorkflowsCommandManager
|
from components.workflows.commands import WorkflowsCommandManager
|
||||||
from components.workflows.components.WorkflowDesigner import WorkflowDesigner
|
from components.workflows.components.WorkflowDesigner import WorkflowDesigner
|
||||||
from components.workflows.constants import WORKFLOWS_INSTANCE_ID
|
from components.workflows.constants import WORKFLOWS_INSTANCE_ID, WORKFLOWS_ROLE
|
||||||
from components.workflows.db_management import WorkflowsDbManager, WorkflowsDesignerSettings
|
from components.workflows.db_management import WorkflowsDbManager, WorkflowsDesignerSettings
|
||||||
from components_helpers import mk_ellipsis, mk_icon
|
from components_helpers import mk_ellipsis, mk_icon
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
@@ -19,7 +19,7 @@ class Workflows(BaseComponentSingleton):
|
|||||||
COMPONENT_INSTANCE_ID = WORKFLOWS_INSTANCE_ID
|
COMPONENT_INSTANCE_ID = WORKFLOWS_INSTANCE_ID
|
||||||
|
|
||||||
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
|
def __init__(self, session, _id, settings_manager=None, tabs_manager=None):
|
||||||
super().__init__(session, _id, settings_manager, tabs_manager)
|
super().__init__(session, _id, WORKFLOWS_ROLE, settings_manager, tabs_manager)
|
||||||
self.commands = WorkflowsCommandManager(self)
|
self.commands = WorkflowsCommandManager(self)
|
||||||
self.db = WorkflowsDbManager(session, settings_manager)
|
self.db = WorkflowsDbManager(session, settings_manager)
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ class Workflows(BaseComponentSingleton):
|
|||||||
def refresh(self):
|
def refresh(self):
|
||||||
return self._mk_workflows(True)
|
return self._mk_workflows(True)
|
||||||
|
|
||||||
def __ft__(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
Div(cls="divider"),
|
Div(cls="divider"),
|
||||||
Div(
|
Div(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
WORKFLOWS_INSTANCE_ID = "__Workflows__"
|
WORKFLOWS_INSTANCE_ID = "__Workflows__"
|
||||||
|
WORKFLOWS_ROLE = "workflows"
|
||||||
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__"
|
WORKFLOW_DESIGNER_INSTANCE_ID = "__WorkflowDesigner__"
|
||||||
WORKFLOW_PLAYER_INSTANCE_ID = "__WorkflowPlayer__"
|
WORKFLOW_PLAYER_INSTANCE_ID = "__WorkflowPlayer__"
|
||||||
WORKFLOWS_DB_ENTRY = "Workflows"
|
WORKFLOWS_DB_ENTRY = "Workflows"
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ load_dotenv()
|
|||||||
DB_PATH = os.getenv("DB_PATH", "tools.db")
|
DB_PATH = os.getenv("DB_PATH", "tools.db")
|
||||||
logger.info(f"{DB_PATH=}")
|
logger.info(f"{DB_PATH=}")
|
||||||
|
|
||||||
|
# Custom database engine settings
|
||||||
|
DBENGINE_PATH = os.getenv("DBENGINE_PATH", ".mytools_db")
|
||||||
|
logger.info(f"{DBENGINE_PATH=}")
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
|
|
||||||
|
|||||||
18
src/core/database_manager.py
Normal file
18
src/core/database_manager.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Database manager for application initialization."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from .user_database import Database, set_user_db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def initialize_database():
|
||||||
|
"""Initialize the application database."""
|
||||||
|
try:
|
||||||
|
# Create default database instance
|
||||||
|
db_instance = Database()
|
||||||
|
set_user_db(db_instance)
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
return db_instance
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database: {e}")
|
||||||
|
raise
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
import config
|
||||||
from constants import NOT_LOGGED, NO_SESSION
|
from constants import NOT_LOGGED, NO_SESSION
|
||||||
from core.dbengine import DbEngine, TAG_PARENT, TAG_USER, TAG_DATE, DbException
|
from core.dbengine import DbEngine, TAG_PARENT, TAG_USER, TAG_DATE, DbException
|
||||||
from core.settings_objects import *
|
from core.settings_objects import *
|
||||||
@@ -92,7 +93,7 @@ class MemoryDbEngine:
|
|||||||
|
|
||||||
class SettingsManager:
|
class SettingsManager:
|
||||||
def __init__(self, engine=None):
|
def __init__(self, engine=None):
|
||||||
self._db_engine = engine or DbEngine()
|
self._db_engine = engine or DbEngine(config.DBENGINE_PATH)
|
||||||
|
|
||||||
def save(self, session: dict, entry: str, obj: object):
|
def save(self, session: dict, entry: str, obj: object):
|
||||||
user_id, user_email = self._get_user(session)
|
user_id, user_email = self._get_user(session)
|
||||||
@@ -263,3 +264,15 @@ class NestedSettingsManager:
|
|||||||
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")
|
raise AttributeError(f"Settings '{self._obj_entry}' has no attribute '{self._obj_attribute}'.")
|
||||||
|
|
||||||
return settings, getattr(settings, self._obj_attribute)
|
return settings, getattr(settings, self._obj_attribute)
|
||||||
|
|
||||||
|
|
||||||
|
_settings_manager = SettingsManager()
|
||||||
|
|
||||||
|
|
||||||
|
def set_settings_manager(_setting_manager):
|
||||||
|
global _settings_manager
|
||||||
|
_settings_manager = _setting_manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_manager():
|
||||||
|
return _settings_manager
|
||||||
|
|||||||
@@ -3,15 +3,17 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .user_database import user_db
|
from .user_database import get_user_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class UserDAO:
|
class UserDAO:
|
||||||
"""Data Access Object for user management."""
|
"""Data Access Object for user management."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create_user(username: str, email: str, password: str | None = None, github_id: str | None = None) -> int:
|
def create_user(username: str, email: str, password: str | None = None, github_id: str | None = None,
|
||||||
|
db_instance=None) -> int:
|
||||||
"""
|
"""
|
||||||
Create a new user with email/password or GitHub authentication.
|
Create a new user with email/password or GitHub authentication.
|
||||||
|
|
||||||
@@ -20,10 +22,12 @@ class UserDAO:
|
|||||||
email: The user's email
|
email: The user's email
|
||||||
password: The user's password (optional)
|
password: The user's password (optional)
|
||||||
github_id: GitHub user ID (optional)
|
github_id: GitHub user ID (optional)
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: ID of the new user or 0 if creation failed
|
int: ID of the new user or 0 if creation failed
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
try:
|
try:
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -48,28 +52,43 @@ class UserDAO:
|
|||||||
password_hash = user_db._hash_password(password, salt)
|
password_hash = user_db._hash_password(password, salt)
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
|
INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
|
||||||
VALUES (?, ?, ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, ?, 0)
|
||||||
''', (username, email, password_hash, salt, github_id))
|
''', (username, email, password_hash, salt, github_id))
|
||||||
|
|
||||||
|
# Get the ID of the newly created user
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Add default roles to the new user
|
||||||
|
default_roles = ['repositories', 'datagrid', 'ai_buddy']
|
||||||
|
for role in default_roles:
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO roles (user_id, role_name)
|
||||||
|
VALUES (?, ?)
|
||||||
|
''', (user_id, role))
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cursor.lastrowid
|
return user_id
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating user: {e}")
|
logger.error(f"Error creating user: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def authenticate_email(email: str, password: str) -> dict[str, Any] | None:
|
def authenticate_email(email: str, password: str, db_instance=None) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Authenticate a user with email and password.
|
Authenticate a user with email and password.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: The user's email
|
email: The user's email
|
||||||
password: The user's password
|
password: The user's password
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict or None: User record if authentication succeeds, None otherwise
|
Dict or None: User record if authentication succeeds, None otherwise
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -100,8 +119,10 @@ class UserDAO:
|
|||||||
# Return user info
|
# Return user info
|
||||||
return dict(user)
|
return dict(user)
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def find_or_create_github_user(github_id: str, username: str, email: str | None) -> dict[str, Any] | None:
|
def find_or_create_github_user(github_id: str, username: str, email: str | None, db_instance=None) -> dict[
|
||||||
|
str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Find existing GitHub user or create a new one.
|
Find existing GitHub user or create a new one.
|
||||||
|
|
||||||
@@ -109,10 +130,12 @@ class UserDAO:
|
|||||||
github_id: GitHub user ID
|
github_id: GitHub user ID
|
||||||
username: The username from GitHub
|
username: The username from GitHub
|
||||||
email: The email from GitHub (may be None)
|
email: The email from GitHub (may be None)
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict or None: User record if found or created, None on error
|
Dict or None: User record if found or created, None on error
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -138,9 +161,9 @@ class UserDAO:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
INSERT INTO users (username, email, github_id, is_admin)
|
INSERT INTO users (username, email, github_id, is_admin)
|
||||||
VALUES (?, ?, ?, 0)
|
VALUES (?, ?, ?, 0)
|
||||||
''', (username, user_email, github_id))
|
''', (username, user_email, github_id))
|
||||||
|
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -156,17 +179,20 @@ class UserDAO:
|
|||||||
logger.error(f"Error creating GitHub user: {e}")
|
logger.error(f"Error creating GitHub user: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_by_id(user_id: int) -> dict[str, Any] | None:
|
def get_user_by_id(user_id: int, db_instance=None) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
Get a user by ID.
|
Get a user by ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user ID
|
user_id: The user ID
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict or None: User record if found, None otherwise
|
Dict or None: User record if found, None otherwise
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
@@ -179,42 +205,80 @@ class UserDAO:
|
|||||||
return dict(user) if user else None
|
return dict(user) if user else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_all_users(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
|
def get_user_roles_by_id(user_id: int, db_instance=None) -> list[str]:
|
||||||
|
"""
|
||||||
|
Retrieve the roles associated with a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id (int): User ID.
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[str]: List of role names associated with the user.
|
||||||
|
"""
|
||||||
|
db_instance = db_instance or get_user_db()
|
||||||
|
|
||||||
|
with db_instance.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT role_name
|
||||||
|
FROM roles
|
||||||
|
WHERE user_id = ?
|
||||||
|
''', (user_id,))
|
||||||
|
|
||||||
|
# get the roles
|
||||||
|
roles = [row["role_name"] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_all_users(limit: int = 100, offset: int = 0, db_instance=None) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all users with pagination.
|
Get all users with pagination.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
limit: Maximum number of users to return
|
limit: Maximum number of users to return
|
||||||
offset: Number of users to skip
|
offset: Number of users to skip
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of user records
|
List of user records
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT id, username, email, is_admin, created_at, last_login,
|
SELECT id,
|
||||||
(github_id IS NOT NULL) as is_github_user
|
username,
|
||||||
FROM users
|
email,
|
||||||
ORDER BY created_at DESC
|
is_admin,
|
||||||
LIMIT ? OFFSET ?
|
created_at,
|
||||||
''', (limit, offset))
|
last_login,
|
||||||
|
(github_id IS NOT NULL) as is_github_user
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
''', (limit, offset))
|
||||||
|
|
||||||
return [dict(user) for user in cursor.fetchall()]
|
return [dict(user) for user in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_admin_status(user_id: int, is_admin: bool) -> bool:
|
def set_admin_status(user_id: int, is_admin: bool, db_instance=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Change a user's admin status.
|
Change a user's admin status.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user ID
|
user_id: The user ID
|
||||||
is_admin: True to make admin, False to remove admin status
|
is_admin: True to make admin, False to remove admin status
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
try:
|
try:
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -230,17 +294,20 @@ class UserDAO:
|
|||||||
logger.error(f"Error setting admin status: {e}")
|
logger.error(f"Error setting admin status: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_user(user_id: int) -> bool:
|
def delete_user(user_id: int, db_instance=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Delete a user and all their data.
|
Delete a user and all their data.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user ID
|
user_id: The user ID
|
||||||
|
db_instance: Database instance (optional, uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
|
user_db = db_instance or get_user_db()
|
||||||
try:
|
try:
|
||||||
with user_db.get_connection() as conn:
|
with user_db.get_connection() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
@@ -46,6 +46,17 @@ class Database:
|
|||||||
''')
|
''')
|
||||||
logger.info("Created users table")
|
logger.info("Created users table")
|
||||||
|
|
||||||
|
# Create the roles table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
role_name TEXT NOT NULL,
|
||||||
|
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
logger.info("Created roles table")
|
||||||
|
|
||||||
# Check if we need to create an admin user
|
# Check if we need to create an admin user
|
||||||
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
|
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1")
|
||||||
@@ -111,5 +122,18 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# Create a singleton instance
|
|
||||||
user_db = Database()
|
# Global instance for backward compatibility (will be modified in tests)
|
||||||
|
_default_instance = None
|
||||||
|
|
||||||
|
def get_user_db():
|
||||||
|
"""Get the default database instance."""
|
||||||
|
global _default_instance
|
||||||
|
if _default_instance is None:
|
||||||
|
_default_instance = Database()
|
||||||
|
return _default_instance
|
||||||
|
|
||||||
|
def set_user_db(instance):
|
||||||
|
"""Set a custom database instance (for testing)."""
|
||||||
|
global _default_instance
|
||||||
|
_default_instance = instance
|
||||||
30
src/main.py
30
src/main.py
@@ -1,6 +1,7 @@
|
|||||||
# global layout
|
# global layout
|
||||||
import logging.config
|
import logging.config
|
||||||
|
|
||||||
|
import click
|
||||||
import yaml
|
import yaml
|
||||||
from fasthtml.common import *
|
from fasthtml.common import *
|
||||||
|
|
||||||
@@ -19,9 +20,10 @@ from components.register.constants import ROUTE_ROOT as REGISTER_ROUTE_ROOT
|
|||||||
from components.register.constants import Routes as RegisterRoutes
|
from components.register.constants import Routes as RegisterRoutes
|
||||||
from config import APP_PORT
|
from config import APP_PORT
|
||||||
from constants import Routes
|
from constants import Routes
|
||||||
|
from core.database_manager import initialize_database
|
||||||
from core.dbengine import DbException
|
from core.dbengine import DbException
|
||||||
from core.instance_manager import InstanceManager
|
from core.instance_manager import InstanceManager
|
||||||
from core.settings_management import SettingsManager
|
from core.settings_management import get_settings_manager
|
||||||
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
|
from pages.admin_import_settings import AdminImportSettings, IMPORT_SETTINGS_PATH, import_settings_app
|
||||||
from pages.another_grid import get_datagrid2
|
from pages.another_grid import get_datagrid2
|
||||||
from pages.basic_test import BASIC_TEST_PATH, basic_test_app, get_basic_test
|
from pages.basic_test import BASIC_TEST_PATH, basic_test_app, get_basic_test
|
||||||
@@ -38,6 +40,9 @@ logging.config.dictConfig(config)
|
|||||||
|
|
||||||
logger = logging.getLogger("MainApp")
|
logger = logging.getLogger("MainApp")
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
initialize_database()
|
||||||
|
|
||||||
# daisy_ui_links_v4 = (
|
# daisy_ui_links_v4 = (
|
||||||
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
|
# Link(href="https://cdn.jsdelivr.net/npm/daisyui@latest/dist/full.min.css", rel="stylesheet", type="text/css"),
|
||||||
# Script(src="https://cdn.tailwindcss.com"),
|
# Script(src="https://cdn.tailwindcss.com"),
|
||||||
@@ -147,6 +152,7 @@ register_component("main_layout", "components.drawerlayout", "DrawerLayoutApp")
|
|||||||
register_component("undo_redo", "components.undo_redo", "UndoRedoApp")
|
register_component("undo_redo", "components.undo_redo", "UndoRedoApp")
|
||||||
register_component("tabs", "components.tabs", "TabsApp") # before repositories
|
register_component("tabs", "components.tabs", "TabsApp") # before repositories
|
||||||
register_component("entryselector", "components.entryselector", "EntrySelectorApp")
|
register_component("entryselector", "components.entryselector", "EntrySelectorApp")
|
||||||
|
register_component("jsonviewer", "components.jsonviewer", "JsonViewerApp")
|
||||||
register_component("applications", "components.applications", "ApplicationsApp")
|
register_component("applications", "components.applications", "ApplicationsApp")
|
||||||
register_component("repositories", "components.repositories", "RepositoriesApp")
|
register_component("repositories", "components.repositories", "RepositoriesApp")
|
||||||
register_component("workflows", "components.workflows", "WorkflowsApp")
|
register_component("workflows", "components.workflows", "WorkflowsApp")
|
||||||
@@ -230,9 +236,7 @@ async def timing_middleware(request, call_next):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
settings_manager = SettingsManager()
|
import_settings = AdminImportSettings(get_settings_manager(), None)
|
||||||
|
|
||||||
import_settings = AdminImportSettings(settings_manager, None)
|
|
||||||
pages = [
|
pages = [
|
||||||
Page("My table", get_datagrid, id="my_table"),
|
Page("My table", get_datagrid, id="my_table"),
|
||||||
Page("new settings", import_settings, id="import_settings"),
|
Page("new settings", import_settings, id="import_settings"),
|
||||||
@@ -241,8 +245,8 @@ pages = [
|
|||||||
Page("Another Table", get_datagrid2, id="another_table"),
|
Page("Another Table", get_datagrid2, id="another_table"),
|
||||||
]
|
]
|
||||||
|
|
||||||
login = Login(settings_manager)
|
login = Login(get_settings_manager())
|
||||||
register = Register(settings_manager)
|
register = Register(get_settings_manager())
|
||||||
InstanceManager.register_many(login, register)
|
InstanceManager.register_many(login, register)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,8 +256,8 @@ def get(session):
|
|||||||
main = InstanceManager.get(session,
|
main = InstanceManager.get(session,
|
||||||
DrawerLayout.create_component_id(session),
|
DrawerLayout.create_component_id(session),
|
||||||
DrawerLayout,
|
DrawerLayout,
|
||||||
settings_manager=settings_manager)
|
settings_manager=get_settings_manager())
|
||||||
return page_layout_lite(session, settings_manager, main)
|
return page_layout_lite(session, get_settings_manager(), main)
|
||||||
except DbException:
|
except DbException:
|
||||||
return RedirectResponse(LOGIN_ROUTE_ROOT + LoginRoutes.Logout, status_code=303)
|
return RedirectResponse(LOGIN_ROUTE_ROOT + LoginRoutes.Logout, status_code=303)
|
||||||
|
|
||||||
@@ -297,7 +301,7 @@ def not_found(path: str, session=None):
|
|||||||
|
|
||||||
return page_layout_new(
|
return page_layout_new(
|
||||||
session=session,
|
session=session,
|
||||||
settings_manager=settings_manager,
|
settings_manager=get_settings_manager(),
|
||||||
content=error_content
|
content=error_content
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -305,9 +309,11 @@ def not_found(path: str, session=None):
|
|||||||
setup_toasts(app)
|
setup_toasts(app)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
@click.command()
|
||||||
logger.info(f" Starting FastHTML server on http://localhost:{APP_PORT}")
|
@click.option('--port', default=APP_PORT, help='Port to run the server on')
|
||||||
serve(port=APP_PORT)
|
def main(port):
|
||||||
|
logger.info(f" Starting FastHTML server on http://localhost:{port}")
|
||||||
|
serve(port=port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,12 +1,161 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
from components.datagrid.DataGrid import reset_instances
|
from components.datagrid.DataGrid import reset_instances
|
||||||
|
from playwright_config import BROWSER_CONFIG, BASE_URL
|
||||||
|
from tests.fixtures.test_database import TestDatabaseManager
|
||||||
|
from tests.fixtures.test_users import TestUsers
|
||||||
|
|
||||||
USER_EMAIL = "test@mail.com"
|
USER_EMAIL = "test@mail.com"
|
||||||
USER_ID = "test_user"
|
USER_ID = "test_user"
|
||||||
|
APP_PORT = 5002
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_database():
|
||||||
|
"""Configure temporary database for tests"""
|
||||||
|
# Create temporary DB
|
||||||
|
temp_db_path = TestDatabaseManager.create_temp_db_path()
|
||||||
|
|
||||||
|
# Save original environment
|
||||||
|
original_env = {
|
||||||
|
"DB_PATH": os.environ.get("DB_PATH"),
|
||||||
|
"ADMIN_EMAIL": os.environ.get("ADMIN_EMAIL"),
|
||||||
|
"ADMIN_PASSWORD": os.environ.get("ADMIN_PASSWORD"),
|
||||||
|
"SECRET_KEY": os.environ.get("SECRET_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure test environment
|
||||||
|
TestDatabaseManager.setup_test_environment(temp_db_path)
|
||||||
|
|
||||||
|
print(f"Test database created at: {temp_db_path}")
|
||||||
|
|
||||||
|
yield temp_db_path
|
||||||
|
|
||||||
|
# Cleanup: restore environment and clean up DB
|
||||||
|
TestDatabaseManager.restore_original_environment(original_env)
|
||||||
|
TestDatabaseManager.cleanup_test_db(temp_db_path)
|
||||||
|
print(f"Test database cleaned up: {temp_db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_users():
|
||||||
|
"""Define available test users"""
|
||||||
|
return {
|
||||||
|
"admin": TestUsers.ADMIN,
|
||||||
|
"regular_user": TestUsers.REGULAR_USER,
|
||||||
|
"invalid_cases": TestUsers.INVALID_CREDENTIALS,
|
||||||
|
"protected_urls": TestUsers.PROTECTED_URLS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def test_setting_manager():
|
||||||
|
# create a dedicated folder for the db
|
||||||
|
db_engine_folder = tempfile.mkdtemp(prefix="test_db_engine")
|
||||||
|
|
||||||
|
yield db_engine_folder
|
||||||
|
|
||||||
|
# clean up folder & restore settings manager
|
||||||
|
if os.path.exists(db_engine_folder):
|
||||||
|
shutil.rmtree(db_engine_folder, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_server(test_database, test_users, test_setting_manager):
|
||||||
|
"""Start application server with test database"""
|
||||||
|
# Use the same Python executable that's running pytest
|
||||||
|
python_executable = sys.executable
|
||||||
|
|
||||||
|
# Get the absolute path to the src directory
|
||||||
|
project_root = Path(__file__).parent.parent
|
||||||
|
src_path = project_root / "src"
|
||||||
|
|
||||||
|
# Create test environment
|
||||||
|
test_env = os.environ.copy()
|
||||||
|
test_env["DB_PATH"] = test_database
|
||||||
|
test_env["DBENGINE_PATH"] = test_setting_manager
|
||||||
|
test_env["ADMIN_EMAIL"] = test_users["admin"]["email"]
|
||||||
|
test_env["ADMIN_PASSWORD"] = test_users["admin"]["password"]
|
||||||
|
test_env["SECRET_KEY"] = "test-secret-key-for-e2e-tests"
|
||||||
|
|
||||||
|
# Start the application server
|
||||||
|
print(f"Starting server on url {BASE_URL} with test database...")
|
||||||
|
port = BASE_URL.split(':')[-1].split('/')[0] if ':' in BASE_URL else APP_PORT
|
||||||
|
print(f"Using port {port}")
|
||||||
|
print(f"Test DB path: {test_database}")
|
||||||
|
|
||||||
|
server_process = subprocess.Popen(
|
||||||
|
[python_executable, "main.py", "--port", "5002"],
|
||||||
|
cwd=str(src_path), # Change to src directory where main.py is located
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
env=test_env # Use test environment
|
||||||
|
)
|
||||||
|
|
||||||
|
# Wait for the server to start
|
||||||
|
max_retries = 10 # Wait up to 30 seconds
|
||||||
|
for i in range(max_retries):
|
||||||
|
try:
|
||||||
|
print(f"Waiting retry {i}/{max_retries}")
|
||||||
|
response = requests.get(BASE_URL, timeout=1)
|
||||||
|
if response.status_code in [200, 302, 404]: # Server is responding
|
||||||
|
print(f"Server started successfully after {i + 1} attempts")
|
||||||
|
break
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
# If we get here, the server didn't start in time
|
||||||
|
print(f"Failed to start after {max_retries} attempts.")
|
||||||
|
server_process.kill()
|
||||||
|
stdout, stderr = server_process.communicate()
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Server failed to start within {max_retries} seconds.\n"
|
||||||
|
f"STDOUT: {stdout.decode()}\n"
|
||||||
|
f"STDERR: {stderr.decode()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Yield control to the tests
|
||||||
|
print('Test server started with isolated database!')
|
||||||
|
yield server_process
|
||||||
|
|
||||||
|
# Cleanup: terminate the server after tests
|
||||||
|
server_process.terminate()
|
||||||
|
try:
|
||||||
|
server_process.wait(timeout=5)
|
||||||
|
print('Test server stopped.')
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
server_process.kill()
|
||||||
|
server_process.wait()
|
||||||
|
print('Test server killed!')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def browser_context_args(browser_context_args):
|
||||||
|
"""Configure browser context arguments"""
|
||||||
|
return {
|
||||||
|
**browser_context_args,
|
||||||
|
**BROWSER_CONFIG,
|
||||||
|
"record_video_dir": "test-results/videos/",
|
||||||
|
"record_har_path": "test-results/har/trace.har",
|
||||||
|
"record_video_size": {"width": 1280, "height": 720},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_url():
|
||||||
|
"""Base URL for the application"""
|
||||||
|
return BASE_URL
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
177
tests/e2e/Readme.md
Normal file
177
tests/e2e/Readme.md
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# README - End-to-End Authentication Testing Guide
|
||||||
|
|
||||||
|
This README provides details on how to set up, configure, and run the end-to-end (e2e) authentication tests for the **My
|
||||||
|
Managing Tools** application. The tests are implemented using `pytest` with `playwright` for browser automation.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
The e2e tests verify that the authentication system works as expected, including login functionality, session
|
||||||
|
validation, and database isolation for testing purposes. These tests ensure the login page behaves properly under
|
||||||
|
different scenarios such as unauthenticated access, form validation, and successful user login.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Setup Instructions](#setup-instructions)
|
||||||
|
- [Application Setup](#application-setup)
|
||||||
|
- [Database Setup](#database-setup)
|
||||||
|
2. [Installing Dependencies](#installing-dependencies)
|
||||||
|
3. [Running Tests](#running-tests)
|
||||||
|
4. [Debugging and Logs](#debugging-and-logs)
|
||||||
|
5. [Test Scenarios](#test-scenarios)
|
||||||
|
6. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installing Dependencies
|
||||||
|
|
||||||
|
Install the required packages for the tests:
|
||||||
|
|
||||||
|
1. **Playwright and Pytest**:
|
||||||
|
Install `pytest-playwright` and playwright dependencies:
|
||||||
|
```bash
|
||||||
|
pip install pytest-playwright
|
||||||
|
playwright install
|
||||||
|
playwright install-deps # For Linux systems
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Project Dependencies**:
|
||||||
|
Use the provided `requirements.txt` to install other necessary packages:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Verify Installation**:
|
||||||
|
Confirm Playwright is installed and functional:
|
||||||
|
```bash
|
||||||
|
playwright --version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
### Database Setup
|
||||||
|
|
||||||
|
The e2e tests are designed to interact with an **isolated test database**. During setup, a temporary SQLite database will be created for each test session.
|
||||||
|
|
||||||
|
1. **Test Database Initialization**:
|
||||||
|
The database is initialized dynamically during the test execution using a custom temporary path. The `test_database` fixture creates a database with the following characteristics:
|
||||||
|
- **Temporary Directory**: Ensures no conflicts with the production database.
|
||||||
|
- **Admin User Auto-Creation**: Adds an admin account if the environment variables `ADMIN_EMAIL` and `ADMIN_PASSWORD` are set.
|
||||||
|
|
||||||
|
2. **Environment Variables**:
|
||||||
|
Ensure the following environment variables are defined for the test database:
|
||||||
|
```env
|
||||||
|
DB_PATH="test_mmt_tools.db"
|
||||||
|
ADMIN_EMAIL="test.admin@test.com"
|
||||||
|
ADMIN_PASSWORD="TestAdmin123"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configuration**:
|
||||||
|
The `test_database.py` script handles the database isolation and cleanup after each test session. It ensures that no state is leaked between tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
To execute the end-to-end authentication tests, run the following commands from the project root.
|
||||||
|
|
||||||
|
1. **Run All Tests**:
|
||||||
|
Execute all e2e tests:
|
||||||
|
```bash
|
||||||
|
pytest -m "e2e"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run Specific Test**:
|
||||||
|
Use the test path and `-k` argument to target specific tests:
|
||||||
|
```bash
|
||||||
|
pytest tests/test_e2e_authentication.py -k "test_unauthenticated_user_redirected_to_login"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Record Test Results**:
|
||||||
|
Save test results (HTML report):
|
||||||
|
```bash
|
||||||
|
pytest --html=test-results/report.html --self-contained-html
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Parallel Execution**:
|
||||||
|
If desired, run tests in parallel:
|
||||||
|
```bash
|
||||||
|
pytest -n auto
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debugging and Logs
|
||||||
|
|
||||||
|
1. **Console Logs**:
|
||||||
|
Console errors during page navigation are captured using the `test_page_loads_without_errors` test. Check the pytest
|
||||||
|
output for details.
|
||||||
|
|
||||||
|
2. **Debugging Tools**:
|
||||||
|
- Videos and HAR traces are recorded in the `test-results` directory.
|
||||||
|
- Use the `page.pause()` method inside a test for interactive debugging with Playwright Inspector.
|
||||||
|
|
||||||
|
3. **Screenshots and Debug HTML**:
|
||||||
|
If a test fails, screenshots and full HTML are saved for post-mortem debugging:
|
||||||
|
- `debug_after_login.png`: Screenshot post-login.
|
||||||
|
- `debug_page.html`: Captured HTML structure of the page.
|
||||||
|
|
||||||
|
4. **Manual Debugging**:
|
||||||
|
```python
|
||||||
|
# Screenshot
|
||||||
|
page.screenshot(path="debug_after_login.png")
|
||||||
|
|
||||||
|
# HTML content
|
||||||
|
with open("debug_page.html", "w", encoding="utf-8") as f:
|
||||||
|
f.write(page.content())
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Server Logs**:
|
||||||
|
When using the `app_server` fixture, server `stdout` and `stderr` are recorded during the test execution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
The main authentication tests cover the following scenarios:
|
||||||
|
|
||||||
|
1. **Unauthenticated User Redirect**:
|
||||||
|
Verify that unauthenticated users accessing protected resources are redirected to the login page.
|
||||||
|
|
||||||
|
2. **Login Page Elements**:
|
||||||
|
Validate that key login page elements are visible, such as email/password fields and the submit button.
|
||||||
|
|
||||||
|
3. **Successful Login**:
|
||||||
|
Ensure users can log in successfully with valid credentials and are redirected to the home page.
|
||||||
|
|
||||||
|
4. **Test Database Isolation**:
|
||||||
|
Confirm that the test environment uses an isolated temporary database and does not affect production data.
|
||||||
|
|
||||||
|
5. **Page Load Without Errors**:
|
||||||
|
Ensure that the login page loads without console or HTTP errors.
|
||||||
|
|
||||||
|
For a full list of test cases, see `tests/test_e2e_authentication.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Server Not Starting**:
|
||||||
|
If the server fails to start during testing, confirm the `DB_PATH`, `BASE_URL`, and all dependencies are correctly
|
||||||
|
configured in the `.env` file.
|
||||||
|
|
||||||
|
- **Playwright Issues**:
|
||||||
|
Ensure Playwright dependencies (`playwright install`) are installed and functional. Missing browsers or outdated
|
||||||
|
Playwright versions can cause tests to fail.
|
||||||
|
|
||||||
|
- **Database Error**:
|
||||||
|
Check that the `tools.db` file or the temporary test database exists and is accessible. Ensure the test database
|
||||||
|
environment variables are set correctly during tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
igurations are hardcoded in the test scripts.
|
||||||
0
tests/e2e/__init__.py
Normal file
0
tests/e2e/__init__.py
Normal file
4
tests/e2e/e2e_constants.py
Normal file
4
tests/e2e/e2e_constants.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
class Login:
|
||||||
|
email = ["input[type='email']", "input[name='email']"]
|
||||||
|
password = ["input[type='password']", "input[name='password']"]
|
||||||
|
submit = "button[type='submit']"
|
||||||
94
tests/e2e/e2e_helper.py
Normal file
94
tests/e2e/e2e_helper.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
|
||||||
|
def locate(page: Page, name, check_visible=True):
|
||||||
|
"""
|
||||||
|
Locate an element or elements on the provided page by name.
|
||||||
|
|
||||||
|
This function identifies elements on a web page using the locator API of the
|
||||||
|
provided page object. The `name` parameter can either be a single locator
|
||||||
|
string or a list of locator strings. If a list is provided, its elements are
|
||||||
|
joined into a single comma-separated string before locating the elements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page (Page): The web page object in which to locate the element(s).
|
||||||
|
name (Union[str, List[str]]): Locator or list of locators to identify elements.
|
||||||
|
check_visible(bool): Whether to check if the element is visible before returning it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Locator: The locator object for the given name.
|
||||||
|
"""
|
||||||
|
if isinstance(name, list):
|
||||||
|
name = ",".join(name)
|
||||||
|
|
||||||
|
element = page.locator(name)
|
||||||
|
|
||||||
|
if check_visible:
|
||||||
|
expect(element).to_be_visible()
|
||||||
|
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
def fill(element, value):
|
||||||
|
"""
|
||||||
|
Fills an element with a specified value and verifies that the element's value matches the provided value.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
element : The UI element to be filled with the value.
|
||||||
|
value : The value to fill into the element.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If the element's value does not match the provided value after the fill operation.
|
||||||
|
"""
|
||||||
|
element.fill(value)
|
||||||
|
expect(element).to_have_value(value)
|
||||||
|
|
||||||
|
|
||||||
|
def debug(page: Page, checkpoint_name: str):
|
||||||
|
"""
|
||||||
|
Takes a screenshot of the current state of a page and saves its full HTML content
|
||||||
|
to support debugging at a specified checkpoint. The screenshot and HTML file are saved
|
||||||
|
with checkpoint-specific filenames in the current working directory.
|
||||||
|
|
||||||
|
This function is useful for capturing the visual state and content of the page at
|
||||||
|
any moment during the script execution, helping in resolving issues or verifying
|
||||||
|
state transitions.
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
page (Page): The page object whose state and content are to be captured.
|
||||||
|
checkpoint_name (str): A descriptive name for the checkpoint. This is used to
|
||||||
|
generate unique filenames for the screenshot and HTML file.
|
||||||
|
"""
|
||||||
|
# Take screenshot
|
||||||
|
page.screenshot(path=f"debug_{checkpoint_name}.png")
|
||||||
|
|
||||||
|
# Save full HTML for inspection
|
||||||
|
with open(f"debug_{checkpoint_name}.html", "w", encoding="utf-8") as f:
|
||||||
|
f.write(page.content())
|
||||||
|
|
||||||
|
with open(f"debug_{checkpoint_name}.txt", "w", encoding="utf-8") as f:
|
||||||
|
f.writelines([
|
||||||
|
f"Checkpoint: {checkpoint_name}\n",
|
||||||
|
f"URL: {page.url}\n",
|
||||||
|
f"Title: {page.title()}\n",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def check_not_in_content(page, keywords):
|
||||||
|
"""
|
||||||
|
Checks that none of the specified keywords are present in the page content.
|
||||||
|
|
||||||
|
This function iterates through a list of keywords and verifies that none of
|
||||||
|
them are present in the given page's content. If any keyword is found, an
|
||||||
|
AssertionError is raised with details about the number of times it was found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: A page object that provides access to retrieve text content.
|
||||||
|
keywords: List of strings representing keywords to search in the page content.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If any of the keywords are found in the page content.
|
||||||
|
"""
|
||||||
|
for keyword in keywords:
|
||||||
|
occurrences = page.get_by_text(keyword, exact=False)
|
||||||
|
assert occurrences.count() == 0, f"Found {occurrences.count()} of '{keyword}' in page content which should not be visible"
|
||||||
212
tests/e2e/test_e2e_authentication.py
Normal file
212
tests/e2e/test_e2e_authentication.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# tests/test_e2e_authentication.py
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from e2e.e2e_constants import Login
|
||||||
|
from e2e.e2e_helper import locate, fill, debug, check_not_in_content
|
||||||
|
from playwright_config import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Tests for authentication and login functionality"""
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_unauthenticated_user_redirected_to_login(self, app_server, page: Page):
|
||||||
|
"""Test that when not logged in, the default page is the login page"""
|
||||||
|
# Navigate to the root URL
|
||||||
|
page.goto(BASE_URL)
|
||||||
|
|
||||||
|
# Wait for the page to fully load
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Check that we're on the login page
|
||||||
|
# Option 1: Check URL contains login-related path
|
||||||
|
expect(page).to_have_url(f"{BASE_URL}/authlogin/login")
|
||||||
|
|
||||||
|
# Option 2: Check for login form elements
|
||||||
|
# Look for typical login form elements
|
||||||
|
login_indicators = [
|
||||||
|
"input[type='email']",
|
||||||
|
"input[type='password']",
|
||||||
|
"input[name*='username']",
|
||||||
|
"input[name*='email']",
|
||||||
|
"input[name*='password']",
|
||||||
|
"button[type='submit']",
|
||||||
|
"form"
|
||||||
|
]
|
||||||
|
|
||||||
|
# At least one login indicator should be present
|
||||||
|
login_form_found = False
|
||||||
|
for selector in login_indicators:
|
||||||
|
if page.locator(selector).count() > 0:
|
||||||
|
login_form_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
assert login_form_found, "No login form elements found on the page"
|
||||||
|
|
||||||
|
# Option 3: Check for login-related text content
|
||||||
|
page_content = page.content().lower()
|
||||||
|
login_keywords = ["login", "sign in", "authenticate", "username", "password", "email"]
|
||||||
|
|
||||||
|
has_login_content = any(keyword in page_content for keyword in login_keywords)
|
||||||
|
assert has_login_content, "Page does not contain login-related content"
|
||||||
|
|
||||||
|
# Option 4: Ensure we're not on a protected page
|
||||||
|
# Check that we don't see protected content like "dashboard", "logout", "profile", "settings"]
|
||||||
|
protected_content = ["dashboard", "logout", "profile", "settings"]
|
||||||
|
page_text = page.locator("body").inner_text().lower()
|
||||||
|
|
||||||
|
for protected_word in protected_content:
|
||||||
|
assert protected_word not in page_text, f"Found protected content '{protected_word}' when not logged in"
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_login_page_has_required_elements(self, app_server, page: Page):
|
||||||
|
"""Test that the login page contains all required elements"""
|
||||||
|
page.goto(BASE_URL)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Check for essential login form elements
|
||||||
|
expect(page.locator("form")).to_be_visible()
|
||||||
|
|
||||||
|
# Check for input fields (at least email/username and password)
|
||||||
|
username_input = page.locator("input[type='email'], input[name*='username'], input[name*='email']")
|
||||||
|
expect(username_input.first).to_be_visible()
|
||||||
|
|
||||||
|
password_input = page.locator("input[type='password'], input[name*='password']")
|
||||||
|
expect(password_input.first).to_be_visible()
|
||||||
|
|
||||||
|
# Check for submit button
|
||||||
|
submit_button = page.locator("button[type='submit'], input[type='submit']")
|
||||||
|
expect(submit_button.first).to_be_visible()
|
||||||
|
|
||||||
|
def test_page_loads_without_errors(self, app_server, page: Page):
|
||||||
|
"""Test that the login page loads without console errors"""
|
||||||
|
console_errors = []
|
||||||
|
|
||||||
|
def handle_console(msg):
|
||||||
|
if msg.type == "error":
|
||||||
|
console_errors.append(msg.text)
|
||||||
|
|
||||||
|
page.on("console", handle_console)
|
||||||
|
page.goto(BASE_URL)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Check for console errors
|
||||||
|
assert len(console_errors) == 0, f"Console errors found: {console_errors}"
|
||||||
|
|
||||||
|
# Check that the page actually loaded (not a 404 or 500 error)
|
||||||
|
expect(page.locator("body")).to_be_visible()
|
||||||
|
|
||||||
|
# Check that the page has a title
|
||||||
|
expect(page).to_have_title("My Managing Tools")
|
||||||
|
|
||||||
|
# New test to validate database isolation
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_test_database_isolation(self, app_server, test_database, test_users):
|
||||||
|
"""Test that application uses isolated test database"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Verify test environment variables are configured
|
||||||
|
assert os.environ.get("DB_PATH") == test_database
|
||||||
|
assert os.environ.get("ADMIN_EMAIL") == test_users["admin"]["email"]
|
||||||
|
assert os.environ.get("ADMIN_PASSWORD") == test_users["admin"]["password"]
|
||||||
|
|
||||||
|
# Verify temporary database file exists
|
||||||
|
assert os.path.exists(test_database), f"Test database file not found: {test_database}"
|
||||||
|
|
||||||
|
# Verify path contains 'test_mmt_' to confirm isolation
|
||||||
|
assert "test_mmt_" in test_database, "Database path should contain test prefix"
|
||||||
|
|
||||||
|
print(f"✅ Test database isolation confirmed: {test_database}")
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PRIORITY 1 TESTS - CRITICAL AUTHENTICATION FLOWS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@pytest.mark.e2e
|
||||||
|
@pytest.mark.priority1
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_successful_login_with_valid_credentials(self, app_server, test_users, page: Page):
|
||||||
|
"""
|
||||||
|
Priority 1 Test: Validate complete successful login flow with valid credentials
|
||||||
|
|
||||||
|
This test ensures that:
|
||||||
|
1. User can access the login page
|
||||||
|
2. User can enter valid credentials
|
||||||
|
3. Form submission works correctly
|
||||||
|
4. User is redirected to home page after successful authentication
|
||||||
|
5. User session is properly established
|
||||||
|
"""
|
||||||
|
admin_user = test_users["admin"]
|
||||||
|
|
||||||
|
# Step 1: Navigate to login page
|
||||||
|
page.goto(BASE_URL)
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Verify we're on the login page
|
||||||
|
expect(page).to_have_url(f"{BASE_URL}/authlogin/login")
|
||||||
|
|
||||||
|
# Step 2: Locate form elements
|
||||||
|
email_input = locate(page, Login.email)
|
||||||
|
password_input = locate(page, Login.password)
|
||||||
|
submit_button = locate(page, Login.submit)
|
||||||
|
|
||||||
|
fill(email_input, admin_user["email"])
|
||||||
|
fill(password_input, admin_user["password"])
|
||||||
|
|
||||||
|
with page.expect_navigation(wait_until="networkidle"):
|
||||||
|
submit_button.click()
|
||||||
|
|
||||||
|
debug(page, "after_login")
|
||||||
|
check_not_in_content(page, ["sign in", "login", "authenticate"])
|
||||||
|
|
||||||
|
page.pause()
|
||||||
|
|
||||||
|
# Step 5: Verify successful authentication and redirect
|
||||||
|
# Should be redirected to the home page
|
||||||
|
expect(page).to_have_url(BASE_URL + "/")
|
||||||
|
|
||||||
|
# Step 6: Verify we're actually authenticated (not redirected back to login)
|
||||||
|
# The page should load without being redirected back to login
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
current_url = page.url
|
||||||
|
assert not current_url.endswith("/authlogin/login"), f"User was redirected back to login page: {current_url}"
|
||||||
|
|
||||||
|
# Step 7: Verify authenticated content is present
|
||||||
|
# Check that we don't see login-related content anymore
|
||||||
|
page_content = page.content().lower()
|
||||||
|
login_keywords = ["sign in", "login", "authenticate"]
|
||||||
|
|
||||||
|
# Should not see login form elements on authenticated page
|
||||||
|
has_login_content = any(keyword in page_content for keyword in login_keywords)
|
||||||
|
assert not has_login_content, "Login content still visible after successful authentication"
|
||||||
|
|
||||||
|
# Step 8: Verify no error messages are displayed
|
||||||
|
# Look for common error message containers
|
||||||
|
error_selectors = [
|
||||||
|
".error",
|
||||||
|
".alert-error",
|
||||||
|
".bg-error",
|
||||||
|
"[class*='error']",
|
||||||
|
".text-red",
|
||||||
|
"[class*='text-red']"
|
||||||
|
]
|
||||||
|
|
||||||
|
for error_selector in error_selectors:
|
||||||
|
error_elements = page.locator(error_selector)
|
||||||
|
if error_elements.count() > 0:
|
||||||
|
# If error elements exist, they should not be visible or should be empty
|
||||||
|
for i in range(error_elements.count()):
|
||||||
|
element = error_elements.nth(i)
|
||||||
|
if element.is_visible():
|
||||||
|
element_text = element.inner_text().strip()
|
||||||
|
assert not element_text, f"Error message found after successful login: {element_text}"
|
||||||
|
|
||||||
|
# Step 9: Verify the page has expected title
|
||||||
|
expect(page).to_have_title("My Managing Tools")
|
||||||
|
|
||||||
|
print(f"✅ Successful login test completed - User {admin_user['email']} authenticated successfully")
|
||||||
0
tests/fixtures/__init__.py
vendored
Normal file
0
tests/fixtures/__init__.py
vendored
Normal file
0
tests/fixtures/app_factory.py
vendored
Normal file
0
tests/fixtures/app_factory.py
vendored
Normal file
56
tests/fixtures/test_database.py
vendored
Normal file
56
tests/fixtures/test_database.py
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabaseManager:
|
||||||
|
"""Manager for temporary test databases"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_temp_db_path():
|
||||||
|
"""Create a unique temporary path for test database"""
|
||||||
|
temp_dir = tempfile.mkdtemp(prefix="test_mmt_")
|
||||||
|
return os.path.join(temp_dir, "test_tools.db")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setup_test_environment(db_path):
|
||||||
|
"""Configure environment to use test database"""
|
||||||
|
os.environ["DB_PATH"] = db_path
|
||||||
|
os.environ["ADMIN_EMAIL"] = "test.admin@test.com"
|
||||||
|
os.environ["ADMIN_PASSWORD"] = "TestAdmin123"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret-key-for-e2e-tests"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def inject_test_database(db_path):
|
||||||
|
"""Inject test database instance into the application"""
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from src.core.user_database import Database, set_user_db
|
||||||
|
|
||||||
|
# Create test database instance
|
||||||
|
test_db = Database(db_path)
|
||||||
|
|
||||||
|
# Set it as the active instance
|
||||||
|
set_user_db(test_db)
|
||||||
|
|
||||||
|
return test_db
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def cleanup_test_db(db_path):
|
||||||
|
"""Clean up temporary database"""
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
# Clean up the complete temporary directory
|
||||||
|
temp_dir = os.path.dirname(db_path)
|
||||||
|
if os.path.exists(temp_dir):
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def restore_original_environment(original_env):
|
||||||
|
"""Restore original environment"""
|
||||||
|
for key, value in original_env.items():
|
||||||
|
if value is None:
|
||||||
|
# Variable didn't exist before
|
||||||
|
if key in os.environ:
|
||||||
|
del os.environ[key]
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
61
tests/fixtures/test_users.py
vendored
Normal file
61
tests/fixtures/test_users.py
vendored
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Test user definitions for E2E authentication testing"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestUsers:
|
||||||
|
"""Class containing test user data"""
|
||||||
|
|
||||||
|
# Admin user (automatically created on startup)
|
||||||
|
ADMIN = {
|
||||||
|
"email": "test.admin@test.com",
|
||||||
|
"password": "TestAdmin123"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Regular user to create for certain tests
|
||||||
|
REGULAR_USER = {
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "user@test.com",
|
||||||
|
"password": "TestUser123"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Failed authentication test cases
|
||||||
|
INVALID_CREDENTIALS = [
|
||||||
|
{
|
||||||
|
"name": "wrong_password",
|
||||||
|
"email": "test.admin@test.com",
|
||||||
|
"password": "wrongpass",
|
||||||
|
"expected_error": "Invalid email or password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nonexistent_user",
|
||||||
|
"email": "nonexistent@test.com",
|
||||||
|
"password": "TestAdmin123",
|
||||||
|
"expected_error": "Invalid email or password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid_email_format",
|
||||||
|
"email": "invalid.email",
|
||||||
|
"password": "TestAdmin123",
|
||||||
|
"expected_error": "Invalid email or password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty_email",
|
||||||
|
"email": "",
|
||||||
|
"password": "TestAdmin123",
|
||||||
|
"expected_error": "Email and password are required"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty_password",
|
||||||
|
"email": "test.admin@test.com",
|
||||||
|
"password": "",
|
||||||
|
"expected_error": "Email and password are required"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Protected URLs to test for unauthorized access
|
||||||
|
PROTECTED_URLS = [
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/repositories",
|
||||||
|
"/workflows",
|
||||||
|
"/applications"
|
||||||
|
]
|
||||||
28
tests/playwright_config.py
Normal file
28
tests/playwright_config.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import os
|
||||||
|
from playwright.sync_api import Playwright
|
||||||
|
|
||||||
|
# Playwright configuration
|
||||||
|
BASE_URL = os.getenv("APP_URL", "http://localhost:5002")
|
||||||
|
TIMEOUT = 30000
|
||||||
|
EXPECT_TIMEOUT = 5000
|
||||||
|
|
||||||
|
def pytest_configure():
|
||||||
|
"""Configure pytest for Playwright"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Browser configuration
|
||||||
|
BROWSER_CONFIG = {
|
||||||
|
#"headless": os.getenv("HEADLESS", "true").lower() == "true",
|
||||||
|
"viewport": {"width": 1280, "height": 720},
|
||||||
|
"ignore_https_errors": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
TEST_CONFIG = {
|
||||||
|
"base_url": BASE_URL,
|
||||||
|
"timeout": TIMEOUT,
|
||||||
|
"expect_timeout": EXPECT_TIMEOUT,
|
||||||
|
"screenshot": "only-on-failure",
|
||||||
|
"video": "retain-on-failure",
|
||||||
|
"trace": "retain-on-failure",
|
||||||
|
}
|
||||||
228
tests/test_hooks.py
Normal file
228
tests/test_hooks.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from components.jsonviewer.hooks import (
|
||||||
|
HookContext, EventType, Hook, HookManager, HookBuilder,
|
||||||
|
WhenLongText, WhenEditable, WhenType, WhenKey, WhenPath, WhenValue,
|
||||||
|
CompositeCondition
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# HookContext test helper
|
||||||
|
def create_mock_context(value=None, key=None, json_path=None, parent_node=None, node_type=None, children=None):
|
||||||
|
"""Helper to create a mock HookContext for testing."""
|
||||||
|
|
||||||
|
class Node:
|
||||||
|
def __init__(self, value, node_type=None, children=None):
|
||||||
|
self.value = value
|
||||||
|
self.__class__.__name__ = node_type or "MockNode"
|
||||||
|
self.children = children or []
|
||||||
|
|
||||||
|
mock_node = Node(value, node_type=node_type, children=children)
|
||||||
|
return HookContext(key=key, node=mock_node, helper=None, jsonviewer=None, json_path=json_path,
|
||||||
|
parent_node=parent_node)
|
||||||
|
|
||||||
|
|
||||||
|
# ================
|
||||||
|
# Test Conditions
|
||||||
|
# ================
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("text, threshold, expected", [
|
||||||
|
("This is a very long text." * 10, 50, True), # Long text, above threshold
|
||||||
|
("Short text", 50, False), # Short text, below threshold
|
||||||
|
])
|
||||||
|
def test_i_can_detect_long_text(text, threshold, expected):
|
||||||
|
context = create_mock_context(value=text)
|
||||||
|
condition = WhenLongText(threshold=threshold)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("json_path, editable_paths, editable_types, node_value, is_leaf, expected", [
|
||||||
|
("root.editable.value", ["root.editable.value"], None, "Editable value", True, True), # Editable path matches
|
||||||
|
("root.not_editable.value", ["root.editable.value"], None, "Editable value", True, False),
|
||||||
|
# Editable path does not match
|
||||||
|
("root.editable.numeric", [], [int], 10, True, True), # Type is editable (int)
|
||||||
|
("root.editable.string", [], [int], "Non-editable value", True, False) # Type is not editable
|
||||||
|
])
|
||||||
|
def test_i_can_detect_editable(json_path, editable_paths, editable_types, node_value, is_leaf, expected):
|
||||||
|
context = create_mock_context(value=node_value, json_path=json_path)
|
||||||
|
context.is_leaf_node = lambda: is_leaf # Mock is_leaf_node behavior
|
||||||
|
condition = WhenEditable(editable_paths=editable_paths, editable_types=editable_types)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("node_value, target_type, expected", [
|
||||||
|
(123, int, True), # Matches target type
|
||||||
|
("String value", int, False) # Does not match target type
|
||||||
|
])
|
||||||
|
def test_i_can_detect_type_match(node_value, target_type, expected):
|
||||||
|
context = create_mock_context(value=node_value)
|
||||||
|
condition = WhenType(target_type=target_type)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key, key_pattern, expected", [
|
||||||
|
("target_key", "target_key", True), # Exact match
|
||||||
|
("target_key", lambda k: k.startswith("target"), True), # Callable match
|
||||||
|
("wrong_key", "target_key", False) # Pattern does not match
|
||||||
|
])
|
||||||
|
def test_i_can_match_key(key, key_pattern, expected):
|
||||||
|
context = create_mock_context(key=key)
|
||||||
|
condition = WhenKey(key_pattern=key_pattern)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("json_path, path_pattern, expected", [
|
||||||
|
("root.items[0].name", r"root\.items\[\d+\]\.name", True), # Matches pattern
|
||||||
|
("root.invalid_path", r"root\.items\[\d+\]\.name", False) # Does not match
|
||||||
|
])
|
||||||
|
def test_i_can_match_path(json_path, path_pattern, expected):
|
||||||
|
context = create_mock_context(json_path=json_path)
|
||||||
|
condition = WhenPath(path_pattern=path_pattern)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value, target_value, predicate, expected", [
|
||||||
|
(123, 123, None, True), # Direct match
|
||||||
|
(123, 456, None, False), # Direct mismatch
|
||||||
|
(150, None, lambda v: v > 100, True), # Satisfies predicate
|
||||||
|
(50, None, lambda v: v > 100, False), # Does not satisfy predicate
|
||||||
|
])
|
||||||
|
def test_i_can_detect_value(value, target_value, predicate, expected):
|
||||||
|
context = create_mock_context(value=value)
|
||||||
|
condition = WhenValue(target_value=target_value, predicate=predicate)
|
||||||
|
assert condition.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value, conditions, operator, expected", [
|
||||||
|
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=int)], "AND", True),
|
||||||
|
# Both conditions pass (AND)
|
||||||
|
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=str)], "AND", False),
|
||||||
|
# One condition fails (AND)
|
||||||
|
(200, [WhenValue(predicate=lambda v: v > 100), WhenType(target_type=str)], "OR", True),
|
||||||
|
# At least one passes (OR)
|
||||||
|
(50, [], "AND", True), # No conditions (default True for AND/OR)
|
||||||
|
])
|
||||||
|
def test_i_can_combine_conditions(value, conditions, operator, expected):
|
||||||
|
context = create_mock_context(value=value)
|
||||||
|
composite = CompositeCondition(conditions=conditions, operator=operator)
|
||||||
|
assert composite.evaluate(context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ================
|
||||||
|
# Test Hooks
|
||||||
|
# ================
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("event_type, actual_event, threshold, text, expected", [
|
||||||
|
(EventType.RENDER, EventType.RENDER, 10, "Long text" * 10, True), # Event matches, meets condition
|
||||||
|
(EventType.RENDER, EventType.CLICK, 10, "Long text" * 10, False), # Event mismatch
|
||||||
|
])
|
||||||
|
def test_i_can_match_hook(event_type, actual_event, threshold, text, expected):
|
||||||
|
context = create_mock_context(value=text)
|
||||||
|
condition = WhenLongText(threshold=threshold)
|
||||||
|
hook = Hook(event_type=event_type, conditions=[condition], executor=lambda ctx: "Executed")
|
||||||
|
|
||||||
|
assert hook.matches(event_type=actual_event, context=context) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ================
|
||||||
|
# Test HookManager
|
||||||
|
# ================
|
||||||
|
|
||||||
|
def test_i_can_execute_hooks_in_manager():
|
||||||
|
hook_manager = HookManager()
|
||||||
|
|
||||||
|
# Add hooks
|
||||||
|
hook1 = Hook(EventType.RENDER, conditions=[], executor=lambda ctx: "Render Executed")
|
||||||
|
hook2 = Hook(EventType.CLICK, conditions=[], executor=lambda ctx: "Click Executed")
|
||||||
|
|
||||||
|
hook_manager.add_hook(hook1)
|
||||||
|
hook_manager.add_hook(hook2)
|
||||||
|
|
||||||
|
context = create_mock_context()
|
||||||
|
render_results = hook_manager.execute_hooks(event_type=EventType.RENDER, context=context)
|
||||||
|
click_results = hook_manager.execute_hooks(event_type=EventType.CLICK, context=context)
|
||||||
|
|
||||||
|
assert len(render_results) == 1
|
||||||
|
assert render_results[0] == "Render Executed"
|
||||||
|
|
||||||
|
assert len(click_results) == 1
|
||||||
|
assert click_results[0] == "Click Executed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_clear_hooks_in_manager():
|
||||||
|
hook_manager = HookManager()
|
||||||
|
|
||||||
|
hook_manager.add_hook(Hook(EventType.RENDER, conditions=[], executor=lambda ctx: "Render"))
|
||||||
|
assert len(hook_manager.hooks) == 1
|
||||||
|
|
||||||
|
hook_manager.clear_hooks()
|
||||||
|
assert len(hook_manager.hooks) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ================
|
||||||
|
# Test HookBuilder with Callable Conditions
|
||||||
|
# ================
|
||||||
|
|
||||||
|
def test_i_can_use_callable_with_when_custom():
|
||||||
|
"""Test that when_custom() accepts callable predicates"""
|
||||||
|
|
||||||
|
# Define a simple callable condition
|
||||||
|
def custom_condition(context):
|
||||||
|
return isinstance(context.get_value(), str) and context.get_value().startswith("CUSTOM_")
|
||||||
|
|
||||||
|
# Create hook using callable condition
|
||||||
|
hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_custom(custom_condition)
|
||||||
|
.execute(lambda ctx: "Custom hook executed"))
|
||||||
|
|
||||||
|
# Test with matching context
|
||||||
|
matching_context = create_mock_context(value="CUSTOM_test_value")
|
||||||
|
assert hook.matches(EventType.RENDER, matching_context) == True
|
||||||
|
assert hook.execute(matching_context) == "Custom hook executed"
|
||||||
|
|
||||||
|
# Test with non-matching context
|
||||||
|
non_matching_context = create_mock_context(value="regular_value")
|
||||||
|
assert hook.matches(EventType.RENDER, non_matching_context) == False
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_use_lambda_with_when_custom():
|
||||||
|
"""Test that when_custom() accepts lambda expressions"""
|
||||||
|
|
||||||
|
# Create hook using lambda condition
|
||||||
|
hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_custom(lambda ctx: ctx.key == "special" and isinstance(ctx.get_value(), int) and ctx.get_value() > 100)
|
||||||
|
.execute(lambda ctx: f"Special value: {ctx.get_value()}"))
|
||||||
|
|
||||||
|
# Test with matching context
|
||||||
|
matching_context = create_mock_context(value=150, key="special")
|
||||||
|
assert hook.matches(EventType.RENDER, matching_context) == True
|
||||||
|
assert hook.execute(matching_context) == "Special value: 150"
|
||||||
|
|
||||||
|
# Test with non-matching contexts
|
||||||
|
wrong_key_context = create_mock_context(value=150, key="normal")
|
||||||
|
assert hook.matches(EventType.RENDER, wrong_key_context) == False
|
||||||
|
|
||||||
|
wrong_value_context = create_mock_context(value=50, key="special")
|
||||||
|
assert hook.matches(EventType.RENDER, wrong_value_context) == False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value, key, json_path, expected", [
|
||||||
|
("CUSTOM_hook_test", "test_key", "root.test", True), # Matches callable condition
|
||||||
|
("regular_text", "test_key", "root.test", False), # Doesn't match callable condition
|
||||||
|
(123, "test_key", "root.test", False), # Wrong type
|
||||||
|
])
|
||||||
|
def test_callable_condition_evaluation(value, key, json_path, expected):
|
||||||
|
"""Test callable condition evaluation with different inputs"""
|
||||||
|
|
||||||
|
def custom_callable_condition(context):
|
||||||
|
return isinstance(context.get_value(), str) and context.get_value().startswith("CUSTOM_")
|
||||||
|
|
||||||
|
hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_custom(custom_callable_condition)
|
||||||
|
.execute(lambda ctx: "Executed"))
|
||||||
|
|
||||||
|
context = create_mock_context(value=value, key=key, json_path=json_path)
|
||||||
|
assert hook.matches(EventType.RENDER, context) == expected
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from components.debugger.components.JsonViewer import *
|
from components.jsonviewer.components.JsonViewer import *
|
||||||
|
from components.jsonviewer.hooks import HookBuilder
|
||||||
from helpers import matches, span_icon, search_elements_by_name, extract_jsonviewer_node
|
from helpers import matches, span_icon, search_elements_by_name, extract_jsonviewer_node
|
||||||
|
|
||||||
JSON_VIEWER_INSTANCE_ID = "json_viewer"
|
JSON_VIEWER_INSTANCE_ID = "json_viewer"
|
||||||
ML_20 = "margin-left: 20px;"
|
ML_20 = "margin-left: 20px;"
|
||||||
CLS_PREFIX = "mmt-jsonviewer"
|
CLS_PREFIX = "mmt-jsonviewer"
|
||||||
USER_ID = "user_id"
|
|
||||||
|
|
||||||
dn = DictNode
|
dn = DictNode
|
||||||
ln = ListNode
|
ln = ListNode
|
||||||
@@ -15,7 +15,7 @@ n = ValueNode
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def json_viewer(session):
|
def json_viewer(session):
|
||||||
return JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, {})
|
return JsonViewer(session, JSON_VIEWER_INSTANCE_ID, {})
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -41,7 +41,7 @@ def jv_id(x):
|
|||||||
ln([{"a": [1, 2]}], jv_id(0), 0, [dn({"a": [1, 2]}, jv_id(1), 1, {"a": ln([1, 2], jv_id(2), 2, [n(1), n(2)])})]))
|
ln([{"a": [1, 2]}], jv_id(0), 0, [dn({"a": [1, 2]}, jv_id(1), 1, {"a": ln([1, 2], jv_id(2), 2, [n(1), n(2)])})]))
|
||||||
])
|
])
|
||||||
def test_i_can_create_node(data, expected_node):
|
def test_i_can_create_node(data, expected_node):
|
||||||
json_viewer_ = JsonViewer(None, JSON_VIEWER_INSTANCE_ID, None, USER_ID, data)
|
json_viewer_ = JsonViewer(None, JSON_VIEWER_INSTANCE_ID, data)
|
||||||
assert json_viewer_.node == expected_node
|
assert json_viewer_.node == expected_node
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ def test_i_can_render(json_viewer):
|
|||||||
(None, Span("null", cls=f"{CLS_PREFIX}-null")),
|
(None, Span("null", cls=f"{CLS_PREFIX}-null")),
|
||||||
])
|
])
|
||||||
def test_i_can_render_simple_value(session, value, expected_inner):
|
def test_i_can_render_simple_value(session, value, expected_inner):
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
actual = jsonv.__ft__()
|
actual = jsonv.__ft__()
|
||||||
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
|
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
|
||||||
expected = Div(
|
expected = Div(
|
||||||
@@ -81,7 +81,7 @@ def test_i_can_render_simple_value(session, value, expected_inner):
|
|||||||
|
|
||||||
def test_i_can_render_expanded_list_node(session):
|
def test_i_can_render_expanded_list_node(session):
|
||||||
value = [1, "hello", True]
|
value = [1, "hello", True]
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
# Force expansion of the node
|
# Force expansion of the node
|
||||||
jsonv.set_folding_mode("expand")
|
jsonv.set_folding_mode("expand")
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def test_i_can_render_expanded_list_node(session):
|
|||||||
|
|
||||||
def test_i_can_render_expanded_dict_node(session):
|
def test_i_can_render_expanded_dict_node(session):
|
||||||
value = {"a": 1, "b": "hello", "c": True}
|
value = {"a": 1, "b": "hello", "c": True}
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
# Force expansion of the node
|
# Force expansion of the node
|
||||||
jsonv.set_folding_mode("expand")
|
jsonv.set_folding_mode("expand")
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ def test_i_can_render_expanded_dict_node(session):
|
|||||||
|
|
||||||
def test_i_can_render_expanded_list_of_dict_node(session):
|
def test_i_can_render_expanded_list_of_dict_node(session):
|
||||||
value = [{"a": 1, "b": "hello"}]
|
value = [{"a": 1, "b": "hello"}]
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
# Force expansion of all nodes
|
# Force expansion of all nodes
|
||||||
jsonv.set_folding_mode("expand")
|
jsonv.set_folding_mode("expand")
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ def test_i_can_render_expanded_list_of_dict_node(session):
|
|||||||
def test_render_with_collapse_folding_mode(session):
|
def test_render_with_collapse_folding_mode(session):
|
||||||
# Create a nested structure to test collapse rendering
|
# Create a nested structure to test collapse rendering
|
||||||
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
|
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
|
|
||||||
# Ensure folding mode is set to collapse (should be default)
|
# Ensure folding mode is set to collapse (should be default)
|
||||||
jsonv.set_folding_mode("collapse")
|
jsonv.set_folding_mode("collapse")
|
||||||
@@ -195,7 +195,7 @@ def test_render_with_collapse_folding_mode(session):
|
|||||||
def test_render_with_specific_node_expanded_in_collapse_mode(session):
|
def test_render_with_specific_node_expanded_in_collapse_mode(session):
|
||||||
# Create a nested structure to test mixed collapse/expand rendering
|
# Create a nested structure to test mixed collapse/expand rendering
|
||||||
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
|
value = {"a": [1, 2, 3], "b": {"x": "y", "z": True}}
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
|
|
||||||
# Ensure folding mode is set to collapse
|
# Ensure folding mode is set to collapse
|
||||||
jsonv.set_folding_mode(FoldingMode.COLLAPSE)
|
jsonv.set_folding_mode(FoldingMode.COLLAPSE)
|
||||||
@@ -230,7 +230,7 @@ def test_render_with_specific_node_expanded_in_collapse_mode(session):
|
|||||||
def test_multiple_folding_levels_in_collapse_mode(session):
|
def test_multiple_folding_levels_in_collapse_mode(session):
|
||||||
# Create a deeply nested structure
|
# Create a deeply nested structure
|
||||||
value = {"level1": {"level2": {"level3": [1, 2, 3]}}}
|
value = {"level1": {"level2": {"level3": [1, 2, 3]}}}
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
|
|
||||||
# Set folding mode to collapse
|
# Set folding mode to collapse
|
||||||
jsonv.set_folding_mode(FoldingMode.COLLAPSE)
|
jsonv.set_folding_mode(FoldingMode.COLLAPSE)
|
||||||
@@ -262,7 +262,7 @@ def test_multiple_folding_levels_in_collapse_mode(session):
|
|||||||
|
|
||||||
def test_toggle_between_folding_modes(session):
|
def test_toggle_between_folding_modes(session):
|
||||||
value = {"a": [1, 2, 3], "b": {"x": "y"}}
|
value = {"a": [1, 2, 3], "b": {"x": "y"}}
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, value)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, value)
|
||||||
|
|
||||||
# Start with collapse mode
|
# Start with collapse mode
|
||||||
jsonv.set_folding_mode("collapse")
|
jsonv.set_folding_mode("collapse")
|
||||||
@@ -271,19 +271,19 @@ def test_toggle_between_folding_modes(session):
|
|||||||
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "expand")
|
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "expand")
|
||||||
|
|
||||||
# Verify node is in tracked nodes (exceptions to collapse mode)
|
# Verify node is in tracked nodes (exceptions to collapse mode)
|
||||||
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._nodes_to_track
|
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._folding_manager.get_nodes_to_track()
|
||||||
|
|
||||||
# Now switch to expand mode
|
# Now switch to expand mode
|
||||||
jsonv.set_folding_mode("expand")
|
jsonv.set_folding_mode("expand")
|
||||||
|
|
||||||
# Tracked nodes should be cleared
|
# Tracked nodes should be cleared
|
||||||
assert len(jsonv._nodes_to_track) == 0
|
assert len(jsonv._folding_manager.get_nodes_to_track()) == 0
|
||||||
|
|
||||||
# Collapse specific node
|
# Collapse specific node
|
||||||
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "collapse")
|
jsonv.set_node_folding(f"{JSON_VIEWER_INSTANCE_ID}-0", "collapse")
|
||||||
|
|
||||||
# Verify node is in tracked nodes (exceptions to expand mode)
|
# Verify node is in tracked nodes (exceptions to expand mode)
|
||||||
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._nodes_to_track
|
assert f"{JSON_VIEWER_INSTANCE_ID}-0" in jsonv._folding_manager.get_nodes_to_track()
|
||||||
|
|
||||||
# Render and verify the output
|
# Render and verify the output
|
||||||
actual = jsonv.__ft__()
|
actual = jsonv.__ft__()
|
||||||
@@ -297,34 +297,43 @@ def test_toggle_between_folding_modes(session):
|
|||||||
|
|
||||||
|
|
||||||
def test_custom_hook_rendering(session, helper):
|
def test_custom_hook_rendering(session, helper):
|
||||||
# Define a custom hook for testing
|
# Define a custom condition to check if the value is "custom_hook_test"
|
||||||
def custom_predicate(key, node, h):
|
def custom_condition(context):
|
||||||
return isinstance(node.value, str) and node.value == "custom_hook_test"
|
return isinstance(context.node.value, str) and context.node.value == "custom_hook_test"
|
||||||
|
|
||||||
def custom_renderer(key, node, h):
|
# Define a custom executor to render the desired output
|
||||||
return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class")
|
def custom_renderer(context):
|
||||||
|
return Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class")
|
||||||
|
|
||||||
hooks = [(custom_predicate, custom_renderer)]
|
# Build the hook using HookBuilder
|
||||||
|
hook = (HookBuilder()
|
||||||
|
.on_render()
|
||||||
|
.when_custom(custom_condition)
|
||||||
|
.execute(custom_renderer))
|
||||||
|
|
||||||
# Create JsonViewer with the custom hook
|
# Create a JsonViewer with the new hook
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, "custom_hook_test", hooks=hooks)
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, "custom_hook_test", hooks=[hook])
|
||||||
|
|
||||||
actual = jsonv.__ft__()
|
# Actual rendered output
|
||||||
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
|
actual = jsonv.__ft__()
|
||||||
|
to_compare = search_elements_by_name(actual, "div", attrs={"id": f"{jv_id('root')}"})[0]
|
||||||
|
|
||||||
expected = Div(
|
# Expected rendered output
|
||||||
Div(
|
expected = Div(
|
||||||
None,
|
Div(
|
||||||
None,
|
None,
|
||||||
Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"),
|
None,
|
||||||
style=ML_20),
|
Span("CUSTOM_HOOK_RENDER", cls="custom-hook-class"),
|
||||||
id=f"{jv_id('root')}")
|
style=ML_20),
|
||||||
|
id=f"{jv_id('root')}"
|
||||||
|
)
|
||||||
|
|
||||||
assert matches(to_compare, expected)
|
# Assert that the actual output matches the expected output
|
||||||
|
assert matches(to_compare, expected)
|
||||||
|
|
||||||
|
|
||||||
def test_folding_mode_operations(session):
|
def test_folding_mode_operations(session):
|
||||||
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, None, USER_ID, {"a": [1, 2, 3]})
|
jsonv = JsonViewer(session, JSON_VIEWER_INSTANCE_ID, {"a": [1, 2, 3]})
|
||||||
|
|
||||||
# Check default folding mode
|
# Check default folding mode
|
||||||
assert jsonv.get_folding_mode() == "collapse"
|
assert jsonv.get_folding_mode() == "collapse"
|
||||||
@@ -338,11 +347,11 @@ def test_folding_mode_operations(session):
|
|||||||
jsonv.set_node_folding(node_id, "collapse")
|
jsonv.set_node_folding(node_id, "collapse")
|
||||||
|
|
||||||
# Node should be in tracked nodes since it differs from the default mode
|
# Node should be in tracked nodes since it differs from the default mode
|
||||||
assert node_id in jsonv._nodes_to_track
|
assert node_id in jsonv._folding_manager.get_nodes_to_track()
|
||||||
|
|
||||||
# Restore to match default mode
|
# Restore to match default mode
|
||||||
jsonv.set_node_folding(node_id, "expand")
|
jsonv.set_node_folding(node_id, "expand")
|
||||||
assert node_id not in jsonv._nodes_to_track
|
assert node_id not in jsonv._folding_manager.get_nodes_to_track()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("input_value, expected_output", [
|
@pytest.mark.parametrize("input_value, expected_output", [
|
||||||
@@ -353,7 +362,7 @@ def test_folding_mode_operations(session):
|
|||||||
('', '""'), # Empty string
|
('', '""'), # Empty string
|
||||||
])
|
])
|
||||||
def test_add_quotes(input_value, expected_output):
|
def test_add_quotes(input_value, expected_output):
|
||||||
result = JsonViewer.add_quotes(input_value)
|
result = JsonViewerHelper.add_quotes(input_value)
|
||||||
assert result == expected_output
|
assert result == expected_output
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ def test_run_simple_workflow(engine):
|
|||||||
assert result == [1, 2, 3]
|
assert result == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not yet implemented")
|
||||||
def test_process_single_item(engine):
|
def test_process_single_item(engine):
|
||||||
"""Test the internal _process_single_item method."""
|
"""Test the internal _process_single_item method."""
|
||||||
mock_processor = MagicMock(spec=DataProcessor)
|
mock_processor = MagicMock(spec=DataProcessor)
|
||||||
|
|||||||
Reference in New Issue
Block a user