From fe441bdce520f90cc9b33b67af5f44ad0d32e6dd Mon Sep 17 00:00:00 2001 From: Naji <54370141+naaajii@users.noreply.github.com> Date: Mon, 3 Feb 2025 21:29:49 +0500 Subject: [PATCH] feat(material-angular-io): allow module imports to be copied from API tab (#30389) adds icon button next to module import text allowing users to copy it directly fixes #16127 --- .../shared/doc-viewer/doc-viewer-module.ts | 4 ++- .../app/shared/doc-viewer/doc-viewer.spec.ts | 36 +++++++++++++++++++ .../src/app/shared/doc-viewer/doc-viewer.ts | 35 ++++++++++++++++++ .../doc-viewer/module-import-copy-button.ts | 34 ++++++++++++++++++ material.angular.io/src/styles/_api.scss | 9 +++++ .../dgeni/templates/entry-point.template.html | 14 +++++--- 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 material.angular.io/src/app/shared/doc-viewer/module-import-copy-button.ts diff --git a/material.angular.io/src/app/shared/doc-viewer/doc-viewer-module.ts b/material.angular.io/src/app/shared/doc-viewer/doc-viewer-module.ts index cd45e0083123..1a20dc31cd32 100644 --- a/material.angular.io/src/app/shared/doc-viewer/doc-viewer-module.ts +++ b/material.angular.io/src/app/shared/doc-viewer/doc-viewer-module.ts @@ -10,6 +10,7 @@ import {NgModule} from '@angular/core'; import {HeaderLink} from './header-link'; import {CodeSnippet} from '../example-viewer/code-snippet'; import {DeprecatedFieldComponent} from './deprecated-tooltip'; +import {ModuleImportCopyButton} from './module-import-copy-button'; // ExampleViewer is included in the DocViewerModule because they have a circular dependency. @NgModule({ @@ -25,7 +26,8 @@ import {DeprecatedFieldComponent} from './deprecated-tooltip'; HeaderLink, CodeSnippet, DeprecatedFieldComponent, + ModuleImportCopyButton, ], - exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent], + exports: [DocViewer, ExampleViewer, HeaderLink, DeprecatedFieldComponent, ModuleImportCopyButton], }) export class DocViewerModule {} diff --git a/material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts b/material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts index e8649b45bb29..f550cff39e48 100644 --- a/material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts +++ b/material.angular.io/src/app/shared/doc-viewer/doc-viewer.spec.ts @@ -7,13 +7,17 @@ import {DocViewer} from './doc-viewer'; import {DocViewerModule} from './doc-viewer-module'; import {ExampleViewer} from '../example-viewer/example-viewer'; import {MatTooltip} from '@angular/material/tooltip'; +import {MatIconButton} from '@angular/material/button'; +import {Clipboard} from '@angular/cdk/clipboard'; describe('DocViewer', () => { let http: HttpTestingController; + const clipboardSpy = jasmine.createSpyObj('Clipboard', ['copy']); beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [DocViewerModule, DocsAppTestingModule, DocViewerTestComponent], + providers: [{provide: Clipboard, useValue: clipboardSpy}], }).compileComponents(); })); @@ -179,6 +183,27 @@ describe('DocViewer', () => { expect(deprecatedSymbol.query(By.directive(MatTooltip))).toBeTruthy(); }); + it('should show copy icon button for module imports', () => { + const fixture = TestBed.createComponent(DocViewerTestComponent); + fixture.componentInstance.documentUrl = `http://material.angular.io/copy-module-import.html`; + fixture.detectChanges(); + + const url = fixture.componentInstance.documentUrl; + http.expectOne(url).flush(FAKE_DOCS[url]); + + const docViewer = fixture.debugElement.query(By.directive(DocViewer)); + expect(docViewer).not.toBeNull(); + + const iconButton = fixture.debugElement.query(By.directive(MatIconButton)); + // icon button for copying module import should exist + expect(iconButton).toBeTruthy(); + + // click on icon button to trigger copying the module import + iconButton.nativeNode.dispatchEvent(new MouseEvent('click')); + fixture.detectChanges(); + expect(clipboardSpy.copy).toHaveBeenCalled(); + }); + // TODO(mmalerba): Add test that example-viewer is instantiated. }); @@ -221,6 +246,17 @@ const FAKE_DOCS: {[key: string]: string} = {
Deprecated
`, + 'http://material.angular.io/copy-module-import.html': `
+

+ + import {MatIconModule} from '@angular/material/icon'; + +

+ +
+
+
`, /* eslint-enable @typescript-eslint/naming-convention */ }; diff --git a/material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts b/material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts index 25ff18066eaf..64fc0d19a6e5 100644 --- a/material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts +++ b/material.angular.io/src/app/shared/doc-viewer/doc-viewer.ts @@ -28,6 +28,7 @@ import {shareReplay, take, tap} from 'rxjs/operators'; import {ExampleViewer} from '../example-viewer/example-viewer'; import {HeaderLink} from './header-link'; import {DeprecatedFieldComponent} from './deprecated-tooltip'; +import {ModuleImportCopyButton} from './module-import-copy-button'; @Injectable({providedIn: 'root'}) class DocFetcher { @@ -150,6 +151,9 @@ export class DocViewer implements OnDestroy { // Create tooltips for the deprecated fields this._createTooltipsForDeprecated(); + // Create icon buttons to copy module import + this._createCopyIconForModule(); + // Resolving and creating components dynamically in Angular happens synchronously, but since // we want to emit the output if the components are actually rendered completely, we wait // until the Angular zone becomes stable. @@ -235,4 +239,35 @@ export class DocViewer implements OnDestroy { this._portalHosts.push(elementPortalOutlet); }); } + + _createCopyIconForModule() { + // every module import element will be marked with docs-api-module-import-button attribute + const moduleImportElements = this._elementRef.nativeElement.querySelectorAll( + '[data-docs-api-module-import-button]', + ); + + [...moduleImportElements].forEach((element: HTMLElement) => { + // get the module import path stored in the attribute + const moduleImport = element.getAttribute('data-docs-api-module-import-button'); + + const elementPortalOutlet = new DomPortalOutlet( + element, + this._componentFactoryResolver, + this._appRef, + this._injector, + ); + + const moduleImportPortal = new ComponentPortal( + ModuleImportCopyButton, + this._viewContainerRef, + ); + const moduleImportOutlet = elementPortalOutlet.attach(moduleImportPortal); + + if (moduleImport) { + moduleImportOutlet.instance.import = moduleImport; + } + + this._portalHosts.push(elementPortalOutlet); + }); + } } diff --git a/material.angular.io/src/app/shared/doc-viewer/module-import-copy-button.ts b/material.angular.io/src/app/shared/doc-viewer/module-import-copy-button.ts new file mode 100644 index 000000000000..282858cfe097 --- /dev/null +++ b/material.angular.io/src/app/shared/doc-viewer/module-import-copy-button.ts @@ -0,0 +1,34 @@ +import {Component, inject} from '@angular/core'; +import {MatIconButton} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {MatTooltip} from '@angular/material/tooltip'; +import {Clipboard} from '@angular/cdk/clipboard'; + +/** + * Shows up an icon button which will allow users to copy the module import + */ +@Component({ + selector: 'module-import-copy-button', + template: ``, + standalone: true, + imports: [MatIconButton, MatIcon, MatTooltip], +}) +export class ModuleImportCopyButton { + private clipboard = inject(Clipboard); + private snackbar = inject(MatSnackBar); + /** Import path for the module that will be copied */ + import = ''; + + copy(): void { + const message = this.clipboard.copy(this.import) + ? 'Copied module import' + : 'Failed to copy module import'; + this.snackbar.open(message, undefined, {duration: 2500}); + } +} diff --git a/material.angular.io/src/styles/_api.scss b/material.angular.io/src/styles/_api.scss index a74bbf65c46e..3f738112707f 100644 --- a/material.angular.io/src/styles/_api.scss +++ b/material.angular.io/src/styles/_api.scss @@ -27,6 +27,15 @@ word-break: break-word; } +.docs-api-module { + display: flex; + align-items: center; +} + +.docs-api-module-import { + margin: 0px; +} + .docs-api-class-name, .docs-api-module-import, .docs-api-class-selector-name, diff --git a/tools/dgeni/templates/entry-point.template.html b/tools/dgeni/templates/entry-point.template.html index ca10491d4a3c..6b70a5ae16d0 100644 --- a/tools/dgeni/templates/entry-point.template.html +++ b/tools/dgeni/templates/entry-point.template.html @@ -37,11 +37,15 @@

{%- if doc.primaryExportName -%} -

- - import {{$ doc.primaryExportName $}} from '{$ doc.moduleImportPath $}'; - -

+
+

+ + import {{$ doc.primaryExportName $}} from '{$ doc.moduleImportPath $}'; + +

+ +
+
{% else %}

Import symbols from {$ doc.moduleImportPath $}

{%- endif -%}