Add UI knowledge: notification recall Id and Page Management document routing

Two ui articles with compiled good/bad samples:
- notification-recall-needs-known-id: a notification that the code also
  recalls needs a fixed Id (single instance; recall before re-send) or
  per-record tracking through codeunit "Notification Lifecycle Mgt."
  (SendNotification[WithAdditionalContext] / RecallNotificationsForRecord,
  HandleDelayedInsert semantics). Never-recalled notifications are exempt.
- list-page-document-routing-uses-page-management: open documents from a
  mixed-type list with PageManagement.PageRun(Rec) instead of a hand-written
  case "Document Type" / Page.Run; register new tables through
  OnConditionalCardPageIDNotFound. Single-type opens and tables Page
  Management does not route are exempt.

Wired into al-ui-review (entry gate now covers notification send/recall
outside pages, tokens, high-signal mappings) and registered both pairs in
the ui review-fixtures override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Michael Dieringer 2026-10-03 12:47:57 +02:00
parent ac249ba4c9
commit 750466c7f3
8 changed files with 227 additions and 6 deletions

View file

@ -0,0 +1,52 @@
page 50730 "Sample Open Sales Docs"
{
PageType = List;
SourceTable = "Sales Header";
Editable = false;
ApplicationArea = Basic, Suite;
UsageCategory = Lists;
Caption = 'Sample Open Sales Documents';
layout
{
area(Content)
{
repeater(Documents)
{
field("Document Type"; Rec."Document Type") { }
field("No."; Rec."No.") { }
field("Sell-to Customer Name"; Rec."Sell-to Customer Name") { }
}
}
}
actions
{
area(Processing)
{
action(ShowDocument)
{
Caption = 'Show Document';
Image = EditLines;
ShortCutKey = 'Return';
ToolTip = 'Open the selected sales document.';
trigger OnAction()
begin
// Copies the Sales Header mapping that Page Management
// already holds; Blanket Order and Return Order rows open nothing.
case Rec."Document Type" of
Rec."Document Type"::Quote:
Page.Run(Page::"Sales Quote", Rec);
Rec."Document Type"::Order:
Page.Run(Page::"Sales Order", Rec);
Rec."Document Type"::Invoice:
Page.Run(Page::"Sales Invoice", Rec);
Rec."Document Type"::"Credit Memo":
Page.Run(Page::"Sales Credit Memo", Rec);
end;
end;
}
}
}
}

View file

@ -0,0 +1,44 @@
page 50730 "Sample Open Sales Docs"
{
PageType = List;
SourceTable = "Sales Header";
Editable = false;
ApplicationArea = Basic, Suite;
UsageCategory = Lists;
Caption = 'Sample Open Sales Documents';
layout
{
area(Content)
{
repeater(Documents)
{
field("Document Type"; Rec."Document Type") { }
field("No."; Rec."No.") { }
field("Sell-to Customer Name"; Rec."Sell-to Customer Name") { }
}
}
}
actions
{
area(Processing)
{
action(ShowDocument)
{
Caption = 'Show Document';
Image = EditLines;
ShortCutKey = 'Return';
ToolTip = 'Open the selected sales document.';
trigger OnAction()
var
PageManagement: Codeunit "Page Management";
begin
// Page Management picks the page for each Document Type.
PageManagement.PageRun(Rec);
end;
}
}
}
}

View file

