flask-construct-ui 0.12.0__tar.gz → 0.12.2__tar.gz
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.
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/PKG-INFO +1 -1
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/base.py +11 -1
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/pivot.py +38 -1
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/contrast.py +34 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/grid.py +9 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/l10n/en/main.flt +2 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/l10n/sv/main.flt +2 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/menu.py +143 -19
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/rcrud.py +26 -1
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/schema.py +67 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/whitelabel.css +87 -22
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/designs/sandstone.css +3 -6
- flask_construct_ui-0.12.2/flask_construct_ui/static/js/construct-core.min.js +2 -0
- flask_construct_ui-0.12.2/flask_construct_ui/static/js/prosemirror-bundle.min.js +35 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/pivot_form.html +13 -6
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_macros.html +10 -3
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/listindex.html +5 -2
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/related_crud_tab_list.html +5 -2
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_menu.html +18 -8
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/PKG-INFO +1 -1
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/pyproject.toml +2 -2
- flask_construct_ui-0.12.0/flask_construct_ui/static/js/construct-core.min.js +0 -2
- flask_construct_ui-0.12.0/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -34
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/LICENSE +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/README.md +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/__init__.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/app_hooks.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/auth.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/capabilities.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/cli.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/__init__.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/calendar.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/chart.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/chart_native.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/content.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/core.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/grid.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/kanban.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/layout.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/live.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/period.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/crud.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/crud_forms.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/csrf.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/data.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/data_sqlalchemy.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/dragdrop.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/errors.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/flashes.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/forms.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/headless.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/icons.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/language.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/layout.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/live.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/navbar.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/navigation.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/orgmode.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/pagination.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/py.typed +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/resume.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/sanitize.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/grid.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/unpoly.min.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/designs/adminlte.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/bootstrap-icons/bootstrap-icons.min.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols-outlined.woff2 +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/names.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/unpoly.min.js +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/_form_field.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/button.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/calendar.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/calendarmini.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/card.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/chart.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/chart_native.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/form.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid_layout.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid_notice.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/kanban.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/live.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/live_refresh.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/main.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/markup.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/period_nav.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/rich_text.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/table.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/tabs.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/text.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_related_options.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/create.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/edit.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/tab_view.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/view.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/errors/error.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/errors/forbidden.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/navbar/notifications.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/navbar/search_results.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/structure/base.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/structure/public.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_flashes.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_footer.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/base_full.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/base_partial.html +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/testing.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/urls.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/validation.py +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/SOURCES.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/requires.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/top_level.txt +0 -0
- {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/setup.cfg +0 -0
|
@@ -1131,6 +1131,7 @@ class FlaskConstructUI(MenuMixin):
|
|
|
1131
1131
|
groups=None,
|
|
1132
1132
|
visible=None,
|
|
1133
1133
|
weight=None,
|
|
1134
|
+
match=None,
|
|
1134
1135
|
):
|
|
1135
1136
|
"""Register a page and add it to the navigation menu.
|
|
1136
1137
|
|
|
@@ -1152,13 +1153,22 @@ class FlaskConstructUI(MenuMixin):
|
|
|
1152
1153
|
``weight`` orders the entry among its siblings (default 0, ties keep
|
|
1153
1154
|
registration order), which is what lets a provider place entries around
|
|
1154
1155
|
statically registered pages.
|
|
1156
|
+
|
|
1157
|
+
``match`` names other URLs the entry stands for, so it is shown as
|
|
1158
|
+
current there too: a pattern string (``"/user/diary/*"``; ``*`` is
|
|
1159
|
+
anything, ``:name`` one path segment), a list of them, or a callable
|
|
1160
|
+
returning either per request — for an entry that redirects somewhere
|
|
1161
|
+
that depends on who is asking. The paths below the entry's own link
|
|
1162
|
+
always count; see :attr:`menu_current`.
|
|
1155
1163
|
"""
|
|
1156
1164
|
self._pages.append(page)
|
|
1157
1165
|
self._blueprint_names.add(page.endpoint)
|
|
1158
1166
|
self._pages_by_endpoint[page.endpoint] = page
|
|
1159
1167
|
if self.app is not None:
|
|
1160
1168
|
self._register_page_blueprint(page)
|
|
1161
|
-
self._add_page_menu_entry(
|
|
1169
|
+
self._add_page_menu_entry(
|
|
1170
|
+
page, submenu, submenu_icon, groups, visible, weight, match
|
|
1171
|
+
)
|
|
1162
1172
|
|
|
1163
1173
|
def _apply_l10n_paths(self):
|
|
1164
1174
|
"""(Re)build the translator's search path.
|
{flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/pivot.py
RENAMED
|
@@ -37,6 +37,11 @@ from .core import Component, _t, jinja_env
|
|
|
37
37
|
#: Tones a cell or column may carry; each maps to ``fcu-pivot--<tone>``.
|
|
38
38
|
TONES = ("success", "warning", "danger", "info", "muted")
|
|
39
39
|
|
|
40
|
+
#: What ``unsaved=`` may ask ``core.js`` to do while boxes differ from what
|
|
41
|
+
#: the server rendered: mark the boxes and totals, say so beside Save, warn
|
|
42
|
+
#: before the page or the pivot's fragment is left.
|
|
43
|
+
UNSAVED = ("mark", "status", "warn")
|
|
44
|
+
|
|
40
45
|
# Separates the name from the keys and the keys from each other. Keys are
|
|
41
46
|
# percent-encoded with nothing kept safe, so neither can contain it.
|
|
42
47
|
_SEP = ":"
|
|
@@ -192,7 +197,18 @@ class PivotFormComponent(Component):
|
|
|
192
197
|
or a :class:`PivotCell` whose ``tone`` / ``css_class`` / ``title`` style
|
|
193
198
|
that total; ``grand_total`` may be a zero-argument callable).
|
|
194
199
|
``total_extra`` is a slot under the totals heading, ``footer_label``
|
|
195
|
-
heads the totals row (default: ``total_label``).
|
|
200
|
+
heads the totals row (default: ``total_label``). The built-in sums
|
|
201
|
+
follow typing in the browser (``core.js``); an app total changes only
|
|
202
|
+
when the server renders it again. ``live_totals=False`` keeps every
|
|
203
|
+
total as rendered.
|
|
204
|
+
* ``unsaved`` picks how the page shows typing that is not saved yet, any
|
|
205
|
+
of :data:`UNSAVED` (``()`` shows nothing): ``"mark"`` dashes the
|
|
206
|
+
changed boxes and the totals they move, ``"status"`` adds a live
|
|
207
|
+
"Unsaved changes" line beside Save (``unsaved_label``), ``"warn"``
|
|
208
|
+
asks before leaving the page or replacing the pivot. Whatever is
|
|
209
|
+
chosen, the form carries ``data-fcu-unsaved`` while anything is
|
|
210
|
+
unsaved and fires ``fcu:pivot-unsaved`` as that flips, for an app
|
|
211
|
+
that handles it itself.
|
|
196
212
|
* ``controls`` render inside the form under the table (a row picker,
|
|
197
213
|
extra submit buttons with ``formaction``), after the Save button, which
|
|
198
214
|
stays the form's first submit button so Enter always means save.
|
|
@@ -214,6 +230,9 @@ class PivotFormComponent(Component):
|
|
|
214
230
|
total_label: Optional[str] = None
|
|
215
231
|
total_extra: Any = None
|
|
216
232
|
footer_label: Optional[str] = None
|
|
233
|
+
live_totals: bool = True
|
|
234
|
+
unsaved: Any = ()
|
|
235
|
+
unsaved_label: Optional[str] = None
|
|
217
236
|
controls: List[Any] = field(default_factory=list)
|
|
218
237
|
save: bool = True
|
|
219
238
|
save_label: Optional[str] = None
|
|
@@ -223,6 +242,12 @@ class PivotFormComponent(Component):
|
|
|
223
242
|
|
|
224
243
|
def __post_init__(self):
|
|
225
244
|
_check_name(self.name, "name")
|
|
245
|
+
if isinstance(self.unsaved, str):
|
|
246
|
+
self.unsaved = (self.unsaved,)
|
|
247
|
+
self.unsaved = tuple(self.unsaved)
|
|
248
|
+
for mode in self.unsaved:
|
|
249
|
+
if mode not in UNSAVED:
|
|
250
|
+
raise ValueError(f"PivotForm unsaved {mode!r} is not one of {UNSAVED}")
|
|
226
251
|
for tone in [c.tone for c in self.columns]:
|
|
227
252
|
if tone is not None and tone not in TONES:
|
|
228
253
|
raise ValueError(f"PivotColumn tone {tone!r} is not one of {TONES}")
|
|
@@ -352,6 +377,13 @@ class PivotFormComponent(Component):
|
|
|
352
377
|
row_totals=row_totals,
|
|
353
378
|
column_totals=column_totals,
|
|
354
379
|
grand=grand,
|
|
380
|
+
# Which totals are the built-in sums, which core.js keeps live
|
|
381
|
+
# while typing; an app total is left as rendered.
|
|
382
|
+
live={
|
|
383
|
+
"row": self.live_totals and self.row_total is None,
|
|
384
|
+
"column": self.live_totals and self.column_total is None,
|
|
385
|
+
"grand": self.live_totals and self.grand_total is None,
|
|
386
|
+
},
|
|
355
387
|
input_name=lambda row, column: self.input_name(
|
|
356
388
|
self.name, row.key, column.key
|
|
357
389
|
),
|
|
@@ -360,6 +392,11 @@ class PivotFormComponent(Component):
|
|
|
360
392
|
"total": total_label,
|
|
361
393
|
"footer": self.footer_label or total_label,
|
|
362
394
|
"save": self.save_label or _t("pivot-save", default="Save"),
|
|
395
|
+
"unsaved": self.unsaved_label
|
|
396
|
+
or _t("pivot-unsaved", default="Unsaved changes"),
|
|
397
|
+
"leave": _t(
|
|
398
|
+
"pivot-unsaved-leave", default="Leave without saving your changes?"
|
|
399
|
+
),
|
|
363
400
|
},
|
|
364
401
|
)
|
|
365
402
|
|
|
@@ -294,6 +294,40 @@ PAIRS = [
|
|
|
294
294
|
("input border on info tint", "--fcu-border-input", "--fcu-alert-info-bg", UI),
|
|
295
295
|
("pivot row heading", "--fcu-text", "--fcu-pivot-rowhead-bg", TEXT),
|
|
296
296
|
("pivot row heading link", "--fcu-link", "--fcu-pivot-rowhead-bg", TEXT),
|
|
297
|
+
# PivotForm unsaved="mark" / "status": a changed box's border and the
|
|
298
|
+
# "Unsaved changes" line; muted (weekend) columns sit on the alt surface.
|
|
299
|
+
("pivot unsaved box border", "--fcu-pivot-unsaved-color", "--fcu-surface", UI),
|
|
300
|
+
(
|
|
301
|
+
"pivot unsaved box border on alt surface",
|
|
302
|
+
"--fcu-pivot-unsaved-color",
|
|
303
|
+
"--fcu-surface-alt",
|
|
304
|
+
UI,
|
|
305
|
+
),
|
|
306
|
+
("pivot unsaved status", "--fcu-pivot-unsaved-text", "--fcu-surface", TEXT),
|
|
307
|
+
(
|
|
308
|
+
"pivot unsaved box border on success tint",
|
|
309
|
+
"--fcu-pivot-unsaved-color",
|
|
310
|
+
"--fcu-alert-success-bg",
|
|
311
|
+
UI,
|
|
312
|
+
),
|
|
313
|
+
(
|
|
314
|
+
"pivot unsaved box border on warning tint",
|
|
315
|
+
"--fcu-pivot-unsaved-color",
|
|
316
|
+
"--fcu-alert-warning-bg",
|
|
317
|
+
UI,
|
|
318
|
+
),
|
|
319
|
+
(
|
|
320
|
+
"pivot unsaved box border on danger tint",
|
|
321
|
+
"--fcu-pivot-unsaved-color",
|
|
322
|
+
"--fcu-alert-danger-bg",
|
|
323
|
+
UI,
|
|
324
|
+
),
|
|
325
|
+
(
|
|
326
|
+
"pivot unsaved box border on info tint",
|
|
327
|
+
"--fcu-pivot-unsaved-color",
|
|
328
|
+
"--fcu-alert-info-bg",
|
|
329
|
+
UI,
|
|
330
|
+
),
|
|
297
331
|
# Grid: the selection ring is the only indicator of the keyboard cursor,
|
|
298
332
|
# and the error border doubles as the grid's error text colour.
|
|
299
333
|
("grid selection ring", "--fcu-grid-selected-border", "--fcu-surface", UI),
|
|
@@ -36,6 +36,8 @@ import decimal
|
|
|
36
36
|
import enum
|
|
37
37
|
from typing import Any
|
|
38
38
|
|
|
39
|
+
import markupsafe
|
|
40
|
+
|
|
39
41
|
from . import schema as schema_mod
|
|
40
42
|
from .data import query as data_query
|
|
41
43
|
from .forms import validate_form_data
|
|
@@ -365,6 +367,13 @@ def cell_text(field, name, row):
|
|
|
365
367
|
"""The text a cell displays. Always plain text — never markup: a cell
|
|
366
368
|
holds user data, and rendering it as markup would be stored XSS on every
|
|
367
369
|
page that embeds the grid."""
|
|
370
|
+
if field.has_display_filter():
|
|
371
|
+
value = field.display_value(name, row, "grid")
|
|
372
|
+
if isinstance(value, markupsafe.Markup):
|
|
373
|
+
# A filter's markup is meant for HTML surfaces; a grid cell keeps
|
|
374
|
+
# only its text.
|
|
375
|
+
return value.striptags()
|
|
376
|
+
return "" if value is None else str(value)
|
|
368
377
|
if field.field_type == "bool":
|
|
369
378
|
return "✓" if getattr(row, name, None) else ""
|
|
370
379
|
value = field.format_value(name, row)
|
|
@@ -10,6 +10,9 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
|
+
import re
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from urllib.parse import urlparse
|
|
13
16
|
|
|
14
17
|
from flask import g, has_request_context, request
|
|
15
18
|
|
|
@@ -55,12 +58,63 @@ def _menu_fingerprint(items):
|
|
|
55
58
|
entry.get("title"),
|
|
56
59
|
entry.get("link"),
|
|
57
60
|
entry.get("icon"),
|
|
61
|
+
entry.get("match"),
|
|
58
62
|
_menu_fingerprint(entry.get("subitems", [])),
|
|
59
63
|
]
|
|
60
64
|
for entry in items
|
|
61
65
|
]
|
|
62
66
|
|
|
63
67
|
|
|
68
|
+
# --- Which entry is current -------------------------------------------------
|
|
69
|
+
#
|
|
70
|
+
# An entry matches its own link, everything below it (``link/*``) and any
|
|
71
|
+
# ``match`` patterns the app gave it. Of the entries matching a path, the one
|
|
72
|
+
# with the most specific pattern is current: an exact pattern beats a wildcard,
|
|
73
|
+
# then the longer literal prefix wins, then menu order. construct-core.js
|
|
74
|
+
# implements the same rule (`currentMenuLink`) for Unpoly navigations, which
|
|
75
|
+
# never redraw the sidebar; keep the two in step.
|
|
76
|
+
#
|
|
77
|
+
# Pattern syntax, shared with the client: ``*`` matches anything (slashes
|
|
78
|
+
# included), ``:name`` matches one path segment, everything else is literal.
|
|
79
|
+
# Paths and patterns are compared without trailing slashes.
|
|
80
|
+
|
|
81
|
+
_PATTERN_TOKEN = re.compile(r"(\*|:[A-Za-z][\w-]*)")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _normalize_path(path):
|
|
85
|
+
"""``path`` without query, fragment or trailing slashes ("/" stays)."""
|
|
86
|
+
path = urlparse(path).path
|
|
87
|
+
return path.rstrip("/") or "/"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@lru_cache(maxsize=512)
|
|
91
|
+
def _pattern_regex(pattern):
|
|
92
|
+
parts = []
|
|
93
|
+
for part in _PATTERN_TOKEN.split(pattern):
|
|
94
|
+
if part == "*":
|
|
95
|
+
parts.append(".*")
|
|
96
|
+
elif part.startswith(":") and len(part) > 1:
|
|
97
|
+
parts.append("[^/]+")
|
|
98
|
+
else:
|
|
99
|
+
parts.append(re.escape(part))
|
|
100
|
+
return re.compile("^" + "".join(parts) + "$")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _pattern_rank(pattern):
|
|
104
|
+
"""How specific ``pattern`` is: (exact, length of its literal prefix)."""
|
|
105
|
+
literal = _PATTERN_TOKEN.split(pattern, maxsplit=1)[0]
|
|
106
|
+
return (len(literal) == len(pattern), len(literal))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _link_entries(items):
|
|
110
|
+
"""Every entry in ``items`` that links somewhere, submenus flattened."""
|
|
111
|
+
for entry in items:
|
|
112
|
+
if entry.get("type") == "submenu":
|
|
113
|
+
yield from _link_entries(entry.get("subitems", []))
|
|
114
|
+
elif entry.get("link"):
|
|
115
|
+
yield entry
|
|
116
|
+
|
|
117
|
+
|
|
64
118
|
class MenuMixin:
|
|
65
119
|
"""Menu state and behaviour of :class:`~.base.FlaskConstructUI`."""
|
|
66
120
|
|
|
@@ -80,7 +134,7 @@ class MenuMixin:
|
|
|
80
134
|
self._menu_poll = poll
|
|
81
135
|
|
|
82
136
|
def _add_page_menu_entry(
|
|
83
|
-
self, page, submenu, submenu_icon, groups, visible, weight
|
|
137
|
+
self, page, submenu, submenu_icon, groups, visible, weight, match=None
|
|
84
138
|
):
|
|
85
139
|
"""Add ``page``'s menu entry; see :meth:`FlaskConstructUI.add_page`."""
|
|
86
140
|
if not hasattr(page, "menu") or page.menu is False:
|
|
@@ -106,6 +160,14 @@ class MenuMixin:
|
|
|
106
160
|
self._menu_predicates = True
|
|
107
161
|
if weight is not None:
|
|
108
162
|
menu["weight"] = weight
|
|
163
|
+
if match is not None:
|
|
164
|
+
menu["match"] = match
|
|
165
|
+
if callable(menu.get("match")):
|
|
166
|
+
# Like `visible`: it answers to the request, so the menu it renders
|
|
167
|
+
# (the patterns are on the links) can change on nothing a stamp
|
|
168
|
+
# key or the auth stamp describes.
|
|
169
|
+
self._menu_gated = True
|
|
170
|
+
self._menu_predicates = True
|
|
109
171
|
if menu.get("groups") or menu.get("visible") is not None:
|
|
110
172
|
self._menu_gated = True
|
|
111
173
|
if submenu is not None:
|
|
@@ -144,14 +206,16 @@ class MenuMixin:
|
|
|
144
206
|
self._menu_providers.append((provider, key))
|
|
145
207
|
return provider
|
|
146
208
|
|
|
147
|
-
def menu_provider(self, key=None):
|
|
209
|
+
def menu_provider(self, provider=None, *, key=None):
|
|
148
210
|
"""Decorator form of :meth:`add_menu_provider`.
|
|
149
211
|
|
|
150
212
|
Usable bare (``@construct.menu_provider``) or called with a key
|
|
151
|
-
(``@construct.menu_provider(key=lambda: feed.version)``).
|
|
213
|
+
(``@construct.menu_provider(key=lambda: feed.version)``). The bare
|
|
214
|
+
form arrives as ``provider``: a key is callable too, so telling the
|
|
215
|
+
two apart by "is it callable" registered the key as the provider.
|
|
152
216
|
"""
|
|
153
|
-
if
|
|
154
|
-
return self.add_menu_provider(
|
|
217
|
+
if provider is not None:
|
|
218
|
+
return self.add_menu_provider(provider)
|
|
155
219
|
|
|
156
220
|
def wrap(fn):
|
|
157
221
|
return self.add_menu_provider(fn, key=key)
|
|
@@ -207,9 +271,77 @@ class MenuMixin:
|
|
|
207
271
|
self._place_entry(items, dict(entry))
|
|
208
272
|
items = self._visible_entries(items)
|
|
209
273
|
_sort_menu(items)
|
|
274
|
+
for entry in _link_entries(items):
|
|
275
|
+
# Resolved once per request, so a callable runs once however often
|
|
276
|
+
# the patterns are read, and the fingerprint sees its result.
|
|
277
|
+
entry["match"] = self._extra_patterns(entry)
|
|
210
278
|
g._construct_menu = items
|
|
211
279
|
return items
|
|
212
280
|
|
|
281
|
+
def _extra_patterns(self, entry):
|
|
282
|
+
"""``entry``'s own ``match`` patterns, as a list of absolute patterns.
|
|
283
|
+
|
|
284
|
+
``match`` is a string (whitespace-separated patterns), a list of them,
|
|
285
|
+
or a zero-argument callable returning either, run per request. A
|
|
286
|
+
pattern not starting with "/" is relative to the construct URL, like a
|
|
287
|
+
page's link.
|
|
288
|
+
"""
|
|
289
|
+
match = entry.get("match")
|
|
290
|
+
if callable(match):
|
|
291
|
+
match = match()
|
|
292
|
+
if not match:
|
|
293
|
+
return []
|
|
294
|
+
if isinstance(match, str):
|
|
295
|
+
match = match.split()
|
|
296
|
+
patterns = []
|
|
297
|
+
for pattern in match:
|
|
298
|
+
if not pattern.startswith("/"):
|
|
299
|
+
pattern = self.url.rstrip("/") + "/" + pattern
|
|
300
|
+
patterns.append(_normalize_path(pattern))
|
|
301
|
+
return patterns
|
|
302
|
+
|
|
303
|
+
def menu_patterns(self, entry):
|
|
304
|
+
"""Every pattern that makes ``entry`` current: its link, the paths
|
|
305
|
+
below it, and its ``match`` patterns.
|
|
306
|
+
|
|
307
|
+
The link at the root (of the site or of the construct URL) does not
|
|
308
|
+
claim the paths below it — that would be every page.
|
|
309
|
+
"""
|
|
310
|
+
link = entry.get("link")
|
|
311
|
+
if not link:
|
|
312
|
+
return []
|
|
313
|
+
base = _normalize_path(link)
|
|
314
|
+
patterns = [base]
|
|
315
|
+
if base not in ("/", _normalize_path(self.url)):
|
|
316
|
+
patterns.append(base + "/*")
|
|
317
|
+
patterns.extend(self._extra_patterns(entry))
|
|
318
|
+
return patterns
|
|
319
|
+
|
|
320
|
+
def _current_entry(self, items, path):
|
|
321
|
+
"""The entry in ``items`` that ``path`` belongs to, or None."""
|
|
322
|
+
path = _normalize_path(path)
|
|
323
|
+
best, best_rank = None, None
|
|
324
|
+
for entry in _link_entries(items):
|
|
325
|
+
for pattern in self.menu_patterns(entry):
|
|
326
|
+
if not _pattern_regex(pattern).match(path):
|
|
327
|
+
continue
|
|
328
|
+
rank = _pattern_rank(pattern)
|
|
329
|
+
if best_rank is None or rank > best_rank:
|
|
330
|
+
best, best_rank = entry, rank
|
|
331
|
+
return best
|
|
332
|
+
|
|
333
|
+
@property
|
|
334
|
+
def menu_current(self):
|
|
335
|
+
"""The entry of :attr:`menu` the current request belongs to, or None.
|
|
336
|
+
|
|
337
|
+
See the matching rule above ``_normalize_path``. Fragment endpoints get
|
|
338
|
+
None for free (nothing matches ``/_construct/menu``); the client marks
|
|
339
|
+
a reloaded menu against the browser's location instead.
|
|
340
|
+
"""
|
|
341
|
+
if not has_request_context():
|
|
342
|
+
return None
|
|
343
|
+
return self._current_entry(self.menu, request.path)
|
|
344
|
+
|
|
213
345
|
def _place_entry(self, items, entry):
|
|
214
346
|
"""Insert a provider-supplied ``entry`` into the working menu."""
|
|
215
347
|
entry.setdefault("type", "item")
|
|
@@ -330,10 +462,11 @@ class MenuMixin:
|
|
|
330
462
|
|
|
331
463
|
@property
|
|
332
464
|
def titleFromLink(self):
|
|
333
|
-
"""Title of the menu entry
|
|
465
|
+
"""Title of the menu entry the current route belongs to, or "".
|
|
334
466
|
|
|
335
|
-
|
|
336
|
-
|
|
467
|
+
Uses the same rule as the sidebar's current link (:attr:`menu_current`),
|
|
468
|
+
so a record's edit view is named after its list's entry and a page an
|
|
469
|
+
entry claims through ``match`` after that entry. (This used to read
|
|
337
470
|
a ``self.page`` attribute that has never existed on the extension, so
|
|
338
471
|
it raised on every call — harmless while nothing used it, and the cause
|
|
339
472
|
of an empty ``<title>`` once ``page_title`` started depending on it.)
|
|
@@ -347,17 +480,8 @@ class MenuMixin:
|
|
|
347
480
|
"""
|
|
348
481
|
if not has_request_context():
|
|
349
482
|
return ""
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if item.get("type") == "submenu":
|
|
353
|
-
for subitem in item.get("subitems", []):
|
|
354
|
-
link = subitem.get("link")
|
|
355
|
-
if link and link.rstrip("/") == here:
|
|
356
|
-
return subitem["title"]
|
|
357
|
-
link = item.get("link")
|
|
358
|
-
if link and link.rstrip("/") == here:
|
|
359
|
-
return item["title"]
|
|
360
|
-
return ""
|
|
483
|
+
entry = self._current_entry(self._menu, request.path)
|
|
484
|
+
return entry["title"] if entry else ""
|
|
361
485
|
|
|
362
486
|
def _add_menu_stamp(self, response):
|
|
363
487
|
# Same problem as the auth stamp, one level down: the sidebar is
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
from functools import cached_property
|
|
5
5
|
from math import ceil
|
|
6
6
|
from flask import (
|
|
7
|
+
abort,
|
|
7
8
|
current_app,
|
|
8
9
|
flash,
|
|
9
10
|
g,
|
|
@@ -210,6 +211,25 @@ class RelatedCrudTab:
|
|
|
210
211
|
return verdict
|
|
211
212
|
return rule(page, action, row, parent)
|
|
212
213
|
|
|
214
|
+
def can_list(self, construct) -> bool | str:
|
|
215
|
+
"""Whether the tab's list may be shown under the current parent.
|
|
216
|
+
|
|
217
|
+
The list is part of the parent's page, so it follows the parent
|
|
218
|
+
page's own access — its ``can_edit`` or ``can_view`` for the parent —
|
|
219
|
+
whatever ``capabilities`` says about the children: a reader the
|
|
220
|
+
parent page turns away must not get the children by asking the tab
|
|
221
|
+
route directly. A denial returns the parent's ``can_edit`` answer, so
|
|
222
|
+
its message reaches the 403 page.
|
|
223
|
+
"""
|
|
224
|
+
parent = self.parent_record()
|
|
225
|
+
if parent is None:
|
|
226
|
+
return False
|
|
227
|
+
page = self._parent_view
|
|
228
|
+
verdict = _capability(page, "edit", parent)
|
|
229
|
+
if verdict is True or _capability(page, "view", parent) is True:
|
|
230
|
+
return True
|
|
231
|
+
return verdict
|
|
232
|
+
|
|
213
233
|
def can_add(self, construct) -> bool:
|
|
214
234
|
return self._child_capability("add", construct)
|
|
215
235
|
|
|
@@ -229,7 +249,7 @@ class RelatedCrudTab:
|
|
|
229
249
|
overriding one of those changes the controls and the routes alike.
|
|
230
250
|
"""
|
|
231
251
|
hook = getattr(self, f"can_{action}")
|
|
232
|
-
if action
|
|
252
|
+
if action in ("add", "list"):
|
|
233
253
|
return hook(construct) is True
|
|
234
254
|
return hook(row, construct) is True
|
|
235
255
|
|
|
@@ -373,6 +393,11 @@ class RelatedCrudTab:
|
|
|
373
393
|
return ds, rec
|
|
374
394
|
|
|
375
395
|
def list_view(id):
|
|
396
|
+
if rct.parent_record() is None:
|
|
397
|
+
abort(404)
|
|
398
|
+
allowed = rct.can_list(construct)
|
|
399
|
+
if allowed is not True:
|
|
400
|
+
return forbidden(parent_view, allowed)
|
|
376
401
|
ds = child_dataclass()
|
|
377
402
|
page, per_page, limit = get_pagination(
|
|
378
403
|
getattr(rct.child_crud_class, "page_size", None)
|
|
@@ -3,6 +3,27 @@ import datetime
|
|
|
3
3
|
from .validation import ValidationError, parse_rule
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
class DisplayContext:
|
|
7
|
+
"""What a display filter gets alongside the value it is formatting.
|
|
8
|
+
|
|
9
|
+
One object rather than more positional arguments, so later additions
|
|
10
|
+
don't break filters apps have already written.
|
|
11
|
+
|
|
12
|
+
``surface`` says where the value is being shown: ``"list"`` (the CRUD
|
|
13
|
+
list), ``"related"`` (a related-records tab), ``"view"`` (a read-only
|
|
14
|
+
field on the view or edit page), ``"inline"`` (a row of a list field) or
|
|
15
|
+
``"grid"`` (a data grid cell, which is plain text only).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__slots__ = ("row", "name", "field", "surface")
|
|
19
|
+
|
|
20
|
+
def __init__(self, row, name, field, surface):
|
|
21
|
+
self.row = row
|
|
22
|
+
self.name = name
|
|
23
|
+
self.field = field
|
|
24
|
+
self.surface = surface
|
|
25
|
+
|
|
26
|
+
|
|
6
27
|
class SchemaField:
|
|
7
28
|
def __init__(self, field_type, name, **kwargs):
|
|
8
29
|
self.field_type = field_type
|
|
@@ -26,6 +47,8 @@ class SchemaField:
|
|
|
26
47
|
# only replaces the generic "Enter <field>" hint and vanishes on input.
|
|
27
48
|
self.placeholder = kwargs.get("placeholder", None)
|
|
28
49
|
self.help = kwargs.get("help", None)
|
|
50
|
+
# Display-only filter, ``fn(value, ctx)`` — see ``display_value``.
|
|
51
|
+
self.display = kwargs.get("display", None)
|
|
29
52
|
self.kwargs = kwargs
|
|
30
53
|
matches = kwargs.get("matches", None)
|
|
31
54
|
self._matches_fn = None
|
|
@@ -61,8 +84,52 @@ class SchemaField:
|
|
|
61
84
|
return 'required aria-required="true"' if self.required else ""
|
|
62
85
|
|
|
63
86
|
def format_value(self, field, row):
|
|
87
|
+
"""The stored value, as a form control or editor is filled with it.
|
|
88
|
+
|
|
89
|
+
This feeds ``value="…"`` attributes as well as read-only text, so it
|
|
90
|
+
must never return markup. To change only how a value *reads*, use a
|
|
91
|
+
display filter — see ``display_value``.
|
|
92
|
+
"""
|
|
64
93
|
return getattr(row, field)
|
|
65
94
|
|
|
95
|
+
def _display_filter(self):
|
|
96
|
+
if self.display is not None:
|
|
97
|
+
return self.display
|
|
98
|
+
from flask import has_app_context, current_app
|
|
99
|
+
|
|
100
|
+
if has_app_context():
|
|
101
|
+
filters = current_app.config.get("CONSTRUCT_DISPLAY_FILTERS") or {}
|
|
102
|
+
return filters.get(self.field_type)
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def has_display_filter(self):
|
|
106
|
+
"""Whether ``display_value`` differs from ``format_value`` here.
|
|
107
|
+
|
|
108
|
+
Templates check this before their built-in per-type rendering (check
|
|
109
|
+
icons, badges, …), so a filter can restyle any field type, while
|
|
110
|
+
fields without one render exactly as before.
|
|
111
|
+
"""
|
|
112
|
+
if type(self).display_value is not SchemaField.display_value:
|
|
113
|
+
return True
|
|
114
|
+
return self._display_filter() is not None
|
|
115
|
+
|
|
116
|
+
def display_value(self, field, row, surface="list"):
|
|
117
|
+
"""The value as it is shown — never typed — in lists, related tabs,
|
|
118
|
+
read-only views and grid cells.
|
|
119
|
+
|
|
120
|
+
Resolved, most specific first: the field's ``display=`` callable, the
|
|
121
|
+
app's ``CONSTRUCT_DISPLAY_FILTERS[field_type]``, then ``format_value``
|
|
122
|
+
unchanged. A filter is called as ``fn(value, ctx)`` with the
|
|
123
|
+
``format_value`` result and a :class:`DisplayContext`. It may return
|
|
124
|
+
``markupsafe.Markup`` for intentional HTML; anything else is escaped.
|
|
125
|
+
Subclasses may override this for full control.
|
|
126
|
+
"""
|
|
127
|
+
value = self.format_value(field, row)
|
|
128
|
+
fn = self._display_filter()
|
|
129
|
+
if fn is None:
|
|
130
|
+
return value
|
|
131
|
+
return fn(value, DisplayContext(row, field, self, surface))
|
|
132
|
+
|
|
66
133
|
def parse_value(self, value):
|
|
67
134
|
return value
|
|
68
135
|
|