---
name: build-power-pages
description: Creates, updates, debugs, and troubleshoots Power Pages portal sites using Dataverse MCP tools. Use when building new portal pages, updating existing page content/JS/CSS, fixing blank pages, troubleshooting Liquid/FetchXML issues, configuring portal security (table permissions, web roles, Web API settings), or deploying via powerpagecomponents. Covers Liquid gotchas, security checklists, theme detection, and deploy pipelines.
argument-hint: "[environment-url] [solution-name] [optional: --site-name SITE]"
---

# Build Power Pages Skill

Create and manage Power Pages portal sites using Dataverse MCP tools. All portal configuration lives in Dataverse — no separate API needed.

## Golden Rules

1. **NEVER guess column names** — always run `dataverse_list_columns(table_name, custom_only: true)` before writing any fetchxml, Liquid, or form references. Column names are frequently non-obvious (e.g., `cr1a2_requeststatus` not `cr1a2_status`).
2. **NEVER use placeholder/dummy data** — all entity names, column names, GUIDs, and option set values MUST come from querying the actual Dataverse environment.
3. **Always update via `powerpagecomponents`** — the `mspp_*` virtual entities silently drop writes for many fields. The `powerpagecomponents` content JSON is the source of truth.
4. **Use single quotes in all HTML/Liquid/FetchXML** stored in content JSON — avoids double-quote escaping hell.
5. **Permissions default to Contact scope** — never use Global scope unless explicitly requested. Always include `entitylogicalname`. Scope values: 756150000=Global, 756150001=Contact, 756150002=Account, 756150003=Parent, 756150004=Self.
6. **Verify option set values** — query `dataverse_get_column` or sample records before using integer values in fetchxml filters.
7. **Chain the `frontend-design` skill for page customization** — any page that has custom HTML/CSS/JS MUST be designed via the `frontend-design:frontend-design` skill to ensure professional, theme-matched output. See [Skill Chaining](#skill-chaining-frontend-design) below.
8. **Web API site settings are mandatory for `/_api/` calls** — every table your JS queries MUST have both `Webapi/{entity}/enabled` and `Webapi/{entity}/fields` site settings, or `/_api/` returns 404. Create these BEFORE writing client-side code.
9. **Use vanilla JS `fetch()`, not jQuery `$.ajax()`** — use `/_layout/tokenhtml` for CSRF tokens (zero jQuery). jQuery is legacy; the `fetch()` + `/_layout/tokenhtml` pattern is the modern Power Pages approach. For multi-table dashboards, use individual `fetch()` calls with per-section error handling.
10. **Use `{% entityform name: %}` and `{% webform name: %}` Liquid tags** — NEVER use `{% entityform id: %}` for edit-mode forms (breaks record resolution in enhanced data model). Always use the `name:` parameter.
11. **Use the dedicated `dataverse_pp_*` tools** for Power Pages operations — `dataverse_pp_create_web_page`, `dataverse_pp_create_basic_form`, `dataverse_pp_create_multistep_form`, `dataverse_pp_create_table_permission`, `dataverse_pp_create_web_api_settings`, `dataverse_pp_create_site_component`. These bake in the correct enhanced data model configuration patterns.

## Liquid & FetchXML Gotchas

These are hard-won lessons from real portal development. Each has caused silent failures or blank pages in production.

### Null Handling in Linked Entity Fields

`| default: 0` does **NOT** work on empty strings from linked entity fields. Aliased fields (e.g., `r['alias.fieldname']`) return `""` when null, not `nil`. The `default` filter only replaces `nil`/`false`, not empty strings.

```liquid
<!-- BAD — returns "" not 0 when the linked field is null -->
{{ r['alias.ncr_quantity'] | default: 0 }}

<!-- GOOD — | plus: 0 coerces "" to 0 -->
{{ r['alias.ncr_quantity'] | plus: 0 }}
```

**Use `| plus: 0` for ALL numeric JSON output fields** (picklists, decimals, integers) sourced from `link-entity` aliases.

### The `| json` Filter Quirk

The `| json` Liquid filter outputs a raw string **without quotes**. Do NOT use it for JSON string values:

```liquid
<!-- BAD — outputs: { "name": John Smith } (missing quotes around value) -->
{ "name": {{ contact.fullname | json }} }

<!-- GOOD — wrap in explicit quotes -->
{ "name": "{{ contact.fullname | escape }}" }
```

### Picklist/Choice Values Require `.value`

Accessing a picklist field directly returns empty. You must use `.value` for the integer:

```liquid
<!-- BAD — returns empty -->
{{ record.cr1a2_status }}

<!-- GOOD — returns the integer value (e.g., 100000000) -->
{{ record.cr1a2_status.value }}
```

### FetchXML `<order>` Syntax for Aliases

When ordering by a linked entity field, use `entityname` attribute, not `alias`:

```xml
<!-- CORRECT -->
<order entityname='parentalias' attribute='name' />

<!-- WRONG — will be silently ignored -->
<order alias='parentalias.name' />
```

### Hidden `<div>` for Data, Not `<script>` Tags

Power Pages Studio **strips `<script>` tags** from Liquid page copy. To pass server-rendered data to client-side JavaScript, use a hidden `<div>`:

```liquid
<!-- In page copy (Liquid) -->
<div id='page-data' style='display:none'>{{ json_data }}</div>

<!-- In customjavascript -->
var data = JSON.parse(document.getElementById('page-data').textContent);
```

### Diagnosing Blank Portal Pages

When a portal page renders blank:

1. **Console empty + page blank** — Content page is missing `mspp_webpagelanguageid`. The portal cannot match the content page to the current language and renders the root page's null copy.
2. **Console shows `[Feature] init` but content area is empty** — FetchXML issue. Check that lookup fields used in FetchXML conditions exist and are populated on the target records.
3. **Console shows JSON parse error** — Liquid output contains unescaped quotes or backslashes in text fields. Apply `| escape` and `| strip_newlines | replace: '\', '\\'` to text fields in JSON output.

## Security Checklist for Portal Features

Every portal feature that accesses Dataverse data MUST have security explicitly configured. Missing any of these causes **silent failures** — the page loads fine but shows no data, or API calls return 403 with no helpful error message.

Before implementing any portal feature, define:

1. **Table Permissions** — Which tables? What CRUD operations? What scope (Contact, Account, Parent, Global)?
2. **Web Roles** — Which roles get the table permissions? Are users assigned to those roles?
3. **Page Access** — Is the page restricted to specific web roles, or open to all authenticated users?
4. **Web API Settings** (if using `/_api/` calls) — `Webapi/{entity}/enabled = true` AND `Webapi/{entity}/fields` listing allowed fields

If security requirements are not clear from the user's request, **ask before building** — don't assume Global scope or skip permissions.

## Skill Chaining: frontend-design

When building or updating Power Pages portal pages that include **custom HTML, CSS, or JavaScript** (dashboard KPIs, enhanced entity lists, interactive forms, charts, calculators, etc.), you MUST invoke the `frontend-design:frontend-design` skill to produce the page content. This ensures professional, theme-matched, production-grade output from a specialized design agent rather than generic code.

### When to Chain

- **Always chain** when creating or updating page copy with custom HTML layouts, CSS styling, or JS behavior
- **Skip chaining** for bare Liquid-only pages (e.g., a page that just renders `{% include 'entity_list' key: 'guid' %}` with no custom styling)

### How to Chain

Invoke the `frontend-design:frontend-design` skill with arguments that include ALL of the following context so the design agent produces compatible output:

```
ARGUMENTS: [describe what the page should do/contain]

## CRITICAL THEME REQUIREMENTS — MUST MATCH EXISTING SITE

[Include theme detection results from pre-flight — see Theme Detection section below]

## DESIGN DIRECTION
[Describe the aesthetic: clean corporate dashboard, modern SaaS, etc.]

## DATA CONTEXT
[List ALL Dataverse tables/columns/picklist values the page will use, with entity set names]

## TECHNICAL CONSTRAINTS
- Bootstrap 5 classes (standard, not dark variants unless site uses dark theme)
- Single quotes ONLY in all HTML attributes (no double quotes — embedded in JSON)
- jQuery for all JS (available globally on Power Pages)
- Chart.js via CDN for charts (if needed): https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js
- [Light/Dark] theme matching existing portal
- Mobile responsive using standard Bootstrap breakpoints
- Color palette from theme detection
- Picklist option values: [list all option set integer→label maps]

## OUTPUT FORMAT
For each page, output THREE clearly delimited sections:
<!-- === PAGE COPY START === -->
(the HTML — single quotes only)
<!-- === PAGE COPY END === -->
<!-- === CUSTOM JAVASCRIPT START === -->
(the JS — no script tags)
<!-- === CUSTOM JAVASCRIPT END === -->
<!-- === CUSTOM CSS START === -->
(the CSS — no style tags)
<!-- === CUSTOM CSS END === -->
```

### Theme Detection Pre-Flight

Before invoking the frontend-design skill, detect the site's current theme so the design agent matches it:

```
# 1. Check Bootstrap version
dataverse_query_records(entity_set: "mspp_sitesettings", filter: "mspp_name eq 'Site/BootstrapV5Enabled'", select: "mspp_value")

# 2. Check for theme feature settings
dataverse_query_records(entity_set: "mspp_sitesettings", filter: "contains(mspp_name, 'Theme')", select: "mspp_name,mspp_value")

# 3. Read existing CSS snippets for color/font conventions
dataverse_query_records(entity_set: "mspp_contentsnippets", filter: "contains(mspp_name, 'CSS') or contains(mspp_name, 'Style')", select: "mspp_name,mspp_value", top: 5)

# 4. Check web templates for layout patterns (dark navbar? sidebar? full-width?)
dataverse_query_records(entity_set: "mspp_webtemplates", select: "mspp_name,mspp_source", top: 10)
```

From the results, identify:
- **Font stack** (system fonts vs custom Google Fonts)
- **Color palette** (primary, success, warning, danger, info colors)
- **Light vs dark theme** (background colors, text colors)
- **Card/component patterns** (shadows, borders, border-radius)
- **Navbar style** (dark/light, position)

Pass ALL of this as part of the `CRITICAL THEME REQUIREMENTS` in the skill arguments.

### Post-Design: Deploy Pipeline

After the frontend-design skill produces the HTML/CSS/JS output:

1. **Save the output** as an HTML source file (e.g., `dashboard.html`, `vendors.html`) with section markers
2. **Process via `deploy_minify.js`** to produce content JSON (minified HTML, CSS, JS bundled into content JSON structure)
3. **Deploy via powerpagecomponents update** using the final JSON as the `data` parameter

#### HTML Source File Format

```html
<!-- === PAGE COPY START === -->
<div class='my-page'>
  <h1>My Page</h1>
  <!-- page HTML here -->
</div>
<!-- === PAGE COPY END === -->
<!-- === CUSTOM JAVASCRIPT START === -->
(function() {
  // JavaScript here
})();
<!-- === CUSTOM JAVASCRIPT END === -->
<!-- === CUSTOM CSS START === -->
.my-page { /* CSS here */ }
<!-- === CUSTOM CSS END === -->
```

#### deploy_minify.js Pattern

Create a `deploy_minify.js` in the project directory that:
1. Extracts HTML (copy), JS, CSS sections from the source file using section markers
2. Minifies each section (HTML: collapse whitespace/newlines; CSS: remove comments/whitespace; JS: trim lines)
3. Builds the content JSON object with all page metadata (partialurl, rootwebpageid, parentpageid, pagetemplateid, publishingstateid, etc.)
4. Wraps in final JSON format: `{ "content": "{...serialized content JSON...}" }`
5. Write both the content string and final JSON to output files for deployment

```
# Run the minifier
node deploy_minify.js

# Deploy — use the final JSON as the data parameter
dataverse_update_record(
  entity_set: "powerpagecomponents",
  id: "{content_page_id}",
  data: {content JSON from the final output file}
)
```

## Pre-Flight: Gather Environment Context

Before ANY portal work, query these to get real IDs (never hardcode):

```
# 1. Site info
dataverse_list_power_pages_sites()

# 2. Website record
dataverse_query_records(entity_set: "mspp_websites", select: "mspp_websiteid,mspp_name")

# 3. Publishing states
dataverse_query_records(entity_set: "mspp_publishingstates", select: "mspp_publishingstateid,mspp_name")

# 4. Language
dataverse_query_records(entity_set: "mspp_websitelanguages", select: "mspp_websitelanguageid,mspp_name,mspp_lcid")

# 5. Page templates
dataverse_query_records(entity_set: "mspp_pagetemplates", select: "mspp_pagetemplateid,mspp_name")

# 6. Home root page (parent for new pages)
dataverse_query_records(entity_set: "mspp_webpages", filter: "mspp_name eq 'Home' and mspp_isroot eq true", select: "mspp_webpageid,mspp_name", top: 1)

# 7. Web roles (for permissions)
dataverse_query_records(entity_set: "mspp_webroles", select: "mspp_webroleid,mspp_name,mspp_authenticatedusersrole,mspp_anonymoususersrole")
```

Before referencing ANY Dataverse table in portal pages:

```
# Get table metadata (display name, logical name, entity set)
dataverse_get_table(table_name: "cr1a2_mytable")

# Get ALL custom columns (verify exact logical names before using in fetchxml/Liquid)
dataverse_list_columns(table_name: "cr1a2_mytable", custom_only: true)

# For choice/picklist columns, get option set values
dataverse_get_column(table_name: "cr1a2_mytable", column_name: "cr1a2_status")

# Verify with actual data — query a few records to confirm column names and value formats
dataverse_query_records(entity_set: "cr1a2_mytables", top: 3, select: "cr1a2_field1,cr1a2_field2")
```

## Data Model: Enhanced vs Standard

| Version | Table Prefix | Notes |
|---------|-------------|-------|
| **Enhanced** (v2) | `mspp_` | Default for new sites. All examples in this skill. |
| **Standard** (v1) | `adx_` | Legacy. Replace `mspp_` with `adx_` if needed. |

Check with `dataverse_list_power_pages_sites` — `data_model_version` field.

## Core Architecture: powerpagecomponents

In the enhanced data model, ALL `mspp_*` tables are **virtual entities** backed by the `powerpagecomponents` table. Each record has:

- `powerpagecomponentid` — the GUID (same as the `mspp_*` record ID)
- `powerpagecomponenttype` — integer identifying the component type
- `content` — **JSON string** containing ALL the component's properties
- `name` — display name

| Type | Component | Virtual Entity |
|------|-----------|---------------|
| 1 | Publishing State | `mspp_publishingstates` |
| 2 | Web Page | `mspp_webpages` |
| 3 | Web File | `mspp_webfiles` |
| 4 | Web Link Set | `mspp_weblinksets` |
| 5 | Web Link | `mspp_weblinks` |
| 6 | Page Template | `mspp_pagetemplates` |
| 7 | Site Setting | `mspp_sitesettings` |
| 8 | Web Template | `mspp_webtemplates` |
| 9 | Web Role | `mspp_webroles` |
| 15 | Entity Form | `mspp_entityforms` |
| 16 | Entity Form Metadata | `mspp_entityformmetadatas` |
| 17 | Entity List | `mspp_entitylists` |
| 18 | Table Permission | `mspp_entitypermissions` |

### When to use `mspp_*` vs `powerpagecomponents`

| Operation | Use `mspp_*` virtual entity | Use `powerpagecomponents` directly |
|-----------|---------------------------|-----------------------------------|
| **Create** basic records | ✓ Works for pages, templates, roles | ✓ Required for table permissions (need `entitylogicalname`) |
| **Read** records | ✓ Familiar field names | ✓ Shows raw content JSON |
| **Update** simple fields | ✗ **AVOID** — `mspp_webpages` PATCH triggers CUDFromSingleEntity plugin which treats PATCH as CREATE (fails with validation errors) | ✓ **Always use this** for updates |
| **Update** content, JS, CSS | ✗ Often silently fails | ✓ **Always use this** — modify content JSON |
| **Bind** entity forms/lists to pages | ✗ "undeclared property" error | ✓ Add `entityformid`/`entitylistid` to content JSON |
| **Set** table permission web roles | ✗ $ref returns success but doesn't persist | ✓ Set `adx_entitypermission_webrole` array in content JSON |

### Content JSON Update Pattern

This is the most important pattern in this skill. Used for ALL page content updates, form/list bindings, permission configuration, etc.

```
# Step 1: Read current content
dataverse_get_record(entity_set: "powerpagecomponents", id: "{component_id}", select: "powerpagecomponentid,name,content")

# Step 2: Parse the content JSON, modify the field(s) you need, rebuild

# Step 3: Update with full content JSON (PATCH — only content field changes)
dataverse_update_record(
  entity_set: "powerpagecomponents",
  id: "{component_id}",
  data: {"content": "{...full updated JSON string...}"}
)
```

**Content JSON escaping rules:**
- The `content` field value is a JSON string containing a JSON object
- Use SINGLE QUOTES for all HTML/Liquid/FetchXML attributes inside the content: `<div class='my-class'>` not `<div class="my-class">`
- This avoids nested double-quote escaping between the outer data JSON → content JSON string → HTML attribute values
- Keep HTML/CSS/JS minified (no newlines) when possible to simplify escaping
- **Backtick pattern for JS:** In `customjavascript`, use JS template literals (backticks) instead of quoted HTML strings. Example: `` `<div class='badge'>${value}</div>` `` instead of `'<div class="badge">' + value + '</div>'`. This eliminates quote-in-quote escaping — only standard `\"` is needed for JSON structure.
- The only escaping needed with single-quote + backtick pattern is `\"` for the JSON string boundary

## Creating Pages

### Page Structure (Two-Record Pattern)

Every visible page requires **TWO** `mspp_webpage` records:

1. **Root Page** (`isroot = true`) — URL, parent page, display order, sitemap, entity list/form bindings
2. **Content Page** (`isroot = false`) — localized HTML copy, JS, CSS

```
Root Web Page (isroot=true, partialurl="my-page")
  └── Content Page (isroot=false, same partialurl, copy has HTML/Liquid)
```

### Step 1: Create or Select a Web Template

```
# Check existing templates first
dataverse_query_records(entity_set: "mspp_webtemplates", select: "mspp_webtemplateid,mspp_name", top: 20)

# Create a custom template (use single quotes in HTML, {% include 'Page Copy' %} to render page content)
dataverse_create_record(
  entity_set: "mspp_webtemplates",
  data: {
    "mspp_name": "My App Layout",
    "mspp_source": "{% extends 'Layout 1 Column' %}{% block main %}<div class='container'>{% include 'Page Copy' %}</div>{% endblock %}",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})"
  }
)
```

### Step 2: Create a Page Template (maps page → web template)

```
dataverse_create_record(
  entity_set: "mspp_pagetemplates",
  data: {
    "mspp_name": "My App Template",
    "mspp_type": 756150001,  # 756150001=Web Template, 756150000=Rewrite
    "mspp_webtemplateid@odata.bind": "/mspp_webtemplates({web_template_id})",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})"
  }
)
```

### Step 3: Create Root Web Page

> **CRITICAL — Auto-Created Content Page:** When you create a root web page via `mspp_webpages`, Power Pages **automatically creates an empty content page** (isroot=false, copy=null) for the same URL. If you then create your own content page in Step 4, there will be TWO content pages — and the portal renders the auto-created empty one, making the page blank. **You MUST delete the auto-created empty content page** after creating your custom one. Query for duplicates: `mspp_webpages` where `mspp_partialurl eq '{url}' and mspp_isroot eq false` — delete any with null/empty `mspp_copy`.

```
dataverse_create_record(
  entity_set: "mspp_webpages",
  data: {
    "mspp_name": "My Page",
    "mspp_partialurl": "my-page",
    "mspp_isroot": true,
    "mspp_pagetemplateid@odata.bind": "/mspp_pagetemplates({page_template_id})",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})",
    "mspp_parentpageid@odata.bind": "/mspp_webpages({home_root_page_id})",
    "mspp_displayorder": 10,
    "mspp_hiddenfromsitemap": false,
    "mspp_publishingstateid@odata.bind": "/mspp_publishingstates({published_state_id})"
  }
)

# IMPORTANT: Query for the auto-created content page and delete it
dataverse_query_records(entity_set: "mspp_webpages", filter: "mspp_partialurl eq 'my-page' and mspp_isroot eq false", select: "mspp_webpageid,mspp_copy")
# Delete the one with null/empty copy
dataverse_delete_record(entity_set: "mspp_webpages", id: "{auto_created_content_page_id}")
```

### Step 4: Create Content Page

**CRITICAL:** Content page MUST include `mspp_parentpageid` (the root page's parent, NOT the root page itself) AND `mspp_rootwebpageid` (the root page). Without `mspp_parentpageid`, the WebPageValidationPlugin fails.

```
dataverse_create_record(
  entity_set: "mspp_webpages",
  data: {
    "mspp_name": "My Page",
    "mspp_partialurl": "my-page",
    "mspp_isroot": false,
    "mspp_rootwebpageid@odata.bind": "/mspp_webpages({root_page_id})",
    "mspp_parentpageid@odata.bind": "/mspp_webpages({home_root_page_id})",
    "mspp_pagetemplateid@odata.bind": "/mspp_pagetemplates({page_template_id})",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})",
    "mspp_webpagelanguageid@odata.bind": "/mspp_websitelanguages({language_id})",
    "mspp_publishingstateid@odata.bind": "/mspp_publishingstates({published_state_id})",
    "mspp_copy": "<h1>My Page</h1><p>Content here.</p>"
  }
)
```

### Updating Page Content (copy, JS, CSS)

**Always update via powerpagecomponents**, not the virtual entity. The content JSON contains ALL page properties.

```
# Read → modify → write the content JSON
dataverse_get_record(entity_set: "powerpagecomponents", id: "{content_page_id}", select: "content")

# Update with modified content JSON
dataverse_update_record(
  entity_set: "powerpagecomponents",
  id: "{content_page_id}",
  data: {"content": "{\"partialurl\":\"my-page\",\"isroot\":false,\"copy\":\"<h1>Updated</h1>\",\"customjavascript\":\"console.log('loaded');\",\"customcss\":\".my-class{color:#333}\",\"rootwebpageid\":\"...\",\"parentpageid\":\"...\",\"pagetemplateid\":\"...\",\"publishingstateid\":\"...\",\"enablerating\":false,\"hiddenfromsitemap\":false,\"sharedpageconfiguration\":true,\"feedbackpolicy\":756150000,\"isofflinecached\":false,\"excludefromsearch\":false}"}
)
```

## Data Loading Strategy: FetchXML vs Web API

Every page that shows Dataverse data needs a deliberate choice between server-side (Liquid fetchxml) and client-side (`/_api/` Web API). Pick the right one per data element — most complex pages use BOTH.

### Decision Matrix

| Factor | Liquid FetchXML (Server-Side) | `/_api/` Web API (Client-Side) |
|--------|-------------------------------|-------------------------------|
| **When it runs** | Server renders data INTO the HTML before sending to browser | Browser JS fires after page loads, fetches data via AJAX |
| **User experience** | Data appears instantly — no loading spinner, no flash of empty content | User sees loading state, then data fills in (100-500ms typical) |
| **Caching** | Portal server caches results — fast on repeat visits | No caching — hits Dataverse on every page load/interaction |
| **Interactivity** | None — data is baked into HTML at render time | Full — filter, sort, paginate, CRUD without page reload |
| **Setup overhead** | Table permissions only | Table permissions AND Webapi site settings (2 per table) |
| **Linked entities** | Native — `<link-entity>` joins across tables in a single query | Must make separate API calls per table and join in JS |
| **Aggregation** | `<fetch aggregate='true'>` for COUNT, SUM, AVG, etc. | Must fetch all records and aggregate in JS (slower for large datasets) |
| **Write operations** | Not possible — read-only | Full CRUD with CSRF token |

### When to Use Which

**Use Liquid FetchXML for:**
- KPI card counts and totals (instant render, cached, no spinner)
- Static labels and display values that don't change with user interaction
- Data that requires linked-entity joins (e.g., vendor name on a contract record)
- Aggregate queries (counts, sums, averages) — server does the math
- Data shown on every page load with no filtering/interaction needed
- SEO-relevant content (server-rendered HTML is indexable)

**Use `/_api/` Web API for:**
- Charts that users can filter or interact with
- Data grids with client-side sorting/filtering beyond entity list capabilities
- CRUD operations (create invoice, update deliverable status)
- Real-time data that must refresh without page reload
- Dynamic calculations (bid calculators, what-if scenarios)
- Data that feeds interactive UI elements (autocomplete, cascading dropdowns)

**Use BOTH (hybrid pattern) for:**
- **Dashboards** — fetchxml for KPI counts (instant render), Web API for chart data (interactive)
- **Detail pages** — fetchxml for the main record display, Web API for child record grids the user can interact with
- **List pages** — entity list for the grid, Web API for dynamic summary stats above it

### Hybrid Dashboard Example

```liquid
{# SERVER-SIDE: KPI counts render instantly, cached #}
{% fetchxml vendor_stats %}
<fetch aggregate='true'>
  <entity name='cr1a2_vendor'>
    <attribute name='cr1a2_vendorid' alias='total' aggregate='count' />
    <filter>
      <condition attribute='cr1a2_status' operator='eq' value='190000000' />
    </filter>
  </entity>
</fetch>
{% endfetchxml %}

<div class='card'>
  <div class='card-body'>
    <h3>{{ vendor_stats.results.entities[0].total }}</h3>
    <p>Active Vendors</p>
  </div>
</div>

{# CLIENT-SIDE: Chart data loads async, supports interaction #}
<canvas id='vendor-chart' style='height:280px'></canvas>
```

Then in the page's `customjavascript`, fetch the chart data via `/_api/` with independent `.done()/.fail()` handlers.

### Performance Guidelines

1. **Minimize API calls** — if a page needs data from 7 tables, consider which can be fetchxml (0 API calls, instant) vs which truly need Web API (interactive)
2. **Use `$select`** — always specify only the columns you need in Web API calls to reduce payload size
3. **Use `$top`** — limit result sets. A dashboard showing "recent invoices" doesn't need all 500 records
4. **Aggregate server-side when possible** — `<fetch aggregate='true'>` is faster than fetching 500 records and counting in JS
5. **Entity lists handle pagination** — don't build custom pagination for data grids; use the built-in entity list component

## Liquid FetchXML (Server-Side Data Queries)

Use Liquid fetchxml in page copy to query Dataverse data server-side. **Requires table permissions** — fetchxml returns 0 results if the user lacks read permission on the table.

### MANDATORY: Verify Column Names First

```
# ALWAYS do this before writing any fetchxml
dataverse_list_columns(table_name: "cr1a2_mytable", custom_only: true)
# Then verify with actual data
dataverse_query_records(entity_set: "cr1a2_mytables", top: 1)
```

### Basic Query

```liquid
{% fetchxml my_data %}
<fetch top='10'>
  <entity name='cr1a2_mytable'>
    <attribute name='cr1a2_title' />
    <attribute name='cr1a2_status' />
    <order attribute='createdon' descending='true' />
  </entity>
</fetch>
{% endfetchxml %}

Total: {{ my_data.results.entities.size }}
{% for item in my_data.results.entities %}
  {{ item.cr1a2_title }}
{% endfor %}
```

### Filtered Query (with choice/optionset values)

```liquid
{% fetchxml active_items %}
<fetch>
  <entity name='cr1a2_mytable'>
    <attribute name='cr1a2_myid' />
    <filter>
      <condition attribute='cr1a2_status' operator='eq' value='100000000' />
    </filter>
  </entity>
</fetch>
{% endfetchxml %}
Count: {{ active_items.results.entities.size }}
```

> **IMPORTANT:** Option set integer values in `<condition value='...' />` must match the actual Dataverse option set definition. Verify with `dataverse_get_column` or by querying records.

### Displaying Choice/Picklist Fields

In Liquid fetchxml results, Picklist fields return `OptionSetValue` objects, NOT strings.

```liquid
{# WRONG — outputs blank #}
{{ item.cr1a2_status }}

{# CORRECT — .Label gets the display text, .Value gets the integer #}
{{ item.cr1a2_status.Label }}
{{ item.cr1a2_status.Value }}
```

### Displaying Lookup Fields

```liquid
{{ item.cr1a2_contactid.Name }}
{{ item.cr1a2_contactid.Id }}
```

### Single-Quote Rule

ALL attribute values in fetchxml stored in content JSON MUST use single quotes:

```liquid
{# CORRECT (single quotes) — safe in JSON content #}
<condition attribute='cr1a2_status' operator='eq' value='100000000' />

{# WRONG (double quotes) — breaks JSON escaping #}
<condition attribute="cr1a2_status" operator="eq" value="100000000" />
```

## Entity Lists (Data Grids)

Entity lists render Dataverse views as interactive grids with search, pagination, and sorting.

### Creating an Entity List

First, find the Dataverse view to use:

```
# Query saved views for the table
dataverse_query_records(
  entity_set: "savedqueries",
  filter: "returnedtypecode eq 'cr1a2_mytable'",
  select: "savedqueryid,name,fetchxml"
)
```

Then create the entity list via powerpagecomponents:

```
dataverse_create_record(
  entity_set: "powerpagecomponents",
  data: {
    "name": "My Table List",
    "powerpagecomponenttype": 17,
    "content": "{\"entityname\":\"cr1a2_mytable\",\"view\":\"{view_guid}\",\"pagesize\":10,\"entitypermissionsenabled\":true,\"searchenabled\":true,\"searchplaceholdertext\":\"Search...\"}",
    "powerpagesiteid@odata.bind": "/powerpagesites({site_id})"
  }
)
```

### Binding Entity List to a Page

**CRITICAL:** The `mspp_entitylistid` binding CANNOT be set via the virtual entity API in the enhanced data model. The `_mspp_entitylist_value` on the virtual entity always remains null regardless of content JSON changes.

**Working approach:** Render the entity list directly in the page copy using Liquid:

```liquid
{% include 'entity_list' key: '{entity_list_guid}' %}
```

Add this to the content page's `copy` field via the powerpagecomponents content JSON update pattern.

> The `{% include 'entity_list' %}` tag renders the full built-in grid (search, pagination, column headers, data rows) using the view defined in the entity list component.

### Entity List with Entity Permissions

When `entitypermissionsenabled: true`, the list only shows records the user has permission to read (via table permissions). When `false`, all records are shown regardless of permissions.

## Entity Forms (Basic Forms)

### Creating an Entity Form

```
dataverse_create_record(
  entity_set: "mspp_entityforms",
  data: {
    "mspp_name": "Submit Request Form",
    "mspp_entityname": "cr1a2_mytable",
    "mspp_formname": "Information",
    "mspp_mode": 100000000,           # 100000000=Insert, 100000001=Edit, 100000002=ReadOnly
    "mspp_tabname": "general",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})"
  }
)
```

### Binding Entity Form to a Page

Same as entity lists — virtual entity binding doesn't work. Two approaches:

**Approach 1: Content JSON (for page template rendering)**
Add `entityformid` to the content page's powerpagecomponents content JSON.

**Approach 2: Liquid in page copy (more reliable)**
```liquid
{% entityform id: '{entity_form_guid}' %}
```

### Entity Form Source Type (Edit/ReadOnly Modes)

**CRITICAL:** Edit and ReadOnly mode entity forms MUST have `entitysourcetype` configured, or they fail with "Object reference not set to an instance of an object." Insert mode forms do NOT need this.

Update the entity form's powerpagecomponents content JSON to include:

```
"entitysourcetype": 756150000,                    # 756150000 = QueryString (resolve record ID from URL ?id=GUID)
"recordidquerystringparametername": "id"           # The URL parameter name containing the record GUID
```

| entitysourcetype Value | Source | Use Case |
|------------------------|--------|----------|
| `756150000` | **QueryString** | Record ID passed via URL parameter (most common for portal pages) |
| `756150001` | **Current Portal User** | Show/edit the logged-in user's contact record |
| `756150002` | **Record Associated to Current Portal User** | Show related record (requires relationship config) |

### Entity Form Metadata (Field Customization)

```
dataverse_create_record(
  entity_set: "mspp_entityformmetadatas",
  data: {
    "mspp_type": 100000000,  # 100000000 = Attribute
    "mspp_attributelogicalname": "cr1a2_title",
    "mspp_label": "Request Title",
    "mspp_fieldisrequired": true,
    "mspp_entityform@odata.bind": "/mspp_entityforms({form_id})"
  }
)
```

## Web Roles & Table Permissions

### Security Model Overview

```
Portal User (contact record)
  └── Web Role (portal-level role, linked to contact)
       └── Table Permission (entity + scope + CRUD + web roles)
```

### Web Roles

```
dataverse_create_record(
  entity_set: "mspp_webroles",
  data: {
    "mspp_name": "App User",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})",
    "mspp_authenticatedusersrole": false
  }
)
```

Built-in roles (auto-created with site):
- **Anonymous Users** (`mspp_anonymoususersrole: true`) — unauthenticated visitors. **NEVER assign table permissions to this role** unless the data is truly public.
- **Authenticated Users** (`mspp_authenticatedusersrole: true`) — all logged-in users.

### Table Permissions (CRITICAL — Enhanced Data Model)

**ALWAYS create via powerpagecomponents.** The virtual entity (`mspp_entitypermissions`) does NOT reliably set `entitylogicalname`, and the `$ref` N:N approach for web role association silently fails.

#### Required Pre-Flight for Table Permissions

```
# 1. Get table display name and logical name
dataverse_get_table(table_name: "cr1a2_mytable")
# → display_name: "My Table", logical_name: "cr1a2_mytable"

# 2. Get web role GUIDs (only Authenticated Users and/or custom roles — NOT Anonymous)
dataverse_query_records(entity_set: "mspp_webroles", select: "mspp_webroleid,mspp_name,mspp_authenticatedusersrole")
```

#### Creating a Table Permission

```
# Use the dedicated tool — handles scope values, content JSON structure, web role array
dataverse_pp_create_table_permission(
  site_id: "{site_id}",
  table_name: "cr1a2_mytable",
  table_display_name: "My Table",
  scope: "Contact",
  read: true, write: true, create: true,
  web_role_ids: "<authenticated_users_guid>"
)
```

#### Content JSON Fields — Table Permission

| Field | Required | Description |
|-------|----------|-------------|
| `entityname` | **YES** | Display name (e.g., "My Table") — get from `dataverse_get_table` |
| `entitylogicalname` | **YES** | Logical name (e.g., "cr1a2_mytable") — **portal matches permissions on this** |
| `scope` | YES | See scope table below |
| `read` | YES | Boolean — can read records |
| `write` | YES | Boolean — can update records |
| `create` | YES | Boolean — can create records |
| `delete` | YES | Boolean — can delete records |
| `append` | YES | Boolean |
| `appendto` | YES | Boolean |
| `adx_entitypermission_webrole` | YES | Array of web role GUIDs — **this is the N:N source of truth** |

#### Permission Scopes

| Value | Scope | Description | Requires |
|-------|-------|-------------|----------|
| `756150000` | **Global** | All records visible. **Use only when explicitly needed.** | Nothing extra |
| `756150001` | **Contact** | User sees only records linked to their contact. **Recommended default.** | Contact lookup column on the table |
| `756150002` | **Account** | Records linked to user's parent account | Account lookup column on the table |
| `756150003` | **Parent** | Child permission inheriting from a parent permission | Parent table permission configured |
| `756150004` | **Self** | User can only access their own contact record | Only for the `contact` table |

#### Security Best Practices

1. **Default to Contact scope** — users see only their own records. Requires a contact lookup field on the entity.
2. **Never assign to Anonymous Users** unless the data is truly public (e.g., published articles).
3. **Be specific with CRUD** — only enable the operations users actually need.
4. **Use custom web roles** for granular access — don't rely solely on Authenticated Users.
5. **Always verify** the permission was created correctly:
   ```
   dataverse_get_record(entity_set: "powerpagecomponents", id: "{permission_id}", select: "content")
   # Confirm both entityname AND entitylogicalname are present
   ```

### Web API Site Settings (Client-Side Data Access)

**CRITICAL:** For portal JavaScript to access Dataverse via `/_api/`, you MUST create site settings for EVERY table your JS queries. Missing settings = 404 errors. This is the #1 cause of "dashboard shows all zeros" / "API calls failing" issues.

**Checklist before writing any `/_api/` JavaScript:**
1. List ALL tables the page JS will query
2. Create `Webapi/{entity_logical_name}/enabled` = `true` for each
3. Create `Webapi/{entity_logical_name}/fields` = comma-separated field list for each
4. Create table permissions with Read access for the user's web role
5. Test each `/_api/{entity_set}` endpoint individually to confirm 200 responses

```
# Use the dedicated tool — creates BOTH settings in one call, auto-includes primary key
dataverse_pp_create_web_api_settings(
  site_id: "{site_id}",
  table_name: "cr1a2_mytable",
  fields: "cr1a2_title,cr1a2_status,cr1a2_description"
)
# Returns: enabled_setting_id, fields_setting_id, full fields list with auto-added primary key
```

> Web API also requires table permissions with appropriate CRUD for the authenticated user's web role.

> **NOTE:** The entity name in the site setting path is the **logical name** (e.g., `cr1a2_mytable`), NOT the entity set name (e.g., `cr1a2_mytables`). The `/_api/` endpoint uses the entity SET name (plural) for the URL.

## Navigation (Web Links)

```
# Query existing navigation menus
dataverse_query_records(entity_set: "mspp_weblinksets", select: "mspp_weblinksetid,mspp_name")

# Add a link to a menu (NOTE: mspp_pageid NOT mspp_webpageid, NO mspp_websiteid)
dataverse_create_record(
  entity_set: "mspp_weblinks",
  data: {
    "mspp_name": "My Page",
    "mspp_displayorder": 10,
    "mspp_weblinksetid@odata.bind": "/mspp_weblinksets({link_set_id})",
    "mspp_pageid@odata.bind": "/mspp_webpages({root_page_id})",
    "mspp_publishingstateid@odata.bind": "/mspp_publishingstates({published_state_id})"
  }
)
```

**Gotchas:** Page binding is `mspp_pageid` (NOT `mspp_webpageid`). Web links do NOT have `mspp_websiteid` — inherited from the parent web link set.

## Site Settings

```
dataverse_create_record(
  entity_set: "mspp_sitesettings",
  data: {
    "mspp_name": "Authentication/Registration/Enabled",
    "mspp_value": "true",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})"
  }
)
```

| Setting | Purpose |
|---------|---------|
| `Authentication/Registration/Enabled` | Enable self-registration |
| `Authentication/Registration/RequiresConfirmation` | Email confirmation required |
| `Webapi/{entity_logical_name}/enabled` | Enable client-side Web API for a table |
| `Webapi/{entity_logical_name}/fields` | Comma-separated allowed fields for Web API |
| `HTTP/X-Frame-Options` | Clickjacking protection |
| `Search/Enabled` | Enable site search |

## Custom JavaScript & CSS

### Method 1: Page-Level (via powerpagecomponents content JSON)

Set `customjavascript` and `customcss` in the content page's content JSON. Power Pages wraps them in `<script>`/`<style>` tags automatically.

jQuery (`$`) is available globally. Use `$(document).ready()` for DOM-dependent code.

### Method 2: Web Template Embedded

Include `<script>` tags directly in the web template's `source` Liquid.

### Method 3: Content Snippets

`mspp_contentsnippets` store reusable HTML/JS fragments. Render via `{% snippet 'Name' %}`.

```
dataverse_create_record(
  entity_set: "mspp_contentsnippets",
  data: {
    "mspp_name": "Custom/MyWidget",
    "mspp_type": 756150001,  # 756150001=HTML, 756150000=Text
    "mspp_value": "<div class='widget'>...</div>",
    "mspp_contentsnippetlanguageid@odata.bind": "/mspp_websitelanguages({language_id})",
    "mspp_websiteid@odata.bind": "/mspp_websites({site_id})"
  }
)
```

### Portal Web API (Client-Side Dataverse Access) — jQuery-Free

**CSRF Token — use `/_layout/tokenhtml` (ZERO jQuery required):**

```javascript
async function getToken() {
  const res = await fetch('/_layout/tokenhtml');
  const html = await res.text();
  const match = html.match(/value="([^"]+)"/);
  return match ? match[1] : null;
}
```

**Read data (no token needed for GET):**

```javascript
// Independent calls with per-section error handling
async function loadSection(entitySet, query, elementId) {
  try {
    const resp = await fetch('/_api/' + entitySet + (query ? '?' + query : ''));
    if (!resp.ok) throw new Error(resp.status);
    const data = await resp.json();
    document.getElementById(elementId).textContent = (data.value || []).length;
  } catch {
    document.getElementById(elementId).textContent = 'N/A';
  }
}

// Each section loads independently — one failure doesn't kill others
loadSection('cr1a2_mytables', '$select=cr1a2_title&$top=500', 'my-count');
loadSection('cr1a2_othertable', '$select=cr1a2_name', 'other-count');
```

**Write data (CSRF token required for POST/PATCH/DELETE):**

```javascript
async function apiWrite(method, url, data) {
  const token = await getToken();
  const resp = await fetch(url, {
    method: method,
    headers: {
      'Content-Type': 'application/json',
      '__RequestVerificationToken': token
    },
    body: data ? JSON.stringify(data) : undefined
  });
  if (!resp.ok) {
    const err = await resp.json();
    throw new Error(err.error?.message || resp.statusText);
  }
  return resp.status === 204 ? null : resp.json();
}

// Create
await apiWrite('POST', '/_api/cr1a2_mytables', { cr1a2_title: 'New Record' });

// Update
await apiWrite('PATCH', '/_api/cr1a2_mytables(guid)', { cr1a2_title: 'Updated' });

// Delete
await apiWrite('DELETE', '/_api/cr1a2_mytables(guid)');
```

**Web API field requirements:**
- `Webapi/{table}/fields` MUST include the primary key field (e.g., `cr1a2_mytableid`)
- For `$filter` on lookup fields, include `_fieldname_value` (navigation property format) in the fields list
- Missing a field returns 403 `AttributePermissionIsMissing`

### Chart.js Integration

When using Chart.js on Power Pages:

1. **Load via CDN** in the page JS (not via a content snippet or web template):
```javascript
function loadChartJS(cb) {
  if (window.Chart) { cb(); return; }
  var s = document.createElement('script');
  s.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js';
  s.onload = cb;
  document.head.appendChild(s);
}
```

2. **Use a chart queue** for async loading — API calls may complete before Chart.js loads:
```javascript
var chartQ = [];
function whenChart(fn) { if (window.Chart) fn(); else chartQ.push(fn); }
loadChartJS(function() { chartQ.forEach(function(f) { f(); }); chartQ = []; });

// In API callbacks, use whenChart() to defer chart creation
apiGet('cr1a2_mytables', '...').done(function(data) {
  whenChart(function() { new Chart(ctx, config); });
});
```

3. **CRITICAL — Container sizing:** Use `height: 280px` (fixed), NOT `min-height: 280px`. Chart.js responsive mode + `min-height` causes an **infinite resize loop** where the chart grows the container, triggering another resize, forever. Always use fixed `height` on chart containers.

## Cache Clearing

> **WARNING — Power Pages Studio Sync Side Effects:** When you sync the site cache via Studio, it may **modify your content**. Known mutations:
> - Converts GUID-based entity list/form references (e.g., `{% include 'entity_list' key: 'fbe9299c-...' %}`) to name-based references (e.g., `key: 'All Vendors List'`). **Name-based references do NOT work in the enhanced data model** — pages go blank.
> - Wraps page copy in additional `<div>` elements with double-quoted inline styles, which can break JSON escaping.
> - **After every Studio sync, verify critical pages** by reading their powerpagecomponents content and confirming GUID-based refs are intact. Restore them if Studio overwrote them.

After ANY portal record changes, sync the site cache:

1. **Power Pages Studio** → Click "Sync" button (most common)
2. **Admin Center** → Environments → Portal Actions → Restart
3. **Site URL** → `/_services/about` → "Clear Cache" (requires admin role)

> The `restart_power_pages_site` API does NOT support service principal auth. Cache clearing must be done via one of the methods above.

## Common Gotchas (Complete Reference)

### Page Creation

| Issue | Cause | Fix |
|-------|-------|-----|
| `"Partial URL of Home Page can only be /"` | Content page missing `mspp_parentpageid` | Always set `mspp_parentpageid` on content pages to the root page's PARENT |
| Content page not rendering | Missing `mspp_webpagelanguageid` or `mspp_rootwebpageid` | Both required on content pages |
| Page not visible | Publishing state = Draft | Set to Published state |
| Duplicate content pages = blank | Auto-created content page conflicts | Delete auto-created empty content page; keep only your custom one |
| `mspp_webpages` PATCH fails with "Partial URL of Home Page" | CUDFromSingleEntity plugin treats PATCH as CREATE | **Never PATCH `mspp_webpages`** — always update via `powerpagecomponents` content JSON instead |
| Entity list/form refs break after Studio sync | Power Pages Studio cache sync converts GUID-based `{% include 'entity_list' key: 'GUID' %}` to name-based `key: 'Entity List Name'`, which don't resolve in enhanced data model | After Studio sync, verify and restore GUID-based references in content page copy via powerpagecomponents update |

### Liquid / FetchXML

| Issue | Cause | Fix |
|-------|-------|-----|
| Fetchxml returns 0 results | Table permissions not configured or missing `entitylogicalname` | Create table permission with both `entityname` and `entitylogicalname` |
| `Liquid error: Exception has been thrown` | Invalid column name in fetchxml | Run `dataverse_list_columns` and verify every column name |
| Choice field displays blank | `{{ item.field }}` on Picklist returns OptionSetValue object | Use `{{ item.field.Label }}` for text or `{{ item.field.Value }}` for integer |
| `{{ page.copy }}` renders empty | Liquid uses `adx_` prefix internally | Use `{% include 'Page Copy' %}` instead |
| `{% entityform %}` bare = syntax error | Enhanced model requires explicit params | Use `{% entityform name: 'Form Name' %}` (MUST use `name:` not `id:` for edit mode) |
| Edit-mode form shows "record not found" | Wrong `mode` or `entitysourcetype` or using `id:` parameter | Use `dataverse_pp_create_basic_form` with `form_mode: Edit`. This sets `mode: 100000001` and `entitysourcetype: "756150001"` (STRING). Page must use `{% entityform name: %}` not `id:` |
| `{% entitylist %}{% endentitylist %}` = blank | `{% entitylist %}` is a context block, not a renderer | Use `{% include 'entity_list' key: 'guid' %}` in page copy |
| Liquid tags error in false conditionals | Liquid parses ALL tags before evaluating | Use separate templates per page type |

### Permissions & Security

| Issue | Cause | Fix |
|-------|-------|-----|
| 403 on `/_api/` calls | Missing table permission OR missing Webapi site settings | Create both table permission AND `Webapi/{entity}/enabled` + `Webapi/{entity}/fields` site settings |
| 404 on `/_api/` calls | Missing `Webapi/{entity}/enabled` site setting for this specific table | Create `Webapi/{logical_name}/enabled` = `true` AND `Webapi/{logical_name}/fields` site settings |
| Dashboard shows all zeros / blank charts | One `/_api/` call returns 404 and `$.when()` kills all promises | 1) Create missing Web API site settings for ALL tables 2) Replace `$.when()` with independent `.done()/.fail()` calls |
| Table permission exists but 403/0 results | Missing `entitylogicalname` in content JSON | Update powerpagecomponents content to include both `entityname` (display) and `entitylogicalname` (logical) |
| N:N web role association doesn't persist | Virtual entity `$ref` silently succeeds but doesn't write | Set `adx_entitypermission_webrole` array in content JSON |
| `$expand` on N:N shows empty | Virtual entity N:N expand is unreliable | Content JSON's `adx_entitypermission_webrole` array is the source of truth |

