URL Parameters And Values
Finding the Right Business IT Solution for Fast Track Growth
Extension capabilities of AX 7 RTW – Security Duties
High Level Overview of Microsoft Dynamics AX 2012 Licensing and Security Architecture
Extension capabilities of AX 7 RTW – Security Roles
Understanding Address Framework Technically
Table | Description |
DirPartyTable | Global address book. This will contain entries for all people and organizations |
you deal with, including customers, suppliers, employees, etc. | |
This information is maintained across the entire organization. NB the table structure often refers to address book entries as 'parties'. Generally other records (like customer, supplier, etc) will reference a record in this table by a field named Party. | |
LogisticsLocation | This is a single 'location' that can be attached to one or more address book entries. This is similar in principle to the old 'Address' table from Ax2009, that no longer exists - The main difference now being that the location header always points to an address book entry, whereas in 2009 the Address table could point to anything. |
Note that this is not an address - Physical address details are stored in | |
LogisticsPostalAddress | |
LogisticsPostalAddress | A postal address, linked to a LogisticsLocation record via field Location. |
LogisticsElectronicAddress | 'Electronic' address details, such as email, phone, web address etc. |
Each different type of address is represented as a separate record, delineated by 'Type'. This links to the location record. | |
DirPartyLocation | This table links entries in the LogisticsLocation table to an address book entry (DirPartyTable). |
LogisticsLocationRole | This defines types of roles that an address are classified as, such as "Delivery", "Invoice", etc. |
DirPartyLocationRole | Links a location role type (LogisticsLocationRole) and an address book entry (DirPartyTable) |
DirPartyPostalAddressView (view) | This is a view that collates address book entries with their linked postal adresses |
static void AddressJob(Args _args) // X++ job. { // Declare variables: views. DirPartyContactInfoView contactInfo; DirPartyPostalAddressView postalAddress; // Declare variables: tables. ContactPerson contactperson; DirPartyTable partyTable = DirPartyTable::findByName("Contoso", DirPartyType::Organization); LogisticsPostalAddress logisticsPostalAddressInfo; // Declare variables: classes. ContactPersonEntity contactPersonEntity; LogisticsLocationEntity entity; // Declare variables: extended data types. Phone phone; // Declare variables: primitives. str firstName; str middleName; str lastName; // Create and populate the contact person. contactPersonEntity = ContactPersonEntity::construct(contactPerson); contactPersonEntity.parmFirstName('Contact'); contactPersonEntity.parmMiddleName('M.'); contactPersonEntity.parmLastName('Person'); contactPersonEntity.parmAssistantName('AssistantName'); contactPersonEntity.parmBillingInformation('Billing info'); contactPersonEntity.parmCharacter('Character description'); contactPersonEntity.parmComputerNetworkName('Computer network name'); contactPersonEntity.parmContactForParty(partyTable.RecId); contactPersonEntity.parmContactMemo('Memo'); contactPersonEntity.parmContactPersonId('CP61'); contactPersonEntity.parmLoyalty('Loyalty'); contactPersonEntity.parmMileage('Mileage'); contactPersonEntity.parmOfficeLocation('Office location'); contactPersonEntity.parmOutlookCategories('Outlook categories'); contactPersonEntity.parmProfession('Profession'); contactPersonEntity.parmSensitivity(smmSensitivity::Personal); contactPersonEntity.parmSpouse('Spouse'); contactPersonEntity.parmTimeAvailableFrom(1000); contactPersonEntity.parmTimeAvailableTo(2000); contactPersonEntity.write(); // Populate the postal address information by using the view. postalAddress.Street = 'One Microsoft Way'; postalAddress.City = 'Redmond'; postalAddress.State = 'WA'; postalAddress.ZipCode = '98052'; postalAddress.CountryRegionId = 'US'; // Update the postal address information. contactPersonEntity.createOrUpdatePostalAddress(postalAddress); // Populate the contact information by using the view. contactInfo.Locator = '555-555-5555'; contactInfo.Type = LogisticsElectronicAddressMethodType::Phone; contactInfo.IsPrimary = true; // Update the contact information. contactPersonEntity.createOrUpdateContactInfo(contactInfo); // Verify that the data was stored correctly. firstName = contactPersonEntity.parmFirstName(); middleName = contactPersonEntity.parmMiddleName(); lastName = contactPersonEntity.parmLastName(); logisticsPostalAddressInfo = entity.getPostalAddress(); phone = contactPersonEntity.getPrimaryElectronicAddressLocation().getPhone(); info(firstName + " " + middleName + " " + LastName + " is located at " + logisticsPostalAddressInfo.StreetNumber + " " + logisticsPostalAddressInfo.Street + ", " + logisticsPostalAddressInfo.City + ", " + logisticsPostalAddressInfo.State + " " + logisticsPostalAddressInfo.ZipCode + ". They can be contacted at " + phone + "."); } |
static void CustomerAddressBook (Args _args) { CustTable custTable; DirPartyTable dirPartyTable; DirPartyLocation partyLocation; LogisticsLocation logisticsLocation; LogisticsPostalAddress postalAddress; ; custTable = custTable::find('Test1001'); // Customer account id dirPartyTable = dirPartyTable::findRec(custTable.Party); while select partyLocation where partyLocation.Party == dirPartyTable.RecId { logisticsLocation = logisticsLocation::find(partyLocation.Location); if(logisticsLocation.IsPostalAddress) { postalAddress = LogisticsPostalAddress::findByLocation(logisticsLocation.RecId); info(strFmt("%1 - %2", logisticsLocation.Description, postalAddress.CountryRegionId)); } } } |
static void CustomerEmailAddresses(Args _args) { CustTable custTable; DirPartyTable dirPartyTable; DirPartyLocation partyLocation; LogisticsLocation logisticsLocation; LogisticsElectronicAddress electronicAddress; ; custTable = custTable::find('Test1001'); // Customer account id dirPartyTable = dirPartyTable::findRec(custTable.Party); while select partyLocation where partyLocation.Party == dirPartyTable.RecId { logisticsLocation = logisticsLocation::find(partyLocation.Location); while select electronicAddress where electronicAddress.Location == logisticsLocation.RecId && electronicAddress.Type == LogisticsElectronicAddressMethodType::Email { info(strFmt("%1",electronicAddress.Locator)); } } } |
static void PhoneNumbersAttachedToWarehouse(Args _args) { InventLocation inventLocation; LogisticsEntityPostalAddressView postalAddressView; LogisticsElectronicAddress elecAddress; LogisticsLocation contactLocation; inventLocation = inventLocation::find('NB'); if(inventLocation) { while select postalAddressView where postalAddressView.Entity == inventLocation.RecId && postalAddressView.EntityType == LogisticsLocationEntityType::Warehouse { while select elecAddress where elecAddress.Type == LogisticsElectronicAddressMethodType::Phone join contactLocation where contactLocation.ParentLocation == postalAddressView.Location && contactLocation.RecId == elecAddress.Location { info(elecAddress.Locator); } } } } |
publicstatic LogisticsPostalAddress retrieveMatchingPostalAddress( Description _locationName, LogisticsAddressStreet _street, LogisticsAddressCity _city, LogisticsAddressCountyId _county, LogisticsAddressZipCodeId _zipCode, LogisticsAddressStateId _state, LogisticsAddressCountryRegionId _countryRegionId, LogisticsPostalAddressRecId _originalPostalAddress = 0 ) { LogisticsPostalAddress ret; LogisticsPostalAddressEntity postalAddressEntity = new LogisticsPostalAddressEntity(); LogisticsPostalAddressView postalAddressView; LogisticsPostalAddress originalPostalAddress; LogisticsLocation originalLocation; boolean createAddress = false; if (_originalPostalAddress != 0) { originalPostalAddress = LogisticsPostalAddress::findRecId(_originalPostalAddress); originalLocation = LogisticsLocation::find(originalPostalAddress.Location); if (originalLocation.Description == _locationName && originalPostalAddress.Street == _street && originalPostalAddress.City == _city && originalPostalAddress.ZipCode == _zipCode && originalPostalAddress.State == _state && originalPostalAddress.County == _county && originalPostalAddress.CountryRegionId == _countryRegionId) { ret = originalPostalAddress; } else { createAddress = true; } } else { createAddress = true; } if (createAddress) { postalAddressView.LocationName = _locationName; postalAddressView.Street = _street; postalAddressView.City = _city; postalAddressView.ZipCode = _zipCode; postalAddressView.State = _state; postalAddressView.County = _county; postalAddressView.CountryRegionId = _countryRegionId; ret = postalAddressEntity.createPostalAddress(postalAddressView); } return ret; } |
UtilElements in AX 7
In older versions of Dynamics AX, you can get information about AX application objects (metadata) through system “tables” such as UtilElements. This doesn’t work anymore in AX 7. These tables still exist, but they don’t have any data, therefore you have to migrate to another solution.
AX 7 comes with a rich framework for metadata, implemented in several assemblies in namespace Microsoft.Dynamics.AX.Metadata. But it’s too complex for simple tasks – you can make it much simpler by using MetadataSupport class. For example, the following piece of code iterates through all form names:
var forms = Microsoft.Dynamics.Ax.Xpp.MetadataSupport::FormNames(); while(forms.MoveNext()){print forms.Current; }
This is even easier than before!
Expect Deep-Dive Learning at AXUG Focus 2016
Accrual amount in Accrued purchases report when discount is applied
This is about the following report – Procurement and Sourcing / Reports / Status / Accrued purchases.
In AX2012 (companing with AX2009) we’ve made a performance improvement of the report by changing it’s datasource. But if you use discounts the report might show you a wrong value. Below you will find a way to resolve it.
Scenario. In case you apply a discount to a purchase order and then post a packing slip, the amount of the packign slip’s Purchase, accrual is the amount before discount (for example, 1000), but the report will show you the amounts after discount (for example, 900). So there’s a discrepancy between the report and the real posting.
Below is an illustration of the code that will resolve the issue, but the report may work a bit slower. The new code lines are marked in yellow.
\Classes\VendAccruedPurchasesDP_NA\processReport
/// <summary>
/// Processes the SQL Server Reporting Services report business logic.
/// </summary>
/// <remarks>
/// This method provides the ability to write the report business logic. This method will be called by
/// SQL Server Reporting Services (SSRS) at run time. The method should compute data and populate the
/// data tables that will be returned to SSRS.
/// </remarks>
[SysEntryPointAttribute(false)]
publicvoid
processReport()
{
VendInvoiceTrans vendInvoiceTrans, vendInvoiceTransNotExists;
VendTable vendTable;
InventTransOrigin inventTransOrigin;
InventTrans inventTrans;
VendInvoicePackingSlipQuantityMatch qtyMatched, qtyMatchedNotExists;
VendAccruedPurchasesPartialInvoicedQty partialInvoiced, partialInvoicedBeforeCutOff;
this.getParametersFromContract();
this.processReportQuery();
if (physicalOnly)
{
this.buildPhysicalOnlyVendAccruedPurchases(true);
this.buildPhysicalOnlyVendAccruedPurchases(false);
}
else
{
this.buildVendAccruedPurchases();
}
// Now that all rows exist for the report, do set-based
// updates to fill in the remaining columns
// Remove the product receipt records that don’t have invoices and are after the cut-off date
delete_from vendAccruedPurchasesTmp_NA
where vendAccruedPurchasesTmp_NA.DatePhysical > cutOffDate
notexistsjoin qtyMatched where
qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine;
// Remove the records that have product receipts and invoices that are after the cut-off date and don’t have
// invoices that are prior to the cut-off date
delete_from vendAccruedPurchasesTmp_NA
where vendAccruedPurchasesTmp_NA.DatePhysical > cutOffDate
existsjoin qtyMatched where
qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
join vendInvoiceTrans where
vendInvoiceTrans.SourceDocumentLine == qtyMatched.InvoiceSourceDocumentLIne
&& vendInvoiceTrans.InvoiceDate > cutOffDate
notexistsjoin qtyMatchedNotExists where
qtyMatchedNotExists.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
join SourceDocumentLine, InvoiceDate from vendInvoiceTransNotExists where
vendInvoiceTransNotExists.SourceDocumentLine == qtyMatchedNotExists.InvoiceSourceDocumentLIne
&& vendInvoiceTransNotExists.InvoiceDate <= cutOffDate;
// Summation of partial invoiced quantity cannot be done in the
// original insert_recordset as it would change the cardinality of
// the rows in the report. Similarly, it can’t be done in a simple
// update_recordset because update_recordset does not support summation..
// Instead, do an insert_recordset with a sum into a staging table,
// and then update out of that staging table.
// At this point partialInvoiced may still contain quantities for the fully invoiced records.
// We will delete them from vendAccruedPurchasesTmp_NA in the next statement.
insert_recordset partialInvoiced (PackingSlipSourceDocumentLine, PartiallyInvoicedQuantity)
select PackingSlipSourceDocumentLine from vendAccruedPurchasesTmp_NA
groupby vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
where vendAccruedPurchasesTmp_NA.DatePhysical <= cutOffDate
joinsum(Quantity) from qtyMatched where
qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
existsjoin vendInvoiceTrans where
vendInvoiceTrans.SourceDocumentLine == qtyMatched.InvoiceSourceDocumentLIne
&& vendInvoiceTrans.InvoiceDate <= cutOffDate;
// Remove those records that have already been fully invoiced (Qty = InvoicedQty)
delete_from vendAccruedPurchasesTmp_NA
existsjoin partialInvoiced where
partialInvoiced.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
&& vendAccruedPurchasesTmp_NA.Qty == partialInvoiced.PartiallyInvoicedQuantity;
// Grab and sum up records that are invoiced prior to the cut-off date, but were received after the cut-off date
insert_recordset partialInvoicedBeforeCutOff (PackingSlipSourceDocumentLine, PartiallyInvoicedQuantity)
select PackingSlipSourceDocumentLine from vendAccruedPurchasesTmp_NA
groupby vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
where vendAccruedPurchasesTmp_NA.DatePhysical > cutOffDate
joinsum(Quantity) from qtyMatched where
qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
existsjoin vendInvoiceTrans where
vendInvoiceTrans.SourceDocumentLine == qtyMatched.InvoiceSourceDocumentLIne
&& vendInvoiceTrans.InvoiceDate <= cutOffDate;
// Date the invoice was posted
update_recordset vendAccruedPurchasesTmp_NA setting
DateFinancial = vendInvoiceTrans.InvoiceDate,
CostAmountPosted = (vendInvoiceTrans.LineAmountMST / vendInvoiceTrans.Qty) * vendAccruedPurchasesTmp_NA.Qty
join qtyMatched
where qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
joinmaxOf(InvoiceDate), LineAmountMST, Qty from vendInvoiceTrans
where vendInvoiceTrans.SourceDocumentLine == qtyMatched.InvoiceSourceDocumentLine;
// Vendor name
update_recordSet vendAccruedPurchasesTmp_NA setting
VendName = dirPartyTable.Name
join dirPartyTable
existsjoin vendTable where
vendTable.AccountNum == vendAccruedPurchasesTmp_NA.InvoiceAccount &&
vendTable.Party == dirPartyTable.RecId;
// Cost amount posted (has to be calculated *after* Qty is calculated for partial invoicing)
update_recordSet vendAccruedPurchasesTmp_NA setting
CostAmountPhysical = (vendAccruedPurchasesTmp_NA.ValueMST / vendAccruedPurchasesTmp_NA.ReceivedQuantity) * (vendAccruedPurchasesTmp_NA.ReceivedQuantity – partialInvoiced.PartiallyInvoicedQuantity),
CostAmountPosted = 0,
qty = (vendAccruedPurchasesTmp_NA.ReceivedQuantity – partialInvoiced.PartiallyInvoicedQuantity),
Voucher = ”
where vendAccruedPurchasesTmp_NA.ReceivedQuantity != 0
join partialInvoiced where
partialInvoiced.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine;
// Any packing slip that is prior to the cut-off date and has invoices that are only after the cut-off date should be updated.
// If there are invoices prior the cut-off date, then this update does not apply.
update_recordSet vendAccruedPurchasesTmp_NA setting
CostAmountPosted = 0
where vendAccruedPurchasesTmp_NA.ReceivedQuantity != 0
join qtyMatched where
qtyMatched.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine
join vendInvoiceTrans where
vendInvoiceTrans.SourceDocumentLine == qtyMatched.InvoiceSourceDocumentLIne
&& vendInvoiceTrans.InvoiceDate > cutOffDate
notExistsjoin partialInvoiced where
partialInvoiced.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine;
// Any packing slip that is prior to the cut-off date and has invoices that are before the cut-off date should be updated.
update_recordSet vendAccruedPurchasesTmp_NA setting
DateFinancial = dateNull()
where vendAccruedPurchasesTmp_NA.ReceivedQuantity != 0
join partialInvoiced where
partialInvoiced.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine;
// Update Costs and quantities for packing slips that are after the cut-off, but invoices that are before the cut-off
update_recordSet vendAccruedPurchasesTmp_NA setting
CostAmountPhysical = (vendAccruedPurchasesTmp_NA.ValueMST / vendAccruedPurchasesTmp_NA.ReceivedQuantity) * partialInvoicedBeforeCutOff.PartiallyInvoicedQuantity,
CostAmountPosted = (vendAccruedPurchasesTmp_NA.ValueMST / vendAccruedPurchasesTmp_NA.ReceivedQuantity) * partialInvoicedBeforeCutOff.PartiallyInvoicedQuantity,
qty = partialInvoicedBeforeCutOff.PartiallyInvoicedQuantity
where vendAccruedPurchasesTmp_NA.ReceivedQuantity != 0
join partialInvoicedBeforeCutOff where
partialInvoicedBeforeCutOff.PackingSlipSourceDocumentLine == vendAccruedPurchasesTmp_NA.PackingSlipSourceDocumentLine;
// Accrual (has to be calculated *after* CostAmountPosted is populated)
update_recordSet vendAccruedPurchasesTmp_NA setting
Accrual = vendAccruedPurchasesTmp_NA.CostAmountPhysical – vendAccruedPurchasesTmp_NA.CostAmountPosted
where vendAccruedPurchasesTmp_NA.DatePhysical <= cutOffDate;
update_recordSet vendAccruedPurchasesTmp_NA setting
Accrual = 0– vendAccruedPurchasesTmp_NA.CostAmountPosted
where vendAccruedPurchasesTmp_NA.DatePhysical > cutOffDate;
ttsbegin;
whileselect forUpdate vendAccruedPurchasesTmp_NAZ
{
select RecId from inventTransOrigin
where inventTransOrigin.InventTransId == vendAccruedPurchasesTmp_NA.InventTransID
join inventTrans
where inventTrans.InventTransOrigin == inventTransOrigin.RecId
&& inventTrans.VoucherPhysical == vendAccruedPurchasesTmp_NA.VoucherPhysical;
if (inventTrans.DatePhysical && inventTrans.DateFinancial)
{
if (inventTrans.DatePhysical < inventTrans.DateFinancial)
{
accrual = inventTrans.costAmountPhysExclStdAdjustment();
}
else
{
if (inventTrans.DatePhysical > inventTrans.DateFinancial)
{
accrual = -inventTrans.costAmountPhysExclStdAdjustment();
}
else
{
accrual = -inventTrans.CostAmountPosted;
}
}
}
else
{
if (inventTrans.DatePhysical)
{
accrual = inventTrans.costAmountPhysExclStdAdjustment();
}
if (inventTrans.DateFinancial)
{
accrual = -inventTrans.CostAmountPosted;
}
}
vendAccruedPurchasesTmp_NA.Accrual = accrual;
vendAccruedPurchasesTmp_NA.update();
}
ttsCommit;
}
Disclaimer. The described code changes are for your information only. Microsoft only supports installation of the packaged hotfix, and we do not recommend that you attempt to implement similar changes manually.
Addin reports in Visual Studio for New Microsoft Dynamics AX RTW
Challenges for trading companies using the project module
Understanding TFS with AX 2012 (III)
How to deploy a translated chart of accounts in a multiple country project
Enterprises may have subsidiaries in multi countries, in that scenario you can utilize the multi-language capabilities in Microsoft Dynamics AX. This enables translation of the client interface, including menus and data translation.
In this blog post I will focus on translation of the shared chart of account functionality, this enables translation of the client interface, including menus and data translation.
In my example a company has two subsidiaries, in one AX instance. The first legal entity is in the USA and the second in Italy.
The chart of accounts is identical across legal entities, and each legal entity is required to have the chart of account in their own language.
In a real life scenario, you must also consider specific regulations from each country that relate to a chart of accounts.
The following example will demonstrate how to use the multi-language functionality in Dynamics AX to fulfill the scenario presented above
In order to fulfill the above requirements and demonstrate it in a proper way;
- Firstly, use the shared chart of accounts across legal entities (USD and Italy)
The shared chart of account language is English. - Secondly, use the translation functionality in the chart of accounts.
You can find more information at the following TechNet article: TechNet Text translation (form) [AX 2012] - I’ve added another user to set the language interfaceThe below diagram illustrate the basic concept of the shared chart of accounts, the use of translation functionality, and user language.
I would consider numbering the steps going forward so they are easier to follow.
- Go to General ledger| Setup| Chart of Accounts| Chart of accounts
- Select the shared chart of accounts
- Select your main account (110110 is used in this example)
- Select the Translations button (You have this listed as Transactions)
5. In the Text translation form, select the language and add the translation text.
6. Set Language in user options, go to Select File| Tool| Options. Then under General tab change language to IT (Italian)
7. Changing the user options will result in menus being translated to Italian. To see this change, go to General Ledger| Journals| General journal. Create and post a general journal entry from the Italian interface. Note the account name shows in Italian.
8. The posted transaction details are shown in Italian as demonstrated below.
9. The below example demonstrates how the translation looks on a Ledger Transaction report (General ledger| Reports| Transactions| Ledger transaction)
10. The below example demonstrates the multi-language functionality with the Trial Balance list page.
(General ledger| Common| Trial balance)
The account list page shows the account name in English. As mentioned in TechNet Text translation (form) [AX 2012]
You also can use this form to create translated text for a main account name and a custom financial dimension value. The translations that you create are displayed everywhere that a main account name or custom financial dimension value is displayed, except on the main account list and list pages.
Import/Export data using composite entity in new Microsoft Dynamics AX RTW
File upload and download in AX 7
New Dynamics AX is a web application running in cloud, so how can users work with files in such an environment? The answer is: in the same way as with other web applications. If you know how to add and download attachments in your webmail client, you can do it in AX 7 as well.
And it’s not too difficult for developers either.
Let me demonstrate it on a simple form I’ve built.
When you click the Upload button, a dialog opens where you can pick a file on your computer and upload it. It even shows progress of uploading.
The whole upload is triggered by a single statement: File::GetFileFromUser(). You don’t have to deal with any details.
By default, the file is uploaded to a temporary blob storage and can be accessed through some ugly URL such as this:
If you click the download button, it will navigate to the URL and your browser will do the rest:
Code of Download button is again a one-liner: new Browser().navigate(fileUrl).
This is the complete code of the form, showing also how to get the URL of the uploaded file:
[Form]publicclass UploadDownloadForm extends FormRun {str fileUrl; [Control("Button")]class UploadButton {publicvoid clicked(){ FileUploadTemporaryStorageResult result = File::GetFileFromUser() as FileUploadTemporaryStorageResult; if(result && result.getUploadStatus()){ fileUrl = result.getDownloadUrl(); info(fileUrl); }}} [Control("Button")]class DownloadButton {publicvoid clicked(){new Browser().navigate(fileUrl); }}}
Your files typically aren’t accessible by URL, because they’re in database or in a secured storage. But that’s not a problem. Just load the content of your file to a stream and pass it to File::SendFileToUser(). It will put the file to the temporary blob storage and navigate to the URL, therefore users can download the file in the same way as above.