Skip to content

Commit 3ff1052

Browse files
authored
Merge pull request #1147 from mathuo/wip/tab-context-menu
feat: add tab context menu with getContextMenuItems callback
2 parents 51505a6 + c8f79ef commit 3ff1052

33 files changed

Lines changed: 2384 additions & 233 deletions

File tree

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { Component, Input, Type } from '@angular/core';
3+
import {
4+
CreateContextMenuItemComponentOptions,
5+
DockviewGroupPanel,
6+
IDockviewPanel,
7+
IContextMenuItemComponentProps,
8+
} from 'dockview-core';
9+
import { DockviewAngularComponent } from '../lib/dockview/dockview-angular.component';
10+
import { AngularRenderer } from '../lib/utils/angular-renderer';
11+
import { setupTestBed, getTestComponents } from './__test_utils__/test-helpers';
12+
13+
@Component({
14+
selector: 'test-context-menu-item',
15+
template: '<div class="test-menu-item">My Item</div>',
16+
})
17+
class TestContextMenuItemComponent {}
18+
19+
@Component({
20+
selector: 'test-context-menu-item-with-inputs',
21+
template: '<div></div>',
22+
})
23+
class TestContextMenuItemWithInputsComponent {
24+
@Input() panel!: IDockviewPanel;
25+
@Input() group!: DockviewGroupPanel;
26+
@Input() api!: any;
27+
@Input() close!: () => void;
28+
@Input() componentProps?: object;
29+
}
30+
31+
describe('DockviewAngularComponent – context menu', () => {
32+
let component: DockviewAngularComponent;
33+
let fixture: ComponentFixture<DockviewAngularComponent>;
34+
35+
beforeEach(async () => {
36+
setupTestBed();
37+
TestBed.overrideModule(
38+
(await import('../lib/dockview-angular.module'))
39+
.DockviewAngularModule,
40+
{}
41+
);
42+
await TestBed.compileComponents();
43+
44+
fixture = TestBed.createComponent(DockviewAngularComponent);
45+
component = fixture.componentInstance;
46+
component.components = getTestComponents();
47+
});
48+
49+
afterEach(() => {
50+
component.getDockviewApi()?.dispose();
51+
fixture.destroy();
52+
TestBed.resetTestingModule();
53+
});
54+
55+
it('getTabContextMenuItems input is accepted without error', () => {
56+
const getTabContextMenuItems = jest
57+
.fn()
58+
.mockReturnValue(['close', 'closeAll']);
59+
component.getTabContextMenuItems = getTabContextMenuItems;
60+
61+
expect(() => component.ngOnInit()).not.toThrow();
62+
});
63+
64+
it('createContextMenuItemComponent returns an AngularRenderer for a component', () => {
65+
component.ngOnInit();
66+
67+
// Access the private method via casting to any
68+
const frameworkOptions = (component as any).createFrameworkOptions();
69+
const factory = frameworkOptions.createContextMenuItemComponent;
70+
71+
expect(factory).toBeDefined();
72+
73+
const renderer = factory({
74+
id: 'test-id',
75+
component: TestContextMenuItemComponent as Type<any>,
76+
} as CreateContextMenuItemComponentOptions);
77+
78+
expect(renderer).toBeInstanceOf(AngularRenderer);
79+
});
80+
81+
it('createContextMenuItemComponent returns undefined when no component provided', () => {
82+
component.ngOnInit();
83+
84+
const frameworkOptions = (component as any).createFrameworkOptions();
85+
const factory = frameworkOptions.createContextMenuItemComponent;
86+
87+
const renderer = factory({
88+
id: 'test-id',
89+
component: undefined,
90+
} as CreateContextMenuItemComponentOptions);
91+
92+
expect(renderer).toBeUndefined();
93+
});
94+
95+
it('AngularRenderer returned by factory can be initialised with context menu props', () => {
96+
component.ngOnInit();
97+
98+
const frameworkOptions = (component as any).createFrameworkOptions();
99+
const factory = frameworkOptions.createContextMenuItemComponent;
100+
101+
const renderer: AngularRenderer = factory({
102+
id: 'test-id',
103+
component: TestContextMenuItemComponent as Type<any>,
104+
} as CreateContextMenuItemComponentOptions);
105+
106+
const props: IContextMenuItemComponentProps = {
107+
panel: {} as any,
108+
group: {} as any,
109+
api: {} as any,
110+
close: jest.fn(),
111+
};
112+
113+
expect(() => renderer.init(props)).not.toThrow();
114+
renderer.dispose();
115+
});
116+
117+
it('forwards panel, group, close, and componentProps to the Angular component @Inputs', () => {
118+
component.ngOnInit();
119+
120+
const frameworkOptions = (component as any).createFrameworkOptions();
121+
const factory = frameworkOptions.createContextMenuItemComponent;
122+
123+
const renderer: AngularRenderer<TestContextMenuItemWithInputsComponent> =
124+
factory({
125+
id: 'test-id',
126+
component: TestContextMenuItemWithInputsComponent as Type<any>,
127+
} as CreateContextMenuItemComponentOptions);
128+
129+
const panel = {} as IDockviewPanel;
130+
const group = {} as DockviewGroupPanel;
131+
const closeFn = jest.fn();
132+
const extraProps = { foo: 'bar' };
133+
134+
const props: IContextMenuItemComponentProps = {
135+
panel,
136+
group,
137+
api: {} as any,
138+
close: closeFn,
139+
componentProps: extraProps,
140+
};
141+
142+
renderer.init(props);
143+
144+
const instance = renderer.component!.instance;
145+
expect(instance.panel).toBe(panel);
146+
expect(instance.group).toBe(group);
147+
expect(instance.close).toBe(closeFn);
148+
expect(instance.componentProps).toBe(extraProps);
149+
150+
renderer.dispose();
151+
});
152+
});

