From 26322c92ffb59e83c671ed43ed76f3e1b3938d2d Mon Sep 17 00:00:00 2001 From: PointStar Date: Tue, 12 May 2026 20:32:44 +0300 Subject: [PATCH] feat(save-attachments): scope folder list to the selected case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder dropdown was showing every DocumentFolder in EspoCRM (global list). The user expected "folders that exist in THIS case" — only the folders already in use for the chosen case's documents. Change: when the user picks a case in the SaveAttachments dialog, the folder list refreshes by walking GET /Case/{id}/documents?select=id,folderId,folderName&maxSize=200 and deduping the folderId values it finds. EspoCRM denormalizes folderName onto Document, so no second lookup is needed. If a case has no documents yet, the dropdown shows just the sentinel "(בלי תיקייה)" — same default behavior as before, but no more unrelated folders from other cases. Files: - Core: DocumentEntity gets FolderName; IEspoCrmClient and EspoCrmClient get ListFoldersUsedInCaseAsync(caseId) - UI: SaveAttachmentsViewModel re-fetches on SelectedCase change (LoadFoldersForCaseAsync); AddInHost no longer calls the old LoadFoldersAsync. Tests: 39 still passing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/DocumentEntity.cs | 3 ++ .../Services/EspoCrmClient.cs | 39 +++++++++++++++++ .../Services/IEspoCrmClient.cs | 1 + .../ViewModels/SaveAttachmentsViewModel.cs | 42 ++++++++++++------- src/OutlookAddin/Services/AddInHost.cs | 4 +- 5 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/OutlookAddin.Core/Models/DocumentEntity.cs b/src/OutlookAddin.Core/Models/DocumentEntity.cs index fbf85f9..92f2235 100644 --- a/src/OutlookAddin.Core/Models/DocumentEntity.cs +++ b/src/OutlookAddin.Core/Models/DocumentEntity.cs @@ -19,6 +19,9 @@ namespace MarcusLaw.OutlookAddin.Core.Models [JsonPropertyName("folderId")] public string? FolderId { get; set; } + [JsonPropertyName("folderName")] + public string? FolderName { get; set; } + [JsonPropertyName("parentType")] public string? ParentType { get; set; } diff --git a/src/OutlookAddin.Core/Services/EspoCrmClient.cs b/src/OutlookAddin.Core/Services/EspoCrmClient.cs index e2fc49f..ef128ae 100644 --- a/src/OutlookAddin.Core/Services/EspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/EspoCrmClient.cs @@ -267,6 +267,45 @@ namespace MarcusLaw.OutlookAddin.Core.Services } } + public async Task> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(caseId)) return new List(); + + // EspoCRM doesn't have a Case → DocumentFolder relationship by + // default — but it DOES expose all documents linked to the case + // via the related-records endpoint, and each document returns + // its folderId + folderName denormalized. We dedupe to surface + // the folders that are already in use for that case (the user's + // mental model: "תיקיות שיש בתיק"). + var path = + "Case/" + Uri.EscapeDataString(caseId) + "/documents" + + "?select=" + Uri.EscapeDataString("id,folderId,folderName") + + "&maxSize=200"; + try + { + var response = await GetJsonAsync>(path, cancellationToken).ConfigureAwait(false); + var seen = new HashSet(StringComparer.Ordinal); + var folders = new List(); + foreach (var doc in response.List) + { + if (string.IsNullOrEmpty(doc.FolderId)) continue; + if (!seen.Add(doc.FolderId!)) continue; + folders.Add(new DocumentFolder + { + Id = doc.FolderId!, + Name = string.IsNullOrWhiteSpace(doc.FolderName) ? doc.FolderId! : doc.FolderName! + }); + } + _logger.Information("ListFoldersUsedInCase({CaseId}) → {Count} distinct folders", caseId, folders.Count); + return folders; + } + catch (Exception ex) + { + _logger.Warning(ex, "ListFoldersUsedInCaseAsync({CaseId}) failed", caseId); + return new List(); + } + } + public async Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) { try diff --git a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs index 9d2177e..73585cd 100644 --- a/src/OutlookAddin.Core/Services/IEspoCrmClient.cs +++ b/src/OutlookAddin.Core/Services/IEspoCrmClient.cs @@ -19,5 +19,6 @@ namespace MarcusLaw.OutlookAddin.Core.Services Task UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default); Task CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default); Task> ListDocumentFoldersAsync(CancellationToken cancellationToken = default); + Task> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default); } } diff --git a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs index d1939f3..e427269 100644 --- a/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs +++ b/src/OutlookAddin.UI/ViewModels/SaveAttachmentsViewModel.cs @@ -85,22 +85,6 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels } } - public async Task LoadFoldersAsync(CancellationToken ct = default) - { - try - { - var result = await _client.ListDocumentFoldersAsync(ct).ConfigureAwait(true); - foreach (var folder in result.List) - { - Folders.Add(folder); - } - } - catch (Exception ex) - { - _logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersAsync failed"); - } - } - partial void OnSearchTextChanged(string value) { _searchCts?.Cancel(); @@ -111,6 +95,32 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels partial void OnSelectedCaseChanged(CaseEntity? value) { SubmitCommand.NotifyCanExecuteChanged(); + // Folders are scoped to the chosen case — refresh whenever the + // case selection changes. + _ = LoadFoldersForCaseAsync(value?.Id); + } + + public async Task LoadFoldersForCaseAsync(string? caseId) + { + // Reset to the sentinel row first; we'll append folders below. + Folders.Clear(); + Folders.Add(new DocumentFolder { Id = string.Empty, Name = "(בלי תיקייה)" }); + SelectedFolder = Folders[0]; + + if (string.IsNullOrWhiteSpace(caseId)) return; + + try + { + var folders = await _client.ListFoldersUsedInCaseAsync(caseId!).ConfigureAwait(true); + foreach (var folder in folders) + { + Folders.Add(folder); + } + } + catch (Exception ex) + { + _logger.Warning(ex, "SaveAttachmentsViewModel.LoadFoldersForCaseAsync failed for {CaseId}", caseId); + } } private async Task SearchAsync(string query, CancellationToken ct) diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs index a711c02..a389c72 100644 --- a/src/OutlookAddin/Services/AddInHost.cs +++ b/src/OutlookAddin/Services/AddInHost.cs @@ -482,8 +482,8 @@ namespace OutlookAddin.Services } var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments); - // Best-effort folder list — failures already logged inside the VM. - _ = vm.LoadFoldersAsync(); + // Folders are loaded lazily when the user picks a case — the + // sentinel "(בלי תיקייה)" row is already present. var dialog = new SaveAttachmentsDialog(vm); AttachOwnerToOutlook(dialog);