Skip to content

08 WebResources Project

RemyDuijkeren edited this page Jul 16, 2026 · 3 revisions

WebResources Project

flowline clone and flowline init scaffold a WebResources/ project alongside your Plugins/ project for your JavaScript or TypeScript source, images, and other web resources.

It has as-simple-as-possible Rollup build pipeline to get you started, and the dist/ folder that flowline push builds and deploys to Dataverse — wiring form event handlers and web resource dependencies automatically as it goes, no Maker Portal visits required.


Project structure

WebResources/
├── 📂 src/
│   ├── 📂 modules/           ← shared code, imported by entry points
│   │   └── 📜 common.ts
│   ├── 📜 account.ts         ← entry point (one per form/ribbon)
│   └── 📜 contact.ts
├── 📂 public/                ← static files copied to dist/ unchanged
│   ├── 📂 images/            
│   └── 📜 *.html
├── 📂 dist/                  ← build output (gitignored) — synced by 'flowline push'
├── 📜 eslint.config.mjs      ← ESLint config
├── 📜 package.json           ← npm config, packages, run scripts
├── 📜 rollup.config.mjs      ← Rollup config
├── 📜 tsconfig.json          ← TypeScript config
└── 📜 WebResources.csproj    ← .NET project file that runs 'npm run build'

This structure is just an easy starting point but it is up to you if you want to modify it. Flowline only looks in the dist/ folder to push the web resources. You can change anything about the structure or build process, see Using a different build tool.


Setup

Node.js must be installed. Restore packages once after cloning:

npm install

To update packages to their latest versions:

npm update

Building

npm run build

Or via .NET — the WebResources.csproj wires Rollup into MSBuild, so dotnet build triggers npm run build automatically. Rollup decides what to rebuild:

dotnet build

npm run build does two things in sequence:

  1. build:static — copies public/ to dist/ unchanged (images, HTML)
  2. build:js — runs Rollup, compiles every .ts/.js in src/ to an IIFE in dist/

flowline push --scope webresources builds the project by calling dotnet build, then syncs dist/ to Dataverse.

If you already built and tested, flowline push --scope webresources --no-build skips the build and pushes the existing dist/.

Either way, push refuses to run when dist/ is missing or empty — an empty folder would delete every web resource in the solution.


Writing scripts

You can write scripts in TypeScript or plain JavaScript — whichever you prefer. The scaffold uses TypeScript by default, but Rollup handles .js files identically. Mix and match freely.

ES modules

Write each script as an ES module. ES modules are the modern JavaScript way to split code across files: you export what should be accessible from outside, and import what you need from other files. Functions without export are private to that file.

For Dataverse, any function you want to register as an event handler (like onLoad, onSave, onChange) must be exported (use export function) so Rollup can expose it in the IIFE bundle. Functions:

// src/account.ts
import { hide } from './modules/common'

export function onLoad(executionContext: Xrm.Events.EventContext): void {
    const formContext = executionContext.getFormContext();
    hide('fax', formContext);
    var myName = formatName(formContext.getAttribute('name')?.getValue() ?? '');
}

// not exported — only used internally
function formatName(name: string): string {
    return name.trim();
}

The same in plain JavaScript:

// src/account.js
import { hide } from './modules/common.js'

export function onLoad(executionContext) {
    const formContext = executionContext.getFormContext();
    hide('fax', formContext);
    var myName = formatName(formContext.getAttribute('name')?.getValue() ?? '');
}

// not exported — only used internally
function formatName(name) {
    return name.trim();
}

Register Account.onLoad as the event handler. Use auto-wiring form event handlers for that.

Entry points

Every .ts or .js file directly in src/ is treated as an entry point — Rollup compiles it to a separate .js file in dist/:

src/account.ts          →  dist/account.js
src/account-ribbon.ts   →  dist/account-ribbon.js
src/contact.ts          →  dist/contact.js

Files in src/modules/ are shared code — they are imported by entry points but not compiled as standalone files.

Shared code

Put utilities in src/modules/. They are plain ES modules — import and export normally:

// src/modules/common.ts
export function hide(field: string, formContext: Xrm.FormContext): void {
    formContext.getControl(field)?.setVisible(false);
}

IIFE output and Dataverse event handlers

Dataverse loads JavaScript web resource libraries as classic, global-scope scripts, not ES modules — it invokes your registered event handler by name off a global object (LibraryName.functionName).

