Skip to content

Changes custom modal to be created dynamically #6799

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 12 commits into from
Mar 26, 2024
1 change: 1 addition & 0 deletions tensorboard/webapp/app_container.ng.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
<settings-polymer-interop></settings-polymer-interop>
<dark-mode-supporter></dark-mode-supporter>
<feature-flag-modal-trigger></feature-flag-modal-trigger>
<div #modal_container></div>
6 changes: 5 additions & 1 deletion tensorboard/webapp/app_container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================*/
import {Component, ViewContainerRef} from '@angular/core';
import {Component, ViewChild, ViewContainerRef} from '@angular/core';

@Component({
selector: 'tb-webapp',
templateUrl: './app_container.ng.html',
styleUrls: ['./app_container.css'],
})
export class AppContainer {
// Container for dynamically created CustomModalComponents.
@ViewChild('modal_container', {read: ViewContainerRef})
readonly modalViewContainerRef!: ViewContainerRef;

// vcRef is required by ngx-color-picker in order for it to place the popup
// in the root node in a modal mode.
// https://github.com/zefoy/ngx-color-picker/blob/94a7c862bb61d7207f21281526fcd94453219b54/projects/lib/src/lib/color-picker.directive.ts#L168-L175
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@
</mat-chip-set>
</div>

<custom-modal #filterModal (onClose)="deselectFilter()">
<tb-data-table-filter
*ngIf="selectedFilter"
[filter]="selectedFilter"
(intervalFilterChanged)="emitIntervalFilterChanged($event)"
(discreteFilterChanged)="emitDiscreteFilterChanged($event)"
(includeUndefinedToggled)="emitIncludeUndefinedToggled()"
></tb-data-table-filter>
</custom-modal>
<ng-template #filterModalTemplate>
<custom-modal (onClose)="deselectFilter()">
<tb-data-table-filter
*ngIf="selectedFilter"
[filter]="selectedFilter"
(intervalFilterChanged)="emitIntervalFilterChanged($event)"
(discreteFilterChanged)="emitDiscreteFilterChanged($event)"
(includeUndefinedToggled)="emitIncludeUndefinedToggled()"
></tb-data-table-filter>
</custom-modal>
</ng-template>
18 changes: 13 additions & 5 deletions tensorboard/webapp/runs/views/runs_table/filterbar_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
EventEmitter,
Component,
ViewChild,
TemplateRef,
AfterViewChecked,
} from '@angular/core';
import {
DiscreteFilter,
Expand All @@ -27,22 +29,22 @@ import {
FilterAddedEvent,
} from '../../../widgets/data_table/types';
import {RangeValues} from '../../../widgets/range_input/types';
import {CustomModalComponent} from '../../../widgets/custom_modal/custom_modal_component';
import {CustomModal} from '../../../widgets/custom_modal/custom_modal';

@Component({
selector: 'filterbar-component',
templateUrl: 'filterbar_component.ng.html',
styleUrls: ['filterbar_component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FilterbarComponent {
export class FilterbarComponent implements AfterViewChecked {
@Input() filters!: Map<string, DiscreteFilter | IntervalFilter>;

@Output() removeHparamFilter = new EventEmitter<string>();
@Output() addFilter = new EventEmitter<FilterAddedEvent>();

@ViewChild('filterModal', {static: false})
private readonly filterModal!: CustomModalComponent;
@ViewChild('filterModalTemplate', {read: TemplateRef})
filterModalTemplate!: TemplateRef<unknown>;

private internalSelectedFilterName = '';
get selectedFilterName(): string {
Expand All @@ -56,12 +58,18 @@ export class FilterbarComponent {
return this.filters.get(this.selectedFilterName);
}

constructor(private readonly customModal: CustomModal) {}

ngAfterViewChecked() {
this.customModal.runChangeDetection();
}

openFilterMenu(event: MouseEvent, filterName: string) {
this.selectedFilterName = filterName;
const rect = (
(event.target as HTMLElement).closest('mat-chip') as HTMLElement
).getBoundingClientRect();
this.filterModal.openAtPosition({
this.customModal.createAtPosition(this.filterModalTemplate, {
Copy link
Contributor

Choose a reason for hiding this comment

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

One relatively minor bug I noticed:

  1. Click on filter chip. Filter opens. (Good.)
  2. Click on filter chip again. Filter closes. (Probably Good.)
  3. Click on filter chip again. Nothing happens. (Probably not Good?)

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh great catch. There was some residual logic pertaining to the old implementation that was hiding the modal on the second click (*ngIf="selectedFilter"). Removing this makes everything work as expected.

x: rect.x + rect.width,
y: rect.y,
});
Expand Down
34 changes: 30 additions & 4 deletions tensorboard/webapp/runs/views/runs_table/filterbar_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@ See the License for the specific language governing permissions and
limitations under the License.
==============================================================================*/
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {NO_ERRORS_SCHEMA} from '@angular/core';
import {
ApplicationRef,
Component,
NO_ERRORS_SCHEMA,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {FilterbarComponent} from './filterbar_component';
import {FilterbarContainer} from './filterbar_container';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
Expand All @@ -39,6 +45,7 @@ import {MatIconTestingModule} from '../../../testing/mat_icon_module';
import {CustomModalComponent} from '../../../widgets/custom_modal/custom_modal_component';
import {FilterDialogModule} from '../../../widgets/data_table/filter_dialog_module';
import {FilterDialog} from '../../../widgets/data_table/filter_dialog_component';
import {CustomModal} from '../../../widgets/custom_modal/custom_modal';

const discreteFilter: DiscreteFilter = {
type: DomainType.DISCRETE,
Expand All @@ -61,6 +68,20 @@ const fakeFilterMap = new Map<string, DiscreteFilter | IntervalFilter>([
['filter2', intervalFilter],
]);

@Component({
selector: 'testable-component',
template: `
<filterbar></filterbar>
<div #modal_container></div>
`,
})
class TestableComponent {
@ViewChild('modal_container', {read: ViewContainerRef})
readonly modalViewContainerRef!: ViewContainerRef;

constructor(readonly customModal: CustomModal) {}
}

describe('hparam_filterbar', () => {
let actualActions: Action[];
let store: MockStore<State>;
Expand All @@ -75,7 +96,7 @@ describe('hparam_filterbar', () => {
MatIconTestingModule,
FilterDialogModule,
],
declarations: [FilterbarComponent, FilterbarContainer],
declarations: [FilterbarComponent, FilterbarContainer, TestableComponent],
providers: [provideMockTbStore()],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
Expand All @@ -85,14 +106,18 @@ describe('hparam_filterbar', () => {
store?.resetSelectors();
});

function createComponent(): ComponentFixture<FilterbarContainer> {
function createComponent(): ComponentFixture<TestableComponent> {
store = TestBed.inject<Store<State>>(Store) as MockStore<State>;
actualActions = [];
dispatchSpy = spyOn(store, 'dispatch').and.callFake((action: Action) => {
actualActions.push(action);
});

return TestBed.createComponent(FilterbarContainer);
const fixture = TestBed.createComponent(TestableComponent);
// Add component to app ref for modal testing.
const appRef = TestBed.inject(ApplicationRef);
appRef.components.push(fixture.componentRef);
return fixture;
}

it('renders hparam filterbar', () => {
Expand Down Expand Up @@ -234,6 +259,7 @@ describe('hparam_filterbar', () => {
const chip = await chipHarness.host();
await chip.click();
fixture.detectChanges();
TestBed.inject(CustomModal).runChangeDetection();
fixture.debugElement
.query(By.directive(FilterDialog))
.componentInstance.discreteFilterChanged.emit(4);
Expand Down
1 change: 1 addition & 0 deletions tensorboard/webapp/widgets/custom_modal/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package(default_visibility = ["//tensorboard:internal"])
tf_ng_module(
name = "custom_modal",
srcs = [
"custom_modal.ts",
"custom_modal_component.ts",
"custom_modal_module.ts",
],
Expand Down
180 changes: 180 additions & 0 deletions tensorboard/webapp/widgets/custom_modal/custom_modal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/* Copyright 2024 The TensorFlow Authors. All Rights Reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================*/
import {
ApplicationRef,
Injectable,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import {CustomModalComponent} from './custom_modal_component';

/**
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice doc!

* Enables dynamic creation of modal components.
*
* # Prerequisites
* App root component must define a ViewContainerRef named `modalViewContainerRef`
* e.g.:
*
* ```
* // Template file
* <div #modal_container></div>
* ```
*
* ```
* // Root Component definition
* class MyAppRoot {
* @ViewChild('modal_container', {read: ViewContainerRef})
* readonly modalViewContainerRef!: ViewContainerRef;
* ...
* }
* ```
*
* # Usage
* Define a modal using an ng-template:
* ```
* <ng-template #myModalTemplate>
* <custom-modal (onOpen)="doSomething" (onClose)="doSomethingElse">
* <my-awesome-modal-content
* [input1]="input1"
* (output1)="output1"
* >
* </my-awesome-modal-content>
* </custom-modal>
* </ng-template>
* ```
*
* Define a ViewChild to reference the template in the component file:
* ```
* // my_component.ts
* ...
* @ViewChild('myModalTemplate', {read: TemplateRef})
* myModalTemplate!: TemplateRef<unknown>;
* ...
* ```
*
* Inject CustomModal into the component
* ```
* // my_component.ts
* ...
* constructor(private readonly customModal: CustomModal) {}
* ...
* ```
*
* To create a modal, call createAtPosition():
* ```
* // my_component.ts
* ...
* onSomeButtonClick() {
* this.customModal.createAtPosition(this.myModalTemplate, {x: 100, y: 100});
* }
* ...
* ```
*
* ## Important note
* runChangeDetection() must be called after view checked to prevent annoying
* https://angular.io/errors/NG0100 errors when using input bindings: this
* will run embedded view change detection in the correct order. Note that
* omitting this will not affect actual modal behavior.
* ```
* // my_component.ts
* ngAfterViewChecked() {
* this.customModal.runChangeDetection();
* }
* ```
*
* # Testing
* A similar setup is required for testing modal behavior from another component.
*
* Define a separate root component to hold the modal container and ref:
* ```
* @Component({
* ...,
* template: `
* <actual-component-to-test></actual-component-to-test>
* <div #modal_container></div>
* `
* })
* class TestableComponent {
* @ViewChild('modal_container', {read: ViewContainerRef})
* readonly modalViewContainerRef!: ViewContainerRef;
*
* constructor(readonly customModal: CustomModal) {}
* ...
* }
* ```
*
* Before each test (e.g. in beforeEach), make sure to add the TestableComponent
* to the app component list:
* ```
* ...
* const fixture = TestBed.createComponent(TestableComponent);
* const appRef = TestBed.inject(ApplicationRef);
* appRef.components.push(fixture.componentRef);
* ```
*/
@Injectable({providedIn: 'root'})
export class CustomModal {
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks good. The problems I was having with the column selector in data table seem to have been solved!

I was wondering, though, could we have instead used Angular Material Overlay? https://material.angular.io/cdk/overlay/overview
Is CustomModal trying to solve the same problem as Overlay?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh wow, didn't know about this! Yes I'd say the Overlay problem scope is basically equivalent to CustomModal's.

I immediately tried this approach out, as it's clearly more preferable to use a mature, featureful library if possible. Unfortunately, the dynamic nature of our clickable affordances doesn't seem to be compatible with how Overlay works.

To elaborate: Overlay usage (ex) seems to require referring to the clickable affordance by template variable (e.g. #trigger), but there is no straightforward way we can implement this for our *ngFor based column headers. Also, it seems that dynamically setting template variables won't be possible for the foreseeable future: angular/angular#33233

Copy link
Contributor

Choose a reason for hiding this comment

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

Overlay also contains a programmatic interface. Does that make it usable?

Here I migrated the filterbar_component to use overlay:
bmd3k@cc7ebdd

I recognize that filterbar is the easiest of the usages of CustomModal but hopefully this points you in a useful direction. I admit that Overlay might still not be the right fit but I think it's worth exploring further.

Some other things to consider:

  • custom_modal.ts could still exist as a service that wraps Overlay with some stuff specific to context menus. Maybe it's actually custom_menu.ts or context_menu.ts?

  • once you've created an overlay for the top level of a menu, is it possible to just reuse that overlay for submenus rather than creating a new overlay for each descendent in the hierarchy?

Copy link
Member Author

Choose a reason for hiding this comment

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

The programmatic approach is nifty, thanks for the example! I like the idea of using the CustomModal service to encapsulate common functionality like attaching the TemplatePortal and adding the backdropClick handler. I'll experiment with this method and re-request review when it's ready.

Copy link
Member Author

Choose a reason for hiding this comment

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

The code has been updated to use Overlay! Note that Overlay features make the CustomModalComponent redundant; we can solely use the CustomModal service now.

Some advantages of Overlay include:

  • Modals can now follow trigger elements when the page is resized
  • Less boilerplate (no extra <custom-modal> required in templates; no extra change detection management needed)
  • Easy opportunities for future customization (e.g. configure modal behavior on scroll)

I've added some convenience logic in CustomModal to make Overlays work well with our nested modals requirements, along with some new tests. PTAL! +cc @rileyajones

P.S.

is it possible to just reuse that overlay for submenus rather than creating a new overlay for each descendent in the hierarchy?

I've found that each overlay must specify its own separate position, which means that each uniquely positioned modal needs its own overlay.

Copy link
Contributor

Choose a reason for hiding this comment

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

Excellent. Thanks for bearing with me. I'm glad it actually went somewhere.

constructor(private appRef: ApplicationRef) {}

/** Gets the ViewContainerRef of the app's root component if it exists. */
private getModalViewContainerRef(): ViewContainerRef | undefined {
const appComponents = this.appRef.components;
if (appComponents.length === 0) {
// appComponents can be empty in tests.
return;
}

const appInstance = appComponents[0].instance;
let viewContainerRef: ViewContainerRef = appInstance.modalViewContainerRef;
if (!viewContainerRef) {
console.warn(
'For proper custom modal function, an ViewContainerRef named `modalViewContainerRef` is required in the root component.'
);
return;
}
return viewContainerRef;
}

/** Creates a modal using the given template at the given position. */
createAtPosition(
templateRef: TemplateRef<unknown>,
position: {x: number; y: number}
): CustomModalComponent | undefined {
const viewContainerRef = this.getModalViewContainerRef();
if (!viewContainerRef) return;

const embeddedViewRef = viewContainerRef.createEmbeddedView(templateRef);
const modalComponent = CustomModalComponent.latestInstance;
modalComponent.parentEmbeddedViewRef = embeddedViewRef;
modalComponent.openAtPosition(position);
return modalComponent;
}

/** Triggers change detection for all modals in the view container. */
runChangeDetection() {
const viewContainerRef = this.getModalViewContainerRef();
if (!viewContainerRef) return;
for (let i = 0; i < viewContainerRef.length; i++) {
viewContainerRef.get(i)?.detectChanges();
}
}

/** Destroys all modals in the view container. */
closeAll() {
const viewContainerRef = this.getModalViewContainerRef();
if (!viewContainerRef) return;
viewContainerRef.clear();
}
}
Loading
Loading