### JavaScript & CSS Patterns

| Issue | Cause | Fix |
|-------|-------|-----|
| Chart.js bar/pie/doughnut grows infinitely | `min-height` on chart container triggers Chart.js responsive resize loop | Use fixed `height: 280px` on chart containers, NEVER `min-height` |
| Dashboard shows 0s / blank when one API fails | `$.when()` rejects ALL promises if ANY single call fails | Use individual `$.ajax().done().fail()` calls with per-section error states |
| Chart not rendering despite data loaded | Chart.js CDN not yet loaded when API `.done()` fires | Use chart queue pattern: `whenChart(fn)` defers until Chart.js `onload` fires |
| Custom page design clashes with site theme | Custom CSS uses different font/color/background than the site's Bootstrap theme | Run theme detection pre-flight, pass results to frontend-design skill |
| Entity list grid enhancements don't apply | Grid rows not yet rendered when JS runs | Use `setInterval` polling (250ms, max 40 attempts) to wait for `.entitylist table tbody tr`, then apply enhancements + `MutationObserver` for re-renders on pagination |
| Picklist option values wrong | Assumed `100000000` base but env uses `190000000` | ALWAYS query `dataverse_get_column` for actual option set values before using in JS status maps |

### Virtual Entity / powerpagecomponents

| Issue | Cause | Fix |
|-------|-------|-----|
| `mspp_entityformid` / `mspp_entitylistid` undeclared | Virtual entity doesn't expose these | Use `{% entityform name: 'Form Name' %}` or `{% include 'entity_list' key: 'guid' %}` in page copy |
| Entity list binding on root page doesn't propagate | `_mspp_entitylist_value` remains null despite content JSON | Render via Liquid `{% include 'entity_list' key: 'guid' %}` in content page copy |
| `mspp_entityforms` PATCH fails | Virtual entity plugin serialization bug | Update via powerpagecomponents content JSON |
| JSON escaping breaks content | Double quotes in HTML conflict with JSON | Use single quotes for ALL HTML/fetchxml attributes |