Native import/export doesn't work in that context. Rollup solves this by bundling your ES module source into an IIFE (Immediately Invoked Function Expression): a self-contained bundle that exposes your exported functions as a global variable, without polluting the rest of the page.

Rollup derives the global variable name from the filename, converted to PascalCase:

File Global name Dataverse event handler
account.ts Account Account.onLoad
account-ribbon.ts AccountRibbon AccountRibbon.onSave
contact.ts Contact Contact.onLoad

To add a namespace prefix (MyCompany.Account.onLoad), set namespacePrefix in rollup.config.mjs:

const namespacePrefix = 'MyCompany.'; // don't forget the trailing dot

TypeScript and linting

The template ships with strict TypeScript (strict: true, target ES2022) and @microsoft/eslint-plugin-power-apps — a Microsoft ESLint plugin that flags deprecated and unsupported Dataverse API usage. Violations print during build but don't block it, so you get a fast feedback loop by default; tighten this in rollup.config.mjs (eslint({ throwOnError: true })) once you want violations to fail the build.

If you write plain JavaScript and don't want TypeScript, remove @rollup/plugin-typescript and tsconfig.json and update rollup.config.mjs to drop the TypeScript plugin.


Form event registration

Normally you'd wire a script up to a form by hand in the maker portal's Configure Event dialog. Flowline lets you declare it from your source instead, with a single-line comment above the handler or on top of the file — no maker portal visit required:

// flowline:onload account "Account Main"
// flowline:onsave account "Account Main"
// flowline:onload account "Account Main" onLoadAccount
// flowline:onload account "Account Main" onLoadAccount(param1,param2)

export function onLoad(executionContext) { ... }
export function onSave(executionContext) { ... }
export function onLoadAccount(executionContext) { ... }

flowline push reads these and adds, updates, or removes the corresponding Form Event Handler and Form Library entries on the form automatically, keeping them in sync with your source on every push.

Syntax

// flowline:onload <entity> <form> [order:N][bulkEdit] [Function[(params)]]
// flowline:onsave <entity> <form> [order:N] [Function[(params)]]
// flowline:onchange <entity> <form> <attribute> [order:N] [Function[(params)]]
// flowline:tabstatechange <entity> <form> <tabname> [order:N] [Function[(params)]]
// flowline:onreadystatecomplete <entity> <form> <controlname> [order:N] [Function[(params)]]

onchange, tabstatechange, and onreadystatecomplete each wire up one extra thing — a field, a tab, or an IFRAME control — with one extra token identifying it:

// flowline:onchange account "Account Main" creditlimit
export function onCreditLimitChange(executionContext) { ... }

// flowline:tabstatechange account "Account Main" Summary
export function onSummaryTabStateChange(executionContext) { ... }

// flowline:onreadystatecomplete account "Account Main" myFrame
export function onMyFrameReadyStateComplete(executionContext) { ... }
  • <entity> — the table's logical name (e.g. account).
  • <form> — the form's display name, exactly as it appears in the form editor. Quote it if it contains spaces: "Account Main".
  • <attribute>onchange only. The field's logical name (e.g. creditlimit).
  • <tabname>tabstatechange only. The tab's control's Name as it appears in the form editor's control properties.
  • <controlname>onreadystatecomplete only. The IFRAME control's Name as shown in the form editor's control properties. The form editor always renders IFRAME_ as a fixed prefix there, so you can write just the suffix (myFrame) or the full id (IFRAME_myFrame) — both resolve to the same control.
  • Function — optional. Defaults to onLoad/onSave, on<Attribute>Change, on<TabName>TabStateChange, or on<ControlName>ReadyStateComplete. Name it explicitly to use a different exported function.
  • (param1,param2) — optional comma-separated parameter list passed to the handler, same as the Dataverse form editor's own "Comma separated list of parameters" field.
  • Only Main and Quick Create forms are supported.
  • Multiple annotations per file are allowed, anywhere in the file.
  • Also recognized: //! flowline:onload ... and /*! flowline:onload ... */ (same for the other directives) — the ! forms survive minification, same as flowline:depends.

For the rare case that needs more control, add modifiers between the scope token and the function name: [bulkEdit] (onload only — also runs the handler during bulk edit form) or [order:N] (forces run order when multiple handlers share an event, lower N first).

If a form gets renamed

