First look at PCF dynamic schema object outputs

One of the new features now supported in PCF (Power Apps Component Framework) code components are 'object outputs'. A PCF component has a manifest file that defines the inputs/output properties that it accepts, and each of those properties has a data type. Until now, it was only supported to use data types that are supported by Dataverse (E.g. Decimals, Lookups, Choices etc. ). Object-typed property support was introduced in 1.19.4 of pcf-scripts (see the release notes). They have not yet made it to the control manifest schema documentation, but I expect that to follow soon once the component samples have been updated.

With object-typed output properties, we can now specify a schema for the output record which will then be picked up in Power Fx. Amongst the scenarios that this unlocks are:

  • Performing calculations or calling APIs, and then returning the results as an object output with a static schema. The output property can then have arrays and nested objects that will be visible in Canvas Apps at design time.
  • Raise the OnChange event and provide a Record as an output similar to the built-in Selected property using a dynamic schema definition. 

For the second scenario, let's imagine a scenario where you have a grid, and when the user selects a command in a row, you want to output both the event type and the source row, without needing to provide a key that must be used to look up the record. For this scenario, we will take the input schema of the dataset being passed to the grid (e.g. Accounts or Contacts), and then map it to an object output schema for a property named EventRow. When the schema of the input dataset changes, the schema of the output property also changes to match.

Define the output property in the ControlManifest.Input.xml

For each object output property, there must be a dependent Schema property that will be used by Canvas Apps to display the auto-complete on the object. We add two properties, the output and the schema:

<property name="EventRow" display-name-key="EventRow" of-type="Object" usage="output"/>
<property name="EventRowSchema" display-name-key="EventRowSchema" of-type="SingleLine.Text" usage="bound" hidden="true"/>

Now we must also indicate that the Schema property is used as the schema for the EventRow property by adding the following below inside the control element:

<property-dependencies>
    <property-dependency input="EventRowSchema" output="EventRow" required-for="schema" />
</property-dependencies>

Notice that the property-dependency element joins the EventRowSchema and EventRow properties together to be used to determine the schema as indicated by required-for="schema".

Define the JSON Schema

In our example, whenever the input dataset changes, we must update the output schema to reflect the same schema so that we can see the same properties. The output schema is defined using the json-schema format.

To use the JSON schema types, we can add the definitely typed node module using:

npm install --save @types/json-schema

Once this has been installed, you can use the type JSONSchema4 to describe the output schema by adding the following to your index.ts:

private getInputSchema(context: ComponentFramework.Context<IInputs>) {
    const dataset = context.parameters.records;
    const columnProperties: Record<string, any> = {};
    dataset.columns
        .filter((c) => !c.isHidden && (c.displayName || c.name))
        .forEach((c) => {
            const properties = this.getColumnSchema(c);
            columnProperties[c.displayName || c.name] = properties;
        });
    this.columnProperties = columnProperties;
    return columnProperties;
}
private getColumnSchema(column: ComponentFramework.PropertyHelper.DataSetApi.Column): JSONSchema4 {
    switch (column.dataType) {
        // Number Types
        case 'TwoOptions':
            return { type: 'boolean' };
        case 'Whole.None':
            return { type: 'integer' };
        case 'Currency':
        case 'Decimal':
        case 'FP':
        case 'Whole.Duration':
            return { type: 'number' };
        // String Types
        case 'SingleLine.Text':
        case 'SingleLine.Email':
        case 'SingleLine.Phone':
        case 'SingleLine.Ticker':
        case 'SingleLine.URL':
        case 'SingleLine.TextArea':
        case 'Multiple':
            return { type: 'string' };
        // Other Types
        case 'DateAndTime.DateOnly':
        case 'DateAndTime.DateAndTime':
            return {
                type: 'string',
                format: 'date-time',
            };
        // Choice Types
        case 'OptionSet':
            // TODO: Can we return an enum type dynamically?
            return { type: 'string' };
        case 'MultiSelectPicklist':
            return {
                type: 'array',
                items: {
                    type: 'number',
                },
            };
        // Lookup Types
        case 'Lookup.Simple':
        case 'Lookup.Customer':
        case 'Lookup.Owner':
            // TODO: What is the schema for lookups?
            return { type: 'string' };
        // Other Types
        case 'Whole.TimeZone':
        case 'Whole.Language':
            return { type: 'string' };
    }
    return { type: 'string' };
}

