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.
Files changed (125) hide show
  1. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/PKG-INFO +1 -1
  2. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/base.py +11 -1
  3. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/pivot.py +38 -1
  4. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/contrast.py +34 -0
  5. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/grid.py +9 -0
  6. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/l10n/en/main.flt +2 -0
  7. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/l10n/sv/main.flt +2 -0
  8. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/menu.py +143 -19
  9. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/rcrud.py +26 -1
  10. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/schema.py +67 -0
  11. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/whitelabel.css +87 -22
  12. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/designs/sandstone.css +3 -6
  13. flask_construct_ui-0.12.2/flask_construct_ui/static/js/construct-core.min.js +2 -0
  14. flask_construct_ui-0.12.2/flask_construct_ui/static/js/prosemirror-bundle.min.js +35 -0
  15. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/pivot_form.html +13 -6
  16. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_macros.html +10 -3
  17. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/listindex.html +5 -2
  18. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/related_crud_tab_list.html +5 -2
  19. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_menu.html +18 -8
  20. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/PKG-INFO +1 -1
  21. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/pyproject.toml +2 -2
  22. flask_construct_ui-0.12.0/flask_construct_ui/static/js/construct-core.min.js +0 -2
  23. flask_construct_ui-0.12.0/flask_construct_ui/static/js/prosemirror-bundle.min.js +0 -34
  24. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/LICENSE +0 -0
  25. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/README.md +0 -0
  26. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/__init__.py +0 -0
  27. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/app_hooks.py +0 -0
  28. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/auth.py +0 -0
  29. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/capabilities.py +0 -0
  30. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/cli.py +0 -0
  31. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/__init__.py +0 -0
  32. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/calendar.py +0 -0
  33. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/chart.py +0 -0
  34. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/chart_native.py +0 -0
  35. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/content.py +0 -0
  36. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/core.py +0 -0
  37. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/grid.py +0 -0
  38. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/kanban.py +0 -0
  39. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/layout.py +0 -0
  40. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/live.py +0 -0
  41. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/components/period.py +0 -0
  42. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/crud.py +0 -0
  43. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/crud_forms.py +0 -0
  44. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/csrf.py +0 -0
  45. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/data.py +0 -0
  46. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/data_sqlalchemy.py +0 -0
  47. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/dragdrop.py +0 -0
  48. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/errors.py +0 -0
  49. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/flashes.py +0 -0
  50. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/forms.py +0 -0
  51. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/headless.py +0 -0
  52. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/icons.py +0 -0
  53. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/language.py +0 -0
  54. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/layout.py +0 -0
  55. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/live.py +0 -0
  56. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/navbar.py +0 -0
  57. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/navigation.py +0 -0
  58. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/orgmode.py +0 -0
  59. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/pagination.py +0 -0
  60. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/py.typed +0 -0
  61. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/resume.py +0 -0
  62. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/sanitize.py +0 -0
  63. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/grid.css +0 -0
  64. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/prosemirror-bundle.min.css +0 -0
  65. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/css/unpoly.min.css +0 -0
  66. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/designs/adminlte.css +0 -0
  67. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/bootstrap-icons/LICENSE.txt +0 -0
  68. {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
  69. {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
  70. {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
  71. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/LICENSE.txt +0 -0
  72. {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
  73. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/material-symbols.css +0 -0
  74. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/fonts/material-symbols/names.txt +0 -0
  75. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/construct-grid.min.js +0 -0
  76. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/kanban-dnd.js +0 -0
  77. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/static/js/unpoly.min.js +0 -0
  78. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/_form_field.html +0 -0
  79. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/button.html +0 -0
  80. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/calendar.html +0 -0
  81. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/calendarmini.html +0 -0
  82. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/card.html +0 -0
  83. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/chart.html +0 -0
  84. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/chart_native.html +0 -0
  85. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/form.html +0 -0
  86. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid.html +0 -0
  87. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid_layout.html +0 -0
  88. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/grid_notice.html +0 -0
  89. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/horizontal_layout.html +0 -0
  90. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/kanban.html +0 -0
  91. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/live.html +0 -0
  92. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/live_refresh.html +0 -0
  93. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/main.html +0 -0
  94. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/markup.html +0 -0
  95. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/period_nav.html +0 -0
  96. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/rich_text.html +0 -0
  97. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/table.html +0 -0
  98. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/tabs.html +0 -0
  99. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/text.html +0 -0
  100. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/components/vertical_layout.html +0 -0
  101. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_many_related_options.html +0 -0
  102. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/_related_options.html +0 -0
  103. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/create.html +0 -0
  104. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/edit.html +0 -0
  105. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/related_crud_tab_form.html +0 -0
  106. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/tab_view.html +0 -0
  107. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/crud/view.html +0 -0
  108. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/errors/error.html +0 -0
  109. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/errors/forbidden.html +0 -0
  110. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/navbar/notifications.html +0 -0
  111. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/navbar/search_results.html +0 -0
  112. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/structure/base.html +0 -0
  113. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/structure/public.html +0 -0
  114. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_flashes.html +0 -0
  115. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/_footer.html +0 -0
  116. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/base_full.html +0 -0
  117. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/templates/style/base_partial.html +0 -0
  118. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/testing.py +0 -0
  119. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/urls.py +0 -0
  120. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui/validation.py +0 -0
  121. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/SOURCES.txt +0 -0
  122. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/dependency_links.txt +0 -0
  123. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/requires.txt +0 -0
  124. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/flask_construct_ui.egg-info/top_level.txt +0 -0
  125. {flask_construct_ui-0.12.0 → flask_construct_ui-0.12.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flask-construct-ui
3
- Version: 0.12.0
3
+ Version: 0.12.2
4
4
  Summary: A UI toolkit for Flask with CRUD and component features.
5
5
  Author-email: Olof Tjerngren <olof@tjerngren.net>
6
6
  License-Expression: LGPL-3.0-or-later
@@ -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(page, submenu, submenu_icon, groups, visible, weight)
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.
@@ -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)
@@ -106,6 +106,8 @@ kanban-add-item = Add
106
106
  kanban-add-placeholder = New item…
107
107
 
108
108
  pivot-save = Save
109
+ pivot-unsaved = Unsaved changes
110
+ pivot-unsaved-leave = Leave without saving your changes?
109
111
  pivot-total = Total
110
112
 
111
113
  period-nav = Period
@@ -107,6 +107,8 @@ kanban-add-item = Lägg till
107
107
  kanban-add-placeholder = Ny post…
108
108
 
109
109
  pivot-save = Spara
110
+ pivot-unsaved = Osparade ändringar
111
+ pivot-unsaved-leave = Lämna utan att spara ändringarna?
110
112
  pivot-total = Summa
111
113
 
112
114
  period-nav = Period
@@ -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 callable(key):
154
- return self.add_menu_provider(key)
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 matching the current route, or "".
465
+ """Title of the menu entry the current route belongs to, or "".
334
466
 
335
- Compared against ``request.path`` with trailing slashes normalised, the
336
- same way the sidebar decides which link is current. (This used to read
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
- here = request.path.rstrip("/")
351
- for item in self._menu:
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 == "add":
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