From cbcb0afa7d1fa72f775b5a31784f3b1b12e72602 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sun, 13 Oct 2024 17:09:29 +0200 Subject: [PATCH 01/85] Development: Fix exam assessment e2e tests failing (#9462) --- src/test/playwright/e2e/exam/ExamAssessment.spec.ts | 2 +- src/test/playwright/e2e/exam/ExamDateVerification.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts index 3bfee439d546..4cd492e86daa 100644 --- a/src/test/playwright/e2e/exam/ExamAssessment.spec.ts +++ b/src/test/playwright/e2e/exam/ExamAssessment.spec.ts @@ -332,7 +332,6 @@ export async function prepareExam(course: Course, end: dayjs.Dayjs, exerciseType gracePeriod: 10, }; exam = await examAPIRequests.createExam(examConfig); - await examAPIRequests.registerStudentForExam(exam, studentOne); let additionalData = {}; switch (exerciseType) { case ExerciseType.PROGRAMMING: @@ -347,6 +346,7 @@ export async function prepareExam(course: Course, end: dayjs.Dayjs, exerciseType } const exercise = await examExerciseGroupCreation.addGroupWithExercise(exam, exerciseType, additionalData); + await examAPIRequests.registerStudentForExam(exam, studentOne); await examAPIRequests.generateMissingIndividualExams(exam); await examAPIRequests.prepareExerciseStartForExam(exam); exercise.additionalData = additionalData; diff --git a/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts b/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts index bf3f981640b0..48d80de67065 100644 --- a/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts +++ b/src/test/playwright/e2e/exam/ExamDateVerification.spec.ts @@ -68,9 +68,9 @@ test.describe('Exam date verification', () => { endDate: dayjs().add(3, 'days'), }; const exam = await examAPIRequests.createExam(examConfig); - await examAPIRequests.registerStudentForExam(exam, studentOne); const exerciseGroup = await examAPIRequests.addExerciseGroupForExam(exam); const exercise = await exerciseAPIRequests.createTextExercise({ exerciseGroup }); + await examAPIRequests.registerStudentForExam(exam, studentOne); await examAPIRequests.generateMissingIndividualExams(exam); await examAPIRequests.prepareExerciseStartForExam(exam); await login(studentOne); @@ -105,9 +105,9 @@ test.describe('Exam date verification', () => { endDate: examEnd, }; const exam = await examAPIRequests.createExam(examConfig); - await examAPIRequests.registerStudentForExam(exam, studentOne); const exerciseGroup = await examAPIRequests.addExerciseGroupForExam(exam); const exercise = await exerciseAPIRequests.createTextExercise({ exerciseGroup }); + await examAPIRequests.registerStudentForExam(exam, studentOne); await examAPIRequests.generateMissingIndividualExams(exam); await examAPIRequests.prepareExerciseStartForExam(exam); await login(studentOne); From f01378426daaac15443a1844409f2cc8ba773d68 Mon Sep 17 00:00:00 2001 From: Paul Rangger <48455539+PaRangger@users.noreply.github.com> Date: Sun, 13 Oct 2024 17:23:34 +0200 Subject: [PATCH 02/85] Communication: Fix user interface reload on channel selection (#9464) --- .../course-conversations.component.ts | 8 +------- .../conversation-messages.component.html | 3 ++- .../conversation-messages.component.scss | 4 ++++ 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts index 159818511aa8..2d56484f8a46 100644 --- a/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts +++ b/src/main/webapp/app/overview/course-conversations/course-conversations.component.ts @@ -156,11 +156,11 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { this.subscribeToIsCodeOfConductAccepted(); this.subscribeToIsCodeOfConductPresented(); this.subscribeToConversationsOfUser(); - this.subscribeToLoading(); this.updateQueryParameters(); this.prepareSidebarData(); this.metisConversationService.checkIsCodeOfConductAccepted(this.course!); this.isServiceSetUp = true; + this.isLoading = false; } }); @@ -224,12 +224,6 @@ export class CourseConversationsComponent implements OnInit, OnDestroy { }); } - private subscribeToLoading() { - this.metisConversationService.isLoading$.pipe(takeUntil(this.ngUnsubscribe)).subscribe((isLoading: boolean) => { - this.isLoading = isLoading; - }); - } - acceptCodeOfConduct() { if (this.course) { this.metisConversationService.acceptCodeOfConduct(this.course); diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html index 373d7b8d9694..f2931fa40d90 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.html @@ -67,8 +67,9 @@
diff --git a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss index 01f09f46008e..d1beda749f53 100644 --- a/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss +++ b/src/main/webapp/app/overview/course-conversations/layout/conversation-messages/conversation-messages.component.scss @@ -59,4 +59,8 @@ padding-top: 100px; padding-bottom: 100px; } + + .conversation-messages-message-list.is-fetching-posts { + display: none; + } } From e072169c67c82f338a20ae76c7abb0e7f352eca6 Mon Sep 17 00:00:00 2001 From: Patrik Zander <38403547+pzdr7@users.noreply.github.com> Date: Sun, 13 Oct 2024 17:26:27 +0200 Subject: [PATCH 03/85] Programming exercises: Add custom themes for the Monaco editor (#9463) --- .../model/themes/editor-colors.interface.ts | 86 +++++++++++++++++++ ...nguage-token-style-definition.interface.ts | 27 ++++++ .../model/themes/monaco-dark.theme.ts | 47 ++++++++++ .../model/themes/monaco-editor-theme.model.ts | 66 ++++++++++++++ .../model/themes/monaco-light.theme.ts | 46 ++++++++++ .../monaco-theme-definition.interface.ts | 9 ++ .../monaco-editor.component.scss | 5 ++ .../monaco-editor/monaco-editor.service.ts | 33 +++++-- .../monaco-editor/monaco-editor-theme.spec.ts | 67 +++++++++++++++ .../monaco-editor.service.spec.ts | 8 +- 10 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/editor-colors.interface.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/language-token-style-definition.interface.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/monaco-dark.theme.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/monaco-editor-theme.model.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/monaco-light.theme.ts create mode 100644 src/main/webapp/app/shared/monaco-editor/model/themes/monaco-theme-definition.interface.ts create mode 100644 src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-theme.spec.ts diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/editor-colors.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/editor-colors.interface.ts new file mode 100644 index 000000000000..a89139f285ee --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/editor-colors.interface.ts @@ -0,0 +1,86 @@ +/** + * Interface for the colors of the editor. + * See https://code.visualstudio.com/api/references/theme-color + * All colors must be in the format '#RRGGBB' or '#RRGGBBAA'. + */ +export interface EditorColors { + /** + * The background color of the editor. + */ + backgroundColor?: string; + /** + * The default color of all text in the editor, not including syntax highlighting. + */ + foregroundColor?: string; + /** + * Colors for line numbers in the editor. + */ + lineNumbers?: { + /** + * The color of the line numbers. + */ + foregroundColor?: string; + /** + * The color of the line number of the line that the cursor is on. + */ + activeForegroundColor?: string; + /** + * The color of the line numbers for dimmed lines. This is used for the final newline of the code. + */ + dimmedForegroundColor?: string; + }; + /** + * Colors for the active line highlight in the editor. + */ + lineHighlight?: { + /** + * The color used as the background color for the cursor's current line. + */ + backgroundColor?: string; + /** + * The color used for the border of the cursor's current line. + */ + borderColor?: string; + }; + /** + * Colors for the diff editor. + */ + diff?: { + /** + * The background color for inserted lines in the diff editor. + */ + insertedLineBackgroundColor?: string; + /** + * The background color for inserted text in the diff editor. + * This will overlap with the `insertedLineBackgroundColor`. + */ + insertedTextBackgroundColor?: string; + /** + * The background color for removed lines in the diff editor. + */ + removedTextBackgroundColor?: string; + /** + * The background color for removed text in the diff editor. + * This will overlap with the `removedLineBackgroundColor`. + */ + removedLineBackgroundColor?: string; + /** + * The color used for the diagonal fill in the diff editor. + * This is used when the diff editor pads the length of the files to align the lines of the original and modified files. + */ + diagonalFillColor?: string; + /** + * Colors for the diff editor gutter. This is the area to the left of the editor that shows the line numbers. + */ + gutter?: { + /** + * The background color for inserted lines in the diff editor gutter. + */ + insertedLineBackgroundColor?: string; + /** + * The background color for removed lines in the diff editor gutter. + */ + removedLineBackgroundColor?: string; + }; + }; +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/language-token-style-definition.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/language-token-style-definition.interface.ts new file mode 100644 index 000000000000..d811b868ad58 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/language-token-style-definition.interface.ts @@ -0,0 +1,27 @@ +/** + * Interface for the style of a token in a language. + * The editor applies these styles to the tokens in the specified language (or all languages), e.g. identifiers, keywords, etc. + */ +export interface LanguageTokenStyleDefinition { + /** + * The token to style, e.g. identifier + */ + token: string; + /** + * The language ID for which the token style should be applied. + * If not specified, the style is applied to all languages. + */ + languageId?: string; + /** + * The color of the text that should be applied to the token. + */ + foregroundColor?: string; + /** + * The background color that should be applied to the token. + */ + backgroundColor?: string; + /** + * The font style that should be applied to the token. + */ + fontStyle?: 'italic' | 'bold' | 'underline'; +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-dark.theme.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-dark.theme.ts new file mode 100644 index 000000000000..24dc4969284e --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-dark.theme.ts @@ -0,0 +1,47 @@ +import { MonacoThemeDefinition } from 'app/shared/monaco-editor/model/themes/monaco-theme-definition.interface'; + +export const MONACO_DARK_THEME_DEFINITION: MonacoThemeDefinition = { + id: 'custom-dark', + baseTheme: 'vs-dark', + tokenStyles: [ + { + token: 'keyword', + foregroundColor: '#ff7b72', + }, + { + token: 'comment', + foregroundColor: '#9198a1', + }, + { + token: 'string', + foregroundColor: '#a5d6ff', + }, + { + token: 'number', + foregroundColor: '#79c0ff', + }, + ], + editorColors: { + backgroundColor: '#181a18', + lineHighlight: { + borderColor: '#00000000', + backgroundColor: '#282a2e', + }, + lineNumbers: { + foregroundColor: '#ffffff', + activeForegroundColor: '#ffffff', + dimmedForegroundColor: '#ffffff', + }, + diff: { + insertedLineBackgroundColor: '#2ea04326', + insertedTextBackgroundColor: '#2ea04326', + removedLineBackgroundColor: '#f8514926', + removedTextBackgroundColor: '#f8514946', + diagonalFillColor: '#00000000', + gutter: { + insertedLineBackgroundColor: '#3fb9504d', + removedLineBackgroundColor: '#f851494d', + }, + }, + }, +}; diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-editor-theme.model.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-editor-theme.model.ts new file mode 100644 index 000000000000..c9f34f06b3de --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-editor-theme.model.ts @@ -0,0 +1,66 @@ +import { MonacoThemeDefinition } from 'app/shared/monaco-editor/model/themes/monaco-theme-definition.interface'; +import * as monaco from 'monaco-editor'; + +export class MonacoEditorTheme { + constructor(private readonly themeDefinition: MonacoThemeDefinition) {} + + getId(): string { + return this.themeDefinition.id; + } + + /** + * Creates a new record without any entries that have a value of `undefined`. + * @param record The record whose keys to filter. + * @returns The new record, only containing keys with defined values. + * @private + */ + private getRecordWithoutUndefinedEntries(record: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; + } + + register(): void { + const colorDefinitions = this.themeDefinition.editorColors; + // The color keys are available here: https://code.visualstudio.com/api/references/theme-color + const colors = { + 'editor.background': colorDefinitions.backgroundColor, + 'editor.foreground': colorDefinitions.foregroundColor, + 'editorLineNumber.foreground': colorDefinitions.lineNumbers?.foregroundColor, + 'editorLineNumber.activeForeground': colorDefinitions.lineNumbers?.activeForegroundColor, + 'editorLineNumber.dimmedForeground': colorDefinitions.lineNumbers?.dimmedForegroundColor, + 'editor.lineHighlightBackground': colorDefinitions.lineHighlight?.backgroundColor, + 'editor.lineHighlightBorder': colorDefinitions.lineHighlight?.borderColor, + 'diffEditor.insertedLineBackground': colorDefinitions.diff?.insertedLineBackgroundColor, + 'diffEditor.insertedTextBackground': colorDefinitions.diff?.insertedTextBackgroundColor, + 'diffEditor.removedTextBackground': colorDefinitions.diff?.removedTextBackgroundColor, + 'diffEditor.removedLineBackground': colorDefinitions.diff?.removedLineBackgroundColor, + 'diffEditor.diagonalFill': colorDefinitions.diff?.diagonalFillColor, + 'diffEditorGutter.insertedLineBackground': colorDefinitions.diff?.gutter?.insertedLineBackgroundColor, + 'diffEditorGutter.removedLineBackground': colorDefinitions.diff?.gutter?.removedLineBackgroundColor, + }; + + const tokenStyleDefinitions = this.themeDefinition.tokenStyles; + const rules = tokenStyleDefinitions.map((tokenDefinition) => { + // Language-specific tokens have the key `token.languageId`, e.g. keyword.custom-md + return { + token: `${tokenDefinition.token}${tokenDefinition.languageId ? '.' + tokenDefinition.languageId : ''}`, + foreground: tokenDefinition.foregroundColor, + background: tokenDefinition.backgroundColor, + fontStyle: tokenDefinition.fontStyle, + }; + }); + + // We cannot pass undefined colors to Monaco, so we filter them out to preserve the default values. + monaco.editor.defineTheme(this.getId(), { + base: this.themeDefinition.baseTheme, + inherit: true, + rules: rules, + colors: this.getRecordWithoutUndefinedEntries(colors), + }); + } +} diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-light.theme.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-light.theme.ts new file mode 100644 index 000000000000..53d265ab5766 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-light.theme.ts @@ -0,0 +1,46 @@ +import { MonacoThemeDefinition } from 'app/shared/monaco-editor/model/themes/monaco-theme-definition.interface'; + +export const MONACO_LIGHT_THEME_DEFINITION: MonacoThemeDefinition = { + id: 'custom-light', + baseTheme: 'vs', + tokenStyles: [ + { + token: 'keyword', + foregroundColor: '#cf222e', + }, + { + token: 'comment', + foregroundColor: '#59636e', + }, + { + token: 'string', + foregroundColor: '#0a3069', + }, + { + token: 'number', + foregroundColor: '#0550ae', + }, + ], + editorColors: { + lineHighlight: { + borderColor: '#00000000', + backgroundColor: '#e8e8e8', + }, + lineNumbers: { + foregroundColor: '#000000', + activeForegroundColor: '#000000', + dimmedForegroundColor: '#000000', + }, + diff: { + insertedLineBackgroundColor: '#dafbe1e6', + insertedTextBackgroundColor: '#aceebbe6', + removedLineBackgroundColor: '#ffebe9ef', + removedTextBackgroundColor: '#ff818250', + diagonalFillColor: '#00000000', + gutter: { + insertedLineBackgroundColor: '#d1f8d9', + removedLineBackgroundColor: '#ffcecb', + }, + }, + }, +}; diff --git a/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-theme-definition.interface.ts b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-theme-definition.interface.ts new file mode 100644 index 000000000000..0de6563077a1 --- /dev/null +++ b/src/main/webapp/app/shared/monaco-editor/model/themes/monaco-theme-definition.interface.ts @@ -0,0 +1,9 @@ +import { LanguageTokenStyleDefinition } from 'app/shared/monaco-editor/model/themes/language-token-style-definition.interface'; +import { EditorColors } from 'app/shared/monaco-editor/model/themes/editor-colors.interface'; + +export interface MonacoThemeDefinition { + id: string; + baseTheme: 'vs' | 'vs-dark' | 'hc-light' | 'hc-black'; + tokenStyles: LanguageTokenStyleDefinition[]; + editorColors: EditorColors; +} diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss index 96f11784298f..6886fcfcc0dc 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.scss @@ -3,6 +3,11 @@ .monaco-editor-container { width: 100%; height: 100%; + + .monaco-editor { + // Disables the focus border around the editor. + outline: none; + } } /* 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 index b16cb7cf6b18..c83734732223 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.service.ts @@ -3,6 +3,9 @@ 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'; +import { MONACO_LIGHT_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-light.theme'; +import { MonacoEditorTheme } from 'app/shared/monaco-editor/model/themes/monaco-editor-theme.model'; +import { MONACO_DARK_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-dark.theme'; /** * Service providing shared functionality for the Monaco editor. @@ -11,29 +14,41 @@ import { toSignal } from '@angular/core/rxjs-interop'; */ @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 }); + private lightTheme: MonacoEditorTheme; + private darkTheme: MonacoEditorTheme; + 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); + this.registerCustomThemes(); + this.registerCustomMarkdownLanguage(); effect(() => { this.applyTheme(this.currentTheme()); }); } + private registerCustomThemes(): void { + this.lightTheme = new MonacoEditorTheme(MONACO_LIGHT_THEME_DEFINITION); + this.darkTheme = new MonacoEditorTheme(MONACO_DARK_THEME_DEFINITION); + this.lightTheme.register(); + this.darkTheme.register(); + } + + private registerCustomMarkdownLanguage(): void { + 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); + } + /** * 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); + monaco.editor.setTheme(artemisTheme === Theme.LIGHT ? this.lightTheme.getId() : this.darkTheme.getId()); } /** @@ -79,6 +94,10 @@ export class MonacoEditorService { hideUnchangedRegions: { enabled: true, }, + guides: { + indentation: false, + }, + renderLineHighlight: 'none', fontSize: 12, }); } diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-theme.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-theme.spec.ts new file mode 100644 index 000000000000..da4a35cbdf18 --- /dev/null +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-theme.spec.ts @@ -0,0 +1,67 @@ +import * as monaco from 'monaco-editor'; +import { EditorColors } from 'app/shared/monaco-editor/model/themes/editor-colors.interface'; +import { LanguageTokenStyleDefinition } from 'app/shared/monaco-editor/model/themes/language-token-style-definition.interface'; +import { MonacoThemeDefinition } from 'app/shared/monaco-editor/model/themes/monaco-theme-definition.interface'; +import { MonacoEditorTheme } from 'app/shared/monaco-editor/model/themes/monaco-editor-theme.model'; + +describe('MonacoEditorTheme', () => { + const colorDefinitions: EditorColors = { + backgroundColor: '#181a18', + foregroundColor: '#ffffff', + diff: { + insertedLineBackgroundColor: '#2ea04326', + insertedTextBackgroundColor: '#2ea04326', + removedLineBackgroundColor: undefined, // Explicit undefined to test that it is removed before being passed to Monaco + removedTextBackgroundColor: undefined, + }, + }; + + const tokenStyleDefinitions: LanguageTokenStyleDefinition[] = [ + { + token: 'keyword', + foregroundColor: '#ff7b72', + }, + { + token: 'keyword', + languageId: 'custom-language-id', + foregroundColor: '#ffffff', + }, + ]; + + const themeDefinition: MonacoThemeDefinition = { + id: 'test-theme', + baseTheme: 'vs', + tokenStyles: tokenStyleDefinitions, + editorColors: colorDefinitions, + }; + + it('should correctly register a theme', () => { + const theme = new MonacoEditorTheme(themeDefinition); + const defineThemeSpy = jest.spyOn(monaco.editor, 'defineTheme'); + theme.register(); + expect(defineThemeSpy).toHaveBeenCalledExactlyOnceWith('test-theme', { + base: 'vs', + inherit: true, + rules: [ + { + token: 'keyword', + foreground: '#ff7b72', + background: undefined, + fontStyle: undefined, + }, + { + token: 'keyword.custom-language-id', + foreground: '#ffffff', + background: undefined, + fontStyle: undefined, + }, + ], + colors: { + 'editor.background': '#181a18', + 'editor.foreground': '#ffffff', + 'diffEditor.insertedLineBackground': '#2ea04326', + 'diffEditor.insertedTextBackground': '#2ea04326', + }, + }); + }); +}); 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 index 82ffd4e2b8b3..1a5a59e69436 100644 --- 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 @@ -6,6 +6,8 @@ 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'; +import { MONACO_LIGHT_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-light.theme'; +import { MONACO_DARK_THEME_DEFINITION } from 'app/shared/monaco-editor/model/themes/monaco-dark.theme'; describe('MonacoEditorService', () => { let monacoEditorService: MonacoEditorService; @@ -40,17 +42,17 @@ describe('MonacoEditorService', () => { 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); + expect(setThemeSpy).toHaveBeenCalledExactlyOnceWith(MONACO_LIGHT_THEME_DEFINITION.id); // Switch to dark theme themeSubject.next(Theme.DARK); TestBed.flushEffects(); expect(setThemeSpy).toHaveBeenCalledTimes(2); - expect(setThemeSpy).toHaveBeenNthCalledWith(2, MonacoEditorService.DARK_THEME_ID); + expect(setThemeSpy).toHaveBeenNthCalledWith(2, MONACO_DARK_THEME_DEFINITION.id); // Switch back to light theme themeSubject.next(Theme.LIGHT); TestBed.flushEffects(); expect(setThemeSpy).toHaveBeenCalledTimes(3); - expect(setThemeSpy).toHaveBeenNthCalledWith(3, MonacoEditorService.LIGHT_THEME_ID); + expect(setThemeSpy).toHaveBeenNthCalledWith(3, MONACO_LIGHT_THEME_DEFINITION.id); }); it.each([ From ca163670a56b8ad29f478b18497cfd57cdb95e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christoph=20Kn=C3=B6dlseder?= <53149143+chrisknedl@users.noreply.github.com> Date: Sun, 13 Oct 2024 20:34:55 +0200 Subject: [PATCH 04/85] Assessment: Remove unnecessary whitespace in result date (#9465) --- .../webapp/app/exercises/shared/result/result.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/webapp/app/exercises/shared/result/result.component.html b/src/main/webapp/app/exercises/shared/result/result.component.html index 911320c183df..65c4be9ca9de 100644 --- a/src/main/webapp/app/exercises/shared/result/result.component.html +++ b/src/main/webapp/app/exercises/shared/result/result.component.html @@ -61,7 +61,7 @@ } @if (!isInSidebarCard) { - ({{ result!.completionDate | artemisTimeAgo }} ) + ({{ result!.completionDate | artemisTimeAgo }}) } @if (hasBuildArtifact() && participation.type === ParticipationType.PROGRAMMING) { From 6549075fade341f3b890c684f74e8b6bbcd9b4ea Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sun, 13 Oct 2024 20:35:54 +0200 Subject: [PATCH 05/85] Development: Fix exam results overview e2e test (#9472) --- src/test/playwright/e2e/exam/ExamResults.spec.ts | 2 +- src/test/playwright/support/requests/ExamAPIRequests.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/playwright/e2e/exam/ExamResults.spec.ts b/src/test/playwright/e2e/exam/ExamResults.spec.ts index 874a49e23089..23168d2be415 100644 --- a/src/test/playwright/e2e/exam/ExamResults.spec.ts +++ b/src/test/playwright/e2e/exam/ExamResults.spec.ts @@ -82,7 +82,7 @@ test.describe('Exam Results', () => { } exercise = await examExerciseGroupCreation.addGroupWithExercise(exam, testCase.exerciseType, additionalData); await examAPIRequests.registerStudentForExam(exam, studentOne); - const studentExams = await examAPIRequests.generateMissingIndividualExams(exam); + const studentExams = await examAPIRequests.getAllStudentExams(exam); studentExam = studentExams[0]; await examAPIRequests.prepareExerciseStartForExam(exam); }); diff --git a/src/test/playwright/support/requests/ExamAPIRequests.ts b/src/test/playwright/support/requests/ExamAPIRequests.ts index 3e5e18a04a7d..34ffccb9a8fe 100644 --- a/src/test/playwright/support/requests/ExamAPIRequests.ts +++ b/src/test/playwright/support/requests/ExamAPIRequests.ts @@ -165,6 +165,15 @@ export class ExamAPIRequests { return await response.json(); } + /** + * Get all student-exams of an exam + * @param exam the exam for which the student-exams are fetched + */ + async getAllStudentExams(exam: Exam) { + const response = await this.page.request.get(`${COURSE_BASE}/${exam.course!.id}/exams/${exam.id}/student-exams`); + return await response.json(); + } + /** * Prepares individual exercises for exam start * @param exam the exam for which the exercises are prepared From ba666c44fabadee95cddbfcfdca88e6c611c5f26 Mon Sep 17 00:00:00 2001 From: Tim Cremer <65229601+cremertim@users.noreply.github.com> Date: Sun, 13 Oct 2024 22:27:58 +0200 Subject: [PATCH 06/85] Communication: Add default message to empty FAQ view (#9467) --- .../app/overview/course-faq/course-faq.component.html | 6 ++++++ .../webapp/app/overview/course-faq/course-faq.component.ts | 3 ++- src/main/webapp/i18n/de/faq.json | 4 +++- src/main/webapp/i18n/en/faq.json | 4 +++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.html b/src/main/webapp/app/overview/course-faq/course-faq.component.html index 115efb80cc1c..57e8599419e6 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.html +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.html @@ -27,9 +27,15 @@

+ @if (faqs.length === 0) { +

+ }
@for (faq of this.filteredFaqs; track faq) { }
+ @if (filteredFaqs.length === 0 && faqs.length > 0) { +

+ } diff --git a/src/main/webapp/app/overview/course-faq/course-faq.component.ts b/src/main/webapp/app/overview/course-faq/course-faq.component.ts index 03fd207fda21..db5a91e2c3d7 100644 --- a/src/main/webapp/app/overview/course-faq/course-faq.component.ts +++ b/src/main/webapp/app/overview/course-faq/course-faq.component.ts @@ -16,6 +16,7 @@ import { loadCourseFaqCategories } from 'app/faq/faq.utils'; import { CustomExerciseCategoryBadgeComponent } from 'app/shared/exercise-categories/custom-exercise-category-badge/custom-exercise-category-badge.component'; import { onError } from 'app/shared/util/global.utils'; import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.component'; +import { ArtemisMarkdownModule } from 'app/shared/markdown.module'; @Component({ selector: 'jhi-course-faq', @@ -23,7 +24,7 @@ import { SearchFilterComponent } from 'app/shared/search-filter/search-filter.co styleUrls: ['../course-overview.scss', 'course-faq.component.scss'], encapsulation: ViewEncapsulation.None, standalone: true, - imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent, SearchFilterComponent], + imports: [ArtemisSharedComponentModule, ArtemisSharedModule, CourseFaqAccordionComponent, CustomExerciseCategoryBadgeComponent, SearchFilterComponent, ArtemisMarkdownModule], }) export class CourseFaqComponent implements OnInit, OnDestroy { private ngUnsubscribe = new Subject(); diff --git a/src/main/webapp/i18n/de/faq.json b/src/main/webapp/i18n/de/faq.json index 0cb07d298310..987b093de2c7 100644 --- a/src/main/webapp/i18n/de/faq.json +++ b/src/main/webapp/i18n/de/faq.json @@ -20,7 +20,9 @@ "questionAnswer": "Antwort auf die Frage", "categories": "Kategorien" }, - "course": "Kurs" + "course": "Kurs", + "noExisting": "Momentan existiert für diesen Kurs noch kein FAQ.", + "noMatching": "Es gibt kein FAQ, das die Kombination aus Filter- und Suchkriterien erfüllt." } } } diff --git a/src/main/webapp/i18n/en/faq.json b/src/main/webapp/i18n/en/faq.json index 1a158eb52c40..3fd403409d2b 100644 --- a/src/main/webapp/i18n/en/faq.json +++ b/src/main/webapp/i18n/en/faq.json @@ -20,7 +20,9 @@ "questionAnswer": "Question answer", "categories": "Categories" }, - "course": "Course" + "course": "Course", + "noExisting": "Currently, there is no FAQ available for this course.", + "noMatching": "There is no FAQ that matches the combination of filter and search criteria." } } } From 45a18f8b33a3418aca85b592ad9683cafc38a111 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Sun, 13 Oct 2024 23:21:55 +0200 Subject: [PATCH 07/85] Programming exercises: Do not always show the request feedback button in the online code editor (#9475) --- .../request-feedback-button.component.html | 2 +- .../request-feedback-button.component.ts | 4 ++-- .../request-feedback-button.component.spec.ts | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html index 6d6addcc2b84..69310708cac3 100644 --- a/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html +++ b/src/main/webapp/app/overview/exercise-details/request-feedback-button/request-feedback-button.component.html @@ -1,4 +1,4 @@ -@if (!isExamExercise) { +@if (!isExamExercise && requestFeedbackEnabled) { @if (athenaEnabled) { @if (exercise().type === ExerciseType.TEXT) {
-
diff --git a/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html b/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html index ce2055d4c657..c80404948591 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar-card-item/sidebar-card-item.component.html @@ -1,6 +1,6 @@ @if (sidebarItem) { -
+
@if (sidebarType === 'exam') {
@@ -46,7 +46,7 @@
} @else { -
+
@if (sidebarItem.icon) { @@ -59,7 +59,7 @@ }
-
+
{{ sidebarItem.subtitleLeft }} diff --git a/src/main/webapp/app/shared/sidebar/sidebar-card-medium/sidebar-card-medium.component.html b/src/main/webapp/app/shared/sidebar/sidebar-card-medium/sidebar-card-medium.component.html index adfa1da77f82..4421540aa874 100644 --- a/src/main/webapp/app/shared/sidebar/sidebar-card-medium/sidebar-card-medium.component.html +++ b/src/main/webapp/app/shared/sidebar/sidebar-card-medium/sidebar-card-medium.component.html @@ -10,7 +10,7 @@ } @else {
-
+
@if (sidebarItem.conversation) { From 3b18dff54a69c2bc0ffb5a3560c35867debe5513 Mon Sep 17 00:00:00 2001 From: Yannik Schmidt Date: Fri, 18 Oct 2024 07:38:09 +0200 Subject: [PATCH 12/85] Development: Fix diverging translations (#9471) --- .../programming-exercise-difficulty.component.html | 6 ++++++ src/main/webapp/i18n/de/competency.json | 6 ++++++ .../webapp/i18n/de/dragAndDropSubmittedAnswer.json | 3 ++- src/main/webapp/i18n/de/editor.json | 1 + src/main/webapp/i18n/de/exam.json | 2 ++ src/main/webapp/i18n/de/exercise-actions.json | 1 + .../webapp/i18n/de/exerciseAssessmentDashboard.json | 1 + src/main/webapp/i18n/de/global.json | 2 ++ src/main/webapp/i18n/de/lectureUnit.json | 3 ++- src/main/webapp/i18n/de/metrics.json | 10 +++++++++- src/main/webapp/i18n/de/notification.json | 2 ++ src/main/webapp/i18n/de/organizationManagement.json | 2 +- src/main/webapp/i18n/de/programmingExercise.json | 7 +++++++ src/main/webapp/i18n/de/quizExercise.json | 6 ------ src/main/webapp/i18n/de/student-dashboard.json | 3 +++ src/main/webapp/i18n/de/userSettings.json | 2 +- src/main/webapp/i18n/en/competency.json | 1 + src/main/webapp/i18n/en/complaint.json | 1 + src/main/webapp/i18n/en/conversation.json | 1 + .../webapp/i18n/en/dragAndDropSubmittedAnswer.json | 1 + src/main/webapp/i18n/en/editor.json | 1 + src/main/webapp/i18n/en/exam.json | 2 ++ src/main/webapp/i18n/en/exampleSubmission.json | 1 + .../webapp/i18n/en/exerciseAssessmentDashboard.json | 1 + src/main/webapp/i18n/en/lectureUnit.json | 1 + src/main/webapp/i18n/en/modelingAssessment.json | 1 + src/main/webapp/i18n/en/multipleChoiceQuestion.json | 1 + src/main/webapp/i18n/en/notification.json | 2 ++ src/main/webapp/i18n/en/programmingExercise.json | 5 +++++ src/main/webapp/i18n/en/quizExercise.json | 6 ------ src/main/webapp/i18n/en/student-dashboard.json | 1 + .../programming-exercise-difficulty.component.spec.ts | 3 ++- 32 files changed, 68 insertions(+), 18 deletions(-) diff --git a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html index 8f66c32f99dd..59cc94f7c1ba 100644 --- a/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html +++ b/src/main/webapp/app/exercises/programming/manage/update/update-components/programming-exercise-difficulty.component.html @@ -42,6 +42,8 @@ ngbTooltip="{{ 'artemisApp.programmingExercise.allowOfflineIde.alertNoTheia' | artemisTranslate }}" /> } + } @else { + }
@@ -74,6 +76,8 @@ ngbTooltip="{{ 'artemisApp.programmingExercise.allowOnlineEditor.alertNoTheia' | artemisTranslate }}" /> } + } @else { + }
@@ -99,6 +103,8 @@ [placement]="'top'" ngbTooltip="{{ 'artemisApp.programmingExercise.allowOnlineIde.alert' | artemisTranslate }}" /> + } @else { + }
diff --git a/src/main/webapp/i18n/de/competency.json b/src/main/webapp/i18n/de/competency.json index 40c540dad5cb..6315ba4df79e 100644 --- a/src/main/webapp/i18n/de/competency.json +++ b/src/main/webapp/i18n/de/competency.json @@ -225,6 +225,7 @@ "titleUniqueValidationError": "Es gibt bereits eine Kompetenz/Voraussetzung mit diesem Titel in dem Kurs", "suggestedTaxonomy": "Vorschlag", "averageMastery": "Durchschnittliche Kompetenzbeherrschung der Studierenden", + "averageStudentScore": "Durchschnittliche Bewertung der Studierenden", "selectLecture": "Wähle eine Vorlesung aus", "noLectures": "Dieser Kurs hat keine Vorlesungen", "dropdown": "{{lectureTitle}} ({{ noOfConnectedUnits }} Vorlesungseinheiten verbunden)", @@ -281,6 +282,11 @@ "edit": { "title": "Bearbeite eine Voraussetzung" }, + "importAll": { + "title": "Alle Kompetenzen eines Kurses importieren", + "success": "{{ noOfCompetencies }} Kompetenzen aus dem Kurs \"{{ courseTitle }}\" importiert.", + "warning": "Es wurden keine Kompetenzen für den Kurs \"{{ courseTitle }}\" gefunden." + }, "competencyCard": { "delete": { "question": "Willst du wirklich die Voraussetzung {{ title }} löschen? Du kannst diese Aktion nicht rückgängig machen!", diff --git a/src/main/webapp/i18n/de/dragAndDropSubmittedAnswer.json b/src/main/webapp/i18n/de/dragAndDropSubmittedAnswer.json index 21bb95afa9a7..d7165bae8e1c 100644 --- a/src/main/webapp/i18n/de/dragAndDropSubmittedAnswer.json +++ b/src/main/webapp/i18n/de/dragAndDropSubmittedAnswer.json @@ -14,7 +14,8 @@ "detail": { "title": "Drag-and-Drop eingereichte Antwort" }, - "mappings": "Zuordnungen" + "mappings": "Zuordnungen", + "assignments": "Anweisungen" } } } diff --git a/src/main/webapp/i18n/de/editor.json b/src/main/webapp/i18n/de/editor.json index 1c1db6d76147..abd74a5c2b5f 100644 --- a/src/main/webapp/i18n/de/editor.json +++ b/src/main/webapp/i18n/de/editor.json @@ -64,6 +64,7 @@ "deleteFolderTitle": "Verzeichnis löschen", "deleteFileConfirm": "Bist Du sicher, dass Du die Datei {{filename}} löschen willst?", "deleteFolderConfirm": "Bist Du sicher, dass Du das Verzeichnis {{filename}} und alle enthaltenen Dateien löschen willst?", + "delete": "Löschen bestätigen", "unsavedChanges": "Datei enthält ungespeicherte Änderungen", "createFolderRoot": "Verzeichnis auf Root Level erstellen", "createFileRoot": "Datei auf Root Level erstellen", diff --git a/src/main/webapp/i18n/de/exam.json b/src/main/webapp/i18n/de/exam.json index bea5f259ab2a..c8644192874a 100644 --- a/src/main/webapp/i18n/de/exam.json +++ b/src/main/webapp/i18n/de/exam.json @@ -213,6 +213,7 @@ "examScores": { "xAxes": "Ergebnis in %", "xAxesSuffix": " + {Notenname}", + "xAxesSuffixNoBonus": " + {Notenname}", "xAxesSuffixBonus": " + {Bonuspunkte}", "yAxes": "Anzahl Teilnehmende", "highlightPassedMedian": "Hebe Median aller bestandenen Klausuren im Diagramm hervor: {{ median }}%", @@ -319,6 +320,7 @@ "notSynced": "Aufgabe nicht gespeichert", "notStarted": "Aufgabe nicht gestartet", "submitted": "Du hast eine Lösung für die Aufgabe eingereicht. Du kannst die Aufgabe weiter bearbeiten und erneut abgeben.", + "submittedSubmissionLimitReached": "Du hast eine Lösung für die Aufgabe eingereicht. Du kannst keine Lösungen mehr einreichen, weil du das Limit erreicht hast.", "notSubmitted": "Aufgabe nicht eingereicht", "notSavedOrSubmitted": "Du hast Änderungen, die nicht abgesendet sind.", "saveSubmissionError": "Die Änderungen konnten nicht gespeichert werden! Bitte stelle sicher, dass du online bist und speichere nochmal.", diff --git a/src/main/webapp/i18n/de/exercise-actions.json b/src/main/webapp/i18n/de/exercise-actions.json index b4f2ce5c25d4..f2050c3aef59 100644 --- a/src/main/webapp/i18n/de/exercise-actions.json +++ b/src/main/webapp/i18n/de/exercise-actions.json @@ -19,6 +19,7 @@ "openCodeEditor": "Programmiereditor öffnen", "openPracticeCodeEditor": "Programmiereditor zum Üben öffnen", "openGradedCodeEditor": "Programmiereditor öffnen", + "choseGradedMode": "Bewertete Teilnahme gewählt", "openModelingEditor": "Modellierungseditor öffnen", "importIntoIDE": "In deiner IDE öffnen", "openOnlineIDE": "Online IDE öffnen", diff --git a/src/main/webapp/i18n/de/exerciseAssessmentDashboard.json b/src/main/webapp/i18n/de/exerciseAssessmentDashboard.json index 532f09c8c8d2..768f8a2d863f 100644 --- a/src/main/webapp/i18n/de/exerciseAssessmentDashboard.json +++ b/src/main/webapp/i18n/de/exerciseAssessmentDashboard.json @@ -40,6 +40,7 @@ "toRead": "zum Lesen", "toReview": "zur Überprüfung", "totalExamples": "Es gibt {{total}} Beispielabgaben: {{toRead}} zum Lesen und {{toAssess}} zur Bewertung.", + "totalExampleSubmissions": "Es gibt {{total}} Beispielabgaben: {{toRead}} zum Lesen and {{toAssess}} zur Bewertung.", "readSubmissions": "Überprüfung von Beispielabgaben", "start": "", "continue": "", diff --git a/src/main/webapp/i18n/de/global.json b/src/main/webapp/i18n/de/global.json index b809dec61943..3b8195fea41d 100644 --- a/src/main/webapp/i18n/de/global.json +++ b/src/main/webapp/i18n/de/global.json @@ -35,6 +35,7 @@ "quizExercise": "Quiz Aufgabe", "lecture": "Vorlesung", "competency": "Kompetenz", + "ltiOutcomeUrl": "LTI-Outcome-URL", "submittedAnswer": "Eingereichte Antwort", "quizQuestion": "Frage", "multipleChoiceQuestion": "Multiple-Choice Frage", @@ -207,6 +208,7 @@ "confirm": "Bestätigen", "download": "Herunterladen", "dashboard": "Dashboard", + "exportRepos": "Repositories herunterladen", "units": "Einheiten", "edit": "Bearbeiten", "connect": "Verknüpfen", diff --git a/src/main/webapp/i18n/de/lectureUnit.json b/src/main/webapp/i18n/de/lectureUnit.json index 41fe6c3255fd..2ed9397e04ff 100644 --- a/src/main/webapp/i18n/de/lectureUnit.json +++ b/src/main/webapp/i18n/de/lectureUnit.json @@ -15,6 +15,7 @@ }, "releaseDate": "Veröffentlichungsdatum", "details": { + "title": "Eigenschaften", "releaseDateNotSet": "Kein Veröffentlichungsdatum gesetzt!", "releaseDateSet": "Veröffentlichungsdatum: ", "attachmentVersion": "Dateiversion: " @@ -86,7 +87,7 @@ "description": "Beschreibung", "notReleasedTooltip": "Onlineeinheit nur sichtbar für Tutor:innen und Lehrende. Veröffentlichungsdatum:", "doOpen": "Link öffnen", - "createVideoUnit": { + "createOnlineUnit": { "title": "Erstelle Onlineeinheit", "name": "Name", "namePlaceHolder": "Gib der Onlineeinheit einen Namen", diff --git a/src/main/webapp/i18n/de/metrics.json b/src/main/webapp/i18n/de/metrics.json index 4c6034248943..c564bdb825aa 100644 --- a/src/main/webapp/i18n/de/metrics.json +++ b/src/main/webapp/i18n/de/metrics.json @@ -80,7 +80,15 @@ "cachename": "Cache Name", "hits": "Treffer", "misses": "Keine Treffer", - "evictions": "Anzahl entfernter Objekte" + "gets": "Cache Gets", + "puts": "Cache Hinzufügungen", + "removals": "Cache Entfernungen", + "evictions": "Anzahl entfernter Objekte", + "hitPercent": "Cache Hit %", + "missPercent": "Cache Miss %", + "averageGetTime": "Durchschnitt get Zeit (µs)", + "averagePutTime": "Durchschnitt put Zeit (µs)", + "averageRemoveTime": "Durchschnitt remove Zeit (µs)" }, "datasource": { "usage": "Usage", diff --git a/src/main/webapp/i18n/de/notification.json b/src/main/webapp/i18n/de/notification.json index bc479b170f0b..5c8f606c432c 100644 --- a/src/main/webapp/i18n/de/notification.json +++ b/src/main/webapp/i18n/de/notification.json @@ -14,8 +14,10 @@ "showAllSavedNotifications": "Zeige alle gespeicherten Benachrichtigungen", "hideAllCurrentlyDisplayedNotifications": "Verstecke alle aktuell dargestellten Benachrichtigungen", "target": { + "newAnswer": "Antwort ansehen", "newAnswerPost": "Antwort ansehen", "newPost": "Beitrag ansehen", + "newQuestion": "Frage ansehen", "exerciseCreated": "Übung ansehen", "exerciseUpdated": "Übung ansehen", "attachmentUpdated": "Vorlesung ansehen" diff --git a/src/main/webapp/i18n/de/organizationManagement.json b/src/main/webapp/i18n/de/organizationManagement.json index a838d55db96d..c6b917d4d238 100644 --- a/src/main/webapp/i18n/de/organizationManagement.json +++ b/src/main/webapp/i18n/de/organizationManagement.json @@ -7,7 +7,7 @@ "url": "URL", "description": "Beschreibung", "logoUrl": "Logo URL", - "Nutzer:innens": "Nutzer:innen", + "users": "Nutzer:innen", "courses": "Kurse", "emailPattern": "E-Mail-Muster", "delete": { diff --git a/src/main/webapp/i18n/de/programmingExercise.json b/src/main/webapp/i18n/de/programmingExercise.json index d1bf21030478..387ffb3f548a 100644 --- a/src/main/webapp/i18n/de/programmingExercise.json +++ b/src/main/webapp/i18n/de/programmingExercise.json @@ -56,6 +56,7 @@ "steps": { "generalInfoStepTitle": "Informationen", "difficultyStepTitle": "Modus", + "buildPlansTitle": "Build-Pläne", "languageStepTitle": "Sprache", "gradingStepTitle": "Benotung", "problemStepTitle": "Problem", @@ -65,6 +66,7 @@ "generalInfoStepTitle": "Allgemein", "generalInfoStepMessage": "Eingabe von Informationen, die die Grundlage für die Programmieraufgabe bilden", "difficultyStepTitle": "Modus", + "buildPlansTitle": "Build-Pläne", "difficultyStepMessage": "Konfiguration der Einstellungen bezüglich der Bearbeitung und Kollaboration der Aufgabe", "languageStepTitle": "Sprache", "languageStepMessage": "Wähle die gewünschte Programmiersprache aus und konfiguriere zusätzliche Funktionen bezüglich der Build Umgebung", @@ -135,18 +137,21 @@ "workdir": "Verzeichnis", "allowOnlineEditor": { "title": "Online-Editor erlauben", + "description": "Ein Online-Texteditor mit Datei-Explorer und Code-Hervorhebungsfunktionen, jedoch ohne die Möglichkeit, Code zu kompilieren oder auszuführen.", "alert": "Es muss mindestens eine Option (Offline-IDE, Online-Editor oder Online-IDE) ausgewählt sein", "alertNoTheia": "Es muss mindestens eine Option (Offline-IDE oder Online-Editor) ausgewählt sein" }, "onlineEditor": "Online", "allowOfflineIde": { "title": "Offline-IDE erlauben", + "description": "Aktiviere diese Option, um den Studierenden zu erlauben, die Übung herunterzuladen und lokal mit der bevorzugten IDE daran zu arbeiten.", "alert": "Es muss mindestens eine Option (Offline-IDE, Online-Editor oder Online-IDE) ausgewählt sein", "alertNoTheia": "Es muss mindestens eine Option (Offline-IDE oder Online-Editor) ausgewählt sein" }, "offlineIde": "IDE", "allowOnlineIde": { "title": "Online-IDE erlauben", + "description": "Eine auf Visual Studio Code basierende Online-Entwicklungsumgebung mit Code-Hervorhebungs- und Formatierungsfunktionen. Vorkonfiguriert für die Sprache der Übung und mit der Möglichkeit, Code online zu kompilieren oder auszuführen.", "alert": "Es muss mindestens eine Option (Offline-IDE, Online-Editor oder Online-IDE) ausgewählt sein.", "alertNoTheia": "Es muss mindestens eine Option (Offline-IDE oder Online-Editor) ausgewählt sein" }, @@ -518,10 +523,12 @@ "repositoryName": "Name des Repositorys", "checkoutDirectory": "Checkout-Verzeichnis", "description": "Beschreibung", + "invalidRepositoryName": "Es existiert bereits ein Repository mit diesem Namen in der Aufgabe.", "duplicateRepositoryNames": "Verschiedene Hilfs-Repositories dürfen nicht den gleichen Namen haben.", "duplicateDirectoryNames": "Verschiedene Hilfs-Repositories dürfen nicht das gleiche Checkout-Verzeichnis haben.", "warning": "Hilfs-Repositories können hinzugefügt, editiert und gelöscht werden. Editierungen wirken sich allerdings nicht auf das VCS und CIS aus und müssen dort manuell durchgeführt werden.", "editedWarning": "Du hast die Hilfs-Repositories verändert. Diese Änderungen wirken sich allerdings nicht auf das VCS und CIS aus und müssen dort manuell durchgeführt werden.", + "editedWarningLocalCI": "Du hast die Hilfs-Repositories verändert. Diese Änderungen wirken sich allerdings nicht auf das VCS und CIS aus und müssen dort manuell durchgeführt werden.", "repositoryNameRequired": { "pattern": "Der Name eines Hilfs-Repositories darf nicht 'exercise', 'solution', 'tests', oder 'auxiliary' sein. Außerdem darf der Name nur Wörter und die Sonderzeichen '-' und '_' enthalten." }, diff --git a/src/main/webapp/i18n/de/quizExercise.json b/src/main/webapp/i18n/de/quizExercise.json index aa444778ac1c..d04f13e11078 100644 --- a/src/main/webapp/i18n/de/quizExercise.json +++ b/src/main/webapp/i18n/de/quizExercise.json @@ -308,12 +308,6 @@ "join": "Bearbeiten", "joinFailed": "Dem Quiz konnte nicht beigetreten werden.", "submitSuccess": "Deine Antworten wurden erfolgreich abgegeben.

Die Ergebnisse sind verfügbar, nachdem das Quiz beendet ist." - }, - "quizLiveModal": { - "title": "Go-Live einer neuen Quiz-Übung", - "body": "Das Quiz {{quizName}} für den Kurs {{courseName}} ist nun aktiv! Klick auf den 'Weiter zum Quiz' Button, um zum Quiz zu gelangen.", - "cancelButton": "Abbrechen", - "goToButton": "Weiter zum Quiz" } } } diff --git a/src/main/webapp/i18n/de/student-dashboard.json b/src/main/webapp/i18n/de/student-dashboard.json index 5906220b4d8a..1150bfc8c7f2 100644 --- a/src/main/webapp/i18n/de/student-dashboard.json +++ b/src/main/webapp/i18n/de/student-dashboard.json @@ -104,6 +104,7 @@ "achievablePoints": "Erreichbare Punktzahl", "applyFilter": "Filter anwenden", "resetFilter": "Filter zurücksetzen", + "clearFilter": "Filter entfernen", "noFilterAvailable": "Für die bisherigen Aufgaben gibt es keine unterscheidenden Filteroptionen", "noMoreOptions": "Keine weiteren Auswahlmöglichkeiten" }, @@ -136,6 +137,7 @@ "notReleased": "Nicht freigegeben", "live": "Live", "shortDueDate": "Bis", + "userNotAssignedToTeam": "Du wurdest noch keinem Team zugeteilt.", "userNotStartedExercise": "Du hast mit der Aufgabe noch nicht angefangen.", "exerciseSubmitted": "Bereits abgegeben, Änderungen sind noch möglich.", "exerciseSubmittedWaitingForGrading": "Bereits abgegeben, noch keine Bewertung.", @@ -148,6 +150,7 @@ "userParticipating": "Du nimmst gerade an diesem Quiz teil.", "userSubmitted": "Du hast bereits abgegeben.", "userNotStartedExerciseShort": "Nicht angefangen", + "userNotAssignedToTeamShort": "Noch kein Team", "exerciseNotSubmittedShort": "Nicht abgegeben", "exerciseMissedDueDateShort": "Frist abgelaufen", "quizNotStartedShort": "Nicht begonnen", diff --git a/src/main/webapp/i18n/de/userSettings.json b/src/main/webapp/i18n/de/userSettings.json index 6a2ebecc1763..0771455d4e2a 100644 --- a/src/main/webapp/i18n/de/userSettings.json +++ b/src/main/webapp/i18n/de/userSettings.json @@ -118,7 +118,7 @@ }, "settingDescriptions": { "basicWeeklySummaryDescription": "Erhalte jeden Freitag um 17:00 Uhr eine einfache wöchentliche Zusammenfassung (bsp. welche neuen Aufgaben diese Woche veröffentlicht wurden und noch bearbeitbar sind)", - "exerciseCreatedOrStartedDescription": "Erhalte eine Mitteilung, wenn eine neue Aufgabe veröffentlicht wurde", + "exerciseReleasedDescription": "Erhalte eine Mitteilung, wenn eine Aufgabe veröffentlicht wurde", "exerciseOpenForPracticeDescription": "Erhalte eine Mitteilung, wenn eine Aufgabe zum Üben freigeschaltet wurde (z.B. ein Quiz)", "exerciseSubmissionAssessedDescription": "Erhalte eine Mitteilung, wenn deine eingereichten Aufgaben bewertet wurden", "newExercisePostDescription": "Erhalte eine Mitteilung, wenn eine neue Nachricht zu einer Aufgabe erstellt wurde", diff --git a/src/main/webapp/i18n/en/competency.json b/src/main/webapp/i18n/en/competency.json index 9baf2d9d4975..2455994daccf 100644 --- a/src/main/webapp/i18n/en/competency.json +++ b/src/main/webapp/i18n/en/competency.json @@ -207,6 +207,7 @@ "create": { "titleUniqueValidationError": "There already exists a competency/prerequisite with this title in the course", "suggestedTaxonomy": "Suggested", + "averageMastery": "Average competency mastery of students", "averageStudentScore": "Average student mastery", "selectLecture": "Select a Lecture", "noLectures": "This course has no lectures", diff --git a/src/main/webapp/i18n/en/complaint.json b/src/main/webapp/i18n/en/complaint.json index 2d879e4213de..83d4d2cb4324 100644 --- a/src/main/webapp/i18n/en/complaint.json +++ b/src/main/webapp/i18n/en/complaint.json @@ -69,6 +69,7 @@ "info": "Submitting a feedback request does not affect your number of allowed complaints, but your score will remain unchanged.", "review": "Review More Feedback Request", "alreadyHandled": "The request has been resolved", + "alreadySubmitted": "You already requested more feedback", "alreadySubmittedSubmissionAuthor": "You have already requested more feedback", "alreadySubmittedNotSubmissionAuthor": "The student has already requested more feedback", "acceptedLong": "Request was answered", diff --git a/src/main/webapp/i18n/en/conversation.json b/src/main/webapp/i18n/en/conversation.json index 40cb47bb8f98..8ed306cca8f4 100644 --- a/src/main/webapp/i18n/en/conversation.json +++ b/src/main/webapp/i18n/en/conversation.json @@ -164,6 +164,7 @@ "maxError": "You can only add {{ max }} users", "addUserLabel": "Search for users in the course", "maxUsersNote": "Note: You can add {{ max }} users to this conversation", + "maxU": "Note: You can only add {{ max }} users to this conversation", "addIndividualUsers": "Add individual users", "addWholeGroups": "Add whole groups", "addAllStudents": "Add all students", diff --git a/src/main/webapp/i18n/en/dragAndDropSubmittedAnswer.json b/src/main/webapp/i18n/en/dragAndDropSubmittedAnswer.json index 497a35543bba..25dc947b1a90 100644 --- a/src/main/webapp/i18n/en/dragAndDropSubmittedAnswer.json +++ b/src/main/webapp/i18n/en/dragAndDropSubmittedAnswer.json @@ -14,6 +14,7 @@ "detail": { "title": "Drag And Drop Submitted Answer" }, + "mappings": "Mappings", "assignments": "Assignments" } } diff --git a/src/main/webapp/i18n/en/editor.json b/src/main/webapp/i18n/en/editor.json index 266ce0333d74..18442db3ca14 100644 --- a/src/main/webapp/i18n/en/editor.json +++ b/src/main/webapp/i18n/en/editor.json @@ -33,6 +33,7 @@ "building": "Building and testing...", "buildFailed": "Build failed", "noBuildOutput": "No build results available", + "generatingFeedback": "Generating feedback...", "selectFile": "Select a file to get started!", "binaryFileSelected": "This is a binary file that cannot be displayed.", "downloadBuildResult": "Download Build Result", diff --git a/src/main/webapp/i18n/en/exam.json b/src/main/webapp/i18n/en/exam.json index cb75f38dece0..691db17fe332 100644 --- a/src/main/webapp/i18n/en/exam.json +++ b/src/main/webapp/i18n/en/exam.json @@ -212,6 +212,7 @@ }, "examScores": { "xAxes": "Score in %", + "xAxesSuffix": " + {Grade Name}", "xAxesSuffixNoBonus": " + {Grade Name}", "xAxesSuffixBonus": " + {Bonus Points}", "yAxes": "Number of Participants", @@ -471,6 +472,7 @@ "examStudentReviewStart": "Begin of Student Review", "examStudentReviewEnd": "End of Student Review", "exampleSolutionPublicationDateTooltip": "Students can see the example solutions for all exercises in the exam from this time onwards if they participated in the exam. Leave blank to disable.", + "studentReviewEnabled": "Student review enabled", "maxPoints": { "title": "Maximum number of points for exam", "warning": "Updating this field will update the grading scale for the exam if such exists, such that the points values for the grade steps still match the percentages, adjusted for the new maximum points value. This also affects all students' grades in the exam!" diff --git a/src/main/webapp/i18n/en/exampleSubmission.json b/src/main/webapp/i18n/en/exampleSubmission.json index d63e75979fd0..63cf358ffd47 100644 --- a/src/main/webapp/i18n/en/exampleSubmission.json +++ b/src/main/webapp/i18n/en/exampleSubmission.json @@ -31,6 +31,7 @@ "readSuccessfully": "You have successfully read and understood this example submission", "createNew": "Create new", "pageHeader": "Example Text Submission for Exercise", + "usedForTutorial": "Use in Tutorial", "assessmentTraining": "Assessment Training", "selectModel": "Select Assessment Training Mode:", "selectModelExplanation": "You can select the assessment training mode here. The assessment training mode defines how the tutor has to confirm that the example was understood.", diff --git a/src/main/webapp/i18n/en/exerciseAssessmentDashboard.json b/src/main/webapp/i18n/en/exerciseAssessmentDashboard.json index cf2d3b1c574e..84d5206f2ae9 100644 --- a/src/main/webapp/i18n/en/exerciseAssessmentDashboard.json +++ b/src/main/webapp/i18n/en/exerciseAssessmentDashboard.json @@ -39,6 +39,7 @@ "readAndUnderstood": "I have read and understood the instructions", "toRead": "to read", "toReview": "to review", + "totalExamples": "There are {{total}} example submissions: {{toRead}} to read and {{toAssess}} to assess.", "totalExampleSubmissions": "There are {{total}} example submissions: {{toRead}} to read and {{toAssess}} to assess.", "readSubmissions": "Read submissions", "start": "Start", diff --git a/src/main/webapp/i18n/en/lectureUnit.json b/src/main/webapp/i18n/en/lectureUnit.json index d83040554b23..d54a06f188b1 100644 --- a/src/main/webapp/i18n/en/lectureUnit.json +++ b/src/main/webapp/i18n/en/lectureUnit.json @@ -149,6 +149,7 @@ "endPage": "End page must be less or equal to {{ max }}." }, "split": { + "empty": "", "unit": "Lecture Units", "unitName": "Unit Name", "releaseDate": "Release Date", diff --git a/src/main/webapp/i18n/en/modelingAssessment.json b/src/main/webapp/i18n/en/modelingAssessment.json index 1fe1b53bee3f..1040ce467a3a 100644 --- a/src/main/webapp/i18n/en/modelingAssessment.json +++ b/src/main/webapp/i18n/en/modelingAssessment.json @@ -2,6 +2,7 @@ "artemisApp": { "modelingAssessment": { "points": "Points", + "conflicts": "Conflicts", "assessor": "Assessor: {{firstName}} {{lastName}}", "noModel": "No model found to display", "invalidAssessments": "Your assessments are not valid!", diff --git a/src/main/webapp/i18n/en/multipleChoiceQuestion.json b/src/main/webapp/i18n/en/multipleChoiceQuestion.json index 2426e8563c8e..f342c31a56bc 100644 --- a/src/main/webapp/i18n/en/multipleChoiceQuestion.json +++ b/src/main/webapp/i18n/en/multipleChoiceQuestion.json @@ -37,6 +37,7 @@ "headingOne": "Heading One", "headingTwo": "Heading Two", "headingThree": "Heading Three", + "heading": "Heading", "quote": "Quote", "link": "Link", "orderedList": "Ordered List", diff --git a/src/main/webapp/i18n/en/notification.json b/src/main/webapp/i18n/en/notification.json index a027df9ace12..6c1e49a8bad0 100644 --- a/src/main/webapp/i18n/en/notification.json +++ b/src/main/webapp/i18n/en/notification.json @@ -15,6 +15,8 @@ "hideAllCurrentlyDisplayedNotifications": "Hide all currently displayed notifications", "target": { "newAnswer": "View reply", + "newAnswerPost": "View reply", + "newPost": "View post", "newQuestion": "View post", "exerciseCreated": "View exercise", "exerciseUpdated": "View exercise", diff --git a/src/main/webapp/i18n/en/programmingExercise.json b/src/main/webapp/i18n/en/programmingExercise.json index 9e88733b77e6..f4163cf7a4d8 100644 --- a/src/main/webapp/i18n/en/programmingExercise.json +++ b/src/main/webapp/i18n/en/programmingExercise.json @@ -147,18 +147,21 @@ }, "allowOnlineEditor": { "title": "Allow Online Editor", + "description": "An online text editor featuring a file explorer, coding window, and code highlighting features but without the ability to compile or run code.", "alert": "At least one option (Offline IDE, Online Editor, or Online IDE) must be selected", "alertNoTheia": "At least one option (Offline IDE or Online Editor) must be selected" }, "onlineEditor": "Online", "allowOfflineIde": { "title": "Allow Offline IDE", + "description": "Check this option to allow students to download the exercise and work on it locally using their preferred IDE.", "alert": "At least one option (Offline IDE, Online Editor, or Online IDE) must be selected", "alertNoTheia": "At least one option (Offline IDE or Online Editor) must be selected" }, "offlineIde": "IDE", "allowOnlineIde": { "title": "Allow Online IDE", + "description": "An online coding environment based on Visual Studio Code featuring code highlighting and formatting features. Pre-configured to your exercise's language and with the ability to compile or run code online.", "alert": "At least one option (Offline IDE, Online Editor, or Online IDE) must be selected.", "alertNoTheia": "At least one option (Offline IDE or Online Editor) must be selected" }, @@ -465,6 +468,8 @@ "allowFeedbackRequestsTooltip": "Students can request feedback before the due date. The requests will be processed by Athena, or, if Athena cannot be reached, students can send manual feedback requests.", "manualFeedbackRequests": "Manual feedback requests", "manualFeedbackRequestsTooltip": "Students can request manual feedback before the due date to receive feedback.", + "feedbackRequestsEnabled": "Feedback requests are enabled.", + "feedbackRequestsDisabled": "Feedback requests are disabled.", "complaintOnAutomaticAssessment": "Complaint on Automatic Assessment", "complaintOnAutomaticAssessmentTooltip": "Students can complain after receiving an automatic assessment. Which will be evaluated by a tutor afterwards.", "exampleSolutionPublicationDateTooltip": "The date when the Solution Repository becomes available to download for students. Leave blank to disable.", diff --git a/src/main/webapp/i18n/en/quizExercise.json b/src/main/webapp/i18n/en/quizExercise.json index 6b9d14db482f..649092bb9f56 100644 --- a/src/main/webapp/i18n/en/quizExercise.json +++ b/src/main/webapp/i18n/en/quizExercise.json @@ -273,12 +273,6 @@ "explanationAnswered": "You have answered this question", "explanationNotAnswered": "You have not answered this question", "submissionWarning": "Are you sure you want to submit? You have not answered all questions and you still have some time left!", - "quizLiveModal": { - "title": "Go-Live of a new Quiz Exercise", - "body": "The Quiz {{quizName}} for Course {{courseName}} is now live! Click on the 'Go to Quiz' button in order to access the quiz.", - "cancelButton": "Cancel", - "goToButton": "Go to quiz" - }, "placeholder": { "title": "Short Quiz Title", "questionTitle": "Question Title", diff --git a/src/main/webapp/i18n/en/student-dashboard.json b/src/main/webapp/i18n/en/student-dashboard.json index b9eddb4ecfa3..9a4d7690a9f5 100644 --- a/src/main/webapp/i18n/en/student-dashboard.json +++ b/src/main/webapp/i18n/en/student-dashboard.json @@ -103,6 +103,7 @@ "achievedScore": "Achieved Score", "achievablePoints": "Achievable Points", "applyFilter": "Apply filter", + "resetFilter": "Reset filter", "clearFilter": "Clear filter", "noFilterAvailable": "There are no distinguishing filter options for the existing exercises", "noMoreOptions": "No more options" diff --git a/src/test/javascript/spec/component/programming-exercise/update-components/programming-exercise-difficulty.component.spec.ts b/src/test/javascript/spec/component/programming-exercise/update-components/programming-exercise-difficulty.component.spec.ts index 70c70a17dd5b..0750ffcdca6a 100644 --- a/src/test/javascript/spec/component/programming-exercise/update-components/programming-exercise-difficulty.component.spec.ts +++ b/src/test/javascript/spec/component/programming-exercise/update-components/programming-exercise-difficulty.component.spec.ts @@ -14,6 +14,7 @@ import { ProfileInfo } from 'app/shared/layouts/profiles/profile-info.model'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_THEIA } from 'app/app.constants'; import { ArtemisTestModule } from '../../../test.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; describe('ProgrammingExerciseDifficultyComponent', () => { let fixture: ComponentFixture; @@ -24,7 +25,7 @@ describe('ProgrammingExerciseDifficultyComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - imports: [ArtemisTestModule], + imports: [ArtemisTestModule, ArtemisSharedComponentModule], declarations: [ CheckboxControlValueAccessor, DefaultValueAccessor, From 94bcf0b22b9cfa26e6d70a42ac5d5620958206ed Mon Sep 17 00:00:00 2001 From: Marcel Gaupp Date: Fri, 18 Oct 2024 07:38:59 +0200 Subject: [PATCH 13/85] Development: Synchronize windfiles and build scripts (#9466) --- src/main/resources/templates/aeolus/c/fact.yaml | 3 +-- src/main/resources/templates/aeolus/c/gcc.yaml | 2 +- src/main/resources/templates/aeolus/c/gcc_static.yaml | 2 +- src/main/resources/templates/aeolus/swift/plain.yaml | 2 +- src/main/resources/templates/aeolus/swift/plain_static.yaml | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/main/resources/templates/aeolus/c/fact.yaml b/src/main/resources/templates/aeolus/c/fact.yaml index 9d3527e65503..4278be956fac 100644 --- a/src/main/resources/templates/aeolus/c/fact.yaml +++ b/src/main/resources/templates/aeolus/c/fact.yaml @@ -7,7 +7,7 @@ actions: # Task Description: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R || true sudo mkdir test-reports sudo chown artemis_user:artemis_user test-reports/ -R || true @@ -32,4 +32,3 @@ actions: - name: junit_test-reports/tests-results.xml path: test-reports/tests-results.xml type: junit - diff --git a/src/main/resources/templates/aeolus/c/gcc.yaml b/src/main/resources/templates/aeolus/c/gcc.yaml index 29b2e2c635ec..31cafd647000 100644 --- a/src/main/resources/templates/aeolus/c/gcc.yaml +++ b/src/main/resources/templates/aeolus/c/gcc.yaml @@ -9,7 +9,7 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R diff --git a/src/main/resources/templates/aeolus/c/gcc_static.yaml b/src/main/resources/templates/aeolus/c/gcc_static.yaml index 21c0b506f179..06ad3136f9aa 100644 --- a/src/main/resources/templates/aeolus/c/gcc_static.yaml +++ b/src/main/resources/templates/aeolus/c/gcc_static.yaml @@ -9,7 +9,7 @@ actions: # Build and run all tests # ------------------------------ - # Updating assignment and test-reports ownership... + # Updating ${studentParentWorkingDirectoryName} and test-reports ownership... sudo chown artemis_user:artemis_user ${studentParentWorkingDirectoryName}/ -R mkdir test-reports chown artemis_user:artemis_user test-reports/ -R diff --git a/src/main/resources/templates/aeolus/swift/plain.yaml b/src/main/resources/templates/aeolus/swift/plain.yaml index 48211dee715a..a2be5d469e65 100644 --- a/src/main/resources/templates/aeolus/swift/plain.yaml +++ b/src/main/resources/templates/aeolus/swift/plain.yaml @@ -6,7 +6,7 @@ actions: cp -R Tests ${studentParentWorkingDirectoryName} cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build cd ${studentParentWorkingDirectoryName} swift build || error=true diff --git a/src/main/resources/templates/aeolus/swift/plain_static.yaml b/src/main/resources/templates/aeolus/swift/plain_static.yaml index 72f683141903..83c76e1a2b61 100644 --- a/src/main/resources/templates/aeolus/swift/plain_static.yaml +++ b/src/main/resources/templates/aeolus/swift/plain_static.yaml @@ -7,7 +7,7 @@ actions: cp -R Tests ${studentParentWorkingDirectoryName} cp Package.swift ${studentParentWorkingDirectoryName} - # In order to get the correct console output we need to execute the command within the assignment directory + # In order to get the correct console output we need to execute the command within the ${studentParentWorkingDirectoryName} directory # swift build cd ${studentParentWorkingDirectoryName} swift build || error=true From 09ed4fb9bd0732aefb8430d19540435af1b4cac5 Mon Sep 17 00:00:00 2001 From: Aybike Ece Eren Date: Fri, 18 Oct 2024 07:42:14 +0200 Subject: [PATCH 14/85] Lectures: Fix PDF rendering issue in lecture units (#9482) --- build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3706479625f3..a1388862f5a6 100644 --- a/build.gradle +++ b/build.gradle @@ -448,8 +448,7 @@ dependencies { implementation "com.ibm.icu:icu4j-charset:75.1" implementation "com.github.seancfoley:ipaddress:5.5.1" implementation "org.apache.maven:maven-model:3.9.9" - // NOTE: 3.0.2 is broken for splitting lecture specific PDFs - implementation "org.apache.pdfbox:pdfbox:3.0.1" + implementation "org.apache.pdfbox:pdfbox:3.0.3" implementation "org.apache.commons:commons-csv:1.12.0" implementation "org.commonmark:commonmark:0.23.0" implementation "commons-fileupload:commons-fileupload:1.5" From 96284c76e0f60e0bf5a8ca212818f284e129cb87 Mon Sep 17 00:00:00 2001 From: Johannes Wiest Date: Fri, 18 Oct 2024 08:57:01 +0200 Subject: [PATCH 15/85] Learning paths: Redesign learning path instructor view (#9144) --- .../atlas/dto/CompetencyGraphNodeDTO.java | 2 +- .../atlas/dto/LearningPathHealthDTO.java | 2 +- .../CompetencyProgressRepository.java | 12 ++ .../learningpath/LearningPathService.java | 35 +++-- .../atlas/web/LearningPathResource.java | 15 ++ .../core/repository/CourseRepository.java | 8 + .../webapp/app/admin/metrics/metrics.model.ts | 1 + .../competency-graph-modal.component.ts | 11 +- .../competency-node.component.html | 6 +- .../competency-node.component.ts | 2 + .../learning-path-nav-overview.component.ts | 7 +- .../learning-paths-analytics.component.html | 27 ++++ .../learning-paths-analytics.component.scss | 7 + .../learning-paths-analytics.component.ts | 44 ++++++ ...earning-paths-configuration.component.html | 41 +++++ .../learning-paths-configuration.component.ts | 78 ++++++++++ .../learning-paths-state.component.html | 40 +++++ .../learning-paths-state.component.scss | 18 +++ .../learning-paths-state.component.ts | 85 ++++++++++ .../learning-paths-table.component.html | 66 ++++++++ .../learning-paths-table.component.scss | 18 +++ .../learning-paths-table.component.ts | 94 +++++++++++ .../learning-path-management.component.html | 134 ++++++++-------- .../learning-path-management.component.ts | 6 +- ...arning-path-instructor-page.component.html | 39 +++++ ...arning-path-instructor-page.component.scss | 13 ++ ...learning-path-instructor-page.component.ts | 62 ++++++++ .../services/base-api-http.service.ts | 47 ++++++ .../services/learning-path-api.service.ts | 33 ++++ .../course/manage/course-management.route.ts | 4 +- .../competency/learning-path-health.model.ts | 24 +-- .../competency/learning-path.model.ts | 11 +- .../i18n/de/learningPathManagement.json | 60 +++++++ .../i18n/en/learningPathManagement.json | 60 +++++++ .../LearningPathIntegrationTest.java | 11 +- .../service/LearningPathServiceTest.java | 18 --- ...learning-paths-analytics.component.spec.ts | 95 +++++++++++ ...ning-paths-configuration.component.spec.ts | 147 ++++++++++++++++++ .../learning-paths-state.component.spec.ts | 145 +++++++++++++++++ .../learning-paths-table.component.spec.ts | 147 ++++++++++++++++++ ...learning-path-management.component.spec.ts | 19 +-- ...ing-path-instructor-page.component.spec.ts | 145 +++++++++++++++++ ...arning-path-student-page.component.spec.ts | 9 +- .../learning-path-api.service.spec.ts | 53 +++++++ 44 files changed, 1734 insertions(+), 167 deletions(-) create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss create mode 100644 src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts create mode 100644 src/main/webapp/i18n/de/learningPathManagement.json create mode 100644 src/main/webapp/i18n/en/learningPathManagement.json create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts create mode 100644 src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java index 59feee0edd6b..c56876064668 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/CompetencyGraphNodeDTO.java @@ -12,7 +12,7 @@ public record CompetencyGraphNodeDTO(String id, String label, ZonedDateTime softDueDate, Double value, CompetencyNodeValueType valueType) { public enum CompetencyNodeValueType { - MASTERY_PROGRESS + MASTERY_PROGRESS, AVERAGE_MASTERY_PROGRESS, } public static CompetencyGraphNodeDTO of(@NotNull CourseCompetency competency, Double value, CompetencyNodeValueType valueType) { diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java index 8592378c6a50..05d621746267 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/dto/LearningPathHealthDTO.java @@ -14,6 +14,6 @@ public LearningPathHealthDTO(Set status) { } public enum HealthStatus { - OK, DISABLED, MISSING, NO_COMPETENCIES, NO_RELATIONS + MISSING, NO_COMPETENCIES, NO_RELATIONS } } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java index 85c627b06408..2e397c06db36 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/repository/CompetencyProgressRepository.java @@ -94,4 +94,16 @@ SELECT COUNT(cp) AND c = :competency """) Set findAllPriorByCompetencyId(@Param("competency") CourseCompetency competency, @Param("user") User userId); + + @Query(""" + SELECT COALESCE(GREATEST(0.0, LEAST(1.0, AVG(cp.progress * cp.confidence / com.masteryThreshold))), 0.0) + FROM CompetencyProgress cp + LEFT JOIN cp.competency com + LEFT JOIN com.course c + LEFT JOIN cp.user u + WHERE com.id = :competencyId + AND cp.progress > 0 + AND c.studentGroupName MEMBER OF u.groups + """) + double findAverageOfAllNonZeroStudentProgressByCompetencyId(@Param("competencyId") long competencyId); } diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java index 190565c5c35c..ea2a4bd9ec37 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/service/learningpath/LearningPathService.java @@ -33,6 +33,7 @@ import de.tum.cit.aet.artemis.atlas.repository.CompetencyProgressRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRelationRepository; import de.tum.cit.aet.artemis.atlas.repository.CompetencyRepository; +import de.tum.cit.aet.artemis.atlas.repository.CourseCompetencyRepository; import de.tum.cit.aet.artemis.atlas.repository.LearningPathRepository; import de.tum.cit.aet.artemis.atlas.service.competency.CompetencyProgressService; import de.tum.cit.aet.artemis.core.domain.Course; @@ -89,10 +90,13 @@ public class LearningPathService { private final StudentParticipationRepository studentParticipationRepository; + private final CourseCompetencyRepository courseCompetencyRepository; + public LearningPathService(UserRepository userRepository, LearningPathRepository learningPathRepository, CompetencyProgressRepository competencyProgressRepository, LearningPathNavigationService learningPathNavigationService, CourseRepository courseRepository, CompetencyRepository competencyRepository, CompetencyRelationRepository competencyRelationRepository, LearningPathNgxService learningPathNgxService, - LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository) { + LectureUnitCompletionRepository lectureUnitCompletionRepository, StudentParticipationRepository studentParticipationRepository, + CourseCompetencyRepository courseCompetencyRepository) { this.userRepository = userRepository; this.learningPathRepository = learningPathRepository; this.competencyProgressRepository = competencyProgressRepository; @@ -103,6 +107,7 @@ public LearningPathService(UserRepository userRepository, LearningPathRepository this.learningPathNgxService = learningPathNgxService; this.lectureUnitCompletionRepository = lectureUnitCompletionRepository; this.studentParticipationRepository = studentParticipationRepository; + this.courseCompetencyRepository = courseCompetencyRepository; } /** @@ -298,20 +303,11 @@ else if (learningPath.isStartedByStudent()) { * @return dto containing the health status and additional information (missing learning paths) if needed */ public LearningPathHealthDTO getHealthStatusForCourse(@NotNull Course course) { - if (!course.getLearningPathsEnabled()) { - return new LearningPathHealthDTO(Set.of(LearningPathHealthDTO.HealthStatus.DISABLED)); - } - Set status = new HashSet<>(); Long numberOfMissingLearningPaths = checkMissingLearningPaths(course, status); checkNoCompetencies(course, status); checkNoRelations(course, status); - // if no issues where found, add OK status - if (status.isEmpty()) { - status.add(LearningPathHealthDTO.HealthStatus.OK); - } - return new LearningPathHealthDTO(status, numberOfMissingLearningPaths); } @@ -366,6 +362,25 @@ public LearningPathCompetencyGraphDTO generateLearningPathCompetencyGraph(@NotNu return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); } + /** + * Generates the graph of competencies with the student's progress for the given learning path. + * + * @param courseId the id of the course for which the graph should be generated + * @return dto containing the competencies and relations of the learning path + */ + public LearningPathCompetencyGraphDTO generateLearningPathCompetencyInstructorGraph(long courseId) { + List competencies = courseCompetencyRepository.findByCourseIdOrderById(courseId); + Set progressDTOs = competencies.stream().map(competency -> { + double averageMasteryProgress = competencyProgressRepository.findAverageOfAllNonZeroStudentProgressByCompetencyId(competency.getId()); + return CompetencyGraphNodeDTO.of(competency, averageMasteryProgress, CompetencyGraphNodeDTO.CompetencyNodeValueType.AVERAGE_MASTERY_PROGRESS); + }).collect(Collectors.toSet()); + + Set relations = competencyRelationRepository.findAllWithHeadAndTailByCourseId(courseId); + Set relationDTOs = relations.stream().map(CompetencyGraphEdgeDTO::of).collect(Collectors.toSet()); + + return new LearningPathCompetencyGraphDTO(progressDTOs, relationDTOs); + } + /** * Generates Ngx graph representation of the learning path graph. * diff --git a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java index f69dae28f80c..43a8135f27cb 100644 --- a/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/atlas/web/LearningPathResource.java @@ -203,6 +203,21 @@ public ResponseEntity getLearningPathCompetencyG return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyGraph(learningPath, user)); } + /** + * GET courses/{courseId}/learning-path/competency-instructor-graph : Gets the competency instructor graph + * + * @param courseId the id of the course for which the graph should be fetched + * @return the ResponseEntity with status 200 (OK) and with body the graph + */ + @GetMapping("courses/{courseId}/learning-path/competency-instructor-graph") + @FeatureToggle(Feature.LearningPaths) + @EnforceAtLeastInstructorInCourse + public ResponseEntity getLearningPathCompetencyInstructorGraph(@PathVariable long courseId) { + log.debug("REST request to get competency instructor graph for learning path with id: {}", courseId); + + return ResponseEntity.ok(learningPathService.generateLearningPathCompetencyInstructorGraph(courseId)); + } + /** * GET learning-path/:learningPathId/graph : Gets the ngx representation of the learning path as a graph. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java index fa3bba8a4b73..ad4c3ab139f5 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/CourseRepository.java @@ -322,6 +322,14 @@ GROUP BY SUBSTRING(CAST(s.submissionDate AS string), 1, 10), p.student.login """) List findAllNotEndedCoursesByManagementGroupNames(@Param("now") ZonedDateTime now, @Param("userGroups") List userGroups); + @Query(""" + SELECT COUNT(DISTINCT ug.userId) + FROM Course c + JOIN UserGroup ug ON c.studentGroupName = ug.group + WHERE c.id = :courseId + """) + int countCourseStudents(@Param("courseId") long courseId); + /** * Counts the number of members of a course, i.e. users that are a member of the course's student, tutor, editor or instructor group. * Users that are part of multiple groups are NOT counted multiple times. diff --git a/src/main/webapp/app/admin/metrics/metrics.model.ts b/src/main/webapp/app/admin/metrics/metrics.model.ts index dbed33af6fc7..cc476415b8ad 100644 --- a/src/main/webapp/app/admin/metrics/metrics.model.ts +++ b/src/main/webapp/app/admin/metrics/metrics.model.ts @@ -83,6 +83,7 @@ export interface Services { export enum HttpMethod { Post = 'POST', Get = 'GET', + Put = 'PUT', Delete = 'DELETE', Patch = 'PATCH', } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts index 8dfd9971432e..ac7a27af9f09 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component.ts @@ -1,7 +1,7 @@ import { Component, effect, inject, input, signal } from '@angular/core'; import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome'; import { faXmark } from '@fortawesome/free-solid-svg-icons'; -import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; import { ArtemisSharedModule } from 'app/shared/shared.module'; import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; @@ -46,4 +46,13 @@ export class CompetencyGraphModalComponent { closeModal(): void { this.activeModal.close(); } + + static openCompetencyGraphModal(modalService: NgbModal, learningPathId: number): void { + const modalRef = modalService.open(CompetencyGraphModalComponent, { + size: 'xl', + backdrop: 'static', + windowClass: 'competency-graph-modal', + }); + modalRef.componentInstance.learningPathId = signal(learningPathId); + } } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html index 77bb3e447289..3c546d22b01d 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.html @@ -1,9 +1,11 @@
- - @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS) { + @if (valueType() === CompetencyGraphNodeValueType.MASTERY_PROGRESS || valueType() === CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS) { {{ value() }} % + } @else { + {{ value() }} } diff --git a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts index 2ff110c01954..5365bb22387b 100644 --- a/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/competency-node/competency-node.component.ts @@ -46,6 +46,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isYellow(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() > 0 && this.value() < 100; default: return false; @@ -55,6 +56,7 @@ export class CompetencyNodeComponent implements AfterViewInit { isGray(): boolean { switch (this.valueType()) { case CompetencyGraphNodeValueType.MASTERY_PROGRESS: + case CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS: return this.value() === 0; default: return false; diff --git a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts index a63830f98bc7..07722fb3d0e7 100644 --- a/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts +++ b/src/main/webapp/app/course/learning-paths/components/learning-path-nav-overview/learning-path-nav-overview.component.ts @@ -61,11 +61,6 @@ export class LearningPathNavOverviewComponent { } openCompetencyGraph(): void { - const modalRef = this.modalService.open(CompetencyGraphModalComponent, { - size: 'xl', - backdrop: 'static', - windowClass: 'competency-graph-modal', - }); - modalRef.componentInstance.learningPathId = this.learningPathId; + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, this.learningPathId()); } } diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html new file mode 100644 index 000000000000..86d03a787e5d --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.html @@ -0,0 +1,27 @@ +
+
+
+
+
+ +
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else if (instructorCompetencyGraph()) { + + } +
+
+
+ + +
+ + +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss new file mode 100644 index 000000000000..64570aa984c9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.scss @@ -0,0 +1,7 @@ +.learning-paths-analytics-container { + height: 500px; + + .learning-paths-analytics-graph-selection-container { + border-right: var(--bs-border-width) solid var(--border-color); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts new file mode 100644 index 000000000000..41a8261eb206 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component.ts @@ -0,0 +1,44 @@ +import { Component, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { CompetencyGraphDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { CompetencyGraphComponent } from 'app/course/learning-paths/components/competency-graph/competency-graph.component'; +import { onError } from 'app/shared/util/global.utils'; + +@Component({ + selector: 'jhi-learning-paths-analytics', + standalone: true, + imports: [ArtemisSharedCommonModule, CompetencyGraphComponent], + templateUrl: './learning-paths-analytics.component.html', + styleUrl: './learning-paths-analytics.component.scss', +}) +export class LearningPathsAnalyticsComponent { + protected readonly CompetencyGraphNodeValueType = CompetencyGraphNodeValueType; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + readonly instructorCompetencyGraph = signal(undefined); + + readonly valueSelection = signal(CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS); + + constructor() { + effect(() => this.loadInstructionCompetencyGraph(this.courseId()), { allowSignalWrites: true }); + } + + private async loadInstructionCompetencyGraph(courseId: number): Promise { + try { + this.isLoading.set(true); + const instructorCompetencyGraph = await this.learningPathApiService.getLearningPathInstructorCompetencyGraph(courseId); + this.instructorCompetencyGraph.set(instructorCompetencyGraph); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html new file mode 100644 index 000000000000..3edcb13c956f --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.html @@ -0,0 +1,41 @@ +
+
+
+ @if (isEditMode()) { + + } @else { + + } +
+
+
+ @if (isConfigLoading()) { +
+
+ +
+
+ } @else { + + + + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts new file mode 100644 index 000000000000..23e5cb8e8612 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component.ts @@ -0,0 +1,78 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { LearningPathApiService } from '../../services/learning-path-api.service'; +import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; + +@Component({ + selector: 'jhi-learning-paths-configuration', + standalone: true, + imports: [FontAwesomeModule, ArtemisSharedCommonModule, ArtemisSharedComponentModule], + templateUrl: './learning-paths-configuration.component.html', + styleUrls: ['../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsConfigurationComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + + readonly courseId = input.required(); + + readonly isEditMode = signal(false); + readonly configHasBeenChanged = signal(false); + + readonly isConfigLoading = signal(false); + readonly isSaving = signal(false); + private readonly learningPathsConfiguration = signal(undefined); + readonly includeAllGradedExercisesEnabled = computed(() => this.learningPathsConfiguration()?.includeAllGradedExercises ?? false); + + constructor() { + effect(() => this.loadLearningPathsConfiguration(this.courseId()), { allowSignalWrites: true }); + } + + private async loadLearningPathsConfiguration(courseId: number): Promise { + try { + this.isConfigLoading.set(true); + const learningPathsConfiguration = await this.learningPathApiService.getLearningPathsConfiguration(courseId); + this.learningPathsConfiguration.set(learningPathsConfiguration); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isConfigLoading.set(false); + } + } + + protected toggleIncludeAllGradedExercises(): void { + this.configHasBeenChanged.set(true); + this.learningPathsConfiguration.set({ + ...this.learningPathsConfiguration(), + includeAllGradedExercises: !this.includeAllGradedExercisesEnabled(), + }); + } + + protected async saveLearningPathsConfiguration(): Promise { + if (this.configHasBeenChanged()) { + try { + this.isSaving.set(true); + await this.learningPathApiService.updateLearningPathsConfiguration(this.courseId(), this.learningPathsConfiguration()!); + this.alertService.success('artemisApp.learningPathManagement.learningPathsConfiguration.saveSuccess'); + this.isEditMode.set(false); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isSaving.set(false); + } + } else { + this.isEditMode.set(false); + } + } + + protected enableEditMode(): void { + this.isEditMode.set(true); + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html new file mode 100644 index 000000000000..cb2e0fb12c93 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.html @@ -0,0 +1,40 @@ +
+
+
+ +
+
+
+ @if (isLoading()) { +
+
+ +
+
+ } @else { + @for (healthState of learningPathHealthState(); let first = $first; track healthState) { +
+ +

+ +
+ } @empty { +
+ +
+ } + } +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss new file mode 100644 index 000000000000..6354cc0912ab --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.scss @@ -0,0 +1,18 @@ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; + +.learning-paths-state-container { + border-left: 2px solid $info; + + &.danger-state { + border-left-color: $danger; + } + + &.warning-state { + border-left-color: $warning; + } + + &.info-state { + border-left-color: $info; + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts new file mode 100644 index 000000000000..e5fc57092472 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-state/learning-paths-state.component.ts @@ -0,0 +1,85 @@ +import { Component, computed, effect, inject, input, signal } from '@angular/core'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'jhi-learning-paths-state', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-state.component.html', + styleUrls: ['./learning-paths-state.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsStateComponent { + protected readonly faSpinner = faSpinner; + + private readonly baseTranslationKey = 'artemisApp.learningPathManagement.learningPathsState.type'; + readonly translationKeys: Record = { + [HealthStatus.MISSING]: `${this.baseTranslationKey}.missing`, + [HealthStatus.NO_COMPETENCIES]: `${this.baseTranslationKey}.noCompetencies`, + [HealthStatus.NO_RELATIONS]: `${this.baseTranslationKey}.noRelations`, + }; + + readonly stateCssClasses: Record = { + [HealthStatus.MISSING]: 'warning-state', + [HealthStatus.NO_COMPETENCIES]: 'danger-state', + [HealthStatus.NO_RELATIONS]: 'warning-state', + }; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly router = inject(Router); + private readonly activatedRoute = inject(ActivatedRoute); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly learningPathHealth = signal(undefined); + readonly learningPathHealthState = computed(() => this.learningPathHealth()?.status ?? []); + + constructor() { + effect(() => this.loadLearningPathHealthState(this.courseId()), { allowSignalWrites: true }); + } + + protected async loadLearningPathHealthState(courseId: number): Promise { + try { + this.isLoading.set(true); + const learningPathHealthState = await this.learningPathApiService.getLearningPathHealthStatus(courseId); + this.learningPathHealth.set(learningPathHealthState); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async handleHealthStateAction(healthState: HealthStatus): Promise { + switch (healthState) { + case HealthStatus.MISSING: + await this.generateMissingLearningPaths(); + break; + case HealthStatus.NO_COMPETENCIES: + case HealthStatus.NO_RELATIONS: + await this.navigateToManageCompetencyPage(); + break; + } + } + + private async navigateToManageCompetencyPage(): Promise { + await this.router.navigate(['../competency-management'], { relativeTo: this.activatedRoute }); + } + + private async generateMissingLearningPaths(): Promise { + try { + await this.learningPathApiService.generateMissingLearningPaths(this.courseId()); + this.alertService.success(`${this.baseTranslationKey}.missing.successAlert`); + await this.loadLearningPathHealthState(this.courseId()); + } catch (error) { + onError(this.alertService, error); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html new file mode 100644 index 000000000000..4f4b23a690e0 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.html @@ -0,0 +1,66 @@ +
+
+
+
+ @if (isLoading()) { + + } + +
+
+
+
+
+ + + + + + + + + + + @for (learningPath of learningPaths(); track learningPath.id) { + + + + + + + } @empty { + + + + } + +
#
{{ learningPath.id }} + + + + + {{ learningPath.progress }} % +
+ +
+
+ +
+
diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss new file mode 100644 index 000000000000..e0a8bf4315b9 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.scss @@ -0,0 +1,18 @@ +.learning-paths-table-container { + height: 185px; + overflow-y: auto; + + table { + thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--bs-card-bg); + } + } +} + +.pagination { + height: 27px; + overflow-y: hidden; +} diff --git a/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts new file mode 100644 index 000000000000..3d5a50483faa --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/components/learning-paths-table/learning-paths-table.component.ts @@ -0,0 +1,94 @@ +import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; +import { SearchResult, SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; +import { onError } from 'app/shared/util/global.utils'; +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CompetencyGraphModalComponent } from 'app/course/learning-paths/components/competency-graph-modal/competency-graph-modal.component'; +import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; + +enum TableColumn { + ID = 'ID', + USER_NAME = 'USER_NAME', + USER_LOGIN = 'USER_LOGIN', + PROGRESS = 'PROGRESS', +} + +@Component({ + selector: 'jhi-learning-paths-table', + standalone: true, + imports: [ArtemisSharedCommonModule], + templateUrl: './learning-paths-table.component.html', + styleUrls: ['./learning-paths-table.component.scss', '../../pages/learning-path-instructor-page/learning-path-instructor-page.component.scss'], +}) +export class LearningPathsTableComponent { + protected readonly faSpinner = faSpinner; + + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly modalService = inject(NgbModal); + + readonly courseId = input.required(); + + readonly isLoading = signal(false); + private readonly searchResults = signal | undefined>(undefined); + readonly learningPaths = computed(() => this.searchResults()?.resultsOnPage ?? []); + + readonly searchTerm = signal(''); + readonly page = signal(1); + private readonly sortingOrder = signal(SortingOrder.ASCENDING); + private readonly sortedColumn = signal(TableColumn.ID); + readonly pageSize = signal(100).asReadonly(); + readonly collectionSize = computed(() => (this.searchResults()?.numberOfPages ?? 1) * this.pageSize()); + + // Debounce the loadLearningPaths function to prevent multiple requests when the user types quickly + private readonly debounceLoadLearningPaths = BaseApiHttpService.debounce(this.loadLearningPaths.bind(this), 300); + + constructor() { + effect( + () => { + // Load learning paths whenever the courseId changes + const courseId = this.courseId(); + untracked(() => this.loadLearningPaths(courseId)); + }, + { allowSignalWrites: true }, + ); + } + + private async loadLearningPaths(courseId: number): Promise { + try { + this.isLoading.set(true); + const searchState = { + page: this.page(), + pageSize: this.pageSize(), + searchTerm: this.searchTerm(), + sortingOrder: this.sortingOrder(), + sortedColumn: this.sortedColumn(), + }; + const searchResults = await this.learningPathApiService.getLearningPathInformation(courseId, searchState); + this.searchResults.set(searchResults); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + search(searchTerm: string): void { + this.searchTerm.set(searchTerm); + this.page.set(1); + this.debounceLoadLearningPaths(this.courseId()); + } + + async setPage(pageNumber: number): Promise { + this.page.set(pageNumber); + await this.loadLearningPaths(this.courseId()); + } + + openCompetencyGraph(learningPathId: number): void { + CompetencyGraphModalComponent.openCompetencyGraphModal(this.modalService, learningPathId); + } +} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html index c8a10304da15..fa2f5b75d63b 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.html @@ -8,21 +8,19 @@ @if (!isLoading && health) {

- @if (health.status?.includes(HealthStatus.DISABLED)) { +
+
- -
- -
+
- } +
@if (health.status?.includes(HealthStatus.MISSING)) { } @@ -32,63 +30,61 @@

@if (health.status?.includes(HealthStatus.NO_RELATIONS)) { } - @if (!health.status?.includes(HealthStatus.DISABLED)) { -
-
- - - @if (searchLoading) { - - } -
- - - - - - - - +
+
+ + + @if (searchLoading) { + + } +
+
- # - - - - - - - - - - -
+ + + + + + + + + + + @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { + + + + + + - - - @for (learningPath of content.resultsOnPage; track trackId($index, learningPath)) { - - - - - - - - } - -
+ # + + + + + + + + + + +
+ {{ learningPath.id }} + + + + + + + + +
- {{ learningPath.id }} - - - - - - - - -
-
- -
+ } + + +
+
- } +
} diff --git a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts index e6f2eb495f5c..bff608a8cb93 100644 --- a/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts +++ b/src/main/webapp/app/course/learning-paths/learning-path-management/learning-path-management.component.ts @@ -135,10 +135,8 @@ export class LearningPathManagementComponent implements OnInit { .subscribe({ next: (res) => { this.health = res.body!; - if (!this.health.status?.includes(HealthStatus.DISABLED)) { - this.performSearch(this.sort, 0); - this.performSearch(this.search, 300); - } + this.performSearch(this.sort, 0); + this.performSearch(this.search, 300); }, error: (res: HttpErrorResponse) => onError(this.alertService, res), }); diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html new file mode 100644 index 000000000000..09ad272a5bbe --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.html @@ -0,0 +1,39 @@ +@if (isLoading()) { +
+
+ +
+
+} @else if (learningPathsEnabled()) { +
+
+ + + + +
+ +
+
+ +
+
+
+ +
+
+} @else { +
+
+

+ + +
+
+} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss new file mode 100644 index 000000000000..be0c2625f1bd --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.scss @@ -0,0 +1,13 @@ +.enable-learning-paths-container { + max-width: 500px; + text-align: center; +} + +.learning-paths-container { + background: var(--bs-card-bg); +} + +.learning-paths-management-container { + max-height: 220px; + overflow-y: auto; +} diff --git a/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts new file mode 100644 index 000000000000..1dab247186e1 --- /dev/null +++ b/src/main/webapp/app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component.ts @@ -0,0 +1,62 @@ +import { Component, computed, effect, inject, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { LearningPathsConfigurationComponent } from 'app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component'; +import { lastValueFrom, map } from 'rxjs'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { onError } from 'app/shared/util/global.utils'; +import { Course } from 'app/entities/course.model'; +import { ArtemisSharedCommonModule } from 'app/shared/shared-common.module'; +import { LearningPathsStateComponent } from 'app/course/learning-paths/components/learning-paths-state/learning-paths-state.component'; +import { LearningPathsTableComponent } from 'app/course/learning-paths/components/learning-paths-table/learning-paths-table.component'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { LearningPathsAnalyticsComponent } from 'app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component'; + +@Component({ + selector: 'jhi-learning-path-instructor-page', + standalone: true, + imports: [LearningPathsConfigurationComponent, ArtemisSharedCommonModule, LearningPathsStateComponent, LearningPathsTableComponent, LearningPathsAnalyticsComponent], + templateUrl: './learning-path-instructor-page.component.html', + styleUrl: './learning-path-instructor-page.component.scss', +}) +export class LearningPathInstructorPageComponent { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly learningPathApiService = inject(LearningPathApiService); + private readonly alertService = inject(AlertService); + private readonly courseManagementService = inject(CourseManagementService); + + readonly courseId = toSignal(this.activatedRoute.parent!.params.pipe(map((params) => Number(params.courseId))), { requireSync: true }); + private readonly course = signal(undefined); + readonly learningPathsEnabled = computed(() => this.course()?.learningPathsEnabled ?? false); + + readonly isLoading = signal(false); + + constructor() { + effect(() => this.loadCourse(this.courseId()), { allowSignalWrites: true }); + } + + private async loadCourse(courseId: number): Promise { + try { + this.isLoading.set(true); + const courseBody = await lastValueFrom(this.courseManagementService.findOneForDashboard(courseId)); + this.course.set(courseBody.body!); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } + + protected async enableLearningPaths(): Promise { + try { + this.isLoading.set(true); + await this.learningPathApiService.enableLearningPaths(this.courseId()); + this.course.update((course) => ({ ...course!, learningPathsEnabled: true })); + } catch (error) { + onError(this.alertService, error); + } finally { + this.isLoading.set(false); + } + } +} diff --git a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts index a5b5653ce442..898a2de8d301 100644 --- a/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/base-api-http.service.ts @@ -2,6 +2,7 @@ import { HttpMethod } from 'app/admin/metrics/metrics.model'; import { inject } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'; import { lastValueFrom } from 'rxjs'; +import { SearchTermPageableSearch } from 'app/shared/table/pageable-table'; export abstract class BaseApiHttpService { private readonly httpClient: HttpClient = inject(HttpClient); @@ -67,6 +68,22 @@ export abstract class BaseApiHttpService { } } + /** + * Creates a `HttpParams` object from the given `SearchTermPageableSearch` object. + * @param pageable The pageable object to create the `HttpParams` object from. + * @protected + * + * @return The `HttpParams` object. + */ + protected createHttpSearchParams(pageable: SearchTermPageableSearch): HttpParams { + return new HttpParams() + .set('pageSize', String(pageable.pageSize)) + .set('page', String(pageable.page)) + .set('sortingOrder', pageable.sortingOrder) + .set('searchTerm', pageable.searchTerm) + .set('sortedColumn', pageable.sortedColumn); + } + /** * Constructs a `GET` request that interprets the body as JSON and * returns a Promise of an object of type `T`. @@ -185,4 +202,34 @@ export abstract class BaseApiHttpService { ): Promise { return await this.request(HttpMethod.Patch, url, { body: body, ...options }); } + + /** + * Constructs a `PUT` request that interprets the body as JSON and + * returns a Promise of an object of type `T`. + * + * @param url The endpoint URL excluding the base server url (/api). + * @param body The content to include in the body of the request. + * @param options The HTTP options to send with the request. + * @protected + * + * @return A `Promise` of type `Object` (T), + */ + protected async put( + url: string, + body?: any, + options?: { + headers?: + | HttpHeaders + | { + [header: string]: string | string[]; + }; + params?: + | HttpParams + | { + [param: string]: string | number | boolean | ReadonlyArray; + }; + }, + ): Promise { + return await this.request(HttpMethod.Put, url, { body: body, ...options }); + } } diff --git a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts index 01a43398f1c8..951927be0f8e 100644 --- a/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts +++ b/src/main/webapp/app/course/learning-paths/services/learning-path-api.service.ts @@ -4,12 +4,16 @@ import { LearningObjectType, LearningPathCompetencyDTO, LearningPathDTO, + LearningPathInformationDTO, LearningPathNavigationDTO, LearningPathNavigationObjectDTO, LearningPathNavigationOverviewDTO, + LearningPathsConfigurationDTO, } from 'app/entities/competency/learning-path.model'; import { HttpParams } from '@angular/common/http'; import { BaseApiHttpService } from 'app/course/learning-paths/services/base-api-http.service'; +import { LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; @Injectable({ providedIn: 'root', @@ -52,6 +56,10 @@ export class LearningPathApiService extends BaseApiHttpService { return await this.get(`learning-path/${learningPathId}/competency-graph`); } + async getLearningPathInstructorCompetencyGraph(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path/competency-instructor-graph`); + } + async getLearningPathCompetencies(learningPathId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies`); } @@ -59,4 +67,29 @@ export class LearningPathApiService extends BaseApiHttpService { async getLearningPathCompetencyLearningObjects(learningPathId: number, competencyId: number): Promise { return await this.get(`learning-path/${learningPathId}/competencies/${competencyId}/learning-objects`); } + + async getLearningPathsConfiguration(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-paths/configuration`); + } + + async getLearningPathHealthStatus(courseId: number): Promise { + return await this.get(`courses/${courseId}/learning-path-health`); + } + + async updateLearningPathsConfiguration(courseId: number, updatedLearningPathsConfiguration: LearningPathsConfigurationDTO): Promise { + await this.put(`courses/${courseId}/learning-paths/configuration`, updatedLearningPathsConfiguration); + } + + async enableLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/enable`); + } + + async generateMissingLearningPaths(courseId: number): Promise { + await this.put(`courses/${courseId}/learning-paths/generate-missing`); + } + + async getLearningPathInformation(courseId: number, pageable: SearchTermPageableSearch): Promise> { + const params = this.createHttpSearchParams(pageable); + return await this.get>(`courses/${courseId}/learning-paths`, { params }); + } } diff --git a/src/main/webapp/app/course/manage/course-management.route.ts b/src/main/webapp/app/course/manage/course-management.route.ts index ad4507123d1a..c85789f6d74e 100644 --- a/src/main/webapp/app/course/manage/course-management.route.ts +++ b/src/main/webapp/app/course/manage/course-management.route.ts @@ -22,7 +22,6 @@ import { CreateTutorialGroupsConfigurationComponent } from 'app/course/tutorial- import { CourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/course-lti-configuration.component'; import { EditCourseLtiConfigurationComponent } from 'app/course/manage/course-lti-configuration/edit-course-lti-configuration.component'; import { CourseManagementTabBarComponent } from 'app/course/manage/course-management-tab-bar/course-management-tab-bar.component'; -import { LearningPathManagementComponent } from 'app/course/learning-paths/learning-path-management/learning-path-management.component'; import { PendingChangesGuard } from 'app/shared/guard/pending-changes.guard'; import { BuildQueueComponent } from 'app/localci/build-queue/build-queue.component'; import { ImportCompetenciesComponent } from 'app/course/competencies/import/import-competencies.component'; @@ -33,6 +32,7 @@ import { ImportPrerequisitesComponent } from 'app/course/competencies/import/imp import { CreatePrerequisiteComponent } from 'app/course/competencies/create/create-prerequisite.component'; import { EditPrerequisiteComponent } from 'app/course/competencies/edit/edit-prerequisite.component'; import { CourseImportStandardizedPrerequisitesComponent } from 'app/course/competencies/import-standardized-competencies/course-import-standardized-prerequisites.component'; +import { LearningPathInstructorPageComponent } from 'app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component'; import { FaqComponent } from 'app/faq/faq.component'; import { FaqUpdateComponent } from 'app/faq/faq-update.component'; import { FaqResolve } from 'app/faq/faq.routes'; @@ -321,7 +321,7 @@ export const courseManagementState: Routes = [ }, { path: 'learning-path-management', - component: LearningPathManagementComponent, + component: LearningPathInstructorPageComponent, data: { authorities: [Authority.INSTRUCTOR, Authority.ADMIN], pageTitle: 'artemisApp.learningPath.manageLearningPaths.title', diff --git a/src/main/webapp/app/entities/competency/learning-path-health.model.ts b/src/main/webapp/app/entities/competency/learning-path-health.model.ts index 1cbcb13ba367..803dabf9eca2 100644 --- a/src/main/webapp/app/entities/competency/learning-path-health.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path-health.model.ts @@ -1,5 +1,5 @@ export class LearningPathHealthDTO { - public status?: HealthStatus[]; + public status: HealthStatus[] = []; public missingLearningPaths?: number; constructor(status: HealthStatus[]) { @@ -8,18 +8,12 @@ export class LearningPathHealthDTO { } export enum HealthStatus { - OK = 'OK', - DISABLED = 'DISABLED', MISSING = 'MISSING', NO_COMPETENCIES = 'NO_COMPETENCIES', NO_RELATIONS = 'NO_RELATIONS', } function getWarningTranslation(status: HealthStatus, element: string) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - const translation = { [HealthStatus.MISSING]: 'missing', [HealthStatus.NO_COMPETENCIES]: 'noCompetencies', @@ -29,33 +23,17 @@ function getWarningTranslation(status: HealthStatus, element: string) { } export function getWarningTitle(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'title'); } export function getWarningBody(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'body'); } export function getWarningAction(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'action'); } export function getWarningHint(status: HealthStatus) { - if (!status || status === HealthStatus.OK || status === HealthStatus.DISABLED) { - return ''; - } - return getWarningTranslation(status, 'hint'); } diff --git a/src/main/webapp/app/entities/competency/learning-path.model.ts b/src/main/webapp/app/entities/competency/learning-path.model.ts index 2c55868d0af0..576afd4e75a5 100644 --- a/src/main/webapp/app/entities/competency/learning-path.model.ts +++ b/src/main/webapp/app/entities/competency/learning-path.model.ts @@ -16,9 +16,9 @@ export class LearningPath implements BaseEntity { } export class LearningPathInformationDTO { - public id?: number; - public user?: UserNameAndLoginDTO; - public progress?: number; + public id: number; + public user: UserNameAndLoginDTO; + public progress: number; } export enum LearningObjectType { @@ -58,8 +58,13 @@ export interface LearningPathNavigationOverviewDTO { learningObjects: LearningPathNavigationObjectDTO[]; } +export interface LearningPathsConfigurationDTO { + includeAllGradedExercises: boolean; +} + export enum CompetencyGraphNodeValueType { MASTERY_PROGRESS = 'MASTERY_PROGRESS', + AVERAGE_MASTERY_PROGRESS = 'AVERAGE_MASTERY_PROGRESS', } export interface CompetencyGraphNodeDTO { diff --git a/src/main/webapp/i18n/de/learningPathManagement.json b/src/main/webapp/i18n/de/learningPathManagement.json new file mode 100644 index 000000000000..b21ec355fbd3 --- /dev/null +++ b/src/main/webapp/i18n/de/learningPathManagement.json @@ -0,0 +1,60 @@ +{ + "artemisApp": { + "learningPathManagement": { + "learningPathsDisabled": { + "title": "Willkommen bei der Lernpfadverwaltung!", + "description": "Die Lernpfadfunktion ist derzeit für diesen Kurs deaktiviert. Mit dieser Funktion können Kursteilnehmer einen personalisierten Lernpfad verfolgen, der auf den definierten Kompetenzen basiert. Jeder Lernpfad wird auf der Grundlage des individuellen Fortschritts des Teilnehmers, der Beziehungen zwischen den Kompetenzen und der festgelegten Fälligkeitsdaten erstellt. Mit dem folgenden Button kann die Lernpfadfunktion aktiviert werden.", + "buttonLabel": "Lernpfade aktivieren" + }, + "learningPathsState": { + "title": "Status", + "emptyState": "Alles ist eingerichtet und bereit!", + "refreshButtonLabel": "Aktualisieren", + "type": { + "missing": { + "title": "Fehlende Lernpfade", + "description": "Einige Studierende haben ihre Lernpfade noch nicht generiert. Ihre Lernpfade werden erst erstellt, wenn sie ihren Lernpfad zum ersten Mal anfordern.", + "actionButton": "Lernpfad generieren", + "successAlert": "Lernpfade wurden erfolgreich generiert." + }, + "noCompetencies": { + "title": "Keine Kompetenzen", + "description": "Es existieren noch keine Kompetenzen für diesen Kurs. Bitte erstelle Kompetenzen für diesen Kurs.", + "actionButton": "Kompetenzen zuweisen" + }, + "noRelations": { + "title": "Keine Kompetenzbeziehungen", + "description": "Einigen Kompetenzen wurden noch keine Beziehungen zugewiesen. Bitte weise diesen Kompetenzen Beziehungen zu.", + "actionButton": "Beziehungen zuweisen" + } + } + }, + "learningPathsConfiguration": { + "title": "Konfiguration", + "saveButtonLabel": "Speichern", + "editButtonLabel": "Bearbeiten", + "configuration": { + "includeAllGradedExercises": "Alle bewerteten Aufgaben einbeziehen", + "includeAllGradedExercisesToolTip": "Die Lernpfade werden alle Aufgaben enthalten, die in die Kursbewertung einfließen. Dadurch können Studierende keine wichtigen Aufgaben im Lernpfad übersehen." + }, + "saveSuccess": "Die Konfiguration wurde erfolgreich gespeichert." + }, + "learningPathsTable": { + "title": "Individuelle Lernpfade", + "searchPlaceholder": "Suchen", + "columnLabel": { + "name": "Name", + "login": "Login", + "progress": "Fortschritt" + }, + "noResults": "Keine Lernpfade gefunden!" + }, + "learningPathsAnalytics": { + "title": "Analyse", + "graphSelection": { + "AVERAGE_MASTERY_PROGRESS": "Ø Beherrschungsfortschritt" + } + } + } + } +} diff --git a/src/main/webapp/i18n/en/learningPathManagement.json b/src/main/webapp/i18n/en/learningPathManagement.json new file mode 100644 index 000000000000..0817497e0f23 --- /dev/null +++ b/src/main/webapp/i18n/en/learningPathManagement.json @@ -0,0 +1,60 @@ +{ + "artemisApp": { + "learningPathManagement": { + "learningPathsDisabled": { + "title": "Welcome to the learning path management!", + "description": "The learning path feature is currently disabled for this course. This feature enables students to follow a personalized learning path based on the competencies you have defined. Each learning path is generated based on the student's individual progress, the relationships between competencies, and the specified soft due dates. You can enable the learning path feature by clicking the button below.", + "buttonLabel": "Enable learning paths" + }, + "learningPathsState": { + "title": "State", + "emptyState": "Everything is set up and ready to go!", + "refreshButtonLabel": "Refresh", + "type": { + "missing": { + "title": "Missing learning paths", + "description": "Some students have not generated their learning paths yet. Their learning paths will be created once they request their learning path for the first time.", + "actionButton": "Generate learning paths", + "successAlert": "Learning paths have been generated successfully." + }, + "noCompetencies": { + "title": "No competencies", + "description": "Some students have not been assigned any competencies yet. Please assign competencies to these students.", + "actionButton": "Assign competencies" + }, + "noRelations": { + "title": "No relations", + "description": "Some competencies have not been assigned any relations yet. Please assign relations to these competencies.", + "actionButton": "Assign relations" + } + } + }, + "learningPathsConfiguration": { + "title": "Configuration", + "saveButtonLabel": "Save", + "editButtonLabel": "Edit", + "configuration": { + "includeAllGradedExercises": "Include all graded exercises", + "includeAllGradedExercisesToolTip": "The learning paths will include all exercises that are included in the course score. This way, students cannot miss important exercises in the learning path." + }, + "saveSuccess": "The configuration has been saved successfully." + }, + "learningPathsTable": { + "title": "Individual learning paths", + "searchPlaceholder": "Search", + "columnLabel": { + "name": "Name", + "login": "Login", + "progress": "Progress" + }, + "noResults": "No learning paths found!" + }, + "learningPathsAnalytics": { + "title": "Analytics", + "graphSelection": { + "AVERAGE_MASTERY_PROGRESS": "Ø Mastery progress" + } + } + } + } +} diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java index 9c55add49a9d..b217564b469f 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/learningpath/LearningPathIntegrationTest.java @@ -72,6 +72,8 @@ class LearningPathIntegrationTest extends AbstractAtlasIntegrationTest { private static final String STUDENT2_OF_COURSE = TEST_PREFIX + "student2"; + private static final String SECOND_STUDENT_OF_COURSE = TEST_PREFIX + "student2"; + private static final String TUTOR_OF_COURSE = TEST_PREFIX + "tutor1"; private static final String EDITOR_OF_COURSE = TEST_PREFIX + "editor1"; @@ -131,6 +133,7 @@ private void testAllPreAuthorize() throws Exception { final var search = pageableSearchUtilService.configureSearch(""); request.getSearchResult("/api/courses/" + course.getId() + "/learning-paths", HttpStatus.FORBIDDEN, LearningPath.class, pageableSearchUtilService.searchMapping(search)); request.get("/api/courses/" + course.getId() + "/learning-path-health", HttpStatus.FORBIDDEN, LearningPathHealthDTO.class); + request.get("/api/courses/" + course.getId() + "/learning-path/competency-instructor-graph", HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); } private void enableLearningPathsRESTCall(Course course) throws Exception { @@ -397,7 +400,7 @@ void testGetLearningPathWithOwner() throws Exception { @WithMockUser(username = STUDENT1_OF_COURSE, roles = "USER") void testGetLearningPathOfOtherUser() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - final var otherStudent = userTestRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + final var otherStudent = userTestRepository.findOneByLogin(SECOND_STUDENT_OF_COURSE).orElseThrow(); final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), otherStudent.getId()); request.get("/api/learning-path/" + learningPath.getId(), HttpStatus.FORBIDDEN, NgxLearningPathDTO.class); } @@ -406,7 +409,7 @@ void testGetLearningPathOfOtherUser() throws Exception { @WithMockUser(username = STUDENT1_OF_COURSE, roles = "USER") void testGetLearningPathCompetencyGraphOfOtherUser() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - final var otherStudent = userTestRepository.findOneByLogin(TEST_PREFIX + "student2").orElseThrow(); + final var otherStudent = userTestRepository.findOneByLogin(SECOND_STUDENT_OF_COURSE).orElseThrow(); final var learningPath = learningPathRepository.findByCourseIdAndUserIdElseThrow(course.getId(), otherStudent.getId()); request.get("/api/learning-path/" + learningPath.getId() + "/competency-graph", HttpStatus.FORBIDDEN, LearningPathCompetencyGraphDTO.class); } @@ -453,7 +456,7 @@ void testGetLearningPathNgxForLearningPathsDisabled(LearningPathResource.NgxRequ @ParameterizedTest(name = "{displayName} [{index}] {argumentsWithNames}") @EnumSource(LearningPathResource.NgxRequestType.class) - @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + @WithMockUser(username = SECOND_STUDENT_OF_COURSE, roles = "USER") void testGetLearningPathNgxForOtherStudent(LearningPathResource.NgxRequestType type) throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var student = userTestRepository.findOneByLogin(STUDENT1_OF_COURSE).orElseThrow(); @@ -617,7 +620,7 @@ void shouldStartLearningPath() throws Exception { } @Test - @WithMockUser(username = TEST_PREFIX + "student2", roles = "USER") + @WithMockUser(username = SECOND_STUDENT_OF_COURSE, roles = "USER") void testGetCompetencyProgressForLearningPathByOtherStudent() throws Exception { course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); final var student = userTestRepository.findOneByLogin(STUDENT1_OF_COURSE).orElseThrow(); diff --git a/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java index 65143ebdf769..471d3752c728 100644 --- a/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/atlas/service/LearningPathServiceTest.java @@ -110,24 +110,6 @@ void setup() { course = courseRepository.save(course); } - @Test - void testHealthStatusDisabled() { - var healthStatus = learningPathService.getHealthStatusForCourse(course); - assertThat(healthStatus.status()).containsExactly(LearningPathHealthDTO.HealthStatus.DISABLED); - assertThat(healthStatus.missingLearningPaths()).isNull(); - } - - @Test - void testHealthStatusOK() { - final var competency1 = competencyUtilService.createCompetency(course); - final var competency2 = competencyUtilService.createCompetency(course); - competencyUtilService.addRelation(competency1, RelationType.MATCHES, competency2); - course = learningPathUtilService.enableAndGenerateLearningPathsForCourse(course); - var healthStatus = learningPathService.getHealthStatusForCourse(course); - assertThat(healthStatus.status()).containsExactly(LearningPathHealthDTO.HealthStatus.OK); - assertThat(healthStatus.missingLearningPaths()).isNull(); - } - @Test void testHealthStatusMissing() { final var competency1 = competencyUtilService.createCompetency(course); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts new file mode 100644 index 000000000000..fe54e94bb83d --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-analytics.component.spec.ts @@ -0,0 +1,95 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsAnalyticsComponent } from 'app/course/learning-paths/components/learning-paths-analytics/learning-paths-analytics.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { CompetencyGraphDTO, CompetencyGraphEdgeDTO, CompetencyGraphNodeDTO, CompetencyGraphNodeValueType } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathsAnalyticsComponent', () => { + let component: LearningPathsAnalyticsComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getInstructorCompetencyGraphSpy: jest.SpyInstance; + + const courseId = 1; + + const competencyGraph = { + nodes: [ + { + id: '1', + label: 'Node 1', + valueType: CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS, + value: 12, + } as CompetencyGraphNodeDTO, + { + id: '2', + label: 'Node 2', + valueType: CompetencyGraphNodeValueType.AVERAGE_MASTERY_PROGRESS, + value: 0, + } as CompetencyGraphNodeDTO, + ], + edges: [ + { + source: '1', + target: '2', + } as CompetencyGraphEdgeDTO, + ], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsAnalyticsComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getInstructorCompetencyGraphSpy = jest.spyOn(learningPathApiService, 'getLearningPathInstructorCompetencyGraph').mockResolvedValue(competencyGraph); + + fixture = TestBed.createComponent(LearningPathsAnalyticsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load instructor competency graph', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getInstructorCompetencyGraphSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.instructorCompetencyGraph()).toEqual(competencyGraph); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error on load instructor competency graph', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getInstructorCompetencyGraphSpy.mockRejectedValue(new Error('Error')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts new file mode 100644 index 000000000000..220681f972f8 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-configuration.component.spec.ts @@ -0,0 +1,147 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsConfigurationComponent } from 'app/course/learning-paths/components/learning-paths-configuration/learning-paths-configuration.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { LearningPathsConfigurationDTO } from 'app/entities/competency/learning-path.model'; + +describe('LearningPathsConfigurationComponent', () => { + let component: LearningPathsConfigurationComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getLearningPathsConfigurationSpy: jest.SpyInstance; + + const courseId = 1; + + const learningPathsConfiguration = { + includeAllGradedExercises: true, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsConfigurationComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getLearningPathsConfigurationSpy = jest.spyOn(learningPathApiService, 'getLearningPathsConfiguration').mockResolvedValue(learningPathsConfiguration); + + fixture = TestBed.createComponent(LearningPathsConfigurationComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load learning paths configuration', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + + fixture.detectChanges(); + + expect(includeAllExercisesCheckBox.checked).toEqual(learningPathsConfiguration.includeAllGradedExercises); + expect(component.includeAllGradedExercisesEnabled()).toEqual(learningPathsConfiguration.includeAllGradedExercises); + expect(getLearningPathsConfigurationSpy).toHaveBeenCalledExactlyOnceWith(courseId); + }); + + it('should show error on load learning paths configuration', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getLearningPathsConfigurationSpy.mockRejectedValue(new Error('Error')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isConfigLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should enable edit mode', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + expect(includeAllExercisesCheckBox.disabled).toBeTrue(); + + await enableEditMode(); + + expect(includeAllExercisesCheckBox.disabled).toBeFalse(); + + const saveButton = fixture.nativeElement.querySelector('#save-learning-paths-configuration-button'); + expect(saveButton).not.toBeNull(); + expect(component.isEditMode()).toBeTrue(); + }); + + it('should toggle include all graded exercises', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + await enableEditMode(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + includeAllExercisesCheckBox.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(includeAllExercisesCheckBox.checked).toBe(!learningPathsConfiguration.includeAllGradedExercises); + expect(component.includeAllGradedExercisesEnabled()).toBe(!learningPathsConfiguration.includeAllGradedExercises); + }); + + it('should save learning paths configuration', async () => { + const updateLearningPathsConfigurationSpy = jest.spyOn(learningPathApiService, 'updateLearningPathsConfiguration').mockResolvedValue(); + const alertServiceSuccessSpy = jest.spyOn(alertService, 'success'); + + fixture.detectChanges(); + await fixture.whenStable(); + + await enableEditMode(); + + const includeAllExercisesCheckBox = fixture.nativeElement.querySelector('#include-all-graded-exercises-checkbox'); + includeAllExercisesCheckBox.click(); + + const saveButton = fixture.nativeElement.querySelector('#save-learning-paths-configuration-button'); + saveButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(updateLearningPathsConfigurationSpy).toHaveBeenCalledExactlyOnceWith(courseId, { + ...learningPathsConfiguration, + includeAllGradedExercises: !learningPathsConfiguration.includeAllGradedExercises, + }); + expect(alertServiceSuccessSpy).toHaveBeenCalledOnce(); + expect(component.isEditMode()).toBeFalse(); + }); + + async function enableEditMode(): Promise { + const editButton = fixture.nativeElement.querySelector('#edit-learning-paths-configuration-button'); + editButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts new file mode 100644 index 000000000000..0193ae9a129f --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-state.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsStateComponent } from 'app/course/learning-paths/components/learning-paths-state/learning-paths-state.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { provideHttpClient } from '@angular/common/http'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of } from 'rxjs'; +import { HealthStatus, LearningPathHealthDTO } from 'app/entities/competency/learning-path-health.model'; +import { MockRouter } from '../../../helpers/mocks/mock-router'; + +describe('LearningPathsStateComponent', () => { + let component: LearningPathsStateComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let router: Router; + let getLearningPathHealthStatusSpy: jest.SpyInstance; + + const courseId = 1; + + const learningPathHealth = { + missingLearningPaths: 1, + status: [HealthStatus.MISSING, HealthStatus.NO_COMPETENCIES, HealthStatus.NO_RELATIONS], + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsStateComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + { + provide: ActivatedRoute, + useValue: { + parent: { + parent: { + params: of({ + courseId: courseId, + }), + }, + }, + }, + }, + { + provide: Router, + useClass: MockRouter, + }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + router = TestBed.inject(Router); + + getLearningPathHealthStatusSpy = jest.spyOn(learningPathApiService, 'getLearningPathHealthStatus').mockResolvedValue(learningPathHealth); + + fixture = TestBed.createComponent(LearningPathsStateComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + it('should load learning path health status', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathsStateContainer = fixture.nativeElement.querySelectorAll('.learning-paths-state-container'); + + expect(learningPathsStateContainer).toHaveLength(learningPathHealth.status.length); + expect(getLearningPathHealthStatusSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathHealthState()).toEqual(learningPathHealth.status); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show error when loading fails', async () => { + jest.spyOn(learningPathApiService, 'getLearningPathHealthStatus').mockRejectedValue(new Error('Error loading learning path health status')); + const onErrorSpy = jest.spyOn(alertService, 'addAlert'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(onErrorSpy).toHaveBeenCalledOnce(); + }); + + it.each([HealthStatus.NO_COMPETENCIES, HealthStatus.NO_RELATIONS])('should navigate to competencies page on %s status', async (status) => { + const navigateSpy = jest.spyOn(router, 'navigate'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [status] }); + + await clickHealthStateButton(`#health-state-button-${status}`); + + expect(navigateSpy).toHaveBeenCalledExactlyOnceWith(['../competency-management'], { relativeTo: TestBed.inject(ActivatedRoute) }); + }); + + it('should generate missing learning paths', async () => { + const generateMissingLearningPathsSpy = jest.spyOn(learningPathApiService, 'generateMissingLearningPaths').mockResolvedValue(); + const successSpy = jest.spyOn(alertService, 'success'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [HealthStatus.MISSING] }); + + await clickHealthStateButton(`#health-state-button-${HealthStatus.MISSING}`); + + expect(generateMissingLearningPathsSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(successSpy).toHaveBeenCalledOnce(); + expect(getLearningPathHealthStatusSpy).toHaveBeenNthCalledWith(2, courseId); + }); + + it('should show error when generating missing learning paths fails', async () => { + jest.spyOn(learningPathApiService, 'generateMissingLearningPaths').mockRejectedValue(new Error('Error generating missing learning paths')); + const onErrorSpy = jest.spyOn(alertService, 'addAlert'); + getLearningPathHealthStatusSpy.mockResolvedValue({ ...learningPathHealth, status: [HealthStatus.MISSING] }); + + await clickHealthStateButton(`#health-state-button-${HealthStatus.MISSING}`); + + expect(onErrorSpy).toHaveBeenCalledOnce(); + }); + + async function clickHealthStateButton(selector: string) { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const healthStateButton = fixture.nativeElement.querySelector(selector); + healthStateButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts b/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts new file mode 100644 index 000000000000..66485d809779 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/components/learning-paths-table.component.spec.ts @@ -0,0 +1,147 @@ +import '@angular/localize/init'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathsTableComponent } from 'app/course/learning-paths/components/learning-paths-table/learning-paths-table.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { TranslateService } from '@ngx-translate/core'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; +import { SearchResult, SearchTermPageableSearch } from 'app/shared/table/pageable-table'; +import { By } from '@angular/platform-browser'; + +describe('LearningPathsTableComponent', () => { + let component: LearningPathsTableComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let getLearningPathInformationSpy: jest.SpyInstance; + + const courseId = 1; + + const searchResults = >{ + numberOfPages: 2, + resultsOnPage: generateResults(0, 100), + }; + + const pageable = { + page: 1, + pageSize: 100, + searchTerm: '', + sortingOrder: 'ASCENDING', + sortedColumn: 'ID', + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LearningPathsTableComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + + getLearningPathInformationSpy = jest.spyOn(learningPathApiService, 'getLearningPathInformation').mockResolvedValue(searchResults); + + fixture = TestBed.createComponent(LearningPathsTableComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('courseId', courseId); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should load learning paths', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathRows = fixture.nativeElement.querySelectorAll('tr'); + + expect(getLearningPathInformationSpy).toHaveBeenCalledExactlyOnceWith(courseId, pageable); + + expect(component.learningPaths()).toEqual(searchResults.resultsOnPage); + expect(learningPathRows).toHaveLength(searchResults.resultsOnPage.length + 1); + expect(component.collectionSize()).toBe(searchResults.resultsOnPage.length * searchResults.numberOfPages); + }); + + it('should open competency graph modal', async () => { + const learningPathId = 1; + const openCompetencyGraphSpy = jest.spyOn(component, 'openCompetencyGraph'); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const learningPathIdButton = fixture.debugElement.query(By.css(`#open-competency-graph-button-${learningPathId}`)); + learningPathIdButton.nativeElement.click(); + expect(openCompetencyGraphSpy).toHaveBeenCalledExactlyOnceWith(1); + }); + + it('should change page', async () => { + const onPageChangeSpy = jest.spyOn(component, 'setPage'); + + fixture.detectChanges(); + await fixture.whenStable(); + + await component.setPage(2); + + expect(onPageChangeSpy).toHaveBeenLastCalledWith(2); + expect(getLearningPathInformationSpy).toHaveBeenLastCalledWith(courseId, { ...pageable, page: 2 }); + }); + + it('should search for learning paths when the search term changes', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const searchField = fixture.debugElement.query(By.css('#learning-path-search')); + const searchPageable = { ...pageable, searchTerm: 'Search Term' }; + searchField.nativeElement.value = 'Search Term'; + searchField.nativeElement.dispatchEvent(new Event('input')); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getLearningPathInformationSpy).toHaveBeenLastCalledWith(courseId, searchPageable); + }); + + it('should show error message when loading learning paths fails', async () => { + getLearningPathInformationSpy.mockRejectedValue(new Error('Error loading learning paths')); + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + function generateResults(start: number, end: number): LearningPathInformationDTO[] { + return Array.from({ length: end - start }, (_, i) => ({ + id: i + start, + user: { name: `User ${i + start}`, login: `user${i + start}` }, + progress: i + start, + })); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts b/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts index d432116e8ae0..884544d93695 100644 --- a/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/management/learning-path-management.component.spec.ts @@ -3,7 +3,7 @@ import { LearningPathManagementComponent, TableColumn } from 'app/course/learnin import { LearningPathPagingService } from 'app/course/learning-paths/learning-path-paging.service'; import { SortService } from 'app/shared/service/sort.service'; import { SearchResult, SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; -import { LearningPath } from 'app/entities/competency/learning-path.model'; +import { LearningPath, LearningPathInformationDTO } from 'app/entities/competency/learning-path.model'; import { ArtemisTestModule } from '../../../test.module'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; import { ButtonComponent } from 'app/shared/components/button.component'; @@ -97,7 +97,7 @@ describe('LearningPathManagementComponent', () => { searchStub.mockReturnValue(of(searchResult)); enableLearningPathsStub.mockReturnValue(of(new HttpResponse())); generateMissingLearningPathsForCourseStub.mockReturnValue(of(new HttpResponse())); - health = new LearningPathHealthDTO([HealthStatus.OK]); + health = new LearningPathHealthDTO([]); getHealthStatusForCourseStub.mockReturnValue(of(new HttpResponse({ body: health }))); }); @@ -128,19 +128,6 @@ describe('LearningPathManagementComponent', () => { expect(alertServiceStub).toHaveBeenCalledOnce(); })); - it('should enable learning paths and load data', fakeAsync(() => { - const healthDisabled = new LearningPathHealthDTO([HealthStatus.DISABLED]); - getHealthStatusForCourseStub.mockReturnValueOnce(of(new HttpResponse({ body: healthDisabled }))).mockReturnValueOnce(of(new HttpResponse({ body: health }))); - fixture.detectChanges(); - comp.ngOnInit(); - expect(comp.health).toEqual(healthDisabled); - comp.enableLearningPaths(); - expect(enableLearningPathsStub).toHaveBeenCalledOnce(); - expect(enableLearningPathsStub).toHaveBeenCalledWith(courseId); - expect(getHealthStatusForCourseStub).toHaveBeenCalledTimes(3); - expect(comp.health).toEqual(health); - })); - it('should alert error if enable learning paths fails', fakeAsync(() => { const error = { status: 404 }; enableLearningPathsStub.mockReturnValue(throwError(() => new HttpErrorResponse(error))); @@ -218,6 +205,6 @@ describe('LearningPathManagementComponent', () => { })); it('should return learning path id', () => { - expect(comp.trackId(0, learningPath)).toEqual(learningPath.id); + expect(comp.trackId(0, { id: 2 })).toEqual(learningPath.id); }); }); diff --git a/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts b/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts new file mode 100644 index 000000000000..500d5db33716 --- /dev/null +++ b/src/test/javascript/spec/component/learning-paths/pages/learning-path-instructor-page.component.spec.ts @@ -0,0 +1,145 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LearningPathInstructorPageComponent } from 'app/course/learning-paths/pages/learning-path-instructor-page/learning-path-instructor-page.component'; +import { LearningPathApiService } from 'app/course/learning-paths/services/learning-path-api.service'; +import { AlertService } from 'app/core/util/alert.service'; +import { MockAlertService } from '../../../helpers/mocks/service/mock-alert.service'; +import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; +import { HttpErrorResponse, HttpResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateService } from '@ngx-translate/core'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; +import { CourseManagementService } from 'app/course/manage/course-management.service'; +import { ArtemisTestModule } from '../../../test.module'; +import { Course } from 'app/entities/course.model'; + +describe('LearningPathInstructorPageComponent', () => { + let component: LearningPathInstructorPageComponent; + let fixture: ComponentFixture; + let learningPathApiService: LearningPathApiService; + let alertService: AlertService; + let courseManagementService: CourseManagementService; + let getCourseSpy: jest.SpyInstance; + + const courseId = 1; + + const course = { + id: 1, + learningPathsEnabled: false, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule, LearningPathInstructorPageComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: ActivatedRoute, + useValue: { + parent: { + params: of({ + courseId: courseId, + }), + }, + }, + }, + { + provide: TranslateService, + useClass: MockTranslateService, + }, + { provide: AlertService, useClass: MockAlertService }, + ], + }).compileComponents(); + + learningPathApiService = TestBed.inject(LearningPathApiService); + alertService = TestBed.inject(AlertService); + courseManagementService = TestBed.inject(CourseManagementService); + + getCourseSpy = jest.spyOn(courseManagementService, 'findOneForDashboard').mockReturnValue( + of( + new HttpResponse({ + body: course, + }), + ), + ); + + fixture = TestBed.createComponent(LearningPathInstructorPageComponent); + component = fixture.componentInstance; + }); + + it('should load course', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + expect(getCourseSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathsEnabled()).toBe(course.learningPathsEnabled); + }); + + it('should show error on load course', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + getCourseSpy.mockRejectedValue(new HttpErrorResponse({ error: 'Error' })); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly on course loading', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + fixture.detectChanges(); + await fixture.whenStable(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + it('should show enable learning paths button if not enabled', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + + const enableLearningPathsButton = fixture.nativeElement.querySelector('#enable-learning-paths-button'); + expect(enableLearningPathsButton).toBeDefined(); + }); + + it('should enable learning paths', async () => { + const enableLearningPathsSpy = jest.spyOn(learningPathApiService, 'enableLearningPaths').mockResolvedValue(); + + await clickEnableLearningPathsButton(); + + expect(enableLearningPathsSpy).toHaveBeenCalledExactlyOnceWith(courseId); + expect(component.learningPathsEnabled()).toBeTrue(); + }); + + it('should show error on enable learning paths', async () => { + const alertServiceErrorSpy = jest.spyOn(alertService, 'addAlert'); + jest.spyOn(learningPathApiService, 'enableLearningPaths').mockRejectedValue(new Error('Error')); + + await clickEnableLearningPathsButton(); + + expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); + }); + + it('should set isLoading correctly on enable learning paths', async () => { + const isLoadingSpy = jest.spyOn(component.isLoading, 'set'); + + await clickEnableLearningPathsButton(); + + expect(isLoadingSpy).toHaveBeenNthCalledWith(1, true); + expect(isLoadingSpy).toHaveBeenNthCalledWith(2, false); + }); + + async function clickEnableLearningPathsButton(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + + const enableLearningPathsButton = fixture.nativeElement.querySelector('#enable-learning-paths-button'); + enableLearningPathsButton.click(); + + fixture.detectChanges(); + await fixture.whenStable(); + } +}); diff --git a/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts b/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts index 15f0dc14b768..2bebd4f5df76 100644 --- a/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts +++ b/src/test/javascript/spec/component/learning-paths/pages/learning-path-student-page.component.spec.ts @@ -78,18 +78,13 @@ describe('LearningPathStudentPageComponent', () => { jest.restoreAllMocks(); }); - it('should initialize', () => { - expect(component).toBeTruthy(); - expect(component.courseId()).toBe(courseId); - }); - it('should get learning path', async () => { const getLearningPathIdSpy = jest.spyOn(learningPathApiService, 'getLearningPathForCurrentUser').mockResolvedValue(learningPath); fixture.detectChanges(); await fixture.whenStable(); - expect(getLearningPathIdSpy).toHaveBeenCalledWith(courseId); + expect(getLearningPathIdSpy).toHaveBeenCalledExactlyOnceWith(courseId); expect(component.learningPath()).toEqual(learningPath); }); @@ -116,7 +111,7 @@ describe('LearningPathStudentPageComponent', () => { fixture.detectChanges(); await fixture.whenStable(); - expect(getLearningPathIdSpy).toHaveBeenCalledWith(courseId); + expect(getLearningPathIdSpy).toHaveBeenCalledExactlyOnceWith(courseId); expect(alertServiceErrorSpy).toHaveBeenCalledOnce(); }); diff --git a/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts b/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts index d7f34259716e..265870ceae6d 100644 --- a/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts +++ b/src/test/javascript/spec/service/learning-path/learning-path-api.service.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { LearningObjectType } from 'app/entities/competency/learning-path.model'; import { provideHttpClient } from '@angular/common/http'; +import { SearchTermPageableSearch, SortingOrder } from 'app/shared/table/pageable-table'; describe('LearningPathApiService', () => { let httpClient: HttpTestingController; @@ -83,6 +84,13 @@ describe('LearningPathApiService', () => { await methodCall; }); + it('should get learning path instructor competency graph', async () => { + const methodCall = learningPathApiService.getLearningPathInstructorCompetencyGraph(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-path/competency-instructor-graph` }); + response.flush({}); + await methodCall; + }); + it('should get learning path competencies', async () => { const methodCall = learningPathApiService.getLearningPathCompetencies(learningPathId); const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/learning-path/${learningPathId}/competencies` }); @@ -100,4 +108,49 @@ describe('LearningPathApiService', () => { response.flush([]); await methodCall; }); + + it('should get learning paths configuration', async () => { + const methodCall = learningPathApiService.getLearningPathsConfiguration(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-paths/configuration` }); + response.flush({}); + await methodCall; + }); + + it('should get learning path health status', async () => { + const methodCall = learningPathApiService.getLearningPathHealthStatus(courseId); + const response = httpClient.expectOne({ method: 'GET', url: `${baseUrl}/courses/${courseId}/learning-path-health` }); + response.flush({}); + await methodCall; + }); + + it('should put enable learning paths', async () => { + const methodCall = learningPathApiService.enableLearningPaths(courseId); + const response = httpClient.expectOne({ method: 'PUT', url: `${baseUrl}/courses/${courseId}/learning-paths/enable` }); + response.flush({}); + await methodCall; + }); + + it('should generate missing learning paths', async () => { + const methodCall = learningPathApiService.generateMissingLearningPaths(courseId); + const response = httpClient.expectOne({ method: 'PUT', url: `${baseUrl}/courses/${courseId}/learning-paths/generate-missing` }); + response.flush({}); + await methodCall; + }); + + it('should get learning path information', async () => { + const pageable = { + pageSize: 10, + page: 1, + searchTerm: 'search', + sortingOrder: SortingOrder.DESCENDING, + sortedColumn: 'column', + }; + const methodCall = learningPathApiService.getLearningPathInformation(courseId, pageable); + const response = httpClient.expectOne({ + method: 'GET', + url: `${baseUrl}/courses/${courseId}/learning-paths?pageSize=${pageable.pageSize}&page=${pageable.page}&sortingOrder=${pageable.sortingOrder}&searchTerm=${pageable.searchTerm}&sortedColumn=${pageable.sortedColumn}`, + }); + response.flush({}); + await methodCall; + }); }); From b2c22854f8df1de2132e7393d0a6b2599e9e17d9 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 18 Oct 2024 17:39:04 +0200 Subject: [PATCH 16/85] Development: Update client dependencies --- package-lock.json | 626 +++++++++++++++++++++++++++++----------------- package.json | 34 +-- 2 files changed, 413 insertions(+), 247 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4737065d0233..3d991d2d9079 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,20 +11,20 @@ "license": "MIT", "dependencies": { "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "18.2.8", "@angular/compiler": "18.2.8", "@angular/core": "18.2.8", "@angular/forms": "18.2.8", "@angular/localize": "18.2.8", - "@angular/material": "18.2.8", + "@angular/material": "18.2.9", "@angular/platform-browser": "18.2.8", "@angular/platform-browser-dynamic": "18.2.8", "@angular/router": "18.2.8", "@angular/service-worker": "18.2.8", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", - "@fingerprintjs/fingerprintjs": "4.5.0", + "@fingerprintjs/fingerprintjs": "4.5.1", "@fortawesome/angular-fontawesome": "0.15.0", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", @@ -60,17 +60,17 @@ "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.167.0", + "posthog-js": "1.174.2", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", "showdown-katex": "0.6.0", - "simple-statistics": "7.8.5", + "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", - "tslib": "2.7.0", + "tslib": "2.8.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -78,13 +78,13 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@angular-devkit/build-angular": "18.2.9", "@angular-eslint/builder": "18.3.1", "@angular-eslint/eslint-plugin": "18.3.1", "@angular-eslint/eslint-plugin-template": "18.3.1", "@angular-eslint/schematics": "18.3.1", "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.8", + "@angular/cli": "18.2.9", "@angular/compiler-cli": "18.2.8", "@angular/language-service": "18.2.8", "@sentry/types": "8.34.0", @@ -93,14 +93,14 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.13", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.5", + "@types/node": "22.7.6", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.8.1", - "@typescript-eslint/parser": "8.8.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", "eslint": "9.12.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -121,7 +121,7 @@ "ngxtension": "4.0.0", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.79.5", + "sass": "1.80.2", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" @@ -212,13 +212,13 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.1802.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.8.tgz", - "integrity": "sha512-/rtFQEKgS7LlB9oHr4NCBSdKnvP5kr8L5Hbd3Vl8hZOYK9QWjxKPEXnryA2d5+PCE98bBzZswCNXqELZCPTgIQ==", + "version": "0.1802.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1802.9.tgz", + "integrity": "sha512-fubJf4WC/t3ITy+tyjI4/CKKwUP4XJTmV+Y0nyPcrkcthVyUcIpZB74NlUOvg6WECiPQuIc+CtoAaA9X5+RQ5Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", + "@angular-devkit/core": "18.2.9", "rxjs": "7.8.1" }, "engines": { @@ -228,17 +228,17 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.8.tgz", - "integrity": "sha512-qK/iLk7A8vQp1CyiJV4DpwfLjPKoiOlTtFqoO5vD8Tyxmc+R06FQp6GJTsZ7JtrTLYSiH+QAWiY6NgF/Rj/hHg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-18.2.9.tgz", + "integrity": "sha512-d4W6t9vBozFUmOP2VvihMcSg/zgr3AvJY6/b7OPuATlK+W3P6tmsqxGIQ6eKc1TxXeu3lWhi14mV2pPykfrwfA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.8", - "@angular-devkit/build-webpack": "0.1802.8", - "@angular-devkit/core": "18.2.8", - "@angular/build": "18.2.8", + "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/build-webpack": "0.1802.9", + "@angular-devkit/core": "18.2.9", + "@angular/build": "18.2.9", "@babel/core": "7.25.2", "@babel/generator": "7.25.0", "@babel/helper-annotate-as-pure": "7.24.7", @@ -249,7 +249,7 @@ "@babel/preset-env": "7.25.3", "@babel/runtime": "7.25.0", "@discoveryjs/json-ext": "0.6.1", - "@ngtools/webpack": "18.2.8", + "@ngtools/webpack": "18.2.9", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.20", @@ -382,13 +382,13 @@ "license": "0BSD" }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.1802.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.8.tgz", - "integrity": "sha512-uPpopkXkO66SSdjtVr7xCyQCPs/x6KUC76xkDc4j0b8EEHifTbi/fNpbkcZ6wBmoAfjKLWXfKvtkh0TqKK5Hkw==", + "version": "0.1802.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1802.9.tgz", + "integrity": "sha512-p7xNGo5ZTV/Z0Rk+q2/E68QQLw9VT33kauDh6s010jIeBLrOwMo74JpzXMSFttQo5O4bLKP8IORzIM+0q7Uzjg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.8", + "@angular-devkit/architect": "0.1802.9", "rxjs": "7.8.1" }, "engines": { @@ -402,9 +402,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.8.tgz", - "integrity": "sha512-4o2T6wsmXGE/v53+F8L7kGoN2+qzt03C9rtjLVQpOljzpJVttQ8bhvfWxyYLWwcl04RWqRa+82fpIZtBkOlZJw==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-18.2.9.tgz", + "integrity": "sha512-bsVt//5E0ua7FZfO0dCF/qGGY6KQD34/bNGyRu5B6HedimpdU2/0PGDptksU5v3yKEc9gNw0xC6mT0UsY/R9pA==", "dev": true, "license": "MIT", "dependencies": { @@ -430,13 +430,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.8.tgz", - "integrity": "sha512-i/h2Oji5FhJMC7wDSnIl5XUe/qym+C1ZwScaATJwDyRLCUIynZkj5rLgdG/uK6l+H0PgvxigkF+akWpokkwW6w==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-18.2.9.tgz", + "integrity": "sha512-aIY5/IomDOINGCtFYi77uo0acDpdQNNCighfBBUGEBNMQ1eE3oGNGpLAH/qWeuxJndgmxrdKsvws9DdT46kLig==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", + "@angular-devkit/core": "18.2.9", "jsonc-parser": "3.3.1", "magic-string": "0.30.11", "ora": "5.4.1", @@ -564,14 +564,14 @@ } }, "node_modules/@angular/build": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.8.tgz", - "integrity": "sha512-ufuA4vHJSrL9SQW7bKV61DOoN1mm0t0ILTHaxSoCG3YF70cZJOX7+HNp3cK2uoldRMwbTOKSvCWBw54KKDRd5Q==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-18.2.9.tgz", + "integrity": "sha512-o1hOEM2e6ARy+ck2Pohl0d/RFgbbXTw6/hTLAj3CBKjtqAGStRaVF2UlJjhi+xOxlfsOPuJJc9IpzLBteku+Ag==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1802.8", + "@angular-devkit/architect": "0.1802.9", "@babel/core": "7.25.2", "@babel/helper-annotate-as-pure": "7.24.7", "@babel/helper-split-export-declaration": "7.24.7", @@ -651,9 +651,9 @@ } }, "node_modules/@angular/cdk": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.8.tgz", - "integrity": "sha512-J8A2FkwTBzLleAEWz6EgW73dEoeq87GREBPjTv8+2JV09LX+V3hnbgNk6zWq5k4OXtQNg9WrWP9QyRbUyA597g==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.2.9.tgz", + "integrity": "sha512-hV2dXpvy2TLwCsRtI/ZXkb2EoaJiellRr+kbcnKwO15LFoz3mTAOhKtsvu7yOyURkaPiI605qiIZrPP4zLL1qw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -668,18 +668,18 @@ } }, "node_modules/@angular/cli": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.8.tgz", - "integrity": "sha512-GKXG7F7z5rxwZ8/bnW/Bp8/zsfE/BpHmIP/icLfUIOwv2kaY5OD2tfQssWXPEuqZzYq2AYz+wjVSbWjxGoja8A==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.2.9.tgz", + "integrity": "sha512-ejTIqwvPABwK7MtVmI2qWbEaMhhbHNsq0NPzl1hwLtkrLbjdDrEVv0Wy+gN0xqrT9NyCPl4AmNLz/xuYTzgU5g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1802.8", - "@angular-devkit/core": "18.2.8", - "@angular-devkit/schematics": "18.2.8", + "@angular-devkit/architect": "0.1802.9", + "@angular-devkit/core": "18.2.9", + "@angular-devkit/schematics": "18.2.9", "@inquirer/prompts": "5.3.8", "@listr2/prompt-adapter-inquirer": "2.0.15", - "@schematics/angular": "18.2.8", + "@schematics/angular": "18.2.9", "@yarnpkg/lockfile": "1.1.0", "ini": "4.1.3", "jsonc-parser": "3.3.1", @@ -862,16 +862,16 @@ } }, "node_modules/@angular/material": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.8.tgz", - "integrity": "sha512-wQGMVsfQ9lQfih2VsWAvV4z3S3uBxrxc61owlE+K0T1BxH9u/jo3A/rnRitIdvR/L4NnYlfhCnmrW9K+Pl+WCg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.2.9.tgz", + "integrity": "sha512-M2oCgPPIMMd6BLgEJCD+FvdC7gRDeCjj9yktNn3ctHmkKUWRvpJ3xRBH/WjVXb+9fPCCW1iNwZI7+bN1fHE7cA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/animations": "^18.0.0 || ^19.0.0", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "^18.0.0 || ^19.0.0", "@angular/core": "^18.0.0 || ^19.0.0", "@angular/forms": "^18.0.0 || ^19.0.0", @@ -3584,9 +3584,9 @@ } }, "node_modules/@fingerprintjs/fingerprintjs": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-4.5.0.tgz", - "integrity": "sha512-mFSQoxyt8SGGRp1QUlhcnVtquW2HzCKfHKxAoIurR6soIJpuK3VvZuH0sg8eNaHH2dJhI3mZOEUx4k+P4GqXzw==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-4.5.1.tgz", + "integrity": "sha512-hKJaRoLHNeUUPhb+Md3pTlY/Js2YR4aXjroaDHpxrjoM8kGnEFyZVZxXo6l3gRyKnQN52Uoqsycd3M73eCdMzw==", "license": "BUSL-1.1", "dependencies": { "tslib": "^2.4.1" @@ -5376,9 +5376,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.8.tgz", - "integrity": "sha512-sq0kI8gEen4QlM6X8XqOYy7j4B8iLCYNo+iKxatV36ts4AXH0MuVkP56+oMaoH5oZNoSqd0RlfnotEHfvJAr8A==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.9.tgz", + "integrity": "sha512-/apDvs4qevjSWoYw3h3/c/mILFrf2EgCJfBy9f3E7PEgi2tjifOIszBRrLQkVpeHAaFgEH8zKS2ol0hAmOl8sw==", "dev": true, "license": "MIT", "engines": { @@ -6570,14 +6570,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "18.2.8", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.8.tgz", - "integrity": "sha512-62Sr7/j/dlhZorxH4GzQgpJy0s162BVts0Q7knZuEacP4VL+IWOUE1NS9OFkh/cbomoyXBdoewkZ5Zd1dVX78w==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.9.tgz", + "integrity": "sha512-LlMHZQ6f8zrqSK24OBXi4u2MTNHNu9ZN6JXpbElq0bz/9QkUR2zy+Kk2wLpPxCwXYTZby7/xgHiTzXvG+zTdhw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "18.2.8", - "@angular-devkit/schematics": "18.2.8", + "@angular-devkit/core": "18.2.9", + "@angular-devkit/schematics": "18.2.9", "jsonc-parser": "3.3.1" }, "engines": { @@ -7360,9 +7360,9 @@ } }, "node_modules/@types/node": { - "version": "22.7.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", - "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "version": "22.7.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz", + "integrity": "sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw==", "dev": true, "license": "MIT", "dependencies": { @@ -7566,17 +7566,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", - "integrity": "sha512-xfvdgA8AP/vxHgtgU310+WBnLB4uJQ9XdyP17RebG26rLtDrQJV3ZYrcopX91GrHmMoH8bdSwMRh2a//TiJ1jQ==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz", + "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/type-utils": "8.8.1", - "@typescript-eslint/utils": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/type-utils": "8.10.0", + "@typescript-eslint/utils": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -7600,16 +7600,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.8.1.tgz", - "integrity": "sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.10.0.tgz", + "integrity": "sha512-E24l90SxuJhytWJ0pTQydFT46Nk0Z+bsLKo/L8rtQSL93rQ6byd1V/QbDpHUTdLPOMsBCcYXZweADNCfOCmOAg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/typescript-estree": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4" }, "engines": { @@ -7629,14 +7629,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.8.1.tgz", - "integrity": "sha512-X4JdU+66Mazev/J0gfXlcC/dV6JI37h+93W9BRYXrSn0hrE64IoWgVkO9MSJgEzoWkxONgaQpICWg8vAN74wlA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz", + "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1" + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7647,14 +7647,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.8.1.tgz", - "integrity": "sha512-qSVnpcbLP8CALORf0za+vjLYj1Wp8HSoiI8zYU5tHxRVj30702Z1Yw4cLwfNKhTPWp5+P+k1pjmD5Zd1nhxiZA==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz", + "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.8.1", - "@typescript-eslint/utils": "8.8.1", + "@typescript-eslint/typescript-estree": "8.10.0", + "@typescript-eslint/utils": "8.10.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -7672,9 +7672,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.8.1.tgz", - "integrity": "sha512-WCcTP4SDXzMd23N27u66zTKMuEevH4uzU8C9jf0RO4E04yVHgQgW+r+TeVTNnO1KIfrL8ebgVVYYMMO3+jC55Q==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz", + "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==", "dev": true, "license": "MIT", "engines": { @@ -7686,14 +7686,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.1.tgz", - "integrity": "sha512-A5d1R9p+X+1js4JogdNilDuuq+EHZdsH9MjTVxXOdVFfTJXunKJR/v+fNNyO4TnoOn5HqobzfRlc70NC6HTcdg==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz", + "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/visitor-keys": "8.8.1", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/visitor-keys": "8.10.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -7715,16 +7715,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.8.1.tgz", - "integrity": "sha512-/QkNJDbV0bdL7H7d0/y0qBbV2HTtf0TIyjSDTvvmQEzeVx8jEImEbLuOA4EsvE8gIgqMitns0ifb5uQhMj8d9w==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz", + "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.8.1", - "@typescript-eslint/types": "8.8.1", - "@typescript-eslint/typescript-estree": "8.8.1" + "@typescript-eslint/scope-manager": "8.10.0", + "@typescript-eslint/types": "8.10.0", + "@typescript-eslint/typescript-estree": "8.10.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7738,13 +7738,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.8.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.1.tgz", - "integrity": "sha512-0/TdC3aeRAsW7MDvYRwEc1Uwm0TIBfzjPFgg60UU2Haj5qsCs9cc3zNgY71edqE3LbWfF/WoZQd3lJoDXFQpag==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz", + "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.8.1", + "@typescript-eslint/types": "8.10.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -8337,9 +8337,9 @@ } }, "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", "dev": true, "license": "MIT" }, @@ -8803,28 +8803,26 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.0.1.tgz", + "integrity": "sha512-PagxbjvuPH6tv0f/kdVbFGcb79D236SLcDTs6DrQ7GizJ88S1UWP4nMXFEo/I4fdhGRGabvFfFjVGm3M7U8JwA==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", + "debug": "3.1.0", "destroy": "1.2.0", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.5.2", "on-finished": "2.4.1", "qs": "6.13.0", - "raw-body": "2.5.2", + "raw-body": "^3.0.0", "type-is": "~1.6.18", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 0.10" } }, "node_modules/body-parser/node_modules/bytes": { @@ -8837,6 +8835,43 @@ "node": ">= 0.8" } }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", + "integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/bonjour-service": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", @@ -9602,9 +9637,9 @@ "optional": true }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", "dev": true, "license": "MIT", "dependencies": { @@ -9652,21 +9687,24 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.1.tgz", + "integrity": "sha512-Xd8lFX4LM9QEEwxQpF9J9NTUh8pmdJO0cyRJhFiDoLTk2eH8FXlRv2IFGYVadZpqI3j8fhNrSdKCeYPxiAhLXw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", + "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/copy-anything": { "version": "2.0.6", @@ -9870,9 +9908,9 @@ "license": "MIT" }, "node_modules/critters": { - "version": "0.0.24", - "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.24.tgz", - "integrity": "sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q==", + "version": "0.0.25", + "resolved": "https://registry.npmjs.org/critters/-/critters-0.0.25.tgz", + "integrity": "sha512-ROF/tjJyyRdM8/6W0VqoN5Ql05xAGnkf5b7f3sTEl1bI5jTQQf8O918RD/V9tEb9pRY/TKcvJekDbJtniHyPtQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11943,46 +11981,94 @@ } }, "node_modules/express": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.0.tgz", - "integrity": "sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz", + "integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==", "dev": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "accepts": "^2.0.0", + "body-parser": "^2.0.1", + "content-disposition": "^1.0.0", "content-type": "~1.0.4", - "cookie": "0.6.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", + "cookie": "0.7.1", + "cookie-signature": "^1.2.1", + "debug": "4.3.6", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", + "finalhandler": "^2.0.0", + "fresh": "2.0.0", "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", + "merge-descriptors": "^2.0.0", "methods": "~1.1.2", + "mime-types": "^3.0.0", "on-finished": "2.4.1", + "once": "1.4.0", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", "proxy-addr": "~2.0.7", "qs": "6.13.0", "range-parser": "~1.2.1", + "router": "^2.0.0", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "^1.1.0", + "serve-static": "^2.1.0", "setprototypeof": "1.2.0", "statuses": "2.0.1", - "type-is": "~1.6.18", + "type-is": "^2.0.0", "utils-merge": "1.0.1", "vary": "~1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" + } + }, + "node_modules/express/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.53.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, "node_modules/express/node_modules/safe-buffer": { @@ -12192,14 +12278,14 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.0.0.tgz", + "integrity": "sha512-MX6Zo2adDViYh+GcxxB1dpO43eypOGUOL12rLCOTMQv/DfIbpSJUy4oQIIZhVZkH9e+bZWKMon0XHFEju16tkQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", - "encodeurl": "~2.0.0", + "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -12210,6 +12296,16 @@ "node": ">= 0.8" } }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/find-cache-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", @@ -12396,13 +12492,13 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/front-matter": { @@ -13617,6 +13713,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -16877,19 +16980,19 @@ "license": "ISC" }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memfs": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.13.0.tgz", - "integrity": "sha512-dIs5KGy24fbdDhIAg0RxXpFqQp3RwL6wgSMRF9OSuphL/Uc9a4u2/SDJKPLj/zUgtOGKuHrRMrj563+IErj4Cg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.14.0.tgz", + "integrity": "sha512-JUeY0F/fQZgIod31Ja1eJgiSxLn7BfQlCnqhwXFBzFHEw63OdLK7VJUJ7bnzNsWgCyoUP5tEp1VRY8rDaYzqOA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -16907,11 +17010,14 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -16973,6 +17079,7 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mime": "cli.js" }, @@ -18855,11 +18962,14 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=16" + } }, "node_modules/path-type": { "version": "5.0.0", @@ -19222,14 +19332,15 @@ "license": "MIT" }, "node_modules/posthog-js": { - "version": "1.167.0", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.167.0.tgz", - "integrity": "sha512-/zXQ6tuJgiF1d4mgg3UsAi/uoyg7UnfFNQtikuALmaE53xFExpcAKbMfHPG/f54QgTvLxSHyGL1kFl/1uspkGg==", + "version": "1.174.2", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.174.2.tgz", + "integrity": "sha512-UgS7eRcDVvVz2XSJ09NMX8zBcdpFnPayfiWDNF3xEbJTsIu1GipkkYNrVlsWlq8U1PIrviNm6i0Dyq8daaxssw==", "license": "MIT", "dependencies": { + "core-js": "^3.38.1", "fflate": "^0.4.8", "preact": "^10.19.3", - "web-vitals": "^4.0.1" + "web-vitals": "^4.2.0" } }, "node_modules/preact": { @@ -19506,15 +19617,15 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "dev": true, "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { @@ -19531,6 +19642,19 @@ "node": ">= 0.8" } }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -20161,6 +20285,25 @@ "dev": true, "license": "MIT" }, + "node_modules/router": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.0.0.tgz", + "integrity": "sha512-dIM5zVoG8xhC6rnSN8uoAgFARwTE7BQs8YwHEvK0VCmfxQXMaOuA1uiR1IPwsW7JyK5iTt7Od/TC9StasS2NPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-flatten": "3.0.0", + "is-promise": "4.0.0", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "^8.0.0", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -20227,9 +20370,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.79.5", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.79.5.tgz", - "integrity": "sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==", + "version": "1.80.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.2.tgz", + "integrity": "sha512-9wXY8cGBlUmoUoT+vwOZOFCiS+naiWVjqlreN9ar9PudXbGwlMTFwCR5K9kB4dFumJ6ib98wZyAObJKsWf1nAA==", "dev": true, "license": "MIT", "dependencies": { @@ -20418,38 +20561,37 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz", + "integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==", "dev": true, "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "destroy": "^1.2.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^0.5.2", + "http-errors": "^2.0.0", + "mime-types": "^2.1.35", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/serialize-javascript": { @@ -20532,19 +20674,19 @@ } }, "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz", + "integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" } }, "node_modules/set-blocking": { @@ -20783,9 +20925,9 @@ } }, "node_modules/simple-statistics": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.5.tgz", - "integrity": "sha512-yw4aOnkvPLbL80zamrEKznAnk5cIIkjEcx/z0aQl+m/YKMmVufrnWgWJWRspqZtwh+ElZXRhJ0MtnUjFUQV5Ow==", + "version": "7.8.7", + "resolved": "https://registry.npmjs.org/simple-statistics/-/simple-statistics-7.8.7.tgz", + "integrity": "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ==", "license": "ISC", "engines": { "node": "*" @@ -22058,9 +22200,9 @@ } }, "node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", "license": "0BSD" }, "node_modules/tsutils": { @@ -22138,14 +22280,38 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz", + "integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==", "dev": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz", + "integrity": "sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz", + "integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.53.0" }, "engines": { "node": ">= 0.6" @@ -22444,9 +22610,9 @@ } }, "node_modules/vite": { - "version": "5.4.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", - "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "version": "5.4.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", + "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 77b79ed4f3d8..fb1e38afe1cd 100644 --- a/package.json +++ b/package.json @@ -14,20 +14,20 @@ ], "dependencies": { "@angular/animations": "18.2.8", - "@angular/cdk": "18.2.8", + "@angular/cdk": "18.2.9", "@angular/common": "18.2.8", "@angular/compiler": "18.2.8", "@angular/core": "18.2.8", "@angular/forms": "18.2.8", "@angular/localize": "18.2.8", - "@angular/material": "18.2.8", + "@angular/material": "18.2.9", "@angular/platform-browser": "18.2.8", "@angular/platform-browser-dynamic": "18.2.8", "@angular/router": "18.2.8", "@angular/service-worker": "18.2.8", "@ctrl/ngx-emoji-mart": "9.2.0", "@danielmoncada/angular-datetime-picker": "18.1.0", - "@fingerprintjs/fingerprintjs": "4.5.0", + "@fingerprintjs/fingerprintjs": "4.5.1", "@fortawesome/angular-fontawesome": "0.15.0", "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-regular-svg-icons": "6.6.0", @@ -63,17 +63,17 @@ "ngx-webstorage": "18.0.0", "papaparse": "5.4.1", "pdfjs-dist": "4.7.76", - "posthog-js": "1.167.0", + "posthog-js": "1.174.2", "rxjs": "7.8.1", "showdown": "2.1.0", "showdown-highlight": "3.1.0", "showdown-katex": "0.6.0", - "simple-statistics": "7.8.5", + "simple-statistics": "7.8.7", "smoothscroll-polyfill": "0.4.4", "sockjs-client": "1.6.1", "split.js": "1.6.5", "ts-cacheable": "1.0.10", - "tslib": "2.7.0", + "tslib": "2.8.0", "uuid": "10.0.0", "webstomp-client": "1.2.6", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", @@ -91,16 +91,16 @@ "eslint": "^9.12.0" }, "braces": "3.0.3", - "cookie": "0.7.1", - "critters": "0.0.24", + "cookie": "1.0.1", + "critters": "0.0.25", "debug": "4.3.7", "eslint-plugin-deprecation": { "eslint": "^9.12.0" }, "eslint-plugin-jest": { - "@typescript-eslint/eslint-plugin": "^8.8.0" + "@typescript-eslint/eslint-plugin": "^8.10.0" }, - "express": "4.21.0", + "express": "5.0.1", "jsdom": "25.0.1", "katex": "0.16.11", "postcss": "8.4.47", @@ -110,7 +110,7 @@ "showdown": "2.1.0" }, "tough-cookie": "5.0.0", - "vite": "5.4.8", + "vite": "5.4.9", "webpack-dev-middleware": "7.4.2", "webpack-dev-server": "5.1.0", "word-wrap": "1.2.5", @@ -119,13 +119,13 @@ }, "devDependencies": { "@angular-builders/jest": "18.0.0", - "@angular-devkit/build-angular": "18.2.8", + "@angular-devkit/build-angular": "18.2.9", "@angular-eslint/builder": "18.3.1", "@angular-eslint/eslint-plugin": "18.3.1", "@angular-eslint/eslint-plugin-template": "18.3.1", "@angular-eslint/schematics": "18.3.1", "@angular-eslint/template-parser": "18.3.1", - "@angular/cli": "18.2.8", + "@angular/cli": "18.2.9", "@angular/compiler-cli": "18.2.8", "@angular/language-service": "18.2.8", "@sentry/types": "8.34.0", @@ -134,14 +134,14 @@ "@types/dompurify": "3.0.5", "@types/jest": "29.5.13", "@types/lodash-es": "4.17.12", - "@types/node": "22.7.5", + "@types/node": "22.7.6", "@types/papaparse": "5.3.14", "@types/showdown": "2.0.6", "@types/smoothscroll-polyfill": "0.3.4", "@types/sockjs-client": "1.5.4", "@types/uuid": "10.0.0", - "@typescript-eslint/eslint-plugin": "8.8.1", - "@typescript-eslint/parser": "8.8.1", + "@typescript-eslint/eslint-plugin": "8.10.0", + "@typescript-eslint/parser": "8.10.0", "eslint": "9.12.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-deprecation": "3.0.0", @@ -162,7 +162,7 @@ "ng-mocks": "14.13.1", "prettier": "3.3.3", "rimraf": "6.0.1", - "sass": "1.79.5", + "sass": "1.80.2", "ts-jest": "29.2.5", "typescript": "5.5.4", "weak-napi": "2.0.2" From b52e25e960a1880cb9b6968f26bcacb3356e3482 Mon Sep 17 00:00:00 2001 From: Stephan Krusche Date: Fri, 18 Oct 2024 17:51:42 +0200 Subject: [PATCH 17/85] Development: Update server guidelines --- build.gradle | 16 ++++++++-------- gradle.properties | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/build.gradle b/build.gradle index a1388862f5a6..c84df836cc9a 100644 --- a/build.gradle +++ b/build.gradle @@ -243,7 +243,7 @@ dependencies { exclude module: "jaxb-api" } - implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.5" + implementation "org.gitlab4j:gitlab4j-api:6.0.0-rc.6" implementation "de.jplag:jplag:${jplag_version}" @@ -329,7 +329,7 @@ dependencies { // implementation "org.springdoc:springdoc-openapi-ui:1.8.0" // use the latest version to avoid security vulnerabilities - implementation "org.springframework:spring-webmvc:6.1.13" + implementation "org.springframework:spring-webmvc:6.1.14" implementation "com.vdurmont:semver4j:3.1.0" @@ -345,7 +345,7 @@ dependencies { implementation "tech.jhipster:jhipster-framework:${jhipster_dependencies_version}" implementation "org.springframework.boot:spring-boot-starter-cache:${spring_boot_version}" - implementation "io.micrometer:micrometer-registry-prometheus:1.13.5" + implementation "io.micrometer:micrometer-registry-prometheus:1.13.6" implementation "net.logstash.logback:logstash-logback-encoder:8.0" // Defines low-level streaming API, and includes JSON-specific implementations @@ -397,8 +397,8 @@ dependencies { implementation "org.springframework.boot:spring-boot-starter-oauth2-resource-server:${spring_boot_version}" implementation "org.springframework.boot:spring-boot-starter-oauth2-client:${spring_boot_version}" - implementation "org.springframework.ldap:spring-ldap-core:3.2.6" - implementation "org.springframework.data:spring-data-ldap:3.3.4" + implementation "org.springframework.ldap:spring-ldap-core:3.2.7" + implementation "org.springframework.data:spring-data-ldap:3.3.5" implementation("org.springframework.cloud:spring-cloud-starter-netflix-eureka-client:4.1.3") { // NOTE: these modules contain security vulnerabilities and are not needed @@ -409,8 +409,8 @@ dependencies { implementation "org.springframework.cloud:spring-cloud-commons:4.1.4" implementation "io.netty:netty-all:4.1.114.Final" - implementation "io.projectreactor.netty:reactor-netty:1.1.22" - implementation "org.springframework:spring-messaging:6.1.13" + implementation "io.projectreactor.netty:reactor-netty:1.1.23" + implementation "org.springframework:spring-messaging:6.1.14" implementation "org.springframework.retry:spring-retry:2.0.9" implementation "org.springframework.security:spring-security-config:${spring_security_version}" @@ -440,7 +440,7 @@ dependencies { implementation "org.bouncycastle:bcpkix-jdk18on:1.78.1" implementation "org.bouncycastle:bcprov-jdk18on:1.78.1" - implementation "com.mysql:mysql-connector-j:9.0.0" + implementation "com.mysql:mysql-connector-j:9.1.0" implementation "org.postgresql:postgresql:42.7.4" implementation "org.zalando:problem-spring-web:0.29.1" diff --git a/gradle.properties b/gradle.properties index 07ee79d07d25..02b6e1e550ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,15 +28,15 @@ slf4j_version=2.0.16 sentry_version=7.15.0 liquibase_version=4.29.2 docker_java_version=3.4.0 -logback_version=1.5.10 +logback_version=1.5.11 java_parser_version=3.26.2 -byte_buddy_version=1.15.4 +byte_buddy_version=1.15.5 # testing # make sure both versions are compatible junit_version=5.11.0 junit_platform_version=1.11.2 -mockito_version=5.14.1 +mockito_version=5.14.2 # gradle plugin version From 34f44102fcd8b2a6bdb2735e245d769c16b6bd42 Mon Sep 17 00:00:00 2001 From: Simon Entholzer <33342534+SimonEntholzer@users.noreply.github.com> Date: Sat, 19 Oct 2024 09:43:31 +0200 Subject: [PATCH 18/85] Integrated code lifecycle: Add auxiliary repository view (#9321) --- ...ogrammingExerciseParticipationService.java | 17 + .../service/ProgrammingExerciseService.java | 11 + ...grammingExerciseParticipationResource.java | 24 +- .../web/ProgrammingExerciseResource.java | 15 + .../AuxiliaryRepositoryResource.java | 220 +++++++ ...y-repository-buttons-detail.component.html | 9 +- .../detail-overview-list.component.html | 2 +- ...ming-exercise-management-routing.module.ts | 36 ++ ...gramming-exercise-participation.service.ts | 12 + .../services/programming-exercise.service.ts | 10 + .../code-editor/model/code-editor.model.ts | 5 +- .../code-editor-conflict-state.service.ts | 3 + ...ditor-domain-dependent-endpoint.service.ts | 2 + .../service/code-editor-domain.service.ts | 9 +- .../commit-history.component.ts | 63 +- .../repository-view.component.ts | 26 + ...iaryRepositoryResourceIntegrationTest.java | 552 ++++++++++++++++++ ...rammingExerciseIntegrationTestService.java | 8 + ...gExerciseParticipationIntegrationTest.java | 156 +++++ .../localvc/commit-history.component.spec.ts | 21 + .../localvc/repository-view.component.spec.ts | 39 ++ ...gramming-exercise-participation.service.ts | 1 + .../mock-programming-exercise.service.ts | 2 + .../programming-exercise.service.spec.ts | 22 + 24 files changed, 1242 insertions(+), 23 deletions(-) create mode 100644 src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java create mode 100644 src/test/java/de/tum/cit/aet/artemis/exercise/programming/AuxiliaryRepositoryResourceIntegrationTest.java diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java index 4731295ed3c3..fd5d93cc660c 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseParticipationService.java @@ -31,6 +31,7 @@ import de.tum.cit.aet.artemis.exercise.domain.participation.Participation; import de.tum.cit.aet.artemis.exercise.repository.ParticipationRepository; import de.tum.cit.aet.artemis.exercise.repository.TeamRepository; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseParticipation; import de.tum.cit.aet.artemis.programming.domain.ProgrammingExerciseStudentParticipation; @@ -517,6 +518,22 @@ public List getCommitInfos(ProgrammingExerciseParticipation parti } } + /** + * Get the commits information for the given auxiliary repository. + * + * @param auxiliaryRepository the auxiliary repository for which to get the commits. + * @return a list of CommitInfo DTOs containing author, timestamp, commit-hash and commit message. + */ + public List getAuxiliaryRepositoryCommitInfos(AuxiliaryRepository auxiliaryRepository) { + try { + return gitService.getCommitInfos(auxiliaryRepository.getVcsRepositoryUri()); + } + catch (GitAPIException e) { + log.error("Could not get commit infos for auxiliaryRepository {} with repository uri {}", auxiliaryRepository.getId(), auxiliaryRepository.getVcsRepositoryUri()); + return List.of(); + } + } + /** * Get the commits information for the test repository of the given participation's exercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java index 0cbf73da21ff..d4e7de493f52 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/ProgrammingExerciseService.java @@ -1048,4 +1048,15 @@ public ProgrammingExercise loadProgrammingExercise(long exerciseId, boolean with programmingExerciseTaskService.replaceTestIdsWithNames(programmingExercise); return programmingExercise; } + + /** + * Load a programming exercise, only with eager auxiliary repositories + * + * @param exerciseId the ID of the programming exercise to load + * @return the loaded programming exercise entity + */ + public ProgrammingExercise loadProgrammingExerciseWithAuxiliaryRepositories(long exerciseId) { + final Set fetchOptions = Set.of(AuxiliaryRepositories); + return programmingExerciseRepository.findByIdWithDynamicFetchElseThrow(exerciseId, fetchOptions); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java index d06fdf5fb975..be1c99c67be6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseParticipationResource.java @@ -49,6 +49,7 @@ import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; import de.tum.cit.aet.artemis.programming.dto.CommitInfoDTO; import de.tum.cit.aet.artemis.programming.dto.VcsAccessLogDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseStudentParticipationRepository; import de.tum.cit.aet.artemis.programming.repository.VcsAccessLogRepository; @@ -89,11 +90,13 @@ public class ProgrammingExerciseParticipationResource { private final Optional vcsAccessLogRepository; + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipationService programmingExerciseParticipationService, ResultRepository resultRepository, ParticipationRepository participationRepository, ProgrammingExerciseStudentParticipationRepository programmingExerciseStudentParticipationRepository, ProgrammingSubmissionService submissionService, ProgrammingExerciseRepository programmingExerciseRepository, AuthorizationCheckService authCheckService, ResultService resultService, ParticipationAuthorizationCheckService participationAuthCheckService, RepositoryService repositoryService, - StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository) { + StudentExamRepository studentExamRepository, Optional vcsAccessLogRepository, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { this.programmingExerciseParticipationService = programmingExerciseParticipationService; this.participationRepository = participationRepository; this.programmingExerciseStudentParticipationRepository = programmingExerciseStudentParticipationRepository; @@ -105,6 +108,7 @@ public ProgrammingExerciseParticipationResource(ProgrammingExerciseParticipation this.participationAuthCheckService = participationAuthCheckService; this.repositoryService = repositoryService; this.studentExamRepository = studentExamRepository; + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; this.vcsAccessLogRepository = vcsAccessLogRepository; } @@ -339,22 +343,25 @@ public ResponseEntity> getVcsAccessLogForParticipationRepo /** * GET /programming-exercise/{exerciseID}/commit-history/{repositoryType} : Get the commit history of a programming exercise repository. The repository type can be TEMPLATE or - * SOLUTION or TESTS. + * SOLUTION, TESTS or AUXILIARY. * Here we check is at least a teaching assistant for the exercise. * * @param exerciseID the id of the exercise for which to retrieve the commit history * @param repositoryType the type of the repository for which to retrieve the commit history + * @param repositoryId the id of the repository * @return the ResponseEntity with status 200 (OK) and with body a list of commitInfo DTOs with the commits information of the repository */ @GetMapping("programming-exercise/{exerciseID}/commit-history/{repositoryType}") @EnforceAtLeastTutor - public ResponseEntity> getCommitHistoryForTemplateSolutionOrTestRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType) { + public ResponseEntity> getCommitHistoryForTemplateSolutionTestOrAuxRepo(@PathVariable long exerciseID, @PathVariable RepositoryType repositoryType, + @RequestParam Optional repositoryId) { boolean isTemplateRepository = repositoryType.equals(RepositoryType.TEMPLATE); boolean isSolutionRepository = repositoryType.equals(RepositoryType.SOLUTION); boolean isTestRepository = repositoryType.equals(RepositoryType.TESTS); + boolean isAuxiliaryRepository = repositoryType.equals(RepositoryType.AUXILIARY); ProgrammingExerciseParticipation participation; - if (!isTemplateRepository && !isSolutionRepository && !isTestRepository) { + if (!isTemplateRepository && !isSolutionRepository && !isTestRepository && !isAuxiliaryRepository) { throw new BadRequestAlertException("Invalid repository type", ENTITY_NAME, "invalidRepositoryType"); } else if (isTemplateRepository) { @@ -364,6 +371,15 @@ else if (isTemplateRepository) { participation = programmingExerciseParticipationService.findSolutionParticipationByProgrammingExerciseId(exerciseID); } participationAuthCheckService.checkCanAccessParticipationElseThrow(participation); + + if (isAuxiliaryRepository) { + var auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(repositoryId.orElseThrow()); + if (!auxiliaryRepo.getExercise().getId().equals(exerciseID)) { + throw new BadRequestAlertException("Invalid repository id", ENTITY_NAME, "invalidRepositoryId"); + } + return ResponseEntity.ok(programmingExerciseParticipationService.getAuxiliaryRepositoryCommitInfos(auxiliaryRepo)); + } + if (isTestRepository) { return ResponseEntity.ok(programmingExerciseParticipationService.getCommitInfosTestRepo(participation)); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java index 2225baa81759..957f7435ce0d 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/ProgrammingExerciseResource.java @@ -534,6 +534,21 @@ public ResponseEntity getProgrammingExerciseWithTemplateAnd return ResponseEntity.ok(programmingExercise); } + /** + * GET /programming-exercises/:exerciseId/with-auxiliary-repository + * + * @param exerciseId the id of the programmingExercise to retrieve + * @return the ResponseEntity with status 200 (OK) and the programming exercise with template and solution participation, or with status 404 (Not Found) + */ + @GetMapping("programming-exercises/{exerciseId}/with-auxiliary-repository") + @EnforceAtLeastTutorInExercise + public ResponseEntity getProgrammingExerciseWithAuxiliaryRepository(@PathVariable long exerciseId) { + + log.debug("REST request to get programming exercise with auxiliary repositories: {}", exerciseId); + final var programmingExercise = programmingExerciseService.loadProgrammingExerciseWithAuxiliaryRepositories(exerciseId); + return ResponseEntity.ok(programmingExercise); + } + /** * DELETE /programming-exercises/:id : delete the "id" programmingExercise. * diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java new file mode 100644 index 000000000000..8c03cf7dae19 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/repository/AuxiliaryRepositoryResource.java @@ -0,0 +1,220 @@ +package de.tum.cit.aet.artemis.programming.web.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.security.Principal; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import jakarta.servlet.http.HttpServletRequest; + +import org.eclipse.jgit.api.errors.CheckoutConflictException; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.WrongRepositoryStateException; +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastTutor; +import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService; +import de.tum.cit.aet.artemis.core.service.ProfileService; +import de.tum.cit.aet.artemis.core.service.feature.Feature; +import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; +import de.tum.cit.aet.artemis.programming.domain.AuxiliaryRepository; +import de.tum.cit.aet.artemis.programming.domain.FileType; +import de.tum.cit.aet.artemis.programming.domain.ProgrammingExercise; +import de.tum.cit.aet.artemis.programming.domain.Repository; +import de.tum.cit.aet.artemis.programming.domain.VcsRepositoryUri; +import de.tum.cit.aet.artemis.programming.dto.FileMove; +import de.tum.cit.aet.artemis.programming.dto.RepositoryStatusDTO; +import de.tum.cit.aet.artemis.programming.repository.AuxiliaryRepositoryRepository; +import de.tum.cit.aet.artemis.programming.repository.ProgrammingExerciseRepository; +import de.tum.cit.aet.artemis.programming.service.GitService; +import de.tum.cit.aet.artemis.programming.service.RepositoryAccessService; +import de.tum.cit.aet.artemis.programming.service.RepositoryService; +import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCServletService; +import de.tum.cit.aet.artemis.programming.service.vcs.VersionControlService; + +/** + * Executes requested actions on the auxiliary repository of a programming exercise. Only available to TAs, Instructors and Admins. + */ +@Profile(PROFILE_CORE) +@RestController +@RequestMapping("api/auxiliary-repository/") +public class AuxiliaryRepositoryResource extends RepositoryResource { + + private final AuxiliaryRepositoryRepository auxiliaryRepositoryRepository; + + public AuxiliaryRepositoryResource(ProfileService profileService, UserRepository userRepository, AuthorizationCheckService authCheckService, GitService gitService, + RepositoryService repositoryService, Optional versionControlService, ProgrammingExerciseRepository programmingExerciseRepository, + RepositoryAccessService repositoryAccessService, Optional localVCServletService, AuxiliaryRepositoryRepository auxiliaryRepositoryRepository) { + super(profileService, userRepository, authCheckService, gitService, repositoryService, versionControlService, programmingExerciseRepository, repositoryAccessService, + localVCServletService); + this.auxiliaryRepositoryRepository = auxiliaryRepositoryRepository; + } + + @Override + Repository getRepository(Long auxiliaryRepositoryId, RepositoryActionType repositoryActionType, boolean pullOnGet) throws GitAPIException { + final var auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + User user = userRepository.getUserWithGroupsAndAuthorities(); + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepository.getExercise(), user, "auxiliary"); + final var repoUri = auxiliaryRepository.getVcsRepositoryUri(); + return gitService.getOrCheckoutRepository(repoUri, pullOnGet); + } + + @Override + VcsRepositoryUri getRepositoryUri(Long auxiliaryRepositoryId) { + var auxRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + return auxRepo.getVcsRepositoryUri(); + } + + @Override + boolean canAccessRepository(Long auxiliaryRepositoryId) { + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(false, auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId).getExercise(), + userRepository.getUserWithGroupsAndAuthorities(), "auxiliary"); + } + catch (AccessForbiddenException e) { + return false; + } + return true; + } + + @Override + String getOrRetrieveBranchOfDomainObject(Long auxiliaryRepositoryId) { + AuxiliaryRepository auxiliaryRepo = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = programmingExerciseRepository.findByIdElseThrow(auxiliaryRepo.getExercise().getId()); + return versionControlService.orElseThrow().getOrRetrieveBranchOfExercise(exercise); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/files", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity> getFiles(@PathVariable Long auxiliaryRepositoryId) { + return super.getFiles(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.getFile(auxiliaryRepositoryId, filename); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filePath, HttpServletRequest request) { + return super.createFile(auxiliaryRepositoryId, filePath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/folder", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity createFolder(@PathVariable Long auxiliaryRepositoryId, @RequestParam("folder") String folderPath, HttpServletRequest request) { + return super.createFolder(auxiliaryRepositoryId, folderPath, request); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/rename-file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity renameFile(@PathVariable Long auxiliaryRepositoryId, @RequestBody FileMove fileMove) { + return super.renameFile(auxiliaryRepositoryId, fileMove); + } + + @Override + @DeleteMapping(value = "{auxiliaryRepositoryId}/file", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity deleteFile(@PathVariable Long auxiliaryRepositoryId, @RequestParam("file") String filename) { + return super.deleteFile(auxiliaryRepositoryId, filename); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}/pull", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity pullChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.pullChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/commit", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity commitChanges(@PathVariable Long auxiliaryRepositoryId) { + return super.commitChanges(auxiliaryRepositoryId); + } + + @Override + @PostMapping(value = "{auxiliaryRepositoryId}/reset", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + @FeatureToggle(Feature.ProgrammingExercises) + public ResponseEntity resetToLastCommit(@PathVariable Long auxiliaryRepositoryId) { + return super.resetToLastCommit(auxiliaryRepositoryId); + } + + @Override + @GetMapping(value = "{auxiliaryRepositoryId}", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastTutor + public ResponseEntity getStatus(@PathVariable Long auxiliaryRepositoryId) throws GitAPIException { + return super.getStatus(auxiliaryRepositoryId); + } + + /** + * Update a list of files in an auxiliary repository based on the submission's content. + * + * @param auxiliaryRepositoryId of exercise to which the files belong + * @param submissions information about the file updates + * @param commit whether to commit after updating the files + * @param principal used to check if the user can update the files + * @return {Map} file submissions or the appropriate http error + */ + @PutMapping("{auxiliaryRepositoryId}/files") + @EnforceAtLeastTutor + public ResponseEntity> updateAuxiliaryFiles(@PathVariable("auxiliaryRepositoryId") Long auxiliaryRepositoryId, + @RequestBody List submissions, @RequestParam Boolean commit, Principal principal) { + + if (versionControlService.isEmpty()) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "VCSNotPresent"); + } + AuxiliaryRepository auxiliaryRepository = auxiliaryRepositoryRepository.findByIdElseThrow(auxiliaryRepositoryId); + ProgrammingExercise exercise = auxiliaryRepository.getExercise(); + + Repository repository; + try { + repositoryAccessService.checkAccessTestOrAuxRepositoryElseThrow(true, exercise, userRepository.getUserWithGroupsAndAuthorities(principal.getName()), "test"); + repository = gitService.getOrCheckoutRepository(auxiliaryRepository.getVcsRepositoryUri(), true); + } + catch (AccessForbiddenException e) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "noPermissions"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN, error.getMessage(), error); + } + catch (CheckoutConflictException | WrongRepositoryStateException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutConflict"); + throw new ResponseStatusException(HttpStatus.CONFLICT, error.getMessage(), error); + } + catch (GitAPIException ex) { + FileSubmissionError error = new FileSubmissionError(auxiliaryRepositoryId, "checkoutFailed"); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, error.getMessage(), error); + } + return saveFilesAndCommitChanges(auxiliaryRepositoryId, submissions, commit, repository); + } +} diff --git a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html index c22ec3d50039..26cf22829413 100644 --- a/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html +++ b/src/main/webapp/app/detail-overview-list/components/programming-auxiliary-repository-buttons-detail/programming-auxiliary-repository-buttons-detail.component.html @@ -1,9 +1,14 @@