From b4523f38d3c2d49bc8b7288dbf80e1ef786010ea Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sun, 26 Nov 2023 12:07:34 +0400 Subject: [PATCH 01/10] Add Athena service call for programming exercises; Adjust the feedback models to match the new DTOs; --- .../Suggestion/FeedbackSuggestion.swift | 2 +- .../ProgrammingFeedbackSuggestion.swift | 41 +++---------------- .../Suggestion/TextFeedbackSuggestion.swift | 11 +---- .../RoundedCornerLayoutManager.swift | 5 +-- .../Sources/CodeEditor/UXCodeTextView.swift | 2 +- .../Models/Feedback/AssessmentFeedback.swift | 6 ++- .../Services/Assessment/AthenaService.swift | 26 +++++++++--- .../Programming/CodeEditorViewModel.swift | 7 +++- .../Text/TextAssessmentViewModel.swift | 3 +- .../Assessment/CodeEditor/CodeView.swift | 2 +- 10 files changed, 43 insertions(+), 62 deletions(-) diff --git a/CodeEditor/Sources/CodeEditor/Models/Suggestion/FeedbackSuggestion.swift b/CodeEditor/Sources/CodeEditor/Models/Suggestion/FeedbackSuggestion.swift index df557344..c472a178 100644 --- a/CodeEditor/Sources/CodeEditor/Models/Suggestion/FeedbackSuggestion.swift +++ b/CodeEditor/Sources/CodeEditor/Models/Suggestion/FeedbackSuggestion.swift @@ -15,7 +15,7 @@ public protocol FeedbackSuggestion: Equatable, Decodable { var title: String { get } var description: String { get } var credits: Double { get } - var gradingInstruction: GradingInstruction? { get } + var structuredGradingInstructionId: Int? { get } var associatedAssessmentFeedbackId: UUID? { get set } // not decoded } diff --git a/CodeEditor/Sources/CodeEditor/Models/Suggestion/ProgrammingFeedbackSuggestion.swift b/CodeEditor/Sources/CodeEditor/Models/Suggestion/ProgrammingFeedbackSuggestion.swift index 86bb9949..847138da 100644 --- a/CodeEditor/Sources/CodeEditor/Models/Suggestion/ProgrammingFeedbackSuggestion.swift +++ b/CodeEditor/Sources/CodeEditor/Models/Suggestion/ProgrammingFeedbackSuggestion.swift @@ -22,42 +22,13 @@ public struct ProgrammingFeedbackSuggestion: FeedbackSuggestion, Decodable { public var credits: Double - public var gradingInstruction: GradingInstruction? + public var structuredGradingInstructionId: Int? public var associatedAssessmentFeedbackId: UUID? - // TODO: rename/remove the fields below once programming suggestions are integrated into Athena - public let participationId: Int - public let srcFile: String - public let fromLine: Int - public let toLine: Int - - enum DecodingKeys: String, CodingKey { - case exercise_id - case participation_id - case src_file - case from_line - case to_line - case text - case credits - } - - // TODO: correct the decoding logic below once programming suggestions are integrated into Athena - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: DecodingKeys.self) - id = Int.random(in: 1...999999) - exerciseId = try values.decode(Int.self, forKey: .exercise_id) - submissionId = -1 - title = "Suggestion" - participationId = try values.decode(Int.self, forKey: .participation_id) - srcFile = try values.decode(String.self, forKey: .src_file) - fromLine = try values.decode(Int.self, forKey: .from_line) - toLine = try values.decode(Int.self, forKey: .to_line) - description = try values.decode(String.self, forKey: .text) - credits = try values.decode(Double.self, forKey: .credits) - } - - public static func == (lhs: Self, rhs: Self) -> Bool { - lhs.id == rhs.id - } + public var filePath: String? + + public var lineStart: Int? + + public var lineEnd: Int? } diff --git a/CodeEditor/Sources/CodeEditor/Models/Suggestion/TextFeedbackSuggestion.swift b/CodeEditor/Sources/CodeEditor/Models/Suggestion/TextFeedbackSuggestion.swift index 42ec57ea..2277c8e9 100644 --- a/CodeEditor/Sources/CodeEditor/Models/Suggestion/TextFeedbackSuggestion.swift +++ b/CodeEditor/Sources/CodeEditor/Models/Suggestion/TextFeedbackSuggestion.swift @@ -21,7 +21,7 @@ public struct TextFeedbackSuggestion: FeedbackSuggestion { public let credits: Double - public let gradingInstruction: GradingInstruction? + public let structuredGradingInstructionId: Int? public var associatedAssessmentFeedbackId: UUID? @@ -29,15 +29,6 @@ public struct TextFeedbackSuggestion: FeedbackSuggestion { public let indexEnd: Int? - public static func == (lhs: TextFeedbackSuggestion, rhs: TextFeedbackSuggestion) -> Bool { - lhs.id == rhs.id - && lhs.exerciseId == rhs.exerciseId - && lhs.submissionId == rhs.submissionId - && lhs.title == rhs.title - && lhs.description == rhs.description - && lhs.credits == rhs.credits - } - public var textBlockContent: String? public var isReferenced: Bool { diff --git a/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift b/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift index 138e5c5e..df96f7c2 100644 --- a/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift +++ b/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift @@ -199,8 +199,7 @@ class RoundedCornerLayoutManager: NSLayoutManager { } private func drawProgrammingFeedbackSuggestions(_ paraNumber: Int, _ rect: CGRect, _ origin: CGPoint) { - let ctx = UIGraphicsGetCurrentContext() - guard let ctx else { + guard let ctx = UIGraphicsGetCurrentContext() else { return } UIGraphicsPushContext(ctx) @@ -208,7 +207,7 @@ class RoundedCornerLayoutManager: NSLayoutManager { ctx.setStrokeColor(CGColor(red: 0, green: 0.2, blue: 0.8, alpha: 0.8)) let programmingSuggestions = feedbackSuggestions.compactMap({ $0 as? ProgrammingFeedbackSuggestion }) - if programmingSuggestions.contains(where: { paraNumber + 1 >= $0.fromLine && paraNumber + 1 <= $0.toLine }) { + if programmingSuggestions.contains(where: { paraNumber + 1 >= $0.lineStart ?? 0 && paraNumber + 1 <= $0.lineEnd ?? 0 }) { let path = CGPath( rect: CGRect( x: rect.origin.x, diff --git a/CodeEditor/Sources/CodeEditor/UXCodeTextView.swift b/CodeEditor/Sources/CodeEditor/UXCodeTextView.swift index 955c8b5d..f2e7243a 100644 --- a/CodeEditor/Sources/CodeEditor/UXCodeTextView.swift +++ b/CodeEditor/Sources/CodeEditor/UXCodeTextView.swift @@ -408,7 +408,7 @@ final class UXCodeTextView: UXTextView, HighlightDelegate, UIScrollViewDelegate var lineNumber = 1 layoutManager.enumerateLineFragments(forGlyphRange: layoutManager.glyphRange(for: textContainer)) { rect, _, _, _, _ in let offset = self.calculateWrapOffsetOf(lineNumber) - if let feedback = self.feedbackSuggestions.first(where: { $0.fromLine == lineNumber - offset }) { + if let feedback = self.feedbackSuggestions.first(where: { $0.lineStart == lineNumber - offset }) { // TODO: get rid of the string interpolation once programming exercise suggestions are integrated into Athena if let lightbulb = self.buildLightbulbButton(rect: rect, feedbackId: "\(feedback.id)") { self.lightBulbs.append(lightbulb) diff --git a/Themis/Models/Feedback/AssessmentFeedback.swift b/Themis/Models/Feedback/AssessmentFeedback.swift index acec636d..20c75bd2 100644 --- a/Themis/Models/Feedback/AssessmentFeedback.swift +++ b/Themis/Models/Feedback/AssessmentFeedback.swift @@ -73,8 +73,10 @@ extension AssessmentFeedback { var newIncompleteFeedbackDetail = incompleteFeedbackDetail if var incompleteFeedbackDetail = incompleteFeedbackDetail as? ProgrammingFeedbackDetail, - let codeSuggestion = suggestion as? ProgrammingFeedbackSuggestion { - let lines = NSRange(location: codeSuggestion.fromLine, length: codeSuggestion.toLine - codeSuggestion.fromLine) + let codeSuggestion = suggestion as? ProgrammingFeedbackSuggestion, + let lineStart = codeSuggestion.lineStart, + let lineEnd = codeSuggestion.lineEnd { + let lines = NSRange(location: lineStart, length: lineEnd - lineStart) incompleteFeedbackDetail.lines = lines newIncompleteFeedbackDetail = incompleteFeedbackDetail } diff --git a/Themis/Services/Assessment/AthenaService.swift b/Themis/Services/Assessment/AthenaService.swift index 35651254..e8ec7228 100644 --- a/Themis/Services/Assessment/AthenaService.swift +++ b/Themis/Services/Assessment/AthenaService.swift @@ -16,10 +16,10 @@ struct AthenaService { let client = APIClient() - // MARK: - Get Feedback Suggestions - private struct GetFeedbackSuggestionsRequest: APIRequest { - typealias Response = [TextFeedbackSuggestion] + private struct GetFeedbackSuggestionsRequest: APIRequest { + typealias Response = [ResponseType] + let exerciseType: String let exerciseId: Int let submissionId: Int @@ -28,11 +28,25 @@ struct AthenaService { } var resourceName: String { - "api/athena/text-exercises/\(exerciseId)/submissions/\(submissionId)/feedback-suggestions" + "api/athena/\(exerciseType)-exercises/\(exerciseId)/submissions/\(submissionId)/feedback-suggestions" } } - func getFeedbackSuggestions(exerciseId: Int, submissionId: Int) async throws -> [TextFeedbackSuggestion] { - try await client.sendRequest(GetFeedbackSuggestionsRequest(exerciseId: exerciseId, submissionId: submissionId)).get().0 + // MARK: - Get Text Feedback Suggestions + func getTextFeedbackSuggestions(exerciseId: Int, submissionId: Int) async throws -> [TextFeedbackSuggestion] { + try await client + .sendRequest(GetFeedbackSuggestionsRequest(exerciseType: TextExercise.type, + exerciseId: exerciseId, + submissionId: submissionId)) + .get().0 + } + + // MARK: - Get Programming Feedback Suggestions + func getProgrammingFeedbackSuggestions(exerciseId: Int, submissionId: Int) async throws -> [ProgrammingFeedbackSuggestion] { + try await client + .sendRequest(GetFeedbackSuggestionsRequest(exerciseType: ProgrammingExercise.type, + exerciseId: exerciseId, + submissionId: submissionId)) + .get().0 } } diff --git a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift index 2f9416e0..b16c4dee 100644 --- a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift @@ -208,8 +208,11 @@ class CodeEditorViewModel: ExerciseRendererViewModel { extension CodeEditorViewModel { @MainActor func addFeedbackSuggestionInlineHighlight(feedbackSuggestion: ProgrammingFeedbackSuggestion, feedbackId: UUID) { - if let file = selectedFile, let code = file.code { - guard let range = getLineRange(text: code, fromLine: feedbackSuggestion.fromLine, toLine: feedbackSuggestion.toLine) else { + if let file = selectedFile, + let code = file.code, + let lineStart = feedbackSuggestion.lineStart, + let lineEnd = feedbackSuggestion.lineEnd { + guard let range = getLineRange(text: code, fromLine: lineStart, toLine: lineEnd) else { return } appendHighlight(feedbackId: feedbackId, range: range, path: file.path) diff --git a/Themis/ViewModels/Assessment/Text/TextAssessmentViewModel.swift b/Themis/ViewModels/Assessment/Text/TextAssessmentViewModel.swift index d638255a..f40cb810 100644 --- a/Themis/ViewModels/Assessment/Text/TextAssessmentViewModel.swift +++ b/Themis/ViewModels/Assessment/Text/TextAssessmentViewModel.swift @@ -107,7 +107,8 @@ class TextAssessmentViewModel: AssessmentViewModel { } do { - var fetchedSuggestions = try await AthenaService().getFeedbackSuggestions(exerciseId: exerciseId, submissionId: submissionId) + var fetchedSuggestions = try await AthenaService().getTextFeedbackSuggestions(exerciseId: exerciseId, + submissionId: submissionId) log.verbose("Fetched \(fetchedSuggestions.count) suggestions") fetchedSuggestions = setTextBlockContent(of: fetchedSuggestions) diff --git a/Themis/Views/Assessment/CodeEditor/CodeView.swift b/Themis/Views/Assessment/CodeEditor/CodeView.swift index 8648b62f..1fe357db 100644 --- a/Themis/Views/Assessment/CodeEditor/CodeView.swift +++ b/Themis/Views/Assessment/CodeEditor/CodeView.swift @@ -47,7 +47,7 @@ struct CodeView: View { scrollUtils: cvm.scrollUtils, diffLines: file.diffLines, isNewFile: file.isNewFile, - feedbackSuggestions: hideSuggestions ? [] : cvm.feedbackSuggestions.filter { $0.srcFile == file.path }, + feedbackSuggestions: hideSuggestions ? [] : cvm.feedbackSuggestions.filter { $0.filePath == file.path }, selectedFeedbackSuggestionId: $cvm.selectedFeedbackSuggestionId ) ) From f79ace58c87ad7b0bc7c2b02154c860719875e7c Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sat, 2 Dec 2023 20:00:35 +0400 Subject: [PATCH 02/10] Change lightbulb button style --- .../Sources/CodeEditor/LighbulbButton.swift | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/CodeEditor/Sources/CodeEditor/LighbulbButton.swift b/CodeEditor/Sources/CodeEditor/LighbulbButton.swift index 56ef42b4..96cf575b 100644 --- a/CodeEditor/Sources/CodeEditor/LighbulbButton.swift +++ b/CodeEditor/Sources/CodeEditor/LighbulbButton.swift @@ -25,12 +25,23 @@ final class LightbulbButton: UIButton { fatalError("not implemented") } - private func setup() { - let image = UIImage(systemName: "lightbulb.fill") - setImage(image, for: .normal) - imageView?.contentMode = .scaleAspectFit - imageView?.tintColor = .yellow + self.backgroundColor = UIColor(netHex: 0xB54EFE) + self.layer.cornerRadius = 6 + + let image = UIImage(named: "SuggestedFeedbackSymbol")?.withRenderingMode(.alwaysTemplate) + let customImgView = UIImageView() + customImgView.image = image + customImgView.tintColor = .white + customImgView.contentMode = .scaleAspectFit + self.addSubview(customImgView) + + customImgView.translatesAutoresizingMaskIntoConstraints = false + customImgView.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true + customImgView.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true + customImgView.widthAnchor.constraint(equalTo: self.widthAnchor, multiplier: 0.5) .isActive = true + customImgView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.6) .isActive = true + addTarget(self, action: #selector(self.onLightBulbTap), for: .touchUpInside) } @@ -39,3 +50,9 @@ final class LightbulbButton: UIButton { toggleShowAddFeedback() } } + +#Preview { + LightbulbButton(frame: .init(x: 0, y: 0, width: 350, height: 350), + setSelectedFeedbackSuggestionId: {}, + toggleShowAddFeedback: {}) +} From d7ea693e1258146c2181e2d945f66b49ff7a0af2 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sat, 2 Dec 2023 20:38:37 +0400 Subject: [PATCH 03/10] Use new feedback suggestion color for the vertical lines indicating suggestion --- .../CodeEditor/Extensions/UIColorExtension.swift | 12 ++++++++++++ CodeEditor/Sources/CodeEditor/LighbulbButton.swift | 2 +- .../CodeEditor/RoundedCornerLayoutManager.swift | 3 +-- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 CodeEditor/Sources/CodeEditor/Extensions/UIColorExtension.swift diff --git a/CodeEditor/Sources/CodeEditor/Extensions/UIColorExtension.swift b/CodeEditor/Sources/CodeEditor/Extensions/UIColorExtension.swift new file mode 100644 index 00000000..c3119a04 --- /dev/null +++ b/CodeEditor/Sources/CodeEditor/Extensions/UIColorExtension.swift @@ -0,0 +1,12 @@ +// +// UIColorExtension.swift +// +// +// Created by Tarlan Ismayilsoy on 02.12.23. +// + +import UIKit + +extension UIColor { + static let feedbackSuggestionColor = UIColor(netHex: 0xB54EFE) +} diff --git a/CodeEditor/Sources/CodeEditor/LighbulbButton.swift b/CodeEditor/Sources/CodeEditor/LighbulbButton.swift index 96cf575b..9f6a7abb 100644 --- a/CodeEditor/Sources/CodeEditor/LighbulbButton.swift +++ b/CodeEditor/Sources/CodeEditor/LighbulbButton.swift @@ -26,7 +26,7 @@ final class LightbulbButton: UIButton { } private func setup() { - self.backgroundColor = UIColor(netHex: 0xB54EFE) + self.backgroundColor = .feedbackSuggestionColor self.layer.cornerRadius = 6 let image = UIImage(named: "SuggestedFeedbackSymbol")?.withRenderingMode(.alwaysTemplate) diff --git a/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift b/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift index df96f7c2..084bee0e 100644 --- a/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift +++ b/CodeEditor/Sources/CodeEditor/RoundedCornerLayoutManager.swift @@ -203,8 +203,7 @@ class RoundedCornerLayoutManager: NSLayoutManager { return } UIGraphicsPushContext(ctx) - ctx.setFillColor(CGColor(red: 0, green: 0.2, blue: 0.8, alpha: 0.8)) - ctx.setStrokeColor(CGColor(red: 0, green: 0.2, blue: 0.8, alpha: 0.8)) + ctx.setFillColor(UIColor.feedbackSuggestionColor.cgColor) let programmingSuggestions = feedbackSuggestions.compactMap({ $0 as? ProgrammingFeedbackSuggestion }) if programmingSuggestions.contains(where: { paraNumber + 1 >= $0.lineStart ?? 0 && paraNumber + 1 <= $0.lineEnd ?? 0 }) { From c20864cdffea85576877eeaed2fd5ee5ede0c929 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sat, 16 Dec 2023 20:47:36 +0400 Subject: [PATCH 04/10] Delete ThemisAPI; Fetch and show programming suggestions; --- Themis.xcodeproj/project.pbxproj | 4 - Themis/API/ThemisAPI.swift | 92 ------------------- .../Assessment/AssessmentViewModel.swift | 14 --- .../Programming/CodeEditorViewModel.swift | 44 +++++++-- .../Mock/MockAssessmentViewModel.swift | 2 - Themis/Views/Assessment/AssessmentView.swift | 4 +- .../Assessment/CodeEditor/CodeView.swift | 6 +- .../EditFeedback/EditFeedbackViewBase.swift | 3 + .../ProgrammingAssessmentView.swift | 10 +- 9 files changed, 47 insertions(+), 132 deletions(-) delete mode 100644 Themis/API/ThemisAPI.swift diff --git a/Themis.xcodeproj/project.pbxproj b/Themis.xcodeproj/project.pbxproj index a6469330..c8085a0b 100644 --- a/Themis.xcodeproj/project.pbxproj +++ b/Themis.xcodeproj/project.pbxproj @@ -159,7 +159,6 @@ 8384C43129210474008CCB4D /* FeedbackListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C43029210474008CCB4D /* FeedbackListView.swift */; }; 8384C439292A8592008CCB4D /* FeedbackCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C438292A8592008CCB4D /* FeedbackCellView.swift */; }; 8384C441292AA1FB008CCB4D /* GradingCriteriaCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8384C440292AA1FB008CCB4D /* GradingCriteriaCellView.swift */; }; - A90D8C7F29818AB70066DBFD /* ThemisAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = A90D8C7E29818AB70066DBFD /* ThemisAPI.swift */; }; A9752D792992AA8B004441D1 /* ExamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9752D782992AA8B004441D1 /* ExamSection.swift */; }; A9752D7B2992AAEB004441D1 /* ExamSectionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9752D7A2992AAEB004441D1 /* ExamSectionDetailView.swift */; }; DA0687C2292FB4870091B88A /* SubmissionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0687C1292FB4870091B88A /* SubmissionListView.swift */; }; @@ -374,7 +373,6 @@ 8384C438292A8592008CCB4D /* FeedbackCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FeedbackCellView.swift; path = Themis/Views/Assessment/CorrectionSidebar/FeedbackCellView.swift; sourceTree = SOURCE_ROOT; }; 8384C440292AA1FB008CCB4D /* GradingCriteriaCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = GradingCriteriaCellView.swift; path = Themis/Views/Assessment/CorrectionSidebar/GradingCriteriaCellView.swift; sourceTree = SOURCE_ROOT; }; 83E729E82915786600DB7B36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - A90D8C7E29818AB70066DBFD /* ThemisAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemisAPI.swift; sourceTree = ""; }; A9752D782992AA8B004441D1 /* ExamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamSection.swift; sourceTree = ""; }; A9752D7A2992AAEB004441D1 /* ExamSectionDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamSectionDetailView.swift; sourceTree = ""; }; DA0687C1292FB4870091B88A /* SubmissionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionListView.swift; sourceTree = ""; }; @@ -1102,7 +1100,6 @@ children = ( E2E460DC29292ECF00ECC0A5 /* Stack.swift */, DAFFA3FA297F066200EA72B8 /* ArtemisDateHelpers.swift */, - A90D8C7E29818AB70066DBFD /* ThemisAPI.swift */, ); name = API; path = Themis/API; @@ -1544,7 +1541,6 @@ 65B49F472A080A9900C9A45F /* ToolbarRedoButton.swift in Sources */, 65F019012A1CCC0300BB1C98 /* UnknownSubmissionServiceImpl.swift in Sources */, E2192ED3291E47820092CE58 /* RESTController.swift in Sources */, - A90D8C7F29818AB70066DBFD /* ThemisAPI.swift in Sources */, E1DC9BA3293D241100674F5B /* SubmissionSearchView.swift in Sources */, DA3F0F01294A2DF800A7B807 /* AddFeedbackView.swift in Sources */, DA295CC6293A67EB000E04DC /* AuthenticationEnvironmentVariables.swift in Sources */, diff --git a/Themis/API/ThemisAPI.swift b/Themis/API/ThemisAPI.swift deleted file mode 100644 index 2d75a83b..00000000 --- a/Themis/API/ThemisAPI.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// ThemisAPI.swift -// Themis -// -// Created by Andreas Cselovszky on 25.01.23. -// - -import Foundation -import CodeEditor - -/// Handles communication with a Themis ML server -enum ThemisAPI { - static let themisMLRestController = RESTController(baseURL: URL(string: themisServer ?? "https://ios2223cit.ase.cit.tum.de")!) - - private static func buildAuthenticatedRequest(_ request: Request) throws -> Request { - var request = request - - if let tokenFromCookie = HTTPCookieStorage.shared.cookies(for: RESTController.shared.baseURL)?.first { - request.setBeaererToken(token: tokenFromCookie.value) - } - - return request - } - - static func sendRequest(_ type: T.Type, request: Request) async throws -> T { - try await themisMLRestController.sendRequest( - buildAuthenticatedRequest(request) - ) - } - - static func sendRequest(_ type: T.Type, request: Request, decode: (Data) throws -> T) async throws -> T { - try await themisMLRestController.sendRequest( - buildAuthenticatedRequest(request), - decode: decode) - } - - static func sendRequest(request: Request) async throws { - try await themisMLRestController.sendRequest( - buildAuthenticatedRequest(request) - ) - } -} - -struct NotifyRequest: Codable { - let exercise_id: Int - let participation_id: Int - let server: String -} - -struct FeedbackSuggestionRequest: Codable { - let server: String - let exercise_id: Int - let participation_id: Int -} - -extension ThemisAPI { - /// notifies Themis-ML about new feedback that should be pulled - static func notifyAboutNewFeedback(exerciseId: Int, participationId: Int) async throws { - let request = Request( - method: .post, - path: "/feedback_suggestions/notify", - body: NotifyRequest( - exercise_id: exerciseId, - participation_id: participationId, - server: removeTrailingSlash(from: RESTController.shared.baseURL.absoluteString) - ) - ) - try await sendRequest(request: request) - } - - /// gets a feedback suggestion for a submission from Themis-ML - static func getFeedbackSuggestions(exerciseId: Int, participationId: Int) async throws -> [ProgrammingFeedbackSuggestion] { - let request = Request( - method: .post, - path: "/feedback_suggestions", - body: FeedbackSuggestionRequest( - server: removeTrailingSlash(from: RESTController.shared.baseURL.absoluteString), - exercise_id: exerciseId, - participation_id: participationId - ) - ) - return try await sendRequest([ProgrammingFeedbackSuggestion].self, request: request) - } - - private static func removeTrailingSlash(from string: String) -> String { - if string.last == "/" { - return String(string.dropLast()) - } - - return string - } -} diff --git a/Themis/ViewModels/Assessment/AssessmentViewModel.swift b/Themis/ViewModels/Assessment/AssessmentViewModel.swift index 80db4f1e..c8aadb58 100644 --- a/Themis/ViewModels/Assessment/AssessmentViewModel.swift +++ b/Themis/ViewModels/Assessment/AssessmentViewModel.swift @@ -207,20 +207,6 @@ class AssessmentViewModel: ObservableObject { } } - func notifyThemisML() async { // TODO: Make this function more general once Athene is integrated - guard let participationId = participation?.id, - case .programming = exercise - else { - return - } - - do { - try await ThemisAPI.notifyAboutNewFeedback(exerciseId: exercise.id, participationId: participationId) - } catch { - log.error(String(describing: error)) - } - } - func getFeedback(byId id: UUID) -> AssessmentFeedback? { assessmentResult.feedbacks.first(where: { $0.id == id }) } diff --git a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift index b16c4dee..e586920c 100644 --- a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift @@ -47,7 +47,7 @@ class CodeEditorViewModel: ExerciseRendererViewModel { return nil } - var selectedFeedbackSuggestion: (any FeedbackSuggestion)? { + var selectedFeedbackSuggestion: ProgrammingFeedbackSuggestion? { feedbackSuggestions.first { "\($0.id)" == selectedFeedbackSuggestionId } } @@ -64,22 +64,28 @@ class CodeEditorViewModel: ExerciseRendererViewModel { return files } - /// Sets this VM up based on the given participation + /// Sets this VM up based on the given parameters @MainActor - func setup(basedOn participationId: Int?, _ exerciseId: Int?, _ assessmentResult: AssessmentResult) async { - guard let participationId, let exerciseId else { + func setup(basedOn assessmentVM: AssessmentViewModel, _ exerciseId: Int?) async { + guard let participationId = assessmentVM.participation?.id, + let exerciseId else { log.error("Setup failed due to missing participation ID or exercise ID") return } reset() + let assessmentResult = assessmentVM.assessmentResult + await withTaskGroup(of: Void.self) { group in group.addTask { [weak self] in await self?.initFileTree(participationId: participationId, repositoryType: .student) await self?.loadInlineHighlightsIfEmpty(assessmentResult: assessmentResult, participationId: participationId) } - group.addTask { [weak self] in - await self?.getFeedbackSuggestions(participationId: participationId, exerciseId: exerciseId) + + if let submissionId = assessmentVM.submission?.id { + group.addTask { [weak self] in + await self?.getFeedbackSuggestions(submissionId: submissionId, exerciseId: exerciseId) + } } } @@ -148,11 +154,15 @@ class CodeEditorViewModel: ExerciseRendererViewModel { } } + // TODO: make sure this is not called for old assessments and in read-only mode @MainActor - func getFeedbackSuggestions(participationId: Int, exerciseId: Int) async { + private func getFeedbackSuggestions(submissionId: Int, exerciseId: Int) async { do { - self.feedbackSuggestions = try await ThemisAPI.getFeedbackSuggestions(exerciseId: exerciseId, participationId: participationId) - log.info("Got \(self.feedbackSuggestions.count) feedback suggestions") + var fetchedSuggestions = try await AthenaService().getProgrammingFeedbackSuggestions(exerciseId: exerciseId, + submissionId: submissionId) + log.verbose("Fetched \(fetchedSuggestions.count) suggestions") + + self.feedbackSuggestions = fetchedSuggestions } catch { log.error(String(describing: error)) } @@ -202,6 +212,22 @@ class CodeEditorViewModel: ExerciseRendererViewModel { feedback.detail = detail assessmentResult.updateFeedback(feedback: feedback) } + + /// Generates a `ProgrammingFeedbackDetail` instance based on the available data. Some fields might be missing + func generateIncompleteFeedbackDetail() -> ProgrammingFeedbackDetail { + if let selectedFeedbackSuggestion, + let lineStart = selectedFeedbackSuggestion.lineStart, + let lineEnd = selectedFeedbackSuggestion.lineEnd { // Generate detail for the selected suggestion + let nsRange = (lineStart ..< lineEnd).toNSRange() + return ProgrammingFeedbackDetail(file: selectedFile, + lines: nsRange, + columns: nil) + } else { // Generate detail for a new feedback + return ProgrammingFeedbackDetail(file: selectedFile, + lines: selectedSectionParsed?.0, + columns: selectedSectionParsed?.1) + } + } } // MARK: - Highlight-Related Functions diff --git a/Themis/ViewModels/Mock/MockAssessmentViewModel.swift b/Themis/ViewModels/Mock/MockAssessmentViewModel.swift index 1e63e7f3..d654a9a1 100644 --- a/Themis/ViewModels/Mock/MockAssessmentViewModel.swift +++ b/Themis/ViewModels/Mock/MockAssessmentViewModel.swift @@ -59,6 +59,4 @@ class MockAssessmentViewModel: AssessmentViewModel { override func saveAssessment() async {} override func submitAssessment() async {} - - override func notifyThemisML() async {} } diff --git a/Themis/Views/Assessment/AssessmentView.swift b/Themis/Views/Assessment/AssessmentView.swift index 82459840..fc1b75d9 100644 --- a/Themis/Views/Assessment/AssessmentView.swift +++ b/Themis/Views/Assessment/AssessmentView.swift @@ -137,7 +137,6 @@ struct AssessmentView: View { Button("Yes") { Task { await assessmentVM.submitAssessment() - await assessmentVM.notifyThemisML() showNavigationOptions.toggle() } } @@ -168,8 +167,7 @@ struct AssessmentView: View { case .programming: ProgrammingAssessmentView(assessmentVM: assessmentVM, assessmentResult: assessmentResult, - exercise: exercise, - submissionId: submissionId) + exercise: exercise) case .text: TextAssessmentView(assessmentVM: assessmentVM, assessmentResult: assessmentResult, diff --git a/Themis/Views/Assessment/CodeEditor/CodeView.swift b/Themis/Views/Assessment/CodeEditor/CodeView.swift index 1fe357db..592a9b25 100644 --- a/Themis/Views/Assessment/CodeEditor/CodeView.swift +++ b/Themis/Views/Assessment/CodeEditor/CodeView.swift @@ -21,6 +21,10 @@ struct CodeView: View { readOnly || !cvm.allowsInlineFeedbackOperations } + private var suggestions: [ProgrammingFeedbackSuggestion] { + cvm.feedbackSuggestions.filter { $0.filePath?.appendingLeadingSlashIfMissing() == file.path } + } + private var highlightedRanges: [HighlightedRange] { guard cvm.allowsInlineFeedbackOperations, let highlights = cvm.inlineHighlights[file.path] else { @@ -47,7 +51,7 @@ struct CodeView: View { scrollUtils: cvm.scrollUtils, diffLines: file.diffLines, isNewFile: file.isNewFile, - feedbackSuggestions: hideSuggestions ? [] : cvm.feedbackSuggestions.filter { $0.filePath == file.path }, + feedbackSuggestions: hideSuggestions ? [] : suggestions, selectedFeedbackSuggestionId: $cvm.selectedFeedbackSuggestionId ) ) diff --git a/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift b/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift index e2e621c8..9e053355 100644 --- a/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift +++ b/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift @@ -219,6 +219,9 @@ struct EditFeedbackViewBase: View { self.score = feedback.baseFeedback.credits ?? 0.0 self.linkedGradingInstruction = feedback.baseFeedback.gradingInstruction } + } else if let feedbackSuggestion { + self.detailText = feedbackSuggestion.description + self.score = feedbackSuggestion.credits } } } diff --git a/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift b/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift index d651c6e5..603175de 100644 --- a/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift +++ b/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift @@ -10,8 +10,6 @@ struct ProgrammingAssessmentView: View { let exercise: Exercise - var submissionId: Int? - private let didStartNextAssessment = NotificationCenter.default.publisher(for: NSNotification.Name.nextAssessmentStarted) var body: some View { @@ -53,9 +51,7 @@ struct ProgrammingAssessmentView: View { assessmentResult: assessmentVM.assessmentResult, feedbackDelegate: codeEditorVM, incompleteFeedback: AssessmentFeedback(scope: .inline, - detail: ProgrammingFeedbackDetail(file: codeEditorVM.selectedFile, - lines: codeEditorVM.selectedSectionParsed?.0, - columns: codeEditorVM.selectedSectionParsed?.1)), + detail: codeEditorVM.generateIncompleteFeedbackDetail()), feedbackSuggestion: codeEditorVM.selectedFeedbackSuggestion, scope: .inline, gradingCriteria: assessmentVM.gradingCriteria, @@ -77,11 +73,11 @@ struct ProgrammingAssessmentView: View { .task { assessmentVM.pencilModeDisabled = true await assessmentVM.initSubmission() - await codeEditorVM.setup(basedOn: assessmentVM.participation?.id, exercise.baseExercise.id, assessmentVM.assessmentResult) + await codeEditorVM.setup(basedOn: assessmentVM, exercise.baseExercise.id) } .onReceive(didStartNextAssessment, perform: { _ in Task { - await codeEditorVM.setup(basedOn: assessmentVM.participation?.id, exercise.baseExercise.id, assessmentVM.assessmentResult) + await codeEditorVM.setup(basedOn: assessmentVM, exercise.baseExercise.id) } }) .onChange(of: assessmentVM.pencilModeDisabled) { _, newValue in From e2d36d70b708a1dba56e8fe7a182e7bee2ca2dc3 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sun, 17 Dec 2023 14:41:03 +0400 Subject: [PATCH 05/10] Add logic for accepting and deleting suggestions --- .../Models/Feedback/AssessmentFeedback.swift | 7 +++- .../Programming/CodeEditorViewModel.swift | 14 ++++++++ .../EditFeedback/EditFeedbackViewBase.swift | 36 +++++++++++++++---- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/Themis/Models/Feedback/AssessmentFeedback.swift b/Themis/Models/Feedback/AssessmentFeedback.swift index 20c75bd2..78048baf 100644 --- a/Themis/Models/Feedback/AssessmentFeedback.swift +++ b/Themis/Models/Feedback/AssessmentFeedback.swift @@ -29,12 +29,17 @@ public struct AssessmentFeedback: Identifiable { init( baseFeedback: Feedback = Feedback(), scope: ThemisFeedbackScope, - detail: (any FeedbackDetail)? = nil + detail: (any FeedbackDetail)? = nil, + textPrefix: String? = nil ) { self.baseFeedback = baseFeedback self.scope = scope self.detail = detail self.detail?.buildArtemisFeedback(feedback: &self.baseFeedback) + + if let textPrefix { + self.baseFeedback.text = textPrefix + (self.baseFeedback.text ?? "") + } } mutating func setBaseFeedback(to feedback: Feedback) { diff --git a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift index e586920c..dde581da 100644 --- a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift @@ -241,12 +241,18 @@ extension CodeEditorViewModel { guard let range = getLineRange(text: code, fromLine: lineStart, toLine: lineEnd) else { return } + deleteHighlight(for: feedbackSuggestion) appendHighlight(feedbackId: feedbackId, range: range, path: file.path) } undoManager.endUndoGrouping() // undo group with addFeedback in AssessmentResult } + @MainActor + func deleteHighlight(for feedbackSuggestion: ProgrammingFeedbackSuggestion) { + feedbackSuggestions.removeAll(where: { $0.id == feedbackSuggestion.id }) + } + @MainActor func addInlineHighlight(feedbackId: UUID) { if let file = selectedFile, let selectedSection = selectedSection { @@ -414,6 +420,14 @@ extension CodeEditorViewModel: FeedbackDelegate { addFeedbackSuggestionInlineHighlight(feedbackSuggestion: suggestion, feedbackId: feedback.id) } + @MainActor + func onFeedbackSuggestionDiscard(_ suggestion: any FeedbackSuggestion) { + guard let suggestion = suggestion as? ProgrammingFeedbackSuggestion else { + return + } + deleteHighlight(for: suggestion) + } + @MainActor func onFeedbackCellTap(_ feedback: AssessmentFeedback, participationId: Int?, templateParticipationId: Int?) { guard let file = (feedback.detail as? ProgrammingFeedbackDetail)?.file, diff --git a/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift b/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift index 9e053355..2643f734 100644 --- a/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift +++ b/Themis/Views/Assessment/CorrectionSidebar/EditFeedback/EditFeedbackViewBase.swift @@ -178,21 +178,45 @@ struct EditFeedbackViewBase: View { } private func createFeedback() { + // We need to add a prefix for programming feedback suggestions + var baseFeedbackTextPrefix: String? = nil + + if let feedbackSuggestion = feedbackSuggestion as? ProgrammingFeedbackSuggestion { + if detailText == feedbackSuggestion.description && score == feedbackSuggestion.credits { + baseFeedbackTextPrefix = Feedback.feedbackSuggestionAcceptedIdentifier + } else { + baseFeedbackTextPrefix = Feedback.feedbackSuggestionAdaptedIdentifier + } + } + if scope == .inline { let feedback = AssessmentFeedback(baseFeedback: Feedback(detailText: detailText, credits: score, type: .MANUAL, gradingInstruction: linkedGradingInstruction), scope: scope, - detail: incompleteFeedback?.detail) + detail: incompleteFeedback?.detail, + textPrefix: baseFeedbackTextPrefix) assessmentResult.addFeedback(feedback: feedback) - feedbackDelegate?.onFeedbackCreation(feedback) + + if let feedbackSuggestion { + feedbackDelegate?.onFeedbackSuggestionSelection(feedbackSuggestion, feedback) + } else { + feedbackDelegate?.onFeedbackCreation(feedback) + } } else { - assessmentResult.addFeedback(feedback: AssessmentFeedback(baseFeedback: Feedback(detailText: detailText, - credits: score, - type: .MANUAL_UNREFERENCED), - scope: scope)) + let feedback = AssessmentFeedback(baseFeedback: Feedback(detailText: detailText, + credits: score, + type: .MANUAL_UNREFERENCED), + scope: scope, + textPrefix: baseFeedbackTextPrefix) + + assessmentResult.addFeedback(feedback: feedback) + + if let feedbackSuggestion { + feedbackDelegate?.onFeedbackSuggestionSelection(feedbackSuggestion, feedback) + } } } From 3a2dd5cc55d029eb41125dc2e45a0808c3aecbd7 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Sun, 17 Dec 2023 16:10:50 +0400 Subject: [PATCH 06/10] Update lightbulbs when suggestions are accepted and deleted --- .../Sources/CodeEditor/UXCodeTextViewRepresentable.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CodeEditor/Sources/CodeEditor/UXCodeTextViewRepresentable.swift b/CodeEditor/Sources/CodeEditor/UXCodeTextViewRepresentable.swift index 75605ce1..21c060b6 100644 --- a/CodeEditor/Sources/CodeEditor/UXCodeTextViewRepresentable.swift +++ b/CodeEditor/Sources/CodeEditor/UXCodeTextViewRepresentable.swift @@ -220,9 +220,13 @@ public struct UXCodeTextViewRepresentable: UXViewRepresentable { textView.string = editorBindings.source.wrappedValue } + textView.feedbackSuggestions = editorBindings.feedbackSuggestions + textView.updateLightBulbs() + } else if editorBindings.feedbackSuggestions.count != textView.lightBulbs.count { textView.feedbackSuggestions = editorBindings.feedbackSuggestions textView.updateLightBulbs() } + textView.setNeedsDisplay() textView.pencilOnly = editorBindings.pencilOnly.wrappedValue textView.dragSelection = self.editorBindings.dragSelection?.wrappedValue From 7ba56d5f4513b14cfb09c6237bcdb243e6bd1d45 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Thu, 4 Jan 2024 18:00:55 +0400 Subject: [PATCH 07/10] Update ArtemisCore to 8.0.0 --- CodeEditor/Package.swift | 2 +- Themis.xcodeproj/project.pbxproj | 2 +- Themis/ViewModels/Course/CourseViewModel.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CodeEditor/Package.swift b/CodeEditor/Package.swift index 2c47b1fe..4d016400 100644 --- a/CodeEditor/Package.swift +++ b/CodeEditor/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/raspu/Highlightr", from: "2.1.2"), - .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", from: "7.0.0"), + .package(url: "https://github.com/ls1intum/artemis-ios-core-modules", from: "8.0.0"), ], targets: [ diff --git a/Themis.xcodeproj/project.pbxproj b/Themis.xcodeproj/project.pbxproj index c8085a0b..e9525365 100644 --- a/Themis.xcodeproj/project.pbxproj +++ b/Themis.xcodeproj/project.pbxproj @@ -1917,7 +1917,7 @@ repositoryURL = "https://github.com/ls1intum/artemis-ios-core-modules"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.0.0; }; }; 65F007962A86C837000FD641 /* XCRemoteSwiftPackageReference "SwiftUI-Shimmer" */ = { diff --git a/Themis/ViewModels/Course/CourseViewModel.swift b/Themis/ViewModels/Course/CourseViewModel.swift index 3a80fe76..e887f29c 100644 --- a/Themis/ViewModels/Course/CourseViewModel.swift +++ b/Themis/ViewModels/Course/CourseViewModel.swift @@ -69,7 +69,7 @@ class CourseViewModel: ObservableObject { log.error(String(describing: error)) } - courses = coursesForDashboard.value?.map({ $0.course }).filter({ $0.isAtLeastTutorInCourse }) ?? [] + courses = coursesForDashboard.value?.courses?.map({ $0.course }).filter({ $0.isAtLeastTutorInCourse }) ?? [] showCoursesIsEmptyMessage = courses.isEmpty if !pickerCourseIDs.contains(where: { $0 == shownCourseID }) { From da7daed4a2ab5210b7baba1bbd2e441ccc4a7c79 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Thu, 4 Jan 2024 18:15:58 +0400 Subject: [PATCH 08/10] Do not unnecessarily fetch suggestions --- .../Assessment/Programming/CodeEditorViewModel.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift index dde581da..cd1aa7aa 100644 --- a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift @@ -76,13 +76,17 @@ class CodeEditorViewModel: ExerciseRendererViewModel { let assessmentResult = assessmentVM.assessmentResult + // Do not fetch suggestions in the read only mode and when fetching a previously-saved assessment + let shouldFetchSubmissions = !assessmentVM.readOnly && assessmentVM.submissionId == nil + await withTaskGroup(of: Void.self) { group in group.addTask { [weak self] in await self?.initFileTree(participationId: participationId, repositoryType: .student) await self?.loadInlineHighlightsIfEmpty(assessmentResult: assessmentResult, participationId: participationId) } - if let submissionId = assessmentVM.submission?.id { + if shouldFetchSubmissions, + let submissionId = assessmentVM.submission?.id { group.addTask { [weak self] in await self?.getFeedbackSuggestions(submissionId: submissionId, exerciseId: exerciseId) } @@ -154,11 +158,10 @@ class CodeEditorViewModel: ExerciseRendererViewModel { } } - // TODO: make sure this is not called for old assessments and in read-only mode @MainActor private func getFeedbackSuggestions(submissionId: Int, exerciseId: Int) async { do { - var fetchedSuggestions = try await AthenaService().getProgrammingFeedbackSuggestions(exerciseId: exerciseId, + let fetchedSuggestions = try await AthenaService().getProgrammingFeedbackSuggestions(exerciseId: exerciseId, submissionId: submissionId) log.verbose("Fetched \(fetchedSuggestions.count) suggestions") From 534b2ea84d488513e50decc73907298025f512da Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Thu, 4 Jan 2024 18:59:53 +0400 Subject: [PATCH 09/10] Register undo actions for suggestions --- .../Assessment/Programming/CodeEditorViewModel.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift index cd1aa7aa..2aca71a5 100644 --- a/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/CodeEditorViewModel.swift @@ -26,7 +26,13 @@ class CodeEditorViewModel: ExerciseRendererViewModel { } @Published var allowsInlineFeedbackOperations = true @Published var error: Error? - @Published var feedbackSuggestions = [ProgrammingFeedbackSuggestion]() + @Published var feedbackSuggestions = [ProgrammingFeedbackSuggestion]() { + didSet { + undoManager.registerUndo(withTarget: self) { target in + target.feedbackSuggestions = oldValue + } + } + } var scrollUtils = ScrollUtils(range: nil, offsets: [:]) From 5525a6b032e29257a79bc146835245c098174100 Mon Sep 17 00:00:00 2001 From: Tarlan Ismayilsoy Date: Fri, 5 Jan 2024 21:50:57 +0400 Subject: [PATCH 10/10] Show a warning message when submitting with pending suggestions --- .../ViewModels/Assessment/AssessmentViewModel.swift | 4 ++++ .../Programming/ProgrammingAssessmentViewModel.swift | 12 +++++++++++- Themis/Views/Assessment/AssessmentView.swift | 6 ++++-- .../ProgrammingAssessmentView.swift | 1 + 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Themis/ViewModels/Assessment/AssessmentViewModel.swift b/Themis/ViewModels/Assessment/AssessmentViewModel.swift index c8aadb58..aa1e299b 100644 --- a/Themis/ViewModels/Assessment/AssessmentViewModel.swift +++ b/Themis/ViewModels/Assessment/AssessmentViewModel.swift @@ -24,6 +24,10 @@ class AssessmentViewModel: ObservableObject { var exercise: Exercise var correctionRound: CorrectionRound + /// A message shown to the user on the alert that appears when they press the submit button. + /// Intended to provide the user with additional information about the consequences of submitting the assessment. + var submissionAlertDetail: String? { nil } + private var cancellables: [AnyCancellable] = [] init(exercise: Exercise, diff --git a/Themis/ViewModels/Assessment/Programming/ProgrammingAssessmentViewModel.swift b/Themis/ViewModels/Assessment/Programming/ProgrammingAssessmentViewModel.swift index 0d366bf4..12ea3dd3 100644 --- a/Themis/ViewModels/Assessment/Programming/ProgrammingAssessmentViewModel.swift +++ b/Themis/ViewModels/Assessment/Programming/ProgrammingAssessmentViewModel.swift @@ -10,6 +10,16 @@ import Common import SharedModels class ProgrammingAssessmentViewModel: AssessmentViewModel { + weak var codeEditorVM: CodeEditorViewModel? + + override var submissionAlertDetail: String? { + if codeEditorVM?.feedbackSuggestions.isEmpty == false { + "There are pending feedback suggestions that will be discarded." + } else { + nil + } + } + @MainActor override func initSubmission() async { guard submission == nil else { @@ -65,7 +75,7 @@ class ProgrammingAssessmentViewModel: AssessmentViewModel { log.error(String(describing: error)) } } - + func participationId(for repoType: RepositoryType) -> Int? { switch repoType { case .student: diff --git a/Themis/Views/Assessment/AssessmentView.swift b/Themis/Views/Assessment/AssessmentView.swift index fc1b75d9..7863520a 100644 --- a/Themis/Views/Assessment/AssessmentView.swift +++ b/Themis/Views/Assessment/AssessmentView.swift @@ -133,7 +133,7 @@ struct AssessmentView: View { presentationMode.wrappedValue.dismiss() } } - .alert("Are you sure you want to submit your assessment?", isPresented: $showSubmitConfirmation) { + .alert("Are you sure you want to submit your assessment?", isPresented: $showSubmitConfirmation, actions: { Button("Yes") { Task { await assessmentVM.submitAssessment() @@ -141,7 +141,9 @@ struct AssessmentView: View { } } Button("Cancel", role: .cancel) {} - } + }, message: { + Text(assessmentVM.submissionAlertDetail ?? "") + }) .alert("What do you want to do next?", isPresented: $showNavigationOptions) { Button("Next Submission") { Task { diff --git a/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift b/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift index 603175de..54141f64 100644 --- a/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift +++ b/Themis/Views/Assessment/Programming Exercise/ProgrammingAssessmentView.swift @@ -71,6 +71,7 @@ struct ProgrammingAssessmentView: View { } } .task { + (assessmentVM as? ProgrammingAssessmentViewModel)?.codeEditorVM = codeEditorVM assessmentVM.pencilModeDisabled = true await assessmentVM.initSubmission() await codeEditorVM.setup(basedOn: assessmentVM, exercise.baseExercise.id)