diff --git a/src/Components/Agents.md b/src/Components/Agents.md new file mode 100644 index 000000000000..9a67b5f672c0 --- /dev/null +++ b/src/Components/Agents.md @@ -0,0 +1,212 @@ +# Working on Issues in the Components Area + +This guide provides step-by-step instructions for working on issues in the ASP.NET Core Components area. + +## Working on New Features + +### Overview + +The workflow for implementing new features in the Components area follows these steps: + +1. **Create a sample scenario first** - This is the most important first step. Update code in one of the projects in `src/Components/Samples` folder to include the scenarios for the feature you want to build. This allows you to develop and test the feature interactively before writing formal tests. + +2. **Build and test interactively** - Build the feature and use Playwright to test it in the browser, ensuring it works end-to-end at a basic level. + +### Sample Projects + +The `src/Components/Samples` folder contains several sample projects you can use for developing and testing features: + +- **BlazorServerApp** - A Blazor Server application for testing server-side scenarios +- **BlazorUnitedApp** - A Blazor Web App (united/hybrid mode) for testing combined server and WebAssembly scenarios +- **BlazorUnitedApp.Client** - The client-side portion of the BlazorUnitedApp + +**Always start by adding your feature scenario to one of these sample projects first.** This allows you to: +- Quickly iterate on the implementation +- Test the feature interactively in a real browser +- Verify the feature works before writing formal E2E tests +- Debug issues more easily with full logging capabilities + +3. **Debug when needed**: + - If something isn't working as expected, increase the logging level in the sample for `Microsoft.AspNetCore.Components` to `Debug` to see detailed logs. + - Check browser console logs using Playwright's `browser_console_messages`. + - Use Microsoft documentation to learn more about troubleshooting Blazor applications. + - You can also increase the log level for JavaScript console output. + +4. **Validate the sample works** - You must have a validated, working sample in the Samples folder before proceeding. Use Playwright to confirm the feature works end-to-end in the browser. + +5. **Implement E2E tests** - Only after the sample is validated, implement E2E tests for it. + +6. **Clean up sample code** - After your E2E tests are passing, remove the sample code you added to the Samples projects. The sample was only for development and interactive testing; the E2E tests now provide the permanent test coverage. + +## Build Tips + +### Efficient Build Strategy + +To avoid unnecessary full repository builds, follow this optimized approach: + +#### 1. Initial Setup - Check for First Build +Before running any commands, check if a full build has already been completed: +- Look for `artifacts\agent-sentinel.txt` in the repository root +- If this file exists, skip to step 2 +- If not present, run the initial build and create the sentinel file: + +```bash +.\eng\build.cmd +echo "We ran eng\build.cmd successfully" > artifacts\agent-sentinel.txt +``` + +#### 2. Check for JavaScript Assets +Before running tests or samples, verify that JavaScript assets are built: +- Check for `src\Components\Web.JS\dist\Debug\blazor.web.js` +- If not present, run from the repository root: `npm run build` + +#### 3. Iterating on C# Changes + +**Most of the time (no dependency changes):** +```bash +dotnet build --no-restore -v:q +``` + +Or with `eng\build.cmd`: +```bash +.\eng\build.cmd -NoRestore -NoBuildDeps -NoBuildRepoTasks -NoBuildNative -NoBuildNodeJS -NoBuildJava -NoBuildInstallers -verbosity:quiet +``` + +**When you've added/changed project references or package dependencies:** + +First restore: +```bash +.\restore.cmd +``` + +Then build: +```bash +dotnet build --no-restore -v:q +``` + +**Note:** The `-v:q` (or `-verbosity:quiet`) flag minimizes build output to only show success/failure and error details. Remove this flag if you need to see detailed build output for debugging. + +#### 4. Building Individual Projects (Fixing Build Errors) + +When fixing build errors in a specific project, you can build just that project without its dependencies for even faster iteration: + +```bash +dotnet build --no-restore --no-dependencies -v:q +``` + +**When to use `--no-dependencies`:** +- Fixing compilation errors in a single project (syntax errors, type errors, etc.) +- Making isolated changes that don't affect project references +- Rapid iteration on a specific library + +**When NOT to use `--no-dependencies`:** +- You've changed public APIs that other projects depend on +- You need to verify that dependent projects still compile correctly +- You're unsure if your changes affect other projects (safer to build without this flag) + +**Example:** +```bash +# Fix a compilation error in Components.Endpoints +dotnet build src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj --no-restore --no-dependencies -v:q +``` + +#### Quick Reference + +1. **First time only**: `.\eng\build.cmd` → create `artifacts\agent-sentinel.txt` +2. **Check JS assets**: Verify `src\Components\Web.JS\dist\Debug\blazor.web.js` exists, run `npm run build` if missing +3. **Most C# changes**: `dotnet build --no-restore -v:q` +4. **Fixing build errors in one project**: `dotnet build --no-restore --no-dependencies -v:q` +5. **Added/changed dependencies**: Run `.\restore.cmd` first, then use step 3 + +### E2E Testing Structure + +Tests live in `src/Components/test`. The structure includes: + +- **TestAssets folder** - Contains test assets and scenarios +- **Components.TestServer project** - A web application that launches multiple web servers with different scenarios (different project startups). Avoid adding new startup files unless strictly necessary. + +### Running E2E Tests Manually + +1. **Build the repository** first with `.\eng\build.cmd` from the repo root +2. **Start Components.TestServer**: + ```bash + cd src\Components\test\testassets\Components.TestServer + dotnet run --project Components.TestServer.csproj + ``` +3. **Navigate to the test server** - The main server runs on `http://127.0.0.1:5019/subdir` +4. **Select a test scenario** - The main page shows a dropdown with all available test components +5. **Reproduce the scenario** to verify it works the same way as in the sample + +Note: There are also other server instances launched for different test configurations (authentication, CORS, prerendering, etc.). These are listed in the "scenarios" table on the main page. + +### Understanding Logging Configuration + +#### Server-side (.NET) Logging + +The server uses `Microsoft.Extensions.Logging.Testing.TestSink` for capturing logs. Log configuration is in `Program.cs`: + +```csharp +.ConfigureLogging((ctx, lb) => +{ + TestSink sink = new TestSink(); + lb.AddProvider(new TestLoggerProvider(sink)); + lb.Services.Add(ServiceDescriptor.Singleton(sink)); +}) +``` + +#### Client-side (Blazor WebAssembly) Logging + +Logs appear in the browser console. Log levels: +- Logs with `warn:` prefix are Warning level +- Logs with `info:` prefix are Information level +- Logs with `fail:` prefix are Error level + +The Blazor WebAssembly log level can be configured at startup: + +```javascript +Blazor.start({ + logLevel: 1 // LogLevel.Debug +}); +``` + +LogLevel values: Trace=0, Debug=1, Information=2, Warning=3, Error=4, Critical=5 + +For Server-side Blazor (SignalR): +```javascript +Blazor.start({ + circuit: { + configureSignalR: builder => { + builder.configureLogging("debug") // LogLevel.Debug + } + } +}); +``` + +#### Viewing Logs in Playwright + +Use `browser_console_messages` to see JavaScript console output including .NET logs routed to the console. + +### Creating E2E Tests + +E2E tests are located in `src/Components/test/E2ETest`. + +1. First, check if there are already E2E tests for the component/feature area you're working on +2. Try to add an additional test to existing test files when possible +3. When adding test coverage, prefer extending existing test components and assets over creating a set of new ones if it doesn't complicate the existing ones excessively. This reduces test infrastructure complexity and keeps related scenarios together. + +### Running E2E Tests + +The E2E tests use Selenium. To build and run tests: + +```bash +# Build the E2E test project (this includes all test assets as dependencies) +dotnet build src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj --no-restore -v:q + +# Run a specific test +dotnet test src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj --no-build --filter "FullyQualifiedName~TestName" +``` + +**Important**: Never run all E2E tests locally as that is extremely costly. Full test runs should only happen on CI machines. + +If a test is failing, it's best to run the server manually and navigate to the test to investigate. The test output won't be very useful for debugging. + diff --git a/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts b/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts index f5dbc71a6063..0b235e9a1830 100644 --- a/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts +++ b/src/Components/Web.JS/src/Rendering/DomSpecialPropertyUtil.ts @@ -98,6 +98,11 @@ function tryApplyValueProperty(element: Element, value: string | null): boolean return true; } default: + // Support contenteditable elements - set textContent as the value + if (element instanceof HTMLElement && element.isContentEditable) { + element.textContent = value || ''; + return true; + } return false; } } diff --git a/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts b/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts index 12b71f5b6f43..9a4b7906a75c 100644 --- a/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts +++ b/src/Components/Web.JS/src/Rendering/Events/EventFieldInfo.ts @@ -33,5 +33,10 @@ function getFormFieldData(elem: Element) { return { value: elem.value }; } + // Support contenteditable elements - use textContent as the value + if (elem instanceof HTMLElement && elem.isContentEditable) { + return { value: elem.textContent || '' }; + } + return null; } diff --git a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts index 89eb799ed3b9..bca454ce9131 100644 --- a/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts +++ b/src/Components/Web.JS/src/Rendering/Events/EventTypes.ts @@ -173,6 +173,9 @@ function parseChangeEvent(event: Event): ChangeEventArgs { .filter(option => option.selected) .map(option => option.value); return { value: selectedValues }; + } else if (isContentEditableElement(element)) { + // For contenteditable elements, use textContent as the value + return { value: (element as HTMLElement).textContent || '' }; } else { const targetIsCheckbox = isCheckbox(element); const newValue = targetIsCheckbox ? !!element['checked'] : element['value']; @@ -322,6 +325,10 @@ function isCheckbox(element: Element | null): boolean { return !!element && element.tagName === 'INPUT' && element.getAttribute('type') === 'checkbox'; } +function isContentEditableElement(element: Element | null): element is HTMLElement { + return !!element && element instanceof HTMLElement && element.isContentEditable; +} + const timeBasedInputs = [ 'date', 'datetime-local', diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs index f98a01a8ce35..84269a8ee661 100644 --- a/src/Components/test/E2ETest/Tests/BindTest.cs +++ b/src/Components/test/E2ETest/Tests/BindTest.cs @@ -2093,4 +2093,61 @@ private void ApplyInputValue(string cssSelector, string value) javascript.ExecuteScript( $"document.querySelector('{cssSelector}').dispatchEvent(new KeyboardEvent('change'));"); } + + [Fact] + public void CanBindContentEditableDiv_WithOninput() + { + var target = Browser.Exists(By.Id("contenteditable-oninput")); + var boundValue = Browser.Exists(By.Id("contenteditable-oninput-value")); + Assert.Equal("Initial content", target.Text); + Assert.Equal("Initial content", boundValue.Text); + + // Use JavaScript to set content and dispatch input event + // (SendKeys on contenteditable causes issues because Blazor re-rendering moves the caret) + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + var el = arguments[0]; + el.textContent = 'New content'; + el.dispatchEvent(new Event('input', { bubbles: true })); + ", target); + + Browser.Equal("New content", () => boundValue.Text); + } + + [Fact] + public void CanBindContentEditableDiv_WithOnchange() + { + var target = Browser.Exists(By.Id("contenteditable-onchange")); + var boundValue = Browser.Exists(By.Id("contenteditable-onchange-value")); + Assert.Equal("Change on blur", target.Text); + Assert.Equal("Change on blur", boundValue.Text); + + // Focus the element first, then set content, then blur + // The contentblur event fires on blur, so we need proper focus/blur sequence + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + var el = arguments[0]; + el.focus(); + el.textContent = 'Updated value'; + el.blur(); + ", target); + + Browser.Equal("Updated value", () => boundValue.Text); + } + + [Fact] + public void CanBindContentEditableDiv_InitiallyBlank() + { + var target = Browser.Exists(By.Id("contenteditable-initially-blank")); + var boundValue = Browser.Exists(By.Id("contenteditable-initially-blank-value")); + Assert.Equal(string.Empty, target.Text); + Assert.Equal(string.Empty, boundValue.Text); + + // Use JavaScript to set content and dispatch input event + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + var el = arguments[0]; + el.textContent = 'Added content'; + el.dispatchEvent(new Event('input', { bubbles: true })); + ", target); + + Browser.Equal("Added content", () => boundValue.Text); + } } diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor index 56bc049ebcb4..ef86c0174796 100644 --- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.razor @@ -472,6 +472,32 @@ @timeStepTextboxTimeOnlyValue.ToLongTimeString()

