[Sprint 19] UX parity hardening for Create/Edit markdown editor (#326) - #332
Conversation
🏗️ PR Added to Squad Triage QueueThis PR has been labeled with Next steps:
|
There was a problem hiding this comment.
Pull request overview
This PR aims to harden UX parity between Blog Post Create and Edit by standardizing the markdown authoring experience (labeling + shared editor component), and by wiring in supporting editor assets and dependencies.
Changes:
- Replaced Create/Edit
InputTextAreawith a sharedTextEditormarkdown component and aligned the “Content (Markdown)” label presentation. - Introduced baseline file-storage infrastructure (local disk) and registered it in DI to support markdown editor image uploads.
- Updated bUnit smoke tests to render/interact with
TextEditor, and added parity assertions for the label hint.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| Directory.Packages.props | Adds centrally-managed package versions for the markdown editor dependencies. |
| src/Web/Web.csproj | Adds package references for Markdig + PSC markdown editor. |
| src/Web/Program.cs | Registers file storage services in DI. |
| src/Web/GlobalUsings.cs | Adds global using for file storage types. |
| src/Web/Components/App.razor | Loads editor CSS/JS and Font Awesome for toolbar icons. |
| src/Web/Components/Shared/TextEditor.razor | New shared markdown editor wrapper with toolbar + image upload hook. |
| src/Web/wwwroot/js/text-editor-helpers.js | Adds JS helper used by TextEditor for selection logic. |
| src/Web/wwwroot/js/markdown-editor-init.js | Adds (currently unused) custom interop wrapper for editor initialization. |
| src/Web/Infrastructure/FileStorage/IFileStorage.cs | Introduces file storage abstraction. |
| src/Web/Infrastructure/FileStorage/FileStorageModels.cs | Adds storage models (FileData, FileMetaData). |
| src/Web/Infrastructure/FileStorage/FileStorageExtensions.cs | Adds DI registration extension method. |
| src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs | Implements local-disk storage under wwwroot/uploads. |
| src/Web/Features/BlogPosts/Create/Create.razor | Swaps textarea for TextEditor and updates label hint. |
| src/Web/Features/BlogPosts/Edit/Edit.razor | Swaps textarea for TextEditor and updates label hint. |
| tests/Web.Tests.Bunit/GlobalUsings.cs | Adds global using for file storage types in bUnit tests. |
| tests/Web.Tests.Bunit/Features/EditAclTests.cs | Configures loose JSInterop + registers IFileStorage test substitute. |
| tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs | Updates tests to find/interact with TextEditor; adds label parity tests. |
| src/AppHost/MongoDbResourceBuilderExtensions.cs | Minor formatting fix (brace alignment). |
|
|
||
| // Persist the file and build a retrieval URL | ||
| var fileName = await FileStorage.AddFileAsync(new FileData(stream, new FileMetaData(file.Name, string.Empty, file.LastModified))); | ||
| file.UploadUrl = new Uri(new Uri(NavigationManager.BaseUri), $"/api/files/{fileName}").ToString(); |
| // Convert the base64 string to a byte array and load it into a memory stream | ||
| var bytes = Convert.FromBase64String(file.ContentBase64); | ||
| var stream = new MemoryStream(bytes); | ||
|
|
||
| // Persist the file and build a retrieval URL | ||
| var fileName = await FileStorage.AddFileAsync(new FileData(stream, new FileMetaData(file.Name, string.Empty, file.LastModified))); | ||
| file.UploadUrl = new Uri(new Uri(NavigationManager.BaseUri), $"/api/files/{fileName}").ToString(); | ||
| return file; |
| public async Task<FileEntry> HandleImageUpload(MarkdownEditor editor, FileEntry file) | ||
| { | ||
| Console.WriteLine($"Image: {file.Name} Size: {file.Size}"); | ||
|
|
| public async Task OnCustomButtonClicked(MarkdownButtonEventArgs args) | ||
| { | ||
| // Get the selected text using JavaScript | ||
| var selectedText = await JsRuntime.InvokeAsync<string>("getSelectedText", ".EasyMDEContainer textarea"); | ||
|
|
||
| if (!string.IsNullOrEmpty(selectedText)) | ||
| { | ||
| // Wrap the selected text with the chosen alignment | ||
| var wrappedText = $"<div style='text-align:{args.Value}'>{selectedText}</div>"; | ||
|
|
||
| // Replace the selected text in the editor | ||
| MyContent = MyContent.Replace(selectedText, wrappedText); | ||
|
|
||
| // Update the editor | ||
| await _textEditor.SetValueAsync(MyContent); | ||
| } |
| public async Task<string> AddFileAsync(FileData file) | ||
| { | ||
| if (string.IsNullOrEmpty(_env.WebRootPath)) | ||
| { | ||
| throw new InvalidOperationException("WebRootPath is not configured."); | ||
| } | ||
|
|
||
| if (file.Content.Length > MaxFileSizeBytes) | ||
| { | ||
| throw new InvalidOperationException("File exceeds the maximum allowed size of 10 MB."); | ||
| } |
| var uniqueName = $"{Guid.NewGuid()}{extension}"; | ||
| var filePath = Path.Combine(uploadsPath, uniqueName); | ||
|
|
||
| await using var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write); |
| <ResourcePreloader /> | ||
| <link rel="stylesheet" href="css/tailwind.css" /> | ||
| <link rel="stylesheet" href="@Assets["Web.styles.css"]" /> | ||
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" /> |
| // Custom initialization wrapper for PSC.Blazor.Components.MarkdownEditor | ||
| // This ensures proper loading and initialization of the markdown editor | ||
|
|
||
| window.markdownEditorInterop = { | ||
| editors: {}, | ||
|
|
||
| initialize: function (dotNetObjectRef, elementRef, elementId, options) { | ||
| try { | ||
| // Wait for EasyMDE to be available | ||
| if (typeof EasyMDE === 'undefined') { | ||
| console.error('EasyMDE is not loaded'); | ||
| return; | ||
| } | ||
|
|
||
| // Create the editor instance | ||
| const textarea = document.getElementById(elementId); | ||
| if (!textarea) { | ||
| console.error('Textarea element not found:', elementId); | ||
| return; | ||
| } | ||
|
|
||
| const editor = new EasyMDE({ | ||
| element: textarea, | ||
| ...options | ||
| }); | ||
|
|
||
| // Store the editor instance | ||
| this.editors[elementId] = { | ||
| editor: editor, | ||
| dotNetRef: dotNetObjectRef | ||
| }; | ||
|
|
||
| // Set up event handlers | ||
| editor.codemirror.on('change', () => { | ||
| const value = editor.value(); | ||
| dotNetObjectRef.invokeMethodAsync('OnEditorChanged', value); | ||
| }); | ||
|
|
||
| console.log('MarkdownEditor initialized successfully:', elementId); | ||
| } catch (error) { | ||
| console.error('Error initializing markdown editor:', error); | ||
| } | ||
| }, | ||
|
|
||
| dispose: function (elementId) { | ||
| if (this.editors[elementId]) { | ||
| const editorData = this.editors[elementId]; | ||
| if (editorData.editor && editorData.editor.toTextArea) { | ||
| editorData.editor.toTextArea(); | ||
| } | ||
| delete this.editors[elementId]; | ||
| console.log('MarkdownEditor disposed:', elementId); | ||
| } | ||
| }, | ||
|
|
||
| getValue: function (elementId) { | ||
| if (this.editors[elementId] && this.editors[elementId].editor) { | ||
| return this.editors[elementId].editor.value(); | ||
| } | ||
| return ''; | ||
| }, | ||
|
|
||
| setValue: function (elementId, value) { | ||
| if (this.editors[elementId] && this.editors[elementId].editor) { | ||
| this.editors[elementId].editor.value(value); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| // Ensure the interop is available globally | ||
| console.log('Markdown Editor Interop Loaded'); |
| <PackageReference Include="Auth0.ManagementApi" /> | ||
| <PackageReference Include="FluentValidation.AspNetCore" /> | ||
| <PackageReference Include="FluentValidation.DependencyInjectionExtensions" /> | ||
| <PackageReference Include="Markdig" /> | ||
| <PackageReference Include="MediatR" /> | ||
| <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" /> | ||
| <PackageReference Include="MongoDB.EntityFrameworkCore" /> | ||
| <PackageReference Include="PSC.Blazor.Components.MarkdownEditor" /> | ||
| <PackageReference Include="Snappier" /> |
0e6458b to
3d1f50f
Compare
|
🤖 Aragorn — Rebase complete Branch |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #332 +/- ##
==========================================
- Coverage 81.67% 81.66% -0.02%
==========================================
Files 51 51
Lines 1370 1369 -1
Branches 166 166
==========================================
- Hits 1119 1118 -1
Misses 184 184
Partials 67 67
🚀 New features to boost your workflow:
|
3d1f50f to
d78a3a6
Compare
d78a3a6 to
9e567c0
Compare
- Align Create.razor label from 'Markdown' to 'Content (Markdown)' matching Edit.razor's label pattern for visual parity - Switch Create.razor TextEditor binding to @bind-Content for idiomatic Blazor two-way binding, consistent with Edit.razor - Add bUnit parity tests: CreatePostShowsMarkdownContentLabel and EditPostShowsMarkdownContentLabel asserting label presence on both pages No behavioral change — form submit, validation, and navigation paths are unchanged. Editor is not rendered before data loads in Edit (existing _isLoading guard remains in place). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
9e567c0 to
a5a1000
Compare
Summary
Closes #326
Working as Legolas (Frontend / Blazor)
Parity Gaps Found and Fixed
Markdown(bare)Content (Markdown)with hintContent=+ContentChanged=@bind-Content(idiomatic)@bind-Content_isLoadingguard already presentFiles Changed
src/Web/Features/BlogPosts/Create/Create.razor— label and binding paritytests/Web.Tests.Bunit/Components/RazorSmokeTests.cs— two new parity testsTest Results
All 6 pre-push gates green.
Dependencies
squad/325-edit-flow-markdown-slice)