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
3,735 changes: 2,471 additions & 1,264 deletions ScriptBeeClient/package-lock.json

Large diffs are not rendered by default.

26 changes: 13 additions & 13 deletions ScriptBeeClient/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.10",
"@angular/animations": "^21.2.11",
"@angular/cdk": "^21.2.9",
"@angular/common": "^21.2.10",
"@angular/compiler": "^21.2.10",
"@angular/core": "^21.2.10",
"@angular/forms": "^21.2.10",
"@angular/common": "^21.2.11",
"@angular/compiler": "^21.2.11",
"@angular/core": "^21.2.11",
"@angular/forms": "^21.2.11",
"@angular/material": "^21.2.9",
"@angular/platform-browser": "^21.2.10",
"@angular/platform-browser-dynamic": "^21.2.10",
"@angular/router": "^21.2.10",
"@angular/platform-browser": "^21.2.11",
"@angular/platform-browser-dynamic": "^21.2.11",
"@angular/router": "^21.2.11",
"@mdi/angular-material": "^7.2.96",
"@microsoft/signalr": "^10.0.0",
"angular-split": "^20.0.0",
Expand All @@ -34,16 +34,16 @@
"tslib": "^2.8.1"
},
"devDependencies": {
"@angular/build": "^21.2.8",
"@angular/cli": "^21.2.8",
"@angular/compiler-cli": "^21.2.10",
"@angular/build": "^21.2.9",
"@angular/cli": "^21.2.9",
"@angular/compiler-cli": "^21.2.11",
"@vitest/browser-playwright": "^4.1.5",
"angular-eslint": "21.3.1",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-unused-imports": "^4.4.1",
"jsdom": "^29.1.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"prettier-eslint": "^16.4.2",
"typescript": "~5.9.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,32 @@
</button>
}

@for (file of files(); track file.id) {
<button class="file-button" mat-raised-button (click)="onDownloadFileButtonClick(file)">
<mat-icon>download</mat-icon>
{{ file.name }}
</button>
}
<div class="file-list">
@for (file of files(); track file.id) {
<div class="file-item">
<span class="file-name" [title]="file.name">{{ file.name }}</span>

@if (fileViewerService.getAvailablePluginsForFile(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>
</button>
}
</mat-menu>
} @else {
<button mat-icon-button class="file-preview-button" (click)="onPreviewFileClick(file)" title="Preview">
<mat-icon>visibility</mat-icon>
</button>
}

<button mat-icon-button class="download-icon-button" (click)="onDownloadFileButtonClick(file, $event)" title="Download">
<mat-icon>download</mat-icon>
</button>
</div>
}
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,34 @@
.file-button {
margin: 8px 4px;
}

.file-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}

.file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
border-radius: 4px;
transition: background-color 0.2s;

&:hover {
background-color: var(--mat-sys-surface-container-high);
}
}

.file-name {
flex-grow: 1;
text-overflow: ellipsis;
white-space: nowrap;
}

