Skip to content

feat(cdk-experimental/menu): Implement grouping logic for menuitems #19628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 17, 2020
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion src/cdk-experimental/menu/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "ng_module")
load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite")

package(default_visibility = ["//visibility:public"])

Expand All @@ -15,3 +15,20 @@ ng_module(
"@npm//rxjs",
],
)

ng_test_library(
name = "unit_test_sources",
srcs = glob(
["**/*.spec.ts"],
exclude = ["**/*.e2e.spec.ts"],
),
deps = [
":menu",
"@npm//@angular/platform-browser",
],
)

ng_web_test_suite(
name = "unit_tests",
deps = [":unit_test_sources"],
)
175 changes: 175 additions & 0 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import {ComponentFixture, TestBed, async} from '@angular/core/testing';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized you test a lot of the same functionality in the tests for menu-group. Since menu-bar extends menu-group, I don't think you need to repeat the same tests here -- this suite can focus on tests specific to menu-bar's extra functionality.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The menu and menu-bar tests are more of a sanity check to ensure that it's wired up correctly The meat of the logic is in the menu-group which contains most of the tests.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, just one or two sanity-check tests should be enough -- I don't think you need to go over checkboxes and radio buttons separately. (That said, I initially thought there were more tests in this file, for some reason. Now that I look again, there doesn't seem to be as much overlap. I'm still not used to reviewing on Github...)

import {Component} from '@angular/core';
import {CdkMenuBar} from './menu-bar';
import {CdkMenuModule} from './menu-module';
import {CdkMenuItem} from './menu-item';
import {By} from '@angular/platform-browser';

describe('MenuBar', () => {
describe('as radio group', () => {
let fixture: ComponentFixture<MenuBarRadioGroup>;
let menuBar: CdkMenuBar;
let menuItems: Array<CdkMenuItem>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarRadioGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarRadioGroup);
fixture.detectChanges();

menuBar = fixture.debugElement.query(By.directive(CdkMenuBar)).injector.get(CdkMenuBar);

menuItems = menuBar._allItems.toArray();
}));

it('should toggle menuitemradio items', () => {
expect(menuItems[0].checked).toBeTrue();
expect(menuItems[1].checked).toBeFalse();

menuItems[1].trigger();

expect(menuItems[0].checked).toBeFalse();
expect(menuItems[1].checked).toBeTrue();
});
});

describe('as checkbox group', () => {
let fixture: ComponentFixture<MenuBarCheckboxGroup>;
let menuBar: CdkMenuBar;
let menuItems: Array<CdkMenuItem>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarCheckboxGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarCheckboxGroup);

menuBar = fixture.debugElement.query(By.directive(CdkMenuBar)).injector.get(CdkMenuBar);
fixture.detectChanges();

menuItems = menuBar._allItems.toArray();
}));

it('should toggle menuitemcheckbox', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you remove words like 'should' from your test descriptions, you can make them more concise. (We can assume that all tests describe the intended behavior for the components under tests.)

Also, this description could be more specific. You're testing that menuitemcheckboxes are toggled in isolation, so if the description mentions this, you can avoid the comment further down.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a convention for Angular projects; we want test description to read like a sentence ("it should...")

expect(menuItems[0].checked).toBeTrue();
expect(menuItems[1].checked).toBeFalse();

menuItems[1].trigger();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with the public Angular testing conventions. Would this be the appropriate point to bring in test harnesses? (Would they apply for CDK directives, as opposed to actual components?)

Usually, it would be better to simulate real clicks using harness actions or mouse events, instead of directly calling methods on the directive class. But I don't know if that applies here, or if it's possible at this point in development.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually don't use harnesses for the component's unit test; we create separate tests for the harnesses once we make them

expect(menuItems[0].checked).toBeTrue(); // checkbox should not change

menuItems[0].trigger();

expect(menuItems[0].checked).toBeFalse();
expect(menuItems[1].checked).toBeTrue();
});
});

describe('checkbox change events', () => {
let fixture: ComponentFixture<MenuBarCheckboxGroup>;
let menu: CdkMenuBar;
let menuItems: Array<CdkMenuItem>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarCheckboxGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarCheckboxGroup);

menu = fixture.debugElement.query(By.directive(CdkMenuBar)).injector.get(CdkMenuBar);
fixture.detectChanges();

menuItems = menu._allItems.toArray();
}));