@ -0,0 +1,38 @@
---
bc-version: [all]
domain: ui
keywords: [page-management, pagerun, show-document, document-type, cardpageid, list-page, page-run, getconditionalcardpageid, onconditionalcardpageidnotfound]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Open documents from a mixed-type list through Page Management
## Description
Some tables back several document pages, chosen by a type field. `Sales Header` rows open as Sales Quote, Sales Order, Sales Invoice, Sales Credit Memo, Blanket Sales Order, or Sales Return Order, depending on `Document Type`. A list over such a table cannot use one `CardPageID`. Codeunit 700 `"Page Management"` already holds that mapping. `PageRun(Rec)` resolves the page through `GetPageID`. That calls `GetConditionalCardPageID`, which handles `Sales Header`, `Purchase Header`, their archives, general and item journal batches and lines, requisition worksheets, and several other tables. When no conditional page applies, it falls back to the default card or lookup page. Base App's own lists call it: the `Show Document` actions on `Sales List` and `Purchase List`, `Sales Lines` (after getting the header), and `Navigate` for posted documents.
A hand-written `case Rec."Document Type" of ... Page.Run(Page::"Sales Order", Rec)` copies that mapping into a single action. The copy misses document types added later. It also bypasses routing that other extensions add through Page Management's events (`OnBeforeGetConditionalCardPageID`, `OnAfterGetPageID`, `OnPageRunAtFieldOnBeforeRunPage`).
## Best Practice
In the list's open-document action, call `PageManagement.PageRun(Rec)`, or `PageRunModal` or `PageRunList` as needed. `PageRun` returns `false` without opening anything when `GuiAllowed` is false or no page resolves. See sample: [`list-page-document-routing-uses-page-management.good.al`](list-page-document-routing-uses-page-management.good.al).
For a new table whose rows map to different pages, register the mapping once and then use `PageRun` everywhere. Subscribe to `OnConditionalCardPageIDNotFound`, which is raised only for tables the codeunit does not route itself. Microsoft's IRS Forms and Sustainability apps register their tables this way. `OnBeforeGetConditionalCardPageID` is the `IsHandled` alternative, used by the Quality Management app.
## Anti Pattern
An action trigger that switches on `Document Type`, or a similar type field, of a table that Page Management already routes, and calls `Page.Run(Page::..., Rec)` in each branch. Base App still has a few of these, for example `Sales Line Archive List`. The result is a duplicated mapping, not a runtime error, so report it as minor. See sample: [`list-page-document-routing-uses-page-management.bad.al`](list-page-document-routing-uses-page-management.bad.al).
Not this pattern:
- Opening one known document type directly. `Opportunity` creates a quote and runs `Sales Quote`, and a single-type list such as `Sales Order List` sets `CardPageID = "Sales Order"`.
- A table that Page Management does not route. `Assembly List` switches on `Assembly Header."Document Type"` itself. Registering the table through `OnConditionalCardPageIDNotFound` is an improvement there, not a defect fix.
## References
- [PageManagement.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Utilities/PageManagement.Codeunit.al): `PageRun` (lines 44-47), `PageRunAtField` with the `GuiAllowed` exit (75-100), `GetPageID` fallback order (112-138), `GetConditionalCardPageID` (194-263; unrouted tables raise `OnConditionalCardPageIDNotFound` at 258), `GetSalesHeaderPageID` (289-315), integration events (671-719).
- Callers: [SalesList.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesList.Page.al) (`ShowDocument`, lines 188-202; no `CardPageID`), [PurchaseList.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Purchases/Document/PurchaseList.Page.al) (line 200), [SalesLines.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesLines.Page.al) (lines 224-230), [Navigate.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Foundation/Navigate/Navigate.Page.al) (from line 1564).
- Subscribers: [IRS1099BaseAppSubscribers.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/US/IRSForms/app/src/Extensions/IRS1099BaseAppSubscribers.Codeunit.al) (lines 149-156), [SustWorkflowEventHandling.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/Sustainability/app/src/Workflow/SustWorkflowEventHandling.Codeunit.al) (lines 184-193), [QltyUtilitiesIntegration.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Apps/W1/Quality%20Management/app/src/Integration/Utilities/QltyUtilitiesIntegration.Codeunit.al) (lines 20-28).
- Counterexamples: [SalesLineArchiveList.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Archive/SalesLineArchiveList.Page.al) (lines 106-121), [AssemblyList.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Assembly/Document/AssemblyList.Page.al) (lines 111-121), [Opportunity.Table.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/CRM/Opportunity/Opportunity.Table.al) (lines 1221-1223), [SalesOrderList.Page.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Sales/Document/SalesOrderList.Page.al) (line 44).

View file

@ -0,0 +1,20 @@
pageextension 50720 "Sample Customer Card Ext" extends "Customer Card"
{
trigger OnAfterGetCurrRecord()
var
NoCreditLimitNotification: Notification;
begin
if Rec."Credit Limit (LCY)" = 0 then begin
// No Id is assigned: Send assigns one that this code never keeps.
NoCreditLimitNotification.Message := NoCreditLimitMsg;
NoCreditLimitNotification.Scope := NotificationScope::LocalScope;
NoCreditLimitNotification.Send();
end else
// This new variable has no Id, so the warning sent for the
// previous customer is not withdrawn.
NoCreditLimitNotification.Recall();
end;
var
NoCreditLimitMsg: Label 'This customer has no credit limit.';
}

View file

@ -0,0 +1,24 @@
pageextension 50720 "Sample Customer Card Ext" extends "Customer Card"
{
trigger OnAfterGetCurrRecord()
var
NoCreditLimitNotification: Notification;
begin
// A fixed Id lets this code recall the notification it sent earlier.
NoCreditLimitNotification.Id := GetNoCreditLimitNotificationId();
NoCreditLimitNotification.Recall();
if Rec."Credit Limit (LCY)" = 0 then begin
NoCreditLimitNotification.Message := NoCreditLimitMsg;
NoCreditLimitNotification.Scope := NotificationScope::LocalScope;
NoCreditLimitNotification.Send();
end;
end;
local procedure GetNoCreditLimitNotificationId(): Guid
begin
exit('6f0c2b8e-4a1d-4f7e-9b3a-2d5e8c1f7a40');
end;
var
NoCreditLimitMsg: Label 'This customer has no credit limit.';
}

View file