packages/dockview-angular/src/lib/dockview/dockview-angular.component.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ import {
2626
DockviewFrameworkOptions,
2727
DockviewComponentOptions,
2828
TabAnimation,
29+
GetTabContextMenuItemsParams,
30+
ContextMenuItem,
2931
} from 'dockview-core';
3032
import { AngularFrameworkComponentFactory } from '../utils/component-factory';
33+
import { AngularRenderer } from '../utils/angular-renderer';
3134
import { AngularLifecycleManager } from '../utils/lifecycle-utils';
3235

3336
export interface DockviewAngularOptions extends DockviewOptions {
@@ -38,6 +41,9 @@ export interface DockviewAngularOptions extends DockviewOptions {
3841
leftHeaderActionsComponent?: Type<any>;
3942
rightHeaderActionsComponent?: Type<any>;
4043
prefixHeaderActionsComponent?: Type<any>;
44+
getTabContextMenuItems?: (
45+
params: GetTabContextMenuItemsParams
46+
) => (ContextMenuItem | { component: Type<any> })[];
4147
}
4248

4349
@Component({
@@ -87,6 +93,9 @@ export class DockviewAngularComponent implements OnInit, OnDestroy, OnChanges {
8793
@Input() disableAutoResizing?: boolean;
8894
@Input() singleTabMode?: 'fullwidth' | 'default';
8995
@Input() tabAnimation?: TabAnimation;
96+
@Input() getTabContextMenuItems?: (
97+
params: GetTabContextMenuItemsParams
98+
) => (ContextMenuItem | { component: Type<any> })[];
9099

91100
@Output() ready = new EventEmitter<DockviewReadyEvent>();
92101
@Output() didDrop = new EventEmitter<DockviewDidDropEvent>();
@@ -222,6 +231,17 @@ export class DockviewAngularComponent implements OnInit, OnDestroy, OnChanges {
222231
)!;
223232
}
224233
: undefined,
234+
createContextMenuItemComponent: (options) => {
235+
if (!options.component) {
236+
return undefined;
237+
}
238+
const renderer = new AngularRenderer({
239+
component: options.component as Type<any>,
240+
injector: this.injector,
241+
environmentInjector: this.environmentInjector,
242+
});
243+
return renderer;
244+
},
225245
};
226246
}
227247

packages/dockview-angular/src/lib/utils/angular-renderer.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,11 @@ export class AngularRenderer<T = any>
3838
}
3939

4040
init(parameters: Parameters): void {
41-
// Only forward params, api, and containerApi to the component
42-
// (matching the React renderer). Other init parameters like
43-
// 'title' are internal to the framework.
41+
// Forward the known user-facing fields from panel/tab renderers
42+
// and context menu item renderers. Other internal fields (e.g. 'title')
43+
// are excluded here; update() further guards with `key in instance`.
4444
const filtered: Record<string, unknown> = {};
45+
// Panel / tab renderer fields
4546
if ('params' in parameters) {
4647
filtered['params'] = parameters['params'];
4748
}
@@ -51,6 +52,19 @@ export class AngularRenderer<T = any>
5152
if ('containerApi' in parameters) {
5253
filtered['containerApi'] = parameters['containerApi'];
5354
}
55+
// Context menu item renderer fields (IContextMenuItemComponentProps)
56+
if ('panel' in parameters) {
57+
filtered['panel'] = parameters['panel'];
58+
}
59+
if ('group' in parameters) {
60+
filtered['group'] = parameters['group'];
61+
}
62+
if ('close' in parameters) {
63+
filtered['close'] = parameters['close'];
64+
}
65+
if ('componentProps' in parameters) {
66+
filtered['componentProps'] = parameters['componentProps'];
67+
}
5468

5569
if (this.componentRef) {
5670
this.update(filtered);
@@ -67,9 +81,7 @@ export class AngularRenderer<T = any>
6781
const instance = this.componentRef.instance as Record<string, unknown>;
6882

6983
for (const key of Object.keys(params)) {
70-
if (key in instance) {
71-
instance[key] = params[key];
72-
}
84+
instance[key] = params[key];
7385
}
7486

7587
// trigger change detection

packages/dockview-core/src/__tests__/dockview/components/tab.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,26 @@ describe('tab', () => {
267267
).toBe(0);
268268
});
269269

270+
describe('contextmenu event', () => {
271+
test('right-clicking a tab calls contextMenuController.show with the panel and group', () => {
272+
const showMock = jest.fn();
273+
const accessor = fromPartial<DockviewComponent>({
274+
options: {},
275+
contextMenuController: { show: showMock },
276+
});
277+
278+
const panel = fromPartial<IDockviewPanel>({ id: 'panelId' });
279+
const group = fromPartial<DockviewGroupPanel>({ id: 'groupId' });
280+
281+
const cut = new Tab(panel, accessor, group);
282+
283+
const event = new MouseEvent('contextmenu', { cancelable: true });
284+
fireEvent(cut.element, event);
285+
286+
expect(showMock).toHaveBeenCalledWith(panel, group, event);
287+
});
288+
});
289+
270290
describe('disableDnd option', () => {
271291
test('that tab is draggable by default (disableDnd not set)', () => {
272292
const accessor = fromPartial<DockviewComponent>({

0 commit comments

Comments
 (0)