quantum-framework 0.18.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantum/__init__.py +1 -0
- quantum/cli/__init__.py +2 -0
- quantum/cli/admin.py +131 -0
- quantum/cli/check.py +223 -0
- quantum/cli/commands/__init__.py +15 -0
- quantum/cli/commands/build.py +414 -0
- quantum/cli/commands/dev.py +185 -0
- quantum/cli/commands/docs.py +329 -0
- quantum/cli/commands/lint.py +523 -0
- quantum/cli/commands/migrate.py +548 -0
- quantum/cli/commands/new.py +622 -0
- quantum/cli/commands/serve.py +190 -0
- quantum/cli/commands/test.py +193 -0
- quantum/cli/deploy.py +810 -0
- quantum/cli/hot_reload.py +951 -0
- quantum/cli/jobs.py +356 -0
- quantum/cli/mq.py +582 -0
- quantum/cli/pkg.py +390 -0
- quantum/cli/runner.py +565 -0
- quantum/cli/server_process.py +159 -0
- quantum/cli/utils.py +334 -0
- quantum/compiler/__init__.py +30 -0
- quantum/compiler/base_generator.py +367 -0
- quantum/compiler/cli.py +295 -0
- quantum/compiler/expression_transformer.py +444 -0
- quantum/compiler/javascript/__init__.py +10 -0
- quantum/compiler/javascript/generator.py +659 -0
- quantum/compiler/optimizer.py +270 -0
- quantum/compiler/python/__init__.py +10 -0
- quantum/compiler/python/generator.py +883 -0
- quantum/compiler/python/runtime.py +863 -0
- quantum/compiler/transpiler.py +330 -0
- quantum/core/__init__.py +3 -0
- quantum/core/ast_nodes.py +2619 -0
- quantum/core/config_env.py +54 -0
- quantum/core/docs_links.py +8 -0
- quantum/core/expression_diagnostics.py +87 -0
- quantum/core/expression_stdlib.py +208 -0
- quantum/core/expressions.py +677 -0
- quantum/core/features/agents/src/__init__.py +30 -0
- quantum/core/features/agents/src/ast_node.py +540 -0
- quantum/core/features/conditionals/src/__init__.py +8 -0
- quantum/core/features/conditionals/src/ast_node.py +68 -0
- quantum/core/features/data_import/src/__init__.py +0 -0
- quantum/core/features/data_import/src/ast_node.py +292 -0
- quantum/core/features/data_import/src/runtime.py +533 -0
- quantum/core/features/dump/src/__init__.py +12 -0
- quantum/core/features/dump/src/ast_node.py +106 -0
- quantum/core/features/dump/src/parser.py +61 -0
- quantum/core/features/dump/src/runtime.py +246 -0
- quantum/core/features/functions/src/__init__.py +8 -0
- quantum/core/features/functions/src/ast_node.py +149 -0
- quantum/core/features/game_engine_2d/src/__init__.py +19 -0
- quantum/core/features/game_engine_2d/src/ast_nodes.py +1719 -0
- quantum/core/features/game_engine_2d/src/parser.py +983 -0
- quantum/core/features/invocation/src/__init__.py +0 -0
- quantum/core/features/invocation/src/ast_node.py +147 -0
- quantum/core/features/invocation/src/runtime.py +332 -0
- quantum/core/features/knowledge_base/src/__init__.py +6 -0
- quantum/core/features/knowledge_base/src/ast_node.py +113 -0
- quantum/core/features/knowledge_base/src/parser.py +82 -0
- quantum/core/features/logging/src/__init__.py +12 -0
- quantum/core/features/logging/src/ast_node.py +111 -0
- quantum/core/features/logging/src/parser.py +50 -0
- quantum/core/features/logging/src/runtime.py +190 -0
- quantum/core/features/loops/src/__init__.py +8 -0
- quantum/core/features/loops/src/ast_node.py +60 -0
- quantum/core/features/query/src/__init__.py +0 -0
- quantum/core/features/query/src/query_validators.py +20 -0
- quantum/core/features/state_management/src/__init__.py +11 -0
- quantum/core/features/state_management/src/ast_node.py +121 -0
- quantum/core/features/terminal_engine/src/__init__.py +21 -0
- quantum/core/features/terminal_engine/src/ast_nodes.py +560 -0
- quantum/core/features/terminal_engine/src/parser.py +361 -0
- quantum/core/features/testing_engine/src/__init__.py +41 -0
- quantum/core/features/testing_engine/src/ast_nodes.py +1212 -0
- quantum/core/features/testing_engine/src/parser.py +604 -0
- quantum/core/features/theming/src/__init__.py +48 -0
- quantum/core/features/theming/src/ast_node.py +137 -0
- quantum/core/features/theming/src/presets.py +405 -0
- quantum/core/features/ui_engine/src/__init__.py +20 -0
- quantum/core/features/ui_engine/src/ast_nodes.py +1913 -0
- quantum/core/features/ui_engine/src/parser.py +1192 -0
- quantum/core/features/websocket/src/__init__.py +24 -0
- quantum/core/features/websocket/src/ast_node.py +247 -0
- quantum/core/guards.py +53 -0
- quantum/core/html_compat.py +299 -0
- quantum/core/parser.py +1551 -0
- quantum/core/parser_registry.py +213 -0
- quantum/core/parsers/__init__.py +76 -0
- quantum/core/parsers/ai/__init__.py +12 -0
- quantum/core/parsers/ai/agent_parser.py +106 -0
- quantum/core/parsers/ai/knowledge_parser.py +86 -0
- quantum/core/parsers/ai/llm_parser.py +78 -0
- quantum/core/parsers/ai/team_parser.py +88 -0
- quantum/core/parsers/base.py +322 -0
- quantum/core/parsers/composition/__init__.py +10 -0
- quantum/core/parsers/composition/import_parser.py +50 -0
- quantum/core/parsers/composition/slot_parser.py +48 -0
- quantum/core/parsers/control_flow/__init__.py +11 -0
- quantum/core/parsers/control_flow/if_parser.py +71 -0
- quantum/core/parsers/control_flow/loop_parser.py +105 -0
- quantum/core/parsers/control_flow/set_parser.py +93 -0
- quantum/core/parsers/data/__init__.py +12 -0
- quantum/core/parsers/data/data_parser.py +229 -0
- quantum/core/parsers/data/invoke_parser.py +133 -0
- quantum/core/parsers/data/query_parser.py +213 -0
- quantum/core/parsers/data/transaction_parser.py +86 -0
- quantum/core/parsers/events/__init__.py +9 -0
- quantum/core/parsers/events/dispatch_event_parser.py +44 -0
- quantum/core/parsers/forms/__init__.py +11 -0
- quantum/core/parsers/forms/action_parser.py +79 -0
- quantum/core/parsers/forms/flash_parser.py +34 -0
- quantum/core/parsers/forms/redirect_parser.py +33 -0
- quantum/core/parsers/functions/__init__.py +11 -0
- quantum/core/parsers/functions/function_parser.py +98 -0
- quantum/core/parsers/functions/param_parser.py +73 -0
- quantum/core/parsers/functions/return_parser.py +31 -0
- quantum/core/parsers/html/__init__.py +10 -0
- quantum/core/parsers/html/component_call_parser.py +130 -0
- quantum/core/parsers/html/html_parser.py +115 -0
- quantum/core/parsers/jobs/__init__.py +11 -0
- quantum/core/parsers/jobs/job_parser.py +71 -0
- quantum/core/parsers/jobs/schedule_parser.py +62 -0
- quantum/core/parsers/jobs/thread_parser.py +57 -0
- quantum/core/parsers/messaging/__init__.py +17 -0
- quantum/core/parsers/messaging/message_ack_parser.py +30 -0
- quantum/core/parsers/messaging/message_nack_parser.py +30 -0
- quantum/core/parsers/messaging/message_parser.py +114 -0
- quantum/core/parsers/messaging/queue_parser.py +61 -0
- quantum/core/parsers/messaging/websocket_parser.py +121 -0
- quantum/core/parsers/routing/__init__.py +9 -0
- quantum/core/parsers/routing/route_parser.py +41 -0
- quantum/core/parsers/scripting/__init__.py +12 -0
- quantum/core/parsers/scripting/pyclass_parser.py +62 -0
- quantum/core/parsers/scripting/pydecorator_parser.py +68 -0
- quantum/core/parsers/scripting/pyimport_parser.py +52 -0
- quantum/core/parsers/scripting/python_parser.py +49 -0
- quantum/core/parsers/services/__init__.py +12 -0
- quantum/core/parsers/services/dump_parser.py +52 -0
- quantum/core/parsers/services/file_parser.py +48 -0
- quantum/core/parsers/services/log_parser.py +46 -0
- quantum/core/parsers/services/mail_parser.py +65 -0
- quantum/core/tiers.py +164 -0
- quantum/core/xml_lines.py +81 -0
- quantum/packages/__init__.py +28 -0
- quantum/packages/manager.py +413 -0
- quantum/packages/manifest.py +351 -0
- quantum/packages/registry.py +399 -0
- quantum/packages/resolver.py +336 -0
- quantum/plugins/__init__.py +33 -0
- quantum/plugins/hooks.py +329 -0
- quantum/plugins/loader.py +479 -0
- quantum/plugins/manifest.py +336 -0
- quantum/plugins/registry.py +371 -0
- quantum/runtime/__init__.py +25 -0
- quantum/runtime/action_handler.py +532 -0
- quantum/runtime/adapters/__init__.py +88 -0
- quantum/runtime/adapters/memory_adapter.py +690 -0
- quantum/runtime/adapters/rabbitmq_adapter.py +715 -0
- quantum/runtime/adapters/redis_adapter.py +582 -0
- quantum/runtime/adapters/sqlite_adapter.py +414 -0
- quantum/runtime/agent_service.py +1133 -0
- quantum/runtime/ast_cache.py +506 -0
- quantum/runtime/auth_service.py +267 -0
- quantum/runtime/component.py +1048 -0
- quantum/runtime/component_composer.py +133 -0
- quantum/runtime/component_resolver.py +174 -0
- quantum/runtime/conditions.py +54 -0
- quantum/runtime/database_service.py +595 -0
- quantum/runtime/db_schema.py +100 -0
- quantum/runtime/dev_panel.py +178 -0
- quantum/runtime/email_service.py +162 -0
- quantum/runtime/error_handler.py +295 -0
- quantum/runtime/execution_context.py +283 -0
- quantum/runtime/executor_registry.py +178 -0
- quantum/runtime/executors/__init__.py +71 -0
- quantum/runtime/executors/ai/__init__.py +12 -0
- quantum/runtime/executors/ai/agent_executor.py +217 -0
- quantum/runtime/executors/ai/knowledge_executor.py +114 -0
- quantum/runtime/executors/ai/llm_executor.py +153 -0
- quantum/runtime/executors/ai/team_executor.py +171 -0
- quantum/runtime/executors/base.py +264 -0
- quantum/runtime/executors/control_flow/__init__.py +12 -0
- quantum/runtime/executors/control_flow/if_executor.py +98 -0
- quantum/runtime/executors/control_flow/loop_executor.py +308 -0
- quantum/runtime/executors/control_flow/redirect_executor.py +43 -0
- quantum/runtime/executors/control_flow/set_executor.py +464 -0
- quantum/runtime/executors/data/__init__.py +12 -0
- quantum/runtime/executors/data/data_executor.py +156 -0
- quantum/runtime/executors/data/invoke_executor.py +228 -0
- quantum/runtime/executors/data/query_executor.py +290 -0
- quantum/runtime/executors/data/transaction_executor.py +91 -0
- quantum/runtime/executors/jobs/__init__.py +11 -0
- quantum/runtime/executors/jobs/job_executor.py +190 -0
- quantum/runtime/executors/jobs/schedule_executor.py +132 -0
- quantum/runtime/executors/jobs/thread_executor.py +127 -0
- quantum/runtime/executors/messaging/__init__.py +17 -0
- quantum/runtime/executors/messaging/message_ack_executor.py +51 -0
- quantum/runtime/executors/messaging/message_executor.py +174 -0
- quantum/runtime/executors/messaging/queue_executor.py +103 -0
- quantum/runtime/executors/messaging/websocket_executor.py +197 -0
- quantum/runtime/executors/scripting/__init__.py +11 -0
- quantum/runtime/executors/scripting/pyclass_executor.py +90 -0
- quantum/runtime/executors/scripting/pyimport_executor.py +81 -0
- quantum/runtime/executors/scripting/python_executor.py +249 -0
- quantum/runtime/executors/services/__init__.py +12 -0
- quantum/runtime/executors/services/dump_executor.py +72 -0
- quantum/runtime/executors/services/file_executor.py +89 -0
- quantum/runtime/executors/services/log_executor.py +77 -0
- quantum/runtime/executors/services/mail_executor.py +81 -0
- quantum/runtime/expression_cache.py +498 -0
- quantum/runtime/file_upload_service.py +326 -0
- quantum/runtime/function_registry.py +118 -0
- quantum/runtime/game_builder.py +166 -0
- quantum/runtime/game_code_generator.py +2371 -0
- quantum/runtime/game_templates.py +2006 -0
- quantum/runtime/godot_code_generator.py +4681 -0
- quantum/runtime/godot_templates.py +1449 -0
- quantum/runtime/job_executor.py +1599 -0
- quantum/runtime/knowledge_service.py +546 -0
- quantum/runtime/llm_cache.py +100 -0
- quantum/runtime/llm_providers.py +704 -0
- quantum/runtime/llm_service.py +287 -0
- quantum/runtime/local_server.py +28 -0
- quantum/runtime/logging_setup.py +141 -0
- quantum/runtime/message_broker.py +364 -0
- quantum/runtime/message_queue_service.py +571 -0
- quantum/runtime/param_validation.py +218 -0
- quantum/runtime/pypy_compat.py +317 -0
- quantum/runtime/python_bridge.py +698 -0
- quantum/runtime/query_validators.py +304 -0
- quantum/runtime/renderer.py +734 -0
- quantum/runtime/service_container.py +470 -0
- quantum/runtime/table_params.py +163 -0
- quantum/runtime/terminal_builder.py +76 -0
- quantum/runtime/terminal_code_generator.py +607 -0
- quantum/runtime/terminal_templates.py +243 -0
- quantum/runtime/testing_builder.py +77 -0
- quantum/runtime/testing_code_generator.py +833 -0
- quantum/runtime/testing_templates.py +85 -0
- quantum/runtime/ui_builder.py +222 -0
- quantum/runtime/ui_console.py +405 -0
- quantum/runtime/ui_desktop.py +56 -0
- quantum/runtime/ui_form_rules.py +193 -0
- quantum/runtime/ui_html_adapter.py +2567 -0
- quantum/runtime/ui_html_templates.py +2106 -0
- quantum/runtime/ui_mobile_adapter.py +1832 -0
- quantum/runtime/ui_mobile_templates.py +1003 -0
- quantum/runtime/ui_page_renderer.py +392 -0
- quantum/runtime/ui_pager.py +73 -0
- quantum/runtime/ui_table_edit.py +139 -0
- quantum/runtime/ui_textual_adapter.py +1866 -0
- quantum/runtime/ui_textual_templates.py +45 -0
- quantum/runtime/ui_tokens.py +465 -0
- quantum/runtime/ui_validator.py +365 -0
- quantum/runtime/ui_view.py +221 -0
- quantum/runtime/validators.py +256 -0
- quantum/runtime/web_server.py +2131 -0
- quantum/runtime/websocket_adapter.py +501 -0
- quantum/runtime/websocket_service.py +585 -0
- quantum/runtime/websocket_transport.py +289 -0
- quantum/runtime/wsgi.py +101 -0
- quantum/services.py +109 -0
- quantum/utils/__init__.py +1 -0
- quantum_admin/__init__.py +4 -0
- quantum_admin/assets/quantum-admin-connectors.js +123 -0
- quantum_admin/assets/quantum-admin.css +2058 -0
- quantum_admin/assets/quantum-source-highlight.js +106 -0
- quantum_admin/backend/__init__.py +4 -0
- quantum_admin/backend/audit_service.py +346 -0
- quantum_admin/backend/auth_service.py +394 -0
- quantum_admin/backend/component_discovery.py +766 -0
- quantum_admin/backend/config_generator.py +586 -0
- quantum_admin/backend/connector_service.py +1016 -0
- quantum_admin/backend/crud.py +557 -0
- quantum_admin/backend/database.py +226 -0
- quantum_admin/backend/db_setup_service.py +193 -0
- quantum_admin/backend/deploy_service.py +698 -0
- quantum_admin/backend/docker_service.py +422 -0
- quantum_admin/backend/environment_service.py +418 -0
- quantum_admin/backend/git_service.py +370 -0
- quantum_admin/backend/health_service.py +390 -0
- quantum_admin/backend/job_service.py +347 -0
- quantum_admin/backend/log_service.py +331 -0
- quantum_admin/backend/main.py +14426 -0
- quantum_admin/backend/models.py +986 -0
- quantum_admin/backend/pipeline_service.py +812 -0
- quantum_admin/backend/resource_manager.py +1118 -0
- quantum_admin/backend/schemas.py +419 -0
- quantum_admin/backend/secret_manager.py +277 -0
- quantum_admin/backend/settings_service.py +488 -0
- quantum_admin/backend/template_service.py +710 -0
- quantum_admin/backend/test_execution_service.py +364 -0
- quantum_admin/backend/test_generator.py +782 -0
- quantum_admin/backend/webhook_service.py +454 -0
- quantum_admin/backend/websocket_manager.py +360 -0
- quantum_admin/components/admin/_layout/AdminShell.q +121 -0
- quantum_admin/components/admin/agents.q +107 -0
- quantum_admin/components/admin/app/[name].q +558 -0
- quantum_admin/components/admin/applications.q +160 -0
- quantum_admin/components/admin/component/[...path].q +205 -0
- quantum_admin/components/admin/components.q +59 -0
- quantum_admin/components/admin/connectors.q +283 -0
- quantum_admin/components/admin/dashboard.q +86 -0
- quantum_admin/components/admin/database.q +95 -0
- quantum_admin/components/admin/features.q +69 -0
- quantum_admin/components/admin/index.q +12 -0
- quantum_admin/components/admin/jobs.q +75 -0
- quantum_admin/components/admin/login.q +65 -0
- quantum_admin/components/admin/logout.q +11 -0
- quantum_admin/components/admin/settings.q +174 -0
- quantum_admin/components/admin/source.q +45 -0
- quantum_admin/components/admin/tests.q +73 -0
- quantum_admin/run.py +13 -0
- quantum_admin/services/__init__.py +16 -0
- quantum_admin/services/_base.py +84 -0
- quantum_admin/services/_component_test_generator.py +205 -0
- quantum_admin/services/_yaml_editor.py +82 -0
- quantum_admin/services/apps.py +330 -0
- quantum_admin/services/auth.py +61 -0
- quantum_admin/services/components.py +210 -0
- quantum_admin/services/connectors.py +164 -0
- quantum_admin/services/projects.py +189 -0
- quantum_admin/services/repository.py +223 -0
- quantum_admin/services/settings.py +150 -0
- quantum_admin/services/yaml_import.py +118 -0
- quantum_framework-0.18.0.dist-info/METADATA +281 -0
- quantum_framework-0.18.0.dist-info/RECORD +333 -0
- quantum_framework-0.18.0.dist-info/WHEEL +5 -0
- quantum_framework-0.18.0.dist-info/entry_points.txt +2 -0
- quantum_framework-0.18.0.dist-info/licenses/LICENSE +21 -0
- quantum_framework-0.18.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""`ui:*` inside a served page (UI-1, PLANO_V1.md U1).
|
|
2
|
+
|
|
3
|
+
The UI Engine used to be a separate world: `<q:application type="ui">`
|
|
4
|
+
compiled to a file, where each target translated the page's logic on its own.
|
|
5
|
+
Here the page's runtime does the logic — queries, actions, the SPEC's rules —
|
|
6
|
+
and this class only draws: it reuses the HTML adapter's drawing of each
|
|
7
|
+
`ui:*` element and hands everything else (q:loop, q:if, HTML, components)
|
|
8
|
+
back to the page renderer.
|
|
9
|
+
|
|
10
|
+
What changes from the standalone adapter:
|
|
11
|
+
|
|
12
|
+
- `{expressions}` are resolved by the page's runtime, and every resolved value
|
|
13
|
+
is HTML-escaped (the adapter's builder escapes nothing).
|
|
14
|
+
- Events are actions: `<ui:button on-click="save">` posts to the page's
|
|
15
|
+
`q:action name="save"`; `with="id={row.id}"` adds fields to that post.
|
|
16
|
+
`<ui:form on-submit="create">` is a form that posts its fields to `create`.
|
|
17
|
+
An event that names no q:action of the page is an error.
|
|
18
|
+
- The adapter's CSS for the elements comes once per page, without its global
|
|
19
|
+
reset (the page keeps its own styles).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import copy
|
|
23
|
+
import html
|
|
24
|
+
from typing import Any, Iterator, List, Optional
|
|
25
|
+
|
|
26
|
+
from quantum.core.features.ui_engine.src import ast_nodes as ui_nodes
|
|
27
|
+
from quantum.runtime.ui_html_adapter import UIHtmlAdapter
|
|
28
|
+
from quantum.runtime.ui_form_rules import FORM_CSS, form_context, generated_fields, prepare_field
|
|
29
|
+
from quantum.runtime.ui_html_templates import CSS_THEME, RESPONSIVE_CSS, HtmlBuilder
|
|
30
|
+
from quantum.runtime.ui_pager import PAGER_CSS, pager_items
|
|
31
|
+
from quantum.runtime.ui_table_edit import CELL_CSS, cell_field, edit_plan, sort_link
|
|
32
|
+
from quantum.core.xml_lines import tag_error
|
|
33
|
+
|
|
34
|
+
# Attributes that name an action or a field, never text to resolve. `source`
|
|
35
|
+
# is data (a list), resolved by source_rows().
|
|
36
|
+
_NOT_TEXT = {'on_click', 'on_submit', 'on_change', 'on_select', 'bind', 'children', 'send', 'source',
|
|
37
|
+
'values'}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
is_ui_node = ui_nodes.is_ui_node
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def source_rows(page, node, where: str, default_var: str) -> Iterator[Any]:
|
|
44
|
+
"""UI-5: once per row of a `source="{...}"`, with the row variable set as
|
|
45
|
+
q:loop sets it (`row`, and `row.field` for dict rows). A source that is not
|
|
46
|
+
a list is an error — never an empty table."""
|
|
47
|
+
fonte = (node.source or '').strip()
|
|
48
|
+
linhas = page._apply_databinding(fonte) if fonte.startswith('{') and fonte.endswith('}') else None
|
|
49
|
+
if not isinstance(linhas, list):
|
|
50
|
+
tipo = 'no {expression}' if linhas is None and not fonte.startswith('{') else type(linhas).__name__
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f'<{where} source="{fonte}"> needs a list — a q:query or an array, as '
|
|
53
|
+
f'source="{{rows}}" — and got {tipo} (UI-5).')
|
|
54
|
+
var = node.as_var or default_var
|
|
55
|
+
ctx = page.context
|
|
56
|
+
originais = ctx.local_vars.copy()
|
|
57
|
+
try:
|
|
58
|
+
for linha in linhas:
|
|
59
|
+
ctx.set_variable(var, linha, scope='local')
|
|
60
|
+
if isinstance(linha, dict):
|
|
61
|
+
for campo, valor in linha.items():
|
|
62
|
+
ctx.set_variable(f'{var}.{campo}', valor, scope='local')
|
|
63
|
+
yield linha
|
|
64
|
+
finally:
|
|
65
|
+
ctx.local_vars = originais
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _single_name(expr) -> Optional[str]:
|
|
69
|
+
import re as _re
|
|
70
|
+
m = _re.fullmatch(r'\s*\{\s*(\w+)\s*\}\s*', expr or '')
|
|
71
|
+
return m.group(1) if m else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cell_value(linha: Any, coluna) -> str:
|
|
75
|
+
"""UI-5: the text of a `<ui:column key>` cell; a key the row lacks is an error."""
|
|
76
|
+
if not isinstance(linha, dict):
|
|
77
|
+
return '' if linha is None else str(linha)
|
|
78
|
+
if coluna.key not in linha:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f'<ui:column key="{coluna.key}"> (UI-5): the row has no field "{coluna.key}" '
|
|
81
|
+
f'(fields: {", ".join(map(str, linha)) or "none"}).')
|
|
82
|
+
valor = linha[coluna.key]
|
|
83
|
+
return '' if valor is None else str(valor)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class UIPageRenderer(UIHtmlAdapter):
|
|
87
|
+
|
|
88
|
+
def __init__(self, page, action_names: List[str]):
|
|
89
|
+
super().__init__()
|
|
90
|
+
self._page = page
|
|
91
|
+
self._actions = set(action_names)
|
|
92
|
+
|
|
93
|
+
# -- entry -------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def render(self, node) -> str:
|
|
96
|
+
b = HtmlBuilder()
|
|
97
|
+
self._render_node(node, b)
|
|
98
|
+
return b.build()
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def css() -> str:
|
|
102
|
+
return CSS_THEME + '\n' + RESPONSIVE_CSS + '\n' + FORM_CSS + '\n' + PAGER_CSS + '\n' + CELL_CSS
|
|
103
|
+
|
|
104
|
+
# -- dispatch ----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _render_node_ui(self, node, b: HtmlBuilder):
|
|
107
|
+
try:
|
|
108
|
+
if is_ui_node(node):
|
|
109
|
+
super()._render_node(self._resolved(node), b)
|
|
110
|
+
else:
|
|
111
|
+
b.raw(self._page.render(node))
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
# DEV-2: the innermost ui:* element that failed names its line.
|
|
114
|
+
tag_error(exc, node)
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
def _render_children(self, children: list, b: HtmlBuilder):
|
|
118
|
+
for child in children:
|
|
119
|
+
self._render_node(child, b)
|
|
120
|
+
|
|
121
|
+
def _resolved(self, node):
|
|
122
|
+
"""A copy of the node with every `{expression}` resolved and escaped."""
|
|
123
|
+
copia = copy.copy(node)
|
|
124
|
+
for nome, valor in vars(node).items():
|
|
125
|
+
if nome in _NOT_TEXT or not isinstance(valor, str) or '{' not in valor:
|
|
126
|
+
continue
|
|
127
|
+
resolvido = self._page._apply_databinding(valor)
|
|
128
|
+
setattr(copia, nome, html.escape('' if resolvido is None else str(resolvido), quote=True))
|
|
129
|
+
return copia
|
|
130
|
+
|
|
131
|
+
# -- events are actions ------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _action(self, nome: str, onde: str) -> str:
|
|
134
|
+
nome = (nome or '').strip()
|
|
135
|
+
if nome.endswith('()'):
|
|
136
|
+
nome = nome[:-2]
|
|
137
|
+
if nome not in self._actions:
|
|
138
|
+
disponiveis = ', '.join(sorted(self._actions)) or 'none'
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f'{onde}="{nome}" names no q:action of this page (UI-1; actions: {disponiveis}). '
|
|
141
|
+
f'Inside a page, a ui:* event is a q:action.')
|
|
142
|
+
return nome
|
|
143
|
+
|
|
144
|
+
def _hidden_fields(self, b: HtmlBuilder, acao: str, envio: str = None):
|
|
145
|
+
b.open_tag('input', {'type': 'hidden', 'name': 'action', 'value': acao}, self_closing=True)
|
|
146
|
+
if envio:
|
|
147
|
+
resolvido = str(self._page._apply_databinding(envio))
|
|
148
|
+
for par in resolvido.split(','):
|
|
149
|
+
if '=' not in par:
|
|
150
|
+
continue
|
|
151
|
+
campo, valor = (p.strip() for p in par.split('=', 1))
|
|
152
|
+
b.open_tag('input', {'type': 'hidden', 'name': html.escape(campo, quote=True),
|
|
153
|
+
'value': html.escape(valor, quote=True)}, self_closing=True)
|
|
154
|
+
|
|
155
|
+
def _render_button(self, node, b: HtmlBuilder):
|
|
156
|
+
if not node.on_click:
|
|
157
|
+
return super()._render_button(node, b)
|
|
158
|
+
acao = self._action(node.on_click, '<ui:button on-click')
|
|
159
|
+
b.open_tag('form', {'method': 'post', 'class': 'q-action', 'style': 'display: inline'})
|
|
160
|
+
b.indent()
|
|
161
|
+
self._hidden_fields(b, acao, getattr(node, 'send', None))
|
|
162
|
+
semeventos = copy.copy(node)
|
|
163
|
+
semeventos.on_click = None
|
|
164
|
+
super()._render_button(semeventos, b)
|
|
165
|
+
b.dedent()
|
|
166
|
+
b.close_tag('form')
|
|
167
|
+
|
|
168
|
+
def _render_form(self, node, b: HtmlBuilder):
|
|
169
|
+
attrs = {'class': 'q-form', 'method': 'post'}
|
|
170
|
+
b.open_tag('form', self._merge_attrs(attrs, self._layout_attrs(node)))
|
|
171
|
+
b.indent()
|
|
172
|
+
acao = None
|
|
173
|
+
if node.on_submit:
|
|
174
|
+
acao = self._action(node.on_submit, '<ui:form on-submit')
|
|
175
|
+
self._hidden_fields(b, acao, getattr(node, 'send', None))
|
|
176
|
+
anterior = getattr(self, '_form_ctx', None)
|
|
177
|
+
self._form_ctx = form_context(self._page, node, acao) # UI-9
|
|
178
|
+
try:
|
|
179
|
+
self._render_children(generated_fields(node, self._form_ctx), b) # UI-10
|
|
180
|
+
finally:
|
|
181
|
+
self._form_ctx = anterior
|
|
182
|
+
b.dedent()
|
|
183
|
+
b.close_tag('form')
|
|
184
|
+
|
|
185
|
+
# -- fields take the action's rules, the values sent back, their error (UI-9)
|
|
186
|
+
|
|
187
|
+
def _field(self, desenhar, node, b: HtmlBuilder):
|
|
188
|
+
copia, erro = prepare_field(node, getattr(self, '_form_ctx', None), escape=True)
|
|
189
|
+
desenhar(copia, b)
|
|
190
|
+
if erro:
|
|
191
|
+
b.open_tag('span', {'class': 'q-field-error', 'role': 'alert'})
|
|
192
|
+
b.text(html.escape(erro))
|
|
193
|
+
b.close_tag('span')
|
|
194
|
+
|
|
195
|
+
def _render_input(self, node, b: HtmlBuilder):
|
|
196
|
+
if getattr(node, 'search', None):
|
|
197
|
+
return self._render_search(node, b)
|
|
198
|
+
self._field(super()._render_input, node, b)
|
|
199
|
+
|
|
200
|
+
# -- search as you type (UI-12) -------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _render_search(self, node, b: HtmlBuilder):
|
|
203
|
+
"""A GET form around the field: Enter searches without JavaScript; with
|
|
204
|
+
htmx, each pause in typing asks for the same page and swaps only the
|
|
205
|
+
target region. The other URL parameters are kept; `page` is not (a new
|
|
206
|
+
search starts on page 1)."""
|
|
207
|
+
if getattr(self, '_form_ctx', None) is not None:
|
|
208
|
+
raise ValueError(f'<ui:input bind="{node.bind}" search=...> cannot be inside a ui:form: '
|
|
209
|
+
f'it is a form of its own (a GET search) (UI-12)')
|
|
210
|
+
alvo = node.search
|
|
211
|
+
self._page._search_targets = getattr(self._page, '_search_targets', set()) | {alvo}
|
|
212
|
+
contexto = self._page.context
|
|
213
|
+
caminho = (getattr(contexto, 'request_vars', None) or {}).get('path') or ''
|
|
214
|
+
try:
|
|
215
|
+
atuais = contexto.get_variable('query')
|
|
216
|
+
except Exception:
|
|
217
|
+
atuais = None
|
|
218
|
+
atuais = atuais if isinstance(atuais, dict) else {}
|
|
219
|
+
campo = copy.copy(node)
|
|
220
|
+
if campo.value in (None, ''):
|
|
221
|
+
valor = atuais.get(node.bind)
|
|
222
|
+
campo.value = html.escape(str(valor), quote=True) if valor not in (None, '') else None
|
|
223
|
+
campo.input_type = 'search' if (campo.input_type or 'text') == 'text' else campo.input_type
|
|
224
|
+
b.open_tag('form', {'method': 'get', 'role': 'search', 'class': 'q-search',
|
|
225
|
+
'action': html.escape(caminho, quote=True)})
|
|
226
|
+
b.indent()
|
|
227
|
+
for nome, valor in atuais.items():
|
|
228
|
+
if nome in (node.bind, 'page'):
|
|
229
|
+
continue
|
|
230
|
+
b.open_tag('input', {'type': 'hidden', 'name': html.escape(str(nome), quote=True),
|
|
231
|
+
'value': html.escape(str(valor), quote=True)}, self_closing=True)
|
|
232
|
+
interno = HtmlBuilder()
|
|
233
|
+
super()._render_input(campo, interno)
|
|
234
|
+
hx = (f'hx-get="{html.escape(caminho, quote=True)}" '
|
|
235
|
+
f'hx-trigger="input changed delay:{int(node.delay)}ms, search" '
|
|
236
|
+
f'hx-target="#{html.escape(alvo, quote=True)}" hx-select="#{html.escape(alvo, quote=True)}" '
|
|
237
|
+
f'hx-swap="outerHTML" hx-push-url="true" hx-include="closest form" ')
|
|
238
|
+
b.raw(interno.build().replace('<input ', f'<input {hx}', 1))
|
|
239
|
+
b.dedent()
|
|
240
|
+
b.close_tag('form')
|
|
241
|
+
|
|
242
|
+
def _render_select(self, node, b: HtmlBuilder):
|
|
243
|
+
self._field(super()._render_select, node, b)
|
|
244
|
+
|
|
245
|
+
def _render_radio(self, node, b: HtmlBuilder):
|
|
246
|
+
self._field(super()._render_radio, node, b)
|
|
247
|
+
|
|
248
|
+
def _render_checkbox(self, node, b: HtmlBuilder):
|
|
249
|
+
self._field(super()._render_checkbox, node, b)
|
|
250
|
+
|
|
251
|
+
def _render_switch(self, node, b: HtmlBuilder):
|
|
252
|
+
self._field(super()._render_switch, node, b)
|
|
253
|
+
|
|
254
|
+
# -- <ui:pager> (UI-11) ----------------------------------------------------
|
|
255
|
+
|
|
256
|
+
def _render_node(self, node, b: HtmlBuilder):
|
|
257
|
+
if isinstance(node, ui_nodes.UIPagerNode):
|
|
258
|
+
try:
|
|
259
|
+
return self._render_pager(node, b)
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
tag_error(exc, node)
|
|
262
|
+
raise
|
|
263
|
+
return self._render_node_ui(node, b)
|
|
264
|
+
|
|
265
|
+
def _render_pager(self, node, b: HtmlBuilder):
|
|
266
|
+
itens = pager_items(self._page, node)
|
|
267
|
+
if not itens:
|
|
268
|
+
return
|
|
269
|
+
b.open_tag('nav', self._merge_attrs({'class': 'q-pager', 'aria-label': 'Pages'}, self._layout_attrs(node)))
|
|
270
|
+
for item in itens:
|
|
271
|
+
if item['href']:
|
|
272
|
+
b.open_tag('a', {'href': html.escape(item['href'], quote=True),
|
|
273
|
+
'aria-label': item['aria'], 'class': f"q-page-{item['kind']}"})
|
|
274
|
+
b.text(html.escape(item['label']))
|
|
275
|
+
b.close_tag('a')
|
|
276
|
+
else:
|
|
277
|
+
classe = ('q-page-current' if item['current'] else
|
|
278
|
+
'q-page-gap' if item['kind'] == 'gap' else 'q-page-disabled')
|
|
279
|
+
atributos = {'class': classe}
|
|
280
|
+
if item['current']:
|
|
281
|
+
atributos['aria-current'] = 'page'
|
|
282
|
+
b.open_tag('span', atributos)
|
|
283
|
+
b.text(html.escape(item['label']))
|
|
284
|
+
b.close_tag('span')
|
|
285
|
+
b.close_tag('nav')
|
|
286
|
+
|
|
287
|
+
# -- a section is a titled group, open (UI-7) ------------------------------
|
|
288
|
+
|
|
289
|
+
def _render_section(self, node, b: HtmlBuilder):
|
|
290
|
+
# The standalone build draws a collapsed <details>: in a page its
|
|
291
|
+
# content was hidden in the browser and shown in the console.
|
|
292
|
+
b.open_tag('section', self._merge_attrs({'class': 'q-section-group'}, self._layout_attrs(node)))
|
|
293
|
+
b.indent()
|
|
294
|
+
if node.title:
|
|
295
|
+
b.open_tag('div', {'class': 'q-section-title'})
|
|
296
|
+
b.text(node.title)
|
|
297
|
+
b.close_tag('div')
|
|
298
|
+
self._render_children(node.children, b)
|
|
299
|
+
b.dedent()
|
|
300
|
+
b.close_tag('section')
|
|
301
|
+
|
|
302
|
+
# -- data: source="{rows}" -----------------------------------------------
|
|
303
|
+
|
|
304
|
+
def _render_table(self, node, b: HtmlBuilder):
|
|
305
|
+
if not node.source:
|
|
306
|
+
return super()._render_table(node, b)
|
|
307
|
+
colunas = [c for c in node.children if isinstance(c, ui_nodes.UIColumnNode)]
|
|
308
|
+
if getattr(node, 'sort', False):
|
|
309
|
+
self._check_sortable(node)
|
|
310
|
+
plano = edit_plan(self._page, node) # UI-13
|
|
311
|
+
estado = getattr(self._page, 'form_state', None)
|
|
312
|
+
b.open_tag('table', self._merge_attrs({'class': 'q-table'}, self._layout_attrs(node)))
|
|
313
|
+
b.indent()
|
|
314
|
+
b.open_tag('thead')
|
|
315
|
+
b.open_tag('tr')
|
|
316
|
+
for coluna in colunas:
|
|
317
|
+
estilo = f'text-align: {coluna.align}' if coluna.align else None
|
|
318
|
+
b.open_tag('th', {'style': estilo} if estilo else None)
|
|
319
|
+
rotulo = html.escape(coluna.label or coluna.key or '')
|
|
320
|
+
if getattr(node, 'sort', False) and coluna.key and not coluna.children:
|
|
321
|
+
href, marca = sort_link(self._page, coluna.key)
|
|
322
|
+
b.open_tag('a', {'href': html.escape(href, quote=True), 'class': 'q-sort'})
|
|
323
|
+
b.text(rotulo + (f' {marca}' if marca else ''))
|
|
324
|
+
b.close_tag('a')
|
|
325
|
+
else:
|
|
326
|
+
b.text(rotulo)
|
|
327
|
+
b.close_tag('th')
|
|
328
|
+
b.close_tag('tr')
|
|
329
|
+
b.close_tag('thead')
|
|
330
|
+
b.open_tag('tbody')
|
|
331
|
+
b.indent()
|
|
332
|
+
for linha in source_rows(self._page, node, 'ui:table', 'row'):
|
|
333
|
+
b.open_tag('tr')
|
|
334
|
+
for coluna in colunas:
|
|
335
|
+
estilo = f'text-align: {coluna.align}' if coluna.align else None
|
|
336
|
+
b.open_tag('td', {'style': estilo} if estilo else None)
|
|
337
|
+
if coluna.children:
|
|
338
|
+
self._render_children(coluna.children, b)
|
|
339
|
+
elif plano and coluna.key in plano['columns']:
|
|
340
|
+
self._render_cell_edit(plano, plano['columns'][coluna.key], linha, estado, b)
|
|
341
|
+
else:
|
|
342
|
+
b.text(html.escape(cell_value(linha, coluna)))
|
|
343
|
+
b.close_tag('td')
|
|
344
|
+
b.close_tag('tr')
|
|
345
|
+
b.dedent()
|
|
346
|
+
b.close_tag('tbody')
|
|
347
|
+
b.dedent()
|
|
348
|
+
b.close_tag('table')
|
|
349
|
+
|
|
350
|
+
def _check_sortable(self, node):
|
|
351
|
+
nome = _single_name(node.source)
|
|
352
|
+
consulta = (getattr(self._page, '_query_nodes', None) or {}).get(nome)
|
|
353
|
+
if consulta is not None and not getattr(consulta, 'sortable', False):
|
|
354
|
+
raise ValueError(f'<ui:table sort="true" source="{node.source}">: the query "{nome}" needs '
|
|
355
|
+
f'sortable="true" to order by a column in SQL (UI-13)')
|
|
356
|
+
|
|
357
|
+
def _render_cell_edit(self, plano, coluna_schema, linha, estado, b: HtmlBuilder):
|
|
358
|
+
"""UI-13: a cell is a small form that posts to the generated __edit action."""
|
|
359
|
+
campo, ocultos, erro = cell_field(plano, coluna_schema, linha, estado, escape=True)
|
|
360
|
+
b.open_tag('form', {'method': 'post', 'class': 'q-cell'})
|
|
361
|
+
for nome, valor in ocultos.items():
|
|
362
|
+
b.open_tag('input', {'type': 'hidden', 'name': nome, 'value': html.escape(valor, quote=True)},
|
|
363
|
+
self_closing=True)
|
|
364
|
+
anterior = getattr(self, '_form_ctx', None)
|
|
365
|
+
self._form_ctx = None # the field is prepared already
|
|
366
|
+
try:
|
|
367
|
+
super()._render_node(campo, b)
|
|
368
|
+
finally:
|
|
369
|
+
self._form_ctx = anterior
|
|
370
|
+
b.open_tag('button', {'class': 'q-cell-save', 'aria-label': f'Save {coluna_schema.name}'})
|
|
371
|
+
b.text('✓')
|
|
372
|
+
b.close_tag('button')
|
|
373
|
+
b.close_tag('form')
|
|
374
|
+
if erro:
|
|
375
|
+
b.open_tag('span', {'class': 'q-field-error', 'role': 'alert'})
|
|
376
|
+
b.text(html.escape(erro))
|
|
377
|
+
b.close_tag('span')
|
|
378
|
+
|
|
379
|
+
def _render_list(self, node, b: HtmlBuilder):
|
|
380
|
+
if not node.source:
|
|
381
|
+
return super()._render_list(node, b)
|
|
382
|
+
b.open_tag('ul', self._merge_attrs({'class': 'q-list'}, self._layout_attrs(node)))
|
|
383
|
+
b.indent()
|
|
384
|
+
for linha in source_rows(self._page, node, 'ui:list', 'item'):
|
|
385
|
+
if node.children:
|
|
386
|
+
self._render_children(node.children, b)
|
|
387
|
+
else:
|
|
388
|
+
b.open_tag('li')
|
|
389
|
+
b.text(html.escape('' if linha is None else str(linha)))
|
|
390
|
+
b.close_tag('li')
|
|
391
|
+
b.dedent()
|
|
392
|
+
b.close_tag('ul')
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""<ui:pager for="query"> — pages over a paginated query (M14, UI-11).
|
|
2
|
+
|
|
3
|
+
The query is `paginate="true"` (DB-2); its page is the URL's `page` parameter
|
|
4
|
+
(DB-9). The pager draws previous, the page numbers around the current one —
|
|
5
|
+
first and last always, a gap where pages are skipped — and next, as links to
|
|
6
|
+
the same page with the other URL parameters kept. The browser and the console
|
|
7
|
+
draw the same items.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
from urllib.parse import urlencode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pagination(renderer, node) -> dict:
|
|
15
|
+
nome = node.for_query
|
|
16
|
+
try:
|
|
17
|
+
resultado = renderer.context.get_variable(f'{nome}_result')
|
|
18
|
+
except Exception:
|
|
19
|
+
resultado = None
|
|
20
|
+
if isinstance(resultado, dict) and isinstance(resultado.get('pagination'), dict):
|
|
21
|
+
return resultado['pagination']
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f'<ui:pager for="{nome}">: there is no paginated q:query named "{nome}" on this page '
|
|
24
|
+
f'(it needs paginate="true" and page_size) (UI-11)')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _href(renderer, param: str, pagina: int) -> str:
|
|
28
|
+
contexto = renderer.context
|
|
29
|
+
caminho = (getattr(contexto, 'request_vars', None) or {}).get('path') or ''
|
|
30
|
+
try:
|
|
31
|
+
atuais = contexto.get_variable('query')
|
|
32
|
+
except Exception:
|
|
33
|
+
atuais = None
|
|
34
|
+
parametros = {k: v for k, v in (atuais or {}).items() if k != param} if isinstance(atuais, dict) else {}
|
|
35
|
+
parametros[param] = str(pagina)
|
|
36
|
+
return f'{caminho}?{urlencode(parametros)}'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def pager_items(renderer, node) -> List[Dict]:
|
|
40
|
+
"""The pager's items: {'kind': prev|page|gap|next, 'label', 'href' (None if not a link), 'current'}."""
|
|
41
|
+
pag = _pagination(renderer, node)
|
|
42
|
+
total = int(pag.get('totalPages') or 0)
|
|
43
|
+
atual = int(pag.get('currentPage') or 1)
|
|
44
|
+
if total <= 1:
|
|
45
|
+
return []
|
|
46
|
+
janela = max(0, int(node.window))
|
|
47
|
+
paginas = sorted({1, total} | set(range(max(1, atual - janela), min(total, atual + janela) + 1)))
|
|
48
|
+
itens: List[Dict] = [{'kind': 'prev', 'label': '‹', 'aria': 'Previous page',
|
|
49
|
+
'href': _href(renderer, node.param, atual - 1) if atual > 1 else None,
|
|
50
|
+
'current': False}]
|
|
51
|
+
anterior: Optional[int] = None
|
|
52
|
+
for n in paginas:
|
|
53
|
+
if anterior is not None and n > anterior + 1:
|
|
54
|
+
itens.append({'kind': 'gap', 'label': '…', 'href': None, 'current': False, 'aria': ''})
|
|
55
|
+
itens.append({'kind': 'page', 'label': str(n), 'aria': f'Page {n}', 'current': n == atual,
|
|
56
|
+
'href': None if n == atual else _href(renderer, node.param, n)})
|
|
57
|
+
anterior = n
|
|
58
|
+
itens.append({'kind': 'next', 'label': '›', 'aria': 'Next page',
|
|
59
|
+
'href': _href(renderer, node.param, atual + 1) if atual < total else None,
|
|
60
|
+
'current': False})
|
|
61
|
+
return itens
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
PAGER_CSS = """
|
|
65
|
+
/* UI-11: <ui:pager> */
|
|
66
|
+
.q-pager { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin: 8px 0; }
|
|
67
|
+
.q-pager a, .q-pager span { min-width: 32px; padding: 4px 8px; text-align: center; border-radius: 6px;
|
|
68
|
+
border: 1px solid var(--q-border, #d0d7de); text-decoration: none; color: inherit; }
|
|
69
|
+
.q-pager a:hover { background: var(--q-surface-hover, #f3f4f6); }
|
|
70
|
+
.q-pager .q-page-current { background: var(--q-primary, #2563eb); border-color: var(--q-primary, #2563eb); color: #fff; }
|
|
71
|
+
.q-pager .q-page-disabled { opacity: .45; }
|
|
72
|
+
.q-pager .q-page-gap { border: none; }
|
|
73
|
+
"""
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""A table that sorts and edits itself (M16, UI-13).
|
|
2
|
+
|
|
3
|
+
`<ui:table source="{tarefas}" sort="true">` — each header is a link that
|
|
4
|
+
orders the query in SQL (`q:query sortable="true"`, by the URL's ?sort= and
|
|
5
|
+
?dir=), so it works with pagination.
|
|
6
|
+
|
|
7
|
+
`<ui:table source="{tarefas}" edit="tarefas" datasource="db">` — each shown
|
|
8
|
+
column of that table (not the key, not a column with `edit="false"`) is a
|
|
9
|
+
small form in its cell. It posts to an action the page does not write:
|
|
10
|
+
`__edit`, handled by the server (web_server._edit_cell), which accepts only a
|
|
11
|
+
table and column this page declares editable, validates the value with the
|
|
12
|
+
column's rules from the schema (UI-10) and updates one row by its key. What it
|
|
13
|
+
does shows in /_dev like any action.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import html
|
|
18
|
+
from typing import Dict, List, Optional
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
EDIT_ACTION = '__edit'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _query_params(renderer) -> dict:
|
|
25
|
+
try:
|
|
26
|
+
atuais = renderer.context.get_variable('query')
|
|
27
|
+
except Exception:
|
|
28
|
+
atuais = None
|
|
29
|
+
return atuais if isinstance(atuais, dict) else {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _path(renderer) -> str:
|
|
33
|
+
return (getattr(renderer.context, 'request_vars', None) or {}).get('path') or ''
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sort_link(renderer, chave: str):
|
|
37
|
+
"""(href, marker) of a header: the next order for this column, and ▲/▼ if it is the current one."""
|
|
38
|
+
atuais = _query_params(renderer)
|
|
39
|
+
atual, direcao = atuais.get('sort'), str(atuais.get('dir') or 'asc').lower()
|
|
40
|
+
proxima = 'desc' if atual == chave and direcao == 'asc' else 'asc'
|
|
41
|
+
parametros = {k: v for k, v in atuais.items() if k not in ('sort', 'dir', 'page')}
|
|
42
|
+
parametros.update({'sort': chave, 'dir': proxima})
|
|
43
|
+
marca = ('▲' if direcao == 'asc' else '▼') if atual == chave else ''
|
|
44
|
+
return f'{_path(renderer)}?{urlencode(parametros)}', marca
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def edit_plan(renderer, table_node) -> Optional[dict]:
|
|
48
|
+
"""For an editable table: the key column and, per editable column key, its schema column."""
|
|
49
|
+
if not getattr(table_node, 'edit', None):
|
|
50
|
+
return None
|
|
51
|
+
from quantum.core.features.ui_engine.src.ast_nodes import UIColumnNode
|
|
52
|
+
from quantum.runtime.table_params import TableParamsError, columns_of
|
|
53
|
+
config = getattr(renderer, 'config', None) or {}
|
|
54
|
+
try:
|
|
55
|
+
colunas = {c.name: c for c in columns_of(config, table_node.edit_datasource, table_node.edit)}
|
|
56
|
+
except TableParamsError as exc:
|
|
57
|
+
raise ValueError(f'<ui:table edit="{table_node.edit}">: {exc} (UI-13)') from exc
|
|
58
|
+
chaves = [c for c in colunas.values() if c.primary_key]
|
|
59
|
+
if len(chaves) != 1:
|
|
60
|
+
raise ValueError(f'<ui:table edit="{table_node.edit}">: the table needs one primary key column '
|
|
61
|
+
f'to know which row a cell is (UI-13)')
|
|
62
|
+
editaveis = {}
|
|
63
|
+
for coluna in table_node.children:
|
|
64
|
+
if isinstance(coluna, UIColumnNode) and coluna.key and coluna.edit and not coluna.children:
|
|
65
|
+
if coluna.key in colunas and not colunas[coluna.key].primary_key:
|
|
66
|
+
editaveis[coluna.key] = colunas[coluna.key]
|
|
67
|
+
return {'table': table_node.edit, 'key': chaves[0].name, 'columns': editaveis}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cell_field(plano: dict, coluna_schema, linha: dict, estado: Optional[dict], escape: bool):
|
|
71
|
+
"""The field of one cell (with the column's rules), its hidden fields, and its error."""
|
|
72
|
+
from quantum.core.features.ui_engine.src.ast_nodes import UICheckboxNode, UIInputNode, UISelectNode
|
|
73
|
+
from quantum.runtime.table_params import param_from_column
|
|
74
|
+
from quantum.runtime.ui_form_rules import FormContext, prepare_field
|
|
75
|
+
chave = plano['key']
|
|
76
|
+
if chave not in linha:
|
|
77
|
+
raise ValueError(f'<ui:table edit=...>: the rows need the key column "{chave}" '
|
|
78
|
+
f'(add it to the query\'s SELECT) (UI-13)')
|
|
79
|
+
param = param_from_column(coluna_schema)
|
|
80
|
+
param.name = 'value'
|
|
81
|
+
if str(param.type) == 'boolean':
|
|
82
|
+
campo = UICheckboxNode()
|
|
83
|
+
elif param.enum:
|
|
84
|
+
campo = UISelectNode()
|
|
85
|
+
else:
|
|
86
|
+
campo = UIInputNode()
|
|
87
|
+
campo.bind = 'value'
|
|
88
|
+
campo.ui_id = f'cell-{coluna_schema.name}-{linha.get(plano["key"])}' # one id per cell
|
|
89
|
+
atual = linha.get(coluna_schema.name)
|
|
90
|
+
identidade = f'{coluna_schema.name}@{linha[chave]}'
|
|
91
|
+
erros, enviados = {}, None
|
|
92
|
+
if estado and estado.get('action') == EDIT_ACTION and estado.get('cell') == identidade:
|
|
93
|
+
erros, enviados = estado.get('errors') or {}, estado.get('values') or {}
|
|
94
|
+
ctx = FormContext([param], {'errors': erros, 'values': enviados} if enviados is not None else None,
|
|
95
|
+
True, {'value': atual})
|
|
96
|
+
campo, erro = prepare_field(campo, ctx, escape)
|
|
97
|
+
ocultos = {'action': EDIT_ACTION, '__table': plano['table'], '__key': str(linha[chave]),
|
|
98
|
+
'__column': coluna_schema.name}
|
|
99
|
+
return campo, ocultos, erro
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
CELL_CSS = """
|
|
103
|
+
/* UI-13: a cell edited in place */
|
|
104
|
+
.q-cell { display: flex; gap: 4px; align-items: center; margin: 0; }
|
|
105
|
+
.q-cell .q-input, .q-cell .q-select { min-width: 0; padding: 2px 6px; }
|
|
106
|
+
.q-cell-save { padding: 2px 8px; border: 1px solid var(--q-border, #d0d7de); border-radius: 6px;
|
|
107
|
+
background: transparent; cursor: pointer; }
|
|
108
|
+
.q-sort { color: inherit; text-decoration: none; }
|
|
109
|
+
.q-sort:hover { text-decoration: underline; }
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _walk(nodes):
|
|
114
|
+
for node in nodes or []:
|
|
115
|
+
yield node
|
|
116
|
+
for campo in ('statements', 'children', 'body', 'if_body', 'else_body'):
|
|
117
|
+
yield from _walk(getattr(node, campo, None))
|
|
118
|
+
for bloco in getattr(node, 'elseif_blocks', None) or []:
|
|
119
|
+
yield from _walk(bloco.get('body') if isinstance(bloco, dict) else getattr(bloco, 'body', None))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def find_editable_table(ast, tabela: str, coluna: str):
|
|
123
|
+
"""The <ui:table edit="tabela"> of this page that shows `coluna` as editable, or None.
|
|
124
|
+
|
|
125
|
+
The server edits only what the page declares: a post naming another table
|
|
126
|
+
or column — a forged form — finds nothing and is refused."""
|
|
127
|
+
from quantum.core.features.ui_engine.src.ast_nodes import UIColumnNode, UITableNode
|
|
128
|
+
for node in _walk(getattr(ast, 'statements', None)):
|
|
129
|
+
if isinstance(node, UITableNode) and node.edit == tabela:
|
|
130
|
+
for filho in node.children:
|
|
131
|
+
if isinstance(filho, UIColumnNode) and filho.key == coluna and filho.edit and not filho.children:
|
|
132
|
+
return node
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def query_nodes(ast) -> Dict[str, object]:
|
|
137
|
+
"""The page's q:query nodes by name (a sortable table checks its query)."""
|
|
138
|
+
from quantum.core.ast_nodes import QueryNode
|
|
139
|
+
return {n.name: n for n in _walk(getattr(ast, 'statements', None)) if isinstance(n, QueryNode)}
|