## MCP Tools Reference

### Dedicated Power Pages Tools (use these first)

| Tool | Purpose |
|------|---------|
| `dataverse_pp_create_web_page` | Create web page with correct 2-step process. Returns root + content page IDs. |
| `dataverse_pp_create_basic_form` | Create basic form with correct mode/entitysourcetype. Returns Liquid tag to use. |
| `dataverse_pp_create_multistep_form` | Create advanced form + steps + chaining. Returns Liquid tag to use. |
| `dataverse_pp_create_table_permission` | Create table permission with scope + web role array. |
| `dataverse_pp_create_web_api_settings` | Create both enabled + fields site settings. Auto-includes primary key. |
| `dataverse_pp_create_site_component` | Create snippets, markers, templates, links, redirects, settings, roles, access rules. |

### General Tools (also needed for Power Pages)

| Tool | Purpose |
|------|---------|
| `dataverse_list_power_pages_sites` | List sites with domain, state, data model version |
| `dataverse_get_power_pages_site` | Get site details + component counts by type |
| `dataverse_get_table` | Get table display name, logical name, entity set (REQUIRED before permissions) |
| `dataverse_list_columns` | Get ALL column logical names (REQUIRED before fetchxml) |
| `dataverse_get_column` | Get option set values for choice columns |
| `dataverse_query_records` | Query portal tables, Dataverse views, verify data |
| `dataverse_create_record` | Create records (use for seeding data, NOT for portal components — use pp_ tools) |
| `dataverse_update_record` | Update powerpagecomponents content (for page content updates after initial creation) |
| `dataverse_get_record` | Read specific record by ID |
| `dataverse_build_form` | Build Dataverse forms with tabs/sections/fields (for creating the form definitions that basic forms reference) |

