@xh/hoist 80.0.0-SNAPSHOT.1768251023007 → 80.0.0-SNAPSHOT.1768251400948

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 (43) hide show
  1. package/CHANGELOG.md +14 -9
  2. package/build/types/cmp/grid/GridModel.d.ts +2 -2
  3. package/build/types/cmp/layout/CollapsibleSet.d.ts +14 -0
  4. package/build/types/cmp/layout/Tags.d.ts +2 -0
  5. package/build/types/cmp/tab/TabContainerModel.d.ts +1 -1
  6. package/build/types/core/XH.d.ts +1 -3
  7. package/build/types/core/enums/RenderMode.d.ts +1 -1
  8. package/build/types/desktop/cmp/button/AppMenuButton.d.ts +1 -10
  9. package/build/types/desktop/cmp/button/CollapsibleSetButton.d.ts +12 -0
  10. package/build/types/desktop/cmp/dash/canvas/DashCanvasModel.d.ts +46 -6
  11. package/build/types/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.d.ts +19 -0
  12. package/build/types/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.d.ts +11 -0
  13. package/build/types/dynamics/desktop.d.ts +1 -0
  14. package/build/types/dynamics/mobile.d.ts +1 -0
  15. package/build/types/mobile/cmp/button/CollapsibleSetButton.d.ts +12 -0
  16. package/build/types/mobile/cmp/header/AppMenuButton.d.ts +1 -10
  17. package/build/types/svc/IdentityService.d.ts +2 -4
  18. package/cmp/grid/GridModel.ts +2 -2
  19. package/cmp/layout/CollapsibleSet.scss +49 -0
  20. package/cmp/layout/CollapsibleSet.ts +135 -0
  21. package/cmp/layout/Tags.ts +2 -0
  22. package/cmp/tab/TabContainerModel.ts +1 -1
  23. package/core/XH.ts +4 -9
  24. package/core/enums/RenderMode.ts +1 -1
  25. package/desktop/appcontainer/AppContainer.ts +2 -0
  26. package/desktop/cmp/appbar/AppBar.scss +5 -24
  27. package/desktop/cmp/button/AppMenuButton.ts +2 -30
  28. package/desktop/cmp/button/CollapsibleSetButton.ts +57 -0
  29. package/desktop/cmp/dash/canvas/DashCanvas.ts +21 -4
  30. package/desktop/cmp/dash/canvas/DashCanvasModel.ts +140 -24
  31. package/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.scss +34 -0
  32. package/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWell.ts +135 -0
  33. package/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel.ts +65 -0
  34. package/dynamics/desktop.ts +2 -0
  35. package/dynamics/mobile.ts +2 -0
  36. package/mobile/appcontainer/AppContainer.ts +2 -0
  37. package/mobile/cmp/button/CollapsibleSetButton.ts +57 -0
  38. package/mobile/cmp/header/AppBar.scss +0 -11
  39. package/mobile/cmp/header/AppMenuButton.ts +1 -29
  40. package/package.json +3 -3
  41. package/styles/vars.scss +1 -2
  42. package/svc/IdentityService.ts +2 -14
  43. package/tsconfig.tsbuildinfo +1 -1
@@ -27,6 +27,7 @@ import {zoneMapperDialog as zoneMapper} from '@xh/hoist/desktop/cmp/zoneGrid/imp
27
27
  import {useContextMenu, useHotkeys} from '@xh/hoist/desktop/hooks';
28
28
  import {DynamicTabSwitcherModel, installDesktopImpls} from '@xh/hoist/dynamics/desktop';
29
29
  import {inspectorPanel} from '@xh/hoist/inspector/InspectorPanel';
30
+ import {collapsibleSetButton} from '@xh/hoist/desktop/cmp/button/CollapsibleSetButton';
30
31
  import {blueprintProvider} from '@xh/hoist/kit/blueprint';
31
32
  import {consumeEvent} from '@xh/hoist/utils/js';