As you can see, each dataverse data type is mapped across to a JSON schema equivalent. I am still trying to establish the correct schema for complex objects such as Choices and Lookups, so I'll update this post when I find out more, but I expect that some of them such as Choice columns may not be possible.

Output the schema

Since the input schema can change at any time, we add the following to detect if it has changed, and then call notifyOutputChanged if it has:

private updateInputSchemaIfChanged() {
    const newSchema = JSON.stringify(this.getInputSchema(this.context));
    if (newSchema !== this.inputSchema) {
        this.inputSchema = newSchema;
        this.eventRow = undefined;
        this.notifyOutputChanged();
    }
}

Inside updateView, we then simply make a call to this to detect the change. I've not worked out a way of detecting the change other than comparing the old and new schema. It would be good if there were a flag in the context.updatedProperties array but there does not seem to be one as far as I can find.

Generate the output record object to match the schema

In our example, each time the selection changes we raise the OnChange event and output the row that was selected (similar to the Selected property that raises the OnSelect event). In order to do this, we have to map the selected record onto an object that has the properties that the schema defines:

private getOutputObjectRecord(row: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord) {
    const outputObject: Record<string, string | number | boolean | number[] | undefined> = {};
    this.context.parameters.records.columns.forEach((c) => {
        const value = this.getRowValue(row, c);
        outputObject[c.displayName || c.name] = value;
    });
    return outputObject;
}
private getRowValue(
    row: ComponentFramework.PropertyHelper.DataSetApi.EntityRecord,
    column: ComponentFramework.PropertyHelper.DataSetApi.Column,
) {
    switch (column.dataType) {
        // Number Types
        case 'TwoOptions':
            return row.getValue(column.name) as boolean;
        case 'Whole.None':
        case 'Currency':
        case 'Decimal':
        case 'FP':
        case 'Whole.Duration':
            return row.getValue(column.name) as number;
        // String Types
        case 'SingleLine.Text':
        case 'SingleLine.Email':
        case 'SingleLine.Phone':
        case 'SingleLine.Ticker':
        case 'SingleLine.URL':
        case 'SingleLine.TextArea':
        case 'Multiple':
            return row.getFormattedValue(column.name);
        // Date Types
        case 'DateAndTime.DateOnly':
        case 'DateAndTime.DateAndTime':
            return (row.getValue(column.name) as Date)?.toISOString();
        // Choice Types
        case 'OptionSet':
            // TODO: Can we return an enum?
            return row.getFormattedValue(column.name) as string;
        case 'MultiSelectPicklist':
            return row.getValue(column.name) as number[];
        // Lookup Types
        case 'Lookup.Simple':
        case 'Lookup.Customer':
        case 'Lookup.Owner':
            // TODO: How do we return Lookups?
            return (row.getValue(column.name) as ComponentFramework.EntityReference)?.id.guid;
        // Other
        case 'Whole.TimeZone':
        case 'Whole.Language':
            return row.getFormattedValue(column.name);
    }
}

Again, I am unsure of the shape that is needed to support lookups and choice columns, so I am simply mapping them to numbers and strings at this time. 

We can now use this to output the record when the selection changes:

this.eventRow = this.getOutputObjectRecord(dataset.records[ids[0]]);
this.notifyOutputChanged();

In the getOutputs, we then simply add:

public getOutputs(): IOutputs {
    return {
        EventRowSchema: this.inputSchema,
        EventRow: this.eventRow,
    } as IOutputs;
}

 

Implement getOutputSchema

Notice above, we output both the selected record and its schema. If the schema has changed, this then triggers Power Apps to make a call to the method called getOutputSchema. This is where the actual JSON schema is returned and used by Power Apps:

public async getOutputSchema(context: ComponentFramework.Context<IInputs>): Promise<Record<string, unknown>> {
    const eventRowSchema: JSONSchema4 = {
        $schema: 'http://json-schema.org/draft-04/schema#',
        title: 'EventRow',
        type: 'object',
        properties: this.getInputSchema(context),
    };
    return Promise.resolve({
        EventRow: eventRowSchema,
    });
}

The result

Once this is done and published, your component will now have a new EventRow property, inheriting the same schema as the input record - with the caveat that Choices and Lookups will be strings, rather than complex types.

If you had bound the grid to Accounts, the EventRow property might look similar to:

You can grab the code for this example from GitHub: https://github.com/scottdurow/PCFDynamicSchemaOutputExample 

This functionality takes us one step closer to parity with the first-party controls in canvas apps - I'm just now waiting for custom events to be supported next!

 

Comments are closed