diff --git a/packages/@o3r/components/collection.json b/packages/@o3r/components/collection.json index 2d75383400..6df0c4e85b 100644 --- a/packages/@o3r/components/collection.json +++ b/packages/@o3r/components/collection.json @@ -6,11 +6,6 @@ "factory": "./schematics/ng-add/index#ngAdd", "schema": "./schematics/ng-add/schema.json", "aliases": ["install", "i"] - }, - "iframe-component": { - "description": "Generate an iframe component for third party script integration", - "factory": "./schematics/iframe/index#ngGenerateIframeComponent", - "schema": "./schematics/iframe/schema.json" } } } diff --git a/packages/@o3r/components/schematics/iframe/index.spec.ts b/packages/@o3r/components/schematics/iframe/index.spec.ts deleted file mode 100644 index 3b5aac8665..0000000000 --- a/packages/@o3r/components/schematics/iframe/index.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { strings } from '@angular-devkit/core'; -import { Tree } from '@angular-devkit/schematics'; -import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; -import { getComponentSelectorWithoutSuffix, TYPES_DEFAULT_FOLDER } from '@o3r/schematics'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -const collectionPath = path.join(__dirname, '..', '..', 'collection.json'); - -/** - * @param componentName - * @param fileName - * @param componentStructure - */ -function getGeneratedComponentPath(componentName: string, fileName: string) { - return `/${TYPES_DEFAULT_FOLDER['@o3r/core:component'].app}/${strings.dasherize(componentName)}/${fileName}`; -} - -describe('Iframe component', () => { - - let initialTree: Tree; - - const componentName = 'iframeComponent'; - const expectedFileNames = [ - 'iframe-component.component.ts', - 'iframe-component.config.ts', - 'iframe-component.module.ts', - 'iframe-component.spec.ts', - 'iframe-component.template.html', - 'README.md', - 'index.ts' - ]; - - beforeEach(() => { - initialTree = Tree.empty(); - initialTree.create('angular.json', fs.readFileSync(path.resolve(__dirname, '..', '..', 'testing', 'mocks', 'angular.mocks.json'))); - initialTree.create('package.json', fs.readFileSync(path.resolve(__dirname, '..', '..', 'testing', 'mocks', 'package.mocks.json'))); - initialTree.create('.eslintrc.json', fs.readFileSync(path.resolve(__dirname, '..', '..', 'testing', 'mocks', '__dot__eslintrc.mocks.json'))); - }); - - it('should generate an iframe component in the default components folder', async () => { - const runner = new SchematicTestRunner('schematics', collectionPath); - const tree = await runner.runSchematic('iframe-component', { - projectName: 'test-project', - componentName, - prefix: 'o3r', - path: 'src/components' - }, initialTree); - - expect(tree.files.filter((file) => /iframe-component/.test(file)).length).toEqual(expectedFileNames.length); - expect(tree.files.filter((file) => /iframe-component/.test(file))).toEqual(expect.arrayContaining( - expectedFileNames.map((fileName) => getGeneratedComponentPath(componentName, fileName))) - ); - }); - - it('should generate a standalone iframe component in the default components folder', async () => { - const runner = new SchematicTestRunner('schematics', collectionPath); - const tree = await runner.runSchematic('iframe-component', { - projectName: 'test-project', - componentName, - prefix: 'o3r', - path: 'src/components', - standalone: true - }, initialTree); - - expect(tree.files.filter((file) => /iframe-component/.test(file)).length).toEqual(expectedFileNames.length - 1); - expect(tree.files.find((file) => /iframe-component\.module\.ts/.test(file))).toBeFalsy(); - expect(tree.readContent(tree.files.find((file) => /iframe-component\.component\.ts/.test(file)))).toContain('standalone: true'); - }); - - it('should generate an iframe component with the selector prefixed with o3r by default', async () => { - const runner = new SchematicTestRunner('schematics', collectionPath); - const tree = await runner.runSchematic('iframe-component', { - projectName: 'test-project', - componentName, - prefix: 'o3r', - path: 'src/components' - }, initialTree); - - expect(tree.readContent(getGeneratedComponentPath(componentName, 'iframe-component.component.ts'))) - .toContain(`selector: '${getComponentSelectorWithoutSuffix(componentName, 'o3r')}'`); - }); - - it('should generate an iframe component without otter configuration', async () => { - const runner = new SchematicTestRunner('schematics', collectionPath); - const tree = await runner.runSchematic('iframe-component', { - projectName: 'test-project', - componentName, - prefix: 'o3r', - useOtterConfig: false, - path: 'src/components' - }, initialTree); - - const expectedFileNamesWithoutConfig = expectedFileNames.filter((fileName) => fileName !== 'iframe-component.config.ts'); - - expect(tree.files.filter((file) => /iframe-component/.test(file)).length).toEqual(expectedFileNamesWithoutConfig.length); - expect(tree.files.filter((file) => /iframe-component/.test(file))).toEqual(expect.arrayContaining( - expectedFileNamesWithoutConfig.map((fileName) => getGeneratedComponentPath(componentName, fileName))) - ); - }); - -}); diff --git a/packages/@o3r/components/schematics/iframe/index.ts b/packages/@o3r/components/schematics/iframe/index.ts deleted file mode 100644 index eefc065aab..0000000000 --- a/packages/@o3r/components/schematics/iframe/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { apply, chain, MergeStrategy, mergeWith, move, noop, renameTemplateFiles, Rule, SchematicContext, template, Tree, url } from '@angular-devkit/schematics'; - -import { - applyEsLintFix, - getComponentConfigKey, - getComponentConfigName, - getComponentFileName, - getComponentFolderName, - getComponentModuleName, - getComponentName, - getComponentSelectorWithoutSuffix, - getDestinationPath, - getInputComponentName, - getProjectFromTree -} from '@o3r/schematics'; -import * as path from 'node:path'; -import { NgGenerateIframeComponentSchematicsSchema } from './schema'; - -const IFRAME_TEMPLATE_PATH = './templates/iframe'; -const MODULE_TEMPLATE_PATH = './templates/module'; -const CONFIG_TEMPLATE_PATH = './templates/config'; - -/** - * Generates the template properties - * - * @param options - * @param prefix - */ -const getTemplateProperties = (options: NgGenerateIframeComponentSchematicsSchema, prefix?: string) => { - - const inputComponentName = getInputComponentName(options.componentName); - const folderName = getComponentFolderName(inputComponentName); - - return { - ...options, - componentType: options.useOtterConfig ? 'ExposedComponent' : 'Component', - projectName: options.projectName || undefined, - moduleName: getComponentModuleName(inputComponentName, ''), - componentName: getComponentName(inputComponentName, ''), - componentConfig: getComponentConfigName(inputComponentName, ''), - componentSelector: getComponentSelectorWithoutSuffix(options.componentName, prefix || null), - folderName, - name: getComponentFileName(options.componentName, ''), - configKey: getComponentConfigKey(options.componentName, ''), - description: options.description || '' - }; -}; - -/** - * Generate an ifrmare component for third party script integration - * - * @param options - */ -export function ngGenerateIframeComponent(options: NgGenerateIframeComponentSchematicsSchema): Rule { - - const generateFiles: Rule = (tree: Tree, context: SchematicContext) => { - - const workspaceProject = getProjectFromTree(tree); - - const properties = getTemplateProperties(options, options.prefix ? options.prefix : workspaceProject?.prefix); - - const destination = getDestinationPath('@o3r/core:component', options.path, tree); - const componentDestination = path.join(destination, properties.folderName); - - const rules: Rule[] = []; - - rules.push(mergeWith(apply(url(IFRAME_TEMPLATE_PATH), [ - template({ - ...properties - }), - renameTemplateFiles(), - move(componentDestination) - ]), MergeStrategy.Overwrite)); - - if (!options.standalone) { - rules.push(mergeWith(apply(url(MODULE_TEMPLATE_PATH), [ - template({ - ...properties - }), - renameTemplateFiles(), - move(componentDestination) - ]), MergeStrategy.Overwrite)); - } - - if (options.useOtterConfig) { - rules.push(mergeWith(apply(url(CONFIG_TEMPLATE_PATH), [ - template({ - ...properties - }), - renameTemplateFiles(), - move(componentDestination) - ]), MergeStrategy.Overwrite)); - } - - return chain(rules)(tree, context); - }; - - return chain([ - generateFiles, - options.skipLinter ? noop() : applyEsLintFix() - ]); -} diff --git a/packages/@o3r/components/schematics/iframe/schema.json b/packages/@o3r/components/schematics/iframe/schema.json deleted file mode 100644 index eab557306e..0000000000 --- a/packages/@o3r/components/schematics/iframe/schema.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "NgGenerateIframeComponentSchematicsSchema", - "title": "Generate Otter Iframe Component", - "description": "ng generate Otter Iframe Component", - "properties": { - "projectName": { - "type": "string", - "description": "Project name", - "$default": { - "$source": "projectName" - } - }, - "componentName": { - "type": "string", - "description": "Component name", - "x-prompt": "Your component name?", - "minLength": 1, - "$default": { - "$source": "argv", - "index": 0 - } - }, - "path": { - "type": "string", - "description": "Directory containing the components" - }, - "prefix": { - "type": "string", - "description": "Prefix of your component selector" - }, - "description": { - "type": "string", - "description": "Component description", - "x-prompt": "Your container description?" - }, - "useOtterConfig": { - "type": "boolean", - "description": "Generate component with Otter configuration", - "default": true, - "x-prompt": "Generate component with Otter configuration?" - }, - "skipLinter": { - "type": "boolean", - "description": "Skip the linter process", - "default": false - }, - "standalone": { - "type": "boolean", - "description": "Whether the generated component is standalone.", - "default": false - } - }, - "additionalProperties": true, - "required": [ - "componentName" - ] -} diff --git a/packages/@o3r/components/schematics/iframe/schema.ts b/packages/@o3r/components/schematics/iframe/schema.ts deleted file mode 100644 index 40f262231f..0000000000 --- a/packages/@o3r/components/schematics/iframe/schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type {JsonObject} from '@angular-devkit/core'; - -export interface NgGenerateIframeComponentSchematicsSchema extends JsonObject { - /** Project name */ - projectName: string | null; - - /** name of the component to generate */ - componentName: string; - - /** Selector prefix */ - prefix: string | null; - - /** Description of the component generated */ - description: string | null; - - /** Component Folder */ - path: string | null; - - /** Use otter configuration */ - useOtterConfig: boolean; - - /** Skip the linter process */ - skipLinter: boolean; - - /** Whether the generated component is standalone */ - standalone: boolean; -} diff --git a/packages/@o3r/components/schematics/iframe/templates/config/__name__.config.ts.template b/packages/@o3r/components/schematics/iframe/templates/config/__name__.config.ts.template deleted file mode 100644 index 67e1eee630..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/config/__name__.config.ts.template +++ /dev/null @@ -1,8 +0,0 @@ -import {Configuration} from '@o3r/core'; -import {computeConfigurationName} from '@o3r/configuration'; - -export interface <%= componentConfig %> extends Configuration {} - -export const <%=configKey%>_DEFAULT_CONFIG: <%=componentConfig%> = {}; - -export const <%=configKey%>_CONFIG_ID = computeConfigurationName('<%= componentSelector %>', '<%=projectName%>'); diff --git a/packages/@o3r/components/schematics/iframe/templates/iframe/README.md b/packages/@o3r/components/schematics/iframe/templates/iframe/README.md deleted file mode 100644 index a486893df1..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/iframe/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# <%= componentName %> - -<%= description %> diff --git a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.component.ts.template b/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.component.ts.template deleted file mode 100644 index d5a4e53d3d..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.component.ts.template +++ /dev/null @@ -1,85 +0,0 @@ -import {AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, <% if (useOtterConfig) { %>Input, <% } %>OnDestroy, OnChanges, OnInit, <% if (useOtterConfig) { %>Optional, <% } %>SimpleChanges, ViewChild, ViewEncapsulation} from '@angular/core'; -import {O3rComponent} from '@o3r/core'; -<% if (useOtterConfig) { %>import {ConfigObserver, ConfigurationBaseService, ConfigurationObserver, DynamicConfigurable} from '@o3r/configuration'; -<% } %>import {<% if (useOtterConfig) { %>Observable, <% } %>Subscription} from 'rxjs'; -import {generateIFrameContent, IframeBridge} from '@o3r/third-party';<% if (useOtterConfig) { %> -import {<%=configKey%>_DEFAULT_CONFIG, <%=configKey%>_CONFIG_ID, <%= componentConfig %>} from './<%= name %>.config';<% } %> - -@O3rComponent({ - componentType: <%= componentType %> -}) -@Component({ - selector: '<%= componentSelector %>',<% if (standalone) { %> - standalone: true, - imports: [CommonModule],<% } %> - templateUrl: './<%= name %>.template.html', - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None -}) -export class <%= componentName %> implements - OnInit, - OnChanges, - OnDestroy, - AfterViewInit<% if (useOtterConfig) { %>, - DynamicConfigurable<<%=componentConfig%>><% } %> { - - <% if (useOtterConfig) { %>/** Input configuration to override the default configuration of the component */ - @Input() - public config: Partial<<%=componentConfig%>> | undefined; - - /** Dynamic configuration based on the input override configuration and the configuration service if used by the application */ - @ConfigObserver() - private dynamicConfig$: ConfigurationObserver<<%=componentConfig%>>; - - /** Configuration stream based on the input and the stored configuration */ - public config$: Observable<<%=componentConfig%>>; - - <% } %>// Iframe object template reference - @ViewChild('frame') private frame: ElementRef; - - // List of subscriptions to unsubscribe on destroy - private subscriptions: Subscription[] = []; - - // object that exposes an easy abstraction layer to communicate between a Host and an IFrame - private bridge: IframeBridge; - - constructor(<% if (useOtterConfig) { %>@Optional() configurationService?: ConfigurationBaseService<% } %>) {<% if (useOtterConfig) { %> - this.dynamicConfig$ = new ConfigurationObserver<<%=componentConfig%>>(<%= configKey %>_CONFIG_ID, <%= configKey %>_DEFAULT_CONFIG, configurationService); - this.config$ = this.dynamicConfig$.asObservable();<% } %> - } - - public ngOnInit() { - // Run on component initialization - } - - public ngAfterViewInit() { - if (this.frame.nativeElement.contentDocument) { - this.frame.nativeElement.contentDocument.write(generateIFrameContent('' /* third-party-script-url */, '' /* third-party-html-headers-to-add */)); - this.frame.nativeElement.contentDocument.close(); - } - if (this.frame.nativeElement.contentWindow) { - this.bridge = new IframeBridge(window, this.frame.nativeElement); - this.subscriptions.push( - this.bridge.messages$.subscribe((message) => { - switch (message.action) { - /* custom logic based on received message */ - default: - console.warn('Received unsupported action: ', message.action); - } - }) - ); - } - } - - public ngOnChanges(change: SimpleChanges) { - // Run on every change of inputs<% if (useOtterConfig) { %> - if (change.config) { - this.dynamicConfig$.next(this.config); - }<% } %> - } - - public ngOnDestroy() { - // clean the subscriptions - this.subscriptions.forEach((subscription) => subscription.unsubscribe()); - } -} diff --git a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.spec.ts.template b/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.spec.ts.template deleted file mode 100644 index 69f4c399fe..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.spec.ts.template +++ /dev/null @@ -1,27 +0,0 @@ -import {ComponentFixture, TestBed} from '@angular/core/testing'; -<% if (!standalone) { %>import {BrowserModule} from '@angular/platform-browser'; -<% } %>import {<%= componentName %>} from './<%= name %>.component'; - - -describe('<%= componentName %>', () => { - let component: <%= componentName %>; - let fixture: ComponentFixture<<%= componentName %>>; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - <% if (standalone) { %>imports: [<%= componentName %>] - <% } else { %>declarations: [<%= componentName %>], - imports: [BrowserModule]<% } %> - }).compileComponents(); - }); - - beforeEach(() => { - fixture = TestBed.createComponent(<%= componentName %>); - component = fixture.componentInstance; - }); - - it('should define objects', () => { - fixture.detectChanges(); - expect(component).toBeDefined(); - }); -}); diff --git a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.template.html b/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.template.html deleted file mode 100644 index 5e1a600912..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/iframe/__name__.template.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/@o3r/components/schematics/iframe/templates/iframe/index.ts b/packages/@o3r/components/schematics/iframe/templates/iframe/index.ts deleted file mode 100644 index e5c6c7f7aa..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/iframe/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './<%= name %>.component'; -<% if (useOtterConfig) { %>export * from './<%= name %>.config'; -<% } %><% if (!standalone) { %>export * from './<%= name %>.module'; -<% } %> diff --git a/packages/@o3r/components/schematics/iframe/templates/module/__name__.module.ts.template b/packages/@o3r/components/schematics/iframe/templates/module/__name__.module.ts.template deleted file mode 100644 index dda6130ed3..0000000000 --- a/packages/@o3r/components/schematics/iframe/templates/module/__name__.module.ts.template +++ /dev/null @@ -1,11 +0,0 @@ -import {CommonModule} from '@angular/common'; -import {NgModule} from '@angular/core'; -import {<%= componentName %>} from './<%= name %>.component'; - -@NgModule({ - imports: [CommonModule], - declarations: [<%= componentName %>], - exports: [<%= componentName %>], - providers: [] -}) -export class <%= moduleName %> {} diff --git a/packages/@o3r/third-party/.eslintrc.js b/packages/@o3r/third-party/.eslintrc.js index a0246848e3..f5ca5f3878 100644 --- a/packages/@o3r/third-party/.eslintrc.js +++ b/packages/@o3r/third-party/.eslintrc.js @@ -7,6 +7,7 @@ module.exports = { 'tsconfigRootDir': __dirname, 'project': [ 'tsconfig.build.json', + 'tsconfig.builders.json', 'tsconfig.spec.json', 'tsconfig.eslint.json' ], diff --git a/packages/@o3r/third-party/README.md b/packages/@o3r/third-party/README.md index e45b0e899f..9cfd46aaec 100644 --- a/packages/@o3r/third-party/README.md +++ b/packages/@o3r/third-party/README.md @@ -18,3 +18,12 @@ ng add @o3r/third-party ``` > **Warning**: this module requires [@o3r/core](https://www.npmjs.com/package/@o3r/core) to be installed. + +## Generators + +Otter framework provides a set of code generators based on [angular schematics](https://angular.io/guide/schematics). + +| Schematics | Description | How to use | +| --------------------- | ------------------------------------------------------------ | --------------------------- | +| add | Include Otter third party module in a library / application. | `ng add @o3r/third-party` | +| iframe-to-component | Add iframe to an Otter component | `ng g iframe-to-component` | diff --git a/packages/@o3r/third-party/collection.json b/packages/@o3r/third-party/collection.json index 29b5da199c..0498929d37 100644 --- a/packages/@o3r/third-party/collection.json +++ b/packages/@o3r/third-party/collection.json @@ -6,6 +6,12 @@ "factory": "./schematics/ng-add/index#ngAdd", "schema": "./schematics/ng-add/schema.json", "aliases": ["install", "i"] + }, + "iframe-to-component": { + "description": "Add iframe to an existing component", + "factory": "./schematics/iframe-to-component/index#ngAddIframe", + "schema": "./schematics/iframe-to-component/schema.json", + "aliases": ["add-iframe", "iframe"] } } } diff --git a/packages/@o3r/third-party/package.json b/packages/@o3r/third-party/package.json index ad373933ae..70b51f8bca 100644 --- a/packages/@o3r/third-party/package.json +++ b/packages/@o3r/third-party/package.json @@ -20,6 +20,18 @@ "tslib": "^2.5.3", "uuid": "^9.0.0" }, + "peerDependencies": { + "@angular-devkit/schematics": "~16.0.5", + "@o3r/schematics": "workspace:^" + }, + "peerDependenciesMeta": { + "@angular-devkit/schematics": { + "optional": true + }, + "@o3r/schematics": { + "optional": true + } + }, "devDependencies": { "@angular-devkit/build-angular": "~16.0.5", "@angular-devkit/core": "~16.0.5", @@ -40,6 +52,7 @@ "@o3r/dev-tools": "workspace:^", "@o3r/eslint-config-otter": "workspace:^", "@o3r/eslint-plugin": "workspace:^", + "@o3r/schematics": "workspace:^", "@types/jest": "~29.5.2", "@types/node": "^18.0.0", "@types/uuid": "^9.0.0", diff --git a/packages/@o3r/third-party/schematics/iframe-to-component/index.spec.ts b/packages/@o3r/third-party/schematics/iframe-to-component/index.spec.ts new file mode 100644 index 0000000000..f898400753 --- /dev/null +++ b/packages/@o3r/third-party/schematics/iframe-to-component/index.spec.ts @@ -0,0 +1,108 @@ +import { Tree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; + +const collectionPath = path.join(__dirname, '..', '..', 'collection.json'); +const o3rComponentPath = '/src/components/test/test.component.ts'; +const templatePath = '/src/components/test/test.template.html'; +const ngComponentPath = '/src/components/ng/ng.component.ts'; + +describe('Add Iframe', () => { + let initialTree: Tree; + + describe('Otter standalone component', () => { + beforeEach(() => { + initialTree = Tree.empty(); + initialTree.create(o3rComponentPath, ` + import {CommonModule} from '@angular/common'; + import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; + import {O3rComponent} from '@o3r/core'; + + @O3rComponent({ + componentType: 'Component' + }) + @Component({ + selector: 'o3r-test-pres', + standalone: true, + imports: [CommonModule], + styleUrls: ['./test.style.scss'], + templateUrl: './test.template.html', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None + }) + export class TestComponent {} + `); + initialTree.create(templatePath, '
My HTML content
'); + initialTree.create('.eslintrc.json', fs.readFileSync(path.resolve(__dirname, '..', '..', 'testing', 'mocks', '__dot__eslintrc.mocks.json'))); + }); + + it('should update the component and the template', async () => { + const runner = new SchematicTestRunner('schematics', collectionPath); + const tree = await runner.runSchematic('iframe-to-component', { + projectName: 'test-project', + path: o3rComponentPath + }, initialTree); + + expect(tree.exists(templatePath)).toBeTruthy(); + expect(tree.readText(templatePath)).toContain(''); + + const componentFileContent = tree.readText(o3rComponentPath); + expect(componentFileContent).toContain('AfterViewInit'); + expect(componentFileContent).toContain('OnDestroy'); + expect(componentFileContent).toContain('private bridge: IframeBridge;'); + expect(componentFileContent).toContain('@ViewChild(\'frame\')'); + expect(componentFileContent).toContain('private frame: ElementRef;'); + expect(componentFileContent).toContain('this.bridge = new IframeBridge(window, this.frame.nativeElement);'); + }); + + it('should throw if we add iframe to a component that already has it', async () => { + const runner = new SchematicTestRunner('schematics', collectionPath); + const tree = await runner.runSchematic('iframe-to-component', { + projectName: 'test-project', + path: o3rComponentPath + }, initialTree); + await expect(runner.runSchematic('iframe-to-component', { + projectName: 'test-project', + path: o3rComponentPath + }, tree)).rejects.toThrow(); + }); + }); + + describe('Angular component', () => { + beforeEach(() => { + initialTree = Tree.empty(); + initialTree.create(ngComponentPath, ` + import {CommonModule} from '@angular/common'; + import {Component} from '@angular/core'; + + @Component({ + selector: 'ng-test', + standalone: true, + imports: [CommonModule], + template: '' + }) + export class NgComponent {} + `); + initialTree.create('.eslintrc.json', fs.readFileSync(path.resolve(__dirname, '..', '..', 'testing', 'mocks', '__dot__eslintrc.mocks.json'))); + }); + + it('should throw if no Otter component', async () => { + const runner = new SchematicTestRunner('schematics', collectionPath); + + await expect(runner.runSchematic('iframe-to-component', { + projectName: 'test-project', + path: ngComponentPath + }, initialTree)).rejects.toThrow(); + }); + + it('should throw if inexisting path', async () => { + const runner = new SchematicTestRunner('schematics', collectionPath); + + await expect(runner.runSchematic('iframe-to-component', { + projectName: 'test-project', + path: 'inexisting-path.component.ts' + }, initialTree)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/@o3r/third-party/schematics/iframe-to-component/index.ts b/packages/@o3r/third-party/schematics/iframe-to-component/index.ts new file mode 100644 index 0000000000..b3875024d7 --- /dev/null +++ b/packages/@o3r/third-party/schematics/iframe-to-component/index.ts @@ -0,0 +1,223 @@ +import { + chain, + noop, + Rule, + SchematicContext, + Tree +} from '@angular-devkit/schematics'; +import { + addCommentsOnClassProperties, + addImportsRule, + applyEsLintFix, + findMethodByName, + fixStringLiterals, + generateBlockStatementsFromString, + generateClassElementsFromString, + generateImplementsExpressionWithTypeArguments, + getO3rComponentInfoOrThrowIfNotFound, + getSimpleUpdatedMethod, + sortClassElement +} from '@o3r/schematics'; +import { dirname, posix } from 'node:path'; +import * as ts from 'typescript'; +import type { NgAddIframeSchematicsSchema } from './schema'; + +const iframeProperties = [ + 'frame', + 'bridge' +]; + +const checkIframePresence = (componentPath: string, tree: Tree) => { + const sourceFile = ts.createSourceFile( + componentPath, + tree.readText(componentPath), + ts.ScriptTarget.ES2020, + true + ); + const classStatement = sourceFile.statements.find(ts.isClassDeclaration); + if ( + classStatement?.members.find((classElement) => + ts.isPropertyDeclaration(classElement) + && ts.isIdentifier(classElement.name) + && iframeProperties.includes(classElement.name.escapedText.toString()) + ) + ) { + throw new Error(`Unable to add iframe to this component because it already has at least one of these properties: ${iframeProperties.join(', ')}.`); + } +}; + +/** + * Add iframe to an existing component + * + * @param options + */ +export function ngAddIframe(options: NgAddIframeSchematicsSchema): Rule { + return (tree: Tree, context: SchematicContext) => { + const { templateRelativePath } = getO3rComponentInfoOrThrowIfNotFound(tree, options.path); + + checkIframePresence(options.path, tree); + + const updateComponentFile: Rule = chain([ + addImportsRule(options.path, [ + { + from: '@angular/core', + importNames: [ + 'AfterViewInit', + 'OnDestroy' + ] + }, + { + from: '@o3r/third-party', + importNames: [ + 'generateIFrameContent', + 'IframeBridge' + ] + }, + { + from: 'rxjs', + importNames: [ + 'Subscription' + ] + } + ]), + () => { + + const sourceFile = ts.createSourceFile( + options.path, + tree.readText(options.path), + ts.ScriptTarget.ES2020, + true + ); + const result = ts.transform(sourceFile, [ + (ctx) => (rootNode: ts.Node) => { + const { factory } = ctx; + const visit = (node: ts.Node): ts.Node => { + if (ts.isClassDeclaration(node)) { + const implementsClauses = node.heritageClauses?.find((heritageClause) => heritageClause.token === ts.SyntaxKind.ImplementsKeyword); + const interfaceToImplements = generateImplementsExpressionWithTypeArguments('OnDestroy, AfterViewInit'); + + const deduplicateHeritageClauses = (clauses: any[]) => + clauses.filter((h, i) => + !clauses.slice(i + 1).some((h2) => h2.kind === h.kind && h2.expression.escapedText === h.expression.escapedText) + ); + + const newImplementsClauses = implementsClauses + ? factory.updateHeritageClause(implementsClauses, deduplicateHeritageClauses([...implementsClauses.types, ...interfaceToImplements])) + : factory.createHeritageClause(ts.SyntaxKind.ImplementsKeyword, [...interfaceToImplements]); + + const heritageClauses: ts.HeritageClause[] = Array.from(node.heritageClauses ?? []) + .filter((h) => h.token !== ts.SyntaxKind.ImplementsKeyword) + .concat(newImplementsClauses); + + const newModifiers = ([] as ts.ModifierLike[]) + .concat(ts.getDecorators(node) || []) + .concat(ts.getModifiers(node) || []); + + const hasSubscriptions = node.members.find((classElement) => + ts.isPropertyDeclaration(classElement) + && ts.isIdentifier(classElement.name) + && classElement.name.escapedText.toString() === 'subscriptions' + ); + + /* eslint-disable indent */ + const propertiesToAdd = generateClassElementsFromString(` + @ViewChild('frame') private frame: ElementRef; + private bridge: IframeBridge; + ${!hasSubscriptions ? 'private subscriptions: Subscription[] = [];' : ''} + `); + /* eslint-disable indent */ + + const newNgAfterViewInit = getSimpleUpdatedMethod(node, factory, 'ngAfterViewInit', generateBlockStatementsFromString(` + if (this.frame.nativeElement.contentDocument) { + this.frame.nativeElement.contentDocument.write( + generateIFrameContent( + '', // third-party-script-url + '' // third-party-html-headers-to-add + ) + ); + this.frame.nativeElement.contentDocument.close(); + } + if (this.frame.nativeElement.contentWindow) { + this.bridge = new IframeBridge(window, this.frame.nativeElement); + this.subscriptions.push( + this.bridge.messages$.subscribe((message) => { + switch (message.action) { + // custom logic based on received message + default: + console.warn('Received unsupported action: ', message.action); + } + }) + ); + } + `)); + + const newNgOnDestroy = getSimpleUpdatedMethod(node, factory, 'ngOnDestroy', generateBlockStatementsFromString(` + this.subscriptions.forEach((subscription) => subscription.unsubscribe()); + `)); + + const newMembers = node.members + .filter((classElement) => !( + findMethodByName('ngAfterViewInit')(classElement) + || (!hasSubscriptions && findMethodByName('ngOnDestroy')(classElement)) + )) + .concat( + propertiesToAdd, + newNgAfterViewInit, + ...(hasSubscriptions ? [] : [newNgOnDestroy]) + ) + .sort(sortClassElement); + + addCommentsOnClassProperties( + newMembers, + { + bridge: 'Iframe object template reference', + subscriptions: 'List of subscriptions to unsubscribe on destroy' + } + ); + + return factory.updateClassDeclaration( + node, + newModifiers, + node.name, + node.typeParameters, + heritageClauses, + newMembers + ); + } + return ts.visitEachChild(node, visit, ctx); + }; + return ts.visitNode(rootNode, visit) as ts.SourceFile; + }, + fixStringLiterals + ]); + + const printer = ts.createPrinter({ + removeComments: false, + newLine: ts.NewLineKind.LineFeed + }); + + tree.overwrite(options.path, printer.printFile(result.transformed[0])); + return tree; + } + ]); + + const updateTemplateFile: Rule = () => { + const templatePath = templateRelativePath && posix.join(dirname(options.path), templateRelativePath); + if (templatePath && tree.exists(templatePath)) { + tree.commitUpdate( + tree + .beginUpdate(templatePath) + .insertLeft(0, '\n') + ); + } + + return tree; + }; + + return chain([ + updateComponentFile, + updateTemplateFile, + options.skipLinter ? noop() : applyEsLintFix() + ])(tree, context); + }; +} diff --git a/packages/@o3r/third-party/schematics/iframe-to-component/schema.json b/packages/@o3r/third-party/schematics/iframe-to-component/schema.json new file mode 100644 index 0000000000..26fca4d9b0 --- /dev/null +++ b/packages/@o3r/third-party/schematics/iframe-to-component/schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "NgAddIframeToComponentSchematicsSchema", + "title": "Add iframe to an existing component", + "description": "Add iframe to an existing component", + "properties": { + "path": { + "type": "string", + "description": "Path to the component" + }, + "skipLinter": { + "type": "boolean", + "description": "Skip the linter process", + "default": false + } + }, + "additionalProperties": true, + "required": [ + "path" + ] +} diff --git a/packages/@o3r/third-party/schematics/iframe-to-component/schema.ts b/packages/@o3r/third-party/schematics/iframe-to-component/schema.ts new file mode 100644 index 0000000000..b71879c734 --- /dev/null +++ b/packages/@o3r/third-party/schematics/iframe-to-component/schema.ts @@ -0,0 +1,9 @@ +import type { JsonObject } from '@angular-devkit/core'; + +export interface NgAddIframeSchematicsSchema extends JsonObject { + /** Path to the component */ + path: string; + + /** Skip the linter process */ + skipLinter: boolean; +} diff --git a/packages/@o3r/third-party/schematics/ng-add/index.ts b/packages/@o3r/third-party/schematics/ng-add/index.ts index 24220b721e..030098a15e 100644 --- a/packages/@o3r/third-party/schematics/ng-add/index.ts +++ b/packages/@o3r/third-party/schematics/ng-add/index.ts @@ -1,10 +1,23 @@ -import { noop } from '@angular-devkit/schematics'; import type { Rule } from '@angular-devkit/schematics'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; /** * Add Otter third-party to an Angular Project */ export function ngAdd(): Rule { - /* ng add rules */ - return noop(); + return async (_, context) => { + try { + const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, { encoding: 'utf-8' })); + const {registerPackageCollectionSchematics} = await import('@o3r/schematics'); + return registerPackageCollectionSchematics(packageJson); + } catch (e) { + // third-party needs o3r/core as peer dep. o3r/core will install o3r/schematics + context.logger.error(`[ERROR]: Adding @o3r/third-party has failed. + If the error is related to missing @o3r dependencies you need to install '@o3r/core' to be able to use the configuration package. Please run 'ng add @o3r/core' . + Otherwise, use the error message as guidance.`); + throw (e); + } + }; } diff --git a/packages/@o3r/third-party/testing/mocks/__dot__eslintrc.mocks.json b/packages/@o3r/third-party/testing/mocks/__dot__eslintrc.mocks.json new file mode 100644 index 0000000000..85e3a25942 --- /dev/null +++ b/packages/@o3r/third-party/testing/mocks/__dot__eslintrc.mocks.json @@ -0,0 +1,5 @@ +{ + "rules": { + "ban": [true, "fit", "fdescribe"] + } +} diff --git a/packages/@o3r/third-party/tsconfig.spec.json b/packages/@o3r/third-party/tsconfig.spec.json index 67f4d1f193..8bbe5f95c6 100644 --- a/packages/@o3r/third-party/tsconfig.spec.json +++ b/packages/@o3r/third-party/tsconfig.spec.json @@ -5,7 +5,7 @@ "rootDir": "." }, "include": [ - "./src/**/*.spec.ts" + "**/*.spec.ts" ], "exclude": [] } diff --git a/yarn.lock b/yarn.lock index 2e3bad2774..b68240ea56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8826,6 +8826,7 @@ __metadata: "@o3r/dev-tools": "workspace:^" "@o3r/eslint-config-otter": "workspace:^" "@o3r/eslint-plugin": "workspace:^" + "@o3r/schematics": "workspace:^" "@types/jest": ~29.5.2 "@types/node": ^18.0.0 "@types/uuid": ^9.0.0 @@ -8849,6 +8850,14 @@ __metadata: typescript: ~5.0.2 uuid: ^9.0.0 zone.js: ~0.13.1 + peerDependencies: + "@angular-devkit/schematics": ~16.0.5 + "@o3r/schematics": "workspace:^" + peerDependenciesMeta: + "@angular-devkit/schematics": + optional: true + "@o3r/schematics": + optional: true languageName: unknown linkType: soft