Power-Toolkit is a comprehensive, client-side developer tool designed to accelerate the development and debugging of Power Apps Model-Driven Apps. Built as a browser extension, it provides a suite of powerful features to inspect, debug, and manipulate form data, metadata, and server-side processes in real-time, directly within your browser.
The toolkit is organized into a clear, tab-based interface, with each tab providing a distinct and powerful capability:
OnLoad, OnSave, and OnChange events as they happenOnLoad/OnSave event handlersTarget, PreEntityImage, and PostEntityImage sent to server-side plugins. Includes a C# unit test generator for FakeXrmEasyInstall directly from your browserβs extension store:
npm install and npm run buildedge://extensions/chrome://extensions/extension/ folderContributions are welcome! Whether you want to fix a bug, add a new feature, or create a new tab, this guide will help you get started.
git clone https://github.com/khawatme/Power-Toolkit.git
cd Power-Toolkit
npm install
npm run dev
npm run build
Power-Toolkit/
βββ src/
β βββ components/ # Tab components (e.g., InspectorTab.js)
β βββ services/ # Business logic services
β βββ helpers/ # Utility functions (modular)
β βββ ui/ # UI factories and controls
β βββ utils/ # Utilities
β β βββ builders/ # ODataQueryBuilder
β β βββ parsers/ # ErrorParser
β β βββ resolvers/ # EntityContextResolver
β β βββ testing/ # Testing utilities
β β βββ ui/ # BusyIndicator, PreferencesHelper, ResultPanel
β βββ constants/ # Configuration and constants
β βββ core/ # Base classes and core infrastructure
β βββ data/ # Static data (code snippets, etc.)
β βββ assets/ # Styles, icons, and static assets
β βββ App.js # Main application entry point
β βββ Main.js # Bootstrap and initialization
βββ extension/
β βββ manifest.json # Extension manifest
β βββ background.cjs # Service worker
β βββ icons/ # Extension icons
βββ webpack.config.js # Webpack configuration
βββ package.json # Dependencies and scripts
Adding a new tab to Power-Toolkit is straightforward thanks to the modular architecture. Follow these steps:
Create a new file in src/components/ (e.g., MyCustomTab.js):
/**
* @file MyCustomTab - Description of what your tab does
* @module components/MyCustomTab
*/
import { BaseComponent } from '../core/BaseComponent.js';
import { ICONS } from '../assets/Icons.js';
import { Config } from '../constants/index.js';
import { DataService } from '../services/DataService.js';
import { NotificationService } from '../services/NotificationService.js';
import { UIFactory } from '../ui/UIFactory.js';
import { escapeHtml } from '../helpers/dom.helpers.js';
export class MyCustomTab extends BaseComponent {
constructor() {
super('mycustomtab', 'My Custom', ICONS.myIcon); // id, label, icon
this.ui = {}; // Store DOM references
// Handler references for cleanup (IMPORTANT: Prevents memory leaks)
/** @private {Function|null} */ this._executeHandler = null;
/** @private {Function|null} */ this._inputKeydownHandler = null;
}
/**
* Render the tab's UI
* @returns {Promise<HTMLElement>} The root element
*/
async render() {
const container = document.createElement('div');
container.innerHTML = `
<div class="pdt-section">
<div class="pdt-section-header">
<h3>My Custom Feature</h3>
<p class="pdt-note">Description of what this tab does</p>
</div>
<div class="pdt-card">
<div class="pdt-input-group">
<label for="my-input">Input Label:</label>
<input type="text" id="my-input" placeholder="Enter something...">
</div>
<div class="pdt-button-group">
<button id="my-execute-btn" class="pdt-button primary">
Execute
</button>
</div>
</div>
<div id="my-result-container"></div>
</div>
`;
return container;
}
/**
* Post-render lifecycle hook - called after DOM is attached
* IMPORTANT: Store handlers as instance properties for cleanup in destroy()
* @param {HTMLElement} element - The root element
*/
postRender(element) {
// Cache DOM references
this.ui = {
executeBtn: element.querySelector('#my-execute-btn'),
inputField: element.querySelector('#my-input'),
resultContainer: element.querySelector('#my-result-container')
};
// Store handlers for cleanup
this._executeHandler = () => this._handleExecute();
this._inputKeydownHandler = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._handleExecute();
}
};
// Attach listeners
this.ui.executeBtn?.addEventListener('click', this._executeHandler);
this.ui.inputField?.addEventListener('keydown', this._inputKeydownHandler);
}
/**
* Handle execute button click
* @private
*/
async _handleExecute() {
const input = this.ui.inputField?.value?.trim();
if (!input) {
NotificationService.show('Please enter a value', 'error');
return;
}
try {
// Show loading state
this.ui.executeBtn.disabled = true;
this.ui.executeBtn.textContent = 'Processing...';
// Your business logic here
const result = await DataService.retrieveRecord('account', input);
// Display results
this.ui.resultContainer.innerHTML = '';
this.ui.resultContainer.appendChild(
UIFactory.createCopyableCodeBlock(
JSON.stringify(result, null, 2),
'json'
)
);
NotificationService.show('Operation completed successfully', 'success');
} catch (error) {
NotificationService.show(`Error: ${error.message}`, 'error');
this.ui.resultContainer.innerHTML = `
<div class="pdt-card error">
<p><strong>Error:</strong> ${escapeHtml(error.message)}</p>
</div>
`;
} finally {
// Reset button state
this.ui.executeBtn.disabled = false;
this.ui.executeBtn.textContent = 'Execute';
}
}
/**
* Lifecycle hook for cleaning up event listeners
* CRITICAL: Always implement this to prevent memory leaks
*/
destroy() {
if (this.ui.executeBtn) {
this.ui.executeBtn.removeEventListener('click', this._executeHandler);
}
if (this.ui.inputField) {
this.ui.inputField.removeEventListener('keydown', this._inputKeydownHandler);
}
}
}
Open src/App.js and import your component:
import { MyCustomTab } from './components/MyCustomTab.js';
Then add it to the _registerComponents() method:
_registerComponents() {
// Existing components...
ComponentRegistry.register(new MyCustomTab());
// ... other components
}
Note: The tab metadata (id, label, icon) is defined in your componentβs constructor via super(). The UIManager automatically discovers all registered components, so no separate tab array is needed.
If your tab needs custom messages or configuration, add them to src/constants/:
messages.js:
export const MESSAGES = {
// ... existing messages
MY_CUSTOM_TAB: {
successMessage: 'Operation completed successfully',
errorMessage: 'Failed to process request',
validationError: 'Invalid input provided'
}
};
If you need custom styles, add them to src/assets/style.css:
/* My Custom Tab Styles */
#my-result-container {
margin-top: 1rem;
max-height: 400px;
overflow-y: auto;
}
.my-custom-class {
/* Your custom styles */
}
npm run dev to start development modeAlways implement proper cleanup to prevent memory leaks!
// β
CORRECT Pattern
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
// Initialize ALL handler properties to null
/** @private {Function|null} */ this._myHandler = null;
}
postRender(element) {
// Store handler as instance property
this._myHandler = () => { /* handler code */ };
element.addEventListener('click', this._myHandler);
}
destroy() {
// Remove listener using stored reference
if (element) {
element.removeEventListener('click', this._myHandler);
}
}
// β WRONG - Memory leak!
postRender(element) {
// Anonymous function - can't be removed later
element.addEventListener('click', () => { /* handler code */ });
}
Key Rules:
destroy() methoddestroy()BaseComponent for automatic lifecycle managementsuper(id, label, icon) with tab metadataasync render() to create and return HTMLElementpostRender(element) to cache DOM refs and attach event listenersdestroy() for cleanup (memory leak prevention)this.ui = {} during postRendersuper(id, label, icon), initialize this.ui = {} and handler properties to nullthis.ui, store handlers as instance properties, attach listenersNotificationService for user-facing errorsfinally blocksstyle.css:
.pdt-section for main containers.pdt-card for content cards.pdt-button for buttons (add primary for primary actions).pdt-input-group for form inputs.pdt-note for helper textUIFactory for common UI elements (code blocks, info grids, etc.)PerformanceHelpers.debounce())Power-Toolkit provides many helper utilities in src/helpers/:
import { escapeHtml, createElement } from '../helpers/dom.helpers.js';
import { isValidGuid, normalizeGuid } from '../helpers/index.js';
import { formatDisplayValue } from '../helpers/formatting.helpers.js';
import { copyToClipboard } from '../helpers/file.helpers.js';
// Usage
const safeText = escapeHtml(userInput);
const isValid = isValidGuid(recordId);
const displayText = formatDisplayValue(attribute.getValue(), attribute);
Power-Toolkit follows strict memory management patterns. Here are common patterns:
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._clickHandler = null;
}
postRender(element) {
this.ui.button = element.querySelector('#my-button');
this._clickHandler = () => { /* code */ };
this.ui.button?.addEventListener('click', this._clickHandler);
}
destroy() {
if (this.ui.button) {
this.ui.button.removeEventListener('click', this._clickHandler);
}
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._delegatedHandler = null;
}
postRender(element) {
this.ui.container = element.querySelector('#container');
this._delegatedHandler = (e) => {
const target = e.target.closest('.my-item');
if (target) { /* handle */ }
};
this.ui.container?.addEventListener('click', this._delegatedHandler);
}
destroy() {
if (this.ui.container) {
this.ui.container.removeEventListener('click', this._delegatedHandler);
}
}
import { debounce } from '../helpers/index.js';
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._debouncedSearch = null;
}
postRender(element) {
this.ui.searchInput = element.querySelector('#search-input');
this._debouncedSearch = debounce(() => {
// Search logic
}, 250);
this.ui.searchInput?.addEventListener('input', this._debouncedSearch);
}
destroy() {
if (this.ui.searchInput) {
this.ui.searchInput.removeEventListener('input', this._debouncedSearch);
}
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._globalHandler = null;
}
postRender(element) {
this._globalHandler = (e) => { /* code */ };
document.addEventListener('pdt:refresh', this._globalHandler);
}
destroy() {
// IMPORTANT: Remove document-level listeners!
document.removeEventListener('pdt:refresh', this._globalHandler);
}
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._inputHandler = null;
}
postRender(element) {
this.ui.inputs = [
element.querySelector('#input1'),
element.querySelector('#input2'),
element.querySelector('#input3')
];
this._inputHandler = () => this._updatePreview();
this.ui.inputs.forEach(input => {
input?.addEventListener('input', this._inputHandler);
});
}
destroy() {
this.ui.inputs?.forEach(input => {
if (input) {
input.removeEventListener('input', this._inputHandler);
}
});
}
Common Mistakes to Avoid:
// β WRONG: Closure captures scope - memory leak
postRender(element) {
const data = this.getData();
const button = element.querySelector('#my-button');
button.addEventListener('click', () => {
console.log(data); // Captures 'data' and 'this'
});
}
// β
CORRECT: Use instance property
constructor() {
super('mytab', 'My Tab', ICONS.myIcon);
this.ui = {};
/** @private {Function|null} */ this._buttonHandler = null;
}
postRender(element) {
this.ui.button = element.querySelector('#my-button');
this._buttonHandler = () => {
console.log(this.getData()); // No closure leak
};
this.ui.button.addEventListener('click', this._buttonHandler);
}
Before submitting your tab:
destroy() method implemented with all event listener cleanupPower-Toolkit uses Vitest as its testing framework with comprehensive test coverage. All tests run in a simulated browser environment using happy-dom.
# Run all tests once
npm test
# Run tests in watch mode (re-runs on file changes)
npm run test:watch
# Run tests with coverage report
npm run test:coverage
# Run specific test file
npm test -- tests/ui/FilterGroupManager.test.js
# Run tests matching a pattern
npm test -- --grep "SmartValueInput"
Tests are organized to mirror the source code structure:
tests/
βββ setup.js # Global test setup and mocks
βββ components/ # Tab component tests
β βββ InspectorTab.test.js
β βββ WebApiExplorerTab.test.js
β βββ FetchXmlTesterTab.test.js
β βββ ...
βββ services/ # Service layer tests
β βββ DataService.test.js
β βββ MetadataService.test.js
β βββ NotificationService.test.js
β βββ ...
βββ helpers/ # Helper function tests
β βββ dom.helpers.test.js
β βββ string.helpers.test.js
β βββ ...
βββ ui/ # UI component tests
β βββ FilterGroupManager.test.js
β βββ SmartValueInput.test.js
β βββ FormControlFactory.test.js
β βββ ...
βββ utils/ # Utility tests
βββ ui/
β βββ BusyIndicator.test.js
β βββ PreferencesHelper.test.js
β βββ ResultPanel.test.js
βββ parsers/
βββ ...
/**
* @file Tests for MyComponent
* @module tests/components/MyComponent.test.js
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MyComponent } from '../../src/components/MyComponent.js';
describe('MyComponent', () => {
let component;
beforeEach(() => {
vi.clearAllMocks();
component = new MyComponent();
});
afterEach(() => {
component?.cleanup?.();
});
describe('constructor', () => {
it('should initialize with correct defaults', () => {
expect(component.id).toBe('mycomponent');
expect(component.ui).toEqual({});
});
});
describe('render', () => {
it('should create container element', async () => {
const element = await component.render();
expect(element).toBeInstanceOf(HTMLElement);
expect(element.querySelector('.my-class')).toBeTruthy();
});
});
describe('_handleClick', () => {
it('should process data correctly', () => {
const result = component._handleClick('test');
expect(result).toBe('expected');
});
});
});
import { vi } from 'vitest';
// Mock a service before importing the component
vi.mock('../../src/services/DataService.js', () => ({
DataService: {
retrieveRecord: vi.fn().mockResolvedValue({ name: 'Test' }),
fetchRecords: vi.fn().mockResolvedValue([])
}
}));
// Import component after mocks
import { MyComponent } from '../../src/components/MyComponent.js';
import { DataService } from '../../src/services/DataService.js';
describe('MyComponent with mocked service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call DataService with correct params', async () => {
const component = new MyComponent();
await component._loadData('account');
expect(DataService.retrieveRecord).toHaveBeenCalledWith('account');
});
it('should handle service errors', async () => {
DataService.retrieveRecord.mockRejectedValue(new Error('API Error'));
const component = new MyComponent();
await expect(component._loadData()).rejects.toThrow('API Error');
});
});
describe('DOM interactions', () => {
let component;
let container;
beforeEach(async () => {
component = new MyComponent();
container = await component.render();
document.body.appendChild(container);
component.postRender(container);
});
afterEach(() => {
component.cleanup?.();
container?.remove();
});
it('should update UI on button click', () => {
const button = container.querySelector('#my-button');
const output = container.querySelector('#output');
button.click();
expect(output.textContent).toBe('Clicked!');
});
it('should handle input changes', () => {
const input = container.querySelector('#my-input');
input.value = 'test value';
input.dispatchEvent(new Event('input'));
expect(component.currentValue).toBe('test value');
});
});
describe('async operations', () => {
it('should show loading state during fetch', async () => {
// Delay the mock response
DataService.fetchRecords.mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([]), 100))
);
const component = new MyComponent();
const container = await component.render();
const loadPromise = component._loadData();
// Check loading state
expect(container.querySelector('.loading')).toBeTruthy();
await loadPromise;
// Check loading cleared
expect(container.querySelector('.loading')).toBeFalsy();
});
});
should show error when input is emptytests/components/MyNewTab.test.jsrender() outputpostRender() event bindingcleanup() methodnpm test -- tests/components/MyNewTab.test.jsnpm run test:coveragegit checkout -b feature/my-new-feature
git commit -m "Add: MyCustomTab for feature X"
git push origin feature/my-new-feature
Type: Short description (50 chars max)
Longer description if needed (wrap at 72 chars)
Fixes #issue-number (if applicable)
Types: Add, Fix, Update, Remove, Refactor, Docs, Style
Found a bug? Have a feature request? Please open an issue with:
This project is licensed under the MIT License - see the LICENSE file for details.
Made with β€οΈ for Power Platform Developers