Skip to content

Commit 6a9a6a4

Browse files
wusteven815mofojed
andauthored
feat: context menu reopen for stack only (#1932)
- Adds #1931 - Add new context menu option for panels - Give IDs to stacks and helper function to get stacks by ID - Add ability to open component to specific stack - Fix unit tests related to IDs and layouts, added E2E tests --------- Co-authored-by: Mike Bender <mikebender@deephaven.io>
1 parent 5f49761 commit 6a9a6a4

10 files changed

Lines changed: 241 additions & 34 deletions

File tree

packages/dashboard-core-plugins/src/panels/Panel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ class Panel extends PureComponent<PanelProps, PanelState> {
333333
className,
334334
renderTabTooltip,
335335
glContainer,
336+
glEventHub,
336337
additionalActions,
337338
errorMessage,
338339
isLoaded,
@@ -362,6 +363,7 @@ class Panel extends PureComponent<PanelProps, PanelState> {
362363
<>
363364
<PanelContextMenu
364365
glContainer={glContainer}
366+
glEventHub={glEventHub}
365367
additionalActions={this.getAdditionActions(
366368
additionalActions,
367369
isClonable,

packages/dashboard-core-plugins/src/panels/PanelContextMenu.test.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import React from 'react';
22
import { render } from '@testing-library/react';
3-
import type { Container } from '@deephaven/golden-layout';
3+
import { EventEmitter, type Container } from '@deephaven/golden-layout';
4+
import { createMockStore } from '@deephaven/redux';
5+
import { ApiContext } from '@deephaven/jsapi-bootstrap';
6+
import dh from '@deephaven/jsapi-shim';
7+
import { Provider } from 'react-redux';
48
import PanelContextMenu from './PanelContextMenu';
59

610
function makeGlComponent({
@@ -9,13 +13,25 @@ function makeGlComponent({
913
emit = jest.fn(),
1014
unbind = jest.fn(),
1115
trigger = jest.fn(),
16+
layoutManager = {
17+
root: {},
18+
},
19+
getConfig = jest.fn(),
1220
} = {}) {
13-
return { on, off, emit, unbind, trigger };
21+
return { on, off, emit, unbind, trigger, layoutManager, getConfig };
1422
}
1523

1624
function mountPanelContextMenu() {
25+
const store = createMockStore();
1726
return render(
18-
<PanelContextMenu glContainer={makeGlComponent() as unknown as Container} />
27+
<ApiContext.Provider value={dh}>
28+
<Provider store={store}>
29+
<PanelContextMenu
30+
glContainer={makeGlComponent() as unknown as Container}
31+
glEventHub={new EventEmitter()}
32+
/>
33+
</Provider>
34+
</ApiContext.Provider>
1935
);
2036
}
2137

packages/dashboard-core-plugins/src/panels/PanelContextMenu.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import React, { PureComponent, ReactElement } from 'react';
22
import { ContextAction, ContextActions } from '@deephaven/components';
3-
import type { Container, Tab } from '@deephaven/golden-layout';
3+
import type { Container, EventEmitter, Tab } from '@deephaven/golden-layout';
4+
import {
5+
CustomizableWorkspace,
6+
RootState,
7+
getWorkspace,
8+
setWorkspace as setWorkspaceAction,
9+
} from '@deephaven/redux';
10+
import { connect } from 'react-redux';
11+
import { ClosedPanel, LayoutUtils, PanelEvent } from '@deephaven/dashboard';
412

513
interface PanelContextMenuProps {
614
additionalActions: ContextAction[];
715
glContainer: Container;
16+
glEventHub: EventEmitter;
17+
workspace: CustomizableWorkspace;
818
}
919

1020
interface HasContainer {
@@ -26,6 +36,7 @@ class PanelContextMenu extends PureComponent<
2636
this.handleCloseTab = this.handleCloseTab.bind(this);
2737
this.handleCloseTabsRight = this.handleCloseTabsRight.bind(this);
2838
this.handleCloseTabsAll = this.handleCloseTabsAll.bind(this);
39+
this.handleReopenLast = this.handleReopenLast.bind(this);
2940
}
3041

3142
getAllTabs(): Tab[] {
@@ -66,6 +77,11 @@ class PanelContextMenu extends PureComponent<
6677
}
6778
}
6879

80+
handleReopenLast(): void {
81+
const { glContainer, glEventHub } = this.props;
82+
glEventHub.emit(PanelEvent.REOPEN_LAST, glContainer);
83+
}
84+
6985
canCloseTabsRight(): boolean {
7086
const { glContainer } = this.props;
7187
const tabs = this.getAllTabs();
@@ -101,13 +117,32 @@ class PanelContextMenu extends PureComponent<
101117
return disabled;
102118
}
103119

120+
canReopenLast(): boolean {
121+
const { workspace, glContainer } = this.props;
122+
const stackId = LayoutUtils.getStackForConfig(
123+
glContainer.layoutManager.root,
124+
glContainer.getConfig()
125+
)?.config.id;
126+
127+
return !workspace.data.closed?.some(
128+
panel => (panel as ClosedPanel).parentStackId === stackId
129+
);
130+
}
131+
104132
render(): ReactElement {
105133
const { additionalActions, glContainer } = this.props;
106134

107135
const contextActions: (ContextAction | (() => ContextAction))[] = [
108136
...additionalActions,
109137
];
110138

139+
contextActions.push(() => ({
140+
title: 'Re-open closed panel',
141+
group: ContextActions.groups.medium + 2004,
142+
action: this.handleReopenLast,
143+
disabled: this.canReopenLast(),
144+
}));
145+
111146
const closable = glContainer.tab?.contentItem?.config?.isClosable;
112147
contextActions.push({
113148
title: 'Close',
@@ -138,4 +173,16 @@ class PanelContextMenu extends PureComponent<
138173
}
139174
}
140175

141-
export default PanelContextMenu;
176+
const mapStateToProps = (
177+
state: RootState
178+
): {
179+
workspace: CustomizableWorkspace;
180+
} => ({
181+
workspace: getWorkspace(state),
182+
});
183+
184+
const ConnectedPanelContextMenu = connect(mapStateToProps, {
185+
setWorkspace: setWorkspaceAction,
186+
})(PanelContextMenu);
187+
188+
export default ConnectedPanelContextMenu;

packages/dashboard/src/PanelManager.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ export type PanelDehydraterFunction = (
2828
config: ReactComponentConfig
2929
) => ReactComponentConfig;
3030

31-
export type ClosedPanel = ReactComponentConfig;
31+
export type ClosedPanel = ReactComponentConfig & {
32+
/**
33+
* The stack the component is in.
34+
*/
35+
parentStackId?: string | string[];
36+
};
3237

3338
export type ClosedPanels = ClosedPanel[];
3439

@@ -291,12 +296,40 @@ class PanelManager {
291296
};
292297

293298
const { root } = this.layout;
294-
LayoutUtils.openComponent({ root, config, replaceConfig });
299+
const stack =
300+
panelConfig.parentStackId === undefined
301+
? undefined
302+
: LayoutUtils.getStackById(root, panelConfig.parentStackId);
303+
LayoutUtils.openComponent({
304+
root,
305+
config,
306+
replaceConfig,
307+
stack: stack ?? undefined,
308+
});
295309
}
296310

297-
handleReopenLast(): void {
311+
/**
312+
*
313+
* @param glContainer Only reopen panels that were closed from the stack of this container, if defined
314+
*/
315+
handleReopenLast(glContainer?: Container): void {
298316
if (this.closed.length === 0) return;
299-
this.handleReopen(this.closed[this.closed.length - 1]);
317+
if (glContainer === undefined) {
318+
this.handleReopen(this.closed[this.closed.length - 1]);
319+
return;
320+
}
321+
322+
const stackId = LayoutUtils.getStackForConfig(
323+
this.layout.root,
324+
glContainer.getConfig()
325+
)?.config.id;
326+
for (let i = this.closed.length - 1; i >= 0; i -= 1) {
327+
const panelConfig = this.closed[i];
328+
if (panelConfig.parentStackId === stackId) {
329+
this.handleReopen(panelConfig);
330+
return;
331+
}
332+
}
300333
}
301334

302335
handleDeleted(panelConfig: ClosedPanel): void {
@@ -322,15 +355,16 @@ class PanelManager {
322355
}
323356

324357
addClosedPanel(glContainer: Container): void {
358+
const { root } = this.layout;
325359
const config = LayoutUtils.getComponentConfigFromContainer(glContainer);
326360
if (config && isReactComponentConfig(config)) {
327361
const dehydratedConfig = this.dehydrateComponent(
328362
config.component,
329363
config
330364
);
331-
if (dehydratedConfig != null) {
332-
this.closed.push(dehydratedConfig);
333-
}
365+
(dehydratedConfig as ClosedPanel).parentStackId =
366+
LayoutUtils.getStackForConfig(root, config)?.config.id;
367+
this.closed.push(dehydratedConfig);
334368
}
335369
}
336370

packages/dashboard/src/layout/LayoutUtils.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,45 @@ class LayoutUtils {
151151
return this.addStack(newParent, !columnPreferred);
152152
}
153153

154+
/**
155+
* Gets a stack by its ID
156+
* @param item Golden layout content item to search for the stack
157+
* @param searchId the ID
158+
*/
159+
static getStackById(
160+
item: ContentItem,
161+
searchId: string | string[],
162+
allowEmptyStack = false
163+
): Stack | null {
164+
if (allowEmptyStack && isStack(item) && item.contentItems.length === 0) {
165+
return item;
166+
}
167+
168+
if (searchId === item.config?.id) {
169+
if (isStack(item)) {
170+
return item as Stack;
171+
}
172+
throw new Error(`Item with ID ${searchId} is not a stack`);
173+
}
174+
175+
if (item.contentItems == null) {
176+
return null;
177+
}
178+
179+
for (let i = 0; i < item.contentItems.length; i += 1) {
180+
const stack = this.getStackById(
181+
item.contentItems[i],
182+
searchId,
183+
allowEmptyStack
184+
);
185+
if (stack) {
186+
return stack;
187+
}
188+
}
189+
190+
return null;
191+
}
192+
154193
/**
155194
* Gets the first stack which contains a contentItem with the given config values
156195
* @param item Golden layout content item to search for the stack
@@ -466,6 +505,7 @@ class LayoutUtils {
466505
static openComponent({
467506
root,
468507
config: configParam,
508+
stack: stackParam = undefined,
469509
replaceExisting = true,
470510
replaceConfig = undefined,
471511
createNewStack = false,
@@ -474,6 +514,7 @@ class LayoutUtils {
474514
}: {
475515
root?: ContentItem;
476516
config?: Partial<ReactComponentConfig>;
517+
stack?: Stack;
477518
replaceExisting?: boolean;
478519
replaceConfig?: Partial<ItemConfigType>;
479520
createNewStack?: boolean;
@@ -498,9 +539,11 @@ class LayoutUtils {
498539
component: config.component,
499540
};
500541
assertNotNull(root);
501-
const stack = createNewStack
502-
? LayoutUtils.addStack(root)
503-
: LayoutUtils.getStackForRoot(root, searchConfig);
542+
const stack =
543+
stackParam ??
544+
(createNewStack
545+
? LayoutUtils.addStack(root)
546+
: LayoutUtils.getStackForRoot(root, searchConfig));
504547

505548
assertNotNull(stack);
506549
const oldContentItem = LayoutUtils.getContentItemInStack(

packages/golden-layout/src/LayoutManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,7 @@ export class LayoutManager extends EventEmitter {
535535
};
536536
}
537537

538+
config.id = config.id ?? getUniqueId();
538539
contentItem = new this._typeToItem[config.type](this, config, parent);
539540
return contentItem;
540541
}

packages/golden-layout/src/container/ItemContainer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,15 @@ export default class ItemContainer<
182182
return this._config.componentState;
183183
}
184184

185+
/**
186+
* Returns the object's config
187+
*
188+
* @returns id
189+
*/
190+
getConfig() {
191+
return this._config;
192+
}
193+
185194
/**
186195
* Merges the provided state into the current one
187196
*

packages/golden-layout/src/utils/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import $ from 'jquery';
2+
import shortid from 'shortid';
23

34
export function getHashValue(key: string) {
45
var matches = location.hash.match(new RegExp(key + '=([^&]*)'));
@@ -40,7 +41,7 @@ export function removeFromArray<T>(item: T, array: T[]) {
4041
}
4142

4243
export function getUniqueId() {
43-
return (Math.random() * 1000000000000000).toString(36).replace('.', '');
44+
return shortid();
4445
}
4546

4647
/**

0 commit comments

Comments
 (0)