flexdesk 0.17.17 → 0.18.0

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 (300) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +0 -0
  3. package/README.md +1 -0
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +0 -0
  6. package/charts.js +0 -0
  7. package/core.js +0 -0
  8. package/css/flexdesk.css +7499 -5851
  9. package/css/reset.css +1 -1
  10. package/css/tokens.css +23 -1
  11. package/dist/canvas.js +0 -0
  12. package/dist/canvas.js.map +0 -0
  13. package/dist/charts.js +10 -6
  14. package/dist/charts.js.map +1 -1
  15. package/dist/chunk-3PHCPZHT.js +0 -0
  16. package/dist/chunk-3PHCPZHT.js.map +0 -0
  17. package/dist/chunk-5W4EEKSK.js +778 -0
  18. package/dist/chunk-5W4EEKSK.js.map +7 -0
  19. package/dist/chunk-DNJ3N4T4.js +82 -0
  20. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +0 -0
  22. package/dist/chunk-FOOS3T5L.js.map +0 -0
  23. package/dist/chunk-GOQLR5WR.js +0 -0
  24. package/dist/chunk-GOQLR5WR.js.map +0 -0
  25. package/dist/{chunk-JKZLNKG6.js → chunk-HKPB6OFA.js} +90 -25
  26. package/dist/{chunk-JKZLNKG6.js.map → chunk-HKPB6OFA.js.map} +2 -2
  27. package/dist/chunk-HNU6MM2B.js +0 -0
  28. package/dist/chunk-HNU6MM2B.js.map +0 -0
  29. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  30. package/dist/chunk-JYWURG5T.js +0 -0
  31. package/dist/chunk-JYWURG5T.js.map +0 -0
  32. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  33. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  34. package/dist/chunk-N7HC3ADV.js +0 -0
  35. package/dist/chunk-N7HC3ADV.js.map +0 -0
  36. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  37. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  38. package/dist/chunk-PAI2ZUWV.js +260 -0
  39. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  40. package/dist/chunk-Q7BALEPX.js +527 -0
  41. package/dist/chunk-Q7BALEPX.js.map +7 -0
  42. package/dist/chunk-QXAOTGCC.js +40 -0
  43. package/dist/chunk-QXAOTGCC.js.map +7 -0
  44. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  45. package/dist/chunk-RKVYIXDC.js.map +7 -0
  46. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  47. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  48. package/dist/chunk-UKP22RUG.js +219 -0
  49. package/dist/chunk-UKP22RUG.js.map +7 -0
  50. package/dist/chunk-VLOAI3HS.js +43 -0
  51. package/dist/chunk-VLOAI3HS.js.map +7 -0
  52. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  53. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  54. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  55. package/dist/chunk-ZL3VKINP.js.map +7 -0
  56. package/dist/editor.js +3 -2
  57. package/dist/editor.js.map +1 -1
  58. package/dist/flexdesk.css +7499 -5851
  59. package/dist/flow.js +29 -97
  60. package/dist/flow.js.map +3 -3
  61. package/dist/git.js +1356 -0
  62. package/dist/git.js.map +7 -0
  63. package/dist/host.js +0 -0
  64. package/dist/host.js.map +0 -0
  65. package/dist/reset.css +1 -1
  66. package/dist/tile_registry-6WZPTUZV.js +0 -0
  67. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  68. package/dist/tiles.js +20 -15
  69. package/dist/tiles.js.map +2 -2
  70. package/dist/tokens.css +23 -1
  71. package/dist/widgets.js +3969 -1189
  72. package/dist/widgets.js.map +4 -4
  73. package/dist/wm.js +121 -33
  74. package/dist/wm.js.map +2 -2
  75. package/docs/README.md +9 -4
  76. package/docs/best-practices.md +2 -2
  77. package/docs/components/chat-shell.md +198 -0
  78. package/docs/components/command-palette.md +0 -0
  79. package/docs/components/conversation.md +10 -0
  80. package/docs/components/data-table-grid.md +0 -0
  81. package/docs/components/data-table.md +0 -0
  82. package/docs/components/editor.md +0 -0
  83. package/docs/components/filter-builder.md +246 -0
  84. package/docs/components/flow-canvas-editor.md +0 -0
  85. package/docs/components/flow-editors.md +0 -0
  86. package/docs/components/flow-lane-editor.md +0 -0
  87. package/docs/components/flow-outline-editor.md +0 -0
  88. package/docs/components/forms-and-dialogs.md +22 -0
  89. package/docs/components/git.md +355 -0
  90. package/docs/components/input-fields.md +167 -2
  91. package/docs/components/keymap.md +8 -0
  92. package/docs/components/markdown-and-outline.md +0 -0
  93. package/docs/components/menus.md +120 -3
  94. package/docs/components/panels-and-desktops.md +16 -5
  95. package/docs/components/record-list-and-fact-table.md +0 -0
  96. package/docs/components/settings-view.md +0 -0
  97. package/docs/components/shell.md +104 -1
  98. package/docs/components/wizard.md +0 -0
  99. package/docs/components/workspace-picker.md +149 -0
  100. package/docs/concepts/building-and-packaging.md +2 -2
  101. package/docs/concepts/domain-neutrality.md +0 -0
  102. package/docs/concepts/host-port.md +0 -0
  103. package/docs/concepts/packages-and-entry-points.md +14 -13
  104. package/docs/concepts/peer-dependencies.md +0 -0
  105. package/docs/concepts/state-guard.md +3 -3
  106. package/docs/quickstart.md +1 -1
  107. package/editor.js +0 -0
  108. package/flow.js +0 -0
  109. package/git.js +13 -0
  110. package/host.js +0 -0
  111. package/package.json +6 -1
  112. package/src/canvas/canvas_adapter.js +0 -0
  113. package/src/canvas/edge_router.js +0 -0
  114. package/src/canvas/graph/connector_router.js +0 -0
  115. package/src/canvas/graph/history_service.js +0 -0
  116. package/src/canvas/graph/node_platform.js +0 -0
  117. package/src/canvas/index.js +0 -0
  118. package/src/canvas/layered_layout.js +0 -0
  119. package/src/charts/data_series_plot_window.js +0 -0
  120. package/src/charts/downsample.js +0 -0
  121. package/src/charts/plot_config.js +0 -0
  122. package/src/charts/plotly_wrapper.js +0 -0
  123. package/src/core/event_bus.js +0 -0
  124. package/src/core/logging.js +0 -0
  125. package/src/core/settings.js +0 -0
  126. package/src/core/state_guard.js +0 -0
  127. package/src/core/state_machine.js +0 -0
  128. package/src/editor/editor_pane.js +0 -0
  129. package/src/editor/monaco_editor_factory.js +0 -0
  130. package/src/editor/monaco_loader.js +0 -0
  131. package/src/editor/notebook_search.js +0 -0
  132. package/src/editor/notebook_undo_manager.js +0 -0
  133. package/src/editor/split_pane_container.js +0 -0
  134. package/src/flow/canvas_editor/connections.js +0 -0
  135. package/src/flow/canvas_editor/editor.js +0 -0
  136. package/src/flow/canvas_editor/index.js +0 -0
  137. package/src/flow/canvas_editor/layout.js +0 -0
  138. package/src/flow/canvas_editor/strings.js +0 -0
  139. package/src/flow/kit/always_before.js +0 -0
  140. package/src/flow/kit/catalogue.js +0 -0
  141. package/src/flow/kit/chip_input.js +0 -0
  142. package/src/flow/kit/dom.js +28 -119
  143. package/src/flow/kit/findings.js +0 -0
  144. package/src/flow/kit/history.js +0 -0
  145. package/src/flow/kit/index.js +0 -0
  146. package/src/flow/kit/keys.js +0 -0
  147. package/src/flow/kit/logic_graph.js +0 -0
  148. package/src/flow/kit/references.js +0 -0
  149. package/src/flow/kit/settings_panel.js +0 -0
  150. package/src/flow/kit/settings_schema.js +0 -0
  151. package/src/flow/kit/step_picker.js +1 -1
  152. package/src/flow/kit/strings.js +0 -0
  153. package/src/flow/kit/value_picker.js +1 -1
  154. package/src/flow/kit/widgets.js +0 -0
  155. package/src/flow/lanes/editor.js +0 -0
  156. package/src/flow/lanes/index.js +0 -0
  157. package/src/flow/lanes/layout.js +0 -0
  158. package/src/flow/lanes/pipeline.js +0 -0
  159. package/src/flow/lanes/preview.js +0 -0
  160. package/src/flow/lanes/strings.js +0 -0
  161. package/src/flow/outline/editor.js +0 -0
  162. package/src/flow/outline/index.js +0 -0
  163. package/src/flow/outline/mapping.js +0 -0
  164. package/src/flow/outline/menu.js +1 -1
  165. package/src/flow/outline/operations.js +0 -0
  166. package/src/flow/outline/recognise.js +0 -0
  167. package/src/flow/outline/strings.js +0 -0
  168. package/src/git/diff_viewer.js +212 -0
  169. package/src/git/diff_window.js +51 -0
  170. package/src/git/git_graph.js +401 -0
  171. package/src/git/git_ops.js +61 -0
  172. package/src/git/git_panel.js +755 -0
  173. package/src/git/index.js +9 -0
  174. package/src/git/line_diff.js +202 -0
  175. package/src/help/help_modal.js +0 -0
  176. package/src/help/help_registry.js +0 -0
  177. package/src/host/external_links.js +0 -0
  178. package/src/host/file_picker.js +0 -0
  179. package/src/host/host.js +0 -0
  180. package/src/host/photino_host.js +0 -0
  181. package/src/host/photino_host.md +0 -0
  182. package/src/host/pywebview_host.js +0 -0
  183. package/src/tiles/chart_tiles.js +0 -0
  184. package/src/tiles/config_schema.js +0 -0
  185. package/src/tiles/figure_tile.js +0 -0
  186. package/src/tiles/layout_persistence.js +0 -0
  187. package/src/tiles/list_tiles.js +0 -0
  188. package/src/tiles/scope_picker.js +0 -0
  189. package/src/tiles/tile_base.js +5 -4
  190. package/src/tiles/tile_config_form.js +0 -0
  191. package/src/tiles/tile_registry.js +0 -0
  192. package/src/tiling/bottom_panel_tabs.js +0 -0
  193. package/src/tiling/command_palette.js +0 -0
  194. package/src/tiling/content_registry.js +0 -0
  195. package/src/tiling/desktops.js +10 -8
  196. package/src/tiling/entity_sources.js +0 -0
  197. package/src/tiling/keymap.js +6 -5
  198. package/src/tiling/kind_taxonomy.js +15 -2
  199. package/src/tiling/landing_table.js +0 -0
  200. package/src/tiling/loading_overlay.js +0 -0
  201. package/src/tiling/nav_panel.js +0 -0
  202. package/src/tiling/page_factory.js +0 -0
  203. package/src/tiling/panel_availability.js +0 -0
  204. package/src/tiling/panel_keys.js +0 -0
  205. package/src/tiling/shell.js +124 -17
  206. package/src/tiling/tab_strip.js +0 -0
  207. package/src/tiling/tile_renderer.js +2 -3
  208. package/src/tiling/tile_tab_menu.js +0 -0
  209. package/src/tiling/zoom.js +0 -0
  210. package/src/ui/base/component_base.js +0 -0
  211. package/src/ui/base/controller_base.js +0 -0
  212. package/src/ui/base/page_base.js +0 -0
  213. package/src/ui/components/about_dialog.js +0 -0
  214. package/src/ui/components/action_dropdown.js +4 -78
  215. package/src/ui/components/alert_log.js +0 -0
  216. package/src/ui/components/alerts_pane.js +0 -0
  217. package/src/ui/components/anchored_layer.js +255 -0
  218. package/src/ui/components/attribute_list_editor.js +0 -0
  219. package/src/ui/components/autocomplete_field.js +0 -0
  220. package/src/ui/components/cell_editor_host.js +0 -0
  221. package/src/ui/components/cell_editors.js +0 -0
  222. package/src/ui/components/cell_grid.js +0 -0
  223. package/src/ui/components/cell_paste.js +0 -0
  224. package/src/ui/components/cell_selection.js +0 -0
  225. package/src/ui/components/chat_shell.js +920 -0
  226. package/src/ui/components/chat_state_machine.js +223 -0
  227. package/src/ui/components/choice_controls.js +45 -2
  228. package/src/ui/components/color_field.js +3 -2
  229. package/src/ui/components/color_list_field.js +3 -2
  230. package/src/ui/components/column_fit.js +0 -0
  231. package/src/ui/components/column_groups.js +0 -0
  232. package/src/ui/components/column_order.js +0 -0
  233. package/src/ui/components/combobox.js +422 -0
  234. package/src/ui/components/conversation_view.js +17 -2
  235. package/src/ui/components/crosstab.js +0 -0
  236. package/src/ui/components/data_table.js +2 -2
  237. package/src/ui/components/detail_header.js +0 -0
  238. package/src/ui/components/document_outline.js +0 -0
  239. package/src/ui/components/drag_reorder.js +0 -0
  240. package/src/ui/components/fact_table.js +0 -0
  241. package/src/ui/components/fill_handle.js +0 -0
  242. package/src/ui/components/filter_builder.js +766 -0
  243. package/src/ui/components/filter_model.js +614 -0
  244. package/src/ui/components/gallery_picker.js +0 -0
  245. package/src/ui/components/header_actions.js +0 -0
  246. package/src/ui/components/inline_renamer.js +0 -0
  247. package/src/ui/components/job_progress.js +0 -0
  248. package/src/ui/components/managed_window.js +24 -47
  249. package/src/ui/components/menu_bar.js +101 -24
  250. package/src/ui/components/modal.js +79 -109
  251. package/src/ui/components/option_list.js +160 -0
  252. package/src/ui/components/popover.js +94 -0
  253. package/src/ui/components/progress.js +0 -0
  254. package/src/ui/components/recent_items.js +169 -0
  255. package/src/ui/components/record_list.js +0 -0
  256. package/src/ui/components/row_activation.js +0 -0
  257. package/src/ui/components/section_stack.js +0 -0
  258. package/src/ui/components/select.js +322 -0
  259. package/src/ui/components/series.js +0 -0
  260. package/src/ui/components/settings_view.js +0 -0
  261. package/src/ui/components/slide_out_panel.js +0 -0
  262. package/src/ui/components/slider_field.js +0 -0
  263. package/src/ui/components/sortable_list.js +0 -0
  264. package/src/ui/components/staged_edits.js +0 -0
  265. package/src/ui/components/staged_history.js +0 -0
  266. package/src/ui/components/table_filter_chips.js +0 -0
  267. package/src/ui/components/table_state_store.js +0 -0
  268. package/src/ui/components/table_structure.js +0 -0
  269. package/src/ui/components/table_values.js +0 -0
  270. package/src/ui/components/tabs.js +0 -0
  271. package/src/ui/components/tag_input.js +77 -8
  272. package/src/ui/components/toast.js +0 -0
  273. package/src/ui/components/toggle_field.js +0 -0
  274. package/src/ui/components/tsv.js +0 -0
  275. package/src/ui/components/window_taskbar.js +0 -0
  276. package/src/ui/components/workspace_picker.js +1155 -0
  277. package/src/ui/controllers/window_chrome_controller.js +4 -1
  278. package/src/ui/utils/auto_scrollbars.js +0 -0
  279. package/src/ui/utils/fire_event.js +17 -0
  280. package/src/ui/utils/focusable.js +25 -0
  281. package/src/ui/utils/markdown.js +0 -0
  282. package/src/ui/utils/modal_host.js +27 -0
  283. package/src/ui/utils/native_context_menu.js +0 -0
  284. package/src/ui/utils/overlay_scrollbar.js +0 -0
  285. package/src/ui/utils/raf_resize_observer.js +0 -0
  286. package/src/ui/utils/scroll_lock.js +0 -0
  287. package/src/ui/utils/strip_scroll.js +0 -0
  288. package/src/ui/utils/tooltip_service.js +0 -0
  289. package/tiles.js +0 -0
  290. package/widgets.js +12 -2
  291. package/wm.js +0 -0
  292. package/css/base.css +0 -10527
  293. package/css/overrides.css +0 -2596
  294. package/dist/chunk-7JI7HHSY.js +0 -107
  295. package/dist/chunk-7JI7HHSY.js.map +0 -7
  296. package/dist/chunk-MIW7D6WC.js +0 -526
  297. package/dist/chunk-MIW7D6WC.js.map +0 -7
  298. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  299. package/dist/chunk-XJNADRGR.js.map +0 -7
  300. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,614 @@