it('should emit on click', () => {
const spy = jasmine.createSpy('cdkMenu change spy');
fixture.debugElement
.query(By.directive(CdkMenuBar))
.injector.get(CdkMenuBar)
.change.subscribe(spy);

menuItems[0].trigger();

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(menuItems[0]);
});
});

describe('radiogroup change events', () => {
let fixture: ComponentFixture<MenuBarRadioGroup>;
let menu: CdkMenuBar;
let menuItems: Array<CdkMenuItem>;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MenuBarRadioGroup],
}).compileComponents();

fixture = TestBed.createComponent(MenuBarRadioGroup);

menu = fixture.debugElement.query(By.directive(CdkMenuBar)).injector.get(CdkMenuBar);
fixture.detectChanges();

menuItems = menu._allItems.toArray();
}));

it('should emit on click', () => {
const spy = jasmine.createSpy('cdkMenu change spy');
fixture.debugElement
.query(By.directive(CdkMenuBar))
.injector.get(CdkMenuBar)
.change.subscribe(spy);

menuItems[0].trigger();

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(menuItems[0]);
});
});
});

@Component({
selector: 'menubar-as-radio-group',
template: `
<ul cdkMenuBar>
<li role="none">
<button checked="true" role="menuitemradio" cdkMenuItem>
first
</button>
</li>
<li role="none">
<button role="menuitemradio" cdkMenuItem>
second
</button>
</li>
</ul>
`,
})
class MenuBarRadioGroup {}

@Component({
selector: 'menubar-as-checkbox-group',
template: `
<ul cdkMenuBar>
<li role="none">
<button checked="true" role="menuitemcheckbox" cdkMenuItem>
first
</button>
</li>
<li role="none">
<button role="menuitemcheckbox" cdkMenuItem>
second
</button>
</li>
</ul>
`,
})
class MenuBarCheckboxGroup {}
11 changes: 9 additions & 2 deletions src/cdk-experimental/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, Input} from '@angular/core';
import {Directive, Input, ContentChildren, QueryList} from '@angular/core';
import {CdkMenuGroup} from './menu-group';
import {CdkMenuItem} from './menu-item';

/**
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
Expand All @@ -21,11 +23,16 @@ import {Directive, Input} from '@angular/core';
'role': 'menubar',
'[attr.aria-orientation]': 'orientation',
},
providers: [{provide: CdkMenuGroup, useExisting: CdkMenuBar}],
})
export class CdkMenuBar {
export class CdkMenuBar extends CdkMenuGroup {
/**
* Sets the aria-orientation attribute and determines where sub-menus will be opened.
* Does not affect styling/layout.
*/
@Input('cdkMenuBarOrientation') orientation: 'horizontal' | 'vertical' = 'horizontal';

/** All the child MenuItem components which this directive wraps including descendants */

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you only using this to access items in the unit test? If so, it may be better to leave it out and find another way to grab the menu items for the test (using selectors, for instance). Although it's good to design things for testability, it's not a good idea to have test-only APIs. In general, they encourage testing in ways that don't match the real-world use of the components. (At the very least, you also want to reduce the size of the Angular payload by not including test-only code.)

If you do plan to use this within this component itself, then never mind (although I still wouldn't use it in tests, since it's been marked private with an underscore).

Copy link
Contributor Author

@andy9775 andy9775 Jun 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'll be used in future functionality. I added it at this point since I can also use it in the test. But ya, I could have also added the ContentChildren into the test fixture and gone about it that way.

As for the package private - the Angular recommendation is _underscore is used internally - kind of like package private in java. @jelbourn thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do use the underscore prefix as "library-internal". For testing, though, we we typically grab content child items directly (via debugElement.query) in the tests rather than going through the container component's ContentChildren property since it is more of an implementation detail.

As a rule of thumb, I would only add stuff to the code as it's needed for some behavior/feature.

@ContentChildren(CdkMenuItem, {descendants: true})
readonly _allItems: QueryList<CdkMenuItem>;
}
Loading