32
33
  import {elementFromContent, useOnMount} from '@xh/hoist/utils/react';
@@ -46,6 +47,7 @@ import {toastSource} from './ToastSource';
46
47
  import {versionBar} from './VersionBar';
47
48
 
48
49
  installDesktopImpls({
50
+ collapsibleSetButton,
49
51
  tabContainerImpl,
50
52
  dockContainerImpl,
51
53
  storeFilterFieldImpl,
@@ -6,6 +6,11 @@
6
6
  */
7
7
 
8
8
  .xh-appbar {
9
+ // Menu button might be on left or right of appBar - add right margin when on left only.
10
+ .xh-app-menu-button:first-child {
11
+ margin-right: var(--xh-pad-px);
12
+ }
13
+
9
14
  .xh-appbar-icon {
10
15
  margin-right: var(--xh-pad-px);
11
16
  }
@@ -15,30 +20,6 @@
15
20
  font-size: var(--xh-appbar-title-font-size-px);
16
21
  margin-right: var(--xh-pad-px);
17
22
  }
18
-
19
- .xh-app-menu-button {
20
- // Menu button might be on left or right of appBar - add right margin when on left only.
21
- &:first-child {
22
- margin-right: var(--xh-pad-px);
23
- }
24
-
25
- &__user-profile {
26
- border-radius: 50%;
27
- border: var(--xh-border-solid);
28
- cursor: pointer;
29
- height: 32px;
30
- line-height: 31px;
31
- text-align: center;
32
- width: 32px;
33
- }
34
-
35
- // Trigger profile-specific hover styles on parent button hover
36
- &:hover .xh-app-menu-button__user-profile {
37
- color: var(--xh-appbar-user-profile-hover-color);
38
- border-color: var(--xh-appbar-user-profile-hover-color);
39
- background-color: transparent;
40
- }
41
- }
42
23
  }
43
24
 
44
25
  //------------------------
@@ -4,16 +4,13 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {div} from '@xh/hoist/cmp/layout';
8
- import {hoistCmp, HoistProps, HoistUser, MenuItemLike, XH} from '@xh/hoist/core';
7
+ import {hoistCmp, MenuItemLike, XH} from '@xh/hoist/core';
9
8
  import {ButtonProps, button} from '@xh/hoist/desktop/cmp/button';
10
9
  import '@xh/hoist/desktop/register';
11
10
  import {Icon} from '@xh/hoist/icon';
12
11
  import {menu, popover} from '@xh/hoist/kit/blueprint';
13
12
  import {parseMenuItems} from '@xh/hoist/utils/impl';
14
13
  import {withDefault} from '@xh/hoist/utils/js';
15
- import {isFunction} from 'lodash';
16
- import {ReactNode} from 'react';
17
14
 
18
15
  export interface AppMenuButtonProps extends ButtonProps {
19
16
  /**
@@ -53,17 +50,8 @@ export interface AppMenuButtonProps extends ButtonProps {
53
50
 
54
51
  /** True to hide the Theme Toggle button. */
55
52
  hideThemeItem?: boolean;
56
-
57
- /**
58
- * Replace the default hamburger icon with a user profile representation. Set to true to render
59
- * the user's initials from their `HoistUser.displayName`. Alternately, provide a custom
60
- * function to render an alternate compact string or element for the current user.
61
- */
62
- renderWithUserProfile?: boolean | RenderWithUserProfileCustomFn;
63
53
  }
64
54
 
65
- type RenderWithUserProfileCustomFn = (user: HoistUser) => ReactNode;
66
-
67
55
  export const [AppMenuButton, appMenuButton] = hoistCmp.withFactory<AppMenuButtonProps>({
68
56
  displayName: 'AppMenuButton',
69
57
  model: false,
@@ -82,7 +70,6 @@ export const [AppMenuButton, appMenuButton] = hoistCmp.withFactory<AppMenuButton
82
70
  hideOptionsItem,
83
71
  hideThemeItem,
84
72
  disabled,
85
- renderWithUserProfile,
86
73
  ...rest
87
74
  } = props;
88
75
 
@@ -92,8 +79,7 @@ export const [AppMenuButton, appMenuButton] = hoistCmp.withFactory<AppMenuButton
92
79
  position: 'bottom-right',
93
80
  minimal: true,
94
81
  item: button({
95
- icon: renderWithUserProfile ? null : Icon.menu(),
96
- text: renderWithUserProfile ? userProfile({renderWithUserProfile}) : null,
82
+ icon: Icon.menu(),
97
83
  disabled,
98
84
  ...rest
99
85
  }),
@@ -109,20 +95,6 @@ export const [AppMenuButton, appMenuButton] = hoistCmp.withFactory<AppMenuButton
109
95
  //---------------------------
110
96
  // Implementation
111
97
  //---------------------------
112
- const userProfile = hoistCmp.factory<
113
- HoistProps & {renderWithUserProfile: true | RenderWithUserProfileCustomFn}
114
- >({
115
- model: false,
116
- render({renderWithUserProfile}) {
117
- return div({
118
- className: 'xh-app-menu-button__user-profile',
119
- item: isFunction(renderWithUserProfile)
120
- ? renderWithUserProfile(XH.getUser())
121
- : XH.getUserInitials()
122
- });
123
- }
124
- });
125
-
126
98
  function buildMenuItems(props: AppMenuButtonProps) {
127
99
  let {
128
100
  hideAboutItem,
@@ -0,0 +1,57 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ import {type ReactElement, type ReactNode, type JSX, useState} from 'react';
9
+ import {tooltip as bpTooltip} from '@xh/hoist/kit/blueprint';
10
+ import {hoistCmp} from '@xh/hoist/core';
11
+ import type {Intent, HoistProps} from '@xh/hoist/core';
12
+ import {button} from '@xh/hoist/desktop/cmp/button';
13
+ import {legend} from '@xh/hoist/cmp/layout';
14
+ import {Icon} from '@xh/hoist/icon/Icon';
15
+
16
+ export interface CollapsibleSetButtonProps extends HoistProps {
17
+ icon?: ReactElement;
18
+ text: ReactNode;
19
+ tooltip?: JSX.Element | string;
20
+ clickHandler?: (boolean) => void;
21
+ intent?: Intent;
22
+ collapsed?: boolean;
23
+ disabled?: boolean;
24
+ }
25
+
26
+ export const [CollapsibleSetButton, collapsibleSetButton] =
27
+ hoistCmp.withFactory<CollapsibleSetButtonProps>({
28
+ displayName: 'CollapsibleSetButton',
29
+ model: false,
30
+ render({icon, text, tooltip, intent, clickHandler, collapsed, disabled}) {
31
+ const [isCollapsed, setIsCollapsed] = useState<boolean>(collapsed === true),
32
+ btn = button({
33
+ text,
34
+ icon,
35
+ rightIcon: isCollapsed ? Icon.angleDown() : Icon.angleUp(),
36
+ outlined: isCollapsed && !intent,
37
+ minimal: !intent || (intent && !isCollapsed),
38
+ intent,
39
+ disabled,
40
+ onClick: () => {
41
+ const val = !isCollapsed;
42
+ setIsCollapsed(val);
43
+ clickHandler?.(val);
44
+ }
45
+ });
46
+
47
+ return legend(
48
+ tooltip
49
+ ? bpTooltip({
50
+ item: btn,
51
+ content: tooltip,
52
+ intent
53
+ })
54
+ : btn
55
+ );
56
+ }
57
+ });
@@ -10,7 +10,7 @@ import ReactGridLayout, {
10
10
  useContainerWidth,
11
11
  getCompactor
12
12
  } from 'react-grid-layout';
13
- import {GridBackground, type GridBackgroundProps} from 'react-grid-layout/extras';
13
+ import {GridBackground, type GridBackgroundProps, wrapCompactor} from 'react-grid-layout/extras';
14
14
  import composeRefs from '@seznam/compose-react-refs';
15
15
  import {div, vbox, vspacer} from '@xh/hoist/cmp/layout';
16
16
  import {
@@ -62,7 +62,11 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory<DashCanvasProps>({
62
62
  render({className, model, rglOptions, testId}, ref) {
63
63
  const isDraggable = !model.layoutLocked,
64
64
  isResizable = !model.layoutLocked,
65
- {width, containerRef, mounted} = useContainerWidth();
65
+ {width, containerRef, mounted} = useContainerWidth(),
66
+ defaultDroppedItemDims = {
67
+ w: Math.floor(model.columns / 3),
68
+ h: Math.floor(model.columns / 3)
69
+ };
66
70
 
67
71
  return refreshContextView({
68
72
  model: model.refreshContextModel,
@@ -98,7 +102,20 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory<DashCanvasProps>({
98
102
  resizeConfig: {
99
103
  enabled: isResizable
100
104
  },
101
- compactor: getCompactor(model.compact, false, false),
105
+ dropConfig: {
106
+ enabled: model.contentLocked ? false : model.allowsDrop,
107
+ defaultItem: defaultDroppedItemDims,
108
+ onDragOver: (evt: DragEvent) => model.onDropDragOver(evt)
109
+ },
110
+ onDrop: (
111
+ layout: LayoutItem[],
112
+ layoutItem: LayoutItem,
113
+ evt: Event
114
+ ) => model.onDrop(layout, layoutItem, evt),
115
+ compactor:
116
+ model.compact === 'wrap'
117
+ ? wrapCompactor
118
+ : getCompactor(model.compact, false, false),
102
119
  onLayoutChange: (layout: LayoutItem[]) =>
103
120
  model.onRglLayoutChange(layout),
104
121
  onResizeStart: () => (model.isResizing = true),
@@ -116,7 +133,7 @@ export const [DashCanvas, dashCanvas] = hoistCmp.withFactory<DashCanvasProps>({
116
133
  ),
117
134
  width
118
135
  }),
119
- emptyContainerOverlay({omit: !mounted})
136
+ emptyContainerOverlay({omit: !mounted || !model.showAddViewButtonWhenEmpty})
120
137
  ],
121
138
  [TEST_ID]: testId
122
139
  })
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
+ import {wait} from '@xh/hoist/promise';
7
8
  import type {LayoutItem} from 'react-grid-layout';
8
9
  import {Persistable, PersistableState, PersistenceProvider, XH} from '@xh/hoist/core';
9
10
  import {required} from '@xh/hoist/data';
@@ -17,6 +18,7 @@ import {createObservableRef} from '@xh/hoist/utils/react';
17
18
  import {
18
19
  defaultsDeep,
19
20
  find,
21
+ omit,
20
22
  uniqBy,
21
23
  times,
22
24
  without,
@@ -41,11 +43,12 @@ export interface DashCanvasConfig extends DashConfig<DashCanvasViewSpec, DashCan
41
43
  rowHeight?: number;
42
44
 
43
45
  /**
44
- * Whether views should "compact" vertically or horizontally
46
+ * Whether views should "compact" vertically, horizontally or wrap
45
47
  * to condense space. Default `true` defaults to vertical compaction.
48
+ * Use `wrap` with caution. It only works well if all items are 1 row high.
46
49
  * See react-grid-layout docs for more information.
47
- * */
48
- compact?: boolean | 'vertical' | 'horizontal';
50
+ */
51
+ compact?: boolean | 'vertical' | 'horizontal' | 'wrap';
49
52
 
50
53
  /** Between items [x,y] in pixels. Default `[10, 10]`. */
51
54
  margin?: [number, number];
@@ -60,6 +63,35 @@ export interface DashCanvasConfig extends DashConfig<DashCanvasViewSpec, DashCan
60
63
  * Whether a grid background should be shown. Default false.
61
64
  */
62
65
  showGridBackground?: boolean;
66
+
67
+ /**
68
+ * Whether the canvas should accept drag-and-drop of views from outside
69
+ * the canvas. Default false.
70
+ */
71
+ allowsDrop?: boolean;
72
+
73
+ /**
74
+ * Optional callback to invoke after a view is successfully dropped onto the canvas.
75
+ */
76
+ onDropDone?: (viewModel: DashCanvasViewModel) => void;
77
+
78
+ /**
79
+ * Optional callback to invoke when an item is dragged over the canvas. This may be used to
80
+ * customize how the size of the dropping placeholder is calculated. The callback should
81
+ * return an object with optional properties indicating the desired width, height (in grid units),
82
+ * and offset (in pixels) of the dropping placeholder. The method's signature is the same as
83
+ * the `onDropDragOver` prop of ReactGridLayout.
84
+ * Returning `false` will prevent the dropping placeholder from being shown, and prevents a drop.
85
+ * Returning `void` will use the default behavior, which is to size the placeholder as per the
86
+ * `dropConfig.defaultItem` specification.
87
+ */
88
+ onDropDragOver?: (e: DragEvent) => OnDropDragOverResult;
89
+
90
+ /**
91
+ * Whether an overlay with an Add View button should be rendered
92
+ * when the canvas is empty. Default true.
93
+ */
94
+ showAddViewButtonWhenEmpty?: boolean;
63
95
  }
64
96
 
65
97
  export interface DashCanvasItemState {
@@ -76,6 +108,16 @@ export interface DashCanvasItemLayout {
76
108
  h: number;
77
109
  }
78
110
 
111
+ export type OnDropDragOverResult =
112
+ | {
113
+ w?: number;
114
+ h?: number;
115
+ dragOffsetX?: number;
116
+ dragOffsetY?: number;
117
+ }
118
+ | false
119
+ | void;
120
+
79
121
  /**
80
122
  * Model for {@link DashCanvas}, managing all configurable options for the component and publishing
81
123
  * the observable state of its current widgets and their layout.
@@ -89,16 +131,21 @@ export class DashCanvasModel
89
131
  //------------------------------
90
132
  @bindable columns: number;
91
133
  @bindable rowHeight: number;
92
- @bindable compact: 'vertical' | 'horizontal';
134
+ @bindable compact: 'vertical' | 'horizontal' | 'wrap';
93
135
  @bindable.ref margin: [number, number]; // [x, y]
94
136
  @bindable.ref containerPadding: [number, number]; // [x, y]
95
137
  @bindable showGridBackground: boolean;
96
138
  @bindable rglHeight: number;
139
+ @bindable showAddViewButtonWhenEmpty: boolean;
97
140
 
98
141
  //-----------------------------
99
142
  // Public properties
100
143
  //-----------------------------
144
+ DROPPING_ELEM_ID = '__dropping-elem__';
101
145
  maxRows: number;
146
+ allowsDrop: boolean;
147
+ onDropDone: (viewModel: DashCanvasViewModel) => void;
148
+ draggedInView: DashCanvasItemState;
102
149
 
103
150
  /** Current number of rows in canvas */
104
151
  get rows(): number {
@@ -118,21 +165,27 @@ export class DashCanvasModel
118
165
  private isLoadingState: boolean;
119
166
 
120
167
  get rglLayout() {
121
- return this.layout.map(it => {
122
- const dashCanvasView = this.getView(it.i),
123
- {autoHeight, viewSpec} = dashCanvasView;
124
-
125
- return {
126
- ...it,
127
- resizeHandles: autoHeight
128
- ? ['w', 'e']
129
- : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'],
130
- maxH: viewSpec.maxHeight,
131
- minH: viewSpec.minHeight,
132
- maxW: viewSpec.maxWidth,
133
- minW: viewSpec.minWidth
134
- };
135
- });
168
+ return this.layout
169
+ .map(it => {
170
+ const dashCanvasView = this.getView(it.i);
171
+
172
+ // `dashCanvasView` will not be found if `it` is a dropping element.
173
+ if (!dashCanvasView) return null;
174
+
175
+ const {autoHeight, viewSpec} = dashCanvasView;
176
+
177
+ return {
178
+ ...it,
179
+ resizeHandles: autoHeight
180
+ ? ['w', 'e']
181
+ : ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'],
182
+ maxH: viewSpec.maxHeight,
183
+ minH: viewSpec.minHeight,
184
+ maxW: viewSpec.maxWidth,
185
+ minW: viewSpec.minWidth
186
+ };
187
+ })
188
+ .filter(Boolean);
136
189
  }
137
190
 
138
191
  constructor({
@@ -152,7 +205,11 @@ export class DashCanvasModel
152
205
  maxRows = Infinity,
153
206
  containerPadding = margin,
154
207
  extraMenuItems,
155
- showGridBackground = false
208
+ showGridBackground = false,
209
+ showAddViewButtonWhenEmpty = true,
210
+ allowsDrop = false,
211
+ onDropDone,
212
+ onDropDragOver
156
213
  }: DashCanvasConfig) {
157
214
  super();
158
215
  makeObservable(this);
@@ -200,6 +257,10 @@ export class DashCanvasModel
200
257
  this.addViewButtonText = addViewButtonText;
201
258
  this.extraMenuItems = extraMenuItems;
202
259
  this.showGridBackground = showGridBackground;
260
+ this.showAddViewButtonWhenEmpty = showAddViewButtonWhenEmpty;
261
+ this.allowsDrop = allowsDrop;
262
+ this.onDropDone = onDropDone;
263
+ if (onDropDragOver) this.onDropDragOver = onDropDragOver;
203
264
 
204
265
  this.loadState(initialState);
205
266
  this.state = this.buildState();
@@ -337,6 +398,59 @@ export class DashCanvasModel
337
398
  this.getView(id)?.ensureVisible();
338
399
  }
339
400
 
401
+ onDrop(rglLayout: LayoutItem[], layoutItem: LayoutItem, evt: Event) {
402
+ throwIf(
403
+ !this.draggedInView,
404
+ `No draggedInView set on DashCanvasModel prior to onDrop operation.
405
+ Typically a developer would set this in response to dragstart events from
406
+ a DashViewTray or similar component.`
407
+ );
408
+
409
+ const droppingItem: any = rglLayout.find(it => it.i === this.DROPPING_ELEM_ID);
410
+ if (!droppingItem) {
411
+ // if `onDropDragOver` returned false, we won't have a dropping item
412
+ // and we cancel the drop
413
+ this.draggedInView = null;
414
+ return;
415
+ }
416
+
417
+ const {viewSpecId, title, state} = this.draggedInView,
418
+ layout = omit(layoutItem, 'i'),
419
+ newViewModel: DashCanvasViewModel = this.addViewInternal(viewSpecId, {
420
+ title,
421
+ state,
422
+ layout
423
+ });
424
+
425
+ // Change ID of dropping item to the new view's id
426
+ // so that the new view goes where the dropping item is.
427
+ droppingItem.i = newViewModel.id;
428
+
429
+ // must wait a tick for RGL to settle
430
+ wait().then(() => {
431
+ this.draggedInView = null;
432
+ this.onRglLayoutChange(rglLayout);
433
+ this.onDropDone?.(newViewModel);
434
+ });
435
+ }
436
+
437
+ setDraggedInView(view?: DashCanvasItemState) {
438
+ this.draggedInView = view;
439
+ }
440
+
441
+ onDropDragOver(evt: DragEvent): OnDropDragOverResult {
442
+ if (!this.draggedInView) return false;
443
+
444
+ return {
445
+ w: this.draggedInView.layout.w,
446
+ h: this.draggedInView.layout.h
447
+ };
448
+ }
449
+
450
+ getViewsBySpecId(id) {
451
+ return this.viewModels.filter(it => it.viewSpec.id === id);
452
+ }
453
+
340
454
  //------------------------
341
455
  // Persistable Interface
342
456
  //------------------------
@@ -413,6 +527,12 @@ export class DashCanvasModel
413
527
 
414
528
  onRglLayoutChange(rglLayout: LayoutItem[]) {
415
529
  rglLayout = rglLayout.map(it => pick(it, ['i', 'x', 'y', 'w', 'h']));
530
+
531
+ // Early out if RGL is changing layout as user is dragging droppable
532
+ // item around the canvas. This will be called again once dragging
533
+ // has stopped and user has dropped the item onto the canvas.
534
+ if (rglLayout.some(it => it.i === this.DROPPING_ELEM_ID)) return;
535
+
416
536
  this.setLayout(rglLayout);
417
537
  }
418
538
 
@@ -496,10 +616,6 @@ export class DashCanvasModel
496
616
  return some(this.viewSpecs, {id});
497
617
  }
498
618
 
499
- private getViewsBySpecId(id) {
500
- return this.viewModels.filter(it => it.viewSpec.id === id);
501
- }
502
-
503
619
  private getNextAvailablePosition({
504
620
  width,
505
621
  height,
@@ -0,0 +1,34 @@
1
+ .xh-dash-canvas-widget-well {
2
+ padding: 0 var(--xh-pad-half-px);
3
+
4
+ .xh-collapsible-set {
5
+ padding: 0 var(--xh-pad-half-px) var(--xh-pad-half-px) var(--xh-pad-half-px);
6
+ margin: var(--xh-pad-half-px);
7
+ }
8
+
9
+ .xh-dash-canvas-draggable-widget {
10
+ border: var(--xh-border-dotted);
11
+ background-color: var(--xh-bg-alt);
12
+ padding: var(--xh-pad-half-px);
13
+ margin: var(--xh-pad-half-px);
14
+ text-wrap-mode: nowrap;
15
+ cursor: grab;
16
+
17
+ &.is-dragging {
18
+ cursor: grabbing;
19
+ // lighten background color of left behind placeholder
20
+ // when dragging
21
+ opacity: 0.25;
22
+ }
23
+
24
+ &:active {
25
+ cursor: grabbing;
26
+ }
27
+ }
28
+
29
+ &--row {
30
+ .xh-dash-canvas-draggable-widget {
31
+ align-self: flex-start;
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,135 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2025 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ import {uniqBy} from 'lodash';
9
+ import classNames from 'classnames';
10
+ import type {ReactElement} from 'react';
11
+ import {div, frame} from '@xh/hoist/cmp/layout';
12
+ import {creates, hoistCmp, HoistProps, TestSupportProps, uses} from '@xh/hoist/core';
13
+ import {DashCanvasModel, DashCanvasViewSpec} from '@xh/hoist/desktop/cmp/dash';
14
+ import {DashCanvasWidgetWellModel} from '@xh/hoist/desktop/cmp/dash/canvas/widgetwell/DashCanvasWidgetWellModel';
15
+ import {collapsibleSet} from '@xh/hoist/cmp/layout/CollapsibleSet';
16
+
17
+ import './DashCanvasWidgetWell.scss';
18
+
19
+ export interface DashCanvasWidgetWellProps extends HoistProps, TestSupportProps {
20
+ /** DashCanvasModel for which this widget well should allow the user to add views from. */
21
+ dashCanvasModel?: DashCanvasModel;
22
+ /** Defaults to `column` */
23
+ flexDirection?: 'row' | 'column';
24
+ }
25
+
26
+ /**
27
+ * Widget Well from which to add items to a DashCanvas by drag-and-drop.
28
+ *
29
+ * Available view specs are listed in their defined order,
30
+ * grouped by their 'groupName' property if present.
31
+ *
32
+ * Typically, an app developer would place this inside a collapsible panel to the side of
33
+ * a DashCanvas.
34
+ */
35
+ export const [DashCanvasWidgetWell, dashCanvasWidgetWell] =
36
+ hoistCmp.withFactory<DashCanvasWidgetWellProps>({
37
+ displayName: 'DashCanvasWidgetWell',
38
+ model: creates(DashCanvasWidgetWellModel),
39
+ className: 'xh-dash-canvas-widget-well',
40
+ render({dashCanvasModel, flexDirection, className, testId}) {
41
+ if (!dashCanvasModel) return;
42
+
43
+ const classes = [];
44
+ if (flexDirection === 'row') classes.push('xh-dash-canvas-widget-well--row');
45
+
46
+ return frame({
47
+ className: classNames(className, classes),
48
+ overflowY: 'auto',
49
+ flexDirection: flexDirection || 'column',
50
+ flexWrap: flexDirection === 'row' ? 'wrap' : 'nowrap',
51
+ items: createDraggableItems(dashCanvasModel, flexDirection),
52
+ testId
53
+ });
54
+ }
55
+ });
56
+
57
+ //---------------------------
58
+ // Implementation
59
+ //---------------------------
60
+ const draggableWidget = hoistCmp.factory<DashCanvasWidgetWellModel>({
61
+ displayName: 'DraggableWidget',
62
+ model: uses(DashCanvasWidgetWellModel),
63
+ render({model, viewSpec}) {
64
+ const {id, icon, title} = viewSpec as DashCanvasViewSpec;
65
+ return div({
66
+ id: `draggableFor-${id}`,
67
+ className: 'xh-dash-canvas-draggable-widget',
68
+ draggable: true,
69
+ unselectable: 'on',
70
+ onDragStart: e => model.onDragStart(e),
71
+ onDragEnd: e => model.onDragEnd(e),
72
+ items: [icon, ' ', title]
73
+ });
74
+ }
75
+ });
76
+
77
+ /**
78
+ * Used to create draggable items (for adding views)
79
+ * @internal
80
+ */
81
+ function createDraggableItems(dashCanvasModel: DashCanvasModel, flexDirection): any[] {
82
+ if (!dashCanvasModel.ref.current) return [];
83
+
84
+ const groupedItems = {},
85
+ ungroupedItems = [];
86
+
87
+ const addToGroup = (item, icon, groupName) => {
88
+ const group = groupedItems[groupName];
89
+ if (group) {
90
+ group.push({item, icon});
91
+ } else {
92
+ groupedItems[groupName] = [{item, icon}];
93
+ }
94
+ };
95
+
96
+ dashCanvasModel.viewSpecs
97
+ .filter(viewSpec => {
98
+ return (
99
+ viewSpec.allowAdd &&
100
+ (!viewSpec.unique || !dashCanvasModel.getViewsBySpecId(viewSpec.id).length)
101
+ );
102
+ })
103
+ .forEach(viewSpec => {
104
+ const {groupName} = viewSpec,
105
+ item = draggableWidget({viewSpec});
106
+
107
+ if (groupName) {
108
+ addToGroup(item, viewSpec.icon, groupName);
109
+ } else {
110
+ ungroupedItems.push(item);
111
+ }
112
+ });
113
+
114
+ return [
115
+ ...Object.keys(groupedItems).map(group => {
116
+ const label = group,
117
+ items = groupedItems[group],
118
+ sameIcons =
119
+ uniqBy<{item: ReactElement; icon: ReactElement}>(
120
+ items,
121
+ it => it.icon.props.iconName
122
+ ).length === 1,
123
+ icon = sameIcons ? items[0].icon : null;
124
+
125
+ return collapsibleSet({
126
+ icon,
127
+ collapsed: false,
128
+ label,
129
+ flexDirection,
130
+ items: items.map(it => it.item)
131
+ });
132
+ }),
133
+ ...ungroupedItems
134
+ ];
135
+ }