diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000000..9b4e9715cc9f --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,12 @@ +# Config for the Gemini Pull Request Review Bot. +# https://github.com/marketplace/gemini-code-assist +have_fun: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: true + code_review: true +ignore_patterns: [] diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md new file mode 100644 index 000000000000..03e5a37b7577 --- /dev/null +++ b/.gemini/styleguide.md @@ -0,0 +1,731 @@ +# Comprehensive Coding Standards and Style Guide + +This document outlines the coding standards, best practices, and contribution guidelines for our development team. These rules are organized by severity level and are designed to ensure code quality, security, maintainability, and consistency across all projects. + +## Table of Contents + +- [General Principles](#general-principles) +- [JavaScript/TypeScript Standards](#javascripttypescript-standards) +- [C# Standards](#c-standards) +- [CSS Standards](#css-standards) +- [Blazor Component Guidelines](#blazor-component-guidelines) +- [Testing Requirements](#testing-requirements) +- [Pull Request Guidelines](#pull-request-guidelines) +- [Project Structure](#project-structure) + +## General Principles + +### Code Quality Fundamentals + +- Write code that is readable, maintainable, and self-documenting +- Follow the principle of least surprise - code should behave as expected +- Prefer composition over inheritance +- Keep functions and methods focused on a single responsibility +- Use meaningful names for variables, functions, and classes +- Include comprehensive documentation and comments where necessary + +### Security First + +- Never hard-code credentials, API keys, or sensitive information +- Always validate user input and sanitize data +- Use secure cryptographic algorithms and practices +- Implement proper error handling without exposing sensitive information +- Follow the principle of least privilege in access controls + +## JavaScript/TypeScript Standards + +### Blocker Issues (Must Fix Immediately) + +#### Control Flow and Logic + +- End `switch` cases with an unconditional `break` statement +- Ensure `for` loops have a condition that will eventually be met to prevent infinite loops +- Avoid nesting `switch` statements +- Remove assignments to variables that are never used (dead stores) +- Avoid unnecessary boolean expressions + +#### Security Requirements + +- Do not hard-code credentials. Specifically, avoid using the terms: `password`, `pwd`, `passwd` +- Do not use `document.write` or `innerHTML` with user-controlled input (XSS prevention) +- Do not create cookies without the `secure` and `HttpOnly` flags +- Do not use hard-coded secrets in code +- Secrets (API keys, tokens, credentials) should not be guessable +- Do not use hard-coded IP addresses for security checks + +#### Data Safety + +- Do not update a collection while iterating over it +- When catching and rethrowing an exception, preserve the original exception +- Do not use `NaN` in direct comparisons; use `isNaN()` instead +- Ensure server ports are positive numbers +- Always check the return value of `read` and `receive` methods + +#### Cryptography and Security + +- Do not use insecure cryptographic algorithms like DES +- Do not use weak RSA padding schemes like PKCS1 +- Do not use insecure key-stretching algorithms +- Do not use weak pseudo-random number generators like `Math.random()` +- Do not use empty passwords for cryptographic operations + +#### Web Security + +- Do not use APIs that are known to be vulnerable +- Do not disable server-side certificate validation +- Do not use `postMessage` with a wildcard `*` as the target origin +- Ensure that JSON Web Tokens (JWTs) are validated before use +- Do not use components with known vulnerabilities +- Do not allow unrestricted file uploads (remote code execution risk) +- Do not use insecure templating engines vulnerable to XSS + +#### Regular Expressions + +- Regular expressions should not be subject to Denial of Service (DoS) attacks +- Ensure regular expressions are resistant to ReDoS attacks + +### Critical Issues (High Priority) + +#### Code Structure + +- Remove dead code paths +- `return`, `throw`, `continue`, and `break` statements should not be followed by other statements +- Avoid variable shadowing +- Remove empty methods and functions +- `return` statements should not be duplicated in `if/else if` chains +- Do not have more than 4 `return` statements in a function + +#### Security and Best Practices + +- Using `eval` is a security risk +- Do not use the same value on both sides of a binary operator +- `Promises` should be handled appropriately +- Do not use insecure SSL/TLS protocols +- Regular expression patterns should not be vulnerable to injection attacks +- Do not use HTTP for sensitive data; use HTTPS instead +- Do not use insecure hashing algorithms like MD5 or SHA-1 + +#### Performance and Logic + +- Do not create and start threads in a loop +- Do not use `alert`, `confirm`, or `prompt` in server-side code +- `for` loop counters should not be modified within the loop body +- Cognitive Complexity of functions should not be higher than 15 +- Do not perform database queries in a loop + +#### JavaScript-Specific + +- `String.prototype.split()` should not be used with lookbehind assertions +- Do not use non-cryptographically secure random number generators for security +- Using `this` outside of a class constructor or method can have unintended consequences +- Do not use `delete` on variables; use it only on object properties +- Do not use `arguments.callee` and `arguments.caller` +- Do not define functions in a loop +- `async` functions should contain `await` expressions or return a `Promise` +- Do not modify the query string of a URL directly +- Do not use `__proto__` property +- Do not use `Function` constructor` +- Do not use `with` statement + +#### Security Vulnerabilities + +- Do not use weak SSL/TLS protocols +- Do not use regular expressions vulnerable to ReDoS +- Do not use weak key-exchange mechanisms +- Do not use insecure randomness sources +- Do not perform redirects to user-controlled URLs without validation +- Do not use insecure XML parsers +- Do not use `this` in a static context to call a non-static method +- Server-side code should not be vulnerable to path traversal attacks +- Do not use `Buffer` constructor without sanitizing input +- Do not use insecure pseudo-random number generators +- Do not use `eval` with expressions from tamperable sources +- Do not disable certificate validation for HTTPS connections +- Do not use `child_process` with unsanitized user input +- Do not use hard-coded credentials +- Do not use insecure template engines +- Do not use `new Function()` with untrusted strings + +#### Framework-Specific + +- React components should not be vulnerable to XSS attacks +- Module dependencies should not form a cycle +- Avoid oversized modules to maintain clean architecture + +### Major Issues (Should Fix) + +#### Function Design + +- Functions should have no more than 7 parameters +- Nested blocks of code should not be empty +- `if/else if` chains should not have duplicated conditions +- Do not assign the result of a `new` expression to a variable that is immediately returned + +#### Code Maintenance + +- `TODO` and `FIXME` tags should be handled +- `switch` statements should have a `default` case +- `switch` statements should have no more than 30 `case` clauses +- Remove unnecessary assignments to variables + +#### Security Configuration + +- Do not use insecure URLs +- `true` and `false` should not be used as strings +- Use `HttpOnly` flag for session cookies +- Do not use `localStorage` or `sessionStorage` for sensitive information +- Limit file upload size to 8MB and standard request size to 2MB + +#### Documentation + +- Use JSDoc comments for functions, methods, and classes +- `if` statements should not be nested too deeply + +### Minor Issues (Style and Consistency) + +#### Naming Conventions + +- Function names should match PascalCase format: `^[A-Z][a-zA-Z0-9]*$` + +#### Code Cleanup + +- Remove unnecessary boolean literals +- Remove commented-out code +- `for` loop update clauses should be correct +- `throw` statements should not be nested in `finally` blocks +- `switch` statements should not have too many `case` clauses +- Use `===` and `!==` instead of `==` and `!=` +- Use secure defaults for `Cross-Origin-Resource-Policy` headers + +### Info Issues (Documentation and Cleanup) + +- Remove or update commented-out code blocks + +## C# Standards + +### Blocker Issues (Must Fix Immediately) + +#### Null Safety + +- When a parameter is nullable, its value must be checked for `null` before being used +- Avoid instantiating a new `Random` object for each use; reuse a single instance + +#### Security + +- Do not hard-code credentials. Avoid using: `password`, `passwd`, `pwd`, or `passphrase` +- Do not use weak cryptographic algorithms +- Do not hard-code certificates or other credentials in code +- Code should not be vulnerable to cross-site scripting (XSS) attacks +- Code should not be vulnerable to SQL injection +- Cookies should be created with the `secure` and `HttpOnly` flags +- Code should not be vulnerable to LDAP injection +- Do not disable server-side certificate validation +- Do not use insecure cryptographic algorithms like DES +- Do not use insecure protocols that accept self-signed certificates +- Do not use weak RSA padding schemes like PKCS1 +- Do not use insecure key-stretching algorithms +- Do not use weak pseudo-random number generators +- Ensure that JSON Web Tokens (JWTs) are properly validated before use +- Secrets (like API keys or tokens) should not be guessable + +#### Threading and Concurrency + +- When using `lock`, it should be on a `private readonly` object +- `Thread.Resume` should not be used +- `Thread.Sleep` should not be used in a `lock` +- `Interlocked.Exchange` should be used to change `ThreadStatic` fields atomically + +#### Memory and Resource Management + +- Remove assignments to variables that are never used (dead stores) +- Return values from `Stream.Read` and related methods should be checked +- `SafeHandle.ReleaseHandle` should not be called from constructors +- Avoid making calls to `GC.Collect` + +#### Data Integrity + +- `Equals` methods on value types should be overridden +- A `DataTable` or `DataSet` should not be serialized with untrusted data +- Dynamic binding should not be used with user-controlled strings +- Variables should not be compared to themselves +- Self-assignments should be removed +- `const` and `static readonly` fields should not be changed + +#### Async/Await + +- Awaiting a `Task` without a timeout can lead to deadlocks + +#### System Security + +- The `System.Security.Permissions.SecurityAction.RequestMinimum` permission should not be used +- Native methods should not be used +- Non-serializable types should not be used in `Session` state + +#### Code Structure + +- Use a `while` loop instead of a `for` loop that has no update statement +- Unused `private` methods should be removed +- `if`/`else if` chains should not have gratuitous `else` clauses +- Members should not be initialized to their default values +- `ISerializable` should be implemented correctly + +### Critical Issues (High Priority) + +#### Method Design + +- Methods should not have identical implementations +- Remove empty methods +- Methods should not be empty +- `async` methods should not be `void` +- `async` methods should be awaited +- Property setters should not be empty + +#### Exception Handling + +- Exception-handling clauses should not be empty +- `[Serializable]` classes should have a constructor that takes `SerializationInfo` and `StreamingContext` + +#### Interface Implementation + +- `ICloneable.Clone` should be implemented correctly +- The `IDisposable` interface should be implemented correctly +- `IDisposable` objects should be disposed before all references are out of scope +- Constructor-injected instances should be stored in fields + +#### Threading + +- Avoid using `Thread.Abort` or `Thread.Suspend` +- Do not use `[DllImport]` on a type that is not a static class + +#### Type Design + +- Do not use the same value on both sides of a binary comparison +- `GetHashCode` should not be overridden on mutable types +- Overriding `Equals` on a type that does not implement `IEquatable` can be error-prone +- A `[Flags]` enum should not have a member with the value zero + +#### Performance + +- Cognitive Complexity of functions should not be higher than 15 +- Do not perform database queries in a loop + +#### Async Patterns + +- Do not use `Task.Factory.StartNew` with an `async` lambda +- Do not use `Task.Result` or `Task.Wait()` on a `Task` that is not completed + +#### Security + +- Do not use `System.Reflection.Assembly.Load` with a byte array +- Avoid using insecure protocols like SSL/TLS +- Regular expressions should not be vulnerable to Denial of Service (DoS) attacks +- Do not use weak random number generators +- Do not allow redirects to user-controlled URLs without validation +- Do not use insecure XML parsers + +### Major Issues (Should Fix) + +#### Code Structure + +- Avoid duplicate `if` statements +- Methods should not have more than 7 parameters +- Empty statements should be removed +- Classes should not have more than 5 levels of inheritance +- Avoid unnecessary `continue` statements +- Finalizers should not be empty + +#### Code Maintenance + +- `TODO` and `FIXME` comments should be resolved +- Unnecessary `using` directives should be removed +- Avoid empty `catch` blocks +- Unused method parameters should be removed +- Avoid duplicate conditions in `if`/`else if` chains +- `switch` statements should have no more than 30 `case` clauses +- Avoid `goto` statements +- Boolean expressions should not be nested +- Unnecessary assignments should be removed + +#### Object-Oriented Design + +- Do not call `virtual` methods in constructors +- `switch` statements should have a `default` case +- Avoid nested `if` statements +- `async` methods should have "Async" as a suffix +- Avoid empty interfaces +- Avoid empty `finally` blocks + +#### Security + +- The `lock` statement should be used to protect shared resources +- The `lock` keyword should be used correctly +- Limit file uploads to a maximum of 8.38MB +- Avoid logging sensitive information + +### Minor Issues (Style and Consistency) + +#### Naming Conventions + +- Class, struct, enum, and interface names should comply with standard naming conventions +- Generic type parameters should be prefixed with "T" +- `[Flags]` enums should be named with a plural +- Logger fields should be `private static readonly` + +#### Code Quality + +- URIs should not be hard-coded +- Do not declare read-only fields that can be converted to `const` +- Remove parameterless constructors from `structs` +- Do not use `virtual` on `sealed` members +- Boolean literals should not be redundant +- Remove duplicate string literals when they exceed a threshold of 3 +- Avoid obsolete `using` directives +- Avoid empty `switch` statements +- Do not use `throw` in a `finally` block +- Remove unused `private` fields +- Avoid unnecessary `public` members in `sealed` classes +- Avoid empty `static` constructors +- Use `nameof()` instead of hard-coded names + +### Info Issues (Documentation and Cleanup) + +- Deprecated code should be removed +- Commented-out code should be removed + +## CSS Standards + +### Blocker Issues (Must Fix Immediately) + +- Avoid using unknown or misspelled HTML element type selectors +- Use valid and standard CSS units (e.g., `px`, `%`, `em`, `rem`) +- Do not use CSS properties that are unknown or misspelled +- Avoid using pseudo-class selectors that are not standard or are misspelled +- Ensure that strings within selectors are enclosed in single or double quotes + +### Critical Issues (High Priority) + +- Media feature names used in `@media` queries must be valid and correctly spelled +- Avoid using a shorthand property after a corresponding longhand property within the same rule +- Do not use font family names that are not defined or recognized + +### Major Issues (Should Fix) + +- Remove commented-out code +- Do not use the `!important` keyword, as it disrupts the natural cascade of styles +- Hex colors should be written in lowercase for consistency +- Avoid using more than three universal selectors (`*`) in a selector list +- Avoid using duplicate selectors within the same stylesheet +- When a property and its value are a fallback, place it before the modern property +- Do not use vendor prefixes for properties that are now standard +- Avoid using unknown or misspelled pseudo-class selectors +- Do not use unknown or non-standard pseudo-element selectors +- Colors should not be specified by their name; use hex, RGB, or HSL values instead +- Avoid using unknown or non-standard at-rules +- Duplicate properties within the same rule should be removed +- Empty comment blocks should be removed + +### Minor Issues (Style and Consistency) + +- Selectors for IDs should not be overqualified by including a type selector +- Remove commented-out code blocks + +## Blazor Component Guidelines + +### Parameter Management (Critical) + +#### The ParameterState Pattern + +**NEVER put logic in parameter getters/setters!** Use the ParameterState framework instead. + +**Bad Example:** + +```csharp +private bool _expanded; + +[Parameter] +public bool Expanded +{ + get => _expanded; + set + { + if (_expanded == value) return; + _expanded = value; + // Logic here causes problems! + _ = UpdateHeight(); // Unobserved async discard! + _ = ExpandedChanged.InvokeAsync(_expanded); // Dangerous! + } +} +``` + +**Good Example:** + +```csharp +private readonly ParameterState _expandedState; + +[Parameter] +public bool Expanded { get; set; } + +public MudCollapse() +{ + using var registerScope = CreateRegisterScope(); + _expandedState = registerScope.RegisterParameter(nameof(Expanded)) + .WithParameter(() => Expanded) + .WithEventCallback(() => ExpandedChanged) + .WithChangeHandler(OnExpandedChangedAsync); +} + +private async Task OnExpandedChangedAsync() +{ + if (_isRendered) + { + _state = _expandedState.Value ? CollapseState.Entering : CollapseState.Exiting; + await UpdateHeightAsync(); // Properly awaited! + _updateHeight = true; + } + else if (_expandedState.Value) + { + _state = CollapseState.Entered; + } + await ExpandedChanged.InvokeAsync(_expandedState.Value); // Properly awaited! +} +``` + +### Avoid Parameter Overwriting + +**Bad Example:** + +```csharp +private Task ToggleAsync() +{ + Expanded = !Expanded; // Don't overwrite parameters! + return ExpandedChanged.InvokeAsync(Expanded); +} +``` + +**Good Example:** + +```csharp +private Task ToggleAsync() +{ + return _expandedState.SetValueAsync(!_expandedState.Value); +} +``` + +### No External Parameter Assignment + +**Bad Example:** + +```razor + + + +@code +{ + private CalendarComponent _calendarRef = null!; + + private void Update() + { + _calendarRef.ShowOnlyOneCalendar = true; // BL0005 warning! + } +} +``` + +**Good Example:** + +```razor + + + +@code +{ + private bool _showOnlyOne; + + private void Update() + { + _showOnlyOne = true; // Declarative approach + } +} +``` + +### Component Design Requirements + +#### RTL Support + +- All components must support Right-to-Left (RTL) layouts +- Include `[CascadingParameter] public bool RightToLeft { get; set; }` when necessary +- Apply RTL styles at the component level + +#### Documentation and Testing + +- Add summary comments for every public property using XML documentation +- Use `CssBuilder` for classes and styles +- Add comprehensive unit tests for any component containing logic +- CSS styling alone requires no testing + +#### CSS Variables + +- Use CSS variables instead of hard-coding colors or other values +- Follow the established design system patterns + +## Testing Requirements + +### Unit Testing Principles + +#### What Must Be Tested + +- All non-trivial C# logic in components +- Two-way bindable properties and their behavior +- Event handling and callbacks +- Component state changes and their effects +- Parameter validation and edge cases + +#### What Doesn't Need Testing + +- Complete rendered HTML output +- Visual appearance of components +- Simple CSS styling without logic + +### Writing bUnit Tests + +#### Best Practices + +```csharp +// Correct approach - don't save HTML elements in variables +var comp = ctx.RenderComponent>(); +comp.Find("input").Change("Garfield"); +comp.Find("input").Blur(); +comp.FindComponent>().Instance.Value.Should().NotBeNullOrEmpty(); +``` + +```csharp +// Wrong approach - HTML elements become stale after interaction +var comp = ctx.RenderComponent>(); +var textField = comp.Find("input"); // Don't do this! +textField.Change("Garfield"); +textField.Blur(); // This will fail - element is stale +``` + +#### Component Interaction + +```csharp +// Always use InvokeAsync for component parameter changes +var comp = ctx.RenderComponent>(); +var textField = comp.FindComponent>().Instance; + +// Wrong +textField.Value = "Garfield"; + +// Correct +await comp.InvokeAsync(() => textField.Value = "I love dogs"); +``` + +### Test Structure + +- Create test components in MudBlazor.UnitTests.Viewer +- Write corresponding tests in MudBlazor.UnitTests +- Assert initial state correctness +- Test parameter changes and their effects +- Test user interactions and event handling +- Verify proper EventCallback invocations + +## Pull Request Guidelines + +### PR Requirements + +#### Content Standards + +- **Single Topic**: Each PR must address only one feature, bug fix, or improvement +- **Target Branch**: Always target the `dev` branch +- **Testing**: All logic changes must include corresponding unit tests +- **Documentation**: Include documentation for new features or API changes + +#### PR Title Format + +``` +: () +``` + +**Example:** + +``` +DateRangePicker: Fix initializing DateRange with null values (#1997) +``` + +#### Description Requirements + +- Link related issues using `Fixes #` for bugs or `Closes #` for features +- Include screenshots/videos for visual changes +- Describe what was changed and why +- List any breaking changes + +#### Technical Requirements + +- All tests must pass (automated CI checks) +- Code must be properly formatted +- No unnecessary refactoring +- Build successfully with no warnings +- Maintain backward compatibility unless explicitly breaking + +### Branch Management + +- Work on descriptive feature branches: `feature/my-new-feature` or `fix/my-bug-fix` +- Keep branches up to date by merging `dev` (don't rebase) +- Use draft PRs for work in progress + +### New Component Requirements + +- Must support RTL layouts +- Include comprehensive unit tests +- Use CSS variables for styling +- Add documentation page with examples +- Examples over 15 lines should be collapsible +- Include XML summary comments for all public properties + +## Project Structure + +### Important Directories + +- `src/MudBlazor/`: Core component library +- `src/MudBlazor.Docs/`: Documentation and examples +- `src/MudBlazor.UnitTests/`: bUnit test suite +- `src/MudBlazor.UnitTests.Viewer/`: Visual test runner + +### Key Files + +- **Components**: `src/MudBlazor/Components/` (.razor, .razor.cs) +- **Styles**: `src/MudBlazor/Styles/components/` (.scss) +- **Enums**: `src/MudBlazor/Enums/` +- **Tests**: `src/MudBlazor.UnitTests/Components/` +- **Test Components**: `src/MudBlazor.UnitTests.Viewer/TestComponents/` + +### Development Workflow + +1. Fork the repository and clone locally +2. Create a descriptive feature branch +3. Make changes and test locally using MudBlazor.Docs.Server +4. Write unit tests for any logic changes +5. Run the full test suite locally +6. Create PR with proper title and description +7. Address review feedback and CI failures +8. Merge when approved and all checks pass + +## Continuous Integration + +### Automated Checks + +- **Build Verification**: Project must compile successfully +- **Test Suite**: All unit tests must pass +- **Code Coverage**: Maintain or improve coverage metrics +- **Code Quality**: Static analysis and linting checks +- **Security Scanning**: Vulnerability detection + +### Local Development + +- Run tests locally before pushing: `dotnet test` +- Use MudBlazor.Docs.Server for development and testing +- Verify changes in MudBlazor.UnitTests.Viewer when applicable +- Format code according to .NET standards + +--- + +**Remember**: These standards exist to ensure code quality, security, and maintainability. When in doubt, err on the side of caution and ask for clarification. All contributors are expected to follow these guidelines to maintain the high quality of our codebase. diff --git a/.github/ISSUE_TEMPLATE/04_performance_issue.md b/.github/ISSUE_TEMPLATE/04_performance_issue.md deleted file mode 100644 index ecafbebdf1f6..000000000000 --- a/.github/ISSUE_TEMPLATE/04_performance_issue.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: Performance issue -about: Report a performance problem or regression -title: '' -labels: 'performance' -assignees: '' - ---- - -### Description - - - -### Configuration - - - -### Data - - - -### Analysis - - diff --git a/.github/codecov.yml b/.github/codecov.yml index ccfe9e78afef..db0fcfafe345 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,5 +1,6 @@ comment: require_changes: true + only_if_failed: true coverage: status: project: @@ -10,4 +11,4 @@ coverage: default: target: 100% threshold: 15% - \ No newline at end of file + diff --git a/.github/scripts/AutoTriage.js b/.github/scripts/AutoTriage.js index cf271c6def70..7c194af80ac3 100644 --- a/.github/scripts/AutoTriage.js +++ b/.github/scripts/AutoTriage.js @@ -1,28 +1,6 @@ /** - * AutoTriage - AI-Powered GitHub Issue & PR Analyzer - * - * Automatically analyzes GitHub issues and pull requests using Google Gemini AI, - * then applies appropriate labels and helpful comments to improve project management. - * - * Features: - * • Smart labeling based on content analysis - * • Helpful AI-generated comments for issues (not PRs) - * • Safe dry-run mode by default - * • Comprehensive error handling and logging - * - * Usage: - * • Issues: Analyzes, labels, comments, and can close if appropriate - * • Pull Requests: Analyzes and labels only (no comments or closing) - * - * Required Environment Variables: - * • GEMINI_API_KEY - Google Gemini API key - * • GITHUB_TOKEN - GitHub token with repo permissions - * • GITHUB_ISSUE_NUMBER - Issue/PR number to process - * • GITHUB_REPOSITORY - Repository in format "owner/repo" - * • AUTOTRIAGE_ENABLED - Set to 'true' to enable real actions (default: dry-run) - * - * Based on original work by Daniel Chalmers - * https://gist.github.com/danielchalmers/503d6b9c30e635fccb1221b2671af5f8 + * AutoTriage - AI-powered GitHub triage bot + * © Daniel Chalmers 2025 */ const fetch = require('node-fetch'); @@ -31,296 +9,305 @@ const core = require('@actions/core'); const fs = require('fs'); const path = require('path'); -// Configuration -const dryRun = process.env.AUTOTRIAGE_ENABLED !== 'true'; -const aiModel = 'gemini-2.5-pro'; - -// Load AI prompt -let basePrompt = ''; -try { - const promptPath = path.join(__dirname, 'AutoTriage.prompt'); - basePrompt = fs.readFileSync(promptPath, 'utf8'); - console.log('🤖 Base prompt loaded from AutoTriage.prompt\n'); -} catch (err) { - console.error('❌ Failed to load AutoTriage.prompt:', err.message); - process.exit(1); -} - -console.log(`🤖 Using Gemini model: ${aiModel}`); - -/** - * Analyze an issue or PR using Gemini AI - */ -async function analyzeIssue(issueText, apiKey, metadata = {}) { - const metadataText = buildMetadataText(metadata); - const commentsText = buildCommentsText(metadata.comments); - - const prompt = `${basePrompt} - -ISSUE TO ANALYZE: -${issueText} - -ISSUE METADATA: -${metadataText} - -COMMENTS: -${commentsText} - -Analyze this issue and provide your structured response.`; +// Global constants +const AI_MODEL = 'gemini-2.5-pro'; +const DB_PATH = process.env.AUTOTRIAGE_DB_PATH; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN; +const GEMINI_API_KEY = process.env.GEMINI_API_KEY; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY; +const ISSUE_NUMBER = parseInt(process.env.GITHUB_ISSUE_NUMBER, 10); +const [OWNER, REPO] = (GITHUB_REPOSITORY || '').split('/'); +const ISSUE_PARAMS = { owner: OWNER, repo: REPO, issue_number: ISSUE_NUMBER }; +const VALID_PERMISSIONS = new Set(['label', 'comment', 'close', 'edit']); +const PERMISSIONS = new Set( + (process.env.AUTOTRIAGE_PERMISSIONS || '') + .split(',') + .map(p => p.trim()) + .filter(p => VALID_PERMISSIONS.has(p)) +); + +async function callGemini(prompt) { + const payload = { + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + responseMimeType: "application/json", + responseSchema: { + type: "object", + properties: { + severity: { type: "integer", description: "How severe the issue is on a scale of 1 to 10" }, + reason: { type: "string", description: "Brief thought process for logging purposes" }, + comment: { type: "string", description: "A comment to reply to the issue with", nullable: true }, + labels: { type: "array", items: { type: "string" }, description: "The final set of labels the issue should have" }, + close: { type: "boolean", description: "Set to true if the issue should be closed as part of this action", nullable: true }, + newTitle: { type: "string", description: "A new title for the issue or pull request", nullable: true } + }, + required: ["severity", "reason", "labels"] + } + } + }; const response = await fetch( - `https://generativelanguage.googleapis.com/v1beta/models/${aiModel}:generateContent`, + `https://generativelanguage.googleapis.com/v1beta/models/${AI_MODEL}:generateContent`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-goog-api-key': apiKey - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: prompt }] }], - generationConfig: { - responseMimeType: "application/json", - responseSchema: { - type: "object", - properties: { - reason: { type: "string", description: "Brief technical explanation for logging purposes" }, - comment: { type: "string", description: "A comment to reply to the issue with", nullable: true }, - labels: { type: "array", items: { type: "string" }, description: "Array of labels to apply" } - }, - required: ["reason", "comment", "labels"] - } - } - }), + headers: { 'Content-Type': 'application/json', 'X-goog-api-key': GEMINI_API_KEY }, + body: JSON.stringify(payload), timeout: 60000 } ); + if (response.status === 429) { + console.error('❌ Gemini API returned 429 (Quota exceeded). Exiting and cancelling backlog.'); + process.exit(3); + } + + if (response.status === 503) { + console.error('❌ Gemini API returned 503 (Model overloaded). Skipping this issue.'); + process.exit(2); + } + if (!response.ok) { - const errText = await response.text(); - throw new Error(`AI API error: ${response.status} ${response.statusText} — ${errText}`); + throw new Error(`Gemini: ${response.status} ${response.statusText} — ${await response.text()}`); } const data = await response.json(); - const analysisResult = data?.candidates?.[0]?.content?.parts?.[0]?.text; + const result = data?.candidates?.[0]?.content?.parts?.[0]?.text; - if (!analysisResult) { - throw new Error('No analysis result in AI response'); - } + saveArtifact('gemini-output.json', JSON.stringify(data, null, 2)); + saveArtifact('gemini-analysis.json', result); - return JSON.parse(analysisResult); + return JSON.parse(result); } -/** - * Build metadata text for AI prompt - */ -function buildMetadataText(metadata) { - const parts = []; - if (metadata.created_at) parts.push(`Created: ${metadata.created_at}`); - if (metadata.updated_at) parts.push(`Last Updated: ${metadata.updated_at}`); - if (metadata.number) parts.push(`Issue Number: #${metadata.number}`); - if (metadata.author) parts.push(`Author: ${metadata.author}`); - if (metadata.comments_count !== undefined) parts.push(`Comments: ${metadata.comments_count}`); - if (metadata.reactions_total !== undefined) parts.push(`Reactions: ${metadata.reactions_total}`); - if (metadata.labels?.length) parts.push(`Labels: ${metadata.labels.join(', ')}`); - - return parts.length ? parts.join('\n') : 'No metadata available.'; +async function buildMetadata(issue, octokit) { + const isIssue = !issue.pull_request; + const currentLabels = issue.labels?.map(l => l.name || l) || []; + const hasAssignee = Array.isArray(issue.assignees) ? issue.assignees.length > 0 : !!issue.assignee; + const { data: collaboratorsData } = await octokit.rest.repos.listCollaborators({ owner: OWNER, repo: REPO, per_page: 100 }); + const { data: releasesData } = await octokit.rest.repos.listReleases({ owner: OWNER, repo: REPO, per_page: 100 }); + + return { + title: issue.title, + state: issue.state, + type: isIssue ? 'issue' : 'pull request', + number: issue.number, + author: issue.user?.login || 'unknown', + created_at: issue.created_at, + updated_at: issue.updated_at, + comments: issue.comments || 0, + reactions: issue.reactions?.total_count || 0, + labels: currentLabels, + assigned: hasAssignee, + collaborators: collaboratorsData.map(c => c.login), + releases: releasesData.map(r => ({ name: r.tag_name, date: r.published_at })), + }; } -/** - * Build comments text for AI prompt - */ -function buildCommentsText(comments) { - if (!comments?.length) return 'No comments available.'; - - let text = '\nISSUE COMMENTS:'; - comments.forEach((comment, idx) => { - text += `\nComment ${idx + 1} by ${comment.author}:\n${comment.body}`; - }); - return text; +async function buildTimeline(octokit) { + const { data: timelineEvents } = await octokit.rest.issues.listEventsForTimeline({ ...ISSUE_PARAMS, per_page: 100 }); + saveArtifact(`github-timeline.md`, JSON.stringify(timelineEvents, null, 2)); + return timelineEvents.map(event => { + const base = { event: event.event, actor: event.actor?.login, timestamp: event.created_at }; + switch (event.event) { + case 'commented': return { ...base, body: event.body }; + case 'labeled': return { ...base, label: { name: event.label.name, color: event.label.color } }; + case 'unlabeled': return { ...base, label: { name: event.label.name } }; + case 'renamed': return { ...base, title: { from: event.rename.from, to: event.rename.to } }; + case 'assigned': + case 'unassigned': return { ...base, user: event.assignee?.login }; + case 'closed': + case 'reopened': + case 'locked': + case 'unlocked': return base; + case 'milestoned': + case 'demilestoned': return { ...base, milestone: event.milestone?.title }; + case 'referenced': return { ...base, commit_id: event.commit_id, commit_url: event.commit_url }; + case 'mentioned': return base; + case 'review_requested': + case 'review_request_removed': return { ...base, requested_reviewer: event.requested_reviewer?.login }; + case 'review_dismissed': return { ...base, review: { state: event.dismissed_review?.state, dismissal_message: event.dismissal_message } }; + case 'merged': return { ...base, commit_id: event.commit_id, commit_url: event.commit_url }; + case 'convert_to_draft': + case 'ready_for_review': return base; + case 'transferred': return { ...base, new_repository: event.new_repository?.full_name }; + default: return null; + } + }).filter(Boolean); } -/** - * Apply labels to match AI suggestions - */ -async function applyLabels(suggestedLabels, issue, repo, octokit) { - const currentLabels = issue.labels?.map(l => typeof l === 'string' ? l : l.name) || []; +async function buildPrompt(issue, octokit, previousContext = null) { + const basePrompt = fs.readFileSync(path.join(__dirname, 'AutoTriage.prompt'), 'utf8'); + const issueText = `${issue.title}\n\n${issue.body || ''}`; + const metadata = await buildMetadata(issue, octokit); + const timelineReport = await buildTimeline(octokit); + const promptString = `${basePrompt} + +=== SECTION: ISSUE TO ANALYZE === +${issueText} + +=== SECTION: ISSUE METADATA (JSON) === +${JSON.stringify(metadata, null, 2)} + +=== SECTION: ISSUE TIMELINE (JSON) === +${JSON.stringify(timelineReport, null, 2)} + +=== SECTION: TRIAGE CONTEXT === +Last triaged: ${previousContext?.lastTriaged} +Previous reasoning: ${previousContext?.previousReasoning} +Current triage date: ${new Date().toISOString()} +Current permissions: ${Array.from(PERMISSIONS).join(', ') || 'none'} +All possible permissions: label (add/remove labels), comment (post comments), close (close issue), edit (edit title) + +=== SECTION: INSTRUCTIONS === +Analyze this issue, its metadata, and its full timeline. +Your entire response must be a single, valid JSON object and nothing else. Do not use Markdown, code fences, or any explanatory text.`; + + saveArtifact(`gemini-input.md`, promptString); + return promptString; +} + +async function updateLabels(suggestedLabels, octokit) { + const { data: issue } = await octokit.rest.issues.get(ISSUE_PARAMS); + const currentLabels = issue.labels?.map(l => l.name || l) || []; const labelsToAdd = suggestedLabels.filter(l => !currentLabels.includes(l)); const labelsToRemove = currentLabels.filter(l => !suggestedLabels.includes(l)); - if (labelsToAdd.length === 0 && labelsToRemove.length === 0) { - console.log('🏷️ No label changes needed'); - return; - } + if (labelsToAdd.length === 0 && labelsToRemove.length === 0) return; - if (!octokit) { - console.log(`🏷️ [DRY RUN] Would add: [${labelsToAdd.join(', ')}]`); - console.log(`🏷️ [DRY RUN] Would remove: [${labelsToRemove.join(', ')}]`); - return; - } + const changes = [ + ...labelsToAdd.map(l => `+${l}`), + ...labelsToRemove.map(l => `-${l}`) + ]; + console.log(`🏷️ Label changes: ${changes.join(', ')}`); + + if (!octokit || !PERMISSIONS.has('label')) return; if (labelsToAdd.length > 0) { - await octokit.rest.issues.addLabels({ - owner: repo.owner, - repo: repo.repo, - issue_number: issue.number, - labels: labelsToAdd - }); - console.log(`🏷️ Added: [${labelsToAdd.join(', ')}]`); + await octokit.rest.issues.addLabels({ ...ISSUE_PARAMS, labels: labelsToAdd }); } for (const label of labelsToRemove) { - await octokit.rest.issues.removeLabel({ - owner: repo.owner, - repo: repo.repo, - issue_number: issue.number, - name: label - }); - } - if (labelsToRemove.length > 0) { - console.log(`🏷️ Removed: [${labelsToRemove.join(', ')}]`); + await octokit.rest.issues.removeLabel({ ...ISSUE_PARAMS, name: label }); } } -/** - * Post a comment on an issue - */ -async function postComment(issue, repo, octokit, comment) { - const commentWithFooter = `${comment}\n\n---\n*This comment was automatically generated using AI. If you have any feedback or questions, please share it in a reply.*`; - - if (!octokit) { - console.log(`💬 [DRY RUN] Would post comment:`); - console.log(commentWithFooter.replace(/^/gm, '> ')); - return; - } +async function createComment(body, octokit) { + if (!octokit || !PERMISSIONS.has('comment')) return; + await octokit.rest.issues.createComment({ ...ISSUE_PARAMS, body: body }); +} - await octokit.rest.issues.createComment({ - owner: repo.owner, - repo: repo.repo, - issue_number: issue.number, - body: commentWithFooter - }); +async function updateTitle(title, newTitle, octokit) { + console.log(`✏️ Updating title from "${title}" to "${newTitle}"`); + if (!octokit || !PERMISSIONS.has('edit')) return; + await octokit.rest.issues.update({ ...ISSUE_PARAMS, title: newTitle }); +} - console.log(`💬 Posted comment`); +async function closeIssue(octokit, reason = 'not_planned') { + console.log(`🔒 Closing issue as ${reason}`); + if (!octokit || !PERMISSIONS.has('close')) return; + await octokit.rest.issues.update({ ...ISSUE_PARAMS, state: 'closed', state_reason: reason }); } -/** - * Fetch issue data from GitHub - */ -async function fetchIssueData(owner, repo, number, octokit) { - if (!octokit) { - throw new Error('GitHub token required to fetch issue data'); +async function processIssue(issue, octokit, previousContext) { + const metadata = await buildMetadata(issue, octokit); + const formattedMetadata = [ + `#${metadata.number} (${metadata.state} ${metadata.type}) was created by ${metadata.author}`, + `Title: ${metadata.title}`, + `Updated: ${metadata.updated_at}`, + `Labels: ${metadata.labels.join(', ') || 'none'}`, + ].map(line => `📝 ${line}`).join('\n'); + console.log(formattedMetadata); + + const prompt = await buildPrompt(issue, octokit, previousContext); + const startTime = Date.now(); + const analysis = await callGemini(prompt); + const analysisTime = ((Date.now() - startTime) / 1000).toFixed(1); + + console.log(`🤖 Gemini returned analysis in ${analysisTime}s with a severity score of ${analysis.severity}/10:`); + console.log(`🤖 "${analysis.reason}"`); + + await updateLabels(analysis.labels, octokit); + + if (analysis.comment) { + console.log(`💬 Posting comment:`); + console.log(analysis.comment.replace(/^/gm, '> ')); + await createComment(analysis.comment, octokit); } - const { data: issue } = await octokit.rest.issues.get({ - owner, - repo, - issue_number: number - }); - - let comments = []; - if (issue.comments > 0) { - const { data: commentsData } = await octokit.rest.issues.listComments({ - owner, - repo, - issue_number: number - }); - comments = commentsData.map(comment => ({ - author: comment.user?.login || 'unknown', - body: comment.body || '' - })); + if (analysis.close) { + await closeIssue(octokit, 'not_planned'); } - return { issue, comments }; -} - -/** - * Process a single issue or PR - */ -async function processIssue(issue, repo, geminiApiKey, octokit, comments = []) { - const isIssue = !issue.pull_request; - const itemType = isIssue ? 'issue' : 'pull request'; - - if (issue.locked) { - console.log(`🔒 Skipping locked ${itemType} #${issue.number}`); - return null; + if (analysis.newTitle) { + await updateTitle(issue.title, analysis.newTitle, octokit); } - const metadata = { - number: issue.number, - created_at: issue.created_at, - updated_at: issue.updated_at, - author: issue.user?.login || 'unknown', - comments_count: issue.comments || 0, - reactions_total: issue.reactions?.total_count || 0, - state: issue.state, - type: isIssue ? 'issue' : 'pull_request', - labels: issue.labels?.map(l => typeof l === 'string' ? l : l.name) || [], - comments: comments - }; - - // Log issue info - console.log(`\n📝 ${issue.title}`); - console.log(`📝 ${metadata.state} ${itemType} by ${metadata.author} (${metadata.created_at})`); - console.log(`🏷️ Current labels: [${metadata.labels.join(', ') || 'none'}]`); - console.log(`💬 Comments: ${metadata.comments_count}, Reactions: ${metadata.reactions_total}`); - - // Analyze with AI - const issueText = `${issue.title}\n\n${issue.body || ''}`; - const analysis = await analyzeIssue(issueText, geminiApiKey, metadata); + return analysis; +} - if (!analysis || typeof analysis !== 'object') { - throw new Error('Invalid analysis result'); +function getPreviousTriageContext(triageEntry) { + // Triage for the first time. + if (!triageEntry) { + return { lastTriaged: null, previousReasoning: 'This issue has never been triaged.' }; } - console.log(`💡 ${analysis.reason}`); - - // Apply labels - await applyLabels(analysis.labels, issue, repo, octokit); + const lastTriagedDate = new Date(triageEntry.lastTriaged); + const timeSinceTriaged = Date.now() - lastTriagedDate.getTime(); - // Post comment for issues only - if (isIssue && analysis.comment) { - await postComment(issue, repo, octokit, analysis.comment); + // Recheck after 14 days for stale checks and prompt updates. + if (timeSinceTriaged > 14 * 86400000) { + return { lastTriaged: triageEntry.lastTriaged, previousReasoning: triageEntry.previousReasoning }; } - return analysis; + return null; // Otherwise, no triage is needed. +} + +function saveArtifact(name, contents) { + const artifactsDir = path.join(process.cwd(), 'artifacts'); + const filePath = path.join(artifactsDir, `${ISSUE_NUMBER}-${name}`); + fs.mkdirSync(artifactsDir, { recursive: true }); + fs.writeFileSync(filePath, contents, 'utf8'); } -/** - * Main execution - */ async function main() { - // Validate environment - const required = ['GITHUB_ISSUE_NUMBER', 'GEMINI_API_KEY', 'GITHUB_REPOSITORY']; - for (const env of required) { - if (!process.env[env]) { - throw new Error(`${env} environment variable is required`); - } + for (const envVar of ['GITHUB_ISSUE_NUMBER', 'GEMINI_API_KEY', 'GITHUB_REPOSITORY', 'GITHUB_TOKEN']) { + if (!process.env[envVar]) throw new Error(`Missing environment variable: ${envVar}`); } - const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/'); - const number = parseInt(process.env.GITHUB_ISSUE_NUMBER, 10); - - console.log(`📝 Processing ${owner}/${repo}#${number}`); - console.log(`🔧 Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}`); - - // Initialize GitHub client - let octokit = null; - if (process.env.GITHUB_TOKEN) { - octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + // Initialize database + let triageDb = {}; + if (DB_PATH && fs.existsSync(DB_PATH)) { + const contents = fs.readFileSync(DB_PATH, 'utf8'); + triageDb = contents ? JSON.parse(contents) : {}; } - // Fetch and process issue - const { issue, comments } = await fetchIssueData(owner, repo, number, octokit); - const octokitForOps = dryRun ? null : octokit; + // Setup + const octokit = new Octokit({ auth: GITHUB_TOKEN }); + const issue = (await octokit.rest.issues.get(ISSUE_PARAMS)).data; + const previousContext = getPreviousTriageContext(triageDb[ISSUE_NUMBER]); - await processIssue(issue, { owner, repo }, process.env.GEMINI_API_KEY, octokitForOps, comments); + // We don't need to triage + if (!previousContext) { + process.exit(2); + } - console.log('\n✅ AutoTriage completed successfully'); + // Take action on issue + console.log("⏭️"); + console.log(`🤖 Using ${AI_MODEL} with [${Array.from(PERMISSIONS).join(', ') || 'none'}] permissions`); + const analysis = await processIssue(issue, octokit, previousContext); + + // Save database + if (DB_PATH && analysis && PERMISSIONS.size > 0) { + triageDb[ISSUE_NUMBER] = { + lastTriaged: new Date().toISOString(), + previousReasoning: analysis.reason + }; + fs.writeFileSync(DB_PATH, JSON.stringify(triageDb, null, 2)); + } } -// Execute main().catch(err => { - console.error('\n❌ Error:', err.message); + console.error('❌ Error:', err.message); core.setFailed(err.message); process.exit(1); }); diff --git a/.github/scripts/AutoTriage.prompt b/.github/scripts/AutoTriage.prompt index f49583b2de5b..f72fa0c510a0 100644 --- a/.github/scripts/AutoTriage.prompt +++ b/.github/scripts/AutoTriage.prompt @@ -1,122 +1,347 @@ -You are a GitHub issue analysis assistant for MudBlazor. Your purpose is to save maintainers time by triaging issues. Only process issues that are in an 'Open' state; ignore any that are 'Closed' or 'Draft'. If you can help by asking questions or giving suggestions based on public information, do so. +# GitHub Issue Analysis Assistant -PERSONA GUIDELINES: -Your role is a first-line triage assistant. Your primary goal is to ensure new issues have enough information for a maintainer to act on them. You are not a discussion moderator, a summarizer, or a participant in ongoing conversations. Your main job is to interact with the initial report; avoid inserting yourself into active back-and-forth technical discussions. On GitHub, your username is `github-actions`. -Crucially, do not attempt to diagnose the internal workings of MudBlazor components or provide code snippets. Your tone should be kind, helpful, and direct. Avoid overstepping your bounds with statements like "This seems like a useful proposal" or "I will close this issue," which imply a level of authority or ability to perform repository actions. +## CORE BEHAVIOR -When you see a potential link to a common problem (caching, static rendering, or versioning), briefly mention it as a possibility to explore. -**Example:** "Hmm, this sounds like it could be related to caching. Have you tried incognito mode to rule that out? Just a thought." +On GitHub, your username is `github-actions` or `github-actions[bot]`. +Analyze the issue and return structured JSON format. +Explain your reasoning in `reason`. If you wanted to make a change but didn't because of a rule, mention that. +All time-based calculations must be performed by comparing relevant dates to "Current triage date". Do not use relative or ambiguous time logic. Always use explicit date comparisons. +You are provided with releases (name and date). Always consider the latest version when analyzing issues, especially when considering if a bug might have been fixed. +For testing purposes, don't consider "danielchalmers" a collaborator. Treat them like any other user. +You cannot test reproduction links, run code, or verify bugs in live environments. Never imply, state, or reason that you have tested a reproduction link or confirmed a bug by running code. Do NOT lie about capabilities; you can only talk. +You're not a discussion moderator, summarizer, or participant in ongoing conversations. Never summarize issues. +Your main job is to interact with initial reports; avoid inserting yourself into active technical discussions. +Never override an action taken by a collaborator unless it seems like a genuine mistake. Before suggesting any change, you must review the item's history. If a collaborator has already performed a relevant action (e.g., adding/removing a label, editing a title), do not suggest altering it. + +## PERSONA GUIDELINES + +Your role is issue management assistant. +Primary goals: triage new issues for completeness and perform routine maintenance, such as identifying inactive (stale) issues requiring attention or closure. You will interact with both new reports and older, ongoing issues as needed. +Foster a welcoming, constructive environment. Always be encouraging, positive, and patient—especially when guiding users to provide more information or improve reports. +Always evaluate new information or arguments from users even if they challenge your previous triage decision. +Your tone should be warm, supportive, and helpful, while remaining clear and direct. + +## PROJECT CONTEXT -PROJECT CONTEXT: - MudBlazor is a Blazor component framework with Material Design - Written in C#, Razor, and CSS with minimal JavaScript - Cross-platform support (Server, WebAssembly, MAUI) -- Reproduction site [https://try.mudblazor.com](https://try.mudblazor.com) is always on the latest version. -- Current v8.x.x supports .NET 8 and later -- Version migration guides are at [https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md](https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md) -- Templates for new projects are at [https://github.com/MudBlazor/Templates](https://github.com/MudBlazor/Templates) -- Complete installation guide is at [https://mudblazor.com/getting-started/installation](https://mudblazor.com/getting-started/installation) -- Contribution guidelines are at [https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md](https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md) -- Code of Conduct is at [https://github.com/MudBlazor/MudBlazor/blob/dev/CODE_OF_CONDUCT.md](https://github.com/MudBlazor/MudBlazor/blob/dev/CODE_OF_CONDUCT.md) -- Community talk is at [GitHub Discussions](https://github.com/MudBlazor/MudBlazor/discussions) or [Discord](https://discord.gg/mudblazor). - -COMMON ISSUE TYPES: +- Accepted reproduction sites: try.mudblazor.com, github.com, or docs on mudblazor.com + - Generic placeholder "https://try.mudblazor.com/snippet" with nothing after "snippet" counts as missing reproduction. Should look like "https://try.mudblazor.com/snippet/GOcpOVQqhRGrGiGV". + - Reproduction site [https://try.mudblazor.com](https://try.mudblazor.com) is always on the latest version +- Version migration guides: [https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md](https://github.com/MudBlazor/MudBlazor/blob/dev/MIGRATION.md) +- Templates: [https://github.com/MudBlazor/Templates](https://github.com/MudBlazor/Templates) +- Installation guide: [https://mudblazor.com/getting-started/installation](https://mudblazor.com/getting-started/installation) +- Contribution guidelines: [https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md](https://github.com/MudBlazor/MudBlazor/blob/dev/CONTRIBUTING.md) +- Code of Conduct: [https://github.com/MudBlazor/MudBlazor/blob/dev/CODE_OF_CONDUCT.md](https://github.com/MudBlazor/MudBlazor/blob/dev/CODE_OF_CONDUCT.md) +- Community: [GitHub Discussions](https://github.com/MudBlazor/MudBlazor/discussions) or [Discord](https://discord.gg/mudblazor) + +**Current maintainer/collaborator logins:** +henon +vernou +tjscience +Anu6is +danielchalmers +Garderoben +Flaflo +igotinfected +HClausing +ingkor +mikes-gh +jperson2000 +ScarletKuro +meenzen +xC0dex +tungi52 +Mr-Technician +lindespang +ralvarezing +just-the-benno +JonBunator +mckaragoz +versile2 + +## VALID LABELS + +**Labels you can apply:** +"accessibility": "Impacts usability for users with disabilities (a11y)" +"breaking change": "For PRs: Signifies that a change will require users to modify their code upon update" +"bug": "An unexpected behavior or defect. Primary issue type." +"build": "Relates to project's build process, tooling, CI/CD, README, or repository configuration" +"dependency": "Involves external libraries, packages, or third-party services" +"docs": "Pertains to documentation changes. Primary issue type." +"enhancement": "A new feature or improvement. Primary issue type." +"good first issue": "Well-defined, uncontroversial, and simple issue suitable for new contributors" +"has workaround": "Indicates reasonable, functional, albeit temporary, solution exists for the reported bug" +"help wanted": "A bug fix or feature that has been open for some time, has clear community interest, and is ready for a contributor to work on." +"info required": "Issue is blocked pending necessary details from the author for triage" +"invalid": "Action label indicating blatant spam or violation of community standards" +"localization": "Concerns support for multiple languages or regional formats" +"mobile": "Impacts or is exclusive to small viewports, touch devices, or mobile-specific layouts (iOS/Android)" +"needs example": "Specific missing information is a code example or reproduction link" +"needs screenshot": "Specific missing information is a screenshot or video of the visual problem" +"new component": "For tracking an 'enhancement' that proposes or adds a brand-new UI component" +"performance": "Relates to speed, responsiveness, or resource efficiency" +"question": "Action label for a user seeking help and not reporting a bug or requesting a feature" +"refactor": "For PRs: Primary focus is code reorganization that preserves existing behavior" +"regression": "High-priority bug where a feature that previously worked is now broken" +"safari": "The issue is specific to the Safari browser on desktop or iOS" +"security": "Impacts application security, including vulnerabilities or data protection" +"stale": "Indicates an issue is inactive and will be closed if no further updates occur" +"tests": "Relates to unit, integration, or other automated testing frameworks" +"triage needed": "Applied to issues or pull requests that are urgent, or when the author is making a genuine effort in follow up comments to engage with maintainers but no collaborator has responded in over two weeks" +"PR: needs review": "Legacy label that no longer exists and should be ignored in the timeline" + +## LABELING GUIDELINES + +Only suggest labels from the VALID LABELS list. Never attempt to create new labels. +Never remove labels that are not in your VALID LABELS list. These are custom labels added by maintainers (e.g., 'on hold', 'not planned') and must always be preserved. +Apply at most one from: 'bug', 'enhancement', 'docs'. + +When to apply specific labels: +- 'info required': + - If uncertain of primary issue type initially, apply 'info required'. + - Can remain alongside primary issue type if other crucial details are still missing for full triage. + - Do not remove unless information previously requested has been satisfactorily provided. + - Always post a comment explaining what information is needed when applying this label. +- 'needs example': Apply with 'info required' when specific missing information is a code example or reproduction link. +- 'needs screenshot': Apply with 'info required' when specific missing information is a screenshot or video of the visual problem. +- 'invalid': + - Extremely low-quality issues that are empty, unintelligible, or spam. + - Bug reports missing MudBlazor version are invalid. + - If issue has any substantive information, apply 'info required' instead and request missing details. + - Always post a comment explaining why the issue was marked invalid. +- 'question': + - Does not apply to pull requests + - Action label for user seeking help and not reporting a bug or requesting a feature. + - Always post a comment explaining why label was added and direct to appropriate community channels. +- 'triage needed': + - Apply to urgent issues or pull requests requiring immediate attention due to severity, impact, or time sensitivity. + - Also apply to issues or pull requests where the author is making a genuine effort to work with maintainers (e.g., providing details, responding to feedback, or constructively following up), but no collaborator has responded in over a month (compare last collaborator reply date to "Current triage date"). + - Do NOT apply if the author is only posting 'bump', 'any update?', or similar non-constructive comments, because that will reset the timer. + - This label is for surfacing items that need a maintainer's attention, not for low-effort follow-ups. +- 'regression': Apply with 'bug' to indicate high-priority bug where a feature that previously worked is now broken. +- 'good first issue': Only apply to issues that are completely clear, well-defined, and have reached consensus. Never apply to ambiguous issues or those still under discussion about the approach or requirements. +- 'help wanted': Only apply when there is demonstrated ongoing community interest through multiple comments, reactions, or confirmations from different users. + +## COMMON ISSUE TYPES + - Component bugs (MudButton, MudTable, MudDialog, MudForm, etc.) - Styling/theming and CSS customization - Browser compatibility - Accessibility and Material Design compliance -- Performance with large datasets - Integration with Blazor Server/WASM/MAUI - Documentation gaps -- Potential security vulnerabilities or major regressions in core components -- Caching: Often causes issues after updates. Suggest testing in incognito mode. -- Static rendering issues: NOT supported in MudSelect/MudAutocomplete. See [render modes documentation](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes) or [discussion](https://github.com/MudBlazor/MudBlazor/discussions/7430). - -ANALYZE THE ISSUE FOR QUALITY: -Consider an issue's age and update frequency. Note engagement like comments and reactions to gauge community interest, especially on older, un-updated issues. - -LOW QUALITY indicators (flag for improvement): -- Vague descriptions. -- Visual problems without screenshots. -- Bug reports missing reproduction steps or a working example (the generic placeholder link "[https://try.mudblazor.com/snippet](https://try.mudblazor.com/snippet)" counts as a missing reproduction). -- A feature request or enhancement that is missing a clear problem description, use case, or motivation (the "why"). -- Missing expected vs actual behavior. -- Missing technical details (version, browser, render mode). -- Ambiguous or unhelpful issue titles. -- Pure "how-to" or usage questions that are not describing a bug or unexpected behavior. You must be very certain it is a request for help and not a bug report. If an issue describes something "not working" or "behaving unexpectedly," treat it as a potential bug, even if phrased as a question. -- Code of conduct violations (harassment, trolling, personal attacks). -- Extremely low-effort issues (single words, gibberish, spam). -- Issues where the author put zero effort into explaining the problem (e.g., just "broken", "doesn't work"). - -HIGH QUALITY indicators (ready for labeling): -- Clear component name and specific problem. -- Includes reproduction steps or a working example (e.g., on [try.mudblazor.com](https://try.mudblazor.com)). -- For feature requests, a clear problem description and use case (the "why") are provided. -- Expected vs actual behavior explained. -- Technical details and screenshots provided. -- Descriptive title. - -VALID LABELS: -{ -    "accessibility": "Impacts usability for users with disabilities (a11y).", -    "breaking change": "For PRs: Signifies that a change will require users to modify their code upon update.", -    "bug": "An unexpected behavior or defect. This is a primary issue type. Apply at most one from the group: 'bug', 'enhancement', 'docs'. If uncertain, apply 'info required' to probe for more details before classifying.", -    "build": "Relates to the project's build process, tooling, CI/CD, README, or repository configuration.", -    "dependency": "Involves external libraries, packages, or third-party services.", -    "docs": "Pertains to documentation changes. This is a primary issue type. Apply at most one from the group: 'bug', 'enhancement', 'docs'. If uncertain, apply 'info required' to probe for more details before classifying.", -    "enhancement": "A new feature or improvement. This is a primary issue type. Apply at most one from the group: 'bug', 'enhancement', 'docs'. If uncertain, apply 'info required' to probe for more details before classifying.", -    "good first issue": "A well-defined, uncontroversial, and very simple to implement issue suitable for new contributors.", - "has workaround": "Indicates that a functional, albeit temporary, solution exists for the reported bug.", -    "info required": "The primary action label when an issue is blocked pending necessary details from the author. Use this for initial triage when it's unclear if an issue is a 'bug', 'enhancement', or something else.", -    "invalid": "An action label indicating a violation of community standards that warrants closing the issue, or an issue that will be closed due to excessive low quality.", -    "localization": "Concerns support for multiple languages or regional formats.", -    "mobile": "Impacts or is exclusive to small viewports, touch devices, or mobile-specific layouts (iOS/Android).", -    "needs example": "Apply IN ADDITION TO 'info required' when the specific missing information is a code example or a reproduction link.", -    "needs screenshot": "Apply IN ADDITION TO 'info required' when the specific missing information is a screenshot or video of the visual problem.", -    "new component": "For tracking an 'enhancement' that proposes or adds a brand-new UI component.", -    "performance": "Relates to speed, responsiveness, or resource efficiency.", -    "question": "An action label for a user seeking help and not reporting a bug or requesting a feature", -    "refactor": "For PRs: The primary focus is code reorganization that preserves existing behavior.", -    "regression": "Apply IN ADDITION TO 'bug' to indicate a high-priority bug where a feature that previously worked is now broken.", -    "safari": "The issue is specific to the Safari browser on desktop or iOS.", -    "security": "Impacts application security, including vulnerabilities or data protection.", - "stale": "Indicates an issue is inactive and will be closed if no further updates occur.", -    "tests": "Relates to unit, integration, or other automated testing frameworks." -} - -TASK: Analyze the issue and provide the following with high confidence: -1.  Quality assessment (whether the issue needs improvement or should be closed). -2. Appropriate labels from the valid labels list to add or remove. Only suggest removing labels if an existing label seems like a genuine mistake. -3.  Comment only if you have suggestions highly likely to help solve/improve the issue. - -COMMENTING GUIDELINES: -The decision to comment is based on one question: "Can I add clear value to this issue without getting in the way of human contributors?" - -**Good reasons to comment:** -*To request critical missing information:* -- The issue is a bug report but is missing a reproduction link on try.mudblazor.com. -- The issue describes a visual problem but is missing a screenshot. -- The issue is vague (e.g., "it's broken") and needs a clearer description of expected vs. actual behavior. -- The issue is a feature request (enhancement) but lacks a clear problem description, use case, or motivation (the "why"). -- If an issue appears to be a high-priority security vulnerability or a major regression that could likely impact a lot of users, you may comment ping the triage team by including `cc @MudBlazor/triage` at the end of your comment. This can be done even if the issue is otherwise high-quality. Use this with caution. - -**Bad reasons to comment (DO NOT COMMENT):** -- The issue meets all criteria for a high-quality report and needs no further input (unless it's a high-priority exception). -- Another user or maintainer (especially a core team member: `versile2`, `scarletkuro`, `danielchalmers`, `henon`, `garderoben`) has already asked for the same information or provided the same suggestion. -- The discussion is an active, ongoing technical debate. +- Caching: Often causes issues after updates. Suggest testing in incognito mode or private browser window +- Static rendering issues: NOT supported in MudSelect, MudAutocomplete, and most other components. See [render modes documentation](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes) or [discussion](https://github.com/MudBlazor/MudBlazor/discussions/7430) +- Commonly changed components: **MudDataGrid**, **MudTable**, **MudTabs**, **MudSelect**, **MudAutocomplete**, **MudPicker**, **MudMenu**, **MudPopover**, **MudOverlay**, **MudDialog**. + +## QUALITY ASSESSMENT + +Consider issue's age and update frequency. Note engagement like comments and reactions to gauge community interest, especially on older, un-updated issues. + +**LOW QUALITY indicators (flag for improvement):** +- Vague descriptions +- Not in English +- Visual problems without screenshots +- Bug reports missing reproduction steps or working example +- Feature requests missing clear problem description, use case, or motivation (the "why") +- Missing expected vs actual behavior +- Missing technical details (version, browser, render mode) +- Ambiguous or unhelpful issue titles +- Pure "how-to" or usage questions that are not describing a bug or unexpected behavior. You must be certain it's a request for help and not a bug report. If issue describes something "not working" or "behaving unexpectedly," treat it as a potential bug, even if phrased as a question +- Code of conduct violations (harassment, trolling, personal attacks) +- Extremely low-effort issues (single words, gibberish, spam) +- Issues where the author put zero effort into explaining the problem (e.g., just "broken", "doesn't work") + +**HIGH QUALITY indicators (ready for labeling):** +- Clear component name and specific problem +- For feature requests, clear problem description and use case (the "why") are provided +- Expected vs actual behavior explained +- Technical details and screenshots provided +- Descriptive title + +## COMMENTING GUIDELINES + +Primary goal: gather information for maintainers efficiently. Only comment when necessary to move an issue forward. + +**Prioritize Commenting For:** +- **Missing Information for Triage (`info required`):** + - If `info required` label is added, you must leave a comment explaining what information is missing and why it is needed for triage, unless you or a collaborator have already recently asked for that information. + - If key details are missing (e.g., reproduction, browser, operating system, screenshots, error messages, logs, clear use case, still present in latest version), you must comment explaining what's needed. + - However, if a user has provided a reproduction after being asked, do not add a new comment requesting a reproduction again. Accept the provided reproduction and proceed, even if it's not on the accepted reproduction site list. + - Any user, including the author, may supply missing details or confirm that the issue is still occurring. If any user has recently confirmed the issue remains relevant—even if the original report was for an older version—you must consider that confirmation in your triage decision. + - Do not request additional information if the issue has an assignee, as this indicates a maintainer is already working on it. + - Do not request additional information if the issue was created by a collaborator. + - Do not request additional information if the issue was created by a bot. +- **Usage Questions (`question` label):** + - If an issue is clearly a help request and not a bug/feature, you must comment to explain the `question` label and direct the user to [GitHub Discussions](https://github.com/MudBlazor/MudBlazor/discussions) or [Discord](https://discord.gg/mudblazor). +- **Stale Issues/PRs (or becoming stale):** + - **Always comment** as per "STALE ISSUE ACTIONS" guidelines when marking an item as stale or closing a stale item. + - Use provided stale comment templates, but you are allowed to modify them to incorporate other comments you want to make. If you don't make any modifications you can exclude the disclaimer you normally add to the end of every comment. + +**General Commenting Principles:** +- **Be Direct & Helpful:** Explain *why* information is needed. Frame advice as possibilities. +- **Avoid Over-commenting:** + - If you've previously commented, only do so again if you can help substantially. Avoid repetitive or minor follow-ups. + - Do not comment if the same information has already been requested recently by you or someone else. +- **No PR Suggestions:** Never ask or suggest that someone create a pull request. +- **Neutral Tone:** Never use "we" or imply you represent the MudBlazor team. Do not promise maintainer actions. Use "For anyone investigating this..." +- **Code of Conduct Violations:** Be firm, explain the violation, link to Code of Conduct, and **immediately close the issue**. + +**DO NOT Comment** +- On pull requests. The only exception is for applying stale rules (marking as stale or closing as stale). This rule is absolute; do not comment on PRs to ask for information, suggest troubleshooting, or for any other reason. +- If the issue is closed. +- If the issue is already high quality and needs no further input from you. +- To join an active, ongoing technical debate. - To summarize, thank the user, or compliment the issue. -- You (`github-actions`) have already commented on the issue. -- The issue has a `stale` label. - -COMMENT STYLE: -- Be direct, technical, and kind. Use markdown for links. -- Explain *why* you need information. (do not repeat these examples verbatim) -  - **Good:** "Could you please add a reproduction of the issue using our interactive playground at [try.mudblazor.com](https://try.mudblazor.com)? It's the fastest way to investigate and confirm a bug." -  - **Good:** "Could you share the full error message and stack trace from the browser's developer console? That will help pinpoint where the problem is happening." - - **Good:** "Thanks for the suggestion. Could you elaborate a bit on the use case for this feature? Understanding the problem you're trying to solve helps the team prioritize and design the best solution." -- Frame advice as possibilities based on general web development practices or public documentation. Avoid speculating about the component's internal code. -- You can't verify if a reproduction link works, so just acknowledge its presence. -- Use the phrase "**For anyone investigating this...**" instead of implying a maintainer will follow up. -- For help questions: Answer if you can, then direct to Discussions or Discord. -- For violations: Be firm, explain the violation, and link to the Code of Conduct. -- For closures: Clearly explain the reason and state that the author can open a new, improved issue. \ No newline at end of file +- If the item was created by a collaborator. + +## COMMENT STYLE + +Don't list all reasons an issue is high quality or good; avoid unnecessary praise or summaries. +Use clean, well-formatted markdown as it will be posted on GitHub. +Always use explicit newline characters (`\n`) within the `comment` field of the JSON response to create line breaks for readability on GitHub. +If asking for multiple pieces of information in a comment, use bullet points for clarity. +Never use "we" or imply you represent the MudBlazor team or maintainers. Don't promise or suggest that maintainers will evaluate, fix, or follow up on the issue or PR. Only state facts, ask for information, or clarify current state. +Explain *why* you need information and frame advice as possibilities based on general web development practices. +Don't attempt to diagnose internal workings of MudBlazor components or suggest that specific features already exist because your information might be out of date. +Use "For anyone investigating this..." instead of implying a maintainer will follow up. +For help questions: Answer if you can, then direct to Discussions or Discord. +For conduct violations: Be firm, explain the violation, and link to Code of Conduct. +Always end your comment with the disclaimer: "\n\n---\n*I'm an AI assistant — If I missed something or made a mistake, please let me know in a reply!*" + +**Examples:** +- "Could you provide a reproduction of this issue using our interactive playground at [try.mudblazor.com](https://try.mudblazor.com)? It's the fastest way to investigate and confirm a bug." +- "Could you provide a minimal code snippet showing how you're using the component? This would be very helpful." +- "To help pinpoint the problem, could you share the full error message and stack trace from your browser's developer console?" +- "Thanks for the idea! Could you elaborate on the specific use case for this feature? Understanding the problem you're trying to solve helps determine the best approach." +- "Please add a screenshot or video showing the visual issue. This helps anyone investigating clearly see what's happening." +- "You mentioned 'it doesn't work' – could you describe what you expected to happen versus what actually occurred? This will help identify the specific problem." +- "Which version of MudBlazor are you using? Also, please share your browser and .NET versions. These details help narrow down potential causes." +- "This seems like a question about usage rather than a bug report. For help, please check our [GitHub Discussions](https://github.com/MudBlazor/MudBlazor/discussions) or [Discord](https://discord.gg/mudblazor) where the community can assist." +- "This might be related to browser caching. Have you tried it in incognito mode or a private window to see if that resolves it?" +- "This appears to involve static rendering, which isn't supported in MudSelect and certain other components. You might find more information in the [render modes documentation](https://learn.microsoft.com/aspnet/core/blazor/components/render-modes) or this [discussion](https://github.com/MudBlazor/MudBlazor/discussions/7430)." +- "This violates our [Code of Conduct](https://github.com/MudBlazor/MudBlazor/blob/dev/CODE_OF_CONDUCT.md). Please keep discussions respectful and constructive." + +Your statements must be objective and based only on information in the issue. Avoid making authoritative judgments or implying you can test code. Use the following guidelines for your tone: +- Instead of: "I tested the link and it's broken.", use: "A user mentioned the reproduction link wasn't working." +- Instead of: "This helps pinpoint the performance issue.", use: "This should help pinpoint the performance issue." +- Instead of: "It looks very similar to bug #xxxx.", use: "It may be similar to a bug that was fixed in #xxxx." +- Instead of: "This is a useful feature.", use: (Do not comment on the usefulness of a feature) + +The examples are for guidance. Avoid using them verbatim. Try to rephrase the example to fit the specific issue context. + +## ISSUE CLOSING POLICY + +You must never close an issue unless: +- It has been marked as `invalid` +- It has been marked as `stale` AND meets all rules for closing stale issues (see below) + +Don't close issues for any other reason, even if they're low quality, invalid, or missing information. Only comment or label in those cases. They will be allowed to go stale and then closed later. + +## STALE ISSUE IDENTIFICATION + +**Mark an issue as stale if ALL of these conditions are met:** +- A bug report has had no activity (excluding label changes) for over a year OR any issue has had one or more of these labels for at least 14 days consecutively: `info required`, `question`, `answered`, `not planned`, `duplicate`, `invalid`, `fixed` +- Issue does NOT have the `on hold`, `help wanted`, or `triage needed` label +- Issue was not created by a collaborator or a bot +- Issue has no assignee + +**Mark a pull request as stale if ALL of these conditions are met:** +- PR has had no activity (excluding label changes) for at least 90 days consecutively +- PR does NOT have the `on hold`, `breaking change`, or `triage needed` label +- PR was not created by a collaborator or a bot +- PR has no assignee + +## STALE ISSUE LABEL + +**When marking an issue as stale:** +1. Add the `stale` label +2. Post this comment: +``` +This issue has been marked as stale. +If you have any updates or additional information, please comment below. + +If no response is received, it will be automatically closed. +``` + +**When marking a PR as stale:** +1. Add the `stale` label +2. Post this comment: +``` +Hi, this pull request hasn't had activity in a while and has been marked as stale. + +Please reply if you're still working on it! +``` + +## CLOSING STALE ISSUES + +**Close a stale issue if:** +- It has the `stale` label +- It has been stale for at least 14 additional days (28 consecutive days total since last activity) +- Post this closing comment: +``` +If you're still experiencing this problem, please open a new issue with updated details. +``` + +**Close a stale PR if:** +- It has the `stale` label +- It has been stale for at least 90 additional days (180 consecutive days total since last activity) +- Post this closing comment: +``` +This pull request has been closed due to inactivity. + +If you would like to continue working on it, please open a new PR referencing this one. +``` + +## EDITING GUIDELINES + +You may suggest a `newTitle` in the JSON if the current title is very unclear or unrelated. In the timeline these events are called "renamed". +If you do not need to change the title, set the `newTitle` field to null in your JSON response. + +- Don't edit issues by collaborators +- Don't edit issues by bots (e.g. renovate-bot) +- Don't edit issues that were updated in the last week +- Titles should be sentence-cased +- Prioritize accuracy, clarity, and searchability +- Use the full name of the component (e.g. MudDataGrid) + +### Pull Request Title Format + +If a pull request relates to a single, specific component, its title should be prefixed with the full name of that component and a colon. + +**Examples:** +- "MudDialog: Fix gaps in body style" +- "MudColorPicker: Improve ARIA labels & doc page wording" + +### Issue Title Format + +It's optional for issue titles to use the colon format (e.g., "MudButton: ..."), but you prefer not to use it when creating a new title. +Issue titles shouldn't include version numbers or labels that are already present in the body. + +**Examples:** +- "Make overlay popovers non-modal by default" +- "Add tooltips to special icon buttons that aren't immediately identifiable" +- "Add hotkey (Ctrl+K) to open search in docs" +- "Ripple effect should provide immediate visual feedback" + +## INTERVENTION GUIDELINES + +The **severity score** indicates how critical it is for a maintainer to assist this issue. This score, on a scale of 1 to 10, is a dynamic value based on the issue's impact, clarity, and age. A previously assigned score can and should be amended if new information arises, such as a surge in community interest, the discovery of a workaround, or simply due to the issue's age. + +- **Age and Inactivity:** Primary factor. Issue urgency decreases sharply over time to keep focus on active problems. A recent, critical bug might start at a 9, but after several months of inactivity, its score should be reduced to low-to-moderate range (2-4). Issues older than six months should generally not be scored higher than 3, unless they are a security risk or see a major resurgence in community interest. +- **Security Vulnerabilities:** Issues representing direct, exploitable security risk are highest priority (**9-10**). +- **Regressions:** Bugs where a previously working feature is now broken. Score depends on impact, ranging from minor regressions (**4-5**) to critical ones in core components that block users (**9-10**). +- **Widespread Impact & Blocking Issues:** Bugs affecting a broad user base or preventing essential development tasks without a simple workaround. These typically score in the high range (**7-9**). +- **Workaround Availability:** If a functional and reasonable workaround is documented in the issue, urgency is significantly lower. This should reduce the score by 1-2 points. +- **Report Quality:** Score should reflect effort required for triage. A clear, well-documented report with minimal reproduction deserves higher score (+1 point) than a vague one that lacks necessary details (-1 point). +- **Community Engagement:** High community interest (many up-votes, confirmations from different users) indicates broader impact and can increase an issue's priority, justifying higher score. + +**Severity score scale:** + +- **0 (None):** Assigned to non-bug issues like `enhancement`, `docs`, or `question`, unless they are related to a bug. Their priority is based on project roadmap, not urgency. +- **1-3 (Low):** Minor bugs with low impact, or older issues whose urgency has decayed. *Examples: Minor cosmetic glitch, bug with simple workaround, inactive bug report that is several months old.* +- **4-6 (Moderate):** General bug reports requiring investigation but not immediately blocking. *Examples: Component behaving unexpectedly but workaround exists, significant bug that is 1-3 months old.* +- **7-8 (High):** Important bugs impacting many users or core components without clear workaround. These should be addressed in reasonable timeframe. +- **9-10 (Critical):** Highest urgency. Reserved for critical security vulnerabilities, data loss bugs, or severe regressions in core functionality that block a large number of users. diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 3da4db93f079..de8d6472832a 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - - name: Check out repository + - name: Checkout repository uses: actions/checkout@v4 - name: Set up Node.js @@ -18,11 +18,11 @@ jobs: - name: Install dependencies run: npm install node-fetch@2 @actions/core @octokit/rest - - name: Run AI triage script + - name: Triage with AI Assistant env: GITHUB_ISSUE_NUMBER: ${{ github.event.issue.number }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AUTOTRIAGE_ENABLED: ${{ vars.AUTOTRIAGE_ENABLED }} + AUTOTRIAGE_PERMISSIONS: ${{ vars.AUTOTRIAGE_PERMISSIONS }} run: node ./.github/scripts/AutoTriage.js \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c9cc71e7eb81..ace901158162 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -18,11 +18,11 @@ jobs: - name: Install dependencies run: npm install node-fetch@2 @actions/core @octokit/rest - - name: Run AI triage script + - name: Triage with AI Assistant env: GITHUB_ISSUE_NUMBER: ${{ github.event.pull_request.number }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AUTOTRIAGE_ENABLED: ${{ vars.AUTOTRIAGE_ENABLED }} + AUTOTRIAGE_PERMISSIONS: ${{ vars.AUTOTRIAGE_PERMISSIONS }} run: node ./.github/scripts/AutoTriage.js \ No newline at end of file diff --git a/.github/workflows/triage-backlog.yml b/.github/workflows/triage-backlog.yml index 2f470af81e1b..e697826f4d6a 100644 --- a/.github/workflows/triage-backlog.yml +++ b/.github/workflows/triage-backlog.yml @@ -2,128 +2,118 @@ name: Triage Backlog on: schedule: - - cron: '30 06 * * *' # Gemini rates reset at 7am, so we can use up the remaining quota for the day. + - cron: '0 5 * * *' # Gemini rates reset at 7. workflow_dispatch: inputs: backlog-size: description: 'Number of issues to auto-discover' required: false - default: '3' + default: '5' type: string issue-numbers: - description: 'Or use issue numbers (comma-separated)' + description: 'Or use issue numbers (space-separated)' required: false type: string - model: - description: 'Gemini model' + permissions: + description: 'Permissions (`none` for dry run)' required: false - default: 'gemini-2.5-flash' + default: 'label, comment, close, edit' type: string - dry-run: - description: 'Dry-run (no actual changes)' - required: false - default: true - type: boolean -env: - DRY_RUN: ${{ inputs.dry-run || vars.AUTOTRIAGE_ENABLED != 'true' }} +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true jobs: - stale-issues: + auto-triage: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.repository == 'MudBlazor/MudBlazor' - + # Safety: Scheduled runs require the AUTOTRIAGE_PERMISSIONS repo variable to be set. + if: github.event_name == 'workflow_dispatch' || vars.AUTOTRIAGE_PERMISSIONS + steps: - - name: Mark stale issues - uses: actions/stale@v9 - with: - stale-issue-label: 'stale' - any-of-issue-labels: 'info required' - days-before-issue-stale: 14 - days-before-issue-close: 14 - stale-issue-message: | - This issue has been marked as stale. - If you have any updates or additional information, please comment below. + - name: Checkout repository + uses: actions/checkout@v4 - If no response is received, it will be automatically closed. + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' - stale-pr-label: 'stale' - exempt-pr-labels: 'on hold,breaking change' - days-before-pr-stale: 180 - days-before-pr-close: 90 - stale-pr-message: | - Hi, this pull request hasn't had any recent activity and has been marked as stale. + - name: Install dependencies + run: npm install node-fetch@2 @actions/core @octokit/rest - Please let us know if you're still working on it or if we can help move it forwards! @MudBlazor/triage - close-pr-message: | - This pull request has been closed due to inactivity. + # Persist the processed issues database (triage-db.json) between runs. + - name: Cache Triage Database + uses: actions/cache@v4 + id: cache-db + with: + path: triage-db.json + # Unique key forces a new cache save on each successful run. + key: ${{ runner.os }}-triage-database-${{ github.run_id }} + # Restore key prefix finds the latest available cache. + restore-keys: | + ${{ runner.os }}-triage-database- - If you would like to continue working on it, please open a new PR referencing this one. + - name: Triage Issues + env: + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Only define AUTOTRIAGE_DB_PATH if not analyzing specified issue numbers from input + AUTOTRIAGE_DB_PATH: ${{ !inputs.issue-numbers && format('{0}/triage-db.json', github.workspace) || '' }} + # Use input for permissions, fallback to repo variable for scheduled runs. + AUTOTRIAGE_PERMISSIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.permissions || vars.AUTOTRIAGE_PERMISSIONS }} + # Use a fixed limit for scheduled runs, fallback to manual input. + MAX_ISSUES: ${{ github.event_name == 'schedule' && 100 || inputs.backlog-size }} + run: | + # Process specified issues, or fetch from the backlog. + if [ -n "${{ inputs.issue-numbers }}" ]; then + echo "Processing specified issues: ${{ inputs.issue-numbers }}" + issue_numbers="${{ inputs.issue-numbers }}" + max_count="" + else + echo "Analyzing up to $MAX_ISSUES issues" + issue_numbers=$(gh pr list --state open --limit 9999 --search 'sort:updated-desc' --json number --jq '.[].number') + issue_numbers+=" $(gh issue list --state open --limit 9999 --search 'sort:updated-desc' --json number --jq '.[].number')" + max_count="$MAX_ISSUES" + fi - exempt-all-assignees: true - debug-only: ${{ env.DRY_RUN }} + echo "Searching through $(echo \"$issue_numbers\" | wc -w) total issues from GitHub..." - auto-triage: - runs-on: ubuntu-latest - needs: stale-issues - if: github.event_name == 'workflow_dispatch' || github.repository == 'MudBlazor/MudBlazor' - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Install dependencies - run: npm install node-fetch@2 @actions/core @octokit/rest - - - name: Get issues to process - id: get-issues - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - if [ -n "${{ inputs.issue-numbers }}" ]; then - # Process specific issues - echo "Processing specific issues: ${{ inputs.issue-numbers }}" - echo "${{ inputs.issue-numbers }}" | tr ', ' '\n' | grep -v '^$' | jq -R . | jq -s . > issues.json - else - # Find unlabeled issues for automated processing - backlog_size="${{ inputs.backlog-size || '15' }}" - echo "Finding $backlog_size unlabeled issues" - gh issue list \ - --state open \ - --limit 99999 \ - --json number,labels \ - --jq '.[] | select((.labels // [] | length == 0)) | .number' \ - | head -n "$backlog_size" \ - | jq -R . | jq -s . > issues.json - fi - - issue_count=$(cat issues.json | jq length) - echo "Found $issue_count issues to process" - echo "issue-count=$issue_count" >> $GITHUB_OUTPUT - - if [ "$issue_count" -eq 0 ]; then - echo "No issues found to process" - exit 0 - fi - - - name: Process issues - if: steps.get-issues.outputs.issue-count > 0 - env: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - AUTOTRIAGE_ENABLED: ${{ env.DRY_RUN == 'false' }} - run: | - cat issues.json | jq -r '.[]' | while read -r issue_number; do - if [ -n "$issue_number" ]; then - echo "——————————————————————————————————Issue #$issue_number——————————————————————————————————" + count=0 + for issue_number in $issue_numbers; do + if [ -n "$max_count" ] && [ "$count" -ge "$max_count" ]; then + break + fi + export GITHUB_ISSUE_NUMBER="$issue_number" - node .github/scripts/AutoTriage.js - # Rate limiting delay - sleep 15 - fi - done + + # Handle script exit codes: 0=Success, 1=Fatal Error, 2=Skip Issue, 3=Cancel All + if node .github/scripts/AutoTriage.js; then + count=$((count + 1)) + if [ -n "$max_count" ]; then + left=$((max_count - count)) + echo "⏭️ $(date '+%Y-%m-%d %H:%M:%S'): $left left" + fi + sleep 10 # Rate limit + else + exit_code=$? + if [ "$exit_code" -eq 1 ]; then + exit 1 + elif [ "$exit_code" -eq 2 ]; then + # No-op for skippable issues. + : + elif [ "$exit_code" -eq 3 ]; then + exit 0 + else + exit "$exit_code" + fi + fi + done + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: triage-artifacts + path: | + triage-db.json + artifacts/ diff --git a/.github/workflows/triage-info-required.yml b/.github/workflows/triage-info-required.yml deleted file mode 100644 index 67f81ef9b5aa..000000000000 --- a/.github/workflows/triage-info-required.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Triage 'info required' - -on: - issue_comment: - types: [created] - -jobs: - remove_label_on_author_comment: - runs-on: ubuntu-latest - - # This 'if' condition ensures the job only runs when all criteria are met: - # 1. The comment author is the same as the issue author. - # 2. The issue currently has the label "info required". - if: | - github.event.comment.user.login == github.event.issue.user.login && - contains(github.event.issue.labels.*.name, 'info required') - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Remove "info required" label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - echo "Removing 'info required' label from issue #$ISSUE_NUMBER" - gh issue edit "$ISSUE_NUMBER" --remove-label "info required" --remove-label "stale" diff --git a/src/.editorconfig b/src/.editorconfig index e5bfa858d422..64877a3e57be 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -46,32 +46,42 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true -# Dotnet code style settings: -[*.{cs,vb}] +# C# files +[*.cs] -# IDE0055: Fix formatting -dotnet_diagnostic.IDE0055.severity = warning +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true -# Sort using and Import directives with System.* appearing first -dotnet_sort_system_directives_first = true -dotnet_separate_import_directive_groups = false -# Avoid "this." and "Me." if not necessary -dotnet_style_qualification_for_field = false:refactoring -dotnet_style_qualification_for_property = false:refactoring -dotnet_style_qualification_for_method = false:refactoring -dotnet_style_qualification_for_event = false:refactoring - -# Use language keywords instead of framework type names for type references +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = flush_left +csharp_indent_switch_labels = true + +# Modifier preferences +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion + +# avoid this. unless absolutely necessary +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Types: use keywords instead of BCL types, and permit var only when the type is clear +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion dotnet_style_predefined_type_for_member_access = true:suggestion -# Suggest more modern language features when available -dotnet_style_object_initializer = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion - # Non-private static fields are PascalCase dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields @@ -155,10 +165,6 @@ dotnet_naming_style.pascal_case_style.capitalization = pascal_case # error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}' dotnet_diagnostic.RS2008.severity = none -# IDE0073: File header -dotnet_diagnostic.IDE0073.severity = warning -file_header_template = Copyright (c) MudBlazor 2021\nMudBlazor licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for more information. - # IDE0035: Remove unreachable code dotnet_diagnostic.IDE0035.severity = warning @@ -168,53 +174,64 @@ dotnet_diagnostic.IDE0036.severity = warning # IDE0043: Format string contains invalid placeholder dotnet_diagnostic.IDE0043.severity = warning -# IDE0044: Make field readonly -dotnet_diagnostic.IDE0044.severity = warning - # RS0016: Only enable if API files are present dotnet_public_api_analyzer.require_api_files = true -# CSharp code style settings: -[*.cs] -# Newline settings -csharp_new_line_before_open_brace = all -csharp_new_line_before_else = true -csharp_new_line_before_catch = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_between_query_expression_clauses = true - -# Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true -csharp_indent_switch_labels = true -csharp_indent_labels = flush_left - # Prefer "var" everywhere csharp_style_var_for_built_in_types = true:suggestion csharp_style_var_when_type_is_apparent = true:suggestion csharp_style_var_elsewhere = true:suggestion -# Prefer method-like constructs to have a block body +# Code style defaults +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true +csharp_prefer_braces = true:silent +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true +csharp_prefer_static_local_function = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +dotnet_style_readonly_field = true:suggestion + +# Expression-level preferences +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_collection_expression = when_types_exactly_match +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +csharp_prefer_simple_default_expression = true:suggestion + +# Expression-bodied members csharp_style_expression_bodied_methods = false:none csharp_style_expression_bodied_constructors = false:none csharp_style_expression_bodied_operators = false:none - -# Prefer property-like constructs to have an expression-body csharp_style_expression_bodied_properties = true:none csharp_style_expression_bodied_indexers = true:none csharp_style_expression_bodied_accessors = true:none +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = true:silent -# Suggest more modern language features when available +# Pattern matching csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion csharp_style_pattern_matching_over_as_with_null_check = true:suggestion csharp_style_inlined_variable_declaration = true:suggestion + +# Null checking preferences csharp_style_throw_expression = true:suggestion csharp_style_conditional_delegate_call = true:suggestion +# Other features +csharp_style_prefer_index_operator = true:none +csharp_style_prefer_range_operator = true:none +csharp_style_pattern_local_over_anonymous_function = false:none + # Space preferences csharp_space_after_cast = false csharp_space_after_colon_in_inheritance_clause = true @@ -239,39 +256,72 @@ csharp_space_between_method_declaration_parameter_list_parentheses = false csharp_space_between_parentheses = false csharp_space_between_square_brackets = false -# Blocks are allowed -csharp_prefer_braces = true:silent -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = true - -# MudBlazor Team Overrides -# Settings in editorconfig are last in wins so add overrides here +############################################################################ +# # +# MudBlazor Team Overrides # +# # +# Settings in editorconfig are last in wins so add overrides here # +# # +############################################################################ [*.{cs,razor}] -# CS1591 Missing XML comment for publicly visible type or member -# We should try and finish the documentation and remove this -# For now its too noisy +file_header_template = Copyright (c) MudBlazor 2021\nMudBlazor licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for more information. +csharp_style_prefer_primary_constructors = true:none # IDE0290 +csharp_remove_unnecessary_imports = true:warning # IDE0005 +dotnet_style_remove_unnecessary_value_assignment = true:warning # IDE0059 +dotnet_code_style_unused_parameters = all:warning # IDE0060 +dotnet_style_prefer_compound_assignment = true:suggestion # IDE0054 +dotnet_style_prefer_simplified_conditional_expression = true:suggestion # IDE0075 +dotnet_style_remove_unnecessary_cast = true:error # IDE0090 + +# Rules that must use diagnostic syntax +# CS1591: Missing XML comment for publicly visible type or member dotnet_diagnostic.CS1591.severity = none -# IDE0071 Require file header +# IDE0073: Require file header dotnet_diagnostic.IDE0073.severity = none # IDE0055: Fix formatting dotnet_diagnostic.IDE0055.severity = suggestion -# IDE0044: Make field readonly -dotnet_diagnostic.IDE0044.severity = none -# IDE0034: 'default' expression can be simplified -dotnet_diagnostic.IDE0034.severity = none -# IDE0056 Indexing can be simplified -dotnet_diagnostic.IDE0056.severity = none -# IDE0057 Substring can be simplified -dotnet_diagnostic.IDE0057.severity = none -# BL0007 Component Parameters must be auto props +# BL0007: Component parameters should be auto-properties dotnet_diagnostic.BL0007.severity = suggestion -# IDE0290 Use primary constructor -dotnet_diagnostic.IDE0290.severity = none +# CS4014: Because this call is not awaited, execution of the current method continues before the call is completed +dotnet_diagnostic.CS4014.severity = error +# IDE0052: Remove unread private members +dotnet_diagnostic.IDE0052.severity = suggestion + +# Rules that could be warnings but were added too late to the project + +# CA1816: Dispose methods should call SuppressFinalize +dotnet_diagnostic.CA1816.severity = suggestion +# CA1822: Mark members as static +dotnet_diagnostic.CA1822.severity = suggestion +# CA1860: Use 'Length' or 'Count' property instead of 'Any()' +dotnet_diagnostic.CA1860.severity = suggestion +# CA1827: Do not use Count() or LongCount() when Any() can be used +dotnet_diagnostic.CA1827.severity = suggestion +# CA1825: Avoid zero-length array allocations +dotnet_diagnostic.CA1825.severity = suggestion +# CA1859: Use concrete types when possible for improved performance +dotnet_diagnostic.CA1859.severity = suggestion +# CA1830: Prefer strongly-typed Appends and Inserts on StringBuilder +dotnet_diagnostic.CA1830.severity = suggestion +# CA1866: Use char overload +dotnet_diagnostic.CA1866.severity = suggestion +# CA1862: Use 'StringComparison' method overloads for string comparisons +dotnet_diagnostic.CA1862.severity = suggestion +# IDE0051: Remove unused private members +dotnet_diagnostic.IDE0051.severity = suggestion +# CA2254: Template should be a static expression +dotnet_diagnostic.CA2254.severity = suggestion +# CA2012: Use ValueTasks correctly +dotnet_diagnostic.CA2012.severity = suggestion +# CA2211: Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = suggestion +# CA1806: Do not ignore method results +dotnet_diagnostic.CA1806.severity = suggestion [MudBlazor/**/*.{cs,razor}] dotnet_style_namespace_match_folder = false resharper_check_namespace_highlighting = none [*] -end_of_line = lf \ No newline at end of file +end_of_line = lf diff --git a/src/MudBlazor.Docs/Pages/Components/Field/Examples/FieldLabelPlaceholderExample.razor b/src/MudBlazor.Docs/Pages/Components/Field/Examples/FieldLabelPlaceholderExample.razor index 2f5b97e17e0d..ca9b9cdc8ad9 100644 --- a/src/MudBlazor.Docs/Pages/Components/Field/Examples/FieldLabelPlaceholderExample.razor +++ b/src/MudBlazor.Docs/Pages/Components/Field/Examples/FieldLabelPlaceholderExample.razor @@ -1,22 +1,68 @@ -@namespace MudBlazor.Docs.Examples +@using MudBlazor.Resources +@using MudBlazor.Utilities +@inject InternalMudLocalizer Localizer +@namespace MudBlazor.Docs.Examples + + - - - -
- Switch between Label and Placeholder + + + + + ChildContent + + + Placeholder + + - + - @color + @color + + + + + + + + + ShrinkLabel + + + Placeholder + + +
@code { RenderFragment content = null; RenderFragment rf1 = @I Am Field; - string color="#6cf014"; + string color = "#6cf014"; + private bool _shrinkLabel = false; + + protected string InputClassname => + new CssBuilder("mud-input-slot") + .AddClass("mud-input-root") + .AddClass($"mud-input-root-{Variant.Outlined.ToDescriptionString()}") + .AddClass($"mud-input-root-adorned-{Adornment.End.ToDescriptionString()}") + .AddClass($"mud-input-root-margin-{Margin.Normal.ToDescriptionString()}") + .Build(); } diff --git a/src/MudBlazor.Docs/Pages/Components/Field/FieldPage.razor b/src/MudBlazor.Docs/Pages/Components/Field/FieldPage.razor index c9399c7b15d0..d76928e872bb 100644 --- a/src/MudBlazor.Docs/Pages/Components/Field/FieldPage.razor +++ b/src/MudBlazor.Docs/Pages/Components/Field/FieldPage.razor @@ -7,8 +7,8 @@ - MudField provides the foundation for creating custom input controls with consistent styling and behavior. - It offers the same visual variants as text fields and + MudField provides the foundation for creating custom input controls with consistent styling and behavior. + It offers the same visual variants as text fields and numeric fields while allowing you to embed any custom content. Fields support labels, helper text, validation states, and adornments just like other input components. @@ -44,17 +44,30 @@ - Build custom input controls by leveraging MudField's flexible content system. When ChildContent is null, - the label automatically becomes a placeholder, enabling dynamic behavior based on content state. - This example demonstrates creating a custom color picker input using native HTML controls within a MudField wrapper. + + Build custom input controls by leveraging MudField's flexible content system. + When ChildContent is null, the label automatically becomes a placeholder, + enabling dynamic behavior based on content or adornment. + + + Use the ShrinkLabel parameter to override this behavior: + when set to true, the label remains inside the field regardless of state, + which is useful when you want to suppress automatic shrinking without causing the content to re-render + or you want to maintain a custom portion of your ChildContent such as an extra ending adornment. + + + Anytime the MudField has focus within the label will shrink and + not act as a placeholder. + - Related Components: For ready-made input controls, see TextField and - NumericField which are built on top of MudField. + Related Components: For ready-made input controls, see TextField and + NumericField which are built nearly identical to MudField, + except they have a built-in input. diff --git a/src/MudBlazor.Docs/wwwroot/CommunityExtensions.json b/src/MudBlazor.Docs/wwwroot/CommunityExtensions.json index fa95b888a41b..4b0aad2a63ae 100644 --- a/src/MudBlazor.Docs/wwwroot/CommunityExtensions.json +++ b/src/MudBlazor.Docs/wwwroot/CommunityExtensions.json @@ -10,11 +10,11 @@ }, { "Category": "Style", - "Name": "Theme Creator", - "Description": "Blazor Theme Creator site for MudBlazor library. This app is designed to be used to create and manage themes for MudBlazor, not as part of your application.", - "Link": "https://themes.arctechonline.tech", - "GitHubUserPath": "versile2", - "GitHubRepoPath": "ThemeCreatorMudBlazor" + "Name": "MudX Theme Creator", + "Description": "Blazor Theme Creator site for MudBlazor library. This app is designed to be used to create and export themes for MudBlazor, not as part of your application.", + "Link": "https://themes.mudx.org", + "GitHubUserPath": "MudXtra", + "GitHubRepoPath": "MudXThemeCreator" }, { "Category": "Components", @@ -87,5 +87,13 @@ "Link": "https://phmatray.github.io/FormCraft", "GitHubUserPath": "phmatray", "GitHubRepoPath": "FormCraft" + }, + { + "Category": "Components", + "Name": "MudX Extensions", + "Description": "A collection of components designed for MudBlazor. SecurityCode for seamless verification code input, Outline for scrollspy navigation, CodeBlock for Prism.js highlighting, and more.", + "Link": "https://mudx.org", + "GitHubUserPath": "MudXtra", + "GitHubRepoPath": "MudX" } ] diff --git a/src/MudBlazor.Docs/wwwroot/images/extensions/MudXtra.MudX.webp b/src/MudBlazor.Docs/wwwroot/images/extensions/MudXtra.MudX.webp new file mode 100644 index 000000000000..8438bb661d42 Binary files /dev/null and b/src/MudBlazor.Docs/wwwroot/images/extensions/MudXtra.MudX.webp differ diff --git a/src/MudBlazor.Docs/wwwroot/images/extensions/versile2.ThemeCreatorMudBlazor.webp b/src/MudBlazor.Docs/wwwroot/images/extensions/MudXtra.MudXThemeCreator.webp similarity index 100% rename from src/MudBlazor.Docs/wwwroot/images/extensions/versile2.ThemeCreatorMudBlazor.webp rename to src/MudBlazor.Docs/wwwroot/images/extensions/MudXtra.MudXThemeCreator.webp diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor index 1e4146ce1b9b..6ea856a045e9 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyColumnTest.razor @@ -18,6 +18,7 @@ @code { + public static string __description__ = "A general test for all things Row Detail / Hierarchy Column"; [Parameter] public bool RightToLeft { get; set; } diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedItemsTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedItemsTest.razor new file mode 100644 index 000000000000..81c3c051346d --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedItemsTest.razor @@ -0,0 +1,71 @@ +@using System.Collections.ObjectModel + + + + + + + + + + + @($"uid = {context.Item.Name}|{context.Item.Age}|{context.Item.Status}|{Guid.NewGuid()}") + + + + +Expand All +Collapse All + + +@code { + public static string __description__ = "A test for Initially Expanded when Items is an observable collection and added after the fact."; + [Parameter] + public bool RightToLeft { get; set; } + + [Parameter] + public bool EnableHeaderToggle { get; set; } + + [Parameter] + public bool ExpandSingleRow { get; set; } + + private MudDataGrid _dataGrid = null!; + + private readonly ObservableCollection _items = []; + + private readonly IReadOnlyList _itemList = + new List + { + new Model("Sam", 56, Severity.Normal), + new Model("Alicia", 54, Severity.Info), + new Model("Ira", 27, Severity.Success), + new Model("John", 32, Severity.Warning), + new Model("Anders", 24, Severity.Error) + }; + + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender); + if (firstRender) + { + foreach (var model in _itemList) + { + _items.Add(model); + } + + StateHasChanged(); + } + } + + private Task ExpandAll() + { + return _dataGrid.ExpandAllHierarchy(); + } + + private Task CollapseAll() + { + return _dataGrid.CollapseAllHierarchy(); + } + + public record Model(string Name, int Age, Severity Status); +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedServerDataTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedServerDataTest.razor new file mode 100644 index 000000000000..18f8ec2d2544 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/DataGrid/DataGridHierarchyInitiallyExpandedServerDataTest.razor @@ -0,0 +1,113 @@ +@using System.Collections.ObjectModel + + + + + + + + + + + @($"uid = {context.Item.Name}|{context.Item.Age}|{context.Item.Status}|{Guid.NewGuid()}") + + + + + + + +Expand All +Collapse All + + +@code { + public static string __description__ = "A test for Initially Expanded when Items are from ServerData."; + [Parameter] + public bool RightToLeft { get; set; } + + [Parameter] + public bool EnableHeaderToggle { get; set; } + + [Parameter] + public bool ExpandSingleRow { get; set; } + + private MudDataGrid _dataGrid = null!; + private int _rowsPerPage = 5; + + private List _itemList = + new List + { + new Model("Sam", 56, Severity.Normal), + new Model("Alicia", 54, Severity.Info), + new Model("Ira", 27, Severity.Success), + new Model("John", 32, Severity.Warning), + new Model("Anders", 24, Severity.Error), + new Model("Anu6is", 56, Severity.Normal), + new Model("Henon", 54, Severity.Info), + new Model("ScarletKuro", 27, Severity.Success), + new Model("Garderoben", 32, Severity.Warning), + new Model("Versile2", 24, Severity.Error) + }; + + private async Task> ServerReload(GridState state) + { + await Task.Delay(200); + + IEnumerable data = _itemList; + + IOrderedEnumerable? orderedData = null; + + foreach (var sort in state.SortDefinitions) + { + Func? keySelector = sort.SortBy switch + { + nameof(Model.Name) => x => x.Name, + nameof(Model.Age) => x => x.Age, + nameof(Model.Status) => x => x.Status, + _ => null + }; + + if (keySelector == null) + continue; + + if (orderedData == null) + { + orderedData = sort.Descending + ? data.OrderByDescending(keySelector) + : data.OrderBy(keySelector); + } + else + { + orderedData = sort.Descending + ? orderedData.ThenByDescending(keySelector) + : orderedData.ThenBy(keySelector); + } + } + + var finalData = (orderedData ?? data); + + var pagedData = finalData + .Skip(state.Page * state.PageSize) + .Take(state.PageSize) + .ToList(); + + return new GridData + { + TotalItems = _itemList.Count, + Items = pagedData + }; + } + + private Task ExpandAll() + { + return _dataGrid.ExpandAllHierarchy(); + } + + private Task CollapseAll() + { + return _dataGrid.CollapseAllHierarchy(); + } + + public record Model(string Name, int Age, Severity Status); +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldStartAdornmentTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldStartAdornmentTest.razor new file mode 100644 index 000000000000..54120863911f --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldStartAdornmentTest.razor @@ -0,0 +1,16 @@ + + + + + Some Content Here + + + + Some Content Here + + + +@code { + public static string __description__ = "Issue 7533, testing that the Start Adornment is not going two lines with the label"; + +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldTest.razor index b42ee2eccb5c..b607e43f611a 100644 --- a/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldTest.razor +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Field/FieldTest.razor @@ -5,7 +5,7 @@ - + @@ -17,4 +17,4 @@ @code { public static string __description__ = "Issue 2150, testing that the Adornment is not overlapping the label"; -} \ No newline at end of file +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Highlighter/BasicHighlighterTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Highlighter/BasicHighlighterTest.razor new file mode 100644 index 000000000000..291b76b4dd72 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Highlighter/BasicHighlighterTest.razor @@ -0,0 +1,30 @@ + + + + +@code { + public static string __description__ = "Various text and search options"; + + [Parameter] + public string Text { get; set; } = "This is a test text for highlighting."; + + [Parameter] + public string HighlightedText { get; set; } = "test"; + + [Parameter] + public bool UntilNextBoundary { get; set; } = false; + + [Parameter] + public bool CaseSensitive { get; set; } = false; + + [Parameter] + public bool Markup { get; set; } = true; + + [Parameter] + public string? CustomClass { get; set; } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverListMaxHeightTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverListMaxHeightTest.razor new file mode 100644 index 000000000000..9439b06885f7 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Popover/PopoverListMaxHeightTest.razor @@ -0,0 +1,38 @@ + +MudBlazor/Issues/11730 + + + + + Demo + + + + One + + + Two + + + Three + + + Four + + + Five + + + Six + + + Seven + + + + + +@code { + public static string __description__ = "Test Popover Lists Max Height Functionality"; +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableCurrentPageParameterIntialized.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableCurrentPageParameterIntialized.razor new file mode 100644 index 000000000000..0b4b1098d726 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableCurrentPageParameterIntialized.razor @@ -0,0 +1,17 @@ + + + Item + + + @context + + + + + + + +@code { + private int _currentPage = 2; + private int[] _items = Enumerable.Range(0, 100).ToArray(); +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingNestedTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingNestedTest.razor new file mode 100644 index 000000000000..c518234e7df3 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingNestedTest.razor @@ -0,0 +1,48 @@ + + + @context.Key + + + @context.Label + + + +@code { + public MudTable? TableInstance { get; private set; } + + public List? Items { get; set; } = [ + new Item { Group = "G1", Nested = "N1", Label = "A" }, + new Item { Group = "G1", Nested = "N1", Label = "B" }, + new Item { Group = "G1", Nested = "N2", Label = "C" }, + new Item { Group = "G2", Nested = "N1", Label = "D" }, + new Item { Group = "G2", Nested = "N1", Label = "E" }, + new Item { Group = "G2", Nested = "N2", Label = "F" }, + new Item { Group = "G3", Nested = "N1", Label = "G" }, + new Item { Group = "G3", Nested = "N1", Label = "H" }, + new Item { Group = "G3", Nested = "N2", Label = "I" } + ]; + + private TableGroupDefinition _groupDefinition = new() { + Indentation = false, + Expandable = true, + IsInitiallyExpanded = false, + Selector = e => e.Group, + InnerGroup = new TableGroupDefinition { + Indentation = false, + Expandable = true, + IsInitiallyExpanded = false, + Selector = e => e.Group + " > " + e.Nested, + } + }; + + public class Item + { + public required string Label { get; set; } + + public required string Group { get; set; } + + public required string Nested { get; set; } + + public override string ToString() => $"({Group} > {Nested}) {Label}"; + } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingTest3.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingTest3.razor new file mode 100644 index 000000000000..335e621201be --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Table/TableGroupingTest3.razor @@ -0,0 +1,40 @@ + + + @context.Key + + + @context.Label + + + +@code { + public MudTable? TableInstance { get; private set; } + + public List? Items { get; set; } = [ + new Item { Group = "One", Label = "A" }, + new Item { Group = "One", Label = "B" }, + new Item { Group = "One", Label = "C" }, + new Item { Group = "Two", Label = "D" }, + new Item { Group = "Two", Label = "E" }, + new Item { Group = "Two", Label = "F" }, + new Item { Group = "Three", Label = "G" }, + new Item { Group = "Three", Label = "H" }, + new Item { Group = "Three", Label = "I" } + ]; + + private TableGroupDefinition _groupDefinition = new() { + Indentation = false, + Expandable = true, + IsInitiallyExpanded = false, + Selector = e => e.Group + }; + + public class Item + { + public required string Label { get; set; } + + public required string Group { get; set; } + + public override string ToString() => $"({Group}) {Label}"; + } +} diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/TabsKeyboardAccessibilityTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/TabsKeyboardAccessibilityTest.razor new file mode 100644 index 000000000000..492d9c575e68 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/TabsKeyboardAccessibilityTest.razor @@ -0,0 +1,11 @@ + + + Content One + + + Content Two + + + Content Three + + diff --git a/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/VerticalTabsKeyboardAccessibilityTest.razor b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/VerticalTabsKeyboardAccessibilityTest.razor new file mode 100644 index 000000000000..643a28dec5c4 --- /dev/null +++ b/src/MudBlazor.UnitTests.Viewer/TestComponents/Tabs/VerticalTabsKeyboardAccessibilityTest.razor @@ -0,0 +1,11 @@ + + + Content One + + + Content Two + + + Content Three + + diff --git a/src/MudBlazor.UnitTests/Components/DataGridTests.cs b/src/MudBlazor.UnitTests/Components/DataGridTests.cs index e9be98ec3c71..dcb46e9e1d39 100644 --- a/src/MudBlazor.UnitTests/Components/DataGridTests.cs +++ b/src/MudBlazor.UnitTests/Components/DataGridTests.cs @@ -2784,111 +2784,52 @@ public async Task DataGridCloseFiltersTest() var dataGrid = comp.FindComponent>(); IElement FilterButton() => dataGrid.FindAll(".filter-button")[0]; - // click on the filter button - FilterButton().Click(); - - // check the number of filters displayed in the filters panel is 1 - comp.FindAll(".filters-panel .mud-grid-item.d-flex").Count.Should().Be(1); - - // Wait for the filter panel to render properly - comp.WaitForState(() => comp.FindAll(".filter-operator").Count > 0, timeout: TimeSpan.FromSeconds(5)); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - //set operator to CONTAINS - comp.FindAll(".mud-list .mud-list-item")[0].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); - - //set operator to NOT CONTAINS - FilterButton().Click(); - - // Wait for the filter panel to render properly - comp.WaitForState(() => comp.FindAll(".filter-operator").Count > 0, timeout: TimeSpan.FromSeconds(5)); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[1].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); - - //set operator to EQUALS - FilterButton().Click(); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[2].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); - - //set operator to NOT EQUALS - FilterButton().Click(); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[3].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); - - //set operator to STARTS WITH - FilterButton().Click(); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[4].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); - - //set operator to ENDS WITH - FilterButton().Click(); - - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[5].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); - - //should be removed since no value is provided - dataGrid.Instance.FilterDefinitions.Count.Should().Be(0); + // Helper method to select a filter operator and verify the outcome + async Task SelectFilterOperator(int operatorIndex, int expectedFilterCount) + { + // Ensure the filter panel is open before interacting + if (comp.FindAll(".filters-panel .mud-grid-item.d-flex").Count == 0) + { + FilterButton().Click(); + comp.WaitForElement(".filter-operator"); + } - //set operator to IS EMPTY - FilterButton().Click(); + // Open the operator dropdown and select an item + await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); + var listItems = comp.WaitForElements(".mud-list .mud-list-item"); + listItems[operatorIndex].Click(); - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); + // Click the overlay to close the dropdown and commit the selection + comp.Find(".mud-overlay").Click(); - comp.FindAll(".mud-list .mud-list-item")[6].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); + // Assert that the number of active filters is correct + comp.WaitForAssertion(() => + { + dataGrid.Instance.FilterDefinitions.Count.Should().Be(expectedFilterCount); + }); - //should maintain filter, no value is required - dataGrid.Instance.FilterDefinitions.Count.Should().Be(1); + // Close the filter panel to ensure a clean state for the next test + if (comp.FindAll(".filters-panel .mud-grid-item.d-flex").Count > 0) + { + FilterButton().Click(); + } + } - //set operator to IS NOT EMPTY + // 1. Initial state: Open the filter panel and confirm it's visible FilterButton().Click(); + comp.WaitForAssertion(() => comp.FindAll(".filters-panel .mud-grid-item.d-flex").Count.Should().Be(1)); - await comp.Find(".filter-operator").MouseDownAsync(new MouseEventArgs()); - - comp.FindAll(".mud-list .mud-list-item")[7].Click(); - comp.Find(".mud-overlay").Click(); - comp.Render(); + // 2. Test operators that should be removed when their value is empty + await SelectFilterOperator(0, 0); // "contains" + await SelectFilterOperator(1, 0); // "not contains" + await SelectFilterOperator(2, 0); // "equals" + await SelectFilterOperator(3, 0); // "not equals" + await SelectFilterOperator(4, 0); // "starts with" + await SelectFilterOperator(5, 0); // "ends with" - //should maintain filter, no value is required - dataGrid.Instance.FilterDefinitions.Count.Should().Be(1); + // 3. Test operators that are valid without a value + await SelectFilterOperator(6, 1); // "is empty" + await SelectFilterOperator(7, 1); // "is not empty" } [Test] @@ -3346,7 +3287,8 @@ public async Task DataGrid_RowDetail_ExpandCollapseAllTest() await dataGrid.InvokeAsync(() => dataGrid.Instance.CollapseAllHierarchy()); dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(0)); await dataGrid.InvokeAsync(() => dataGrid.Instance.ExpandAllHierarchy()); - dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(5)); + // one is disabled and will not be expanded + dataGrid.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(4)); } [Test] @@ -3400,28 +3342,6 @@ await comp.InvokeAsync(() => }); } - [Test] - public void DataGridRowDetailInitiallyExpandedMultipleTest() - { - var comp = Context.RenderComponent(); - var dataGrid = comp.FindComponent>(); - - var item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Ira"); - - dataGrid.Instance._openHierarchies.Should().Contain(item); - - item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Anders"); - - dataGrid.Instance._openHierarchies.Should().Contain(item); - - comp.Markup.Should().Contain("uid = Ira|27|Success|"); - comp.Markup.Should().Contain("uid = Anders|24|Error|"); - - comp.Markup.Should().NotContain("uid = Sam|56|Normal|"); - comp.Markup.Should().NotContain("uid = Alicia|54|Info|"); - comp.Markup.Should().NotContain("uid = John|32|Warning|"); - } - [Test] public void DataGridChildRowContentTest() { @@ -5237,7 +5157,7 @@ public void DataGridHeaderToggleHierarchyTest() // Click again to expand all toggleButton = headerElement.QuerySelector(".mud-hierarchy-toggle-button"); toggleButton.Click(); - comp.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(5)); + comp.WaitForAssertion(() => dataGrid.Instance._openHierarchies.Count.Should().Be(4)); // one disabled } [Test] @@ -5303,8 +5223,8 @@ public async Task DataGridToggleHierarchyMethodTest() // Call ToggleHierarchy again await accessor.ToggleHierarchyAsync(); - // Now all hierarchies should be expanded - dataGrid.Instance._openHierarchies.Count.Should().Be(5); + // Now all hierarchies should be expanded (except the disabled one) + dataGrid.Instance._openHierarchies.Count.Should().Be(4); } [Test] @@ -5353,6 +5273,102 @@ public void DataGrid_HierarchyExpandSingleRowTest() dataGrid.Instance._openHierarchies.First().Should().Be(item); } + [Test] + public void DataGridRowDetailInitiallyExpandedMultipleTest() + { + // just setting Items + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + var item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Ira"); + + dataGrid.Instance._openHierarchies.Should().Contain(item); + + item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Anders"); + + dataGrid.Instance._openHierarchies.Should().Contain(item); + + comp.Markup.Should().Contain("uid = Ira|27|Success|"); + comp.Markup.Should().Contain("uid = Anders|24|Error|"); + + comp.Markup.Should().NotContain("uid = Sam|56|Normal|"); + comp.Markup.Should().NotContain("uid = Alicia|54|Info|"); + comp.Markup.Should().NotContain("uid = John|32|Warning|"); + } + + [Test] + public void DataGridRowDetailInitiallyExpandedObservableMultipleTest() + { + // updating an observable collection of items after initial load + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + var item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Ira"); + + dataGrid.Instance._openHierarchies.Should().Contain(item); + + item = dataGrid.Instance.Items.FirstOrDefault(x => x.Name == "Anders"); + + dataGrid.Instance._openHierarchies.Should().Contain(item); + + comp.Markup.Should().Contain("uid = Ira|27|Success|"); + comp.Markup.Should().Contain("uid = Anders|24|Error|"); + + comp.Markup.Should().NotContain("uid = Sam|56|Normal|"); + comp.Markup.Should().NotContain("uid = Alicia|54|Info|"); + comp.Markup.Should().NotContain("uid = John|32|Warning|"); + } + + [Test] + public async Task DataGridRowDetailInitiallyExpandedServerMultipleTest() + { + // ServerReload different pages + var comp = Context.RenderComponent(); + var dataGrid = comp.FindComponent>(); + + comp.WaitForAssertion(() => comp.Markup.Should().Contain("uid = Ira|27|Success|")); + comp.Markup.Should().Contain("uid = Anders|24|Error|"); + + comp.Markup.Should().NotContain("uid = Sam|56|Normal|"); + comp.Markup.Should().NotContain("uid = Alicia|54|Info|"); + comp.Markup.Should().NotContain("uid = John|32|Warning|"); + + // Collapse Ira to ensure it remains collapsed when we return to the row + // Use LINQ to find the index of the row containing "uid = Ira" + var iraIndex = comp.FindAll("tr") + .Select((row, index) => new { row, index }) + .First(r => r.row.InnerHtml.Contains("uid = Ira")).index; + + iraIndex.Should().BeGreaterThan(0, "Expected a row above the Ira detail row"); + + // Now access the row above and find the toggle button and click it + await comp.InvokeAsync(() => comp.FindAll("tr")[iraIndex - 2].QuerySelector("button").Click()); + + // Find button with aria-label = "Next Page" + var nextButton = comp.Find("button[aria-label='Next page']"); + nextButton.Should().NotBeNull(); + nextButton.Click(); + + comp.WaitForAssertion(() => comp.Markup.Should().Contain("uid = ScarletKuro|27|Success|")); + + comp.Markup.Should().NotContain("uid = Versile2|24|Error|"); + comp.Markup.Should().NotContain("uid = Anu6is|56|Normal|"); + comp.Markup.Should().NotContain("uid = Garderoben|32|Warning|"); + comp.Markup.Should().NotContain("uid = Henon|54|Info|"); + + // go back and make sure Ira isn't re-expanded + var prevButton = comp.Find("button[aria-label='Previous page']"); + prevButton.Should().NotBeNull(); + prevButton.Click(); + + comp.WaitForAssertion(() => comp.Markup.Should().Contain("uid = Anders|24|Error|")); + + comp.Markup.Should().NotContain("uid = Ira|27|Success|"); + comp.Markup.Should().NotContain("uid = Sam|56|Normal|"); + comp.Markup.Should().NotContain("uid = Alicia|54|Info|"); + comp.Markup.Should().NotContain("uid = John|32|Warning|"); + } + [Test] public async Task DataGridShouldAllowUnsortedAscDescOnly() { diff --git a/src/MudBlazor.UnitTests/Components/DatePickerTests.cs b/src/MudBlazor.UnitTests/Components/DatePickerTests.cs index 614d07b201e0..ee06289867a6 100644 --- a/src/MudBlazor.UnitTests/Components/DatePickerTests.cs +++ b/src/MudBlazor.UnitTests/Components/DatePickerTests.cs @@ -463,11 +463,16 @@ public void DatePickerStaticWithPickerActionsDayClick_Test() picker.Markup.Should().Contain("mud-selected"); //confirm selected date is shown - comp.SelectDate("23"); + // Calculate expected date before selection var date = DateTime.Today.Subtract(TimeSpan.FromDays(60)); + var expectedDate = new DateTime(date.Year, date.Month, 23); + + // Select the date + comp.SelectDate("23"); - picker.Instance.Date.Should().Be(new DateTime(date.Year, date.Month, 23)); + // Wait for the date picker to update its state after selection + comp.WaitForAssertion(() => picker.Instance.Date.Should().Be(expectedDate)); } [Test] diff --git a/src/MudBlazor.UnitTests/Components/FieldTests.cs b/src/MudBlazor.UnitTests/Components/FieldTests.cs new file mode 100644 index 000000000000..fbb762b18146 --- /dev/null +++ b/src/MudBlazor.UnitTests/Components/FieldTests.cs @@ -0,0 +1,74 @@ +// Copyright (c) MudBlazor 2021 +// MudBlazor licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Bunit; +using FluentAssertions; +using MudBlazor.UnitTests.TestComponents.Field; +using NUnit.Framework; + +namespace MudBlazor.UnitTests.Components +{ + [TestFixture] + public class FieldTests : BunitTest + { + [Test] + public void FieldTest_ShouldRender_Variants() + { + var comp = Context.RenderComponent(); + var fields = comp.FindAll(".mud-grid .mud-input-control.mud-field"); + fields.Should().HaveCount(3); + fields[0].ClassList.Should().Contain("mud-input-text-with-label"); + fields[0].TextContent.Trim().Should().Be("Standard"); + fields[1].ClassList.Should().Contain("mud-input-filled-with-label"); + fields[1].TextContent.Trim().Should().Be("Filled"); + fields[2].ClassList.Should().Contain("mud-input-outlined-with-label"); + fields[2].TextContent.Trim().Should().Be("OutlinedOutlined"); // Outlined includes a special fieldset label + } + + [Test] + public void FieldTest_ShouldRender_AriaAdornment() + { + var comp = Context.RenderComponent(); + var fields = comp.FindAll(".mud-grid .mud-input-control.mud-field"); + fields.Should().HaveCount(3); + fields[0].ClassList.Should().Contain("mud-input-text-with-label"); + fields[0].TextContent.Trim().Should().Be("Standard"); + var adornmentAria = comp.Find(".mud-grid .mud-input-control.mud-field svg.mud-input-adornment-icon"); + // get what adornmentAria aria-label says + adornmentAria.GetAttribute("aria-label").Trim().Should().Be("test-aria"); + } + + [Test] + public void FieldTests_ShrinkLabel() + { + // Issue 7533, when ChildContent is null, the mud-shrink class is applied + // Add a shrink label override to the field in addition to the ChildContent + var comp = Context.RenderComponent(); + // find all the mud-fields inner area + var fields = comp.FindAll(".mud-input-control.mud-field > .mud-input-control-input-container > .mud-input"); + var fieldLabels = comp.FindAll(".mud-input-control.mud-field > .mud-input-control-input-container label"); + fields.Should().HaveCount(5); + + // with end adornment no content + fields[0].ClassList.Should().NotContain("mud-shrink"); + fieldLabels[0].TextContent.Trim().Should().Contain("What am I? (0)"); + // with start adornment + fields[1].ClassList.Should().Contain("mud-shrink"); + fieldLabels[1].TextContent.Trim().Should().Be("What am I? (1)"); + // content + fields[2].ClassList.Should().Contain("mud-shrink"); + fields[2].TextContent.Trim().Should().Be("Some Content Here"); + fieldLabels[2].TextContent.Trim().Should().Be("What am I? (2)"); + + // with shrink label override + //start adornment + fields[3].ClassList.Should().NotContain("mud-shrink"); + fieldLabels[3].TextContent.Trim().Should().Be("What am I? (3)"); + // content and end adornment + fields[4].ClassList.Should().NotContain("mud-shrink"); + fields[4].TextContent.Trim().Should().Be("Some Content Here"); + fieldLabels[4].TextContent.Trim().Should().Be("What am I? (4)"); + } + } +} diff --git a/src/MudBlazor.UnitTests/Components/HighlighterTests.cs b/src/MudBlazor.UnitTests/Components/HighlighterTests.cs index f7b27dcb4e54..12dd591ff57b 100644 --- a/src/MudBlazor.UnitTests/Components/HighlighterTests.cs +++ b/src/MudBlazor.UnitTests/Components/HighlighterTests.cs @@ -1,6 +1,7 @@ using Bunit; using FluentAssertions; using MudBlazor.Components.Highlighter; +using MudBlazor.UnitTests.TestComponents.Highlighter; using NUnit.Framework; using static Bunit.ComponentParameterFactory; using static MudBlazor.Components.Highlighter.Splitter; @@ -138,7 +139,7 @@ public void GetHtmlAwareFragments_NullOrEmptyText_ReturnsEmptyList() // Test with empty text var resultEmpty = GetHtmlAwareFragments(string.Empty, "any", null, out outRegex, false, false); resultEmpty.Should().BeEmpty(); - // As per L59, regex is set to string.Empty at the start of the method before the null check. + outRegex.Should().Be(string.Empty); } @@ -200,7 +201,7 @@ public void GetHtmlAwareFragments_MismatchedClosingTag_IsEncodedAsText() [Test] public void GetHtmlAwareFragments_UnmatchedTagWithHighlightAndTrailingText_CorrectlyFragments() { - var text = "unclosed highlight then_text"; // Simplified input + var text = "unclosed highlight then_text"; var highlightedText = "highlight"; var fragments = GetHtmlAwareFragments(text, highlightedText, null, out var outRegex, caseSensitive: false, untilNextBoundary: false); @@ -404,173 +405,202 @@ public void MudHighlighterMarkupRenderFragmentTest() var rawOutput = "<i>MudBlazor</i>"; var formattedOutput = "MudBlazor"; - var text = Parameter(nameof(MudHighlighter.Text), markupText); - var highlightedText = Parameter(nameof(MudHighlighter.HighlightedText), searchFor); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, markupText) + .Add(p => p.HighlightedText, searchFor) + .Add(p => p.Markup, false) + ); - var textAsMarkupFalse = Parameter(nameof(MudHighlighter.Markup), false); - var comp = Context.RenderComponent(text, highlightedText, textAsMarkupFalse); - comp.MarkupMatches(rawOutput); + comp.Markup.Should().Contain(rawOutput); - var textAsMarkupTrue = Parameter(nameof(MudHighlighter.Markup), true); - comp = Context.RenderComponent(text, highlightedText, textAsMarkupTrue); - comp.MarkupMatches(formattedOutput); + comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, markupText) + .Add(p => p.HighlightedText, searchFor) + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain(formattedOutput); } [Test] public void MudHighlighter_MarkupTrue_HtmlInText_ShouldHighlightCorrectly() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello World"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "Hello"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello World"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello World") + .Add(p => p.HighlightedText, "Hello") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello World"); } [Test] public void MudHighlighter_MarkupTrue_HtmlSensitiveCharInHighlightedText_ShouldEncodeAndHighlight() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello "); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), ""); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello <World>"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello ") + .Add(p => p.HighlightedText, "") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello <World>"); } [Test] public void MudHighlighter_MarkupTrue_HtmlInText_ShouldNotHighlightInTags() { - var textParam = Parameter(nameof(MudHighlighter.Text), "
div content div
"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "div"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("
div content div
"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "
div content div
") + .Add(p => p.HighlightedText, "div") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("
div content div
"); } [Test] public void MudHighlighter_MarkupTrue_HtmlTag_ShouldNotHighlight() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello Mud World"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), ""); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello Mud World"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello Mud World") + .Add(p => p.HighlightedText, "") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello Mud World"); } [Test] public void MudHighlighter_MarkupTrue_TextWithHtmlEntities_HighlightedTextIsEntityText() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello & World"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "&"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - // Adjusted to match BUnit's actual output. This implies that when FragmentInfo.Content = "&", - // the rendered @FragmentInfo.Content is captured by BUnit as &amp;. - comp.MarkupMatches("Hello &amp; World"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello & World") + .Add(p => p.HighlightedText, "&") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello &amp; World"); } [Test] public void MudHighlighter_MarkupTrue_HighlightedTextWithSingleQuotes_ShouldHighlight() { - var textParam = Parameter(nameof(MudHighlighter.Text), "This is a 'quoted' text and a \"double quoted\" text."); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "'quoted'"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("This is a 'quoted' text and a \"double quoted\" text."); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "This is a 'quoted' text and a \"double quoted\" text.") + .Add(p => p.HighlightedText, "'quoted'") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("This is a 'quoted' text and a "double quoted" text."); } [Test] public void MudHighlighter_MarkupTrue_HighlightedTextWithDoubleQuotes_ShouldHighlight() { - var textParam = Parameter(nameof(MudHighlighter.Text), "This is a 'quoted' text and a \"double quoted\" text."); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "\"double quoted\""); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("This is a 'quoted' text and a "double quoted" text."); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "This is a 'quoted' text and a \"double quoted\" text.") + .Add(p => p.HighlightedText, "\"double quoted\"") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("This is a 'quoted' text and a "double quoted" text."); } [Test] public void MudHighlighter_MarkupTrue_HighlightedTextAsAttributeValue_ShouldNotHighlightInAttribute() { - var textParam = Parameter(nameof(MudHighlighter.Text), "nothing"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "nothing"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("nothing"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "MudBlazor is important") + .Add(p => p.HighlightedText, "nothing") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("MudBlazor is important"); } [Test] public void MudHighlighter_MarkupTrue_FormattingPreservation_ItalicsAndColor() { - var textParam = Parameter(nameof(MudHighlighter.Text), "MudBlazor is important"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "MudBlazor"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("MudBlazor is important"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "MudBlazor is important") + .Add(p => p.HighlightedText, "MudBlazor") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("MudBlazor is important"); } [Test] public void MudHighlighter_MarkupTrue_FormattingPreservation_Bold() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Normal bold normal"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "bold"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Normal bold normal"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Normal bold normal") + .Add(p => p.HighlightedText, "bold") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Normal bold normal"); } [Test] public void MudHighlighter_MarkupTrue_NonStandardTag_NoHighlight() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello world"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), ""); // Or null, effectively no highlight - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello <ambitious> world"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello world") + .Add(p => p.HighlightedText, "") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello <ambitious> world"); } [Test] public void MudHighlighter_MarkupTrue_NonStandardTag_WithHighlightAfterTag() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello world"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "world"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello <ambitious> world"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello world") + .Add(p => p.HighlightedText, "world") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello <ambitious> world"); } [Test] public void MudHighlighter_MarkupTrue_NonStandardTag_WithHighlightInsideTag() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Hello world"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "bit"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); - comp.MarkupMatches("Hello <ambitious> world"); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Hello world") + .Add(p => p.HighlightedText, "bit") + .Add(p => p.Markup, true) + ); + + comp.Markup.Should().Contain("Hello <ambitious> world"); } [Test] public void MudHighlighter_MarkupTrue_NoFragments_RendersTextAsMarkupString() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Some text"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "zip"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Some text") + .Add(p => p.HighlightedText, "zip") + .Add(p => p.Markup, true) + ); - comp.MarkupMatches("Some text"); + comp.Markup.Should().Contain("Some text"); } [Test] public void MudHighlighter_MarkupTrue_WithClass_RendersMarkWithClass() { - var textParam = Parameter(nameof(MudHighlighter.Text), "Highlight this"); - var highlightedTextParam = Parameter(nameof(MudHighlighter.HighlightedText), "Highlight"); - var markupParam = Parameter(nameof(MudHighlighter.Markup), true); - var classParam = Parameter(nameof(MudHighlighter.Class), "my-custom-class"); - - var comp = Context.RenderComponent(textParam, highlightedTextParam, markupParam, classParam); + var comp = Context.RenderComponent(parameters => parameters + .Add(p => p.Text, "Highlight this") + .Add(p => p.HighlightedText, "Highlight") + .Add(p => p.Markup, true) + .Add(p => p.CustomClass, "my-custom-class") + ); - comp.MarkupMatches("Highlight this"); + comp.Markup.Should().Contain("Highlight this"); } [Test] @@ -580,23 +610,21 @@ public void MudHighlighter_MarkupFalse_AfterMarkupTrue_ClearsHtmlAwareFragmentsA var initialHighlightedText = "highlight"; // 1. Render initially with Markup = true - var comp = Context.RenderComponent(parameters => parameters + var comp = Context.RenderComponent(parameters => parameters .Add(p => p.Text, initialText) .Add(p => p.HighlightedText, initialHighlightedText) .Add(p => p.Markup, true) ); - comp.MarkupMatches("Test with HTML and highlight"); + comp.Markup.Should().Contain("Test with HTML and highlight"); // 2. Re-render with Markup = false comp.SetParametersAndRender(parameters => parameters - .Add(p => p.Text, initialText) // Keep text and highlight the same - .Add(p => p.HighlightedText, initialHighlightedText) .Add(p => p.Markup, false) ); var expectedMarkup = "Test with <b>HTML</b> and highlight"; - comp.MarkupMatches(expectedMarkup); + comp.Markup.Should().Contain(expectedMarkup); } } } diff --git a/src/MudBlazor.UnitTests/Components/StepperTests.cs b/src/MudBlazor.UnitTests/Components/StepperTests.cs index 8a77a5c39a34..4f96261205ed 100644 --- a/src/MudBlazor.UnitTests/Components/StepperTests.cs +++ b/src/MudBlazor.UnitTests/Components/StepperTests.cs @@ -658,6 +658,49 @@ Task OnPreviewInteraction(StepperInteractionEventArgs args) stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(0); } + [Test] + public void ResetButton_ShouldTriggerResetStepActionForSkippedSteps() + { + var cancel = false; + var actions = new List(); + var index = -1; + Task OnPreviewInteraction(StepperInteractionEventArgs args) + { + actions.Add(args.Action); + index = args.StepIndex; + args.Cancel = cancel; + return Task.CompletedTask; + } + var stepper = Context.RenderComponent(self => + { + self.Add(x => x.OnPreviewInteraction, OnPreviewInteraction); + self.Add(x => x.ShowResetButton, true); + self.Add(x => x.NonLinear, true); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + }); + + // clicking skip sends Skip action requests to get us in a state that reset is a valid click + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(0); + stepper.Find(".mud-stepper-button-skip").Click(); + index.Should().Be(0); + actions[0].Should().Be(StepAction.Skip); + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(1); + stepper.Find(".mud-stepper-button-skip").Click(); + index.Should().Be(1); + actions[1].Should().Be(StepAction.Skip); + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(2); + + // check that clicking reset sends Reset StepAction + stepper.Find(".mud-stepper-button-reset").Click(); + actions[2].Should().Be(StepAction.Reset); + actions[3].Should().Be(StepAction.Reset); + actions[4].Should().Be(StepAction.Reset); + actions[5].Should().Be(StepAction.Activate); + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(0); + } + [Test] public void NextButton_ShouldTriggerCompleteStepAction() { @@ -690,6 +733,38 @@ Task OnPreviewInteraction(StepperInteractionEventArgs args) stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(1); } + [Test] + public void SkipButton_ShouldTriggerSkipStepAction() + { + var cancel = false; + var action = StepAction.Reset; + var index = -1; + Task OnPreviewInteraction(StepperInteractionEventArgs args) + { + action = args.Action; + index = args.StepIndex; + args.Cancel = cancel; + return Task.CompletedTask; + } + var stepper = Context.RenderComponent(self => + { + self.Add(x => x.OnPreviewInteraction, OnPreviewInteraction); + self.Add(x => x.ShowResetButton, true); + self.Add(x => x.NonLinear, true); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + self.AddChildContent(step => { step.Add(s => s.Skippable, true); }); + }); + + // clicking skip sends Skipped action requests + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(0); + stepper.Find(".mud-stepper-button-skip").Click(); + index.Should().Be(0); + action.Should().Be(StepAction.Skip); + stepper.Instance.GetState(nameof(MudStepper.ActiveIndex)).Should().Be(1); + } + + [Test] public void BackButton_ShouldTriggerActivateStepAction() { diff --git a/src/MudBlazor.UnitTests/Components/TableTests.cs b/src/MudBlazor.UnitTests/Components/TableTests.cs index 4f6caf9229b8..1e8b76a2e0ef 100644 --- a/src/MudBlazor.UnitTests/Components/TableTests.cs +++ b/src/MudBlazor.UnitTests/Components/TableTests.cs @@ -5,6 +5,7 @@ using FluentAssertions; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; +using MudBlazor.UnitTests.TestComponents.DataGrid; using MudBlazor.UnitTests.TestComponents.Table; using NUnit.Framework; @@ -2116,6 +2117,128 @@ public void TableGroupingTest() } + /// + /// A table with 3 unexpanded groups. The first group is expanded, next removed. + /// The other groups remain unexpanded. + /// + /// + /// https://github.com/MudBlazor/MudBlazor/issues/10250 + /// + [Test] + public void TableGrouping_ExpandFirstGroupAndRemoveIt_OtherGroupsRemainUnexpanded() + { + // Arrange + + var comp = Context.RenderComponent(); + var table = comp.Instance.TableInstance; + comp.Render(); + + // Assert : Three groups are unexpanded + + table.Context.GroupRows.Count.Should().Be(3); + table.Context.GroupRows.ElementAt(0).Expanded.Should().BeFalse(); + table.Context.GroupRows.ElementAt(1).Expanded.Should().BeFalse(); + table.Context.GroupRows.ElementAt(2).Expanded.Should().BeFalse(); + + // Act : Expend the first group + + comp.FindAll("button")[0].Click(); + + // Assert : Only the first group is expanded + + table.Context.GroupRows.Count.Should().Be(3); + table.Context.GroupRows.ElementAt(0).Expanded.Should().BeTrue(); + table.Context.GroupRows.ElementAt(1).Expanded.Should().BeFalse(); + table.Context.GroupRows.ElementAt(2).Expanded.Should().BeFalse(); + + // Act : Remove the first group + + comp.Instance.Items.RemoveAll(i => i.Group == "One"); + comp.Render(); + + // Assert : Two groups are unexpanded + + table.Context.GroupRows.Count.Should().Be(2); + table.Context.GroupRows.ElementAt(0).Expanded.Should().BeFalse(); + table.Context.GroupRows.ElementAt(1).Expanded.Should().BeFalse(); + } + + /// + /// A table with unexpanded groups and unexpanded nested groups. + /// The first group and its first nested group are expanded. Then remove the first nested group. + /// The other nested group remains unexpanded. + /// + /// + /// https://github.com/MudBlazor/MudBlazor/issues/10250 + /// + [Test] + public void TableGrouping_ExpandFirstNestedGroupAndRemoveIt_OtherNestedGroupsRemainUnexpanded() + { + // Arrange + + var comp = Context.RenderComponent(); + var table = comp.Instance.TableInstance; + comp.Render(); + + // Assert : All groups are unexpanded + + { + var groups = table.Context.GroupRows; + groups.Count.Should().Be(3); + groups.Single(g => g.Items.Key.ToString() == "G1").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G3").Expanded.Should().BeFalse(); + } + + // Act : Expend the first group + + comp.FindAll("button")[0].Click(); + + // Assert : Only the first group is expanded + + { + var groups = table.Context.GroupRows; + groups.Count.Should().Be(5); + groups.Single(g => g.Items.Key.ToString() == "G1").Expanded.Should().BeTrue(); + groups.Single(g => g.Items.Key.ToString() == "G1 > N1").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G1 > N2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G3").Expanded.Should().BeFalse(); + } + + // Act : Expand the first nested group in the first group + + comp.FindAll("button")[1].Click(); + + // Assert : Only the first group and its first nested group are expanded + + { + var groups = table.Context.GroupRows; + groups.Count.Should().Be(5); + groups.Single(g => g.Items.Key.ToString() == "G1").Expanded.Should().BeTrue(); + groups.Single(g => g.Items.Key.ToString() == "G1 > N1").Expanded.Should().BeTrue(); + groups.Single(g => g.Items.Key.ToString() == "G1 > N2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G3").Expanded.Should().BeFalse(); + } + + // Act : Remove the first nested group in first group + + comp.Instance.Items.RemoveAll(i => i.Group == "G1" && i.Nested == "N1"); + comp.Render(); + + // Assert : Only the first group is expanded and its remaining nested group is unexpanded + + { + var groups = table.Context.GroupRows; + groups.Count.Should().Be(4); + groups.Single(g => g.Items.Key.ToString() == "G1").Expanded.Should().BeTrue(); + groups.Single(g => g.Items.Key.ToString() == "G1 > N2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G2").Expanded.Should().BeFalse(); + groups.Single(g => g.Items.Key.ToString() == "G3").Expanded.Should().BeFalse(); + } + } + /// /// Tests the grouping behavior and ensure that it won't break anything else. /// @@ -2546,6 +2669,30 @@ public async Task TestCurrentPageParameterTwoWayBinding() comp.WaitForAssertion(() => comp.Find(".mud-table-body .mud-table-row .mud-table-cell").TextContent.Should().Be("3")); } + /// + /// Table initialized to display the third page + /// + /// + /// Table.CurrentPage start at 0, so 2 is the second page + /// https://github.com/MudBlazor/MudBlazor/issues/11727 + /// + [Test] + public void Table_WithCurrentPage_ShouldFirstRenderThisPage() + { + // Arrange + + var comp = Context.RenderComponent(); + var table = comp.FindComponent>().Instance; + + // Assert : DataGrid is initialized with CurrentPage at 2 + + table.CurrentPage.Should().Be(2); + + // Assert : The first item in the third page is 20 + + comp.Find(".mud-table-body .mud-table-row .mud-table-cell").TextContent.Should().Be("20"); + } + [Test] [TestCase(SortDirection.None)] [TestCase(SortDirection.Ascending)] diff --git a/src/MudBlazor.UnitTests/Components/TabsTests.cs b/src/MudBlazor.UnitTests/Components/TabsTests.cs index 11943a87e228..5990b06c1f19 100644 --- a/src/MudBlazor.UnitTests/Components/TabsTests.cs +++ b/src/MudBlazor.UnitTests/Components/TabsTests.cs @@ -1,6 +1,8 @@ using System.Globalization; +using System.Reflection; using Bunit; using FluentAssertions; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.DependencyInjection; using MudBlazor.Services; using MudBlazor.UnitTests.TestComponents.Tabs; @@ -137,7 +139,7 @@ public void KeepTabs_Not_AliveTest() // only the first panel should be rendered first comp.FindAll("p")[^1].MarkupMatches("

Panel 1

"); // no child divs in div.mud-tabs-panels - comp.FindAll("div.mud-tabs-panels > div").Count.Should().Be(0); + comp.FindAll("div.mud-tabs-panels > div").Count.Should().Be(1); // click first button and show button click counters comp.FindAll("button")[0].TrimmedText().Should().Be("Panel 1=0"); comp.FindAll("button")[0].Click(); @@ -1338,7 +1340,7 @@ public void TabPanel_Hidden_Class(bool visible) #nullable enable [Test] - public void TabsDragAndDrop_With_FiresOnItemDropped() + public async Task TabsDragAndDrop_With_FiresOnItemDroppedAsync() { bool onItemDroppedCalled = false; MudItemDropInfo? finalDropInfo = null; @@ -1367,7 +1369,7 @@ public void TabsDragAndDrop_With_FiresOnItemDropped() // simulate dragging a tab to index 2 var dropInfo = new MudItemDropInfo(draggableTab, "mud-drop-zone", 2); - tabs.ItemUpdated(dropInfo); + await tabs.ItemUpdated(dropInfo); // Assert that OnItemDropped was called comp.WaitForAssertion(() => onItemDroppedCalled.Should().BeTrue()); @@ -1588,5 +1590,129 @@ public void Tab_DragAndDrop_ActivatePanel() comp.WaitForAssertion(() => divs[3].ClassList.Contains("mud-tab-active").Should().BeTrue()); comp.WaitForAssertion(() => divs[3].ClassList.Contains("test-active").Should().BeTrue()); } + + /// + /// Tab selection changes on keyboard Left and Right arrow keys, is activated by Enter/Space keys and ensures disabled tab is not selectable. + /// + [Test] + public async Task KeyboardActivation_DisablesDisabledTab_LeftRight() + { + var comp = Context.RenderComponent(); + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content One"); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[0].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowRight" }); + }); + var tabsAfterArrowRight = comp.FindAll("div.mud-tab"); + await comp.InvokeAsync(async () => + { + await tabsAfterArrowRight[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + }); + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content Two"); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowLeft" }); + }); + var tabsAfterArrowLeft = comp.FindAll("div.mud-tab"); + await comp.InvokeAsync(async () => + { + await tabsAfterArrowLeft[0].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = " " }); + }); + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content One"); + } + + /// + /// Tab selection changes on keyboard Up and Down arrow keys, is activated by Enter/Space keys + /// + [Test] + public async Task VerticalTabs_SupportsArrowUpDownNavigation() + { + var comp = Context.RenderComponent(); + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[0].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" }); + }); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + }); + + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content Two"); + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowDown" }); + }); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[2].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = " " }); + }); + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content Three"); + } + + + /// + /// Tab selection wraps on keyboard Left and Right arrow keys, is activated by Enter/Space keys and ensures disabled tab is not selectable. + /// + [Test] + public async Task KeyboardNavigation_LeftArrow_WrapsToLastEnabledTab() + { + var comp = Context.RenderComponent(); + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[0].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowLeft" }); + }); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "Enter" }); + }); + + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content Two"); + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[1].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = "ArrowLeft" }); + }); + + await comp.InvokeAsync(async () => + { + var tabs = comp.FindAll("div.mud-tab"); + await tabs[0].TriggerEventAsync("onkeydown", new KeyboardEventArgs { Key = " " }); + }); + comp.Find("div.mud-tabs-panels").InnerHtml.Should().Contain("Content One"); + } + + /// + /// Code coverage test showed a missing test line, this tests the return tabListId returns the correct ID. + /// + [Test] + public void TabListId_ReturnsCorrectId() + { + var comp = Context.RenderComponent(); + var instance = comp.Instance; + + // Use reflection to set the internal field _tabListId + var field = typeof(MudTabs).GetField("_tabListId", BindingFlags.NonPublic | BindingFlags.Instance); + field.Should().NotBeNull("because the field '_tabListId' should exist on MudTabs"); + field!.SetValue(instance, "test-tab-list-id"); + + var method = typeof(MudTabs).GetMethod("GetTabListId", BindingFlags.NonPublic | BindingFlags.Instance); + method.Should().NotBeNull("because the method 'GetTabListId' should exist on MudTabs"); + var result = method!.Invoke(instance, null); + + result.Should().Be("test-tab-list-id"); + } } } diff --git a/src/MudBlazor.UnitTests/Other/CategoryAttributeTests.cs b/src/MudBlazor.UnitTests/Other/CategoryAttributeTests.cs index cff723dc1538..89eed45b5bc6 100644 --- a/src/MudBlazor.UnitTests/Other/CategoryAttributeTests.cs +++ b/src/MudBlazor.UnitTests/Other/CategoryAttributeTests.cs @@ -1,6 +1,4 @@ -using System; -using System.Linq; -using System.Reflection; +using System.Reflection; using FluentAssertions; using Microsoft.AspNetCore.Components; using NUnit.Framework; @@ -42,7 +40,7 @@ public void AllComponentPropertiesHaveCategories() typeof(MudDataGridPager<>), typeof(SelectColumn<>), typeof(HierarchyColumn<>), - + typeof(TemplateColumn<>), typeof(MudTHeadRow), typeof(MudTFootRow), typeof(MudTr), diff --git a/src/MudBlazor/Base/MudBaseInput.cs b/src/MudBlazor/Base/MudBaseInput.cs index e5f78d1f75e7..20eae457ead8 100644 --- a/src/MudBlazor/Base/MudBaseInput.cs +++ b/src/MudBlazor/Base/MudBaseInput.cs @@ -675,11 +675,11 @@ public virtual void ForceRender(bool forceTextUpdate) /// public override async Task SetParametersAsync(ParameterView parameters) { - await base.SetParametersAsync(parameters); - var hasText = parameters.Contains(nameof(Text)); var hasValue = parameters.Contains(nameof(Value)); + await base.SetParametersAsync(parameters); + // Refresh Value from Text if (hasText && !hasValue) { @@ -788,13 +788,5 @@ private async Task UpdateInputIdStateAsync() await _inputIdState.SetValueAsync(_componentId); } - - protected async Task HandleContainerClick() - { - if (!_isFocused && IsJSRuntimeAvailable) - { - await JsRuntime.InvokeVoidAsync("mudInput.focusInput", InputElementId); - } - } } } diff --git a/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor b/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor index 6ae772b17579..df696d77b014 100644 --- a/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor +++ b/src/MudBlazor/Components/Chart/Charts/TimeSeries.razor @@ -62,12 +62,12 @@ var lineClass = isHovered ? "mud-chart-serie mud-chart-line mud-chart-serie-hovered" : "mud-chart-serie mud-chart-line"; - + if (series.LineDisplayType == LineDisplayType.Area) { var chartArea = _chartAreas[chartLine.Index]; - + } @foreach (var item in _chartDataPoints[chartLine.Index].OrderBy(x => x.Index)) diff --git a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor index 287ae64e4af8..08a2ed3ddd35 100644 --- a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor +++ b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor @@ -4,8 +4,8 @@ diff --git a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor.cs b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor.cs index a12329cb41dc..298bbbfcdf55 100644 --- a/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor.cs +++ b/src/MudBlazor/Components/DataGrid/HierarchyColumn.razor.cs @@ -15,9 +15,6 @@ namespace MudBlazor; /// public partial class HierarchyColumn<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T> : MudComponentBase { - private bool _finishedInitialExpanded; - private readonly HashSet> _initiallyExpandedItems = []; - /// /// Displays the content right-to-left. /// @@ -136,32 +133,18 @@ public partial class HierarchyColumn<[DynamicallyAccessedMembers(DynamicallyAcce [Parameter] public RenderFragment> CellTemplate { get; set; } +#nullable enable /// /// The function which determines whether the row should be initially expanded. /// /// /// This function takes an item of type as input and returns a boolean indicating - /// whether the row should be expanded. + /// whether the row should be expanded. Requires item to override the Equals and GetHashCode methods. /// Defaults to a function that always returns false. /// [Parameter] - public Func InitiallyExpandedFunc { get; set; } = _ => false; - - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender); - - if (firstRender) - { - _finishedInitialExpanded = true; - - foreach (var context in _initiallyExpandedItems) - { - await context.Actions.ToggleHierarchyVisibilityForItemAsync.Invoke(); - } - } - } + public Func? InitiallyExpandedFunc { get; set; } +#nullable disable private string GetGroupIcon(CellContext context) { diff --git a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs index 8b4fe95d83c4..2783038b5e7d 100644 --- a/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs +++ b/src/MudBlazor/Components/DataGrid/MudDataGrid.razor.cs @@ -32,6 +32,9 @@ public partial class MudDataGrid<[DynamicallyAccessedMembers(DynamicallyAccessed private bool _filtersMenuVisible = false; private bool _columnsPanelVisible = false; internal HashSet _openHierarchies = []; + private readonly HashSet _initialExpansions = []; + private Func _initialExpandedFunc = null; + private Func _buttonDisabledFunc = null; private string _columnsPanelSearch = string.Empty; private MudDropContainer> _dropContainer; private MudDropContainer> _columnsPanelDropContainer; @@ -736,28 +739,71 @@ public IEnumerable Items _items = value; - if (PagerStateHasChangedEvent != null) - InvokeAsync(PagerStateHasChangedEvent); + OnPagerStateChanged(); + SetupGrouping(); + ApplyInitialExpansionForItems(_items); + SetupCollectionChangeTracking(); + } + } + + + private void OnPagerStateChanged() + { + if (PagerStateHasChangedEvent is not null) + InvokeAsync(PagerStateHasChangedEvent); + } + + private void SetupGrouping() + { + if (Groupable) + GroupItems(); + } + + private void SetupCollectionChangeTracking() + { + if (_items is INotifyCollectionChanged changed) + { + changed.CollectionChanged += (s, e) => + { + _currentRenderFilteredItemsCache = null; + + if (Groupable) + GroupItems(); - // set initial grouping - if (Groupable) + ApplyInitialExpansionForNewItems(e); + }; + } + } + + private void ApplyInitialExpansionForNewItems(NotifyCollectionChangedEventArgs e) + { + if (_initialExpandedFunc is null || e.NewItems is null) + return; + + foreach (T item in e.NewItems) + { + if (_initialExpandedFunc.Invoke(item) && _initialExpansions.Add(item)) { - GroupItems(); + _openHierarchies.Add(item); } + } + } - // Setup ObservableCollection functionality. - if (_items is INotifyCollectionChanged changed) + private void ApplyInitialExpansionForItems(IEnumerable items) + { + if (_initialExpandedFunc is null || items is null) + return; + + foreach (var item in items) + { + if (_initialExpandedFunc.Invoke(item) && _initialExpansions.Add(item)) { - changed.CollectionChanged += (s, e) => - { - _currentRenderFilteredItemsCache = null; - if (Groupable) - GroupItems(); - }; + _openHierarchies.Add(item); } } } + /// /// Shows a loading animation while querying data. /// @@ -1373,7 +1419,6 @@ internal async Task InvokeServerLoadFunc() // Cancel any prior request CancelServerDataToken(); await _mudVirtualize.RefreshDataAsync(); - StateHasChanged(); } else { @@ -1396,7 +1441,6 @@ internal async Task InvokeServerLoadFunc() _currentRenderFilteredItemsCache = null; Loading = false; - StateHasChanged(); } } else @@ -1420,9 +1464,22 @@ internal async Task InvokeServerLoadFunc() CurrentPage = 0; Loading = false; - StateHasChanged(); PagerStateHasChangedEvent?.Invoke(); } + // handle initial hierarchy expansion + if (_initialExpandedFunc is not null) + { + foreach (var data in _serverData.Items) + { + // ensure we only add it once if they were expanded initially + if (_initialExpandedFunc(data) && _initialExpansions.Add(data)) + { + _openHierarchies.Add(data); + } + } + } + + StateHasChanged(); GroupItems(); } @@ -1430,6 +1487,20 @@ internal void AddColumn(Column column) { if (column.Tag?.ToString() == "hierarchy-column") { + if (column is TemplateColumn templateColumn) + { + _initialExpandedFunc = templateColumn.InitiallyExpandedFunc; + _buttonDisabledFunc = templateColumn.ButtonDisabledFunc; + // Apply expansion now if items or _serverData.Items is already set + if (_items is not null) + { + ApplyInitialExpansionForItems(_items); + } + else if (_serverData?.Items?.Any() == true) + { + ApplyInitialExpansionForItems(_serverData.Items); + } + } RenderedColumns.Insert(0, column); } else if (column.Tag?.ToString() == "select-column") @@ -2295,7 +2366,7 @@ private async Task ToggleGroupExpandRecursively(bool expanded) public async Task ExpandAllHierarchy() { _openHierarchies.Clear(); - _openHierarchies.UnionWith(FilteredItems); + _openHierarchies.UnionWith(FilteredItems.Where(x => !_buttonDisabledFunc(x))); await InvokeAsync(StateHasChanged); } @@ -2304,7 +2375,7 @@ public async Task ExpandAllHierarchy() /// public async Task CollapseAllHierarchy() { - _openHierarchies.Clear(); + _openHierarchies.RemoveWhere(x => !_buttonDisabledFunc(x)); await InvokeAsync(StateHasChanged); } @@ -2329,6 +2400,7 @@ public async Task ToggleHierarchyVisibilityAsync(T item) await InvokeAsync(StateHasChanged); } + #region Resize feature [Inject] private IEventListenerFactory EventListenerFactory { get; set; } diff --git a/src/MudBlazor/Components/DataGrid/TemplateColumn.cs b/src/MudBlazor/Components/DataGrid/TemplateColumn.cs index e9279fe022ce..43653966c4b2 100644 --- a/src/MudBlazor/Components/DataGrid/TemplateColumn.cs +++ b/src/MudBlazor/Components/DataGrid/TemplateColumn.cs @@ -2,7 +2,6 @@ // MudBlazor licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; @@ -71,5 +70,23 @@ protected internal override void SetProperty(object item, object value) /// [Parameter] public override bool? ShowColumnOptions { get; set; } = false; + + /// + /// Sets the initial expansion state of this column if used as a Hierarchy Column. + /// + /// + /// Used internally for Hierarchy Columns, toggling will have no effect. + /// + [Parameter] + public Func? InitiallyExpandedFunc { get; set; } + + /// + /// Sets the function which determines whether buttons are disabled if used in a Hierarchy Column. + /// + /// + /// Used internally for Hierarchy Columns, setting this will have no effect. + /// + [Parameter] + public Func ButtonDisabledFunc { get; set; } = _ => false; } } diff --git a/src/MudBlazor/Components/Field/MudField.razor b/src/MudBlazor/Components/Field/MudField.razor index 8c48f00b392c..488d3528e937 100644 --- a/src/MudBlazor/Components/Field/MudField.razor +++ b/src/MudBlazor/Components/Field/MudField.razor @@ -14,6 +14,7 @@ Size="@IconSize" Text="@AdornmentText" Placement="@Adornment.Start" + AriaLabel="@AdornmentAriaLabel" AdornmentClick="@OnAdornmentClick" /> }
@@ -27,6 +28,7 @@ Size="@IconSize" Text="@AdornmentText" Placement="@Adornment.End" + AriaLabel="@AdornmentAriaLabel" AdornmentClick="@OnAdornmentClick" /> } @if (Variant == Variant.Outlined) @@ -40,4 +42,4 @@ }
- \ No newline at end of file + diff --git a/src/MudBlazor/Components/Field/MudField.razor.cs b/src/MudBlazor/Components/Field/MudField.razor.cs index e4d45e0789bf..bf1c7bacd43d 100644 --- a/src/MudBlazor/Components/Field/MudField.razor.cs +++ b/src/MudBlazor/Components/Field/MudField.razor.cs @@ -19,7 +19,12 @@ public partial class MudField : MudComponentBase .AddClass($"mud-input-adorned-{Adornment.ToDescriptionString()}", Adornment != Adornment.None) .AddClass($"mud-input-margin-{Margin.ToDescriptionString()}", () => Margin != Margin.None) .AddClass("mud-input-underline", () => Underline && Variant != Variant.Outlined) - .AddClass("mud-shrink", () => !string.IsNullOrWhiteSpace(ChildContent?.ToString()) || Adornment == Adornment.Start) + // Without the mud-shrink class, the label will become a placeholder + // Apply "mud-shrink" only if ShrinkLabel is false AND + // (there is content OR the adornment is at the start) + .AddClass("mud-shrink", + !ShrinkLabel && + (ChildContent != null || Adornment == Adornment.Start)) .AddClass("mud-disabled", Disabled) .AddClass("mud-input-error", Error && !string.IsNullOrEmpty(ErrorText)) .AddClass($"mud-typography-{Typo.ToDescriptionString()}") @@ -177,6 +182,16 @@ public partial class MudField : MudComponentBase [Category(CategoryTypes.FormComponent.Appearance)] public Color AdornmentColor { get; set; } = Color.Default; + /// + /// The aria-label for the adornment. + /// + /// + /// Defaults to null. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Appearance)] + public string? AdornmentAriaLabel { get; set; } + /// /// The size of the icon. /// @@ -212,5 +227,17 @@ public partial class MudField : MudComponentBase [Parameter] [Category(CategoryTypes.Field.Appearance)] public bool Underline { get; set; } = true; + + /// + /// Controls whether the label is displayed inside the field or shrinks above it when the field does not have focus. + /// + /// + /// Defaults to false. + /// When false, the label behaves like a placeholder and shrinks only if there is content or an adornment at the start. + /// When true, the label does not shrink and remains as a placeholder regardless of content, which may result in overlap. + /// + [Parameter] + [Category(CategoryTypes.FormComponent.Appearance)] + public bool ShrinkLabel { get; set; } } } diff --git a/src/MudBlazor/Components/Highlighter/MudHighlighter.razor b/src/MudBlazor/Components/Highlighter/MudHighlighter.razor index b34629df01ea..cf0017ad5896 100644 --- a/src/MudBlazor/Components/Highlighter/MudHighlighter.razor +++ b/src/MudBlazor/Components/Highlighter/MudHighlighter.razor @@ -3,12 +3,34 @@ @using System.Text.RegularExpressions @using MudBlazor.Components.Highlighter @using System.Text +@using System.Net @if (Markup) { if (_htmlAwareFragments != null) { - @CreateFragmentContent(); + var sb = new StringBuilder(); + foreach (var fragmentInfo in _htmlAwareFragments) + { + switch (fragmentInfo.Type) + { + case FragmentType.HighlightedText: + var attributes = RenderAttributes(UserAttributes); + var classNames = string.IsNullOrWhiteSpace(Class) ? string.Empty : $" class=\"{Class}\" "; + var styles = string.IsNullOrWhiteSpace(Style) ? string.Empty : $" style=\"{Style}\" "; + sb.Append($"{WebUtility.HtmlEncode(fragmentInfo.Content)}"); + break; + case FragmentType.Markup: + sb.Append(fragmentInfo.Content); + break; + case FragmentType.Text: + default: + sb.Append(WebUtility.HtmlEncode(fragmentInfo.Content)); + break; + } + } + + @((MarkupString)sb.ToString()) } else if (!string.IsNullOrEmpty(Text)) { @@ -35,44 +57,18 @@ else if (!string.IsNullOrEmpty(Text)) } @code { - private RenderFragment CreateFragmentContent() => builder => + private string RenderAttributes(IDictionary attributes) { - int sequence = 0; - - foreach (var fragmentInfo in _htmlAwareFragments) - { - switch (fragmentInfo.Type) - { - case FragmentType.HighlightedText: - if (IsMatch(fragmentInfo.Content)) - { - builder.OpenElement(sequence++, "mark"); - - if (!string.IsNullOrWhiteSpace(Class)) - builder.AddAttribute(sequence++, "class", Class); + if (attributes == null) return string.Empty; - if (!string.IsNullOrWhiteSpace(Style)) - builder.AddAttribute(sequence++, "style", Style); - - if (UserAttributes != null || UserAttributes.Count != 0) - builder.AddMultipleAttributes(sequence++, UserAttributes); - - builder.AddContent(sequence++, fragmentInfo.Content); - builder.CloseElement(); - } - else - { - builder.AddContent(sequence++, fragmentInfo.Content); - } - break; - case FragmentType.Markup: - builder.AddMarkupContent(sequence++, fragmentInfo.Content); - break; - case FragmentType.Text: - default: - builder.AddContent(sequence++, fragmentInfo.Content); - break; - } + var sb = new StringBuilder(); + foreach (var kvp in attributes) + { + var attrName = System.Net.WebUtility.HtmlEncode(kvp.Key); + var attrValue = System.Net.WebUtility.HtmlEncode(kvp.Value?.ToString()); + sb.Append($" {attrName}=\"{attrValue}\" "); } - }; + + return sb.ToString(); + } } diff --git a/src/MudBlazor/Components/Stepper/MudStep.cs b/src/MudBlazor/Components/Stepper/MudStep.cs index 0234f00a0f50..ff51a6daa408 100644 --- a/src/MudBlazor/Components/Stepper/MudStep.cs +++ b/src/MudBlazor/Components/Stepper/MudStep.cs @@ -27,12 +27,17 @@ public MudStep() .WithParameter(() => HasError) .WithEventCallback(() => HasErrorChanged) .WithChangeHandler(OnParameterChanged); + SkippedState = registerScope.RegisterParameter(nameof(Skipped)) + .WithParameter(() => Skipped) + .WithEventCallback(() => SkippedChanged) + .WithChangeHandler(OnParameterChanged); } private bool _disposed; internal ParameterState CompletedState { get; private set; } internal ParameterState DisabledState { get; private set; } internal ParameterState HasErrorState { get; private set; } + internal ParameterState SkippedState { get; private set; } internal string Styles => new StyleBuilder() .AddStyle(Style) @@ -47,6 +52,7 @@ public MudStep() new CssBuilder("mud-step-label-icon") .AddClass($"mud-{(CompletedStepColor.HasValue ? CompletedStepColor.Value.ToDescriptionString() : Parent?.CompletedStepColor.ToDescriptionString())}", CompletedState && !HasErrorState && Parent?.CompletedStepColor != Color.Default && (Parent?.ActiveStep != this || (Parent?.IsCompleted == true && Parent?.NonLinear == false))) .AddClass($"mud-{(ErrorStepColor.HasValue ? ErrorStepColor.Value.ToDescriptionString() : Parent?.ErrorStepColor.ToDescriptionString())}", HasErrorState) + .AddClass($"mud-{(SkippedStepColor.HasValue ? SkippedStepColor.Value.ToDescriptionString() : Parent?.SkippedStepColor.ToDescriptionString())}", SkippedState) .AddClass($"mud-{Parent?.CurrentStepColor.ToDescriptionString()}", Parent?.ActiveStep == this && !(Parent?.IsCompleted == true && Parent?.NonLinear == false)) .Build(); @@ -118,14 +124,14 @@ public MudStep() public Color? ErrorStepColor { get; set; } /// - /// Whether the user can skip this step. + /// The color used when this step is skipped. /// /// - /// Defaults to false. + /// Defaults to null. /// [Parameter] - [Category(CategoryTypes.List.Behavior)] - public bool Skippable { get; set; } + [Category(CategoryTypes.List.Appearance)] + public Color? SkippedStepColor { get; set; } /// /// Whether this step is completed. @@ -182,6 +188,31 @@ public MudStep() [Category(CategoryTypes.List.Behavior)] public EventCallback OnClick { get; set; } + /// + /// Whether the user can skip this step. + /// + /// + /// Defaults to false. + /// + [Parameter] + [Category(CategoryTypes.List.Behavior)] + public bool Skippable { get; set; } + /// + /// Whether this step has been skipped. + /// + /// + /// Defaults to false. + /// + [Parameter] + [Category(CategoryTypes.List.Behavior)] + public bool Skipped { get; set; } + /// + /// Occurs when has changed. + /// + [Parameter] + [Category(CategoryTypes.List.Behavior)] + public EventCallback SkippedChanged { get; set; } + protected override async Task OnInitializedAsync() { base.OnInitialized(); @@ -222,6 +253,16 @@ public async Task SetDisabledAsync(bool value, bool refreshParent = true) RefreshParent(); } + /// + /// Sets the parameter, and optionally refreshes the parent . + /// + public async Task SetSkippedAsync(bool value, bool refreshParent = true) + { + await SkippedState.SetValueAsync(value); + if (refreshParent) + RefreshParent(); + } + private void RefreshParent() { (Parent as IMudStateHasChanged)?.StateHasChanged(); diff --git a/src/MudBlazor/Components/Stepper/MudStepper.razor b/src/MudBlazor/Components/Stepper/MudStepper.razor index 197a09231de6..5ce3ccad34a4 100644 --- a/src/MudBlazor/Components/Stepper/MudStepper.razor +++ b/src/MudBlazor/Components/Stepper/MudStepper.razor @@ -17,6 +17,7 @@ .AddClass("mud-clickable", NonLinear && !step.DisabledState.Value) .AddClass("mud-step-error", step.HasErrorState.Value) .AddClass("mud-step-completed", step.CompletedState.Value) + .AddClass("mud-step-skipped", step.SkippedState.Value) .Build();