.file-preview-button,
.download-icon-button {
flex-shrink: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ComponentRef, NO_ERRORS_SCHEMA } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
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 { 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';

describe('FileOutputComponent', () => {
let fixture: ComponentFixture<FileOutputComponent>;
let componentRef: ComponentRef<FileOutputComponent>;
let mockOutputFilesService: { downloadFile: Mock; downloadAll: Mock };
let mockDownloadService: { downloadFile: Mock };
let mockFileViewerService: { getAvailablePluginsForFile: Mock };
let mockDialog: { open: Mock };

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

beforeEach(async () => {
mockOutputFilesService = {
downloadFile: vi.fn().mockReturnValue(of(new Blob())),
downloadAll: vi.fn().mockReturnValue(of(new Blob())),
};
mockDownloadService = { downloadFile: vi.fn() };
mockFileViewerService = {
getAvailablePluginsForFile: vi.fn().mockReturnValue([defaultPlugin]),
};
mockDialog = { open: vi.fn() };

await TestBed.configureTestingModule({
imports: [FileOutputComponent],
providers: [
{ provide: OutputFilesService, useValue: mockOutputFilesService },
{ provide: DownloadService, useValue: mockDownloadService },
{ provide: FileViewerService, useValue: mockFileViewerService },
{ provide: MatDialog, useValue: mockDialog },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

fixture = TestBed.createComponent(FileOutputComponent);
componentRef = fixture.componentRef;
componentRef.setInput('projectId', 'proj-1');
componentRef.setInput('analysisId', 'analysis-1');
componentRef.setInput('files', []);
fixture.detectChanges();
});

it('should show "no output files" message when files list is empty', () => {
const message = fixture.debugElement.query(By.css('.no-output-files'));
expect(message).toBeTruthy();
});

it('should render file items with names and icon buttons', () => {
componentRef.setInput('files', [{ id: 'f1', name: 'report.json', type: 'json' }]);
fixture.detectChanges();

const fileName = fixture.debugElement.query(By.css('.file-name'));
expect(fileName.nativeElement.textContent).toContain('report.json');

const previewButton = fixture.debugElement.query(By.css('.file-preview-button'));
expect(previewButton).toBeTruthy();
expect(previewButton.nativeElement.getAttribute('title')).toBe('Preview');
});

it('should open preview dialog when clicking a file with a single viewer plugin', () => {
componentRef.setInput('files', [{ id: 'f1', name: 'report.json', type: 'json' }]);
fixture.detectChanges();

const previewButton = fixture.debugElement.query(By.css('.file-preview-button'));
previewButton.nativeElement.click();

expect(mockDialog.open).toHaveBeenCalledWith(
AnalysisFilePreviewDialogComponent,
expect.objectContaining({
data: expect.objectContaining({
projectId: 'proj-1',
analysisId: 'analysis-1',
pluginComponent: MonacoEditorViewerComponent,
}),
})
);
});

it('should trigger file download when clicking the download icon button', () => {
componentRef.setInput('files', [{ id: 'f1', name: 'report.json', type: 'json' }]);
fixture.detectChanges();

const downloadButton = fixture.debugElement.query(By.css('.download-icon-button'));
downloadButton.nativeElement.click();

expect(mockOutputFilesService.downloadFile).toHaveBeenCalledWith('proj-1', 'analysis-1', 'f1');
expect(mockDownloadService.downloadFile).toHaveBeenCalledWith('report.json', expect.any(Blob));
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Component, inject, input } from '@angular/core';
import { AnalysisFile } from '../../../../../../types/analysis-results';
import { MatButton } from '@angular/material/button';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
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 { AnalysisFilePreviewDialogComponent } from '../../../../../../shared/file-viewer/analysis-file-preview-dialog/analysis-file-preview-dialog.component';

@Component({
selector: 'app-file-output',
templateUrl: './file-output.component.html',
styleUrls: ['./file-output.component.scss'],
imports: [MatIcon, MatButton],
imports: [MatIcon, MatButton, MatIconButton, MatMenuModule],
})
export class FileOutputComponent {
projectId = input.required<string>();
Expand All @@ -19,16 +23,41 @@ export class FileOutputComponent {

private outputFilesService = inject(OutputFilesService);
private downloadService = inject(DownloadService);
public fileViewerService = inject(FileViewerService);
private dialog = inject(MatDialog);

onDownloadFileButtonClick(file: AnalysisFile) {
this.outputFilesService.downloadFile(this.projectId(), this.analysisId(), file.id).subscribe((data) => {
onDownloadFileButtonClick(file: AnalysisFile, event: Event) {
event.stopPropagation();
this.outputFilesService.downloadFile(this.projectId(), this.analysisId(), file.id).subscribe((data: Blob) => {
this.downloadService.downloadFile(file.name, data);
});
}

onDownloadAllButtonClick() {
this.outputFilesService.downloadAll(this.projectId(), this.analysisId()).subscribe((data) => {
this.outputFilesService.downloadAll(this.projectId(), this.analysisId()).subscribe((data: Blob) => {
this.downloadService.downloadFile('outputFiles.zip', data);
});
}

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

openWithPlugin(plugin: FileViewerPlugin, file: AnalysisFile) {
this.dialog.open(AnalysisFilePreviewDialogComponent, {
data: {
projectId: this.projectId(),
analysisId: this.analysisId(),
file,
pluginComponent: plugin.component,
},
width: '90vw',
height: '90vh',
maxWidth: '100vw',
maxHeight: '100vh',
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<h2 mat-dialog-title>Preview: {{ data.file.name }}</h2>
<mat-dialog-content class="preview-dialog-content">
@if (isLoading()) {
<app-loading-progress-bar title="Loading File Content..."></app-loading-progress-bar>
} @else if (errorMessage()) {
<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>
</div>
}
</mat-dialog-content>

<mat-dialog-actions align="end">
<button mat-button mat-dialog-close>Close</button>
</mat-dialog-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}

mat-dialog-content.preview-dialog-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
max-height: none;
padding: 0;
margin: 0;
}

.viewer-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { By } from '@angular/platform-browser';
import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { of, throwError } from 'rxjs';
import { AnalysisFilePreviewDialogComponent, AnalysisFilePreviewDialogData } from './analysis-file-preview-dialog.component';
import { OutputFilesService } from '../../../services/analysis/output-files.service';

@Component({ template: '<div class="dummy-viewer"></div>' })
class DummyViewerComponent {}

describe('AnalysisFilePreviewDialogComponent', () => {
let fixture: ComponentFixture<AnalysisFilePreviewDialogComponent>;
let mockOutputFilesService: { downloadFile: Mock };

const dialogData: AnalysisFilePreviewDialogData = {
projectId: 'project-1',
analysisId: 'analysis-1',
file: { id: 'file-1', name: 'test.json', type: 'json' },
pluginComponent: DummyViewerComponent,
};

beforeEach(async () => {
mockOutputFilesService = {
downloadFile: vi.fn().mockReturnValue(of(new Blob(['test content'], { type: 'text/plain' }))),
};

await TestBed.configureTestingModule({
imports: [AnalysisFilePreviewDialogComponent],
providers: [
{ provide: MatDialogRef, useValue: {} },
{ provide: MAT_DIALOG_DATA, useValue: dialogData },
{ provide: OutputFilesService, useValue: mockOutputFilesService },
],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();

fixture = TestBed.createComponent(AnalysisFilePreviewDialogComponent);
});

it('should show the file name in the title', () => {
fixture.detectChanges();

const title = fixture.debugElement.query(By.css('[mat-dialog-title]'));
expect(title.nativeElement.textContent).toContain('test.json');
});

it('should show error state when download fails', async () => {
mockOutputFilesService.downloadFile.mockReturnValue(throwError(() => new Error('Download failed')));

fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

const errorState = fixture.debugElement.query(By.css('app-error-state'));
expect(errorState).toBeTruthy();
});
});
Loading