mirror of
https://github.com/microsoft/BCQuality.git
synced 2026-10-05 06:36:55 +01:00
Add AL-focused AppSource validation guidance (#142)
* knowledge(appsource): add AL validation guidance * Address Marketplace review feedback * Address remaining Marketplace review feedback * Align AppSource review applicability outcome
This commit is contained in:
parent
35d0966a8d
commit
45ac371e7a
19 changed files with 322 additions and 4 deletions
|
|
@ -0,0 +1,12 @@
|
|||
codeunit 50100 "Rental Profile Install"
|
||||
{
|
||||
Subtype = Install;
|
||||
|
||||
trigger OnInstallAppPerDatabase()
|
||||
var
|
||||
RentalProfile: Record Profile;
|
||||
begin
|
||||
RentalProfile.Init();
|
||||
RentalProfile.Insert(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
profile "RENTAL MANAGER"
|
||||
{
|
||||
Caption = 'Rental Manager';
|
||||
Description = 'Manages rental agreements and equipment availability.';
|
||||
RoleCenter = "Business Manager Role Center";
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [profile-object, profile-table, install-codeunit, role-center, page-customization]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Define profiles as AL objects
|
||||
|
||||
## Description
|
||||
|
||||
Profiles delivered by a Marketplace extension must be declared as AL `profile` objects. A profile object is validated with its Role Center and page customizations when the extension is compiled and is registered through extension synchronization. Inserting profile-table records from install or setup code bypasses that object lifecycle.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Declare each app-owned profile with the `profile` object and set its `RoleCenter`, user-facing caption, and optional customizations in AL. Let installation and synchronization register the object.
|
||||
|
||||
See sample: [`define-profiles-as-al-objects.good.al`](define-profiles-as-al-objects.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Install, upgrade, or setup code that creates an app-owned profile by inserting a `Profile` table record. Detection signal: a `Record Profile` variable followed by `Insert` in profile provisioning code.
|
||||
|
||||
See sample: [`define-profiles-as-al-objects.bad.al`](define-profiles-as-al-objects.bad.al).
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Audit"
|
||||
{
|
||||
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
|
||||
begin
|
||||
RentalAgreement."Created At" := CurrentDateTime() + 7200000;
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Audit"
|
||||
{
|
||||
procedure SetCreatedAt(var RentalAgreement: Record "Rental Agreement")
|
||||
begin
|
||||
RentalAgreement."Created At" := CurrentDateTime();
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [datetime, time-zone, utc, currentdatetime, locale, regional-settings]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Do not hard-code time-zone offsets
|
||||
|
||||
## Description
|
||||
|
||||
Marketplace extensions run for users and services in many time zones. Adding a fixed offset to a `DateTime` assumes one locale, ignores daylight-saving transitions, and changes an absolute timestamp into an incorrect value for other regions.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Store and compare `DateTime` values without a manually applied regional offset. Business Central stores `DateTime` values in UTC and presents them according to the client time zone. Keep service contracts time-zone explicit and perform a conversion only when the business requirement identifies a particular zone.
|
||||
|
||||
See sample: [`do-not-hard-code-time-zone-offsets.good.al`](do-not-hard-code-time-zone-offsets.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Adding or subtracting a fixed duration solely to convert `CurrentDateTime` or another timestamp to an assumed local time. Detection signals include fixed hour-sized millisecond values near `DateTime` assignments and comments naming a specific time zone; confirm the duration is an offset rather than a legitimate deadline or schedule interval.
|
||||
|
||||
See sample: [`do-not-hard-code-time-zone-offsets.bad.al`](do-not-hard-code-time-zone-offsets.bad.al).
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
codeunit 50100 "Rental Service"
|
||||
{
|
||||
[ServiceEnabled]
|
||||
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
|
||||
var
|
||||
RentalAgreement: Record "Rental Agreement";
|
||||
begin
|
||||
if not Confirm(CloseAgreementQst, false, AgreementNo) then
|
||||
exit(false);
|
||||
|
||||
RentalAgreement.Get(AgreementNo);
|
||||
RentalAgreement.Closed := true;
|
||||
RentalAgreement.Modify(true);
|
||||
Message(AgreementClosedMsg, AgreementNo);
|
||||
exit(true);
|
||||
end;
|
||||
|
||||
var
|
||||
CloseAgreementQst: Label 'Close rental agreement %1?';
|
||||
AgreementClosedMsg: Label 'Rental agreement %1 was closed.';
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
codeunit 50100 "Rental Service"
|
||||
{
|
||||
[ServiceEnabled]
|
||||
procedure CloseAgreement(AgreementNo: Code[20]): Boolean
|
||||
var
|
||||
RentalAgreement: Record "Rental Agreement";
|
||||
begin
|
||||
if not RentalAgreement.Get(AgreementNo) then
|
||||
exit(false);
|
||||
|
||||
RentalAgreement.Closed := true;
|
||||
RentalAgreement.Modify(true);
|
||||
exit(true);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [web-service, serviceenabled, guiallowed, message, confirm, strmenu]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Keep web-service paths free of UI calls
|
||||
|
||||
## Description
|
||||
|
||||
Pages and codeunits exposed as web services run without an interactive client. Calls that require a UI callback, including `Confirm`, `StrMenu`, and modal pages, can terminate the service request instead of completing the operation. `Message` does not raise the callback error: the message is suppressed and logged, making it ineffective for communicating a service result.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Keep service entry points and every procedure they call free of interactive UI. Return data through the service contract and report validation failures with service-safe error handling. When a procedure is shared with an interactive client, guard UI-only behavior with `GuiAllowed` while preserving the underlying operation.
|
||||
|
||||
See sample: [`keep-web-service-paths-free-of-ui-calls.good.al`](keep-web-service-paths-free-of-ui-calls.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A web-service-exposed page or codeunit calls an interactive UI method directly or indirectly. Detection signals include `Message`, `Confirm`, `StrMenu`, `Page.RunModal`, and confirmation-dialog pages on a service call path. Treat `Message` as suppressed and ineffective, not as a callback failure. Do not flag a controlled `Error` solely because it returns a service fault.
|
||||
|
||||
See sample: [`keep-web-service-paths-free-of-ui-calls.bad.al`](keep-web-service-paths-free-of-ui-calls.bad.al).
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
pageextension 50100 "Rental Customer List" extends "Customer List"
|
||||
{
|
||||
actions
|
||||
{
|
||||
addafter("Customer Ledger Entries")
|
||||
{
|
||||
action(OpenRentalAgreements)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Rental Agreements';
|
||||
RunObject = page "Rental Agreement List";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
pageextension 50100 "Rental Customer List" extends "Customer List"
|
||||
{
|
||||
actions
|
||||
{
|
||||
addlast(Processing)
|
||||
{
|
||||
action(OpenRentalAgreements)
|
||||
{
|
||||
ApplicationArea = All;
|
||||
Caption = 'Rental Agreements';
|
||||
RunObject = page "Rental Agreement List";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [pageextension, actions, addfirst, addlast, addbefore, addafter]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Place page extension actions with addfirst or addlast
|
||||
|
||||
## Description
|
||||
|
||||
Place new page-extension actions at the beginning or end of an existing action group with `addfirst` or `addlast`. Anchoring a new action relative to a specific base-app action with `addbefore` or `addafter` couples the extension to an implementation detail that can move or disappear between Business Central releases.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Choose the semantic action area or group and append or prepend the extension's actions. This keeps placement deterministic without depending on the continued existence of one neighboring action.
|
||||
|
||||
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.good.al`](place-page-extension-actions-with-addfirst-or-addlast.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Using `addbefore` or `addafter` to place newly added actions next to a specific action from another app. The syntax is valid AL, but the placement anchor is brittle for a Marketplace extension.
|
||||
|
||||
See sample: [`place-page-extension-actions-with-addfirst-or-addlast.bad.al`](place-page-extension-actions-with-addfirst-or-addlast.bad.al).
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
page 50100 "Rental Agreement List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Rental Agreement";
|
||||
ApplicationArea = All;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Agreements)
|
||||
{
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
page 50100 "Rental Agreement List"
|
||||
{
|
||||
PageType = List;
|
||||
SourceTable = "Rental Agreement";
|
||||
ApplicationArea = All;
|
||||
UsageCategory = Lists;
|
||||
|
||||
layout
|
||||
{
|
||||
area(Content)
|
||||
{
|
||||
repeater(Agreements)
|
||||
{
|
||||
field("No."; Rec."No.")
|
||||
{
|
||||
ApplicationArea = All;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [usagecategory, tell-me, search, page, report, discoverability]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Set UsageCategory on searchable entry points
|
||||
|
||||
## Description
|
||||
|
||||
Pages and reports that users are expected to open directly must set `UsageCategory`. Without it, the object is absent from Tell Me and users cannot bookmark it from the web client. Supporting objects such as list parts, dialogs, API pages, and objects reached only through another page do not need to be searchable entry points.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Set `UsageCategory` to the category that matches the entry point, such as `Lists`, `Tasks`, `ReportsAndAnalysis`, or `Documents`. Also set the appropriate object-level `ApplicationArea` so search results respect feature visibility.
|
||||
|
||||
See sample: [`set-usagecategory-on-searchable-entry-points.good.al`](set-usagecategory-on-searchable-entry-points.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
A user-facing page or report intended for direct discovery omits `UsageCategory` or sets it to `None`. Do not infer intent from the object type alone; require evidence that the object is a direct user entry point.
|
||||
|
||||
See sample: [`set-usagecategory-on-searchable-entry-points.bad.al`](set-usagecategory-on-searchable-entry-points.bad.al).
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
codeunit 50100 "Rental Period Defaults"
|
||||
{
|
||||
procedure GetPolicyStartDate(): Date
|
||||
var
|
||||
PolicyStartDate: Date;
|
||||
begin
|
||||
Evaluate(PolicyStartDate, '01/31/2025');
|
||||
exit(PolicyStartDate);
|
||||
end;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
codeunit 50100 "Rental Period Defaults"
|
||||
{
|
||||
procedure GetPolicyStartDate(): Date
|
||||
begin
|
||||
exit(20250131D);
|
||||
end;
|
||||
}
|
||||
26
community/knowledge/appsource/use-invariant-date-literals.md
Normal file
26
community/knowledge/appsource/use-invariant-date-literals.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
bc-version: [all]
|
||||
domain: appsource
|
||||
keywords: [date-literal, invariant-date, dateformula, localization, appsourcecop]
|
||||
technologies: [al]
|
||||
countries: [w1]
|
||||
application-area: [all]
|
||||
---
|
||||
|
||||
# Use invariant date literals
|
||||
|
||||
## Description
|
||||
|
||||
Write fixed dates in AL with the invariant `yyyymmddD` syntax. A locale-dependent text value parsed with `Evaluate` can change meaning or fail under another user's regional settings, which makes the Marketplace extension unreliable across markets.
|
||||
|
||||
## Best Practice
|
||||
|
||||
Represent a fixed date directly as an AL date literal, such as `20250131D`. Use `CalcDate` with a date formula when the value is relative rather than fixed.
|
||||
|
||||
See sample: [`use-invariant-date-literals.good.al`](use-invariant-date-literals.good.al).
|
||||
|
||||
## Anti Pattern
|
||||
|
||||
Building a fixed date by passing localized text such as `01/02/2025` to `Evaluate`. Detection signal: `Evaluate` converting a hard-coded or label-backed formatted string into a `Date`.
|
||||
|
||||
See sample: [`use-invariant-date-literals.bad.al`](use-invariant-date-literals.bad.al).
|
||||
Loading…
Add table
Add a link
Reference in a new issue