@aappddeevv/dynamics-client-ui
Version:
## What is it? A library to help you create great dynamics applications.
633 lines (553 loc) • 23.8 kB
text/typescript
/**
* Metadata access with cacheing so its not too slow. Cache
* is shared across all Metadata instances.
*/
import { DEBUG } from "BuildSettings"
import { Id, QueryOptions, Option as ApiOption } from "./CRMWebAPI"
import { Client } from "./client"
//import * as R from "ramda"
import values from "ramda/es/values"
import mergeDeepRight from "ramda/es/mergeDeepRight"
import { IResult, Ok, Err } from "../Dynamics/Result"
export interface LabelValue {
Label: string
Value: number
}
/** From OptionSet. */
export interface Option extends LabelValue {
Description?: string
}
export interface MetadataBase {
MetadataId: string
}
export interface EntityDefinition extends MetadataBase {
SchemaName: string
LogicalName: string
PrimaryIdAttribute: string
LogicalCollectionName: string
IconSmallName: string | null
Description?: string
DisplayName?: string
Attributes?: Array<Attribute>
}
export interface Relationship extends MetadataBase {
ReferencedAttribute: string
ReferencedEntity: string,
ReferencedEntityNavigationPropertyName: string
ReferencingAttribute: string
ReferencingEntity: string
ReferencingEntityNavigationPropertyName: string
RelationshipType: string // always OneToManyRelationship
SchemaName: string
IsManaged: boolean
IsHierarchical: boolean
/** OOTB or created through customization. */
IsCustomRelationship: boolean
}
/**
* 1:N => Referenced is the 1 side. For example, for all contact.OneToManyRelationships,
* the ReferenceEntity is always "contact".
*/
export interface OneToManyRelationship extends Relationship { }
/**
* N:1 => Referenced is the N side. For example, for all contact.ManyToOneRelationships,
* the ReferencingEntity is always "contact".
*/
export interface ManyToOneRelationship extends OneToManyRelationship { }
export interface BlahValue {
Value: boolean
CanBeChanged: boolean
ManagedPropertyLogicalName: string
}
export interface Localized {
HasChanged: boolean | null
IsManaged: boolean
Label: string | null
LanguageCode: number
MetadataId: string
}
export interface LocalizedLabels {
UserLocalizedLabel: Localized
LocalizedLabels: Array<Localized>
}
/**
* Return the user localized label using lcid if its provided and found.
*/
export function getLabel(labels: LocalizedLabels, lcid?: number): Localized | null {
if (!lcid && labels.UserLocalizedLabel) {
// if no lcid, return the default if it exists
return labels.UserLocalizedLabel
}
const x = labels.LocalizedLabels.filter(l => l.LanguageCode === lcid)
if (x.length > 0) return x[0]
return null
}
/** Simple attribute. */
export interface Attribute extends MetadataBase {
LogicalName: string
AttributeOf: string | null
AttributeType: string
AttributeTypeName: { Value: string }
ColumnNumber: number
DatabaseLength: number | null
Description: LocalizedLabels
DisplayName: LocalizedLabels
EntityLogicalName: string
ExternalName: string | null
FormulaDefinition: string | null
HasChanged: boolean | null
RequiredLevel: any
IsAuditEnabled: BlahValue
IsCustomAttribute: boolean
IsCustomizable: BlahValue
IsDataSourceSecret: boolean
IsFilterable: boolean
IsGlobalFilterEnabled: BlahValue
IsLocalizable: boolean
IsLogical: boolean
IsManaged: boolean
IsPrimaryId: boolean
IsPrimaryName: boolean
IsRenameable: BlahValue
IsRequiredForForm: boolean
/** Use this to find retrievable attributes. */
IsRetrievable: boolean
IsSearchable: boolean
IsSecured: boolean
IsSortableEnabled: BlahValue
IsValidForAdvancedFind: BlahValue
IsValidForCreate: boolean
IsValidForForm: boolean
IsValidForGrid: boolean
IsValidForRead: boolean
IsValidForUpdate: boolean
LinkedAttributeId: string | null
MaxLength: number
SourceType: number
SourceTypeMask: number
}
export interface LookupAttribute extends Attribute {
/** Array of logical entity names that can be looked up. */
Targets: Array<string>
}
/** [entity name] => {[attribute name]: Attribute} */
let entityToAttribute = {}
/** singular entity name to entity object */
const entityNameToDefinition: Map<string, EntityDefinition> = new Map()
const entityDefinitions: Array<EntityDefinition> = []
/** entity logical name to array of relationships */
const entityNameToOneToMany: Map<string, Array<OneToManyRelationship>> = new Map()
const entityNameToManyToOne: Map<string, Array<ManyToOneRelationship>> = new Map()
export interface ObjectTypeCodePair {
LogicalName: string
ObjectTypeCode: number
}
let objectTypeCodes: Array<ObjectTypeCodePair> = []
const objectTypeCodesByCode: Map<number, ObjectTypeCodePair> = new Map()
const objectTypeCodesByName: Map<string, ObjectTypeCodePair> = new Map()
/** Connection role categories. Classifies a ConnectionRole. Value is the fk. */
export interface ConnectionRoleCategory {
Label: string
Value: number
}
let connectionRoleCategories: Array<ConnectionRoleCategory> = []
const connectionRoleCategoriesByName: Map<string, ConnectionRoleCategory> = new Map()
const connectionRoleCategoriesByValue: Map<number, ConnectionRoleCategory> = new Map()
/** Set of all connection roles. */
export interface ConnectionRole {
connectionroleid: string
name: string
/** FK to ConnectionRoleCategory */
category: number
/** Name of ConnectionRoleCategory */
["category@OData.Community.Display.V1.FormattedValue"]: string
description: string
statecode: number
statuscode: number
/** http link to reciprocals. */
["connectionroleassociation_association@odata.nextLink"]: string
}
let connectionRoles: Array<ConnectionRole> = []
const connectionRolesById: Map<Id, ConnectionRole> = new Map()
const connectionRolesByName: Map<string, ConnectionRole> = new Map()
/** Reciprocal connection roles. Most just have 1 but you can have many. */
const connectionRoleAssociatedRoles: Map<Id, Array<Id>> = new Map()
/**
* An entry describing the type of object a connection role can connect to.
*/
export interface ConnectionRoleObjectTypeCode {
/** Same as associatedobjecttypecode. */
entityName: string
/** Same as _connectionroleid_value_formatted. */
roleName: string
connectionroleobjecttypecodeid: Id
/** Logical name of allowed entity e.g. contact or systemuser. */
associatedobjecttypecode: string
/** Display name of allowed entity e.g systemuser => User. */
"associatedobjecttypecode@OData.Community.Display.V1.FormattedValue": string
/** Display name of allowed entity. */
associatedobjecttypecode_formatted: string
organizationid: Id
/** Connection role's id's */
_connectionroleid_value: Id
/** The connection role's display name .*/
"_connectionroleid_value@OData.Community.Display.V1.FormattedValue": string
/** The connection role's display name. */
_connectionroleid_value_formatted: string
}
let connectionRoleObjectTypeCodes: Array<ConnectionRoleObjectTypeCode> = []
/** Cache that indexes by a connection role's id. */
const connectionRoleObjectTypeCodesByRoleId: Map<Id, Array<ConnectionRoleObjectTypeCode>> = new Map()
/**
* Metadata API. Fetched metadata is shared among all instances of this class at the moment.
*/
export class Metadata {
constructor(client: Client, lcid: number = 1033) {
this.client = client
this.lcid = lcid
}
private lcid: number
private client: Client
public getLabel(labels: LocalizedLabels): Localized | null {
return getLabel(labels, this.lcid)
}
/** Get all attributes for a logical entity name or return [] */
public getAttributes = async (entityName: string): Promise<Array<Attribute>> => {
// quick wins and cache check
if(!entityName) {
if(DEBUG) console.log("Metadata.getAttributes: called with nil entityName")
return []
}
else if (entityName in entityToAttribute) {
const entry = entityToAttribute[entityName]
return values(entry)
}
try {
const m = await this.getMetadata(entityName)
// Navigate to attributes by pulling an EntityDefinition with the Attributes.
const attrs = await this.client.Get<EntityDefinition>("EntityDefinitions", m!.MetadataId, {
Select: ["LogicalName"],
Expand: [{ Property: "Attributes" }]
}).
then(m => m.Attributes)
if (attrs && attrs.length > 0) {
// place in cache, by logical name!
const mergeMe = attrs.reduce((accum, a) => {
accum[a.LogicalName] = a
return accum
}, {})
entityToAttribute = mergeDeepRight(entityToAttribute, {
[entityName]: mergeMe
})
return attrs
}
} catch (e) {
console.log(`Error obtaining entity attributes for entity name '${entityName}'`, e)
}
// no attributes returned? probably a bad entity name?? should we error?
return []
}
/** Find a specific entity-attribute metadata. Return null if not found. */
public lookupAttribute = async <T=Attribute>(entityName: string, attributeName: string): Promise<T|null> => {
if(DEBUG) {
console.log(`Metadata.lookupAttribute: looking up '${entityName}.${attributeName}'`)
}
if(!entityName || !attributeName) return null
await this.getAttributes(entityName)
// attribtse for entityName should be in "cache"
const entityAttributes = entityToAttribute[entityName]
if (entityAttributes) {
const attribute = entityAttributes[attributeName]
if (attribute) return attribute
}
return null
}
/** Returns all entity {LogicalName, ObjectTypeCode} pairs. */
public getObjectTypeCodes = async () => {
if (objectTypeCodes.length > 0) return objectTypeCodes
const qopts = {
Select: ["LogicalName", "ObjectTypeCode"]
}
const r = await this.client.GetList<ObjectTypeCodePair>("EntityDefinitions", qopts)
objectTypeCodes = r.List
objectTypeCodes.forEach(c => objectTypeCodesByCode.set(c.ObjectTypeCode, c))
objectTypeCodes.forEach(c => objectTypeCodesByName.set(c.LogicalName, c))
return objectTypeCodes
}
/** Given a numerical code, return the (LogicalName, ObjectTypeCode) pair. */
public async lookupObjectTypeCodeByCode(code: number) {
await this.getObjectTypeCodes()
return objectTypeCodesByCode.get(code)
}
/** Given a name, return the (LogicalName, ObjectTypeCode) pair. */
public lookupObjectTypeCodeByName = async (name: string) => {
await this.getObjectTypeCodes()
return objectTypeCodesByName.get(name)
}
/** Pass in the entity singular logical name. Returns null if not found. Pulls all attributes but no navs. */
public getMetadata = async (entityName: string): Promise<EntityDefinition | null> => {
if(DEBUG) console.log(`Metadata.getMetadata: entity name ${entityName}`)
const cacheCheck = entityNameToDefinition.get(entityName)
if (cacheCheck) return cacheCheck
const qopts = {
Filter: `LogicalName eq '${entityName}'`
}
// We can do this with a EntityDefinitions(LogicalName='..name...') but CRMWebAPI
// does not have that.
return this.client.GetList<EntityDefinition>("EntityDefinitions", qopts).
then(r => {
if (!r.List) return null
// add to cache
const edef: EntityDefinition = r.List[0]
entityDefinitions.push(edef)
entityNameToDefinition.set(entityName, edef)
return edef
})
}
/** Get the entity set name given the entity logical name e.g. contact => contacts. */
public getEntitySetName = async (logicalName: string) => {
const md = await this.getMetadata(logicalName)
if (md) return md.LogicalCollectionName
return null
}
/** Get the schema name given the entity logical name. */
public getSchemaName = async (logicalName: string) => {
const md = await this.getMetadata(logicalName)
if (md) return md.SchemaName
return null
}
/** Return all connection roles. */
public getConnectionRoles = async () => {
if (connectionRoles.length > 0) return connectionRoles
const qopts = {
FormattedValues: true,
Filter: "statecode eq 0",
Expand: [
{Property: "connectionroleassociation_association"},
//{Property: "connectionroleassociation_association_referenced"},
],
}
const r = await this.client.GetList<ConnectionRole>("connectionroles", qopts).then(r => r.List)
//console.log("connectionroles", r)
connectionRoles = r
connectionRoles.forEach(cr => connectionRolesById.set(cr.connectionroleid, cr))
connectionRoles.forEach(cr => connectionRolesByName.set(cr.name, cr))
return r
}
/** Get "reciprocal" ConnectionRoles. You'll need to lookup the id using `getConnectionRoleById`. */
public getConnectionRoleAssociatedConnectionRoles = async(connectionRoleId: Id): Promise<Array<Id>> => {
if(connectionRoleAssociatedRoles.has(connectionRoleId))
return connectionRoleAssociatedRoles.get(connectionRoleId)!
const cr = await this.getConnectionRoleById(connectionRoleId)
if(!cr) return []
const qopts = {
Select: ["connectionroledid"],
}
const associated = await
this.client.Fetch(cr["connectionroleassociation_association@odata.nextLink"], qopts)
.then(r => r.value)
const associatedIds = associated.map(cr => cr.connectionroleid)
connectionRoleAssociatedRoles.set(connectionRoleId, associatedIds)
return associatedIds
}
/**
* Return an array of connection roles for a given connection category name.
*
* TODO: Rewrite this so it does not need a filter, just use the id lookup cache.
*/
public getConnectionRolesForCategoryNamed = async (categoryName: string): Promise<Array<ConnectionRole>> => {
const roles = await this.getConnectionRoles()
const cat = await this.getConnectionRoleCategoryByName(categoryName)
return roles.filter(cr => cr!.category === cat!.Value)
}
public getConnectionRoleByCategoryAndName = async (categoryName: string, roleName: string):
Promise<ConnectionRole | null> => {
await this.getConnectionRoles()
const x = connectionRoles.filter(cr => cr.name === roleName &&
cr["category@OData.Community.Display.V1.FormattedValue"] === categoryName)
if (x.length === 1) return x[0]
return null
}
/** Return a connection role by its id. */
public getConnectionRoleById = async (id: string): Promise<ConnectionRole | null> => {
await this.getConnectionRoles()
const r = connectionRolesById.get(id)
return r ? r : null
}
/** Return a connection role by its name. */
public getConnectionRoleByName = async (name: string): Promise<ConnectionRole | null> => {
await this.getConnectionRoles()
const r = connectionRolesByName.get(name)
return r ? r : null
}
/** Return an array of connection role categories. */
public getConnectionRoleCategories = async () => {
if (connectionRoleCategories.length > 0)
return connectionRoleCategories
const r = await this.client.GetOptionSetUserLabels("connectionrole_category")
connectionRoleCategories = connectionRoleCategories.concat(r)
connectionRoleCategories.forEach(crc => connectionRoleCategoriesByName.set(crc.Label, crc))
connectionRoleCategories.forEach(crc => connectionRoleCategoriesByValue.set(crc.Value, crc))
return connectionRoleCategories
}
/** Return a connecton role category by value (Category = OptionSet). */
public getConnectionRoleCategoryByValue = async (value: number) => {
await this.getConnectionRoleCategories()
return connectionRoleCategoriesByValue.get(value)
}
/** Return a connection role category its name. */
public getConnectionRoleCategoryByName = async (name: string) => {
await this.getConnectionRoleCategories()
return connectionRoleCategoriesByName.get(name)
}
/**
* Obtain the list of allowed object type. Empty means any entity type is allowed.
*/
public getAllowedTypeCodesForConnectionRoleId = async (roleId: Id):
Promise<Array<ConnectionRoleObjectTypeCode>> => {
const cacheItem = connectionRoleObjectTypeCodesByRoleId.get(roleId)
if (cacheItem !== undefined) return cacheItem
const qopts = {
FormattedValues: true,
Filter: `_connectionroleid_value eq ${roleId}`
}
return this.client.GetList<ConnectionRoleObjectTypeCode>("connectionroleobjecttypecodes", qopts).
then(r => {
const list = r.List.map(i => ({
...i,
entityName: i.associatedobjecttypecode,
associatedobjecttypecode_formatted: i["associatedobjecttypecode@OData.Community.Display.V1.FormattedValue"],
_connectionroleid_value_formatted: i["_connectionroleid_value@OData.Community.Display.V1.FormattedValue"],
roleName: i["_connectionroleid_value@OData.Community.Display.V1.FormattedValue"],
}))
if (list.length > 0) {
connectionRoleObjectTypeCodes = connectionRoleObjectTypeCodes.concat(list)
}
connectionRoleObjectTypeCodesByRoleId.set(roleId, list)
return list
})
}
/**
* Get Option pairs back, Label and Value or an empty list..
* Hackey implementation. Only looks at Attribute.OptionSet not Attribute.GlobalOptionSet.
* Not cached yet!!!
*/
public getOptionSet = async (entityLogicalName: string,
attributeLogicalName: string): Promise<Array<Option>> => {
const emeta = await this.getMetadata(entityLogicalName)
const ameta = await this.lookupAttribute(entityLogicalName, attributeLogicalName)
if (!emeta || !ameta) return []
const qopts: QueryOptions = {
Select: ["Options"],
Path: [
{
Property: `Attributes(${ameta.MetadataId!})`,
Type: "Microsoft.Dynamics.CRM.PicklistAttributeMetadata"
},
{
Property: "OptionSet",
}]
}
const attr: any = await this.client.Get("EntityDefinitions", emeta.MetadataId, qopts)
const pairs = attr.Options.map(opt => ({
Label: opt.Label.LocalizedLabels[0].Label,
Value: opt.Value
}))
//console.log("attr", attr, pairs)
return pairs
}
/**
* Return all activity types. How do we filter on non-published kinds?
* This may return a surprising number of activities that are used only
* in a specialized context so you absolutely will need to filter this list
* down for use in your application.
*/
public getAllActivityTypes = async (): Promise<Array<EntityDefinition>> => {
const qopts: QueryOptions = {
Select: ["LogicalName", "ObjectTypeCode", "Description", "DisplayName",
"IconSmallName", "IconLargeName", "IconMediumName"],
Filter: "IsActivity eq true",
}
const l = await this.client.GetList("EntityDefinitions", qopts).then(r => {
return r.List.map((entry: EntityDefinition) => ({
...entry,
// @ts-ignore
Description: entry.Description.LocalizedLabels[0].Label,
// @ts-ignore
DisplayName: entry.DisplayName.LocalizedLabels[0].Label,
}))
})
return l
}
/** Retur the primary PK logical attribute name for a given entity. */
public getPk = async (entityLogicalName: string): Promise<string | null> => {
return this.getMetadata(entityLogicalName).
then(md => {
if (md) return md.PrimaryIdAttribute
return null
})
}
/** Relationships. Returns empty array if not found. */
public getOneToManyRelationships = async (entityLogicalName: string): Promise<Array<OneToManyRelationship>> => {
const rels = entityNameToOneToMany.get(entityLogicalName)
if (rels) return rels
const m = await this.getMetadata(entityLogicalName)
if (!m) return []
return this.client.Get<any>(
"EntityDefinitions", m!.MetadataId,
{
Select: ["LogicalName"],
Expand: [{ Property: "OneToManyRelationships" }]
}).
then((r: any) => {
entityNameToOneToMany.set(entityLogicalName, r.OneToManyRelationships)
return r.OneToManyRelationships
})
}
/** Get a 1:M relationship to a specific name. Could be multiple, so choose wisely. */
public getOneToManyRelationshipsTo = async (entityLogicalName: string, toEntityLogicalName: string) => {
const rels = await this.getOneToManyRelationships(entityLogicalName)
return rels.filter(r => r.ReferencingEntityNavigationPropertyName === toEntityLogicalName)
}
/** Should be only one. null if not found. */
public getOneToManyRelationshipBySchemaName = async (entityLogicalName: string, schemaName: string) => {
const rels = await this.getOneToManyRelationships(entityLogicalName)
const x = rels.filter(r => r.SchemaName === schemaName)
if (x.length === 1) return x[0]
return null
}
public getManyToOneRelationships = async (entityLogicalName: string):
Promise<Array<ManyToOneRelationship>> => {
const rels = entityNameToManyToOne.get(entityLogicalName)
if (rels) return rels
const m = await this.getMetadata(entityLogicalName)
if (!m) return []
return this.client.Get<any>(
"EntityDefinitions", m!.MetadataId,
{
Select: ["LogicalName"],
Expand: [{ Property: "ManyToOneRelationships" }]
}).
then((r: any) => {
entityNameToManyToOne.set(entityLogicalName, r.ManyToOneRelationships)
return r.ManyToOneRelationships
})
}
public getManyToOneRelationshipsFrom = async (entityLogicalName: string,
fromEntityLogicalName: string) => {
const rels = await this.getManyToOneRelationships(entityLogicalName)
return rels.filter(r => r.ReferencedEntityNavigationPropertyName === fromEntityLogicalName)
}
public getManyToOneRelationshipBySchemaName = async (entityLogicalName: string,
schemaName: string) => {
const rels = await this.getManyToOneRelationships(entityLogicalName)
const x = rels.filter(r => r.SchemaName === schemaName)
if (x.length === 1) return x[0]
return null
}
}
export default Metadata
/** Something that can provide a metadata object. */
export interface MetadataProvider {
metadata: Metadata
}