diff --git a/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts b/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts index d72ed6f72de0..17b5682de4cb 100644 --- a/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts +++ b/src/main/webapp/app/exercises/programming/hestia/git-diff-report/git-diff-report.module.ts @@ -6,12 +6,12 @@ import { GitDiffFileComponent } from 'app/exercises/programming/hestia/git-diff- import { GitDiffReportModalComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-report-modal.component'; import { GitDiffFilePanelComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-file-panel.component'; import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { GitDiffFilePanelTitleComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-file-panel-title.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { MonacoDiffEditorComponent } from 'app/shared/monaco-editor/monaco-diff-editor.component'; @NgModule({ - imports: [ArtemisSharedModule, NgbAccordionModule, MonacoEditorModule, ArtemisSharedComponentModule], + imports: [ArtemisSharedModule, NgbAccordionModule, MonacoDiffEditorComponent, ArtemisSharedComponentModule], declarations: [GitDiffFilePanelComponent, GitDiffFilePanelTitleComponent, GitDiffReportComponent, GitDiffFileComponent, GitDiffReportModalComponent, GitDiffLineStatComponent], exports: [GitDiffReportComponent, GitDiffReportModalComponent, GitDiffLineStatComponent], }) diff --git a/src/main/webapp/app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module.ts b/src/main/webapp/app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module.ts index 04e5a60b0ad6..c37bc7a255da 100644 --- a/src/main/webapp/app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module.ts +++ b/src/main/webapp/app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.module.ts @@ -4,10 +4,10 @@ import { TestwiseCoverageReportModalComponent } from 'app/exercises/programming/ import { TestwiseCoverageReportComponent } from 'app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-report.component'; import { TestwiseCoverageFileComponent } from 'app/exercises/programming/hestia/testwise-coverage-report/testwise-coverage-file.component'; import { MatExpansionModule } from '@angular/material/expansion'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ - imports: [ArtemisSharedModule, MatExpansionModule, MonacoEditorModule], + imports: [ArtemisSharedModule, MatExpansionModule, MonacoEditorComponent], declarations: [TestwiseCoverageFileComponent, TestwiseCoverageReportComponent, TestwiseCoverageReportModalComponent], exports: [TestwiseCoverageFileComponent, TestwiseCoverageReportModalComponent, TestwiseCoverageReportComponent], }) diff --git a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts index 1cb726111a17..ce2056d24a44 100644 --- a/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/programming-exercise-management.module.ts @@ -28,7 +28,7 @@ import { ArtemisCodeEditorModule } from 'app/exercises/programming/shared/code-e import { DetailModule } from 'app/detail-overview-list/detail.module'; import { IrisModule } from 'app/iris/iris.module'; import { ArtemisExerciseModule } from 'app/exercises/shared/exercise/exercise.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ imports: [ @@ -56,7 +56,7 @@ import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.modul ArtemisExerciseModule, DetailModule, IrisModule, - MonacoEditorModule, + MonacoEditorComponent, ], declarations: [ ProgrammingExerciseDetailComponent, diff --git a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts index e3df242248bd..0a2fa9061344 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts +++ b/src/main/webapp/app/exercises/programming/manage/update/programming-exercise-update.module.ts @@ -32,7 +32,7 @@ import { FormsModule } from 'app/forms/forms.module'; import { ProgrammingExerciseBuildPlanCheckoutDirectoriesComponent } from 'app/exercises/programming/shared/build-details/programming-exercise-build-plan-checkout-directories.component'; import { ProgrammingExerciseRepositoryAndBuildPlanDetailsComponent } from 'app/exercises/programming/shared/build-details/programming-exercise-repository-and-build-plan-details.component'; import { ProgrammingExerciseTheiaComponent } from 'app/exercises/programming/manage/update/update-components/theia/programming-exercise-theia.component'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ imports: [ @@ -57,7 +57,7 @@ import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.modul FormsModule, ProgrammingExerciseBuildPlanCheckoutDirectoriesComponent, ProgrammingExerciseRepositoryAndBuildPlanDetailsComponent, - MonacoEditorModule, + MonacoEditorComponent, ProgrammingExerciseTheiaComponent, ], declarations: [ diff --git a/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts b/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts index 7b23b4959793..49583cbc95d4 100644 --- a/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts +++ b/src/main/webapp/app/exercises/programming/shared/code-editor/code-editor.module.ts @@ -21,9 +21,9 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TreeviewModule } from 'app/exercises/programming/shared/code-editor/treeview/treeview.module'; import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code-editor/header/code-editor-header.component'; import { CodeEditorFileBrowserBadgeComponent } from 'app/exercises/programming/shared/code-editor/file-browser/code-editor-file-browser-badge.component'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ imports: [ @@ -33,7 +33,7 @@ import { ArtemisSharedComponentModule } from 'app/shared/components/shared-compo TreeviewModule.forRoot(), ArtemisProgrammingExerciseInstructionsEditorModule, ArtemisProgrammingManualAssessmentModule, - MonacoEditorModule, + MonacoEditorComponent, ArtemisSharedComponentModule, ], declarations: [ diff --git a/src/main/webapp/app/exercises/shared/exercise-hint/shared/exercise-hint-shared.module.ts b/src/main/webapp/app/exercises/shared/exercise-hint/shared/exercise-hint-shared.module.ts index ef5dd4dce389..08a2b1c8ee07 100644 --- a/src/main/webapp/app/exercises/shared/exercise-hint/shared/exercise-hint-shared.module.ts +++ b/src/main/webapp/app/exercises/shared/exercise-hint/shared/exercise-hint-shared.module.ts @@ -4,10 +4,10 @@ import { CastToCodeHintPipe } from 'app/exercises/shared/exercise-hint/services/ import { SolutionEntryComponent } from 'app/exercises/shared/exercise-hint/shared/solution-entry.component'; import { CodeHintContainerComponent } from 'app/exercises/shared/exercise-hint/shared/code-hint-container.component'; import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ - imports: [ArtemisSharedModule, ArtemisMarkdownModule, MonacoEditorModule], + imports: [ArtemisSharedModule, ArtemisMarkdownModule, MonacoEditorComponent], declarations: [SolutionEntryComponent, CodeHintContainerComponent, CastToCodeHintPipe], exports: [SolutionEntryComponent, CodeHintContainerComponent, CastToCodeHintPipe], }) diff --git a/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts b/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts index 198dad328789..a911ddf74ceb 100644 --- a/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts +++ b/src/main/webapp/app/shared/markdown-editor/markdown-editor.module.ts @@ -5,11 +5,11 @@ import { ArtemisSharedModule } from 'app/shared/shared.module'; import { MatMenuModule } from '@angular/material/menu'; import { MatButtonModule } from '@angular/material/button'; import { MarkdownEditorMonacoComponent } from 'app/shared/markdown-editor/monaco/markdown-editor-monaco.component'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { DragDropModule } from '@angular/cdk/drag-drop'; +import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; @NgModule({ - imports: [ArtemisSharedModule, MonacoEditorModule, FormsModule, ArtemisColorSelectorModule, MatMenuModule, MatButtonModule, DragDropModule], + imports: [ArtemisSharedModule, MonacoEditorComponent, FormsModule, ArtemisColorSelectorModule, MatMenuModule, MatButtonModule, DragDropModule], declarations: [MarkdownEditorMonacoComponent], exports: [MarkdownEditorMonacoComponent], }) diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-diff-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-diff-editor.component.ts index dd5b624d6554..8cb86be9a233 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-diff-editor.component.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-diff-editor.component.ts @@ -1,68 +1,55 @@ -import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, Renderer2, ViewEncapsulation } from '@angular/core'; -import { Theme, ThemeService } from 'app/core/theme/theme.service'; +import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewEncapsulation, effect, inject, input, output } from '@angular/core'; import * as monaco from 'monaco-editor'; -import { Subscription } from 'rxjs'; import { Disposable } from 'app/shared/monaco-editor/model/actions/monaco-editor.util'; +import { MonacoEditorService } from './monaco-editor.service'; export type MonacoEditorDiffText = { original: string; modified: string }; @Component({ selector: 'jhi-monaco-diff-editor', template: '', + standalone: true, styleUrls: ['monaco-diff-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class MonacoDiffEditorComponent implements OnInit, OnDestroy { private _editor: monaco.editor.IStandaloneDiffEditor; monacoDiffEditorContainerElement: HTMLElement; - themeSubscription?: Subscription; + + allowSplitView = input(true); + onReadyForDisplayChange = output(); + + /* + * Subscriptions and listeners that need to be disposed of when this component is destroyed. + */ listeners: Disposable[] = []; resizeObserver?: ResizeObserver; - @Input() - set allowSplitView(value: boolean) { - this._editor.updateOptions({ - renderSideBySide: value, - }); - } - - @Output() - onReadyForDisplayChange = new EventEmitter(); + /* + * Injected services and elements. + */ + private readonly elementRef = inject(ElementRef); + private readonly renderer = inject(Renderer2); + private readonly monacoEditorService = inject(MonacoEditorService); - constructor( - private themeService: ThemeService, - elementRef: ElementRef, - renderer: Renderer2, - ) { + constructor() { /* * The constructor injects the editor along with its container into the empty template of this component. * This makes the editor available immediately (not just after ngOnInit), preventing errors when the methods * of this component are called. */ - this.monacoDiffEditorContainerElement = renderer.createElement('div'); - this._editor = monaco.editor.createDiffEditor(this.monacoDiffEditorContainerElement, { - glyphMargin: true, - minimap: { enabled: false }, - readOnly: true, - renderSideBySide: true, - scrollBeyondLastLine: false, - stickyScroll: { - enabled: false, - }, - renderOverviewRuler: false, - scrollbar: { - vertical: 'hidden', - handleMouseWheel: true, - alwaysConsumeMouseWheel: false, - }, - hideUnchangedRegions: { - enabled: true, - }, - fontSize: 12, - }); - renderer.appendChild(elementRef.nativeElement, this.monacoDiffEditorContainerElement); + this.monacoDiffEditorContainerElement = this.renderer.createElement('div'); + this._editor = this.monacoEditorService.createStandaloneDiffEditor(this.monacoDiffEditorContainerElement); + this.renderer.appendChild(this.elementRef.nativeElement, this.monacoDiffEditorContainerElement); this.setupDiffListener(); this.setupContentHeightListeners(); + + effect(() => { + this._editor.updateOptions({ + renderSideBySide: this.allowSplitView(), + }); + }); } ngOnInit(): void { @@ -70,11 +57,9 @@ export class MonacoDiffEditorComponent implements OnInit, OnDestroy { this.layout(); }); this.resizeObserver.observe(this.monacoDiffEditorContainerElement); - this.themeSubscription = this.themeService.getCurrentThemeObservable().subscribe((theme) => this.changeTheme(theme)); } ngOnDestroy(): void { - this.themeSubscription?.unsubscribe(); this.resizeObserver?.disconnect(); this.listeners.forEach((listener) => { listener.dispose(); @@ -137,15 +122,6 @@ export class MonacoDiffEditorComponent implements OnInit, OnDestroy { this._editor.layout({ width, height }); } - /** - * Sets the theme of all Monaco editors according to the Artemis theme. - * As of now, it is not possible to have two editors with different themes. - * @param artemisTheme The active Artemis theme. - */ - changeTheme(artemisTheme: Theme): void { - monaco.editor.setTheme(artemisTheme === Theme.DARK ? 'vs-dark' : 'vs-light'); - } - /** * Updates the files displayed in this editor. When this happens, {@link onReadyForDisplayChange} will signal that the editor is not * ready to display the diff (as it must be computed first). This will later be change by the appropriate listener. diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts index a0e28544e914..b9138398fcee 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts @@ -1,7 +1,5 @@ -import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, Renderer2, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy, OnInit, Renderer2, ViewEncapsulation, effect, inject, input, output } from '@angular/core'; import * as monaco from 'monaco-editor'; -import { Subscription } from 'rxjs'; -import { Theme, ThemeService } from 'app/core/theme/theme.service'; import { MonacoEditorLineWidget } from 'app/shared/monaco-editor/model/monaco-editor-inline-widget.model'; import { MonacoEditorBuildAnnotation, MonacoEditorBuildAnnotationType } from 'app/shared/monaco-editor/model/monaco-editor-build-annotation.model'; import { MonacoEditorLineHighlight } from 'app/shared/monaco-editor/model/monaco-editor-line-highlight.model'; @@ -12,107 +10,98 @@ import { TranslateService } from '@ngx-translate/core'; import { MonacoEditorOptionPreset } from 'app/shared/monaco-editor/model/monaco-editor-option-preset.model'; import { Disposable, EditorPosition, EditorRange, MonacoEditorTextModel } from 'app/shared/monaco-editor/model/actions/monaco-editor.util'; import { MonacoTextEditorAdapter } from 'app/shared/monaco-editor/model/actions/adapter/monaco-text-editor.adapter'; +import { MonacoEditorService } from 'app/shared/monaco-editor/monaco-editor.service'; export const MAX_TAB_SIZE = 8; @Component({ selector: 'jhi-monaco-editor', template: '', + standalone: true, styleUrls: ['monaco-editor.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, }) export class MonacoEditorComponent implements OnInit, OnDestroy { - private _editor: monaco.editor.IStandaloneCodeEditor; - private textEditorAdapter: MonacoTextEditorAdapter; - private monacoEditorContainerElement: HTMLElement; - themeSubscription?: Subscription; + /** + * The default width of the line decoration button in the editor. We use the ch unit to avoid fixed pixel sizes. + * @private + */ + private static readonly DEFAULT_LINE_DECORATION_BUTTON_WIDTH = '2.3ch'; + private static readonly SHRINK_TO_FIT_CLASS = 'monaco-shrink-to-fit'; + + private readonly _editor: monaco.editor.IStandaloneCodeEditor; + private readonly textEditorAdapter: MonacoTextEditorAdapter; + private readonly monacoEditorContainerElement: HTMLElement; + + /* + * Elements, models, and actions of the editor. + */ models: MonacoEditorTextModel[] = []; lineWidgets: MonacoEditorLineWidget[] = []; - editorBuildAnnotations: MonacoEditorBuildAnnotation[] = []; + buildAnnotations: MonacoEditorBuildAnnotation[] = []; lineHighlights: MonacoEditorLineHighlight[] = []; actions: TextEditorAction[] = []; lineDecorationsHoverButton?: MonacoEditorLineDecorationsHoverButton; - /** - * The default width of the line decoration button in the editor. We use the ch unit to avoid fixed pixel sizes. - * @private + /* + * Inputs and outputs. */ - private static readonly DEFAULT_LINE_DECORATION_BUTTON_WIDTH = '2.3ch'; + textChangedEmitDelay = input(); + shrinkToFit = input(true); + stickyScroll = input(false); + readOnly = input(false); - constructor( - private readonly themeService: ThemeService, - elementRef: ElementRef, - private readonly renderer: Renderer2, - private readonly translateService: TranslateService, - ) { + textChanged = output(); + contentHeightChanged = output(); + onBlurEditor = output(); + + /* + * Disposable listeners, subscriptions, and timeouts. + */ + private contentHeightListener?: Disposable; + private textChangedListener?: Disposable; + private blurEditorWidgetListener?: Disposable; + private textChangedEmitTimeout?: NodeJS.Timeout; + + /* + * Injected services and elements. + */ + private readonly renderer = inject(Renderer2); + private readonly translateService = inject(TranslateService); + private readonly elementRef = inject(ElementRef); + private readonly monacoEditorService = inject(MonacoEditorService); + + constructor() { /* * The constructor injects the editor along with its container into the empty template of this component. * This makes the editor available immediately (not just after ngOnInit), preventing errors when the methods * of this component are called. */ - this.monacoEditorContainerElement = renderer.createElement('div'); - renderer.addClass(this.monacoEditorContainerElement, 'monaco-editor-container'); - renderer.addClass(this.monacoEditorContainerElement, 'monaco-shrink-to-fit'); - this._editor = monaco.editor.create(this.monacoEditorContainerElement, { - value: '', - glyphMargin: true, - minimap: { enabled: false }, - readOnly: this._readOnly, - lineNumbersMinChars: 4, - scrollBeyondLastLine: false, - scrollbar: { - alwaysConsumeMouseWheel: false, // Prevents the editor from consuming the mouse wheel event, allowing the parent element to scroll. - }, - }); - this._editor.getModel()?.setEOL(monaco.editor.EndOfLineSequence.LF); + this.monacoEditorContainerElement = this.renderer.createElement('div'); + this.renderer.addClass(this.monacoEditorContainerElement, 'monaco-editor-container'); + this.renderer.addClass(this.monacoEditorContainerElement, MonacoEditorComponent.SHRINK_TO_FIT_CLASS); + this._editor = this.monacoEditorService.createStandaloneCodeEditor(this.monacoEditorContainerElement); this.textEditorAdapter = new MonacoTextEditorAdapter(this._editor); - renderer.appendChild(elementRef.nativeElement, this.monacoEditorContainerElement); - } - - @Input() - textChangedEmitDelay?: number; - - // TODO: The CSS class below allows the editor to shrink in the CodeEditorContainerComponent. We should eventually remove this class and handle the editor size differently in the code editor grid. - @Input() - set shrinkToFit(value: boolean) { - if (value) { - this.renderer.addClass(this.monacoEditorContainerElement, 'monaco-shrink-to-fit'); - } else { - this.renderer.removeClass(this.monacoEditorContainerElement, 'monaco-shrink-to-fit'); - } - } - - @Input() - set stickyScroll(value: boolean) { - this._editor.updateOptions({ - stickyScroll: { enabled: value }, + this.renderer.appendChild(this.elementRef.nativeElement, this.monacoEditorContainerElement); + + effect(() => { + // TODO: The CSS class below allows the editor to shrink in the CodeEditorContainerComponent. We should eventually remove this class and handle the editor size differently in the code editor grid. + if (this.shrinkToFit()) { + this.renderer.addClass(this.monacoEditorContainerElement, MonacoEditorComponent.SHRINK_TO_FIT_CLASS); + } else { + this.renderer.removeClass(this.monacoEditorContainerElement, MonacoEditorComponent.SHRINK_TO_FIT_CLASS); + } }); - } - @Input() - set readOnly(value: boolean) { - this._readOnly = value; - this._editor.updateOptions({ - readOnly: value, + effect(() => { + this._editor.updateOptions({ + stickyScroll: { enabled: this.stickyScroll() }, + readOnly: this.readOnly(), + }); }); } - private _readOnly: boolean = false; - - @Output() - textChanged = new EventEmitter(); - - @Output() - contentHeightChanged = new EventEmitter(); - - @Output() - onBlurEditor = new EventEmitter(); - - private contentHeightListener?: Disposable; - private textChangedListener?: Disposable; - private blurEditorWidgetListener?: Disposable; - private textChangedEmitTimeout?: NodeJS.Timeout; - ngOnInit(): void { const resizeObserver = new ResizeObserver(() => { this._editor.layout(); @@ -132,14 +121,11 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { this.blurEditorWidgetListener = this._editor.onDidBlurEditorWidget(() => { this.onBlurEditor.emit(); }); - - this.themeSubscription = this.themeService.getCurrentThemeObservable().subscribe((theme) => this.changeTheme(theme)); } ngOnDestroy() { this.reset(); this._editor.dispose(); - this.themeSubscription?.unsubscribe(); this.textChangedListener?.dispose(); this.contentHeightListener?.dispose(); this.blurEditorWidgetListener?.dispose(); @@ -147,7 +133,8 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { private emitTextChangeEvent() { const newValue = this.getText(); - if (!this.textChangedEmitDelay) { + const delay = this.textChangedEmitDelay(); + if (!delay) { this.textChanged.emit(newValue); } else { if (this.textChangedEmitTimeout) { @@ -156,7 +143,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { } this.textChangedEmitTimeout = setTimeout(() => { this.textChanged.emit(newValue); - }, this.textChangedEmitDelay); + }, delay); } } @@ -268,10 +255,10 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { } disposeAnnotations() { - this.editorBuildAnnotations.forEach((o) => { + this.buildAnnotations.forEach((o) => { o.dispose(); }); - this.editorBuildAnnotations = []; + this.buildAnnotations = []; } disposeLineHighlights(): void { @@ -288,10 +275,6 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { this.actions = []; } - changeTheme(artemisTheme: Theme): void { - monaco.editor.setTheme(artemisTheme === Theme.DARK ? 'vs-dark' : 'vs-light'); - } - layout(): void { this._editor.layout(); } @@ -325,7 +308,7 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { ); editorBuildAnnotation.addToEditor(); editorBuildAnnotation.setOutdatedAndUpdate(outdated); - this.editorBuildAnnotations.push(editorBuildAnnotation); + this.buildAnnotations.push(editorBuildAnnotation); } } diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts deleted file mode 100644 index 88f841d9407a..000000000000 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; -import { MonacoDiffEditorComponent } from 'app/shared/monaco-editor/monaco-diff-editor.component'; -import { CUSTOM_MARKDOWN_CONFIG, CUSTOM_MARKDOWN_LANGUAGE, CUSTOM_MARKDOWN_LANGUAGE_ID } from 'app/shared/monaco-editor/model/languages/monaco-custom-markdown.language'; - -import * as monaco from 'monaco-editor'; - -monaco.languages.register({ id: CUSTOM_MARKDOWN_LANGUAGE_ID }); -monaco.languages.setLanguageConfiguration(CUSTOM_MARKDOWN_LANGUAGE_ID, CUSTOM_MARKDOWN_CONFIG); -monaco.languages.setMonarchTokensProvider(CUSTOM_MARKDOWN_LANGUAGE_ID, CUSTOM_MARKDOWN_LANGUAGE); - -@NgModule({ - declarations: [MonacoEditorComponent, MonacoDiffEditorComponent], - exports: [MonacoEditorComponent, MonacoDiffEditorComponent], -}) -export class MonacoEditorModule {} diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts new file mode 100644 index 000000000000..0d00d81300a3 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts @@ -0,0 +1,84 @@ +import { Injectable, effect, inject } from '@angular/core'; +import * as monaco from 'monaco-editor'; +import { CUSTOM_MARKDOWN_CONFIG, CUSTOM_MARKDOWN_LANGUAGE, CUSTOM_MARKDOWN_LANGUAGE_ID } from 'app/shared/monaco-editor/model/languages/monaco-custom-markdown.language'; +import { Theme, ThemeService } from 'app/core/theme/theme.service'; +import { toSignal } from '@angular/core/rxjs-interop'; + +/** + * Service providing shared functionality for the Monaco editor. + * This service is intended to be used by components that need to create and manage Monaco editors. + * It also ensures that the editor's theme matches the current theme of Artemis. + */ +@Injectable({ providedIn: 'root' }) +export class MonacoEditorService { + static readonly LIGHT_THEME_ID = 'vs'; + static readonly DARK_THEME_ID = 'vs-dark'; + + private readonly themeService: ThemeService = inject(ThemeService); + private readonly currentTheme = toSignal(this.themeService.getCurrentThemeObservable(), { requireSync: true }); + + constructor() { + monaco.languages.register({ id: CUSTOM_MARKDOWN_LANGUAGE_ID }); + monaco.languages.setLanguageConfiguration(CUSTOM_MARKDOWN_LANGUAGE_ID, CUSTOM_MARKDOWN_CONFIG); + monaco.languages.setMonarchTokensProvider(CUSTOM_MARKDOWN_LANGUAGE_ID, CUSTOM_MARKDOWN_LANGUAGE); + + effect(() => { + this.applyTheme(this.currentTheme()); + }); + } + + /** + * Applies the given theme to the Monaco editor. + * @param artemisTheme The theme to apply. + * @private + */ + private applyTheme(artemisTheme: Theme): void { + monaco.editor.setTheme(artemisTheme === Theme.LIGHT ? MonacoEditorService.LIGHT_THEME_ID : MonacoEditorService.DARK_THEME_ID); + } + + /** + * Creates a standalone code editor (see {@link MonacoEditorComponent}) with sensible default settings and inserts it into the given DOM element. + * @param domElement The DOM element to insert the editor into. + */ + createStandaloneCodeEditor(domElement: HTMLElement): monaco.editor.IStandaloneCodeEditor { + const editor = monaco.editor.create(domElement, { + value: '', + glyphMargin: true, + minimap: { enabled: false }, + lineNumbersMinChars: 4, + scrollBeyondLastLine: false, + scrollbar: { + alwaysConsumeMouseWheel: false, // Prevents the editor from consuming the mouse wheel event, allowing the parent element to scroll. + }, + }); + editor.getModel()?.setEOL(monaco.editor.EndOfLineSequence.LF); + return editor; + } + + /** + * Creates a standalone diff editor (see {@link MonacoDiffEditorComponent}) with sensible default settings and inserts it into the given DOM element. + * @param domElement The DOM element to insert the editor into. + */ + createStandaloneDiffEditor(domElement: HTMLElement): monaco.editor.IStandaloneDiffEditor { + return monaco.editor.createDiffEditor(domElement, { + glyphMargin: true, + minimap: { enabled: false }, + readOnly: true, + renderSideBySide: true, + scrollBeyondLastLine: false, + stickyScroll: { + enabled: false, + }, + renderOverviewRuler: false, + scrollbar: { + vertical: 'hidden', + handleMouseWheel: true, + alwaysConsumeMouseWheel: false, + }, + hideUnchangedRegions: { + enabled: true, + }, + fontSize: 12, + }); + } +} diff --git a/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts index e6a35ac15468..df3b885c4a93 100644 --- a/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts +++ b/src/test/javascript/spec/component/code-editor/code-editor-monaco.component.spec.ts @@ -4,7 +4,6 @@ import { ArtemisTestModule } from '../../test.module'; import { Annotation, CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; import { MockComponent } from 'ng-mocks'; import { CodeEditorTutorAssessmentInlineFeedbackComponent } from 'app/exercises/programming/assess/code-editor-tutor-assessment-inline-feedback.component'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; import { CodeEditorFileService } from 'app/exercises/programming/shared/code-editor/service/code-editor-file.service'; @@ -48,13 +47,8 @@ describe('CodeEditorMonacoComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [ - CodeEditorMonacoComponent, - MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), - MockComponent(CodeEditorHeaderComponent), - MonacoEditorComponent, - ], + imports: [ArtemisTestModule, MonacoEditorComponent], + declarations: [CodeEditorMonacoComponent, MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), MockComponent(CodeEditorHeaderComponent)], providers: [ CodeEditorFileService, { provide: CodeEditorRepositoryFileService, useClass: MockCodeEditorRepositoryFileService }, diff --git a/src/test/javascript/spec/component/hestia/git-diff-report/git-diff-file.component.spec.ts b/src/test/javascript/spec/component/hestia/git-diff-report/git-diff-file.component.spec.ts index dbef6bd7eb72..47d3e75710b4 100644 --- a/src/test/javascript/spec/component/hestia/git-diff-report/git-diff-file.component.spec.ts +++ b/src/test/javascript/spec/component/hestia/git-diff-report/git-diff-file.component.spec.ts @@ -1,8 +1,8 @@ import { ArtemisTestModule } from '../../../test.module'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { GitDiffFileComponent } from 'app/exercises/programming/hestia/git-diff-report/git-diff-file.component'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; +import { MonacoDiffEditorComponent } from '../../../../../../main/webapp/app/shared/monaco-editor/monaco-diff-editor.component'; function getDiffEntryWithPaths(previousFilePath?: string, filePath?: string) { return { @@ -17,7 +17,7 @@ describe('GitDiffFileComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], + imports: [ArtemisTestModule, MonacoDiffEditorComponent], declarations: [GitDiffFileComponent], providers: [], }).compileComponents(); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-aeolus-build-plan.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-aeolus-build-plan.component.spec.ts index 8b59cc5e8c6b..005fa8fdd41a 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-aeolus-build-plan.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-aeolus-build-plan.component.spec.ts @@ -8,8 +8,6 @@ import { ProgrammingExercise, ProgrammingLanguage, ProjectType } from 'app/entit import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { Course } from 'app/entities/course.model'; import { ProgrammingExerciseCustomAeolusBuildPlanComponent } from 'app/exercises/programming/manage/update/update-components/custom-build-plans/programming-exercise-custom-aeolus-build-plan.component'; -import { ElementRef, Renderer2 } from '@angular/core'; -import { ThemeService } from 'app/core/theme/theme.service'; import { MockComponent } from 'ng-mocks'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HelpIconComponent } from 'app/shared/components/help-icon.component'; @@ -18,10 +16,8 @@ import { AeolusService } from 'app/exercises/programming/shared/service/aeolus.s import { PROFILE_AEOLUS } from 'app/app.constants'; import { Observable } from 'rxjs'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; -import { TranslateService } from '@ngx-translate/core'; describe('ProgrammingExercise Aeolus Custom Build Plan', () => { - let mockThemeService: ThemeService; let comp: ProgrammingExerciseCustomAeolusBuildPlanComponent; const course = { id: 123 } as Course; @@ -33,8 +29,7 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => { let cleanBuildAction: ScriptAction = new ScriptAction(); let platformAction: PlatformAction = new PlatformAction(); let mockAeolusService: AeolusService; - let renderer2: Renderer2; - let translateService: TranslateService; + let monacoEditorComponent: MonacoEditorComponent; beforeEach(() => { programmingExercise = new ProgrammingExercise(course, undefined); @@ -75,16 +70,13 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => { .compileComponents() .then(() => { mockAeolusService = TestBed.inject(AeolusService); - mockThemeService = TestBed.inject(ThemeService); }); const fixture = TestBed.createComponent(ProgrammingExerciseCustomAeolusBuildPlanComponent); - // These are not directly injected into the component, but are needed for the tests. - renderer2 = fixture.debugElement.injector.get(Renderer2); - translateService = fixture.debugElement.injector.get(TranslateService); comp = fixture.componentInstance; comp.programmingExercise = programmingExercise; + monacoEditorComponent = TestBed.createComponent(MonacoEditorComponent).componentInstance; }); afterEach(() => { @@ -123,11 +115,13 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => { expect(programmingExercise.buildConfig?.windfile?.actions.length).toBe(size! + 1); }); - it('should accept editor', () => { - const elementRef: ElementRef = new ElementRef(document.createElement('div')); + it('should accept and setup editor', () => { + const setTextStub = jest.spyOn(monacoEditorComponent, 'setText').mockImplementation(); + comp.code = 'void'; expect(comp.editor).toBeUndefined(); - comp.editor = new MonacoEditorComponent(mockThemeService, elementRef, renderer2, translateService); - expect(comp.editor).toBeDefined(); + comp.editor = monacoEditorComponent; + expect(comp.editor).toBe(monacoEditorComponent); + expect(setTextStub).toHaveBeenCalledExactlyOnceWith(comp.code); }); it('should change code of active action', () => { @@ -175,8 +169,7 @@ describe('ProgrammingExercise Aeolus Custom Build Plan', () => { }); it('should set editor text', () => { - const elementRef: ElementRef = new ElementRef(document.createElement('div')); - comp.editor = new MonacoEditorComponent(mockThemeService, elementRef, renderer2, translateService); + comp.editor = monacoEditorComponent; comp.changeActiveAction('gradle'); expect(comp.editor?.getText()).toBe(gradleBuildAction.script); }); diff --git a/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-build-plan.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-build-plan.component.spec.ts index b2ec21d46f82..7637950ee528 100644 --- a/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-build-plan.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/programming-exercise-custom-build-plan.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BuildAction, PlatformAction, ScriptAction } from 'app/entities/programming/build.action'; import { DockerConfiguration } from 'app/entities/programming/docker.configuration'; import { WindFile } from 'app/entities/programming/wind.file'; @@ -7,8 +7,7 @@ import { ArtemisTestModule } from '../../test.module'; import { ProgrammingExercise, ProgrammingLanguage, ProjectType } from 'app/entities/programming/programming-exercise.model'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { Course } from 'app/entities/course.model'; -import { ElementRef, Renderer2 } from '@angular/core'; -import { ThemeService } from 'app/core/theme/theme.service'; +import { Renderer2 } from '@angular/core'; import { MockComponent } from 'ng-mocks'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { HelpIconComponent } from 'app/shared/components/help-icon.component'; @@ -18,10 +17,9 @@ import { ProgrammingExerciseCustomBuildPlanComponent } from 'app/exercises/progr import { PROFILE_LOCALCI } from 'app/app.constants'; import { Observable } from 'rxjs'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; -import { TranslateService } from '@ngx-translate/core'; describe('ProgrammingExercise Custom Build Plan', () => { - let mockThemeService: ThemeService; + let fixture: ComponentFixture; let comp: ProgrammingExerciseCustomBuildPlanComponent; const course = { id: 123 } as Course; @@ -33,8 +31,6 @@ describe('ProgrammingExercise Custom Build Plan', () => { let cleanBuildAction: ScriptAction = new ScriptAction(); let platformAction: PlatformAction = new PlatformAction(); let mockAeolusService: AeolusService; - let renderer2: Renderer2; - let translateService: TranslateService; beforeEach(() => { programmingExercise = new ProgrammingExercise(course, undefined); @@ -68,14 +64,10 @@ describe('ProgrammingExercise Custom Build Plan', () => { .compileComponents() .then(() => { mockAeolusService = TestBed.inject(AeolusService); - mockThemeService = TestBed.inject(ThemeService); }); - const fixture = TestBed.createComponent(ProgrammingExerciseCustomBuildPlanComponent); + fixture = TestBed.createComponent(ProgrammingExerciseCustomBuildPlanComponent); comp = fixture.componentInstance; - // These are not directly injected into the component, but are needed for the tests. - renderer2 = fixture.debugElement.injector.get(Renderer2); - translateService = fixture.debugElement.injector.get(TranslateService); comp.programmingExercise = programmingExercise; }); @@ -92,9 +84,8 @@ describe('ProgrammingExercise Custom Build Plan', () => { }); it('should accept editor', () => { - const elementRef: ElementRef = new ElementRef(document.createElement('div')); expect(comp.editor).toBeUndefined(); - comp.editor = new MonacoEditorComponent(mockThemeService, elementRef, renderer2, translateService); + comp.editor = TestBed.createComponent(MonacoEditorComponent).componentInstance; expect(comp.editor).toBeDefined(); }); @@ -224,15 +215,14 @@ describe('ProgrammingExercise Custom Build Plan', () => { it('should accept editor for existing exercise', () => { comp.programmingExercise.id = 1; - const elementRef: ElementRef = new ElementRef(document.createElement('div')); comp.programmingExercise.buildConfig!.buildScript = 'buildscript'; - const editor = new MonacoEditorComponent(mockThemeService, elementRef, renderer2, translateService); + const editor = TestBed.createComponent(MonacoEditorComponent).componentInstance; expect(comp.editor).toBeUndefined(); comp.editor = editor; expect(comp.code).toBe('buildscript'); expect(comp.editor).toBeDefined(); comp.programmingExercise.buildConfig!.buildScript = undefined; - comp.editor = new MonacoEditorComponent(mockThemeService, elementRef, renderer2, translateService); + comp.editor = TestBed.createComponent(MonacoEditorComponent).componentInstance; expect(comp.code).toBe(''); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-diff-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-diff-editor.component.spec.ts index 350a6ed235d3..459c093e3896 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-diff-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-diff-editor.component.spec.ts @@ -1,28 +1,21 @@ -import { Theme, ThemeService } from 'app/core/theme/theme.service'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ArtemisTestModule } from '../../../test.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; import { MonacoDiffEditorComponent } from 'app/shared/monaco-editor/monaco-diff-editor.component'; -import { BehaviorSubject } from 'rxjs'; describe('MonacoDiffEditorComponent', () => { let fixture: ComponentFixture; let comp: MonacoDiffEditorComponent; - let mockThemeService: ThemeService; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [MonacoDiffEditorComponent], - providers: [], + imports: [ArtemisTestModule, MonacoDiffEditorComponent], }) .compileComponents() .then(() => { global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { return new MockResizeObserver(callback); }); - mockThemeService = TestBed.inject(ThemeService); fixture = TestBed.createComponent(MonacoDiffEditorComponent); comp = fixture.componentInstance; }); @@ -32,25 +25,12 @@ describe('MonacoDiffEditorComponent', () => { jest.restoreAllMocks(); }); - it('should adjust its theme to the global theme', () => { - const themeSubject = new BehaviorSubject(Theme.LIGHT); - const subscribeStub = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); - const changeThemeSpy = jest.spyOn(comp, 'changeTheme'); - fixture.detectChanges(); - themeSubject.next(Theme.DARK); - expect(subscribeStub).toHaveBeenCalledOnce(); - expect(changeThemeSpy).toHaveBeenCalledTimes(2); - expect(changeThemeSpy).toHaveBeenNthCalledWith(1, Theme.LIGHT); - expect(changeThemeSpy).toHaveBeenNthCalledWith(2, Theme.DARK); - }); - it('should dispose its listeners and subscriptions when destroyed', () => { fixture.detectChanges(); const resizeObserverDisconnectSpy = jest.spyOn(comp.resizeObserver!, 'disconnect'); - const themeSubscriptionUnsubscribeSpy = jest.spyOn(comp.themeSubscription!, 'unsubscribe'); const listenerDisposeSpies = comp.listeners.map((listener) => jest.spyOn(listener, 'dispose')); comp.ngOnDestroy(); - for (const spy of [resizeObserverDisconnectSpy, themeSubscriptionUnsubscribeSpy, ...listenerDisposeSpies]) { + for (const spy of [resizeObserverDisconnectSpy, ...listenerDisposeSpies]) { expect(spy).toHaveBeenCalledOnce(); } }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action-quiz.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action-quiz.integration.spec.ts index 06d99f3ae996..a759b1da24fc 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action-quiz.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action-quiz.integration.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { ArtemisTestModule } from '../../../test.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; import { InsertShortAnswerOptionAction } from 'app/shared/monaco-editor/model/actions/quiz/insert-short-answer-option.action'; import { InsertShortAnswerSpotAction } from 'app/shared/monaco-editor/model/actions/quiz/insert-short-answer-spot.action'; @@ -20,9 +19,7 @@ describe('MonacoEditorActionQuizIntegration', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [MonacoEditorComponent], - providers: [], + imports: [ArtemisTestModule, MonacoEditorComponent], }) .compileComponents() .then(() => { diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts index 730f84f110f1..f4fe90f07a2c 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { ArtemisTestModule } from '../../../test.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; import { BoldAction } from 'app/shared/monaco-editor/model/actions/bold.action'; import { TextEditorAction } from 'app/shared/monaco-editor/model/actions/text-editor-action.model'; @@ -29,9 +28,7 @@ describe('MonacoEditorActionIntegration', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [MonacoEditorComponent], - providers: [], + imports: [ArtemisTestModule, MonacoEditorComponent], }) .compileComponents() .then(() => { diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts index 9fa5c2b391c5..862de1bdc8a7 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-communication-action.integration.spec.ts @@ -43,6 +43,7 @@ describe('MonacoEditorCommunicationActionIntegration', () => { beforeEach(() => { return TestBed.configureTestingModule({ + imports: [MonacoEditorComponent], providers: [ { provide: MetisService, useClass: MockMetisService }, { provide: TranslateService, useClass: MockTranslateService }, @@ -51,7 +52,6 @@ describe('MonacoEditorCommunicationActionIntegration', () => { MockProvider(CourseManagementService), MockProvider(ChannelService), ], - declarations: [MonacoEditorComponent], }) .compileComponents() .then(() => { @@ -60,7 +60,6 @@ describe('MonacoEditorCommunicationActionIntegration', () => { }); fixture = TestBed.createComponent(MonacoEditorComponent); comp = fixture.componentInstance; - // debugElement = fixture.debugElement; metisService = TestBed.inject(MetisService); courseManagementService = TestBed.inject(CourseManagementService); lectureService = TestBed.inject(LectureService); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-grading-instructions.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-grading-instructions.integration.spec.ts index 8d608dc314b6..ab99555cc7d6 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-grading-instructions.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-grading-instructions.integration.spec.ts @@ -1,6 +1,5 @@ import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { ArtemisTestModule } from '../../../test.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { GradingInstructionAction } from 'app/shared/monaco-editor/model/actions/grading-criteria/grading-instruction.action'; @@ -17,8 +16,7 @@ describe('MonacoEditorActionGradingInstructionsIntegration', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [MonacoEditorComponent], + imports: [ArtemisTestModule, MonacoEditorComponent], providers: [], }) .compileComponents() diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts index ae34f743a3a7..df228c9b340c 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts @@ -1,10 +1,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { ArtemisTestModule } from '../../../test.module'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { MonacoEditorComponent } from 'app/shared/monaco-editor/monaco-editor.component'; import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; -import { Theme, ThemeService } from 'app/core/theme/theme.service'; -import { BehaviorSubject } from 'rxjs'; import { MonacoEditorBuildAnnotationType } from 'app/shared/monaco-editor/model/monaco-editor-build-annotation.model'; import { MonacoCodeEditorElement } from 'app/shared/monaco-editor/model/monaco-code-editor-element.model'; import { MonacoEditorLineDecorationsHoverButton } from 'app/shared/monaco-editor/model/monaco-editor-line-decorations-hover-button.model'; @@ -14,7 +11,6 @@ import { MonacoEditorOptionPreset } from 'app/shared/monaco-editor/model/monaco- describe('MonacoEditorComponent', () => { let fixture: ComponentFixture; let comp: MonacoEditorComponent; - let mockThemeService: ThemeService; const singleLineText = 'public class Main { }'; const multiLineText = ['public class Main {', 'static void main() {', 'foo();', '}', '}'].join('\n'); @@ -23,13 +19,10 @@ describe('MonacoEditorComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule], - declarations: [MonacoEditorComponent], - providers: [], + imports: [ArtemisTestModule, MonacoEditorComponent], }) .compileComponents() .then(() => { - mockThemeService = TestBed.inject(ThemeService); fixture = TestBed.createComponent(MonacoEditorComponent); comp = fixture.componentInstance; global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { @@ -59,7 +52,7 @@ describe('MonacoEditorComponent', () => { it('should only send a notification once per delay interval', fakeAsync(() => { const delay = 1000; const valueCallbackStub = jest.fn(); - comp.textChangedEmitDelay = delay; + fixture.componentRef.setInput('textChangedEmitDelay', delay); fixture.detectChanges(); comp.textChanged.subscribe(valueCallbackStub); comp.setText('too early'); @@ -70,36 +63,14 @@ describe('MonacoEditorComponent', () => { })); it('should be set to readOnly depending on the input', () => { - comp.readOnly = true; + fixture.componentRef.setInput('readOnly', true); fixture.detectChanges(); expect(comp.isReadOnly()).toBeTrue(); - comp.readOnly = false; + fixture.componentRef.setInput('readOnly', false); fixture.detectChanges(); expect(comp.isReadOnly()).toBeFalse(); }); - it('should adjust its theme to the global theme', () => { - const themeSubject = new BehaviorSubject(Theme.LIGHT); - const subscribeStub = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); - const changeThemeSpy = jest.spyOn(comp, 'changeTheme'); - fixture.detectChanges(); - themeSubject.next(Theme.DARK); - expect(subscribeStub).toHaveBeenCalledOnce(); - expect(changeThemeSpy).toHaveBeenCalledTimes(2); - expect(changeThemeSpy).toHaveBeenNthCalledWith(1, Theme.LIGHT); - expect(changeThemeSpy).toHaveBeenNthCalledWith(2, Theme.DARK); - }); - - it('should unsubscribe from the global theme when destroyed', () => { - const themeSubject = new BehaviorSubject(Theme.LIGHT); - const subscribeStub = jest.spyOn(mockThemeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); - fixture.detectChanges(); - const unsubscribeStub = jest.spyOn(comp.themeSubscription!, 'unsubscribe').mockImplementation(); - comp.ngOnDestroy(); - expect(subscribeStub).toHaveBeenCalledOnce(); - expect(unsubscribeStub).toHaveBeenCalledOnce(); - }); - it('should display hidden line widgets', () => { const lineWidgetDiv = document.createElement('div'); // This is the case e.g. for feedback items. @@ -118,9 +89,9 @@ describe('MonacoEditorComponent', () => { comp.setAnnotations(buildAnnotationArray, false); comp.setText(multiLineText); const element = document.getElementById(buildAnnotationId); - expect(comp.editorBuildAnnotations).toHaveLength(1); + expect(comp.buildAnnotations).toHaveLength(1); expect(element).not.toBeNull(); - expect(element).toEqual(comp.editorBuildAnnotations[0].getGlyphMarginDomNode()); + expect(element).toEqual(comp.buildAnnotations[0].getGlyphMarginDomNode()); }); it('should not display build annotations that are out of bounds', () => { @@ -130,28 +101,28 @@ describe('MonacoEditorComponent', () => { comp.setAnnotations(buildAnnotationArray, false); comp.setText(singleLineText); const element = document.getElementById(buildAnnotationId); - expect(comp.editorBuildAnnotations).toHaveLength(1); + expect(comp.buildAnnotations).toHaveLength(1); // Ensure that the element is actually there, but not displayed in the DOM. expect(element).toBeNull(); - expect(comp.editorBuildAnnotations[0].getGlyphMarginDomNode().id).toBe(buildAnnotationId); + expect(comp.buildAnnotations[0].getGlyphMarginDomNode().id).toBe(buildAnnotationId); }); it('should mark build annotations as outdated if specified', () => { fixture.detectChanges(); comp.setText(multiLineText); comp.setAnnotations(buildAnnotationArray, true); - expect(comp.editorBuildAnnotations).toHaveLength(1); - expect(comp.editorBuildAnnotations[0].isOutdated()).toBeTrue(); + expect(comp.buildAnnotations).toHaveLength(1); + expect(comp.buildAnnotations[0].isOutdated()).toBeTrue(); }); it('should mark build annotations as outdated when a keyboard input is made', () => { fixture.detectChanges(); comp.setText(multiLineText); comp.setAnnotations(buildAnnotationArray, false); - expect(comp.editorBuildAnnotations).toHaveLength(1); - expect(comp.editorBuildAnnotations[0].isOutdated()).toBeFalse(); + expect(comp.buildAnnotations).toHaveLength(1); + expect(comp.buildAnnotations[0].isOutdated()).toBeFalse(); comp.triggerKeySequence('typing'); - expect(comp.editorBuildAnnotations[0].isOutdated()).toBeTrue(); + expect(comp.buildAnnotations[0].isOutdated()).toBeTrue(); }); it('should highlight line ranges with the specified classnames', () => { @@ -205,7 +176,7 @@ describe('MonacoEditorComponent', () => { }); it('should not allow editing in readonly mode', () => { - comp.readOnly = true; + fixture.componentRef.setInput('readOnly', true); fixture.detectChanges(); comp.setText(singleLineText); comp.triggerKeySequence('some ignored input'); @@ -218,7 +189,7 @@ describe('MonacoEditorComponent', () => { comp.addLineWidget(1, 'widget', document.createElement('div')); comp.setLineDecorationsHoverButton('testClass', jest.fn()); comp.highlightLines(1, 1); - const disposeAnnotationSpy = jest.spyOn(comp.editorBuildAnnotations[0], 'dispose'); + const disposeAnnotationSpy = jest.spyOn(comp.buildAnnotations[0], 'dispose'); const disposeWidgetSpy = jest.spyOn(comp.lineWidgets[0], 'dispose'); const disposeHoverButtonSpy = jest.spyOn(comp.lineDecorationsHoverButton!, 'dispose'); const disposeLineHighlightSpy = jest.spyOn(comp.lineHighlights[0], 'dispose'); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts new file mode 100644 index 000000000000..82ffd4e2b8b3 --- /dev/null +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.service.spec.ts @@ -0,0 +1,69 @@ +import { TestBed } from '@angular/core/testing'; +import * as monaco from 'monaco-editor'; +import { Theme, ThemeService } from 'app/core/theme/theme.service'; +import { MonacoEditorService } from '../../../../../../main/webapp/app/shared/monaco-editor/monaco-editor.service'; +import { ArtemisTestModule } from '../../../test.module'; +import { CUSTOM_MARKDOWN_LANGUAGE_ID } from 'app/shared/monaco-editor/model/languages/monaco-custom-markdown.language'; +import { BehaviorSubject } from 'rxjs'; +import { MockResizeObserver } from '../../../helpers/mocks/service/mock-resize-observer'; + +describe('MonacoEditorService', () => { + let monacoEditorService: MonacoEditorService; + let setThemeSpy: jest.SpyInstance; + let registerLanguageSpy: jest.SpyInstance; + const themeSubject = new BehaviorSubject(Theme.LIGHT); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + }); + // Avoids an error with the diff editor, which uses a ResizeObserver. + global.ResizeObserver = jest.fn().mockImplementation((callback: ResizeObserverCallback) => { + return new MockResizeObserver(callback); + }); + registerLanguageSpy = jest.spyOn(monaco.languages, 'register'); + setThemeSpy = jest.spyOn(monaco.editor, 'setTheme'); + const themeService = TestBed.inject(ThemeService); + jest.spyOn(themeService, 'getCurrentThemeObservable').mockReturnValue(themeSubject.asObservable()); + monacoEditorService = TestBed.inject(MonacoEditorService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should register the custom markdown language', () => { + const customMarkdownLanguage = monaco.languages.getLanguages().find((l) => l.id === CUSTOM_MARKDOWN_LANGUAGE_ID); + expect(customMarkdownLanguage).toBeDefined(); + expect(registerLanguageSpy).toHaveBeenCalledExactlyOnceWith({ id: customMarkdownLanguage!.id }); + }); + + it('should correctly handle themes', () => { + // Initialization: The editor should be in light mode since that is what we initialized the themeSubject with + expect(setThemeSpy).toHaveBeenCalledExactlyOnceWith(MonacoEditorService.LIGHT_THEME_ID); + // Switch to dark theme + themeSubject.next(Theme.DARK); + TestBed.flushEffects(); + expect(setThemeSpy).toHaveBeenCalledTimes(2); + expect(setThemeSpy).toHaveBeenNthCalledWith(2, MonacoEditorService.DARK_THEME_ID); + // Switch back to light theme + themeSubject.next(Theme.LIGHT); + TestBed.flushEffects(); + expect(setThemeSpy).toHaveBeenCalledTimes(3); + expect(setThemeSpy).toHaveBeenNthCalledWith(3, MonacoEditorService.LIGHT_THEME_ID); + }); + + it.each([ + { className: 'monaco-editor', createFn: (element: HTMLElement) => monacoEditorService.createStandaloneCodeEditor(element) }, + { className: 'monaco-diff-editor', createFn: (element: HTMLElement) => monacoEditorService.createStandaloneDiffEditor(element) }, + ])( + 'should insert an editor ($className) into the provided DOM element', + ({ className, createFn }: { className: string; createFn: (element: HTMLElement) => monaco.editor.IStandaloneCodeEditor | monaco.editor.IStandaloneDiffEditor }) => { + const element = document.createElement('div'); + const editor = createFn(element); + expect(editor.getContainerDomNode()).toBe(element); + expect(element.children).toHaveLength(1); + expect(element.children.item(0)!.classList).toContain(className); + }, + ); +}); diff --git a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts index d8f9e0bb8126..406dce4f6a5c 100644 --- a/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts +++ b/src/test/javascript/spec/integration/code-editor/code-editor-container.integration.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed, discardPeriodicTasks, fakeAsync, flush, tick } from '@angular/core/testing'; import { LocalStorageService, SessionStorageService } from 'ngx-webstorage'; import dayjs from 'dayjs/esm'; -import { ChangeDetectorRef, DebugElement } from '@angular/core'; +import { DebugElement } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { NgModel } from '@angular/forms'; import { NgbDropdown, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; @@ -73,8 +73,8 @@ import { TreeviewItemComponent } from 'app/exercises/programming/shared/code-edi import { CodeEditorHeaderComponent } from 'app/exercises/programming/shared/code-editor/header/code-editor-header.component'; import { AlertService } from 'app/core/util/alert.service'; import { MockResizeObserver } from '../../helpers/mocks/service/mock-resize-observer'; -import { MonacoEditorModule } from 'app/shared/monaco-editor/monaco-editor.module'; import { CodeEditorMonacoComponent } from 'app/exercises/programming/shared/code-editor/monaco/code-editor-monaco.component'; +import { MonacoEditorComponent } from '../../../../../main/webapp/app/shared/monaco-editor/monaco-editor.component'; describe('CodeEditorContainerIntegration', () => { let container: CodeEditorContainerComponent; @@ -100,7 +100,7 @@ describe('CodeEditorContainerIntegration', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule, MonacoEditorModule, MockDirective(NgbDropdown), MockModule(NgbTooltipModule)], + imports: [ArtemisTestModule, MonacoEditorComponent, MockDirective(NgbDropdown), MockModule(NgbTooltipModule)], declarations: [ CodeEditorContainerComponent, MockComponent(CodeEditorGridComponent), @@ -125,7 +125,6 @@ describe('CodeEditorContainerIntegration', () => { MockComponent(CodeEditorTutorAssessmentInlineFeedbackComponent), ], providers: [ - ChangeDetectorRef, CodeEditorConflictStateService, MockProvider(AlertService), { provide: ActivatedRoute, useClass: MockActivatedRouteWithSubjects },