+

ContentEditable

+

+ ContentEditable div with oninput (standard event): +

@contentEditableValue
+ @contentEditableValue +

+

+ ContentEditable div with custom contentblur event (blur doesn't fire change on contenteditable): +

@contentEditableChangeValue
+ @contentEditableChangeValue +

+

+ ContentEditable initially blank: +

@contentEditableBlankValue
+ @contentEditableBlankValue +

+ @code { string textboxInitiallyBlankValue = null; string textboxInitiallyPopulatedValue = "Hello"; @@ -575,4 +601,24 @@ selectValue = SelectableValue.Sixth; variableValue = SelectableValue.Sixth; } + + // ContentEditable binding + string contentEditableValue = "Initial content"; + string contentEditableChangeValue = "Change on blur"; + string contentEditableBlankValue = ""; + + void OnContentEditableInput(ChangeEventArgs e) + { + contentEditableValue = e.Value?.ToString() ?? ""; + } + + void OnContentEditableChange(ContentEditableEventArgs e) + { + contentEditableChangeValue = e.TextContent ?? ""; + } + + void OnContentEditableBlankInput(ChangeEventArgs e) + { + contentEditableBlankValue = e.Value?.ToString() ?? ""; + } } diff --git a/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs b/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs index 469eb100ca8c..71dcd2b0ced5 100644 --- a/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs +++ b/src/Components/test/testassets/BasicTestApp/EventCustomArgsTypes.cs @@ -11,6 +11,7 @@ namespace BasicTestApp.CustomEventTypesNamespace; [EventHandler("onkeydown.yetanother", typeof(YetAnotherCustomKeyboardEventArgs), true, true)] [EventHandler("oncustommouseover", typeof(EventArgs), true, true)] [EventHandler("onsendjsobject", typeof(EventWithCustomSerializedDataEventArgs), true, true)] +[EventHandler("oncontentblur", typeof(ContentEditableEventArgs), true, true)] public static class EventHandlers { } @@ -48,3 +49,12 @@ public DotNetType(string propertyValue) Property = propertyValue; } } + +/// +/// Event arguments for contenteditable elements that capture both textContent and innerHTML. +/// +class ContentEditableEventArgs : EventArgs +{ + public string TextContent { get; set; } + public string InnerHTML { get; set; } +} diff --git a/src/Components/test/testassets/BasicTestApp/_Imports.razor b/src/Components/test/testassets/BasicTestApp/_Imports.razor index ca71cae52dfa..e36b82a12918 100644 --- a/src/Components/test/testassets/BasicTestApp/_Imports.razor +++ b/src/Components/test/testassets/BasicTestApp/_Imports.razor @@ -1,3 +1,4 @@ @using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.Web.Media +@using BasicTestApp.CustomEventTypesNamespace diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js b/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js index a0e30c0a8e79..ce72f2c82964 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/BasicTestApp.lib.module.js @@ -4,6 +4,26 @@ let resourceRequests = []; // we are using the resource list in BootResourceCachingTest and when it's too full it stops reporting correctly window.performance.setResourceTimingBufferSize(1000); +// Helper function to register contentblur custom event for contenteditable elements. +// Note: The standard 'input' event works for oninput on contenteditable elements. +// However, 'change' events don't fire on blur for contenteditable, so we need +// a custom event mapped to 'blur' to handle that scenario. +function registerContentBlurEvent(blazorInstance) { + blazorInstance.registerCustomEventType('contentblur', { + browserEventName: 'blur', + createEventArgs: event => { + const element = event.target; + if (element instanceof HTMLElement && element.isContentEditable) { + return { + textContent: element.textContent || '', + innerHTML: element.innerHTML || '' + }; + } + return { textContent: '', innerHTML: '' }; + } + }); +} + export async function beforeStart(options) { const url = new URL(document.URL); runInitializer = url.hash.indexOf('initializer') !== -1; @@ -24,6 +44,12 @@ export async function beforeStart(options) { } export async function afterStarted() { + // Register custom contentblur event for contenteditable elements using global Blazor object. + // Standard 'change' events don't fire on blur for contenteditable, so we need this custom event. + if (typeof Blazor !== 'undefined' && Blazor.registerCustomEventType) { + registerContentBlurEvent(Blazor); + } + if (runInitializer) { const end = document.createElement('p'); end.setAttribute('id', 'initializer-end');