diff --git a/.github/instructions/ac0011-caption-required.instructions.md b/.github/instructions/ac0011-caption-required.instructions.md new file mode 100644 index 00000000..e822cb3e --- /dev/null +++ b/.github/instructions/ac0011-caption-required.instructions.md @@ -0,0 +1,45 @@ +--- +applyTo: 'src/ALCops.ApplicationCop/**/CaptionRequired*' +--- + +# AC0011: CaptionRequired + +## Purpose + +Checks that user-facing symbols define a `Caption` (or `CaptionClass`/`CaptionML`) property: pages, tables, table fields, page controls, actions, enum values, permission sets, and analysis views. + +## Diagnostic properties + +**AC0011** · Category: Design · Severity: Warning · Enabled: true +Message: `Caption is missing.` +No version gate · Full netstandard2.1 support + +## Design decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Caption satisfied by | `Caption`, `CaptionClass`, or `CaptionML` | Any of the three provides a user-facing caption. | +| `ShowCaption = false` | Suppresses the check | Explicitly hidden captions need no value. | +| API pages | Entire page skipped (`IsInApiPage`) | API pages are not user-facing. No pageextension handling needed: API pages cannot be extended. | +| HeadlinePart field controls | Skipped (`IsInHeadlinePartPage`), including via pageextension targets | The runtime ignores `Caption` on HeadlinePart field controls; only `Expression`, `Visible`, `ApplicationArea`, `Drilldown`, and `DrillDownPageID` apply ([docs](https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-create-role-center-headline#in-development)). Page object, actions, and groups in HeadlinePart pages remain checked. See issue #293. | +| Field controls | Fall back to `RelatedFieldSymbol` caption | A page field without a caption inherits the source table field's caption. | +| Part controls | Fall back to `RelatedPartSymbol` caption | Same inheritance principle for page parts. | +| Area/Grid/Repeater/UserControl/SystemPart controls | Skipped | No user-facing caption requirement. | +| System tables/fields (Id >= 2000000000) | Skipped | System objects are Microsoft-owned. | +| Predefined action category groups | Skipped | Names like `Category_Process` get captions from the platform. | +| Promoted SplitButton groups | Checked only when containing repeater-scoped actionrefs | Only case where the runtime displays the group caption. | +| Empty enum values | Skipped | Blank enum values conventionally have no caption. | +| Non-assignable permission sets | Skipped | Not shown in the UI for assignment. | + +## Architecture + +Single `RegisterSymbolAction` over Page, Query, Table, Field, Action, EnumValue, Control, PermissionSet, and AnalysisView symbol kinds. Per-symbol dispatch on symbol kind, then control kind/action kind. + +`IsInHeadlinePartPage` resolves the containing object via `GetContainingObjectTypeSymbol()`; for pageextensions it resolves `IApplicationObjectExtensionTypeSymbol.Target?.OriginalDefinition as IPageBaseTypeSymbol` (same pattern as `PermissionResolver`), then compares `PageType` to `EnumProvider.PageTypeKind.HeadlinePart`. + +## Test coverage + +**HasDiagnostic (5 cases):** EnumObject, HeadlinePartPage, PageObject, PageAnalysisView, TableObject. +**NoDiagnostic (7 cases):** ApiPage, EnumObject, HeadlinePartPage, HeadlinePartPageExtension, PageObject, PageAnalysisView, TableObject. + +PageAnalysisView cases require the net10.0 SDK (skipped below version 18.0.36). HeadlinePartPageExtension requires SDK 13.0+ (older compilers reject an extension whose target is declared in the same module, AL0334). diff --git a/.github/instructions/instruction-maintenance.instructions.md b/.github/instructions/instruction-maintenance.instructions.md index dbfcb543..186bd0dc 100644 --- a/.github/instructions/instruction-maintenance.instructions.md +++ b/.github/instructions/instruction-maintenance.instructions.md @@ -86,6 +86,7 @@ Include: project structure, templates, step-by-step guides, API reference, commo | `cicd` | `'.github/**'` | CI/CD workflows | | `release-strategy` | `'.github/**'` | Release channels, versioning, cleanup | | `get-bc-devtools` | `'.github/actions/get-bc-devtools/**'` | BC DevTools discovery and caching | +| `ac0011-caption-required` | rule-scoped | AC0011 rule | | `ac0013-field-groups-required` | rule-scoped | AC0013 rule | | `ac0014-tooltip-must-end-with-punctuation` | rule-scoped | AC0014 rule | | `ac0026-allow-in-customizations-for-omitted-fields` | rule-scoped | AC0026 rule | diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs index 6eaf41a6..25c3f90b 100644 --- a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/CaptionRequired.cs @@ -10,6 +10,7 @@ public class CaptionRequired : NavCodeAnalysisBase private string _testCasePath; private static readonly string[] AnalysisViewTestCases = ["PageAnalysisView"]; + private static readonly string[] SameModuleExtensionTestCases = ["HeadlinePartPageExtension"]; [SetUp] public void Setup() @@ -26,6 +27,7 @@ public void Setup() [Test] [TestCase("EnumObject")] + [TestCase("HeadlinePartPage")] [TestCase("PageObject")] [TestCase("PageAnalysisView")] [TestCase("TableObject")] @@ -48,6 +50,8 @@ public async Task HasDiagnostic(string testCase) [Test] [TestCase("ApiPage")] [TestCase("EnumObject")] + [TestCase("HeadlinePartPage")] + [TestCase("HeadlinePartPageExtension")] [TestCase("PageObject")] [TestCase("PageAnalysisView")] [TestCase("TableObject")] @@ -60,6 +64,13 @@ public async Task NoDiagnostic(string testCase) "PageAnalysisView requires net10.0 SDK." ); + SkipTestIfVersionIsTooLow( + SameModuleExtensionTestCases, + testCase, + "13.0", + "No support for page extensions when the target itself is already declared in the same module." + ); + var code = await File.ReadAllTextAsync(Path.Combine(_testCasePath, nameof(NoDiagnostic), $"{testCase}.al")) .ConfigureAwait(false); diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al new file mode 100644 index 00000000..697fb47c --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/HasDiagnostic/HeadlinePartPage.al @@ -0,0 +1,28 @@ +page 50100 [|HeadlinePartPage|] +{ + PageType = HeadlinePart; + + layout + { + area(Content) + { + field(MyHeadlineField; MyHeadlineText) + { + ApplicationArea = All; + } + } + } + + actions + { + area(Processing) + { + action([|MyAction|]) + { + } + } + } + + var + MyHeadlineText: Text; +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al new file mode 100644 index 00000000..84f8c6ca --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPage.al @@ -0,0 +1,28 @@ +page 50100 HeadlinePartPage +{ + PageType = HeadlinePart; + Caption = 'Headline'; + + layout + { + area(Content) + { + field([|MyHeadlineField|]; MyHeadlineText) + { + ApplicationArea = All; + Visible = true; + } + group(MyGroup) + { + ShowCaption = false; + field([|MyGroupedHeadlineField|]; MyHeadlineText) + { + ApplicationArea = All; + } + } + } + } + + var + MyHeadlineText: Text; +} diff --git a/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al new file mode 100644 index 00000000..fc1bec4c --- /dev/null +++ b/src/ALCops.ApplicationCop.Test/Rules/CaptionRequired/NoDiagnostic/HeadlinePartPageExtension.al @@ -0,0 +1,29 @@ +pageextension 50100 MyHeadlineExtension extends HeadlinePartPage +{ + layout + { + addlast(Content) + { + field([|MyExtensionHeadlineField|]; MyExtensionHeadlineText) + { + ApplicationArea = All; + } + } + } + + var + MyExtensionHeadlineText: Text; +} + +page 50100 HeadlinePartPage +{ + PageType = HeadlinePart; + Caption = 'Headline'; + + layout + { + area(Content) + { + } + } +} diff --git a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs index 6339820b..0f415211 100644 --- a/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs +++ b/src/ALCops.ApplicationCop/Analyzers/CaptionRequired.cs @@ -45,6 +45,12 @@ private void CheckForMissingCaptions(SymbolAnalysisContext context) switch (Control.ControlKind) { case var _ when Control.ControlKind == EnumProvider.ControlKind.Field: + // The Caption property is ignored by the runtime for field controls on HeadlinePart pages; + // only Expression, Visible, ApplicationArea, Drilldown and DrillDownPageID apply there. + // https://learn.microsoft.com/en-us/dynamics365/business-central/dev-itpro/developer/devenv-create-role-center-headline#in-development + if (IsInHeadlinePartPage(context)) + break; + if (CaptionIsMissing(context.Symbol, context)) if (Control.RelatedFieldSymbol is not null) { @@ -190,6 +196,18 @@ private static bool IsInApiPage(SymbolAnalysisContext context) return ((IPageTypeSymbol)containingObject).PageType == EnumProvider.PageTypeKind.API; } + private static bool IsInHeadlinePartPage(SymbolAnalysisContext context) + { + IPageBaseTypeSymbol? pageBase = context.Symbol.GetContainingObjectTypeSymbol() switch + { + IPageTypeSymbol page => page, + IApplicationObjectExtensionTypeSymbol ext => ext.Target?.OriginalDefinition as IPageBaseTypeSymbol, + _ => null + }; + + return pageBase?.PageType == EnumProvider.PageTypeKind.HeadlinePart; + } + private void RaiseDiagnostic(SymbolAnalysisContext context) { context.ReportDiagnostic(Diagnostic.Create( diff --git a/src/ALCops.Common/Reflection/EnumProvider.cs b/src/ALCops.Common/Reflection/EnumProvider.cs index a23f0a01..5f73ad96 100644 --- a/src/ALCops.Common/Reflection/EnumProvider.cs +++ b/src/ALCops.Common/Reflection/EnumProvider.cs @@ -555,6 +555,8 @@ public static class PageTypeKind new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Card))); private static readonly Lazy _document = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.Document))); + private static readonly Lazy _headlinePart = + new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.HeadlinePart))); private static readonly Lazy _list = new(() => ParseEnum(nameof(NavCodeAnalysis.PageTypeKind.List))); private static readonly Lazy _listPart = @@ -567,6 +569,7 @@ public static class PageTypeKind public static NavCodeAnalysis.PageTypeKind API => _api.Value; public static NavCodeAnalysis.PageTypeKind Card => _card.Value; public static NavCodeAnalysis.PageTypeKind Document => _document.Value; + public static NavCodeAnalysis.PageTypeKind HeadlinePart => _headlinePart.Value; public static NavCodeAnalysis.PageTypeKind List => _list.Value; public static NavCodeAnalysis.PageTypeKind ListPart => _listPart.Value; public static NavCodeAnalysis.PageTypeKind ListPlus => _listPlus.Value;