feat(save-attachments): scope folder list to the selected case

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) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-12 20:32:44 +03:00
parent c98fee7b5b
commit 26322c92ff
5 changed files with 71 additions and 18 deletions
@@ -19,6 +19,9 @@ namespace MarcusLaw.OutlookAddin.Core.Models
[JsonPropertyName("folderId")] [JsonPropertyName("folderId")]
public string? FolderId { get; set; } public string? FolderId { get; set; }
[JsonPropertyName("folderName")]
public string? FolderName { get; set; }
[JsonPropertyName("parentType")] [JsonPropertyName("parentType")]
public string? ParentType { get; set; } public string? ParentType { get; set; }
@@ -267,6 +267,45 @@ namespace MarcusLaw.OutlookAddin.Core.Services
} }
} }
public async Task<List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(caseId)) return new List<DocumentFolder>();
// 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<EspoListResponse<DocumentEntity>>(path, cancellationToken).ConfigureAwait(false);
var seen = new HashSet<string>(StringComparer.Ordinal);
var folders = new List<DocumentFolder>();
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<DocumentFolder>();
}
}
public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default) public async Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default)
{ {
try try
@@ -19,5 +19,6 @@ namespace MarcusLaw.OutlookAddin.Core.Services
Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default); Task<AttachmentEntity> UploadAttachmentAsync(string fileName, string contentType, string base64Content, CancellationToken cancellationToken = default);
Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default); Task<DocumentEntity> CreateDocumentAsync(string name, string attachmentFileId, string? parentType, string? parentId, string? folderId, CancellationToken cancellationToken = default);
Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default); Task<EspoListResponse<DocumentFolder>> ListDocumentFoldersAsync(CancellationToken cancellationToken = default);
Task<System.Collections.Generic.List<DocumentFolder>> ListFoldersUsedInCaseAsync(string caseId, CancellationToken cancellationToken = default);
} }
} }
@@ -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) partial void OnSearchTextChanged(string value)
{ {
_searchCts?.Cancel(); _searchCts?.Cancel();
@@ -111,6 +95,32 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
partial void OnSelectedCaseChanged(CaseEntity? value) partial void OnSelectedCaseChanged(CaseEntity? value)
{ {
SubmitCommand.NotifyCanExecuteChanged(); 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) private async Task SearchAsync(string query, CancellationToken ct)
+2 -2
View File
@@ -482,8 +482,8 @@ namespace OutlookAddin.Services
} }
var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments); var vm = new SaveAttachmentsViewModel(CrmClient!, Logger, envelope.Attachments);
// Best-effort folder list — failures already logged inside the VM. // Folders are loaded lazily when the user picks a case — the
_ = vm.LoadFoldersAsync(); // sentinel "(בלי תיקייה)" row is already present.
var dialog = new SaveAttachmentsDialog(vm); var dialog = new SaveAttachmentsDialog(vm);
AttachOwnerToOutlook(dialog); AttachOwnerToOutlook(dialog);