## Playwright Sync Automation

After ANY Power Pages component changes (pages, forms, permissions, settings), the portal cache must be refreshed. Use this Playwright pattern:

### Setup: Open persistent Design Studio tab (do once per session)

```javascript
// Open studio tab — keep it open for the entire session
const studioPage = await page.context().newPage();
await studioPage.goto('https://make.powerpages.microsoft.com/e/{env-id}/sites/{site-id}/pages');
await studioPage.waitForTimeout(15000); // Wait for studio to load
```

The `{env-id}` is the Power Platform environment ID (from `pac env who`), and `{site-id}` is the Power Pages site GUID.

### Quick Sync (reuse the open tab — no crashes)

```javascript
const studioTab = page.context().pages().find(p => p.url().includes('make.powerpages'));
await studioTab.getByTestId('commandBar-farItems-sync').click();
await studioTab.waitForTimeout(8000);
```

**CRITICAL: NEVER navigate away from the studio tab.** The Design Studio fires a `beforeunload` dialog when leaving, which crashes the Playwright MCP. Always use a separate tab for the portal.

### Validation After Sync

After syncing, navigate to the portal page in the ORIGINAL tab (not the studio tab) and use Playwright to:
1. Screenshot the page
2. Check console for errors via `browser_console_messages`
3. Verify data loads correctly

