UNPKG

powerplatform-mcp

Version:

PowerPlatform Model Context Protocol server

238 lines (237 loc) 13.1 kB
import { randomUUID } from 'crypto'; /** Standard classid for a generic form control (works for text, money, picklist, lookup, etc.) */ const STANDARD_CONTROL_CLASSID = '{4273EDBD-AC1D-40d3-9FB2-095C621B552D}'; /** * Service for managing Dataverse forms (systemform) and views (savedquery). */ export class FormViewService { client; constructor(client) { this.client = client; } // ─── FORMS ───────────────────────────────────────────────────────────────── /** * List forms for an entity. * @param entityLogicalName The entity logical name * @param type Form type filter (default: 2 = Main) */ async getEntityForms(entityLogicalName, type) { // objecttypecode on systemforms accepts the entity logical name as a string. let filter = `objecttypecode eq '${entityLogicalName}'`; if (type !== undefined) filter += ` and type eq ${type}`; const result = await this.client.get(`api/data/v9.2/systemforms?$select=formid,name,type,description&$filter=${filter}`); return result.value ?? []; } /** * Get the fields currently on a form by parsing its formxml. * Returns the list of `datafieldname` values found in `<control>` elements. */ async getFormFields(formId) { const form = await this.client.get(`api/data/v9.2/systemforms(${formId})?$select=formxml`); const xml = form.formxml ?? ''; const fields = []; const re = /datafieldname="([^"]+)"/gi; let match; while ((match = re.exec(xml)) !== null) { fields.push(match[1]); } return fields; } /** * Add a field to a form. Appends a new row/cell/control at the end of the first * section in the first tab. If the field is already present, this is a no-op. * * @param formId The systemform GUID * @param attributeName The logical name of the attribute to add * @param entityLogicalName Used only for publishing after the change */ async addFormField(formId, attributeName, entityLogicalName) { const form = await this.client.get(`api/data/v9.2/systemforms(${formId})?$select=formxml`); let xml = form.formxml ?? ''; // Check if the field is already on the form. if (xml.toLowerCase().includes(`datafieldname="${attributeName.toLowerCase()}"`)) { return { added: false }; } // Build the new row element. const cellId = `{${randomUUID()}}`; const newRow = `<row><cell id="${cellId}" showlabel="true" locklevel="0"><labels><label description="${attributeName}" languagecode="1033" /></labels><control id="${attributeName}" classid="${STANDARD_CONTROL_CLASSID}" datafieldname="${attributeName}" /></cell></row>`; // Insert before the closing </rows> of the first section. const rowsCloseIdx = xml.indexOf('</rows>'); if (rowsCloseIdx === -1) { throw new Error('Could not find </rows> in formxml — form may have an unexpected structure.'); } xml = xml.substring(0, rowsCloseIdx) + newRow + xml.substring(rowsCloseIdx); // Patch the form. await this.client.patch(`api/data/v9.2/systemforms(${formId})`, { formxml: xml }); // Publish the entity. await this.publishEntity(entityLogicalName); return { added: true }; } /** * Remove a field from a form. Removes the entire `<row>` containing the field's control. * If the field is not on the form, this is a no-op. * * @param formId The systemform GUID * @param attributeName The logical name of the attribute to remove * @param entityLogicalName Used only for publishing after the change */ async removeFormField(formId, attributeName, entityLogicalName) { const form = await this.client.get(`api/data/v9.2/systemforms(${formId})?$select=formxml`); let xml = form.formxml ?? ''; // Build a regex that matches the <row> containing the target control. // Use a non-greedy match between <row> and </row> to avoid over-matching. const escaped = attributeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const rowRegex = new RegExp(`<row>[\\s\\S]*?datafieldname="${escaped}"[\\s\\S]*?<\\/row>`, 'i'); const newXml = xml.replace(rowRegex, ''); if (newXml === xml) { return { removed: false }; } await this.client.patch(`api/data/v9.2/systemforms(${formId})`, { formxml: newXml }); await this.publishEntity(entityLogicalName); return { removed: true }; } // ─── VIEWS ───────────────────────────────────────────────────────────────── /** * List views (saved queries) for an entity. * @param entityLogicalName The entity logical name */ async getEntityViews(entityLogicalName) { // returnedtypecode on savedqueries accepts the entity logical name as a string. const result = await this.client.get(`api/data/v9.2/savedqueries?$select=savedqueryid,name,querytype,isdefault,description&$filter=returnedtypecode eq '${entityLogicalName}'`); return result.value ?? []; } /** * Get the columns currently in a view by parsing its layoutxml. */ async getViewColumns(viewId) { const view = await this.client.get(`api/data/v9.2/savedqueries(${viewId})?$select=layoutxml`); return this.parseLayoutColumns(view.layoutxml ?? ''); } /** Extract column names from <cell name="..."> elements in layoutxml. */ parseLayoutColumns(layoutxml) { const cols = []; const re = /<cell\s+name="([^"]+)"/gi; let match; while ((match = re.exec(layoutxml)) !== null) { cols.push(match[1]); } return cols; } /** * Add a column to a view. Appends to both layoutxml and fetchxml. * * @param viewId The savedquery GUID * @param attributeName The logical name of the attribute to add * @param width Column width in pixels (default 150) * @param entityLogicalName Used only for publishing */ async addViewColumn(viewId, attributeName, entityLogicalName, width = 150) { const view = await this.client.get(`api/data/v9.2/savedqueries(${viewId})?$select=layoutxml,fetchxml`); let layoutxml = view.layoutxml ?? ''; let fetchxml = view.fetchxml ?? ''; // Check if already present. if (layoutxml.toLowerCase().includes(`name="${attributeName.toLowerCase()}"`)) { return { added: false }; } // Add to layoutxml: insert <cell name="..." width="..." /> before </row> const rowCloseIdx = layoutxml.indexOf('</row>'); if (rowCloseIdx === -1) { throw new Error('Could not find </row> in layoutxml.'); } layoutxml = layoutxml.substring(0, rowCloseIdx) + `<cell name="${attributeName}" width="${width}" />` + layoutxml.substring(rowCloseIdx); // Add to fetchxml: insert <attribute name="..." /> before </entity> const entityCloseIdx = fetchxml.indexOf('</entity>'); if (entityCloseIdx === -1) { throw new Error('Could not find </entity> in fetchxml.'); } fetchxml = fetchxml.substring(0, entityCloseIdx) + `<attribute name="${attributeName}" />` + fetchxml.substring(entityCloseIdx); await this.client.patch(`api/data/v9.2/savedqueries(${viewId})`, { layoutxml, fetchxml }); await this.publishEntity(entityLogicalName); return { added: true }; } /** * Remove a column from a view. * * @param viewId The savedquery GUID * @param attributeName The logical name of the attribute to remove * @param entityLogicalName Used only for publishing */ async removeViewColumn(viewId, attributeName, entityLogicalName) { const view = await this.client.get(`api/data/v9.2/savedqueries(${viewId})?$select=layoutxml,fetchxml`); let layoutxml = view.layoutxml ?? ''; let fetchxml = view.fetchxml ?? ''; // Prevent removing the last column — Dataverse rejects an empty <row>. const currentCols = this.parseLayoutColumns(layoutxml); if (currentCols.length <= 1) { throw new Error(`Cannot remove '${attributeName}' — it is the only column on the view. Dataverse requires at least one column.`); } const escaped = attributeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const cellRegex = new RegExp(`<cell\\s+name="${escaped}"[^/]*/\\s*>`, 'i'); const attrRegex = new RegExp(`<attribute\\s+name="${escaped}"[^/]*/\\s*>`, 'i'); const newLayout = layoutxml.replace(cellRegex, ''); const newFetch = fetchxml.replace(attrRegex, ''); if (newLayout === layoutxml && newFetch === fetchxml) { return { removed: false }; } await this.client.patch(`api/data/v9.2/savedqueries(${viewId})`, { layoutxml: newLayout, fetchxml: newFetch }); await this.publishEntity(entityLogicalName); return { removed: true }; } /** * Replace the entire column set of a view. * This is the safest way to reconfigure view columns — it rewrites both layoutxml * and fetchxml in one operation, preserving filters and ordering. * * @param viewId The savedquery GUID * @param columns Array of { name: string, width?: number } in display order * @param entityLogicalName Used for publishing and for fetchxml entity name * @param orderBy Optional attribute to sort by (default: first column ascending) * @param orderDescending Sort descending (default false) */ async setViewColumns(viewId, columns, entityLogicalName, orderBy, orderDescending = false) { if (columns.length === 0) { throw new Error('At least one column is required.'); } // Read current view to preserve the <grid> attributes (object, jump, etc.) const view = await this.client.get(`api/data/v9.2/savedqueries(${viewId})?$select=layoutxml,fetchxml`); const currentLayout = view.layoutxml ?? ''; // Extract the <grid ...> opening tag to preserve object, jump, select, icon, preview attributes. const gridMatch = currentLayout.match(/<grid\s[^>]+>/i); const gridOpen = gridMatch ? gridMatch[0] : '<grid name="resultset" select="1" icon="1" preview="1">'; // Extract the row id attribute (usually the entity's primary key). const rowIdMatch = currentLayout.match(/id="([^"]+)"/i); const rowId = rowIdMatch ? rowIdMatch[1] : `${entityLogicalName}id`; // Build layoutxml const cells = columns.map(c => `<cell name="${c.name}" width="${c.width ?? 150}" />`).join(''); const layoutxml = `${gridOpen}<row name="result" id="${rowId}">${cells}</row></grid>`; // Build fetchxml — preserve existing filters from the current fetchxml. const currentFetch = view.fetchxml ?? ''; // Extract everything between <entity ...> and </entity> that is NOT an <attribute> or <order> element. const entityContentMatch = currentFetch.match(/<entity\s+name="[^"]*">([\s\S]*)<\/entity>/i); let preservedContent = ''; if (entityContentMatch) { // Remove existing <attribute> and <order> elements, keep <filter>, <link-entity>, etc. preservedContent = entityContentMatch[1] .replace(/<attribute\s+name="[^"]*"\s*\/>/gi, '') .replace(/<order\s[^/]*\/>/gi, '') .trim(); } const attrs = columns.map(c => `<attribute name="${c.name}" />`).join(''); const pkAttr = `<attribute name="${rowId}" />`; const sortAttr = orderBy ?? columns[0].name; const order = `<order attribute="${sortAttr}" descending="${orderDescending}" />`; const fetchxml = `<fetch version="1.0" output-format="xml-platform" mapping="logical"><entity name="${entityLogicalName}">${attrs}${pkAttr}${order}${preservedContent ? preservedContent : ''}</entity></fetch>`; await this.client.patch(`api/data/v9.2/savedqueries(${viewId})`, { layoutxml, fetchxml }); await this.publishEntity(entityLogicalName); return { columns: columns.map(c => c.name) }; } // ─── Helpers ─────────────────────────────────────────────────────────────── async publishEntity(entityLogicalName) { await this.client.post('api/data/v9.2/PublishXml', { ParameterXml: `<importexportxml><entities><entity>${entityLogicalName}</entity></entities></importexportxml>` }); } }