Skip to content

Commit

Permalink
Merge branch 'release/2.0.0'
Browse files Browse the repository at this point in the history
# Conflicts:
#	bin/main.js
#	bin/root/tools/deploy.js
#	bin/root/tools/resx.js
#	bin/root/tools/setFormCustomizable.js
#	package.json
  • Loading branch information
nsteenbeek committed Nov 6, 2020
2 parents 11da64a + db8702a commit 4528868
Show file tree
Hide file tree
Showing 26 changed files with 272 additions and 375 deletions.
Empty file added bin/Entity/Entity.enum.ts
Empty file.
3 changes: 1 addition & 2 deletions bin/Entity/Entity.model.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {Model} from '../WebApi/Model';

// Please add Entity fields here, which are (to be) used in code
// Please add NavigationProperties here, referring to another model, which are (to be) used in code
export interface EntityModel extends Model {
interface EntityModel extends Model {
EntityLogicalNameid?: string;
}
7 changes: 3 additions & 4 deletions bin/Entity/Entity.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {Filter, MultipleSystemQueryOptions, SystemQueryOptions, WebApi} from '../WebApi/WebApi';
import {EntityModel} from './Entity.model';
import {Model, ModelValidation} from '../WebApi/Model';
import {Service} from '../WebApi/Service';

export class EntityService {
private static logicalName = 'EntityLogicalName';
Expand Down Expand Up @@ -31,10 +30,10 @@ export class EntityService {

public static async retrieveClone(id: string): Promise<EntityModel> {
const origRecord = await Xrm.WebApi.retrieveRecord(EntityService.logicalName, id);
return Model.parseCreateModel(EntityService.logicalName, origRecord);
return Service.parseCreateModel(EntityService.logicalName, origRecord);
}

public static async validateRecord(entityModel: EntityModel): Promise<ModelValidation> {
return Model.validateRecord(EntityService.logicalName, entityModel);
return Service.validateRecord(EntityService.logicalName, entityModel);
}
}
4 changes: 2 additions & 2 deletions bin/root/src/Annotation/Annotation.model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Model} from '../WebApi/Model';
export interface AnnotationModel extends Model {

interface AnnotationModel extends Model {
annotationid?: string;
documentbody?: string;
filename?: string;
Expand Down
9 changes: 4 additions & 5 deletions bin/root/src/Annotation/Annotation.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {Filter, MultipleSystemQueryOptions, SystemQueryOptions, WebApi} from '../WebApi/WebApi';
import {AnnotationModel} from './Annotation.model';
import {WebApi} from '../WebApi/WebApi';
import {Base64} from '../util/Base64';
import {Model, ModelValidation} from '../WebApi/Model';
import {Service} from '../WebApi/Service';

export class AnnotationService {
private static logicalName = 'annotation';
Expand Down Expand Up @@ -82,10 +81,10 @@ export class AnnotationService {

public static async retrieveClone(id: string): Promise<AnnotationModel> {
const origRecord = await Xrm.WebApi.retrieveRecord(AnnotationService.logicalName, id);
return Model.parseCreateModel(AnnotationService.logicalName, origRecord);
return Service.parseCreateModel(AnnotationService.logicalName, origRecord);
}

public static async validateRecord(annotationModel: AnnotationModel): Promise<ModelValidation> {
return Model.validateRecord(AnnotationService.logicalName, annotationModel);
return Service.validateRecord(AnnotationService.logicalName, annotationModel);
}
}
12 changes: 0 additions & 12 deletions bin/root/src/Http/Http.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,3 @@
export type Method = 'GET' | 'POST' | 'DELETE';

export interface HttpHeaders {
[index: string]: string;
}

export interface JsonHttpHeaders extends HttpHeaders {
'OData-MaxVersion': string;
'OData-Version': string;
'Accept': string;
'Content-Type': string;
}

export const jsonHttpHeaders = {
'OData-MaxVersion': '4.0',
Expand Down
12 changes: 12 additions & 0 deletions bin/root/src/Http/HttpHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
type Method = 'GET' | 'POST' | 'DELETE';

interface HttpHeaders {
[index: string]: string;
}

interface JsonHttpHeaders extends HttpHeaders {
'OData-MaxVersion': string;
'OData-Version': string;
'Accept': string;
'Content-Type': string;
}
124 changes: 2 additions & 122 deletions bin/root/src/WebApi/Model.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {WebApi} from './WebApi';

export interface Model {
interface Model {
//Attributes for $select
statecode?: number;
owneridname?: string;
Expand Down Expand Up @@ -30,127 +29,8 @@ export interface Model {

type ValidationCategory = 'Mandatory' | 'Invalid key';

export interface ModelValidation {
interface ModelValidation {
isValid: boolean;
attribute?: string;
category?: ValidationCategory;
}

export class Model {
public static async parseCreateModel(entityLogicalName: string, model: Model): Promise<Model> {
const entity = Model.parseModel(model),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, Object.keys(entity));
Model.cleanCloneModel(entity, metadata);
return WebApi.populateBindings(entity, metadata);
}

private static cleanCloneModel(model: Model, metadata: Xrm.Metadata.EntityMetadata): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (model as any)[metadata.PrimaryIdAttribute];
delete model.statuscode;
delete model.statecode;

delete model.ownerid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (model as any)['[email protected]'];
// delete model.modifiedon;
// delete model.createdon;
// delete model.versionnumber;
}

private static parseModel(model: Model): Model {
const keys = Object.keys(model),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cleanModel: any = {...model};
for (const key of keys) {
if (cleanModel[key] === null) {
delete cleanModel[key];
} else if (key.startsWith('@')) {
delete cleanModel[key];
} else if (key.endsWith('FormattedValue')) {
delete cleanModel[key];
} else if (key.startsWith('_') && key.endsWith('_value') && !key.includes('@')) {
const navigationProperty = cleanModel[`${key}@Microsoft.Dynamics.CRM.associatednavigationproperty`],
trimmedKey = key.substr(1, key.length - 7);
if (navigationProperty) {
cleanModel[trimmedKey] = cleanModel[key];
delete cleanModel[`${key}@Microsoft.Dynamics.CRM.associatednavigationproperty`];
} else {
delete cleanModel[`${key}@Microsoft.Dynamics.CRM.lookuplogicalname`];
}
delete cleanModel[key];
delete cleanModel[`${trimmedKey}_LogicalName`]; // Added in WebApi#parseValues
}
}
return cleanModel;
}

public static async validateRecord(entityLogicalName: string, model: Model): Promise<ModelValidation> {
let validation = await Model.validateRequiredAttributes(entityLogicalName, model);
if (validation.isValid) {
validation = await Model.validateMesh(entityLogicalName, model);
}
return validation;
}

private static async validateMesh(entityLogicalName: string, model: Model): Promise<ModelValidation> {
const keys = Object.keys(model),
wrongKey = keys.find(key => key.startsWith('_') || key.endsWith('FormattedValue') || key.endsWith('LogicalName'));
if (wrongKey) {
return {
isValid: false,
attribute: wrongKey,
category: 'Invalid key'
};
}
return {isValid: true};
}

private static async validateRequiredAttributes(entityLogicalName: string, model: Model): Promise<ModelValidation> {
const requiredAttributeLogicalNames = await Model.getRequiredAttributeLogicalNames(entityLogicalName),
filteredAttributeMetadatas = await Xrm.Utility.getEntityMetadata(entityLogicalName, requiredAttributeLogicalNames),
keys = Object.keys(model);
for (const name of requiredAttributeLogicalNames) {
const attributeDescriptor = Model.getAttributeDescriptor(name, filteredAttributeMetadatas),
property = attributeDescriptor.LogicalName,
bindings: string[] = [];
const targets: string[] = attributeDescriptor.Targets || [];
for (const target of targets) {
const binding = await WebApi.getBinding(property, 'dummy', filteredAttributeMetadatas, target);
bindings.push(Object.keys(binding)[0]);
}
if (!keys.includes(property) && !keys.includes(`${property}@odata.bind`) && !bindings.some(binding => keys.includes(binding))) {
return {
isValid: false,
attribute: name,
category: 'Mandatory'
};
}
}
return {isValid: true};
}

private static getAttributeDescriptor(attribute: string, entityMetadata: Xrm.Metadata.EntityMetadata): {
AttributeOf?: string;
LogicalName: string;
Targets: string[];
} {
const attributeMetadata = entityMetadata.Attributes.get(attribute);
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
let attributeDescriptor = attributeMetadata.attributeDescriptor;
if (attributeDescriptor.AttributeOf) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
attributeDescriptor = entityMetadata.Attributes.get(attributeDescriptor.AttributeOf).attributeDescriptor;
}
return attributeDescriptor;
}

private static async getRequiredAttributeLogicalNames(entityLogicalName: string): Promise<string[]> {
const rawAttributesMetadatas = await WebApi.getAttributesMetadata(entityLogicalName, ['LogicalName', 'RequiredLevel']);
return rawAttributesMetadatas
.filter((attrMetadata: {RequiredLevel: {Value: string}}) => attrMetadata.RequiredLevel.Value === 'ApplicationRequired')
.map((requiredAttributeMetadata: {LogicalName: string}) => requiredAttributeMetadata.LogicalName);
}
}
120 changes: 120 additions & 0 deletions bin/root/src/WebApi/Service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {WebApi} from './WebApi';

export class Service {
public static async parseCreateModel(entityLogicalName: string, model: Model): Promise<Model> {
const entity = Service.parseModel(model),
metadata = await Xrm.Utility.getEntityMetadata(entityLogicalName, Object.keys(entity));
Service.cleanCloneModel(entity, metadata);
return WebApi.populateBindings(entity, metadata);
}

private static cleanCloneModel(model: Model, metadata: Xrm.Metadata.EntityMetadata): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (model as any)[metadata.PrimaryIdAttribute];
delete model.statuscode;
delete model.statecode;

delete model.ownerid;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (model as any)['[email protected]'];
// delete model.modifiedon;
// delete model.createdon;
// delete model.versionnumber;
}

private static parseModel(model: Model): Model {
const keys = Object.keys(model),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cleanModel: any = {...model};
for (const key of keys) {
if (cleanModel[key] === null) {
delete cleanModel[key];
} else if (key.startsWith('@')) {
delete cleanModel[key];
} else if (key.endsWith('FormattedValue')) {
delete cleanModel[key];
} else if (key.startsWith('_') && key.endsWith('_value') && !key.includes('@')) {
const navigationProperty = cleanModel[`${key}@Microsoft.Dynamics.CRM.associatednavigationproperty`],
trimmedKey = key.substr(1, key.length - 7);
if (navigationProperty) {
cleanModel[trimmedKey] = cleanModel[key];
delete cleanModel[`${key}@Microsoft.Dynamics.CRM.associatednavigationproperty`];
} else {
delete cleanModel[`${key}@Microsoft.Dynamics.CRM.lookuplogicalname`];
}
delete cleanModel[key];
delete cleanModel[`${trimmedKey}_LogicalName`]; // Added in WebApi#parseValues
}
}
return cleanModel;
}

public static async validateRecord(entityLogicalName: string, model: Model): Promise<ModelValidation> {
let validation = await Service.validateRequiredAttributes(entityLogicalName, model);
if (validation.isValid) {
validation = await Service.validateMesh(entityLogicalName, model);
}
return validation;
}

private static async validateMesh(entityLogicalName: string, model: Model): Promise<ModelValidation> {
const keys = Object.keys(model),
wrongKey = keys.find(key => key.startsWith('_') || key.endsWith('FormattedValue') || key.endsWith('LogicalName'));
if (wrongKey) {
return {
isValid: false,
attribute: wrongKey,
category: 'Invalid key'
};
}
return {isValid: true};
}

private static async validateRequiredAttributes(entityLogicalName: string, model: Model): Promise<ModelValidation> {
const requiredAttributeLogicalNames = await Service.getRequiredAttributeLogicalNames(entityLogicalName),
filteredAttributeMetadatas = await Xrm.Utility.getEntityMetadata(entityLogicalName, requiredAttributeLogicalNames),
keys = Object.keys(model);
for (const name of requiredAttributeLogicalNames) {
const attributeDescriptor = Service.getAttributeDescriptor(name, filteredAttributeMetadatas),
property = attributeDescriptor.LogicalName,
bindings: string[] = [];
const targets: string[] = attributeDescriptor.Targets || [];
for (const target of targets) {
const binding = await WebApi.getBinding(property, 'dummy', filteredAttributeMetadatas, target);
bindings.push(Object.keys(binding)[0]);
}
if (!keys.includes(property) && !keys.includes(`${property}@odata.bind`) && !bindings.some(binding => keys.includes(binding))) {
return {
isValid: false,
attribute: name,
category: 'Mandatory'
};
}
}
return {isValid: true};
}

private static getAttributeDescriptor(attribute: string, entityMetadata: Xrm.Metadata.EntityMetadata): {
AttributeOf?: string;
LogicalName: string;
Targets: string[];
} {
const attributeMetadata = entityMetadata.Attributes.get(attribute);
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
let attributeDescriptor = attributeMetadata.attributeDescriptor;
if (attributeDescriptor.AttributeOf) {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
attributeDescriptor = entityMetadata.Attributes.get(attributeDescriptor.AttributeOf).attributeDescriptor;
}
return attributeDescriptor;
}

private static async getRequiredAttributeLogicalNames(entityLogicalName: string): Promise<string[]> {
const rawAttributesMetadatas = await WebApi.getAttributesMetadata(entityLogicalName, ['LogicalName', 'RequiredLevel']);
return rawAttributesMetadatas
.filter((attrMetadata: {RequiredLevel: {Value: string}}) => attrMetadata.RequiredLevel.Value === 'ApplicationRequired')
.map((requiredAttributeMetadata: {LogicalName: string}) => requiredAttributeMetadata.LogicalName);
}
}
48 changes: 48 additions & 0 deletions bin/root/src/WebApi/SystemQueryOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

interface Expand {
attribute: string;
select: string[];
}

interface SystemQueryOptions {
select: string[];
expands?: Expand[];
}

type Order = 'asc' | 'desc';
interface OrderBy {
attribute: string;
order?: Order;
}

// https://docs.microsoft.com/en-us/dynamics365/customer-engagement/web-api/tomorrow?view=dynamics-ce-odata-9
type QueryFunction = 'Above' | 'AboveOrEqual' | 'Between' | 'Contains' | 'ContainValues' | 'DoesNotContainValues' | 'EqualBusinessId' | 'EqualUserId' |
'EqualUserLanguage' | 'EqualUserOrUserHierarchy' | 'EqualUserOrHierarchyAndTeams' | 'EqualUserOrUserTeams' | 'EqualUserTeams' | 'In' | 'InFiscalPeriod' |
'InFiscalPeriodAndYear' | 'InFiscalYear' | 'InOrAfterFiscalPeriodAndYear' | 'InOrBeforeFiscalPeriodAndYear' | 'Last7Days' | 'LastFiscalPeriod' | 'LastFiscalYear' |
'LastMonth' | 'LastWeek' | 'LastXDays' | 'LastXFiscalPeriods' | 'LastXFiscalYears' | 'LastXHours' | 'LastXMonths' | 'LastXWeeks' | 'LastXYears' | 'LastYear' |
'Next7Days' | 'NextFiscalPeriod' | 'NextFiscalYear' | 'NextMonth' | 'NextWeek' | 'NextXDays' | 'NextXFiscalPeriods' | 'NextXFiscalYears' | 'NextXHours' |
'NextXMonths' | 'NextXWeeks' | 'NextXYears' | 'NextYear' | 'NotBetween' | 'NotEqualBusinessId' | 'NotEqualUserId' | 'NotIn' | 'NotUnder' | 'OlderThanXDays' |
'OlderThanXHours' | 'OlderThanXMinutes' | 'OlderThanXMonths' | 'OlderThanXWeeks' | 'OlderThanXYears' | 'On' | 'OnOrAfter' | 'OnOrBefore' | 'ThisFiscalPerios' |
'ThisFiscalYear' | 'ThisMonth' | 'ThisWeek' | 'ThisYear' | 'Today' | 'Tomorrow' | 'Under' | 'UnderOrEqual' | 'Yesterday';
const filterConditions = ['eq' , 'ne', 'gt', 'ge', 'lt', 'le'] as const;
type FilterCondition = typeof filterConditions[number]; // 'eq' | 'ne' | 'gt' | 'ge' | 'lt' | 'le';
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Condition {
attribute: string;
operator?: FilterCondition | QueryFunction;
value?: any;
}
/* eslint-enable @typescript-eslint/no-explicit-any */

type FilterType = 'and' | 'or' | 'not';
interface Filter {
type?: FilterType;
conditions: Condition[];
filters?: Filter[];
}

interface MultipleSystemQueryOptions extends SystemQueryOptions {
filters?: Filter[];
orders?: OrderBy[];
top?: number;
}
Loading

0 comments on commit 4528868

Please sign in to comment.