diff --git a/__sample-components__/GridControl/generated/ManifestTypes.d.ts b/__sample-components__/GridControl/generated/ManifestTypes.d.ts new file mode 100644 index 0000000..47954c0 --- /dev/null +++ b/__sample-components__/GridControl/generated/ManifestTypes.d.ts @@ -0,0 +1,11 @@ +/* +*This is auto generated from the ControlManifest.Input.xml file +*/ + +// Define IInputs and IOutputs Type. They should match with ControlManifest. +export interface IInputs { + records: ComponentFramework.PropertyTypes.DataSet; +} +export interface IOutputs { + FilteredRecordCount?: number; +} diff --git a/__sample-components__/GridControl/index.ts b/__sample-components__/GridControl/index.ts new file mode 100644 index 0000000..7aa0d9e --- /dev/null +++ b/__sample-components__/GridControl/index.ts @@ -0,0 +1,57 @@ +import { IInputs, IOutputs } from './generated/ManifestTypes'; + +export class GridControl implements ComponentFramework.StandardControl { + notifyOutputChanged: () => void; + container: HTMLDivElement; + filteredRecordCount?: number; + + /** + * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here. + * Data-set values are not initialized here, use updateView. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions. + * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously. + * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface. + * @param container If a control is marked control-type='standard', it will receive an empty div element within which it can render its content. + */ + public init( + context: ComponentFramework.Context, + notifyOutputChanged: () => void, + state: ComponentFramework.Dictionary, + container: HTMLDivElement, + ): void { + this.notifyOutputChanged = notifyOutputChanged; + this.container = container; + } + + /** + * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc. + * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions + */ + public updateView(context: ComponentFramework.Context): void { + const dataset = context.parameters.records; + const datasetChanged = context.updatedProperties.indexOf('dataset') > -1; + if (datasetChanged) { + if (this.filteredRecordCount !== dataset.sortedRecordIds.length) { + this.filteredRecordCount = dataset.sortedRecordIds.length; + this.notifyOutputChanged(); + } + } + this.container.innerHTML = `${this.filteredRecordCount}`; + } + + /** + * It is called by the framework prior to a control receiving new data. + * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output” + */ + public getOutputs(): IOutputs { + return { + FilteredRecordCount: this.filteredRecordCount, + } as IOutputs; + } + + /** + * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup. + * i.e. cancelling any pending remote calls, removing listeners, etc. + */ + destroy(): void {} +} diff --git a/__tests__/Components/GridControl.test.ts b/__tests__/Components/GridControl.test.ts new file mode 100644 index 0000000..6fa4df1 --- /dev/null +++ b/__tests__/Components/GridControl.test.ts @@ -0,0 +1,43 @@ +/* + Copyright (c) 2022 Betim Beja and Shko Online LLC + Licensed under the MIT license. +*/ + +import { it, expect, describe, beforeEach } from '@jest/globals'; +import { ComponentFrameworkMockGenerator, DataSetMock } from '../../src'; +import { GridControl } from '../../__sample-components__/GridControl'; +import { IInputs, IOutputs } from '../../__sample-components__/GridControl/generated/ManifestTypes'; + +describe('GridControl', () => { + let mockGenerator: ComponentFrameworkMockGenerator; + beforeEach(() => { + const container = document.createElement('div'); + mockGenerator = new ComponentFrameworkMockGenerator( + GridControl, + { + records: DataSetMock, + }, + container, + ); + mockGenerator.context._parameters.records._InitItems([ + { + name: 'Betim', + surname: 'Beja', + }, + ]); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = null; + }); + + it('Should render count', (done) => { + mockGenerator.context._parameters.records._onLoaded.callsFake(() => { + expect(mockGenerator.container).toMatchSnapshot(); + done(); + }); + mockGenerator.ExecuteInit(); + mockGenerator.ExecuteUpdateView(); + }); +}); diff --git a/__tests__/Components/__snapshots__/GridControl.test.ts.snap b/__tests__/Components/__snapshots__/GridControl.test.ts.snap new file mode 100644 index 0000000..7302a9f --- /dev/null +++ b/__tests__/Components/__snapshots__/GridControl.test.ts.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GridControl Should render count 1`] = ` +
+ 1 +
+`; diff --git a/__tests__/DataSetMock.test.ts b/__tests__/DataSetMock.test.ts index 65b39ce..6219ab3 100644 --- a/__tests__/DataSetMock.test.ts +++ b/__tests__/DataSetMock.test.ts @@ -53,6 +53,7 @@ describe('DataSetMock', () => { }); dataset = new DataSetMock('dataset', db); + dataset._loading = false; }); describe('with id', () => { @@ -79,7 +80,7 @@ describe('DataSetMock', () => { expect(dataset.getSelectedRecordIds()).toEqual([]); }); - it('sortedRecordIds should contain the right data', () => { + it('sortedRecordIds should contain the right data', () => { expect(dataset.sortedRecordIds).toEqual(['1', '2']); }); diff --git a/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator-React.ts b/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator-React.ts index 4ef3499..a034ca5 100644 --- a/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator-React.ts +++ b/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator-React.ts @@ -19,7 +19,6 @@ import { mockGetEntityMetadata } from './mockGetEntityMetadata'; import { mockSetControlState } from './mockSetControlState'; import { mockSetControlResource } from './mockSetControlResource'; import { mockRefreshParameters } from './mockRefreshParameters'; -import { mockNotifyOutputChanged } from './mockNotifyOutputChanged'; export class ComponentFrameworkMockGeneratorReact< TInputs extends ShkoOnline.PropertyTypes, @@ -27,6 +26,7 @@ export class ComponentFrameworkMockGeneratorReact< > implements MockGenerator { RefreshParameters: SinonStub<[], void>; + RefreshDatasets: SinonStub<[], void>; context: ContextMock; control: SinonSpiedInstance>; notifyOutputChanged: SinonStub<[], void>; @@ -47,6 +47,7 @@ export class ComponentFrameworkMockGeneratorReact< this.onOutputChanged = stub(); this.RefreshParameters = stub(); mockRefreshParameters(this); + this.RefreshDatasets = stub(); this.SetControlResource = stub(); mockSetControlResource(this); mockSetControlState(this); diff --git a/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator.ts b/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator.ts index cc0dbf8..a6629fa 100644 --- a/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator.ts +++ b/src/ComponentFramework-Mock-Generator/ComponentFramework-Mock-Generator.ts @@ -16,17 +16,21 @@ import { mockRefreshParameters } from './mockRefreshParameters'; import { mockNotifyOutputChanged } from './mockNotifyOutputChanged'; import { ContextMock } from '../ComponentFramework-Mock'; import { showBanner } from '../utils'; +import { MockGenerator } from './MockGenerator'; +import { mockRefreshDatasets } from './mockRefreshDatasets'; export class ComponentFrameworkMockGenerator< TInputs extends ShkoOnline.PropertyTypes, TOutputs extends ShkoOnline.KnownTypes, -> { +> implements MockGenerator +{ RefreshParameters: SinonStub<[], void>; + RefreshDatasets: SinonStub<[], void>; container: HTMLDivElement; context: ContextMock; control: SinonSpiedInstance>; notifyOutputChanged: SinonStub<[], void>; - onOutputChanged: SinonStub<[],void>; + onOutputChanged: SinonStub<[], void>; state: ComponentFramework.Dictionary; SetControlResource: SinonStub<[resource: string], void>; metadata: MetadataDB; @@ -60,6 +64,8 @@ export class ComponentFrameworkMockGenerator< this.onOutputChanged = stub(); this.RefreshParameters = stub(); mockRefreshParameters(this); + this.RefreshDatasets = stub(); + mockRefreshDatasets(this, this.ExecuteUpdateView.bind(this)); this.SetControlResource = stub(); mockSetControlResource(this); mockSetControlState(this); @@ -74,5 +80,6 @@ export class ComponentFrameworkMockGenerator< ExecuteUpdateView() { this.RefreshParameters(); this.control.updateView(this.context); + this.RefreshDatasets(); } } diff --git a/src/ComponentFramework-Mock-Generator/MockGenerator.ts b/src/ComponentFramework-Mock-Generator/MockGenerator.ts index 181f002..3fc8321 100644 --- a/src/ComponentFramework-Mock-Generator/MockGenerator.ts +++ b/src/ComponentFramework-Mock-Generator/MockGenerator.ts @@ -20,6 +20,13 @@ export interface MockGenerator< */ RefreshParameters: SinonStub<[], void>; + /** + * Used to refresh the dataset parameters that might be still loading + * + * This is called internally by the framework at each UpdateView. + */ + RefreshDatasets: SinonStub<[], void>; + /** * Mocked context that will be passed to the component in the init or update calls. */ diff --git a/src/ComponentFramework-Mock-Generator/ReactResizeObserver.ts b/src/ComponentFramework-Mock-Generator/ReactResizeObserver.ts index a7dae2b..4d7bb8d 100644 --- a/src/ComponentFramework-Mock-Generator/ReactResizeObserver.ts +++ b/src/ComponentFramework-Mock-Generator/ReactResizeObserver.ts @@ -9,6 +9,7 @@ import type { ShkoOnline } from '../ShkoOnline'; import { createElement, Fragment, useEffect, useRef, useState } from 'react'; import { ComponentFrameworkMockGeneratorReact } from './ComponentFramework-Mock-Generator-React'; import { mockNotifyOutputChanged } from './mockNotifyOutputChanged'; +import { mockRefreshDatasets } from './mockRefreshDatasets'; export interface ReactResizeObserverProps< TInputs extends ShkoOnline.PropertyTypes, @@ -34,18 +35,24 @@ export const ReactResizeObserver = < componentFrameworkMockGeneratorReact, componentFrameworkMockGeneratorReact.control.getOutputs?.bind(componentFrameworkMockGeneratorReact.control), () => { - Object.getOwnPropertyNames>( - componentFrameworkMockGeneratorReact.context.parameters, - ).forEach((propertyName) => { - componentFrameworkMockGeneratorReact.context._parameters[propertyName]._Refresh(); - }); + componentFrameworkMockGeneratorReact.RefreshParameters(); setComponent( componentFrameworkMockGeneratorReact.control.updateView( componentFrameworkMockGeneratorReact.context, ), ); - }); - + + componentFrameworkMockGeneratorReact.RefreshDatasets(); + + }, + ); + + mockRefreshDatasets(componentFrameworkMockGeneratorReact, () => { + setComponent( + componentFrameworkMockGeneratorReact.control.updateView(componentFrameworkMockGeneratorReact.context), + ); + }); + componentFrameworkMockGeneratorReact.context.mode.trackContainerResize.callsFake((value) => { if (!containerRef.current) { console.error('Container Ref is null'); @@ -55,11 +62,7 @@ export const ReactResizeObserver = < const size = entries[0]; componentFrameworkMockGeneratorReact.context.mode.allocatedHeight = size.contentRect.height; componentFrameworkMockGeneratorReact.context.mode.allocatedWidth = size.contentRect.width; - Object.getOwnPropertyNames>( - componentFrameworkMockGeneratorReact.context.parameters, - ).forEach((propertyName) => { - componentFrameworkMockGeneratorReact.context._parameters[propertyName]._Refresh(); - }); + componentFrameworkMockGeneratorReact.RefreshParameters(); setComponent( componentFrameworkMockGeneratorReact.control.updateView( componentFrameworkMockGeneratorReact.context, diff --git a/src/ComponentFramework-Mock-Generator/index.ts b/src/ComponentFramework-Mock-Generator/index.ts index 83782d9..34afd2d 100644 --- a/src/ComponentFramework-Mock-Generator/index.ts +++ b/src/ComponentFramework-Mock-Generator/index.ts @@ -8,6 +8,7 @@ export { ComponentFrameworkMockGenerator } from './ComponentFramework-Mock-Gener export { MetadataDB } from './Metadata.db'; export type { MockGenerator } from './MockGenerator'; export { mockGetEntityMetadata } from './mockGetEntityMetadata'; +export { mockRefreshDatasets } from './mockRefreshDatasets'; export { mockRefreshParameters } from './mockRefreshParameters'; export { mockSetControlResource } from './mockSetControlResource'; export { mockSetControlState } from './mockSetControlState'; diff --git a/src/ComponentFramework-Mock-Generator/mockRefreshDatasets.ts b/src/ComponentFramework-Mock-Generator/mockRefreshDatasets.ts new file mode 100644 index 0000000..a1bf0ca --- /dev/null +++ b/src/ComponentFramework-Mock-Generator/mockRefreshDatasets.ts @@ -0,0 +1,40 @@ +/* + Copyright (c) 2022 Betim Beja and Shko Online LLC + Licensed under the MIT license. +*/ + +/// + +import type { PropertyToMock } from '../ComponentFramework-Mock'; +import type { MockGenerator } from './MockGenerator'; +import type { ShkoOnline } from '../ShkoOnline'; + +import { stub } from 'sinon'; +import { DataSetMock } from '../ComponentFramework-Mock'; + +export const mockRefreshDatasets = < + TInputs extends ShkoOnline.PropertyTypes, + TOutputs extends ShkoOnline.KnownTypes, +>( + mockGenerator: MockGenerator, + callback: () => void, +) => { + mockGenerator.RefreshDatasets = stub(); + mockGenerator.RefreshDatasets.callsFake(() => { + Object.getOwnPropertyNames>(mockGenerator.context._parameters).forEach( + (propertyName) => { + const mock = mockGenerator.context._parameters[propertyName]; + if (!(mock instanceof DataSetMock) || !mock._loading) { + return; + } + setTimeout(() => { + mock._loading = !mock._loading; + mockGenerator.RefreshParameters(); + mockGenerator.context.updatedProperties = [propertyName as string, 'dataset']; + callback(); + mock._onLoaded(); + }, mock._delay); + }, + ); + }); +}; diff --git a/src/ComponentFramework-Mock/PropertyTypes/DataSet.mock.ts b/src/ComponentFramework-Mock/PropertyTypes/DataSet.mock.ts index 74a007e..e4c875e 100644 --- a/src/ComponentFramework-Mock/PropertyTypes/DataSet.mock.ts +++ b/src/ComponentFramework-Mock/PropertyTypes/DataSet.mock.ts @@ -24,6 +24,9 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { _Bind: SinonStub<[boundTable: string, boundColumn: string, boundRow?: string], void>; _Refresh: SinonStub<[], void>; _InitItems: SinonStub<[items: { [column: string]: any }[]], void>; + _loading: boolean; + _onLoaded: SinonStub<[], void>; + _delay: number; _SelectedRecordIds: string[]; addColumn?: SinonStub<[name: string, entityAlias?: string], void>; columns: Column[]; @@ -51,6 +54,7 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { this._boundColumn = propertyName; this._db = db; this._SelectedRecordIds = []; + this._onLoaded = stub(); this.error = false; this.errorMessage = ''; this.linking = new LinkingMock(); @@ -87,7 +91,7 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { } }); } - + let i = 0; let columns: { [key: string]: number } = {}; items.forEach((item) => { @@ -135,18 +139,22 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { const columnsResult = this._db.GetAllRows(`${this._boundTable}@columns`); this.columns = columnsResult.rows; const rows = this._db.GetAllRows(this._boundTable); - const records = rows.rows.map((item) => { - const row = new EntityRecordMock( - db, - this._boundTable, - item[rows.entityMetadata?.PrimaryIdAttribute || 'id'], - ); - return row; - }); + const records = this._loading + ? [] + : rows.rows.map((item) => { + const row = new EntityRecordMock( + db, + this._boundTable, + item[rows.entityMetadata?.PrimaryIdAttribute || 'id'], + ); + return row; + }); this.records = {}; + this.loading = this._loading; records.forEach((record) => { this.records[record.getRecordId()] = record; }); + this.paging.pageSize = this.paging._pageSize; this.paging.totalResultCount = records.length; if (this.sorting.length > 0) { @@ -166,7 +174,9 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { .map((record) => record.getRecordId()); } }); - this.loading = false; + this.loading = true; + this._loading = true; + this._delay = 200; this.sortedRecordIds = []; this.sorting = []; this.columns = []; @@ -190,14 +200,14 @@ export class DataSetMock implements ComponentFramework.PropertyTypes.DataSet { this.refresh = stub(); this.setSelectedRecordIds = stub(); this.setSelectedRecordIds.callsFake((ids) => { - if(!ids){ + if (!ids) { this._SelectedRecordIds = []; return; } // validate - for(let i=0;i