Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@
<div class="file-item">
<span class="file-name" [title]="file.name">{{ file.name }}</span>

@if (fileViewerService.getAvailablePluginsForFile(file).length > 1) {
@if (fileViewerService.getAvailablePreviewersForFile(file).length > 1) {
<button mat-icon-button class="file-preview-button" [matMenuTriggerFor]="pluginMenu" (click)="$event.stopPropagation()" title="Preview">
<mat-icon>visibility</mat-icon>
</button>
<mat-menu #pluginMenu="matMenu">
@for (plugin of fileViewerService.getAvailablePluginsForFile(file); track plugin.id) {
<button mat-menu-item (click)="openWithPlugin(plugin, file)">
<mat-icon>{{ plugin.icon }}</mat-icon>
<span>{{ plugin.name }}</span>
@for (previewer of fileViewerService.getAvailablePreviewersForFile(file); track previewer.id) {
<button mat-menu-item (click)="openWithPreviewer(previewer, file)">
@if (previewer.icon) {
<mat-icon>{{ previewer.icon }}</mat-icon>
}
<span>{{ previewer.name }}</span>
</button>
}
</mat-menu>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { of } from 'rxjs';
import { FileOutputComponent } from './file-output.component';
import { OutputFilesService } from '../../../../../../services/analysis/output-files.service';
import { DownloadService } from '../../../../../../services/common/download.service';
import { FileViewerService, FileViewerPlugin } from '../../../../../../shared/file-viewer/file-viewer.service';
import { FileViewerService, FilePreviewer } from '../../../../../../shared/file-viewer/file-viewer.service';
import { AnalysisFilePreviewDialogComponent } from '../../../../../../shared/file-viewer/analysis-file-preview-dialog/analysis-file-preview-dialog.component';
import { MonacoEditorViewerComponent } from '../../../../../../shared/file-viewer/monaco-editor-viewer/monaco-editor-viewer.component';

Expand All @@ -16,14 +16,15 @@ describe('FileOutputComponent', () => {
let componentRef: ComponentRef<FileOutputComponent>;
let mockOutputFilesService: { downloadFile: Mock; downloadAll: Mock };
let mockDownloadService: { downloadFile: Mock };
let mockFileViewerService: { getAvailablePluginsForFile: Mock };
let mockFileViewerService: { getAvailablePreviewersForFile: Mock };
let mockDialog: { open: Mock };

const defaultPlugin: FileViewerPlugin = {
const defaultPlugin: FilePreviewer = {
id: 'monaco-editor-default',
name: 'Monaco Editor',
icon: 'code',
component: MonacoEditorViewerComponent,
supportedFileExtensions: undefined,
};

beforeEach(async () => {
Expand All @@ -33,7 +34,7 @@ describe('FileOutputComponent', () => {
};
mockDownloadService = { downloadFile: vi.fn() };
mockFileViewerService = {
getAvailablePluginsForFile: vi.fn().mockReturnValue([defaultPlugin]),
getAvailablePreviewersForFile: vi.fn().mockReturnValue([defaultPlugin]),
};
mockDialog = { open: vi.fn() };

Expand Down Expand Up @@ -86,7 +87,7 @@ describe('FileOutputComponent', () => {
data: expect.objectContaining({
projectId: 'proj-1',
analysisId: 'analysis-1',
pluginComponent: MonacoEditorViewerComponent,
previewerComponent: MonacoEditorViewerComponent,
}),
})
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatDialog } from '@angular/material/dialog';
import { OutputFilesService } from '../../../../../../services/analysis/output-files.service';
import { DownloadService } from '../../../../../../services/common/download.service';
import { FileViewerService, FileViewerPlugin } from '../../../../../../shared/file-viewer/file-viewer.service';
import { FilePreviewer, FileViewerService } from '../../../../../../shared/file-viewer/file-viewer.service';
import { AnalysisFilePreviewDialogComponent } from '../../../../../../shared/file-viewer/analysis-file-preview-dialog/analysis-file-preview-dialog.component';

@Component({
Expand Down Expand Up @@ -40,19 +40,20 @@ export class FileOutputComponent {
}

onPreviewFileClick(file: AnalysisFile) {
const plugins = this.fileViewerService.getAvailablePluginsForFile(file);
if (plugins.length === 1) {
this.openWithPlugin(plugins[0], file);
const previewers = this.fileViewerService.getAvailablePreviewersForFile(file);
if (previewers.length === 1) {
this.openWithPreviewer(previewers[0], file);
}
}

openWithPlugin(plugin: FileViewerPlugin, file: AnalysisFile) {
openWithPreviewer(previewer: FilePreviewer, file: AnalysisFile) {
this.dialog.open(AnalysisFilePreviewDialogComponent, {
data: {
projectId: this.projectId(),
analysisId: this.analysisId(),
file,
pluginComponent: plugin.component,
previewerComponent: previewer.component,
pluginOutlet: previewer.pluginOutlet,
},
width: '90vw',
height: '90vh',
Expand Down
40 changes: 22 additions & 18 deletions ScriptBeeClient/src/app/services/plugin/gateway-plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, Observable } from 'rxjs';
import {
FilePreviewExtensionPointOutlet,
Plugin,
RoutingExtensionPointOutlet,
SidePanelExtensionPointOutlet,
Expand All @@ -12,6 +13,13 @@ import { WebResponse } from '../../types/web-response';
import { Router, Routes } from '@angular/router';
import { loadRemoteModule } from '@angular-architects/native-federation';

export interface RemoteMeta {
remoteName: string;
remoteEntry: string;
}

type RoutingOutlets = Extract<UIExtensionPointOutlet, RoutingExtensionPointOutlet>;

@Injectable({
providedIn: 'root',
})
Expand All @@ -21,8 +29,11 @@ export class GatewayPluginsService {

private uiPlugins = signal<Plugin[]>([]);

public topNavigationBarOutlets = computed<TopNavigationBarExtensionPointOutlet[]>(() => this.getOutlets(this.uiPlugins(), 'top-navigation-bar'));
public sidePanelOutlets = computed<SidePanelExtensionPointOutlet[]>(() => this.getOutlets(this.uiPlugins(), 'side-panel'));
public topNavigationBarOutlets = computed<(TopNavigationBarExtensionPointOutlet & RemoteMeta)[]>(() =>
this.getOutlets(this.uiPlugins(), 'top-navigation-bar')
);
public sidePanelOutlets = computed<(SidePanelExtensionPointOutlet & RemoteMeta)[]>(() => this.getOutlets(this.uiPlugins(), 'side-panel'));
public filePreviewOutlets = computed<(FilePreviewExtensionPointOutlet & RemoteMeta)[]>(() => this.getOutlets(this.uiPlugins(), 'file-previewer'));

public fetchUIPlugins(): Observable<void> {
return this.http.get<WebResponse<Plugin[]>>('/api/plugins/gateway', { params: { kind: 'UI' } }).pipe(
Expand Down Expand Up @@ -54,20 +65,10 @@ export class GatewayPluginsService {
this.router.resetConfig([...topBarRoutes, ...updatedProjectRoutesWithPluginSidePanelRoutes]);
}

private buildRoutesFromPlugins(plugins: Plugin[], type: UIExtensionPointOutlet['type']): Routes {
return plugins.flatMap((plugin) =>
plugin.extensionPoints
.filter((ep) => ep.kind === 'UI')
.flatMap((extensionPoint) => {
const pluginRoutes: Routes = extensionPoint.outlets
.filter((outlet) => outlet.type === type)
.map((outlet) => {
return this.buildRouteFromPlugin(extensionPoint.remoteName, outlet);
});
private buildRoutesFromPlugins<T extends RoutingOutlets['type']>(plugins: Plugin[], type: T): Routes {
const outlets = this.getOutlets(plugins, type) as (RoutingExtensionPointOutlet & RemoteMeta)[];

return pluginRoutes;
})
);
return outlets.map((outlet) => this.buildRouteFromPlugin(outlet.remoteName, outlet));
}

private buildRouteFromPlugin(remoteName: string, outlet: RoutingExtensionPointOutlet) {
Expand All @@ -87,11 +88,14 @@ export class GatewayPluginsService {
};
}

private getOutlets<T extends UIExtensionPointOutlet['type']>(plugins: Plugin[], type: T): Extract<UIExtensionPointOutlet, { type: T }>[] {
private getOutlets<T extends UIExtensionPointOutlet['type']>(plugins: Plugin[], type: T): (Extract<UIExtensionPointOutlet, { type: T }> & RemoteMeta)[] {
return plugins
.flatMap((plugin) => plugin.extensionPoints)
.filter((extensionPoint) => extensionPoint.kind === 'UI')
.flatMap((extensionPoint) => extensionPoint.outlets)
.filter((outlet): outlet is Extract<UIExtensionPointOutlet, { type: T }> => outlet.type === type);
.flatMap((extensionPoint) =>
extensionPoint.outlets
.filter((outlet): outlet is Extract<UIExtensionPointOutlet, { type: T }> => outlet.type === type)
.map((outlet) => ({ ...outlet, remoteName: extensionPoint.remoteName, remoteEntry: extensionPoint.remoteEntry }))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ <h2 mat-dialog-title>Preview: {{ data.file.name }}</h2>
<app-error-state [error]="errorMessage()!" (retry)="loadFileContent()"></app-error-state>
} @else {
<div class="viewer-container">
<ng-container *ngComponentOutlet="data.pluginComponent; inputs: { content: fileContent(), file: data.file }"></ng-container>
@if (data.pluginOutlet) {
<ng-container
*ngComponentOutlet="data.previewerComponent; inputs: { content: fileContent(), file: data.file, pluginOutlet: data.pluginOutlet }"></ng-container>
} @else {
<ng-container *ngComponentOutlet="data.previewerComponent; inputs: { content: fileContent(), file: data.file }"></ng-container>
}
</div>
}
</mat-dialog-content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('AnalysisFilePreviewDialogComponent', () => {
projectId: 'project-1',
analysisId: 'analysis-1',
file: { id: 'file-1', name: 'test.json', type: 'json' },
pluginComponent: DummyViewerComponent,
previewerComponent: DummyViewerComponent,
};

beforeEach(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { NgComponentOutlet } from '@angular/common';
import { ErrorResponse } from '../../../types/api';
import { convertError } from '../../../utils/api';
import { from, switchMap } from 'rxjs';
import { FilePreviewExtensionPointOutlet } from '../../../types/plugin';

export interface AnalysisFilePreviewDialogData {
projectId: string;
analysisId: string;
file: AnalysisFile;
pluginComponent: Type<unknown>;
previewerComponent: Type<unknown>;
pluginOutlet?: FilePreviewExtensionPointOutlet;
}

@Component({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ describe('FileViewerService', () => {

const jsonFile: AnalysisFile = { id: '1', name: 'report.json', type: 'json' };

it('should provide the monaco editor as the default plugin for a file', () => {
it('should provide the monaco editor as the default previewer for a file', () => {
TestBed.configureTestingModule({ providers: [FileViewerService] });
service = TestBed.inject(FileViewerService);

const plugins = service.getAvailablePluginsForFile(jsonFile);
expect(plugins.length).toBeGreaterThan(0);
expect(plugins[0].id).toBe('monaco-editor-default');
expect(plugins[0].component).toBe(MonacoEditorViewerComponent);
const previewers = service.getAvailablePreviewersForFile(jsonFile);
expect(previewers.length).toBeGreaterThan(0);
expect(previewers[0].id).toBe('monaco-editor-default');
expect(previewers[0].component).toBe(MonacoEditorViewerComponent);
});
});
40 changes: 33 additions & 7 deletions ScriptBeeClient/src/app/shared/file-viewer/file-viewer.service.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
import { Injectable, Type } from '@angular/core';
import { inject, Injectable, Type } from '@angular/core';
import { AnalysisFile } from '../../types/analysis-results';
import { MonacoEditorViewerComponent } from './monaco-editor-viewer/monaco-editor-viewer.component';
import { GatewayPluginsService } from '../../services/plugin/gateway-plugins.service';
import { FilePreviewExtensionPointOutlet } from '../../types/plugin';
import { RemotePluginHostComponent } from './remote-plugin-host/remote-plugin-host.component';

export interface FileViewerPlugin {
export interface FilePreviewer {
id: string;
name: string;
icon: string;
icon?: string;
component: Type<unknown>;
supportedFileExtensions: string[] | undefined;
pluginOutlet?: FilePreviewExtensionPointOutlet;
}

@Injectable({ providedIn: 'root' })
export class FileViewerService {
private plugins: FileViewerPlugin[] = [
private gatewayPluginsService = inject(GatewayPluginsService);

private defaultPreviewers: FilePreviewer[] = [
{
id: 'monaco-editor-default',
name: 'Monaco Editor',
icon: 'code',
component: MonacoEditorViewerComponent,
supportedFileExtensions: undefined,
pluginOutlet: undefined,
},
];

getAvailablePluginsForFile(file: AnalysisFile): FileViewerPlugin[] {
console.log(file);
return this.plugins;
getAvailablePreviewersForFile(file: AnalysisFile): FilePreviewer[] {
const pluginPreviewers: FilePreviewer[] = this.gatewayPluginsService.filePreviewOutlets().map((outlet) => ({
id: `${outlet.remoteEntry}-${outlet.exposedModule}`,
name: outlet.label,
icon: outlet.icon,
supportedFileExtensions: outlet.supportedFileExtensions,
component: RemotePluginHostComponent,
pluginOutlet: outlet,
}));

const extension = file.name.split('.').pop()?.toLowerCase();
const allPreviewers = [...this.defaultPreviewers, ...pluginPreviewers];

if (!extension) {
return allPreviewers;
}

return allPreviewers.filter((plugin) => {
return !plugin.supportedFileExtensions || plugin.supportedFileExtensions.includes(extension);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@if (isLoading()) {
<app-loading-progress-bar title="Loading plugin..."></app-loading-progress-bar>
} @else if (error()) {
<app-error-state [error]="{ title: 'Failed to load plugin', status: 500 }"></app-error-state>
} @else {
<ng-container *ngComponentOutlet="loadedComponent(); inputs: { inputs: { content: content(), file: file() } }"></ng-container>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Component, input } from '@angular/core';
import { RemotePluginHostComponent } from './remote-plugin-host.component';

const mockLoadRemoteModule = vi.fn();

vi.mock('@angular-architects/native-federation', () => ({
loadRemoteModule: (...args: unknown[]) => mockLoadRemoteModule(...args),
}));

@Component({
selector: 'app-test-remote',
template: '<div>Plugin Content Successfully Loaded!</div>',
standalone: true,
})
class TestRemoteComponent {
inputs = input<unknown>();
}

describe('RemotePluginHostComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemotePluginHostComponent, TestRemoteComponent],
}).compileComponents();

mockLoadRemoteModule.mockReset();
});

it('should render the remote component content on success', async () => {
mockLoadRemoteModule.mockResolvedValue({ App: TestRemoteComponent });
const fixture = TestBed.createComponent(RemotePluginHostComponent);

const plugin = {
remoteName: 'test',
exposedModule: './Component',
componentName: 'App',
};
fixture.componentRef.setInput('pluginOutlet', plugin);

fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('Plugin Content Successfully Loaded!');
});

it('should display an error message if the plugin fails to load', async () => {
mockLoadRemoteModule.mockRejectedValue(new Error('Network error'));
const fixture = TestBed.createComponent(RemotePluginHostComponent);

const plugin = {
remoteName: 'test',
exposedModule: './Component',
componentName: 'App',
};
fixture.componentRef.setInput('pluginOutlet', plugin);

fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve));
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('Failed to load plugin');
});
});
Loading