Renaming a form in the maker portal doesn't touch your annotation, so the next push fails with form '<form>' not found for entity '<entity>'. Update the annotation to the new form name and push again. files are deduplicated automatically.


Web resource dependencies

Dataverse has a built-in web resource dependencies feature: associate one web resource with another so the platform loads them together — a shared utility, a library from another solution, or localization strings a script needs — without you registering each one separately wherever the script is used.

Flowline lets you declare these dependencies easily from your source, instead of clicking through the web resource form in the maker portal. Declare it anywhere in the file, but preferably at the top of the file:

// flowline:depends av_ext/shared-library.js

export function onLoad(executionContext) { ... }

flowline push reads these annotations and registers the dependency in Dataverse. During flowline deploy, any resource listed in a // flowline:depends annotation is protected from orphan cleanup — it will not be deleted even if it is absent from your local solution.

Syntax

  • One logical name per line: // flowline:depends <webresource-logical-name>.
  • The logical name can include path separators: av_ext/lib.js, av_ext/strings.1033.resx.
  • Multiple annotations per file are allowed, anywhere in the file; duplicates across files are deduplicated automatically.
  • Also recognized: //! flowline:depends <name> and /*! flowline:depends <name> */ to help with survival during minification. The last form is the best way to ensure annotations survive minification.

RESX localization files

RESX files in your project that share a base name with a script are automatically registered as dependencies of that script — no annotation required:

Local file Auto-matched to
dist/av/AccountForm.1033.resx dist/av/AccountForm.js
dist/av/AccountForm.1041.resx dist/av/AccountForm.js

This covers the common pattern where a form script uses localized strings — the RESX loads alongside the script so the localized values are available to it. Auto-detection works on exact base-name matches regardless of folder depth:

  • One match found — RESX registered as a dependency automatically.
  • No match — Flowline warns and skips. Use an explicit // flowline:depends annotation instead.
  • Multiple matches — Flowline warns and skips. Use an explicit annotation to name the target.

If you reference a bare RESX name without an LCID suffix (e.g., // flowline:depends av_ext/strings.resx), Flowline expands it to all matching *.1033.resx, *.1041.resx, etc. variants present in the combined local and Dataverse resource set.


Static files

Place images, HTML, and other static assets in public/. They are copied to dist/ unchanged on every build, preserving the folder structure:

public/images/logo.png  →  dist/images/logo.png
public/help.html        →  dist/help.html

If you prefer plain JavaScript the old-fashioned way — no modules — just put your .js files in public/ and ignore src/ entirely. They are copied to dist/ as-is and deployed by flowline push.


Deploying

flowline push will build the project, then sync the contents of dist/ to Dataverse. It adds, updates, or removes web resources to match the local dist/ folder exactly, and registers any form event handlers or web resource dependencies declared in the source.

npm run build
flowline push --scope webresources

Or push everything (plugins + web resources) in one step:

flowline push

Building your project means calling dotnet build which runs npm run build automatically. If you already built and tested, --no-build skips the build and pushes the existing dist/.

See 03-Command-Reference#push for all push options including --dry-run and --no-delete.


Using a different build tool

Why the scaffold uses Rollup

Rollup is a good fit for Dataverse web resources for a few specific reasons:

  • Named IIFE global per entry point, auto-discovered. Matches exactly what Dataverse expects (Account.onLoad off a global). rollup.config.mjs scans src/ and turns each file into its own build — adding a new contact.ts file is picked up automatically, no config change. Vite can't do this for multiple entries — it's Rollup underneath, and Rollup itself refuses iife/umd output for more than one entry point. esbuild can do a named global, but only one per build call, so multiple files need the same manual looping this config already does.
  • Minimal config. No dev server or HMR needed — you test inside a live Dataverse form, not a previewed page, so that machinery would be dead weight.

Swapping the build tool

You can replace Rollup with any tool — Vite, esbuild, Webpack, or plain tsc. The only contract is that npm run build populates dist/ with the files to deploy. WebResources.csproj calls npm run build; it does not care what runs inside it.

To switch:

  1. Replace rollup.config.mjs and update devDependencies in package.json with your preferred tool.
  2. Update the build:js script in package.json to call your tool instead of rollup --config.
  3. Ensure your tool outputs IIFE bundles to dist/ with PascalCase global names, or adjust your Dataverse event handler registrations to match whatever output format you choose.

The build:static script (copies public/ to dist/) is independent and can stay as-is or be folded into your build tool's config.

Clone this wiki locally