## Content Security Policy (CSP)

Power Pages sites MUST include these domains in the CSP site setting or platform JS/CSS will be blocked:

```
HTTP/Content-Security-Policy:
  script-src 'self' https://content.powerapps.com https://*.powerapps.com https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval';
  style-src 'self' https://content.powerapps.com https://*.powerapps.com https://cdn.jsdelivr.net 'unsafe-inline';
```

Missing `content.powerapps.com` blocks ALL platform JavaScript (jQuery, Bootstrap, PCF controls) — pages render static HTML/Liquid but all interactive features break.

## Key Tables Reference

| Table (Entity Set) | Purpose |
|---------------------|---------|
| `powerpagecomponents` | **Backing table for ALL `mspp_*` virtual entities** — primary update target |
| `powerpagesites` | Site-level records |
| `mspp_websites` | Website root record |
| `mspp_webpages` | Web pages (root + content) |
| `mspp_webtemplates` | Liquid/HTML templates |
| `mspp_pagetemplates` | Page template → web template mapping |
| `mspp_entityforms` | Basic forms |
| `mspp_entityformmetadatas` | Field-level form customization |
| `mspp_entitylists` | Entity lists (grids) |
| `mspp_webroles` | Portal security roles |
| `mspp_entitypermissions` | Table-level CRUD permissions |
| `mspp_sitesettings` | Key-value site configuration |
| `mspp_websitelanguages` | Language configuration |
| `mspp_publishingstates` | Publishing states (Draft/Published) |
| `mspp_weblinks` | Navigation menu items |
| `mspp_weblinksets` | Navigation menu groups |
| `mspp_contentsnippets` | Reusable content fragments |
| `mspp_webpageaccesscontrolrules` | Page-level access control |
| `savedqueries` | Dataverse views (used by entity lists) |

## Validation

After creating or updating Power Pages components:
- Clear the portal cache (`/_services/about` -> Clear Cache, or restart the portal in Power Platform admin center)
- Navigate to the portal page in a browser and verify it renders content (not blank)
- Check browser console for JavaScript errors, especially around `/_api/` calls and JSON parsing
- If data doesn't appear: verify table permissions, web roles, and site settings (`Webapi/{entity}/enabled` + `Webapi/{entity}/fields`) are all configured
- For complex pages with custom JS: consider `/validate-ui` for a visual spot-check with console error capture
- Report: pages created/updated, site settings configured, table permissions set, any cache-clear needed
