flexdesk 0.16.0 → 0.17.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.
- package/CHANGELOG.md +93 -0
- package/LICENSE +0 -0
- package/README.md +2261 -42
- package/THIRD_PARTY_LICENSES.md +0 -0
- package/css/base.css +1834 -75
- package/css/flexdesk.css +3373 -238
- package/css/overrides.css +16 -0
- package/css/reset.css +0 -0
- package/css/tokens.css +0 -0
- package/dist/canvas.js +6 -239
- package/dist/canvas.js.map +3 -3
- package/dist/charts.js +709 -18
- package/dist/charts.js.map +4 -4
- package/dist/chunk-3PHCPZHT.js +0 -0
- package/dist/chunk-3PHCPZHT.js.map +0 -0
- package/dist/chunk-4A7SJC3T.js +503 -0
- package/dist/chunk-4A7SJC3T.js.map +7 -0
- package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
- package/dist/chunk-CVMCZZX2.js.map +7 -0
- package/dist/chunk-D3COLZZ2.js +1311 -0
- package/dist/chunk-D3COLZZ2.js.map +7 -0
- package/dist/chunk-FOOS3T5L.js +0 -0
- package/dist/chunk-FOOS3T5L.js.map +0 -0
- package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
- package/dist/chunk-GOQLR5WR.js.map +7 -0
- package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
- package/dist/chunk-HNU6MM2B.js.map +7 -0
- package/dist/chunk-HYJYYEPK.js +375 -0
- package/dist/chunk-HYJYYEPK.js.map +7 -0
- package/dist/chunk-JKZLNKG6.js +2041 -0
- package/dist/chunk-JKZLNKG6.js.map +7 -0
- package/dist/chunk-JYWURG5T.js +0 -0
- package/dist/chunk-JYWURG5T.js.map +0 -0
- package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
- package/dist/chunk-MCIXFNVF.js.map +7 -0
- package/dist/chunk-N7HC3ADV.js +30 -0
- package/dist/chunk-N7HC3ADV.js.map +7 -0
- package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
- package/dist/chunk-Q36EPT2R.js.map +7 -0
- package/dist/chunk-QWQJUDPD.js +610 -0
- package/dist/chunk-QWQJUDPD.js.map +7 -0
- package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
- package/dist/chunk-UURZ37SD.js.map +7 -0
- package/dist/chunk-UWO43BZU.js +111 -0
- package/dist/chunk-UWO43BZU.js.map +7 -0
- package/dist/chunk-WVFGV5FT.js +0 -0
- package/dist/chunk-WVFGV5FT.js.map +0 -0
- package/dist/core.js +170 -118
- package/dist/core.js.map +2 -2
- package/dist/editor.js +156 -70
- package/dist/editor.js.map +2 -2
- package/dist/flexdesk.css +3373 -238
- package/dist/flow.js +2789 -306
- package/dist/flow.js.map +4 -4
- package/dist/host.js +109 -3
- package/dist/host.js.map +3 -3
- package/dist/reset.css +0 -0
- package/dist/tile_registry-6WZPTUZV.js +0 -0
- package/dist/tile_registry-6WZPTUZV.js.map +0 -0
- package/dist/tiles.js +1131 -253
- package/dist/tiles.js.map +4 -4
- package/dist/tokens.css +0 -0
- package/dist/widgets.js +3254 -2112
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +1227 -345
- package/dist/wm.js.map +4 -4
- package/package.json +3 -1
- package/src/canvas/canvas_adapter.js +5 -0
- package/src/canvas/edge_router.js +0 -0
- package/src/canvas/graph/connector_router.js +0 -0
- package/src/canvas/graph/history_service.js +0 -0
- package/src/canvas/graph/node_platform.js +0 -0
- package/src/canvas/index.js +1 -0
- package/src/canvas/layered_layout.js +244 -0
- package/src/charts/chart_types.js +0 -0
- package/src/charts/data_series_plot_window.js +0 -0
- package/src/charts/downsample.js +2 -2
- package/src/charts/plot_config.js +53 -29
- package/src/charts/plot_config_editor.js +627 -0
- package/src/charts/plot_grid.js +449 -0
- package/src/charts/plot_popout_window.js +103 -317
- package/src/charts/plotly_wrapper.js +22 -2
- package/src/charts/series_transforms.js +206 -0
- package/src/core/event_bus.js +0 -0
- package/src/core/logging.js +1 -1
- package/src/core/settings.js +105 -19
- package/src/core/state_guard.js +93 -51
- package/src/core/state_guard_config.js +85 -74
- package/src/core/state_machine.js +0 -0
- package/src/editor/editor_pane.js +3 -3
- package/src/editor/monaco_editor_factory.js +124 -60
- package/src/editor/monaco_loader.js +103 -28
- package/src/editor/notebook_search.js +0 -0
- package/src/editor/notebook_tab_bar.js +38 -52
- package/src/editor/notebook_undo_manager.js +0 -0
- package/src/editor/split_pane_container.js +6 -3
- package/src/flow/canvas_editor/connections.js +90 -0
- package/src/flow/canvas_editor/editor.js +2351 -153
- package/src/flow/canvas_editor/index.js +2 -1
- package/src/flow/canvas_editor/layout.js +119 -12
- package/src/flow/canvas_editor/strings.js +80 -0
- package/src/flow/kit/always_before.js +0 -0
- package/src/flow/kit/catalogue.js +97 -3
- package/src/flow/kit/chip_input.js +0 -0
- package/src/flow/kit/dom.js +0 -0
- package/src/flow/kit/findings.js +0 -0
- package/src/flow/kit/history.js +0 -0
- package/src/flow/kit/index.js +3 -3
- package/src/flow/kit/keys.js +7 -1
- package/src/flow/kit/logic_graph.js +97 -15
- package/src/flow/kit/references.js +0 -0
- package/src/flow/kit/settings_panel.js +0 -0
- package/src/flow/kit/settings_schema.js +2 -1
- package/src/flow/kit/step_picker.js +0 -0
- package/src/flow/kit/strings.js +3 -1
- package/src/flow/kit/value_picker.js +0 -0
- package/src/flow/kit/widgets.js +86 -109
- package/src/flow/lanes/editor.js +136 -14
- package/src/flow/lanes/index.js +3 -1
- package/src/flow/lanes/layout.js +0 -0
- package/src/flow/lanes/pipeline.js +44 -2
- package/src/flow/lanes/preview.js +0 -0
- package/src/flow/lanes/strings.js +1 -0
- package/src/flow/outline/editor.js +0 -0
- package/src/flow/outline/index.js +0 -0
- package/src/flow/outline/mapping.js +0 -0
- package/src/flow/outline/menu.js +0 -0
- package/src/flow/outline/operations.js +0 -0
- package/src/flow/outline/recognise.js +0 -0
- package/src/flow/outline/strings.js +0 -0
- package/src/help/help_modal.js +168 -42
- package/src/help/help_registry.js +12 -1
- package/src/host/external_links.js +0 -0
- package/src/host/file_picker.js +117 -0
- package/src/host/host.js +10 -2
- package/src/host/photino_host.js +9 -0
- package/src/host/photino_host.md +21 -3
- package/src/host/pywebview_host.js +10 -0
- package/src/tiles/chart_tiles.js +53 -245
- package/src/tiles/config_schema.js +8 -0
- package/src/tiles/figure_tile.js +322 -0
- package/src/tiles/layout_persistence.js +3 -3
- package/src/tiles/list_tiles.js +422 -0
- package/src/tiles/scope_picker.js +96 -0
- package/src/tiles/tile_base.js +12 -1
- package/src/tiles/tile_config_form.js +442 -0
- package/src/tiles/tile_grid.js +38 -1
- package/src/tiles/tile_registry.js +0 -0
- package/src/tiling/bottom_panel_tabs.js +67 -99
- package/src/tiling/command_palette.js +64 -19
- package/src/tiling/content_registry.js +4 -1
- package/src/tiling/desktops.js +11 -1
- package/src/tiling/entity_sources.js +0 -0
- package/src/tiling/keymap.js +403 -128
- package/src/tiling/kind_taxonomy.js +78 -0
- package/src/tiling/landing_table.js +0 -0
- package/src/tiling/loading_overlay.js +0 -0
- package/src/tiling/nav_panel.js +59 -10
- package/src/tiling/page_factory.js +5 -5
- package/src/tiling/panel_availability.js +49 -0
- package/src/tiling/panel_keys.js +0 -0
- package/src/tiling/shell.js +114 -12
- package/src/tiling/tab_strip.js +0 -0
- package/src/tiling/tile_breadcrumb.js +0 -0
- package/src/tiling/tile_renderer.js +105 -9
- package/src/tiling/tile_tab_menu.js +0 -0
- package/src/tiling/tile_tree.js +0 -0
- package/src/tiling/wm.js +562 -30
- package/src/tiling/zoom.js +0 -0
- package/src/ui/base/component_base.js +0 -0
- package/src/ui/base/controller_base.js +0 -0
- package/src/ui/base/page_base.js +0 -0
- package/src/ui/components/about_dialog.js +0 -0
- package/src/ui/components/action_dropdown.js +30 -2
- package/src/ui/components/alert_log.js +0 -0
- package/src/ui/components/alerts_pane.js +0 -0
- package/src/ui/components/attribute_list_editor.js +0 -0
- package/src/ui/components/autocomplete_field.js +0 -0
- package/src/ui/components/cell_editor_host.js +211 -0
- package/src/ui/components/cell_grid.js +648 -0
- package/src/ui/components/cell_selection.js +220 -0
- package/src/ui/components/choice_controls.js +351 -0
- package/src/ui/components/color_field.js +211 -0
- package/src/ui/components/color_list_field.js +210 -0
- package/src/ui/components/column_fit.js +0 -0
- package/src/ui/components/column_groups.js +314 -0
- package/src/ui/components/column_order.js +142 -0
- package/src/ui/components/computing_status_window.js +98 -25
- package/src/ui/components/confirm_dialog.js +81 -38
- package/src/ui/components/context_menu.js +2 -2
- package/src/ui/components/conversation_view.js +440 -0
- package/src/ui/components/crosstab.js +289 -0
- package/src/ui/components/data_table.js +601 -62
- package/src/ui/components/detail_header.js +22 -11
- package/src/ui/components/document_outline.js +397 -0
- package/src/ui/components/drag_reorder.js +0 -0
- package/src/ui/components/fact_table.js +0 -0
- package/src/ui/components/fill_handle.js +322 -0
- package/src/ui/components/gallery_picker.js +19 -8
- package/src/ui/components/header_actions.js +55 -0
- package/src/ui/components/inline_renamer.js +0 -0
- package/src/ui/components/job_progress.js +51 -0
- package/src/ui/components/managed_window.js +27 -4
- package/src/ui/components/menu_bar.js +745 -0
- package/src/ui/components/modal.js +449 -75
- package/src/ui/components/notification_history.js +3 -3
- package/src/ui/components/progress.js +483 -0
- package/src/ui/components/record_list.js +0 -0
- package/src/ui/components/row_activation.js +0 -0
- package/src/ui/components/section_stack.js +298 -0
- package/src/ui/components/series.js +173 -0
- package/src/ui/components/settings_view.js +634 -0
- package/src/ui/components/slide_out_panel.js +43 -6
- package/src/ui/components/slider_field.js +275 -18
- package/src/ui/components/sortable_list.js +26 -4
- package/src/ui/components/staged_edits.js +343 -0
- package/src/ui/components/staged_history.js +172 -0
- package/src/ui/components/table_filter_chips.js +0 -0
- package/src/ui/components/table_state_store.js +0 -0
- package/src/ui/components/table_structure.js +182 -0
- package/src/ui/components/table_values.js +0 -0
- package/src/ui/components/tabs.js +497 -0
- package/src/ui/components/tag_input.js +215 -0
- package/src/ui/components/toast.js +25 -8
- package/src/ui/components/toggle_field.js +175 -0
- package/src/ui/components/tree_view.js +55 -34
- package/src/ui/components/window_taskbar.js +0 -0
- package/src/ui/components/wizard.js +563 -0
- package/src/ui/controllers/window_chrome_controller.js +2 -2
- package/src/ui/notification_center.js +375 -119
- package/src/ui/utils/auto_scrollbars.js +0 -0
- package/src/ui/utils/markdown.js +709 -0
- package/src/ui/utils/native_context_menu.js +0 -0
- package/src/ui/utils/overlay_scrollbar.js +16 -12
- package/src/ui/utils/raf_resize_observer.js +0 -0
- package/src/ui/utils/scroll_lock.js +0 -0
- package/src/ui/utils/strip_scroll.js +55 -0
- package/src/ui/utils/tooltip_service.js +0 -8
- package/dist/chunk-6JSOVNID.js.map +0 -7
- package/dist/chunk-CWPMLX67.js +0 -475
- package/dist/chunk-CWPMLX67.js.map +0 -7
- package/dist/chunk-FBGP5KN5.js.map +0 -7
- package/dist/chunk-FL5KFNQH.js.map +0 -7
- package/dist/chunk-NLTOY57M.js.map +0 -7
- package/dist/chunk-NNFTETK3.js +0 -633
- package/dist/chunk-NNFTETK3.js.map +0 -7
- package/dist/chunk-O5OHMWBB.js +0 -284
- package/dist/chunk-O5OHMWBB.js.map +0 -7
- package/dist/chunk-RQM5Z2MU.js.map +0 -7
- package/dist/chunk-SN4LSOAR.js.map +0 -7
- package/src/ui/controllers/panel_state_machine.js +0 -634
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tile_config_form.js — a tile's config schema drawn as a form (0.17.0).
|
|
3
|
+
*
|
|
4
|
+
* `TileBase.getConfigSchema()` answers `{fields: [...]}` and `config_schema.js`
|
|
5
|
+
* knows how to validate, default and list options for those fields. This draws
|
|
6
|
+
* them, so an embedder no longer hand-builds a control per field:
|
|
7
|
+
*
|
|
8
|
+
* import { mountTileConfigForm } from '@flexdesk/tiles';
|
|
9
|
+
* const form = mountTileConfigForm(host, {
|
|
10
|
+
* schema: tile.getConfigSchema(),
|
|
11
|
+
* config: tile.config,
|
|
12
|
+
* data, // for `options: 'variables'` and the like
|
|
13
|
+
* onChange: (key, value, config) => tile.update(undefined, config),
|
|
14
|
+
* renderers: { variable: (ctx) => myPicker(ctx) },
|
|
15
|
+
* });
|
|
16
|
+
* form.getValues(); form.setValues(next); form.isValid();
|
|
17
|
+
* form.focusFirstInvalid(); form.flush(); form.dispose();
|
|
18
|
+
*
|
|
19
|
+
* ══ A FIELD ═════════════════════════════════════════════════════════════
|
|
20
|
+
*
|
|
21
|
+
* { key, type, label?, description?, default?, required?, readonly?,
|
|
22
|
+
* min?, max?, step?, options?, visibleWhen? }
|
|
23
|
+
*
|
|
24
|
+
* `type` is a `FieldTypes` entry (text, number, checkbox, select, multiselect,
|
|
25
|
+
* color, range) or a type a consumer renders through `renderers`. `options` is
|
|
26
|
+
* what `getFieldOptions(field, data)` reads. `visibleWhen` is
|
|
27
|
+
* `{field, in: [...]}` or `{field, equals}`: the row is hidden while the other
|
|
28
|
+
* field's value does not match, and shown again when it does — by toggling the
|
|
29
|
+
* row, never by rebuilding the form, so the control being typed into keeps its
|
|
30
|
+
* focus and its caret.
|
|
31
|
+
*
|
|
32
|
+
* A field with no `key` throws: a form that wrote every edit to
|
|
33
|
+
* `config['undefined']` was BUG-0021, and a loud mount is the cheaper failure.
|
|
34
|
+
* So does a `type` with neither a built-in control nor a renderer. `label` is
|
|
35
|
+
* optional: without one the field is called by its `key`, on its label and in
|
|
36
|
+
* every message (`count must be at least 1`).
|
|
37
|
+
*
|
|
38
|
+
* ══ AN EDIT ═════════════════════════════════════════════════════════════
|
|
39
|
+
*
|
|
40
|
+
* Every edit is checked by `validateField`. A valid one is applied and calls
|
|
41
|
+
* `onChange(key, value, config)` with a copy of the whole config. An invalid one
|
|
42
|
+
* shows its error under the field and is NOT applied: the config keeps the last
|
|
43
|
+
* valid value until the field is corrected (or `setValues` replaces it).
|
|
44
|
+
* `setValues` does not call `onChange`. `isValid()` answers false while a
|
|
45
|
+
* shown row has an error; `focusFirstInvalid()` focuses the first such row's
|
|
46
|
+
* control (a renderer's element, or the first focusable element inside it) and
|
|
47
|
+
* answers whether there was one.
|
|
48
|
+
*
|
|
49
|
+
* `commitOn: 'input'` (the default) checks text, number, range and colour
|
|
50
|
+
* controls on every keystroke. `commitOn: 'change'` checks them when the edit is
|
|
51
|
+
* finished (the control's `change`: blur, Enter, a released slider), for a
|
|
52
|
+
* consumer whose `onChange` is expensive — a save, a reload. A range still shows
|
|
53
|
+
* its value as it moves. `flush()` commits an edit typed but not yet finished,
|
|
54
|
+
* for a consumer about to take the form away; it is a no-op under `'input'`.
|
|
55
|
+
*
|
|
56
|
+
* ══ A CONSUMER'S CONTROL ════════════════════════════════════════════════
|
|
57
|
+
*
|
|
58
|
+
* `renderers[type](ctx)` — consulted before the built-ins, so it may also
|
|
59
|
+
* replace one — gets `{field, key, value, data, readonly, commit}` and returns an
|
|
60
|
+
* element, or `{element, setValue?(value), dispose?()}`. `commit(value)` runs
|
|
61
|
+
* the same check as a built-in edit and answers `{valid, error?}`. On a
|
|
62
|
+
* `readonly` field it applies nothing and answers `{valid: false, error}`, as a
|
|
63
|
+
* built-in control on a readonly field is disabled and never commits.
|
|
64
|
+
*
|
|
65
|
+
* ══ THE GRID'S COGWHEEL ═════════════════════════════════════════════════
|
|
66
|
+
*
|
|
67
|
+
* `TileGrid` emits `grid:config-modal-show` and nothing listens by default.
|
|
68
|
+
* `openTileConfig(eventBus, opts)` is the opt-in listener: it shows the form in
|
|
69
|
+
* a `SlideOutPanel` over `opts.host` (every finished, valid edit is saved — the
|
|
70
|
+
* form runs `commitOn: 'change'`, so a typed title is one save, not one per
|
|
71
|
+
* keystroke; closing the panel saves an edit still being typed) or in a modal
|
|
72
|
+
* (saved on Save, which is refused while a field shows an error: the modal
|
|
73
|
+
* stays open and the first invalid field gets the focus). A request that arrives while a modal is open is
|
|
74
|
+
* ignored: the open modal is the one being answered. It answers a function that
|
|
75
|
+
* unsubscribes; it also closes the slide-out, but leaves an open modal alone —
|
|
76
|
+
* that modal stays working (its Save still saves) until the user dismisses it.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import {
|
|
80
|
+
FieldTypes, validateField, mergeWithDefaults, getFieldOptions,
|
|
81
|
+
} from './config_schema.js';
|
|
82
|
+
import { SlideOutPanel } from '../ui/components/slide_out_panel.js';
|
|
83
|
+
import { openModal } from '../ui/components/modal.js';
|
|
84
|
+
|
|
85
|
+
let _seq = 0;
|
|
86
|
+
// What focusFirstInvalid may focus inside a renderer's element.
|
|
87
|
+
const FOCUSABLE = 'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), '
|
|
88
|
+
+ 'button:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
|
89
|
+
|
|
90
|
+
function el(tag, className, text) {
|
|
91
|
+
const n = document.createElement(tag);
|
|
92
|
+
if (className) n.className = className;
|
|
93
|
+
if (text != null) n.textContent = String(text);
|
|
94
|
+
return n;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isVisible(field, values) {
|
|
98
|
+
const rule = field.visibleWhen;
|
|
99
|
+
if (!rule) return true;
|
|
100
|
+
const v = values[rule.field];
|
|
101
|
+
if (Array.isArray(rule.in)) return rule.in.includes(v);
|
|
102
|
+
if ('equals' in rule) return v === rule.equals;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Built-in controls: each answers `{element, read(), write(value)}`. */
|
|
107
|
+
const BUILT_IN = {
|
|
108
|
+
[FieldTypes.TEXT]: (field, id) => {
|
|
109
|
+
const input = el('input', 'fd-tile-config__input');
|
|
110
|
+
input.type = 'text';
|
|
111
|
+
input.id = id;
|
|
112
|
+
return {
|
|
113
|
+
element: input, events: ['input'],
|
|
114
|
+
read: () => input.value,
|
|
115
|
+
write: (v) => { input.value = v == null ? '' : String(v); },
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
[FieldTypes.NUMBER]: (field, id) => numeric(field, id, 'number'),
|
|
119
|
+
[FieldTypes.RANGE]: (field, id) => numeric(field, id, 'range'),
|
|
120
|
+
[FieldTypes.CHECKBOX]: (field, id) => {
|
|
121
|
+
const input = el('input', 'fd-tile-config__checkbox');
|
|
122
|
+
input.type = 'checkbox';
|
|
123
|
+
input.id = id;
|
|
124
|
+
return {
|
|
125
|
+
element: input, events: ['change'],
|
|
126
|
+
read: () => input.checked,
|
|
127
|
+
write: (v) => { input.checked = !!v; },
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
[FieldTypes.COLOR]: (field, id) => {
|
|
131
|
+
const input = el('input', 'fd-tile-config__color');
|
|
132
|
+
input.type = 'color';
|
|
133
|
+
input.id = id;
|
|
134
|
+
return {
|
|
135
|
+
element: input, events: ['input'],
|
|
136
|
+
read: () => input.value,
|
|
137
|
+
write: (v) => { if (v) input.value = String(v); },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
[FieldTypes.SELECT]: (field, id, data) => choice(field, id, data, false),
|
|
141
|
+
[FieldTypes.MULTISELECT]: (field, id, data) => choice(field, id, data, true),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
function numeric(field, id, kind) {
|
|
145
|
+
const input = el('input', `fd-tile-config__${kind === 'range' ? 'range' : 'input'}`);
|
|
146
|
+
input.type = kind;
|
|
147
|
+
input.id = id;
|
|
148
|
+
if (field.min != null) input.min = String(field.min);
|
|
149
|
+
if (field.max != null) input.max = String(field.max);
|
|
150
|
+
if (field.step != null) input.step = String(field.step);
|
|
151
|
+
const out = kind === 'range' ? el('output', 'fd-tile-config__range-value') : null;
|
|
152
|
+
const element = out ? el('span', 'fd-tile-config__range-wrap') : input;
|
|
153
|
+
if (out) element.append(input, out);
|
|
154
|
+
return {
|
|
155
|
+
element, events: ['input'],
|
|
156
|
+
// The raw text goes to `validateField`; the form turns it into a number.
|
|
157
|
+
read: () => input.value,
|
|
158
|
+
write: (v) => {
|
|
159
|
+
input.value = v == null ? '' : String(v);
|
|
160
|
+
if (out) out.textContent = input.value;
|
|
161
|
+
},
|
|
162
|
+
shown: (v) => { if (out) out.textContent = v == null ? '' : String(v); },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function choice(field, id, data, multiple) {
|
|
167
|
+
const select = el('select', 'fd-tile-config__select');
|
|
168
|
+
select.id = id;
|
|
169
|
+
select.multiple = multiple;
|
|
170
|
+
// Option values keep their type (a number stays a number) — they are read
|
|
171
|
+
// back from this array by index, never from the element's string value.
|
|
172
|
+
const options = getFieldOptions(field, data);
|
|
173
|
+
options.forEach((o) => select.append(el('option', null, o.label ?? o.value)));
|
|
174
|
+
return {
|
|
175
|
+
element: select, events: ['change'],
|
|
176
|
+
read: () => (multiple
|
|
177
|
+
? options.filter((_, i) => select.options[i].selected).map((o) => o.value)
|
|
178
|
+
: (options[select.selectedIndex]?.value ?? null)),
|
|
179
|
+
write: (v) => {
|
|
180
|
+
if (multiple) {
|
|
181
|
+
const want = Array.isArray(v) ? v : [];
|
|
182
|
+
options.forEach((o, i) => { select.options[i].selected = want.includes(o.value); });
|
|
183
|
+
} else {
|
|
184
|
+
select.selectedIndex = options.findIndex((o) => o.value === v);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Number and range read as text; an empty one is null. */
|
|
191
|
+
function coerce(field, raw) {
|
|
192
|
+
if (field.type === FieldTypes.NUMBER || field.type === FieldTypes.RANGE) {
|
|
193
|
+
return raw === '' || raw == null ? null : Number(raw);
|
|
194
|
+
}
|
|
195
|
+
return raw;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Draw `schema.fields` into `host`.
|
|
200
|
+
* @param {'input'|'change'} [opts.commitOn='input'] When a text, number, range
|
|
201
|
+
* or colour control is checked and committed: on every keystroke, or once the
|
|
202
|
+
* edit is finished.
|
|
203
|
+
* @returns {{getValues(): Object, setValues(config: Object): void,
|
|
204
|
+
* isValid(): boolean, focusFirstInvalid(): boolean, flush(): void,
|
|
205
|
+
* dispose(): void}}
|
|
206
|
+
*/
|
|
207
|
+
export function mountTileConfigForm(host, {
|
|
208
|
+
schema, config = {}, data = null, onChange = null, renderers = {}, commitOn = 'input',
|
|
209
|
+
} = {}) {
|
|
210
|
+
if (!host) throw new TypeError('mountTileConfigForm: a host element is required');
|
|
211
|
+
if (commitOn !== 'input' && commitOn !== 'change') {
|
|
212
|
+
throw new TypeError(`mountTileConfigForm: unknown commitOn "${commitOn}"`);
|
|
213
|
+
}
|
|
214
|
+
const fields = Array.isArray(schema?.fields) ? schema.fields : [];
|
|
215
|
+
fields.forEach((f, i) => {
|
|
216
|
+
if (typeof f?.key !== 'string' || !f.key) {
|
|
217
|
+
const called = typeof f?.label === 'string' && f.label ? ` "${f.label}"` : '';
|
|
218
|
+
throw new TypeError(`mountTileConfigForm: field #${i + 1}${called} has no key`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
let values = mergeWithDefaults(config || {}, schema);
|
|
223
|
+
const unlisten = []; // listeners removed by dispose
|
|
224
|
+
const root = el('div', 'fd-tile-config');
|
|
225
|
+
const rows = []; // {field, named, row, error, control, invalid, pending}
|
|
226
|
+
|
|
227
|
+
const showError = (r, message) => {
|
|
228
|
+
r.invalid = !!message;
|
|
229
|
+
r.error.textContent = message || '';
|
|
230
|
+
r.error.hidden = !message;
|
|
231
|
+
r.row.classList.toggle('fd-tile-config__row--invalid', !!message);
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const applyVisibility = () => {
|
|
235
|
+
for (const r of rows) r.row.hidden = !isVisible(r.field, values);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const commit = (r, raw) => {
|
|
239
|
+
if (r.field.readonly) return { valid: false, error: `${r.named.label} is read-only` };
|
|
240
|
+
const result = validateField(raw, r.named);
|
|
241
|
+
if (!result.valid) {
|
|
242
|
+
showError(r, result.error);
|
|
243
|
+
return { valid: false, error: result.error };
|
|
244
|
+
}
|
|
245
|
+
showError(r, null);
|
|
246
|
+
const value = coerce(r.field, raw);
|
|
247
|
+
values = { ...values, [r.field.key]: value };
|
|
248
|
+
applyVisibility();
|
|
249
|
+
onChange?.(r.field.key, value, { ...values });
|
|
250
|
+
return { valid: true };
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
for (const field of fields) {
|
|
254
|
+
const id = `fd-tile-config-${++_seq}`;
|
|
255
|
+
const row = el('div', `fd-tile-config__row fd-tile-config__row--${field.type}`);
|
|
256
|
+
row.setAttribute('data-field', field.key);
|
|
257
|
+
// What validateField and the messages read: the label, or the key.
|
|
258
|
+
const named = { ...field, label: field.label ?? field.key };
|
|
259
|
+
const label = el('label', 'fd-tile-config__label', named.label);
|
|
260
|
+
const error = el('div', 'fd-tile-config__error');
|
|
261
|
+
error.hidden = true;
|
|
262
|
+
const r = { field, named, row, error, control: null, invalid: false, pending: null };
|
|
263
|
+
|
|
264
|
+
const custom = renderers?.[field.type];
|
|
265
|
+
if (typeof custom === 'function') {
|
|
266
|
+
const made = custom({
|
|
267
|
+
field, key: field.key, value: values[field.key], data,
|
|
268
|
+
readonly: !!field.readonly, commit: (v) => commit(r, v),
|
|
269
|
+
});
|
|
270
|
+
const element = made instanceof HTMLElement ? made : made?.element;
|
|
271
|
+
if (!(element instanceof HTMLElement)) {
|
|
272
|
+
throw new TypeError(`mountTileConfigForm: the renderer for "${field.type}" returned no element`);
|
|
273
|
+
}
|
|
274
|
+
element.id ||= id;
|
|
275
|
+
// The id the control carries: a renderer may have set its own.
|
|
276
|
+
label.htmlFor = element.id;
|
|
277
|
+
r.control = {
|
|
278
|
+
element,
|
|
279
|
+
write: made instanceof HTMLElement ? () => {} : (v) => made.setValue?.(v),
|
|
280
|
+
dispose: made instanceof HTMLElement ? null : () => made.dispose?.(),
|
|
281
|
+
};
|
|
282
|
+
} else {
|
|
283
|
+
const build = BUILT_IN[field.type];
|
|
284
|
+
if (!build) {
|
|
285
|
+
throw new TypeError(`mountTileConfigForm: no control for type "${field.type}" (field "${field.key}"); pass a renderer`);
|
|
286
|
+
}
|
|
287
|
+
const c = build(field, id, data);
|
|
288
|
+
label.htmlFor = id;
|
|
289
|
+
c.write(values[field.key]);
|
|
290
|
+
const input = c.element.matches('input, select') ? c.element : c.element.querySelector('input, select');
|
|
291
|
+
if (field.readonly) {
|
|
292
|
+
input.disabled = true;
|
|
293
|
+
} else {
|
|
294
|
+
const listen = (type, fn) => {
|
|
295
|
+
input.addEventListener(type, fn);
|
|
296
|
+
unlisten.push(() => input.removeEventListener(type, fn));
|
|
297
|
+
};
|
|
298
|
+
const onEdit = () => {
|
|
299
|
+
r.pending = null;
|
|
300
|
+
const raw = c.read();
|
|
301
|
+
c.shown?.(raw);
|
|
302
|
+
commit(r, raw);
|
|
303
|
+
};
|
|
304
|
+
if (commitOn === 'change') {
|
|
305
|
+
// Checked once the edit is finished; until then it is
|
|
306
|
+
// pending, so flush() can still commit it.
|
|
307
|
+
listen('change', onEdit);
|
|
308
|
+
if (c.events.includes('input')) {
|
|
309
|
+
listen('input', () => { r.pending = onEdit; c.shown?.(c.read()); });
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
for (const type of c.events) listen(type, onEdit);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
r.control = c;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (field.type === FieldTypes.CHECKBOX) row.append(r.control.element, label);
|
|
319
|
+
else row.append(label, r.control.element);
|
|
320
|
+
if (field.description) row.append(el('div', 'fd-tile-config__help', field.description));
|
|
321
|
+
row.append(error);
|
|
322
|
+
rows.push(r);
|
|
323
|
+
root.append(row);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
applyVisibility();
|
|
327
|
+
host.append(root);
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
getValues: () => ({ ...values }),
|
|
331
|
+
setValues(next) {
|
|
332
|
+
values = mergeWithDefaults(next || {}, schema);
|
|
333
|
+
for (const r of rows) {
|
|
334
|
+
r.pending = null;
|
|
335
|
+
r.control.write(values[r.field.key]);
|
|
336
|
+
showError(r, null);
|
|
337
|
+
}
|
|
338
|
+
applyVisibility();
|
|
339
|
+
},
|
|
340
|
+
isValid: () => rows.every((r) => r.row.hidden || !r.invalid),
|
|
341
|
+
focusFirstInvalid() {
|
|
342
|
+
const r = rows.find((x) => !x.row.hidden && x.invalid);
|
|
343
|
+
if (!r) return false;
|
|
344
|
+
const { element } = r.control;
|
|
345
|
+
const target = element.matches(FOCUSABLE) ? element : element.querySelector(FOCUSABLE);
|
|
346
|
+
(target || element).focus();
|
|
347
|
+
return true;
|
|
348
|
+
},
|
|
349
|
+
flush() {
|
|
350
|
+
for (const r of rows) r.pending?.();
|
|
351
|
+
},
|
|
352
|
+
dispose() {
|
|
353
|
+
unlisten.splice(0).forEach((off) => off());
|
|
354
|
+
for (const r of rows) {
|
|
355
|
+
try { r.control.dispose?.(); } catch (err) { console.warn('[tile-config] a renderer dispose threw', err); }
|
|
356
|
+
}
|
|
357
|
+
rows.length = 0;
|
|
358
|
+
root.remove();
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Show `grid:config-modal-show` requests as a config form.
|
|
365
|
+
*
|
|
366
|
+
* @param {{on(name: string, handler: Function): {dispose(): void}}} eventBus
|
|
367
|
+
* The `EventBus` the `TileGrid` emits on.
|
|
368
|
+
* @param {object} [opts]
|
|
369
|
+
* @param {'modal'|'slide-out'} [opts.mode='modal'] A modal saves on Save, and
|
|
370
|
+
* refuses Save while a field shows an error; a slide-out saves every
|
|
371
|
+
* finished, valid edit (`commitOn: 'change'`).
|
|
372
|
+
* @param {HTMLElement} [opts.host] The slide-out's host (required for it).
|
|
373
|
+
* @param {number} [opts.width] The slide-out's or the modal's width.
|
|
374
|
+
* @param {Object} [opts.renderers] Passed to `mountTileConfigForm`.
|
|
375
|
+
* @param {string|Function} [opts.title='Tile settings'] Or `(detail) => string`.
|
|
376
|
+
* @returns {() => void} Unsubscribes and closes the slide-out. An open modal is
|
|
377
|
+
* left to the user: it keeps its form, and its Save still calls `onSave`.
|
|
378
|
+
*/
|
|
379
|
+
export function openTileConfig(eventBus, { mode = 'modal', host = null, width, renderers = {}, title = 'Tile settings' } = {}) {
|
|
380
|
+
if (typeof eventBus?.on !== 'function') throw new TypeError('openTileConfig: an event bus with on() is required');
|
|
381
|
+
if (mode !== 'modal' && mode !== 'slide-out') throw new TypeError(`openTileConfig: unknown mode "${mode}"`);
|
|
382
|
+
if (mode === 'slide-out' && !(host instanceof HTMLElement)) {
|
|
383
|
+
throw new TypeError('openTileConfig: a slide-out needs a host element');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let form = null; // the slide-out's form
|
|
387
|
+
let panel = null;
|
|
388
|
+
let modalOpen = false;
|
|
389
|
+
// An edit still being typed when the panel goes is saved, not lost.
|
|
390
|
+
const drop = () => { form?.flush(); form?.dispose(); form = null; };
|
|
391
|
+
|
|
392
|
+
const show = (detail = {}) => {
|
|
393
|
+
if (modalOpen) return;
|
|
394
|
+
const heading = typeof title === 'function' ? title(detail) : title;
|
|
395
|
+
const base = { schema: detail.schema, config: detail.config, data: detail.data ?? null, renderers };
|
|
396
|
+
|
|
397
|
+
if (mode === 'slide-out') {
|
|
398
|
+
drop();
|
|
399
|
+
if (!panel) {
|
|
400
|
+
panel = new SlideOutPanel({ ...(width ? { width } : {}), onClose: drop });
|
|
401
|
+
panel.mount(host);
|
|
402
|
+
}
|
|
403
|
+
const body = panel.open(heading, 'tune');
|
|
404
|
+
form = mountTileConfigForm(body, {
|
|
405
|
+
...base, commitOn: 'change', onChange: (_k, _v, cfg) => detail.onSave?.(cfg),
|
|
406
|
+
});
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// The modal's form lives until the user dismisses the modal, whatever
|
|
411
|
+
// else happens meanwhile: requests are ignored while it is open, and
|
|
412
|
+
// the returned function leaves it alone.
|
|
413
|
+
const content = el('div', 'fd-tile-config-host');
|
|
414
|
+
const mine = mountTileConfigForm(content, base);
|
|
415
|
+
modalOpen = true;
|
|
416
|
+
openModal({
|
|
417
|
+
title: heading, icon: 'tune', content, ...(width ? { width } : {}),
|
|
418
|
+
actions: [
|
|
419
|
+
{ label: 'Cancel', value: null },
|
|
420
|
+
// Refused while a field shows an error: its last valid value is
|
|
421
|
+
// not what the user is looking at, so saving it would be a lie.
|
|
422
|
+
{ label: 'Save', value: 'save', primary: true, keepOpen: true, onClick: (close) => {
|
|
423
|
+
if (mine.isValid()) { close('save'); return; }
|
|
424
|
+
mine.focusFirstInvalid();
|
|
425
|
+
} },
|
|
426
|
+
],
|
|
427
|
+
}).then((v) => {
|
|
428
|
+
modalOpen = false;
|
|
429
|
+
if (v === 'save') detail.onSave?.(mine.getValues());
|
|
430
|
+
mine.dispose();
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const sub = eventBus.on('grid:config-modal-show', show);
|
|
435
|
+
|
|
436
|
+
return () => {
|
|
437
|
+
sub.dispose();
|
|
438
|
+
drop();
|
|
439
|
+
panel?.dispose();
|
|
440
|
+
panel = null;
|
|
441
|
+
};
|
|
442
|
+
}
|
package/src/tiles/tile_grid.js
CHANGED
|
@@ -24,6 +24,9 @@ export class TileGrid {
|
|
|
24
24
|
* @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
|
|
25
25
|
* handed to every tile. With none, the grid is the broadcast-only grid
|
|
26
26
|
* it has always been and no tile is ever asked to load anything.
|
|
27
|
+
* @param {*} [options.scope=null] - The board's scope, a filter every tile
|
|
28
|
+
* follows (0.16.1); see `setScope`.
|
|
29
|
+
* @param {Function} [options.onScopeChange] - `(scope)` after `setScope` changed it.
|
|
27
30
|
*/
|
|
28
31
|
constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
|
|
29
32
|
showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
|
|
@@ -35,8 +38,10 @@ export class TileGrid {
|
|
|
35
38
|
host = null, stateGuard = null,
|
|
36
39
|
// Injected, never constructed here: the grid does not know what
|
|
37
40
|
// a binding is, only that a tile may have somewhere to ask.
|
|
38
|
-
dataSource = null }) {
|
|
41
|
+
dataSource = null, scope = null, onScopeChange = null }) {
|
|
39
42
|
this.host = host;
|
|
43
|
+
this.scope = scope;
|
|
44
|
+
this._onScopeChangeCallback = onScopeChange;
|
|
40
45
|
this.stateGuard = stateGuard;
|
|
41
46
|
this.dataSource = dataSource;
|
|
42
47
|
this.container = container;
|
|
@@ -192,6 +197,8 @@ export class TileGrid {
|
|
|
192
197
|
});
|
|
193
198
|
|
|
194
199
|
if (!tile) return null;
|
|
200
|
+
// Before mount, so a tile's first render and first load already follow it.
|
|
201
|
+
tile.scope = this.scope;
|
|
195
202
|
|
|
196
203
|
// Add to layout
|
|
197
204
|
const layoutEntry = {
|
|
@@ -289,6 +296,34 @@ export class TileGrid {
|
|
|
289
296
|
});
|
|
290
297
|
}
|
|
291
298
|
|
|
299
|
+
/**
|
|
300
|
+
* THE BOARD'S SCOPE: one filter every tile follows ("Everyone", "Assigned
|
|
301
|
+
* by me", …), set from a `createScopePicker` or from anything else.
|
|
302
|
+
*
|
|
303
|
+
* The grid only carries the value; what it means is the embedder's. Every
|
|
304
|
+
* tile gets it as `tile.scope` and through `tile.onScopeChange(scope)`, the
|
|
305
|
+
* grid's `onScopeChange(scope)` is called, and a grid with a data source
|
|
306
|
+
* reloads its tiles, since their rows depend on it. The scope is not part
|
|
307
|
+
* of the layout: it is how the board is being looked at, not what is on it.
|
|
308
|
+
*
|
|
309
|
+
* @param {*} scope - Compared by JSON: setting the same scope again does nothing.
|
|
310
|
+
* @returns {boolean} whether it changed
|
|
311
|
+
*/
|
|
312
|
+
setScope(scope) {
|
|
313
|
+
if (JSON.stringify(scope ?? null) === JSON.stringify(this.scope ?? null)) return false;
|
|
314
|
+
this.scope = scope ?? null;
|
|
315
|
+
this.tiles.forEach((tile) => {
|
|
316
|
+
tile.scope = this.scope;
|
|
317
|
+
try { tile.onScopeChange?.(this.scope); }
|
|
318
|
+
catch (err) { console.error('[TileGrid] a tile\'s onScopeChange threw:', err); }
|
|
319
|
+
});
|
|
320
|
+
try { this._onScopeChangeCallback?.(this.scope); }
|
|
321
|
+
catch (err) { console.error('[TileGrid] onScopeChange threw:', err); }
|
|
322
|
+
this.eventBus?.emit?.('grid:scope-changed', { scope: this.scope });
|
|
323
|
+
if (this.dataSource) this.reloadAll();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
292
327
|
/**
|
|
293
328
|
* Get current layout.
|
|
294
329
|
* @returns {Array} Layout array
|
|
@@ -813,6 +848,8 @@ export class TileGrid {
|
|
|
813
848
|
tileType,
|
|
814
849
|
config,
|
|
815
850
|
schema,
|
|
851
|
+
// What `getFieldOptions` reads for a field with dynamic options.
|
|
852
|
+
data: this.data,
|
|
816
853
|
variables: this.data ? this._getVariableList() : [],
|
|
817
854
|
onSave: (newConfig) => {
|
|
818
855
|
const tile = this.tiles.get(tileId);
|
|
File without changes
|