1
+ /**
2
+ * The filter expression language: recursive AND/OR/NOT groups of conditions,
3
+ * with no idea what it is filtering. No DOM; `filter_builder.js` is the editor.
4
+ *
5
+ * Group = { kind: 'group', op: 'AND' | 'OR', negate?: boolean, children: [...] }
6
+ * Clause = { kind: 'clause', field: string, op: string, value?: any }
7
+ *
8
+ * A node of any other kind is carried untouched and never interpreted (it is how
9
+ * `fromConditionTree` keeps a node it cannot read). The expression is plain JSON,
10
+ * so it round-trips through a bridge, a store and the editor as it is.
11
+ *
12
+ * A FIELD CATALOGUE goes in, a bound MODEL comes out:
13
+ *
14
+ * const model = createFilterModel({ fields: [...] });
15
+ * model.matcherFor(expr)(row);
16
+ * model.describeFilter(expr); // "Status is open AND Size one of s, m"
17
+ *
18
+ * A field is `{ key, label, type, get?, options?, operators?, editor?, hidden?,
19
+ * group?, time? }`. `get` pulls the COMPARABLE value, not always the displayed
20
+ * one, and defaults to `row[key]`. `options` may be a getter over live data; it
21
+ * is read at use, never cached.
22
+ */
23
+
24
+ /* ── operators ──────────────────────────────────────────────────────────
25
+ *
26
+ * `arity` is what the editor draws and what a condition needs: 0 no value, 1 one,
27
+ * 2 a range. `multi` still has arity 1, but the value is an ARRAY. `input: 'days'`
28
+ * marks a value that is a count of days on a date field. */
29
+
30
+ const OPERATOR_LIST = [
31
+ { id: 'is', label: 'is', arity: 1 },
32
+ { id: 'is_not', label: 'is not', arity: 1 },
33
+ { id: 'contains', label: 'contains', arity: 1 },
34
+ { id: 'not_contains', label: 'does not contain', arity: 1 },
35
+ { id: 'starts_with', label: 'starts with', arity: 1 },
36
+ { id: 'ends_with', label: 'ends with', arity: 1 },
37
+ { id: 'gt', label: '>', arity: 1 },
38
+ { id: 'lt', label: '<', arity: 1 },
39
+ { id: 'gte', label: '>=', arity: 1 },
40
+ { id: 'lte', label: '<=', arity: 1 },
41
+ { id: 'between', label: 'between', arity: 2 },
42
+ { id: 'before', label: 'before', arity: 1 },
43
+ { id: 'after', label: 'after', arity: 1 },
44
+ { id: 'in_last_days', label: 'in the last N days', arity: 1, input: 'days' },
45
+ { id: 'one_of', label: 'one of', arity: 1, multi: true },
46
+ { id: 'none_of', label: 'none of', arity: 1, multi: true },
47
+ { id: 'all_of', label: 'all of', arity: 1, multi: true },
48
+ { id: 'is_empty', label: 'is empty', arity: 0 },
49
+ { id: 'is_not_empty', label: 'is not empty', arity: 0 },
50
+ { id: 'is_null', label: 'has no value', arity: 0 },
51
+ { id: 'is_not_null', label: 'has a value', arity: 0 },
52
+ { id: 'is_true', label: 'is true', arity: 0 },
53
+ { id: 'is_false', label: 'is false', arity: 0 },
54
+ ];
55
+
56
+ /** Every operator FlexDesk knows, by id. */
57
+ export const FILTER_OPERATORS = Object.freeze(
58
+ Object.fromEntries(OPERATOR_LIST.map((o) => [o.id, Object.freeze(o)])),
59
+ );
60
+
61
+ /** What a field of each type is offered unless its `operators` says otherwise. */
62
+ export const FILTER_TYPE_OPERATORS = Object.freeze({
63
+ enum: Object.freeze(['is', 'is_not', 'one_of', 'none_of', 'is_empty', 'is_not_empty']),
64
+ text: Object.freeze(['is', 'is_not', 'contains', 'not_contains', 'starts_with', 'ends_with',
65
+ 'one_of', 'none_of', 'is_empty', 'is_not_empty']),
66
+ number: Object.freeze(['is', 'is_not', 'gt', 'lt', 'gte', 'lte', 'between',
67
+ 'one_of', 'none_of', 'is_empty', 'is_not_empty']),
68
+ date: Object.freeze(['is', 'is_not', 'before', 'after', 'between', 'in_last_days',
69
+ 'is_empty', 'is_not_empty']),
70
+ set: Object.freeze(['contains', 'not_contains', 'one_of', 'none_of', 'all_of',
71
+ 'is_empty', 'is_not_empty']),
72
+ bool: Object.freeze(['is_true', 'is_false', 'is_empty']),
73
+ });
74
+
75
+ /* ── the expression, without a model ───────────────────────────────────── */
76
+
77
+ /** A fresh, empty AND group. */
78
+ export const emptyFilter = () => ({ kind: 'group', op: 'AND', children: [] });
79
+
80
+ /** A deep copy. The expression is JSON by contract, so this is total. */
81
+ export const cloneFilter = (expr) => (expr == null ? expr : JSON.parse(JSON.stringify(expr)));
82
+
83
+ /** The expression as text. */
84
+ export const serializeFilter = (expr) => JSON.stringify(expr);
85
+
86
+ /** An expression from `serializeFilter`'s text. Throws on text that is not one. */
87
+ export function parseFilter(text) {
88
+ const expr = JSON.parse(text);
89
+ if (!expr || typeof expr !== 'object' || Array.isArray(expr) || !expr.kind) {
90
+ throw new Error('[FilterModel] not a filter expression');
91
+ }
92
+ return expr;
93
+ }
94
+
95
+ /** How many conditions an expression holds. A carried node counts as one. */
96
+ export function countConditions(node) {
97
+ if (!node) return 0;
98
+ if (node.kind !== 'group') return 1;
99
+ return (node.children || []).reduce((n, child) => n + countConditions(child), 0);
100
+ }
101
+
102
+ /** A field's options as `{ value, label }`. Bare strings and `{ value | id, label }` are legal. */
103
+ export function filterOptions(field) {
104
+ return (field?.options || []).map((o) => {
105
+ if (o === null || typeof o !== 'object') return { value: o, label: String(o ?? '') };
106
+ const value = o.value ?? o.id ?? '';
107
+ return { value, label: String(o.label ?? value) };
108
+ });
109
+ }
110
+
111
+ /* ── the value a text box writes ───────────────────────────────────────── */
112
+
113
+ /** How many significant decimal digits a double holds exactly. */
114
+ const EXACT_DIGITS = 15;
115
+
116
+ function significantDigits(text) {
117
+ const [whole, fraction = ''] = text.replace(/^[+-]/, '').split('.');
118
+ const lead = whole.replace(/^0+/, '');
119
+ const tail = fraction.replace(/0+$/, '');
120
+ return lead ? lead.length + tail.length : tail.replace(/^0+/, '').length;
121
+ }
122
+
123
+ /**
124
+ * The value a control's string becomes, typed by the field's declared type, not
125
+ * by sniffing: a number field writes a number when the string is a plain decimal
126
+ * a double holds exactly. It declines (returns `raw`) whenever converting would
127
+ * change the filter rather than type it: an empty box is empty and not zero, a
128
+ * half-typed `-` stays text, and a wider decimal is not rounded. Every other
129
+ * field type keeps the string.
130
+ */
131
+ export function coerceFilterValue(field, raw) {
132
+ if (typeof raw !== 'string' || field?.type !== 'number') return raw;
133
+ const text = raw.trim();
134
+ if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return raw;
135
+ if (significantDigits(text) > EXACT_DIGITS) return raw;
136
+ const value = Number(text);
137
+ // Too small for a double: "greater than zero" is a different filter.
138
+ if (value === 0 && /[1-9]/.test(text)) return raw;
139
+ return value;
140
+ }
141
+
142
+ /* ── value coercion for matching ───────────────────────────────────────
143
+ *
144
+ * Text comparisons are case-insensitive. A date degrades to a DAY number, so "is"
145
+ * means "same day" without timezone arithmetic; a field with `time: true` compares
146
+ * instants instead. */
147
+
148
+ const low = (v) => String(v ?? '').trim().toLowerCase();
149
+ const num = (v) => (v === '' || v === null || v === undefined ? NaN : Number(v));
150
+ const arr = (v) => (Array.isArray(v) ? v : v === '' || v === null || v === undefined ? [] : [v]);
151
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
152
+
153
+ function day(v) {
154
+ const s = String(v ?? '').trim().slice(0, 10);
155
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return NaN;
156
+ return Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10));
157
+ }
158
+ const instant = (v) => (v === null || v === undefined || v === '' ? NaN : Date.parse(String(v)));
159
+
160
+ const todayUtc = () => {
161
+ const d = new Date();
162
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
163
+ };
164
+
165
+ const isBlank = (v) => v === null || v === undefined || v === ''
166
+ || (Array.isArray(v) && v.length === 0);
167
+
168
+ /* ── the condition tree ────────────────────────────────────────────────
169
+ *
170
+ * The other form an expression travels in, for a server that compiles conditions:
171
+ *
172
+ * { op: 'and' | 'or', children: [...] }
173
+ * { op: 'not', children: [one] }
174
+ * { column, operator, value? }
175
+ *
176
+ * docs/components/filter-builder.md has the mapping and what it preserves. */
177
+
178
+ const TO_TREE_OPERATOR = Object.freeze({
179
+ is: 'eq',
180
+ is_not: 'ne',
181
+ one_of: 'in',
182
+ none_of: 'not_in',
183
+ is_empty: 'is_blank',
184
+ is_not_empty: 'is_not_blank',
185
+ before: 'lt',
186
+ after: 'gt',
187
+ });
188
+
189
+ const FROM_TREE_OPERATOR = Object.freeze({
190
+ eq: 'is',
191
+ ne: 'is_not',
192
+ in: 'one_of',
193
+ not_in: 'none_of',
194
+ is_blank: 'is_empty',
195
+ is_not_blank: 'is_not_empty',
196
+ });
197
+
198
+ const isBareNode = (node) => node !== null && typeof node === 'object';
199
+
200
+ /** An expression as a condition tree. A carried node comes back as it went in. */
201
+ export function toConditionTree(node) {
202
+ if (!isBareNode(node)) return node;
203
+ if (node.kind === 'clause') {
204
+ const { kind: _kind, field, op, ...rest } = node;
205
+ return { column: field, operator: TO_TREE_OPERATOR[op] ?? op, ...rest };
206
+ }
207
+ if (node.kind !== 'group') return node.kind === 'opaque' ? node.node : node;
208
+ const { kind: _kind, op, negate, children, ...rest } = node;
209
+ const list = (children || []).map(toConditionTree);
210
+ const treeOp = String(op).toLowerCase();
211
+ if (!negate) return { op: treeOp, children: list, ...rest };
212
+ // `not` takes exactly one child, and a group of one needs no group around it.
213
+ return { op: 'not', children: [list.length === 1 ? list[0] : { op: treeOp, children: list }], ...rest };
214
+ }
215
+
216
+ /**
217
+ * A condition tree as an expression. `null` is the empty filter. A node that is
218
+ * neither a condition, an `and`/`or` group nor a `not` of exactly one child is
219
+ * carried as `{ kind: 'opaque', node }`: the editor shows it read-only, and
220
+ * `toConditionTree` returns it untouched.
221
+ */
222
+ export function fromConditionTree(node) {
223
+ if (node === null || node === undefined) return emptyFilter();
224
+ if (!isBareNode(node)) return { kind: 'opaque', node };
225
+ const op = String(node.op ?? '').toLowerCase();
226
+ if (!node.op && 'column' in node) {
227
+ const { column, operator, ...rest } = node;
228
+ const id = String(operator ?? 'eq').toLowerCase();
229
+ return { kind: 'clause', field: column, op: FROM_TREE_OPERATOR[id] ?? id, ...rest };
230
+ }
231
+ if ((op === 'and' || op === 'or') && Array.isArray(node.children ?? [])) {
232
+ const { op: _op, children, ...rest } = node;
233
+ return { kind: 'group', op: op.toUpperCase(), children: (children || []).map(fromConditionTree), ...rest };
234
+ }
235
+ if (op === 'not' && Array.isArray(node.children) && node.children.length === 1) {
236
+ const { op: _op, children, ...rest } = node;
237
+ const inner = fromConditionTree(children[0]);
238
+ // `not(and(a, b))` is one negated group. A one-child inner group stays a
239
+ // group, so that the tree comes back with the level it arrived with.
240
+ if (inner.kind === 'group' && !inner.negate && inner.children.length !== 1) {
241
+ return { ...inner, negate: true, ...rest };
242
+ }
243
+ return { kind: 'group', op: 'AND', negate: true, children: [inner], ...rest };
244
+ }
245
+ return { kind: 'opaque', node };
246
+ }
247
+
248
+ /* ── the model ─────────────────────────────────────────────────────────── */
249
+
250
+ const CACHE_MAX = 200;
251
+
252
+ /**
253
+ * Bind a field catalogue into everything that needs to know about it.
254
+ *
255
+ * @param {object} o
256
+ * @param {Array} o.fields the catalogue (see the top of this file)
257
+ * @param {string} [o.noun] what one row is called, for messages
258
+ * @param {Array} [o.operators] operators added to the catalogue, or known ones
259
+ * relabelled: `{ id, label, arity?, multi?, input?, match? }`. An id FlexDesk
260
+ * knows keeps what you leave out. `match(rowValue, clauseValue, row)` is what
261
+ * `matcherFor` runs for it; without one a clause using it matches nothing.
262
+ * @param {'everything'|'nothing'} [o.emptyOr] what an empty OR group matches. An
263
+ * empty AND always matches everything.
264
+ * @param {(key: string) => string} [o.unknownName] how a field key the catalogue
265
+ * does not know is shown.
266
+ */
267
+ export function createFilterModel({
268
+ fields, noun = 'row', operators = [], emptyOr = 'everything', unknownName = (key) => key,
269
+ }) {
270
+ const byKey = new Map(fields.map((f) => [f.key, f]));
271
+ const fieldByKey = (key) => byKey.get(key) || null;
272
+
273
+ const catalogue = new Map(OPERATOR_LIST.map((o) => [o.id, o]));
274
+ for (const o of operators) {
275
+ const known = catalogue.get(o.id);
276
+ if (!known && !Number.isInteger(o.arity)) {
277
+ throw new Error(`[FilterModel] operator "${o.id}" needs an arity`);
278
+ }
279
+ catalogue.set(o.id, { ...known, ...o });
280
+ }
281
+ for (const f of fields) {
282
+ for (const id of f.operators || []) {
283
+ if (!catalogue.has(id)) {
284
+ throw new Error(`[FilterModel] field "${f.key}" names unknown operator "${id}"`);
285
+ }
286
+ }
287
+ }
288
+
289
+ /** The operators a field is offered, as definitions. An unknown field has none. */
290
+ const operatorsFor = (fieldKey) => {
291
+ const field = byKey.get(fieldKey);
292
+ if (!field) return [];
293
+ const ids = field.operators || FILTER_TYPE_OPERATORS[field.type] || [];
294
+ return ids.map((id) => catalogue.get(id));
295
+ };
296
+ /** Any operator's definition by id, offered on a field or not; null when unknown. */
297
+ const operatorById = (id) => catalogue.get(id) || null;
298
+
299
+ /** What a freshly picked (field, operator) starts from; undefined means no value. */
300
+ function defaultValue(field, op) {
301
+ if (!field || !op || !op.arity) return undefined;
302
+ if (op.multi) return [];
303
+ if (op.input === 'days') return 7;
304
+ if (op.arity === 2) return field.type === 'number' ? [null, null] : ['', ''];
305
+ if (field.type === 'number') return null;
306
+ if (field.type === 'enum') return filterOptions(field)[0]?.value ?? '';
307
+ return '';
308
+ }
309
+
310
+ /** A clause on `key` (default: the first field offered), on its first operator.
311
+ * Null when the catalogue offers nothing. */
312
+ function newClause(key) {
313
+ const field = key ? byKey.get(key) : fields.find((f) => !f.hidden);
314
+ if (!field) return null;
315
+ const op = operatorsFor(field.key)[0] || null;
316
+ const clause = { kind: 'clause', field: field.key, op: op ? op.id : '' };
317
+ const value = defaultValue(field, op);
318
+ if (value !== undefined) clause.value = value;
319
+ return clause;
320
+ }
321
+
322
+ /* Compiled per type so a clause does its type dispatch ONCE, not on every row. */
323
+ function compileClause(clause) {
324
+ const field = byKey.get(clause?.field);
325
+ if (!field) {
326
+ console.warn('[FilterModel] clause references unknown field', clause?.field);
327
+ return () => false;
328
+ }
329
+ const op = clause.op;
330
+ const raw = clause.value;
331
+ const get = field.get || ((row) => row?.[field.key]);
332
+
333
+ const custom = catalogue.get(op)?.match;
334
+ if (custom) return (row) => !!custom(get(row), raw, row);
335
+
336
+ switch (op) {
337
+ case 'is_empty': return (r) => isBlank(get(r));
338
+ case 'is_not_empty': return (r) => !isBlank(get(r));
339
+ case 'is_null': return (r) => get(r) === null || get(r) === undefined;
340
+ case 'is_not_null': return (r) => get(r) !== null && get(r) !== undefined;
341
+ case 'is_true': return (r) => get(r) === true || low(get(r)) === 'true';
342
+ case 'is_false': return (r) => get(r) === false || low(get(r)) === 'false';
343
+ default:
344
+ }
345
+
346
+ if (field.type === 'number') {
347
+ const a = num(first(raw));
348
+ const b = num(Array.isArray(raw) ? raw[1] : undefined);
349
+ const list = arr(raw).map(num);
350
+ switch (op) {
351
+ case 'is': return (r) => num(get(r)) === a;
352
+ case 'is_not': return (r) => num(get(r)) !== a;
353
+ case 'gt': return (r) => num(get(r)) > a;
354
+ case 'lt': return (r) => num(get(r)) < a;
355
+ case 'gte': return (r) => num(get(r)) >= a;
356
+ case 'lte': return (r) => num(get(r)) <= a;
357
+ case 'between': return (r) => {
358
+ const n = num(get(r));
359
+ return n >= Math.min(a, b) && n <= Math.max(a, b);
360
+ };
361
+ case 'one_of': return (r) => list.includes(num(get(r)));
362
+ case 'none_of': return (r) => !list.includes(num(get(r)));
363
+ default:
364
+ }
365
+ }
366
+
367
+ if (field.type === 'date') {
368
+ const at = field.time ? instant : day;
369
+ const a = at(first(raw));
370
+ const b = Array.isArray(raw) ? at(raw[1]) : NaN;
371
+ const compare = (test) => (r) => {
372
+ const d = at(get(r));
373
+ return !Number.isNaN(d) && test(d);
374
+ };
375
+ switch (op) {
376
+ case 'is': return (r) => at(get(r)) === a;
377
+ case 'is_not': return (r) => at(get(r)) !== a;
378
+ case 'before': case 'lt': return compare((d) => d < a);
379
+ case 'after': case 'gt': return compare((d) => d > a);
380
+ case 'lte': return compare((d) => d <= a);
381
+ case 'gte': return compare((d) => d >= a);
382
+ case 'between': return compare((d) => d >= Math.min(a, b) && d <= Math.max(a, b));
383
+ // Inclusive of today and of the Nth day back.
384
+ case 'in_last_days': {
385
+ const days = num(first(raw));
386
+ return (r) => {
387
+ const d = day(get(r));
388
+ if (Number.isNaN(d) || Number.isNaN(days)) return false;
389
+ return d >= todayUtc() - (days - 1) * 86400000 && d <= todayUtc();
390
+ };
391
+ }
392
+ default:
393
+ }
394
+ }
395
+
396
+ const one = low(first(raw));
397
+ const many = arr(raw).map(low).filter((s) => s !== '');
398
+
399
+ if (field.type === 'set') {
400
+ const vals = (r) => arr(get(r)).map(low);
401
+ switch (op) {
402
+ case 'contains': return (r) => vals(r).includes(one);
403
+ case 'not_contains': return (r) => !vals(r).includes(one);
404
+ case 'one_of': return (r) => { const v = vals(r); return many.some((m) => v.includes(m)); };
405
+ case 'none_of': return (r) => { const v = vals(r); return !many.some((m) => v.includes(m)); };
406
+ case 'all_of': return (r) => { const v = vals(r); return many.every((m) => v.includes(m)); };
407
+ default:
408
+ }
409
+ }
410
+
411
+ // enum and text share string semantics: enum only constrains what the
412
+ // editor offers, not how a comparison behaves.
413
+ if (field.type === 'enum' || field.type === 'text') {
414
+ switch (op) {
415
+ case 'is': return (r) => low(get(r)) === one;
416
+ case 'is_not': return (r) => low(get(r)) !== one;
417
+ case 'contains': return (r) => low(get(r)).includes(one);
418
+ case 'not_contains': return (r) => !low(get(r)).includes(one);
419
+ case 'starts_with': return (r) => low(get(r)).startsWith(one);
420
+ case 'ends_with': return (r) => low(get(r)).endsWith(one);
421
+ case 'one_of': return (r) => many.includes(low(get(r)));
422
+ case 'none_of': return (r) => !many.includes(low(get(r)));
423
+ default:
424
+ }
425
+ }
426
+
427
+ console.warn('[FilterModel] clause uses an operator its field cannot run', op, field.key);
428
+ return () => false;
429
+ }
430
+
431
+ /** What a group with no children matches, before its NOT. */
432
+ const emptyMatches = (group) => String(group?.op).toUpperCase() !== 'OR' || emptyOr === 'everything';
433
+
434
+ function compile(node) {
435
+ if (!node) return () => true;
436
+ if (node.kind === 'clause') return compileClause(node);
437
+ if (node.kind !== 'group') {
438
+ console.warn('[FilterModel] cannot run a node of kind', node.kind);
439
+ return () => false;
440
+ }
441
+ const children = (Array.isArray(node.children) ? node.children : []).map(compile);
442
+ const negate = !!node.negate;
443
+ if (!children.length) {
444
+ const result = emptyMatches(node) !== negate;
445
+ return () => result;
446
+ }
447
+ const test = String(node.op || 'AND').toUpperCase() === 'OR'
448
+ ? (row) => children.some((c) => c(row))
449
+ : (row) => children.every((c) => c(row));
450
+ return negate ? (row) => !test(row) : test;
451
+ }
452
+
453
+ /* Cached by the expression's JSON, so a page may call this once per render or
454
+ * per row without recompiling. Bounded: an editor changes the expression on
455
+ * every keystroke. */
456
+ const cache = new Map();
457
+ function matcherFor(expr) {
458
+ const key = JSON.stringify(expr);
459
+ const hit = cache.get(key);
460
+ if (hit) return hit;
461
+ const fn = compile(expr);
462
+ if (cache.size >= CACHE_MAX) cache.clear();
463
+ cache.set(key, fn);
464
+ return fn;
465
+ }
466
+ const evaluateFilter = (expr, row) => matcherFor(expr)(row);
467
+
468
+ /* ── description: "Status is open AND (Size is s OR Size is m)" ─────── */
469
+
470
+ function describeValue(field, op, value, format) {
471
+ if (op.arity === 0) return '';
472
+ const shown = (v) => (format && v !== '' && v !== null && v !== undefined ? format(v, field) : String(v ?? ''));
473
+ if (op.multi) return arr(value).map(shown).join(', ');
474
+ if (op.arity === 2) {
475
+ const [a, b] = arr(value);
476
+ return `${shown(a)} and ${shown(b)}`;
477
+ }
478
+ return shown(first(value));
479
+ }
480
+
481
+ function describeNode(node, depth, format) {
482
+ if (!node) return '';
483
+ if (node.kind === 'clause') {
484
+ const field = byKey.get(node.field);
485
+ // A field that has gone is SAID, so a filter matching nothing reads
486
+ // as broken, not as empty.
487
+ if (!field) return `⚠ unknown field “${unknownName(String(node.field ?? ''))}”`;
488
+ const op = catalogue.get(node.op);
489
+ if (!op) return `${field.label} ${node.op ?? ''}`.trim();
490
+ const val = describeValue(field, op, node.value, format);
491
+ return val ? `${field.label} ${op.label} ${val}` : `${field.label} ${op.label}`;
492
+ }
493
+ if (node.kind !== 'group') return `⚠ ${JSON.stringify(node.node ?? node)}`;
494
+ const parts = (Array.isArray(node.children) ? node.children : [])
495
+ .map((c) => describeNode(c, depth + 1, format)).filter(Boolean);
496
+ if (!parts.length) return emptyMatches(node) !== !!node.negate ? 'everything' : 'nothing';
497
+ const joined = parts.join(String(node.op || 'AND').toUpperCase() === 'OR' ? ' OR ' : ' AND ');
498
+ if (node.negate) return `NOT (${joined})`;
499
+ return depth > 0 && parts.length > 1 ? `(${joined})` : joined;
500
+ }
501
+
502
+ /**
503
+ * One line of words. `formatValue(value, field)` names each value (a key shown
504
+ * as a person's name, say); without it a value is shown as itself.
505
+ */
506
+ const describeFilter = (expr, { formatValue = null } = {}) => describeNode(expr, 0, formatValue);
507
+
508
+ /** The field keys an expression names that the catalogue does not know. */
509
+ function unknownFields(expr) {
510
+ const out = new Set();
511
+ const walk = (n) => {
512
+ if (!n) return;
513
+ if (n.kind === 'clause') {
514
+ if (!byKey.has(n.field)) out.add(n.field);
515
+ } else if (n.kind === 'group') {
516
+ (Array.isArray(n.children) ? n.children : []).forEach(walk);
517
+ }
518
+ };
519
+ walk(expr);
520
+ return [...out];
521
+ }
522
+
523
+ /* ── validation: EVERY problem, not the first ───────────────────────── */
524
+
525
+ function validateClause(node, errors, path) {
526
+ const field = byKey.get(node.field);
527
+ if (!field) {
528
+ errors.push(`${path}: unknown field "${unknownName(String(node.field ?? ''))}"`);
529
+ return;
530
+ }
531
+ const op = operatorsFor(field.key).find((o) => o.id === node.op);
532
+ if (!op) {
533
+ errors.push(`${field.label}: "${node.op}" is not a valid operator`);
534
+ return;
535
+ }
536
+ if (op.arity === 0) return;
537
+ const say = (what) => errors.push(`${field.label} ${op.label}: ${what}`);
538
+ if (op.multi) {
539
+ if (!Array.isArray(node.value) || node.value.length === 0) say('pick at least one value');
540
+ return;
541
+ }
542
+ const values = op.arity === 2 ? arr(node.value).slice(0, 2) : [first(node.value)];
543
+ const needed = op.arity === 2 ? 2 : 1;
544
+ if (values.length < needed || values.some(isBlank)) {
545
+ say(needed === 2 ? 'needs two values' : 'needs a value');
546
+ return;
547
+ }
548
+ if (op.input === 'days') {
549
+ if (!(num(values[0]) > 0)) say('value must be a positive number of days');
550
+ } else if (field.type === 'number') {
551
+ if (values.some((v) => Number.isNaN(num(v)))) say('value must be a number');
552
+ } else if (field.type === 'date') {
553
+ const at = field.time ? instant : day;
554
+ if (values.some((v) => Number.isNaN(at(v)))) {
555
+ say(field.time ? 'value must be a date and time' : 'value must be a date (YYYY-MM-DD)');
556
+ }
557
+ }
558
+ }
559
+
560
+ function validateNode(node, errors, path) {
561
+ if (!node || typeof node !== 'object') {
562
+ errors.push(`${path}: not an expression node`);
563
+ } else if (node.kind === 'group') {
564
+ if (node.op !== 'AND' && node.op !== 'OR') errors.push(`${path}: group op must be AND or OR`);
565
+ if (!Array.isArray(node.children)) {
566
+ errors.push(`${path}: group children must be an array`);
567
+ return;
568
+ }
569
+ node.children.forEach((c, i) => validateNode(c, errors, `${path}/${i}`));
570
+ } else if (node.kind === 'clause') {
571
+ validateClause(node, errors, path);
572
+ } else {
573
+ errors.push(`${path}: unknown node kind "${node.kind}"`);
574
+ }
575
+ }
576
+
577
+ /** `{ ok, errors }`. An empty group is valid. */
578
+ function validateFilter(expr) {
579
+ const errors = [];
580
+ validateNode(expr, errors, 'expr');
581
+ return { ok: errors.length === 0, errors };
582
+ }
583
+
584
+ /* ── prune: what is left once half-built rows are dropped ───────────── */
585
+
586
+ function prune(node) {
587
+ if (!node || typeof node !== 'object') return null;
588
+ if (node.kind === 'clause') {
589
+ const op = catalogue.get(node.op);
590
+ if (!op || !byKey.has(node.field) || op.arity === 0) return node;
591
+ const blank = Array.isArray(node.value) ? node.value.every(isBlank) : isBlank(node.value);
592
+ return blank ? null : node;
593
+ }
594
+ if (node.kind !== 'group') return node;
595
+ const children = (node.children || []).map(prune).filter(Boolean);
596
+ if (!children.length) return null;
597
+ // A group of one group is the same predicate with a level nobody asked
598
+ // for, so a round trip does not grow a layer each time.
599
+ if (children.length === 1 && children[0].kind === 'group' && !node.negate) return children[0];
600
+ return { ...node, children };
601
+ }
602
+
603
+ /**
604
+ * A copy with empty groups and conditions with nothing to compare against
605
+ * dropped, or null when nothing is left. A condition this catalogue cannot
606
+ * read, and a carried node, are kept: they were not built here.
607
+ */
608
+ const pruneFilter = (expr) => prune(cloneFilter(expr));
609
+
610
+ return Object.freeze({
611
+ fields, noun, fieldByKey, operatorsFor, operatorById, defaultValue, newClause, emptyMatches,
612
+ unknownName, matcherFor, evaluateFilter, describeFilter, validateFilter, unknownFields, pruneFilter,
613
+ });
614
+ }
File without changes
File without changes
File without changes
File without changes