feat(web): Edit flow markdown authoring slice (#325) - #331
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 updates the Blog Post Create and Edit flows to use a shared TextEditor component backed by PSC.Blazor.Components.MarkdownEditor (EasyMDE), adding the supporting web assets, package references, and test adjustments needed to run the editor in bUnit.
Changes:
- Add shared markdown editor infrastructure (packages, app shell CSS/JS,
TextEditorcomponent, JS helper). - Wire
TextEditorinto BlogPost Create/Edit forms and registerIFileStorage(local disk) for editor upload integration. - Update bUnit smoke/ACL tests to accommodate JS-based editor rendering and DI requirements.
Reviewed changes
Copilot reviewed 17 out of 18 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Directory.Packages.props | Adds CPM-pinned versions for Markdig + PSC markdown editor. |
| src/Web/Web.csproj | Adds package references for Markdig and PSC markdown editor. |
| src/Web/Components/App.razor | Adds Font Awesome + PSC editor CSS/JS and a local JS helper script. |
| src/Web/Components/Shared/TextEditor.razor | Introduces shared markdown editor wrapper with toolbar + image upload hooks. |
| src/Web/wwwroot/js/text-editor-helpers.js | Adds JS function used by the editor for selected-text operations. |
| src/Web/wwwroot/js/markdown-editor-init.js | Adds a custom init/dispose interop wrapper for EasyMDE (currently not wired). |
| src/Web/Program.cs | Registers IFileStorage implementation in DI. |
| src/Web/Infrastructure/FileStorage/IFileStorage.cs | Adds file storage abstraction used by the editor upload path. |
| src/Web/Infrastructure/FileStorage/FileStorageModels.cs | Adds file + metadata records for storage operations. |
| src/Web/Infrastructure/FileStorage/FileStorageExtensions.cs | Adds DI registration extension for file storage. |
| src/Web/Infrastructure/FileStorage/LocalDiskFileStorage.cs | Implements local-disk storage under wwwroot/uploads. |
| src/Web/GlobalUsings.cs | Adds global using for the file storage namespace. |
| src/Web/Features/BlogPosts/Edit/Edit.razor | Replaces textarea with TextEditor for markdown editing. |
| src/Web/Features/BlogPosts/Create/Create.razor | Replaces textarea with TextEditor for markdown authoring. |
| tests/Web.Tests.Bunit/GlobalUsings.cs | Adds global using for file storage types in bUnit tests. |
| tests/Web.Tests.Bunit/Components/RazorSmokeTests.cs | Updates Create/Edit assertions + sets Loose JSInterop + registers IFileStorage mock. |
| tests/Web.Tests.Bunit/Features/EditAclTests.cs | Sets Loose JSInterop + registers IFileStorage mock. |
| src/AppHost/MongoDbResourceBuilderExtensions.cs | Minor formatting fix in seed document initialization. |
Comments suppressed due to low confidence (3)
src/Web/Components/Shared/TextEditor.razor:83
HandleImageUploadallocates aMemoryStreambut never disposes it. Wrap the stream in ausing/await using(or dispose in afinally) afterAddFileAsynccompletes to avoid leaking memory under repeated uploads.
// 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;
src/Web/Components/Shared/TextEditor.razor:75
- Avoid
Console.WriteLinein components; it bypasses structured logging and can spam server logs in production. Use an injectedILogger<TextEditor>(or remove the log) for upload diagnostics.
public async Task<FileEntry> HandleImageUpload(MarkdownEditor editor, FileEntry file)
{
Console.WriteLine($"Image: {file.Name} Size: {file.Size}");
src/Web/Components/Shared/TextEditor.razor:101
MyContent.Replace(selectedText, wrappedText)will replace all occurrences of the selected text in the document, not just the current selection. Use the editor/CodeMirror API to replace the current selection (or compute a single-range replacement) so alignment only affects what the user selected.
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);
}
|
|
||
| // 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(); |
| <MarkdownToolbarButton Action="MarkdownAction.Bold" Name="Bold" Icon="fa fa-bold" Title="Bold" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Italic" Name="Italic" Icon="fa fa-italic" Title="Italic" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Strikethrough" Name="Strike Through" Icon="fa fa-strikethrough" | ||
| Title="Strike Through" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.HorizontalRule" Name="Horizontal Rule" Icon="fa fa-window-minimize" | ||
| Title="Horizontal Rule" /> | ||
| <MarkdownToolbarButton Separator Action="MarkdownAction.Heading" Name="Header" Icon="fa fa-header" | ||
| Title="Heading" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Quote" Name="Add Quote" Icon="fa fa-quote-left" Title="Quote" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.UnorderedList" Name="Add Un-ordered-List" Icon="fa fa-list-ul" | ||
| Title="Unordered List" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.OrderedList" Name="Add Ordered-List" Icon="fa fa-list-ol" | ||
| Title="ordered List" /> | ||
|
|
||
| @if (AlignmentOptionsEnabled) | ||
| { | ||
| <MarkdownToolbarButton Separator Name="Align Left" Value="@("left")" Icon="fa fa-align-left" Title="Align Left" /> | ||
| <MarkdownToolbarButton Name="Align Centre" Value="@("center")" Icon="fa fa-align-center" Title="Align Centre" /> | ||
| <MarkdownToolbarButton Name="Align Right" Value="@("right")" Icon="fa fa-align-right" Title="Align Right" /> | ||
| } | ||
|
|
||
| <MarkdownToolbarButton Separator Action="MarkdownAction.Link" Name="Add Link" Icon="fa fa-link" Title="Link" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Image" Name="Add Image" Icon="fa fa-image" Title="Image" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Table" Name="Add Table" Icon="fa fa-table" Title="Add Table" /> | ||
| <MarkdownToolbarButton Separator Action="MarkdownAction.Preview" Name="preview" Icon="no-disable fa fa-eye" | ||
| Title="Toggle Preview" /> | ||
| <MarkdownToolbarButton Action="MarkdownAction.Code" Name="Code Block" Icon="fa fa-code" Title="Code Block" /> | ||
| <MarkdownToolbarButton Separator Action="MarkdownAction.Fullscreen" Name="Fullscreen" Icon="fa fa-arrows-alt" | ||
| Title="Fullscreen" /> | ||
| <MarkdownToolbarButton Separator Action="MarkdownAction.Guide" Name="Markdown Guide" Icon="fa fa-question-circle" |
| { | ||
| /// <summary> | ||
| /// Persists <paramref name="file"/> and returns the stored filename that callers | ||
| /// can use to build a retrieval URL (e.g. <c>/api/files/{filename}</c>). |
| if (file.Content.Length > MaxFileSizeBytes) | ||
| { | ||
| throw new InvalidOperationException("File exceeds the maximum allowed size of 10 MB."); | ||
| } |
| // 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'); |
| @@ -0,0 +1,11 @@ | |||
| (function () { | |||
| window.getSelectedText = function (cssSelector) { | |||
f65bd63 to
7d12770
Compare
|
🤖 Aragorn — Rebase complete Branch
All pre-push gates passed ✅ |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev #331 +/- ##
==========================================
+ Coverage 80.64% 81.67% +1.03%
==========================================
Files 51 51
Lines 1369 1370 +1
Branches 166 166
==========================================
+ Hits 1104 1119 +15
+ Misses 198 184 -14
Partials 67 67
🚀 New features to boost your workflow:
|
7d12770 to
e90feee
Compare
…eate and Edit flows (#324) - Fix CPM NU1010: add Markdig v0.44.0 and PSC.Blazor.Components.MarkdownEditor v10.0.7 to Directory.Packages.props - Wire Font Awesome CDN + PSC EasyMDE CSS/JS into App.razor shell - Replace InputTextArea with TextEditor component in Create.razor (label: Content → Markdown) - Replace InputTextArea with TextEditor component in Edit.razor - Add IFileStorage mock and JSInterop.Mode = Loose to EditAclTests constructor - Add global using for MyBlog.Web.Infrastructure.FileStorage in bUnit test project - Update RazorSmokeTests: use FindComponent<TextEditor>() and ContentChanged.InvokeAsync() instead of textarea.Change() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
e90feee to
cbc3aaa
Compare
Summary
Working as Legolas (UI/Frontend).
Replaces
InputTextAreawith theTextEditorshared component in the Edit blog post flow, so authors get the full EasyMDE markdown editor when editing posts. Also includes the Create flow wiring (covered by #324) since both depend on the same shared infrastructure.Changes
Infrastructure (shared with Create flow)
Directory.Packages.props— Fixes CPM NU1010 gap left by [Sprint 19] Restore markdown editor compile path end-to-end #323: addsMarkdig v0.44.0andPSC.Blazor.Components.MarkdownEditor v10.0.7versionssrc/Web/Components/App.razor— Adds Font Awesome 6.7.2 CDN + PSC EasyMDE CSS/JS assets to the app shellFeature: Edit flow (#325)
src/Web/Features/BlogPosts/Edit/Edit.razor— Replaces<InputTextArea>with<TextEditor @bind-Content="_model.Content" AlignmentOptionsEnabled="false" />; initial content loads correctly via the existing@if (_model is not null)guardFeature: Create flow (#324)
src/Web/Features/BlogPosts/Create/Create.razor— Same TextEditor swap for Create formTests
tests/Web.Tests.Bunit/GlobalUsings.cs— AddsMyBlog.Web.Infrastructure.FileStorageglobal usingtests/Web.Tests.Bunit/Components/RazorSmokeTests.cs— RegistersIFileStoragemock + setsJSInterop.Mode = Loose; updatesEditPostLoadsExistingPostto assert viaFindComponent<TextEditor>().Instance.Content(PSC renders textarea via JS, not static HTML); fixes unawaited-task CS4014 error on NSubstitute verificationtests/Web.Tests.Bunit/Features/EditAclTests.cs— RegistersIFileStoragemock + setsJSInterop.Mode = LooseHow it works
Edit.razorguards the form with@if (_model is not null), soTextEditoris only instantiated afterOnParametersSetAsyncloads the existing post. This means theContentparameter receives the existing post content at first render — no extra lifecycle workaround needed.Test results
All 92 bUnit tests pass (0 failures). All integration and architecture tests pass.
Closes #325
Also addresses #324
devfirst.Closes #324
Closes #325