@ -0,0 +1,39 @@
---
bc-version: [all]
domain: ui
keywords: [notification, recall, notification-id, createguid, notification-lifecycle-mgt, sendnotification, sendnotificationwithadditionalcontext, recallnotificationsforrecord, handledelayedinsert, notification-context]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A notification that must be recalled needs an Id the code can find again
## Description
`Notification.Recall()` withdraws the notification whose `Id` it carries. When `Id` is left unassigned, `Send()` assigns one. A later `Recall()` on a new `Notification` variable has no way to name that Id, so a warning sent that way cannot be withdrawn when its condition clears. It stays until the user dismisses it or the page instance closes. Microsoft Learn's own `Id`/`Recall` example uses a predefined Id "so that the notification can be recalled". `Recall()` does not fail on a notification that was never sent or was already recalled, so code with a known Id can recall unconditionally.
Which Id is right depends on how many instances can be shown at once:
- **At most one at a time** (one condition per page or task): a fixed GUID, returned from a procedure or assigned as a literal. Base App's `Analysis View.ShowResetNeededNotification` assigns a literal Id, calls `Recall()`, then sets the message and calls `Send()`. Learn does not document what `Send()` does when a notification with the same Id is already displayed, so recall first rather than relying on `Send()` to replace it.
- **One per record** (for example one warning per document line): a single fixed Id cannot tell the records apart. Use codeunit 1511 `"Notification Lifecycle Mgt."`. `SendNotification(Notification, RecId)` assigns `CreateGuid()` when `Id` is null, sends, and stores the Id against the `RecordId` in the temporary table `"Notification Context"`. `RecallNotificationsForRecord(RecId, HandleDelayedInsert)` recalls every tracked notification for that record. When one record can carry several independent warnings, pass a fixed GUID per reason to `SendNotificationWithAdditionalContext` and `RecallNotificationsForRecordWithAdditionalContext`. `Item-Check Avail.` does this: a `CreateGuid()` Id per notification, its fixed availability GUID as the additional context.
## Best Practice
Assign a fixed `Id` to any single-instance notification that the same code path can also withdraw, and recall it with that Id before re-sending updated content. See sample: [`notification-recall-needs-known-id.good.al`](notification-recall-needs-known-id.good.al).
For per-record notifications, send and recall through `"Notification Lifecycle Mgt."` instead of calling `Send()`/`Recall()` directly. The codeunit is `SingleInstance`, so tracking lasts for the session. While a record does not exist yet, its notification is stored under the table's empty `RecordId`. Pass `HandleDelayedInsert = true` when recalling for a record that may not be inserted yet, and `false` when recalling after the record is deleted, as Base App's own delete subscribers do. Base App's `"Notification Lifecycle Handler"` (codeunit 1508) moves tracked notifications on insert and rename, and recalls them on delete, only for the Base App tables it subscribes to, such as `Sales Line`. For another table, call `SetRecordID`, `UpdateRecordID`, and `RecallNotificationsForRecord` from that table's own insert, rename, and delete paths.
## Anti Pattern
Code that both sends and recalls a notification, for example `Send()` when a condition holds and `Recall()` in the `else` branch or when the condition clears, but never assigns `Id`, or assigns a fresh `CreateGuid()` and calls `Send()`/`Recall()` directly. The `Recall()` cannot reach the notification that was sent. A single fixed Id shared by notifications for several records, sent and recalled directly, is the per-record form of the same mistake. See sample: [`notification-recall-needs-known-id.bad.al`](notification-recall-needs-known-id.bad.al).
Not this pattern: a one-off informational notification that the code never recalls, which Learn's own Sales Order example sends without an Id; and a notification sent through `"Notification Lifecycle Mgt."` without an Id, because the codeunit assigns and tracks one.
## References
- [Notification.Id method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/notification/notification-id-method): an unassigned Id is assigned at `Send()`; the example sets a predefined Id so the notification can be recalled.
- [Notification.Recall method](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/methods-auto/notification/notification-recall-method): recalling more than once, or before sending, does not fail.
- [Using nonintrusive notifications](https://learn.microsoft.com/dynamics365/business-central/dev-itpro/developer/devenv-notifications-developing): notifications remain for the page instance or until dismissed.
- [NotificationLifecycleMgt.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Modules/System/Notifications/NotificationLifecycleMgt.Codeunit.al): `SendNotification` and `SendNotificationWithAdditionalContext` (lines 17-36), `RecallNotificationsForRecord` (38-44), `GetUsableRecordId` (177-191). [NotificationLifecycleHandler.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/System/Notifications/NotificationLifecycleHandler.Codeunit.al): `Sales Line` insert, rename, and delete subscribers (lines 27-52).
- Base App usage: [AnalysisView.Table.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Finance/Analysis/AnalysisView.Table.al) (`ShowResetNeededNotification`, lines 1039-1051) and [ItemCheckAvail.Codeunit.al](https://github.com/microsoft/BCApps/blob/main/src/Layers/W1/BaseApp/Inventory/Availability/ItemCheckAvail.Codeunit.al) (recall at lines 89-90, `CreateGuid()` Id and send at 636-646).