diff --git a/README.md b/README.md index 7f0ed99e..dd96e039 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,15 @@ export default defineMarkdownConfig({ }); ``` +You can also leverage the `exclude` property to indirectly modify things like custom metadata records you do +not want included in the custom metadata type object documentation. + +```typescript +//... +exclude: ['**/*.md-meta.xml'] +//... +``` + ### Excluding Tags from Appearing in the Documentation Note: Only works for Markdown documentation. diff --git a/examples/vitepress/docs/.vitepress/cache/deps/_metadata.json b/examples/vitepress/docs/.vitepress/cache/deps/_metadata.json index 3c6ae1c5..e710f560 100644 --- a/examples/vitepress/docs/.vitepress/cache/deps/_metadata.json +++ b/examples/vitepress/docs/.vitepress/cache/deps/_metadata.json @@ -1,31 +1,31 @@ { - "hash": "f2216e85", + "hash": "05eb2d4f", "configHash": "7f7b0dad", - "lockfileHash": "76121266", - "browserHash": "441a8d6a", + "lockfileHash": "3a9c2374", + "browserHash": "a831d6e7", "optimized": { "vue": { "src": "../../../../node_modules/vue/dist/vue.runtime.esm-bundler.js", "file": "vue.js", - "fileHash": "885cbaa9", + "fileHash": "1632d62a", "needsInterop": false }, "vitepress > @vue/devtools-api": { "src": "../../../../node_modules/@vue/devtools-api/dist/index.js", "file": "vitepress___@vue_devtools-api.js", - "fileHash": "ff3ba36c", + "fileHash": "dc8fec00", "needsInterop": false }, "vitepress > @vueuse/core": { "src": "../../../../node_modules/@vueuse/core/index.mjs", "file": "vitepress___@vueuse_core.js", - "fileHash": "b6cc6d79", + "fileHash": "3d02446b", "needsInterop": false }, "@theme/index": { "src": "../../../../node_modules/vitepress/dist/client/theme-default/index.js", "file": "@theme_index.js", - "fileHash": "6b17bcd7", + "fileHash": "3d2d1de3", "needsInterop": false } }, diff --git a/examples/vitepress/docs/.vitepress/sidebar.json b/examples/vitepress/docs/.vitepress/sidebar.json index 6f2444a3..0fc24fcc 100644 --- a/examples/vitepress/docs/.vitepress/sidebar.json +++ b/examples/vitepress/docs/.vitepress/sidebar.json @@ -93,6 +93,10 @@ { "text": "Speaker__c", "link": "custom-objects/Speaker__c.md" + }, + { + "text": "VisibleCMT__mdt", + "link": "custom-objects/VisibleCMT__mdt.md" } ] } diff --git a/examples/vitepress/docs/changelog.md b/examples/vitepress/docs/changelog.md index 3cc6f539..915a7aae 100644 --- a/examples/vitepress/docs/changelog.md +++ b/examples/vitepress/docs/changelog.md @@ -79,4 +79,12 @@ These custom fields have been added or removed. ### Contact -- New Field: PhotoUrl__c. URL of the contact's photo \ No newline at end of file +- New Field: PhotoUrl__c. URL of the contact's photo + +## New or Removed Custom Metadata Type Records + +These custom metadata type records have been added or removed. + +### VisibleCMT__mdt + +- New Custom Metadata Record: Some_Record_1 \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/VisibleCMT__mdt.md b/examples/vitepress/docs/custom-objects/VisibleCMT__mdt.md new file mode 100644 index 00000000..edc0ffa8 --- /dev/null +++ b/examples/vitepress/docs/custom-objects/VisibleCMT__mdt.md @@ -0,0 +1,29 @@ +--- +title: VisibleCMT__mdt +--- + +# VisibleCMT + +## API Name +`VisibleCMT__mdt` + +## Fields +### Field1 +**Required** + +**API Name** + +`apexdocs__Field1__c` + +**Type** + +*Text* + +## Records +### Some Record 1 + +`Protected` + +**API Name** + +`VisibleCMT.Some_Record_1` \ No newline at end of file diff --git a/examples/vitepress/docs/index.md b/examples/vitepress/docs/index.md index 6d8ce3bb..ca295921 100644 --- a/examples/vitepress/docs/index.md +++ b/examples/vitepress/docs/index.md @@ -49,6 +49,8 @@ Represents a line item on a sales order. Represents a speaker at an event. +### [VisibleCMT__mdt](custom-objects/VisibleCMT__mdt) + ## Miscellaneous ### [BaseClass](miscellaneous/BaseClass) diff --git a/examples/vitepress/force-app/main/default/customMetadata/VisibleCMT.Some_Record_1.md-meta.xml b/examples/vitepress/force-app/main/default/customMetadata/VisibleCMT.Some_Record_1.md-meta.xml new file mode 100644 index 00000000..13c352d4 --- /dev/null +++ b/examples/vitepress/force-app/main/default/customMetadata/VisibleCMT.Some_Record_1.md-meta.xml @@ -0,0 +1,9 @@ + + + + true + + Field1__c + Sample Value + + diff --git a/examples/vitepress/force-app/main/default/objects/SameNamespaceMDT__mdt/SameNamespaceMDT__mdt.object-meta.xml b/examples/vitepress/force-app/main/default/objects/SameNamespaceMDT__mdt/SameNamespaceMDT__mdt.object-meta.xml new file mode 100644 index 00000000..a5d81fa8 --- /dev/null +++ b/examples/vitepress/force-app/main/default/objects/SameNamespaceMDT__mdt/SameNamespaceMDT__mdt.object-meta.xml @@ -0,0 +1,6 @@ + + + + SameNamespaceMDTs + Protected + diff --git a/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml b/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml new file mode 100644 index 00000000..e4b702db --- /dev/null +++ b/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml @@ -0,0 +1,6 @@ + + + + VisibleCMTs + Public + diff --git a/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml b/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml new file mode 100644 index 00000000..8363be54 --- /dev/null +++ b/examples/vitepress/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Field1__c + false + DeveloperControlled + + 255 + true + Text + false + diff --git a/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml b/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml new file mode 100644 index 00000000..e4b702db --- /dev/null +++ b/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/VisibleCMT__mdt.object-meta.xml @@ -0,0 +1,6 @@ + + + + VisibleCMTs + Public + diff --git a/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml b/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml new file mode 100644 index 00000000..8363be54 --- /dev/null +++ b/examples/vitepress/previous/force-app/main/default/objects/VisibleCMT__mdt/fields/Field1__c.field-meta.xml @@ -0,0 +1,11 @@ + + + Field1__c + false + DeveloperControlled + + 255 + true + Text + false + diff --git a/package.json b/package.json index d4c505b3..bbae05bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cparra/apexdocs", - "version": "3.7.3", + "version": "3.8.0", "description": "Library with CLI capabilities to generate documentation for Salesforce Apex classes.", "keywords": [ "apex", diff --git a/src/application/Apexdocs.ts b/src/application/Apexdocs.ts index e317b345..0369760a 100644 --- a/src/application/Apexdocs.ts +++ b/src/application/Apexdocs.ts @@ -6,7 +6,7 @@ import markdown from './generators/markdown'; import openApi from './generators/openapi'; import changelog from './generators/changelog'; -import { processFiles } from './source-code-file-reader'; +import { allComponentTypes, processFiles } from './source-code-file-reader'; import { DefaultFileSystem } from './file-system'; import { Logger } from '#utils/logger'; import { @@ -52,10 +52,9 @@ async function processMarkdown(config: UserDefinedMarkdownConfig) { return pipe( E.tryCatch( () => - readFiles(['ApexClass', 'CustomObject', 'CustomField'], { includeMetadata: config.includeMetadata })( - config.sourceDir, - config.exclude, - ), + readFiles(allComponentTypes, { + includeMetadata: config.includeMetadata, + })(config.sourceDir, config.exclude), (e) => new FileReadingError('An error occurred while reading files.', e), ), TE.fromEither, @@ -73,8 +72,8 @@ async function processOpenApi(config: UserDefinedOpenApiConfig, logger: Logger) async function processChangeLog(config: UserDefinedChangelogConfig) { function loadFiles(): [UnparsedSourceBundle[], UnparsedSourceBundle[]] { return [ - readFiles(['ApexClass', 'CustomObject', 'CustomField'])(config.previousVersionDir, config.exclude), - readFiles(['ApexClass', 'CustomObject', 'CustomField'])(config.currentVersionDir, config.exclude), + readFiles(allComponentTypes)(config.previousVersionDir, config.exclude), + readFiles(allComponentTypes)(config.currentVersionDir, config.exclude), ]; } diff --git a/src/application/source-code-file-reader.ts b/src/application/source-code-file-reader.ts index a2a81ae6..96bcaaf2 100644 --- a/src/application/source-code-file-reader.ts +++ b/src/application/source-code-file-reader.ts @@ -1,10 +1,16 @@ import { FileSystem } from './file-system'; -import { UnparsedApexBundle, UnparsedCustomFieldBundle, UnparsedCustomObjectBundle } from '../core/shared/types'; +import { + UnparsedApexBundle, + UnparsedCustomFieldBundle, + UnparsedCustomMetadataBundle, + UnparsedCustomObjectBundle, +} from '../core/shared/types'; import { minimatch } from 'minimatch'; import { flow, pipe } from 'fp-ts/function'; import { apply } from '#utils/fp'; -type ComponentTypes = 'ApexClass' | 'CustomObject' | 'CustomField'; +export type ComponentTypes = 'ApexClass' | 'CustomObject' | 'CustomField' | 'CustomMetadata'; +export const allComponentTypes: ComponentTypes[] = ['ApexClass', 'CustomObject', 'CustomField', 'CustomMetadata']; /** * Simplified representation of a source component, with only @@ -43,6 +49,14 @@ type CustomFieldSourceComponent = { parentName: string; }; +type CustomMetadataSourceComponent = { + type: 'CustomMetadata'; + apiName: string; + name: string; + contentPath: string; + parentName: string; +}; + function getApexSourceComponents( includeMetadata: boolean, sourceComponents: SourceComponentAdapter[], @@ -125,6 +139,41 @@ function toUnparsedCustomFieldBundle( })); } +function getCustomMetadataSourceComponents( + sourceComponents: SourceComponentAdapter[], +): CustomMetadataSourceComponent[] { + function getParentAndNamePair(component: SourceComponentAdapter): [string, string] { + // Custom metadata take the format [Namespace].[ParentName].[MetadataName], where namespace is optional. + // Here we split the strig and return the last 2 elements, representing the parent and the metadata name. + const [parentName, name] = component.name.split('.').slice(-2); + return [parentName, name]; + } + + return sourceComponents + .filter((component) => component.type.name === 'CustomMetadata') + .map((component) => ({ + apiName: component.name, + name: getParentAndNamePair(component)[1], + type: 'CustomMetadata' as const, + contentPath: component.xml!, + parentName: getParentAndNamePair(component)[0], + })); +} + +function toUnparsedCustomMetadataBundle( + fileSystem: FileSystem, + customMetadataSourceComponents: CustomMetadataSourceComponent[], +): UnparsedCustomMetadataBundle[] { + return customMetadataSourceComponents.map((component) => ({ + apiName: component.apiName, + type: 'custommetadata', + name: component.name, + filePath: component.contentPath, + content: fileSystem.readFile(component.contentPath), + parentName: component.parentName, + })); +} + /** * Reads from source code files and returns their raw body. */ @@ -137,7 +186,12 @@ export function processFiles(fileSystem: FileSystem) { ComponentTypes, ( components: SourceComponentAdapter[], - ) => (UnparsedApexBundle | UnparsedCustomObjectBundle | UnparsedCustomFieldBundle)[] + ) => ( + | UnparsedApexBundle + | UnparsedCustomObjectBundle + | UnparsedCustomFieldBundle + | UnparsedCustomMetadataBundle + )[] > = { ApexClass: flow(apply(getApexSourceComponents, options.includeMetadata), (apexSourceComponents) => toUnparsedApexBundle(fileSystem, apexSourceComponents), @@ -148,6 +202,9 @@ export function processFiles(fileSystem: FileSystem) { CustomField: flow(getCustomFieldSourceComponents, (customFieldSourceComponents) => toUnparsedCustomFieldBundle(fileSystem, customFieldSourceComponents), ), + CustomMetadata: flow(getCustomMetadataSourceComponents, (customMetadataSourceComponents) => + toUnparsedCustomMetadataBundle(fileSystem, customMetadataSourceComponents), + ), }; const convertersToUse = componentTypesToRetrieve.map((componentType) => converters[componentType]); diff --git a/src/core/changelog/__test__/helpers/custom-field-metadata-builder.ts b/src/core/changelog/__test__/helpers/custom-field-metadata-builder.ts new file mode 100644 index 00000000..6bdc8e5d --- /dev/null +++ b/src/core/changelog/__test__/helpers/custom-field-metadata-builder.ts @@ -0,0 +1,28 @@ +import { CustomFieldMetadata } from '../../../reflection/sobject/reflect-custom-field-source'; + +export default class CustomFieldMetadataBuilder { + name: string = 'MyField'; + description: string | null = null; + + withName(name: string): CustomFieldMetadataBuilder { + this.name = name; + return this; + } + + withDescription(testDescription: string) { + this.description = testDescription; + return this; + } + + build(): CustomFieldMetadata { + return { + type: 'Text', + type_name: 'customfield', + label: 'MyField', + name: this.name, + description: this.description, + parentName: 'MyObject', + required: false, + }; + } +} diff --git a/src/core/changelog/__test__/helpers/custom-metadata-metadata-builder.ts b/src/core/changelog/__test__/helpers/custom-metadata-metadata-builder.ts new file mode 100644 index 00000000..62241fb5 --- /dev/null +++ b/src/core/changelog/__test__/helpers/custom-metadata-metadata-builder.ts @@ -0,0 +1,22 @@ +import { CustomMetadataMetadata } from '../../../reflection/sobject/reflect-custom-metadata-source'; + +export default class CustomMetadataMetadataBuilder { + parentName: string = 'MyObject'; + name: string = 'FieldName__c'; + + withName(name: string): CustomMetadataMetadataBuilder { + this.name = name; + return this; + } + + build(): CustomMetadataMetadata { + return { + type_name: 'custommetadata', + apiName: `${this.parentName}.${this.name}`, + protected: false, + label: 'MyMetadata', + name: this.name, + parentName: this.parentName, + }; + } +} diff --git a/src/core/changelog/__test__/helpers/custom-object-metadata-builder.ts b/src/core/changelog/__test__/helpers/custom-object-metadata-builder.ts new file mode 100644 index 00000000..62c6a61a --- /dev/null +++ b/src/core/changelog/__test__/helpers/custom-object-metadata-builder.ts @@ -0,0 +1,32 @@ +import { CustomFieldMetadata } from '../../../reflection/sobject/reflect-custom-field-source'; +import { CustomObjectMetadata } from '../../../reflection/sobject/reflect-custom-object-sources'; +import { CustomMetadataMetadata } from '../../../reflection/sobject/reflect-custom-metadata-source'; + +export default class CustomObjectMetadataBuilder { + label: string = 'MyObject'; + fields: CustomFieldMetadata[] = []; + metadataRecords: CustomMetadataMetadata[] = []; + + withField(field: CustomFieldMetadata): CustomObjectMetadataBuilder { + this.fields.push(field); + return this; + } + + withMetadataRecord(metadataRecord: CustomMetadataMetadata): CustomObjectMetadataBuilder { + this.metadataRecords.push(metadataRecord); + return this; + } + + build(): CustomObjectMetadata { + return { + type_name: 'customobject', + deploymentStatus: 'Deployed', + visibility: 'Public', + label: this.label, + name: 'MyObject', + description: null, + fields: this.fields, + metadataRecords: this.metadataRecords, + }; + } +} diff --git a/src/core/changelog/__test__/processing-changelog.spec.ts b/src/core/changelog/__test__/processing-changelog.spec.ts index ad8ea5a1..6eccbb8b 100644 --- a/src/core/changelog/__test__/processing-changelog.spec.ts +++ b/src/core/changelog/__test__/processing-changelog.spec.ts @@ -1,7 +1,8 @@ import { processChangelog } from '../process-changelog'; import { reflect, Type } from '@cparra/apex-reflection'; -import { CustomObjectMetadata } from '../../reflection/sobject/reflect-custom-object-sources'; -import { CustomFieldMetadata } from '../../reflection/sobject/reflect-custom-field-source'; +import CustomFieldMetadataBuilder from './helpers/custom-field-metadata-builder'; +import CustomObjectMetadataBuilder from './helpers/custom-object-metadata-builder'; +import CustomMetadataMetadataBuilder from './helpers/custom-metadata-metadata-builder'; function apexTypeFromRawString(raw: string): Type { const result = reflect(raw); @@ -12,55 +13,6 @@ function apexTypeFromRawString(raw: string): Type { return result.typeMirror!; } -class CustomFieldMetadataBuilder { - name: string = 'MyField'; - description: string | null = null; - - withName(name: string): CustomFieldMetadataBuilder { - this.name = name; - return this; - } - - withDescription(testDescription: string) { - this.description = testDescription; - return this; - } - - build(): CustomFieldMetadata { - return { - type: 'Text', - type_name: 'customfield', - label: 'MyField', - name: this.name, - description: this.description, - parentName: 'MyObject', - required: false, - }; - } -} - -class CustomObjectMetadataBuilder { - label: string = 'MyObject'; - fields: CustomFieldMetadata[] = []; - - withField(field: CustomFieldMetadata): CustomObjectMetadataBuilder { - this.fields.push(field); - return this; - } - - build(): CustomObjectMetadata { - return { - type_name: 'customobject', - deploymentStatus: 'Deployed', - visibility: 'Public', - label: this.label, - name: 'MyObject', - description: null, - fields: this.fields, - }; - } -} - describe('when generating a changelog', () => { it('has no new types when both the old and new versions are empty', () => { const oldVersion = { types: [] }; @@ -668,4 +620,71 @@ describe('when generating a changelog', () => { ]); }); }); + + describe('with custom metadata records', () => { + it('does not list custom metadata records that are the same in both versions', () => { + // The record uniqueness is determined by its api name. + + const oldCustomMetadata = new CustomMetadataMetadataBuilder().build(); + const newCustomMetadata = new CustomMetadataMetadataBuilder().build(); + + const oldManifest = { types: [oldCustomMetadata] }; + const newManifest = { types: [newCustomMetadata] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([]); + }); + + it('lists new records of a custom object', () => { + const oldObject = new CustomObjectMetadataBuilder() + .withMetadataRecord(new CustomMetadataMetadataBuilder().build()) + .build(); + const newObject = new CustomObjectMetadataBuilder() + .withMetadataRecord(new CustomMetadataMetadataBuilder().build()) + .withMetadataRecord(new CustomMetadataMetadataBuilder().withName('NewField__c').build()) + .build(); + + const oldManifest = { types: [oldObject] }; + const newManifest = { types: [newObject] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: newObject.name, + modifications: [ + { + __typename: 'NewCustomMetadataRecord', + name: 'NewField__c', + }, + ], + }, + ]); + }); + + it('lists removed records of a custom object', () => { + const oldObject = new CustomObjectMetadataBuilder() + .withMetadataRecord(new CustomMetadataMetadataBuilder().withName('OldField__c').build()) + .build(); + const newObject = new CustomObjectMetadataBuilder().build(); + + const oldManifest = { types: [oldObject] }; + const newManifest = { types: [newObject] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: oldObject.name, + modifications: [ + { + __typename: 'RemovedCustomMetadataRecord', + name: 'OldField__c', + }, + ], + }, + ]); + }); + }); }); diff --git a/src/core/changelog/generate-change-log.ts b/src/core/changelog/generate-change-log.ts index a11d7daa..380f443b 100644 --- a/src/core/changelog/generate-change-log.ts +++ b/src/core/changelog/generate-change-log.ts @@ -18,13 +18,14 @@ import { HookError, ReflectionErrors } from '../errors/errors'; import { apply } from '#utils/fp'; import { filterScope } from '../reflection/apex/filter-scope'; import { isInSource, isSkip, passThroughHook, skip, toFrontmatterString } from '../shared/utils'; -import { reflectCustomFieldsAndObjects } from '../reflection/sobject/reflectCustomFieldsAndObjects'; +import { reflectCustomFieldsAndObjectsAndMetadataRecords } from '../reflection/sobject/reflectCustomFieldsAndObjectsAndMetadataRecords'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { Type } from '@cparra/apex-reflection'; -import { filterApexSourceFiles, filterCustomObjectsAndFields } from '#utils/source-bundle-utils'; +import { filterApexSourceFiles, filterCustomObjectsFieldsAndMetadataRecords } from '#utils/source-bundle-utils'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; import { hookableTemplate } from '../markdown/templates/hookable'; import changelogToSourceChangelog from './helpers/changelog-to-source-changelog'; +import { CustomMetadataMetadata } from '../reflection/sobject/reflect-custom-metadata-source'; type Config = Omit; @@ -70,7 +71,7 @@ function reflect(bundles: UnparsedSourceBundle[], config: Omit { return pipe( - reflectCustomFieldsAndObjects(filterCustomObjectsAndFields(bundles)), + reflectCustomFieldsAndObjectsAndMetadataRecords(filterCustomObjectsFieldsAndMetadataRecords(bundles)), TE.map((parsedObjectFiles) => [...parsedApexFiles, ...parsedObjectFiles]), ); }), @@ -81,7 +82,10 @@ function toManifests({ oldVersion, newVersion }: { oldVersion: ParsedFile[]; new function parsedFilesToManifest(parsedFiles: ParsedFile[]): VersionManifest { return { types: parsedFiles.reduce( - (previousValue: (Type | CustomObjectMetadata | CustomFieldMetadata)[], parsedFile: ParsedFile) => { + ( + previousValue: (Type | CustomObjectMetadata | CustomFieldMetadata | CustomMetadataMetadata)[], + parsedFile: ParsedFile, + ) => { if (!isInSource(parsedFile.source) && parsedFile.type.type_name === 'customobject') { // When we are dealing with a custom object that was not in the source (for extension fields), we return all // of its fields. @@ -89,7 +93,7 @@ function toManifests({ oldVersion, newVersion }: { oldVersion: ParsedFile[]; new } return [...previousValue, parsedFile.type]; }, - [] as (Type | CustomObjectMetadata | CustomFieldMetadata)[], + [] as (Type | CustomObjectMetadata | CustomFieldMetadata | CustomMetadataMetadata)[], ), }; } diff --git a/src/core/changelog/process-changelog.ts b/src/core/changelog/process-changelog.ts index d689db40..64aeab26 100644 --- a/src/core/changelog/process-changelog.ts +++ b/src/core/changelog/process-changelog.ts @@ -3,9 +3,10 @@ import { pipe } from 'fp-ts/function'; import { areMethodsEqual } from './method-changes-checker'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; +import { CustomMetadataMetadata } from '../reflection/sobject/reflect-custom-metadata-source'; export type VersionManifest = { - types: (Type | CustomObjectMetadata | CustomFieldMetadata)[]; + types: (Type | CustomObjectMetadata | CustomFieldMetadata | CustomMetadataMetadata)[]; }; type ModificationTypes = @@ -18,7 +19,9 @@ type ModificationTypes = | 'NewProperty' | 'RemovedProperty' | 'NewField' - | 'RemovedField'; + | 'RemovedField' + | 'NewCustomMetadataRecord' + | 'RemovedCustomMetadataRecord'; export type MemberModificationType = { __typename: ModificationTypes; @@ -105,7 +108,10 @@ function getNewOrModifiedApexMembers(oldVersion: VersionManifest, newVersion: Ve function getCustomObjectModifications(oldVersion: VersionManifest, newVersion: VersionManifest): NewOrModifiedMember[] { return pipe( getCustomObjectsInBothVersions(oldVersion, newVersion), - (customObjectsInBoth) => getNewOrRemovedCustomFields(customObjectsInBoth), + (customObjectsInBoth) => [ + ...getNewOrRemovedCustomFields(customObjectsInBoth), + ...getNewOrRemovedCustomMetadataRecords(customObjectsInBoth), + ], (customObjectModifications) => customObjectModifications.filter((member) => member.modifications.length > 0), ); } @@ -179,6 +185,21 @@ function getNewOrRemovedCustomFields(typesInBoth: TypeInBoth[]): NewOrModifiedMember[] { + return typesInBoth.map(({ oldType, newType }) => { + const oldCustomObject = oldType; + const newCustomObject = newType; + + return { + typeName: newType.name, + modifications: [ + ...getNewValues(oldCustomObject, newCustomObject, 'metadataRecords', 'NewCustomMetadataRecord'), + ...getRemovedValues(oldCustomObject, newCustomObject, 'metadataRecords', 'RemovedCustomMetadataRecord'), + ], + }; + }); +} + function getNewOrModifiedEnumValues(typesInBoth: TypeInBoth[]): NewOrModifiedMember[] { return pipe( typesInBoth.filter((typeInBoth): typeInBoth is TypeInBoth => typeInBoth.oldType.type_name === 'enum'), diff --git a/src/core/changelog/renderable-changelog.ts b/src/core/changelog/renderable-changelog.ts index 0b72ed1c..825feccb 100644 --- a/src/core/changelog/renderable-changelog.ts +++ b/src/core/changelog/renderable-changelog.ts @@ -4,6 +4,7 @@ import { RenderableContent } from '../renderables/types'; import { adaptDescribable } from '../renderables/documentables'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; +import { CustomMetadataMetadata } from '../reflection/sobject/reflect-custom-metadata-source'; type NewTypeRenderable = { name: string; @@ -43,11 +44,12 @@ export type RenderableChangelog = { newCustomObjects: NewTypeSection<'customobject'> | null; removedCustomObjects: RemovedTypeSection | null; newOrRemovedCustomFields: NewOrModifiedMembersSection | null; + newOrRemovedCustomMetadataTypeRecords: NewOrModifiedMembersSection | null; }; export function convertToRenderableChangelog( changelog: Changelog, - newManifest: (Type | CustomObjectMetadata | CustomFieldMetadata)[], + newManifest: (Type | CustomObjectMetadata | CustomFieldMetadata | CustomMetadataMetadata)[], ): RenderableChangelog { const allNewTypes = [...changelog.newApexTypes, ...changelog.newCustomObjects].map( (newType) => newManifest.find((type) => type.name.toLowerCase() === newType.toLowerCase())!, @@ -59,6 +61,16 @@ export function convertToRenderableChangelog( const newCustomObjects = allNewTypes.filter( (type): type is CustomObjectMetadata => type.type_name === 'customobject', ); + const newOrModifiedCustomFields = changelog.customObjectModifications.filter( + (modification): modification is NewOrModifiedMember => + modification.modifications.some((mod) => mod.__typename === 'NewField' || mod.__typename === 'RemovedField'), + ); + const newOrModifiedCustomMetadataTypeRecords = changelog.customObjectModifications.filter( + (modification): modification is NewOrModifiedMember => + modification.modifications.some( + (mod) => mod.__typename === 'NewCustomMetadataRecord' || mod.__typename === 'RemovedCustomMetadataRecord', + ), + ); return { newClasses: @@ -121,11 +133,19 @@ export function convertToRenderableChangelog( } : null, newOrRemovedCustomFields: - changelog.customObjectModifications.length > 0 + newOrModifiedCustomFields.length > 0 ? { heading: 'New or Removed Fields to Custom Objects or Standard Objects', description: 'These custom fields have been added or removed.', - modifications: changelog.customObjectModifications.map(toRenderableModification), + modifications: newOrModifiedCustomFields.map(toRenderableModification), + } + : null, + newOrRemovedCustomMetadataTypeRecords: + newOrModifiedCustomMetadataTypeRecords.length > 0 + ? { + heading: 'New or Removed Custom Metadata Type Records', + description: 'These custom metadata type records have been added or removed.', + modifications: newOrModifiedCustomMetadataTypeRecords.map(toRenderableModification), } : null, }; @@ -179,5 +199,9 @@ function toRenderableModificationDescription(memberModificationType: MemberModif return `New Type: ${withDescription(memberModificationType)}`; case 'RemovedType': return `Removed Type: ${memberModificationType.name}`; + case 'NewCustomMetadataRecord': + return `New Custom Metadata Record: ${withDescription(memberModificationType)}`; + case 'RemovedCustomMetadataRecord': + return `Removed Custom Metadata Record: ${memberModificationType.name}`; } } diff --git a/src/core/changelog/templates/changelog-template.ts b/src/core/changelog/templates/changelog-template.ts index eb6f431b..26ec2d79 100644 --- a/src/core/changelog/templates/changelog-template.ts +++ b/src/core/changelog/templates/changelog-template.ts @@ -95,6 +95,21 @@ export const changelogTemplate = ` - {{this}} {{/each}} +{{/each}} +{{/if}} + +{{#if newOrRemovedCustomMetadataTypeRecords}} +## {{newOrRemovedCustomMetadataTypeRecords.heading}} + +{{newOrRemovedCustomMetadataTypeRecords.description}} + +{{#each newOrRemovedCustomMetadataTypeRecords.modifications}} +### {{this.typeName}} + +{{#each this.modifications}} +- {{this}} +{{/each}} + {{/each}} {{/if}} `.trim(); diff --git a/src/core/markdown/__test__/generating-custom-object-docs.spec.ts b/src/core/markdown/__test__/generating-custom-object-docs.spec.ts index feef912e..572c2901 100644 --- a/src/core/markdown/__test__/generating-custom-object-docs.spec.ts +++ b/src/core/markdown/__test__/generating-custom-object-docs.spec.ts @@ -1,7 +1,10 @@ import { extendExpect } from './expect-extensions'; import { customFieldPickListValues, generateDocs, unparsedObjectBundleFromRawString } from './test-helpers'; import { assertEither } from '../../test-helpers/assert-either'; -import { unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; +import { + unparsedCustomMetadataFromRawString, + unparsedFieldBundleFromRawString, +} from '../../test-helpers/test-data-builders'; import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; describe('Generates Custom Object documentation', () => { @@ -136,5 +139,73 @@ describe('Generates Custom Object documentation', () => { expect(result).documentationBundleHasLength(1); assertEither(result, (data) => expect(data).firstDocContains('`TestField__c`')); }); + + describe('when documenting Custom Metadata Types', () => { + it('displays the Records heading if fields are present', async () => { + const customObjectBundle = unparsedObjectBundleFromRawString({ + name: 'TestObject__mdt', + rawContent: new CustomObjectXmlBuilder().build(), + filePath: 'src/object/TestObject__mdt.object-meta.xml', + }); + + const customMetadataBundle = unparsedCustomMetadataFromRawString({ + filePath: 'src/customMetadata/TestField__c.field-meta.xml', + parentName: 'TestObject', + apiName: 'TestObject.TestField__c', + }); + + const result = await generateDocs([customObjectBundle, customMetadataBundle])(); + expect(result).documentationBundleHasLength(1); + assertEither(result, (data) => expect(data).firstDocContains('## Records')); + }); + + it('does not display the Records heading if no records are present', async () => { + const input = unparsedObjectBundleFromRawString({ + name: 'TestObject__mdt', + rawContent: new CustomObjectXmlBuilder().build(), + filePath: 'src/object/TestObject__c.object-meta.xml', + }); + + const result = await generateDocs([input])(); + expect(result).documentationBundleHasLength(1); + assertEither(result, (data) => expect(data).not.firstDocContains('## Records')); + }); + + it('displays the record label as a heading', async () => { + const customObjectBundle = unparsedObjectBundleFromRawString({ + name: 'TestObject__mdt', + rawContent: new CustomObjectXmlBuilder().build(), + filePath: 'src/object/TestObject__mdt.object-meta.xml', + }); + + const customMetadataBundle = unparsedCustomMetadataFromRawString({ + filePath: 'src/customMetadata/TestField__c.field-meta.xml', + parentName: 'TestObject', + apiName: 'TestObject.TestField__c', + }); + + const result = await generateDocs([customObjectBundle, customMetadataBundle])(); + expect(result).documentationBundleHasLength(1); + assertEither(result, (data) => expect(data).firstDocContains('## Test Metadata')); + }); + + it('displays the record api name', async () => { + const customObjectBundle = unparsedObjectBundleFromRawString({ + name: 'TestObject__mdt', + rawContent: new CustomObjectXmlBuilder().build(), + filePath: 'src/object/TestObject__mdt.object-meta.xml', + }); + + const customMetadataBundle = unparsedCustomMetadataFromRawString({ + filePath: 'src/customMetadata/TestField__c.field-meta.xml', + parentName: 'TestObject', + apiName: 'TestObject.TestField__c', + }); + + const result = await generateDocs([customObjectBundle, customMetadataBundle])(); + expect(result).documentationBundleHasLength(1); + assertEither(result, (data) => expect(data).firstDocContains('TestObject.TestField__c')); + }); + }); }); }); diff --git a/src/core/markdown/__test__/test-helpers.ts b/src/core/markdown/__test__/test-helpers.ts index 2f126de6..0ed98827 100644 --- a/src/core/markdown/__test__/test-helpers.ts +++ b/src/core/markdown/__test__/test-helpers.ts @@ -15,17 +15,18 @@ export function unparsedApexBundleFromRawString(raw: string, rawMetadata?: strin export function unparsedObjectBundleFromRawString(meta: { rawContent: string; filePath: string; + name?: string; }): UnparsedCustomObjectBundle { return { type: 'customobject', - name: 'TestObject__c', + name: meta.name ?? 'TestObject__c', filePath: meta.filePath, content: meta.rawContent, }; } -export function generateDocs(apexBundles: UnparsedSourceBundle[], config?: Partial) { - return gen(apexBundles, { +export function generateDocs(bundles: UnparsedSourceBundle[], config?: Partial) { + return gen(bundles, { targetDir: 'target', scope: ['global', 'public'], defaultGroupName: 'Miscellaneous', diff --git a/src/core/markdown/adapters/type-to-renderable.ts b/src/core/markdown/adapters/type-to-renderable.ts index 46226a0f..39220d51 100644 --- a/src/core/markdown/adapters/type-to-renderable.ts +++ b/src/core/markdown/adapters/type-to-renderable.ts @@ -12,6 +12,7 @@ import { GetRenderableContentByTypeName, RenderableCustomObject, RenderableCustomField, + RenderableCustomMetadata, } from '../../renderables/types'; import { adaptDescribable, adaptDocumentable } from '../../renderables/documentables'; import { adaptConstructor, adaptMethod } from './methods-and-constructors'; @@ -21,6 +22,7 @@ import { ExternalMetadata, SourceFileMetadata } from '../../shared/types'; import { CustomObjectMetadata } from '../../reflection/sobject/reflect-custom-object-sources'; import { getTypeGroup, isInSource } from '../../shared/utils'; import { CustomFieldMetadata } from '../../reflection/sobject/reflect-custom-field-source'; +import { CustomMetadataMetadata } from '../../reflection/sobject/reflect-custom-metadata-source'; type GetReturnRenderable = T extends InterfaceMirror ? RenderableInterface @@ -266,6 +268,12 @@ function objectMetadataToRenderable( heading: 'Fields', value: objectMetadata.fields.map((field) => fieldMetadataToRenderable(field, config, 3)), }, + hasRecords: objectMetadata.metadataRecords.length > 0, + metadataRecords: { + headingLevel: 2, + heading: 'Records', + value: objectMetadata.metadataRecords.map((metadata) => customMetadataToRenderable(metadata, 3)), + }, }; } @@ -292,6 +300,17 @@ function fieldMetadataToRenderable( }; } +function customMetadataToRenderable(metadata: CustomMetadataMetadata, headingLevel: number): RenderableCustomMetadata { + return { + type: 'metadata', + headingLevel: headingLevel, + heading: metadata.label ?? metadata.name, + apiName: metadata.apiName, + label: metadata.label ?? metadata.name, + protected: metadata.protected, + }; +} + function getApiName(currentName: string, config: MarkdownGeneratorConfig) { if (config.namespace) { // first remove any `__c` suffix diff --git a/src/core/markdown/generate-docs.ts b/src/core/markdown/generate-docs.ts index fbffc05a..ef7f6e42 100644 --- a/src/core/markdown/generate-docs.ts +++ b/src/core/markdown/generate-docs.ts @@ -32,8 +32,8 @@ import { removeExcludedTags } from '../reflection/apex/remove-excluded-tags'; import { HookError } from '../errors/errors'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { Type } from '@cparra/apex-reflection'; -import { reflectCustomFieldsAndObjects } from '../reflection/sobject/reflectCustomFieldsAndObjects'; -import { filterApexSourceFiles, filterCustomObjectsAndFields } from '#utils/source-bundle-utils'; +import { reflectCustomFieldsAndObjectsAndMetadataRecords } from '../reflection/sobject/reflectCustomFieldsAndObjectsAndMetadataRecords'; +import { filterApexSourceFiles, filterCustomObjectsFieldsAndMetadataRecords } from '#utils/source-bundle-utils'; export type MarkdownGeneratorConfig = Omit< UserDefinedMarkdownConfig, @@ -52,9 +52,10 @@ export function generateDocs(unparsedBundles: UnparsedSourceBundle[], config: Ma ); const sort = apply(sortTypesAndMembers, config.sortAlphabetically); - function filterOutCustomFields(parsedFiles: ParsedFile[]): ParsedFile[] { + function filterOutCustomFieldsAndMetadata(parsedFiles: ParsedFile[]): ParsedFile[] { return parsedFiles.filter( - (parsedFile): parsedFile is ParsedFile => parsedFile.source.type !== 'customfield', + (parsedFile): parsedFile is ParsedFile => + parsedFile.source.type !== 'customfield' && parsedFile.source.type !== 'custommetadata', ); } @@ -62,11 +63,11 @@ export function generateDocs(unparsedBundles: UnparsedSourceBundle[], config: Ma generateForApex(filterApexSourceFiles(unparsedBundles), config), TE.chain((parsedApexFiles) => { return pipe( - reflectCustomFieldsAndObjects(filterCustomObjectsAndFields(unparsedBundles)), + reflectCustomFieldsAndObjectsAndMetadataRecords(filterCustomObjectsFieldsAndMetadataRecords(unparsedBundles)), TE.map((parsedObjectFiles) => [...parsedApexFiles, ...parsedObjectFiles]), ); }), - TE.map((parsedFiles) => sort(filterOutCustomFields(parsedFiles))), + TE.map((parsedFiles) => sort(filterOutCustomFieldsAndMetadata(parsedFiles))), TE.bindTo('parsedFiles'), TE.bind('references', ({ parsedFiles }) => TE.right( @@ -75,7 +76,9 @@ export function generateDocs(unparsedBundles: UnparsedSourceBundle[], config: Ma ), ), TE.flatMap(({ parsedFiles, references }) => transformReferenceHook(config)({ references, parsedFiles })), - TE.map(({ parsedFiles, references }) => convertToRenderableBundle(filterOutCustomFields(parsedFiles), references)), + TE.map(({ parsedFiles, references }) => + convertToRenderableBundle(filterOutCustomFieldsAndMetadata(parsedFiles), references), + ), TE.map(convertToDocumentationBundleForTemplate), TE.flatMap(transformDocumentationBundleHook(config)), TE.map(postHookCompile), diff --git a/src/core/markdown/templates/custom-object-template.ts b/src/core/markdown/templates/custom-object-template.ts index 16d2f99c..f33022fd 100644 --- a/src/core/markdown/templates/custom-object-template.ts +++ b/src/core/markdown/templates/custom-object-template.ts @@ -39,4 +39,25 @@ export const customObjectTemplate = ` {{/each}} {{/if}} +{{#if hasRecords}} +{{ heading metadataRecords.headingLevel metadataRecords.heading }} +{{#each metadataRecords.value}} +{{ heading headingLevel heading }} + +{{#if protected}} +\`Protected\` +{{/if}} + +{{#if description}} +{{{renderContent description}}} +{{/if}} + +**API Name** + +\`{{{apiName}}}\` + +{{#unless @last}}---{{/unless}} +{{/each}} +{{/if}} + `.trim(); diff --git a/src/core/reflection/sobject/reflect-custom-metadata-source.ts b/src/core/reflection/sobject/reflect-custom-metadata-source.ts new file mode 100644 index 00000000..7ed4f1c9 --- /dev/null +++ b/src/core/reflection/sobject/reflect-custom-metadata-source.ts @@ -0,0 +1,86 @@ +import { ParsedFile, UnparsedCustomMetadataBundle } from '../../shared/types'; +import * as TE from 'fp-ts/TaskEither'; +import { ReflectionError, ReflectionErrors } from '../../errors/errors'; +import { pipe } from 'fp-ts/function'; +import * as A from 'fp-ts/Array'; +import * as E from 'fp-ts/Either'; +import { XMLParser } from 'fast-xml-parser'; + +export type CustomMetadataMetadata = { + type_name: 'custommetadata'; + protected: boolean; + apiName: string; + name: string; + label?: string | null; + parentName: string; +}; + +export function reflectCustomMetadataSources( + customMetadataSources: UnparsedCustomMetadataBundle[], +): TE.TaskEither[]> { + return pipe(customMetadataSources, A.traverse(TE.ApplicativePar)(reflectCustomMetadataSource)); +} + +function reflectCustomMetadataSource( + customMetadataSource: UnparsedCustomMetadataBundle, +): TE.TaskEither> { + return pipe( + E.tryCatch(() => new XMLParser().parse(customMetadataSource.content), E.toError), + E.flatMap(validate), + E.map(toCustomMetadataMetadata), + E.map((metadata) => addNames(metadata, customMetadataSource.name, customMetadataSource.apiName)), + E.map((metadata) => addParentName(metadata, customMetadataSource.parentName)), + E.map((metadata) => toParsedFile(customMetadataSource.filePath, metadata)), + E.mapLeft((error) => new ReflectionErrors([new ReflectionError(customMetadataSource.filePath, error.message)])), + TE.fromEither, + ); +} + +function validate(parsedResult: unknown): E.Either { + const err = E.left(new Error('Invalid custom metadata')); + + function isObject(value: unknown) { + return typeof value === 'object' && value !== null ? E.right(value) : err; + } + + function hasTheCustomMetadataKey(value: object) { + return 'CustomMetadata' in value ? E.right(value) : err; + } + + return pipe(parsedResult, isObject, E.chain(hasTheCustomMetadataKey)); +} + +function toCustomMetadataMetadata(parserResult: { CustomMetadata: unknown }): CustomMetadataMetadata { + const customMetadata = + parserResult?.CustomMetadata != null && typeof parserResult.CustomMetadata === 'object' + ? parserResult.CustomMetadata + : {}; + const defaultValues: Partial = { + label: null, + }; + + return { + ...defaultValues, + ...customMetadata, + type_name: 'custommetadata', + } as CustomMetadataMetadata; +} + +function addNames(metadata: CustomMetadataMetadata, name: string, apiName: string): CustomMetadataMetadata { + return { ...metadata, name, apiName }; +} + +function addParentName(metadata: CustomMetadataMetadata, parentName: string): CustomMetadataMetadata { + return { ...metadata, parentName }; +} + +function toParsedFile(filePath: string, typeMirror: CustomMetadataMetadata): ParsedFile { + return { + source: { + filePath, + name: typeMirror.name, + type: typeMirror.type_name, + }, + type: typeMirror, + }; +} diff --git a/src/core/reflection/sobject/reflect-custom-object-sources.ts b/src/core/reflection/sobject/reflect-custom-object-sources.ts index 6f22780d..5654d6e8 100644 --- a/src/core/reflection/sobject/reflect-custom-object-sources.ts +++ b/src/core/reflection/sobject/reflect-custom-object-sources.ts @@ -9,6 +9,7 @@ import * as A from 'fp-ts/Array'; import * as E from 'fp-ts/Either'; import { CustomFieldMetadata } from './reflect-custom-field-source'; import { getPickListValues } from './parse-picklist-values'; +import { CustomMetadataMetadata } from './reflect-custom-metadata-source'; export type CustomObjectMetadata = { type_name: 'customobject'; @@ -18,6 +19,7 @@ export type CustomObjectMetadata = { name: string; description: string | null; fields: CustomFieldMetadata[]; + metadataRecords: CustomMetadataMetadata[]; }; export function reflectCustomObjectSources( @@ -64,11 +66,12 @@ function validate(parseResult: unknown): E.Either = { deploymentStatus: 'Deployed', visibility: 'Public', description: null, fields: [] as CustomFieldMetadata[], + metadataRecords: [] as CustomMetadataMetadata[], }; return { ...defaultValues, ...customObject } as CustomObjectMetadata; } diff --git a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts b/src/core/reflection/sobject/reflectCustomFieldsAndObjectsAndMetadataRecords.ts similarity index 73% rename from src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts rename to src/core/reflection/sobject/reflectCustomFieldsAndObjectsAndMetadataRecords.ts index 083865a4..157f8354 100644 --- a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts +++ b/src/core/reflection/sobject/reflectCustomFieldsAndObjectsAndMetadataRecords.ts @@ -1,13 +1,19 @@ -import { ParsedFile, UnparsedCustomFieldBundle, UnparsedCustomObjectBundle } from '../../shared/types'; +import { + ParsedFile, + UnparsedCustomFieldBundle, + UnparsedCustomMetadataBundle, + UnparsedCustomObjectBundle, +} from '../../shared/types'; import { CustomObjectMetadata, reflectCustomObjectSources } from './reflect-custom-object-sources'; import * as TE from 'fp-ts/TaskEither'; import { ReflectionErrors } from '../../errors/errors'; import { CustomFieldMetadata, reflectCustomFieldSources } from './reflect-custom-field-source'; import { pipe } from 'fp-ts/function'; import { TaskEither } from 'fp-ts/TaskEither'; +import { CustomMetadataMetadata, reflectCustomMetadataSources } from './reflect-custom-metadata-source'; -export function reflectCustomFieldsAndObjects( - objectBundles: (UnparsedCustomObjectBundle | UnparsedCustomFieldBundle)[], +export function reflectCustomFieldsAndObjectsAndMetadataRecords( + objectBundles: (UnparsedCustomObjectBundle | UnparsedCustomFieldBundle | UnparsedCustomMetadataBundle)[], ): TaskEither[]> { function filterNonPublished(parsedFiles: ParsedFile[]): ParsedFile[] { return parsedFiles.filter((parsedFile) => parsedFile.type.deploymentStatus === 'Deployed'); @@ -25,12 +31,22 @@ export function reflectCustomFieldsAndObjects( (object): object is UnparsedCustomFieldBundle => object.type === 'customfield', ); + const customMetadata = objectBundles.filter( + (object): object is UnparsedCustomMetadataBundle => object.type === 'custommetadata', + ); + function generateForFields( fields: UnparsedCustomFieldBundle[], ): TE.TaskEither[]> { return pipe(fields, reflectCustomFieldSources); } + function generateForMetadata( + metadata: UnparsedCustomMetadataBundle[], + ): TE.TaskEither[]> { + return pipe(metadata, reflectCustomMetadataSources); + } + return pipe( customObjects, reflectCustomObjectSources, @@ -38,24 +54,29 @@ export function reflectCustomFieldsAndObjects( TE.map(filterNonPublic), TE.bindTo('objects'), TE.bind('fields', () => generateForFields(customFields)), - TE.map(({ objects, fields }) => { - return [...mapFieldsToObjects(objects, fields), ...mapExtensionFields(objects, fields)]; + TE.bind('metadata', () => generateForMetadata(customMetadata)), + TE.map(({ objects, fields, metadata }) => { + return [...mapFieldsAndMetadata(objects, fields, metadata), ...mapExtensionFields(objects, fields)]; }), ); } -function mapFieldsToObjects( +function mapFieldsAndMetadata( objects: ParsedFile[], fields: ParsedFile[], + metadata: ParsedFile[], ): ParsedFile[] { // Locate the fields for each object by using the parentName property return objects.map((object) => { const objectFields = fields.filter((field) => field.type.parentName === object.type.name); + const objectMetadata = metadata.filter((meta) => `${meta.type.parentName}__mdt` === object.type.name); + return { ...object, type: { ...object.type, fields: [...object.type.fields, ...objectFields.map((field) => field.type)], + metadataRecords: [...object.type.metadataRecords, ...objectMetadata.map((meta) => meta.type)], }, }; }); @@ -97,6 +118,7 @@ function mapExtensionFields( name: key, description: null, fields: fields, + metadataRecords: [], }, }; }); diff --git a/src/core/renderables/types.d.ts b/src/core/renderables/types.d.ts index f54c5fad..5f1bd03a 100644 --- a/src/core/renderables/types.d.ts +++ b/src/core/renderables/types.d.ts @@ -181,7 +181,9 @@ export type RenderableCustomObject = Omit & { apiName: string; type: 'customobject'; hasFields: boolean; + hasRecords: boolean; fields: RenderableSection; + metadataRecords: RenderableSection; }; export type RenderableCustomField = { @@ -195,6 +197,15 @@ export type RenderableCustomField = { required: boolean; }; +export type RenderableCustomMetadata = { + headingLevel: number; + heading: string; + apiName: string; + type: 'metadata'; + label: string; + protected: boolean; +}; + export type Renderable = (RenderableClass | RenderableInterface | RenderableEnum | RenderableCustomObject) & { filePath: string | undefined; }; diff --git a/src/core/shared/types.d.ts b/src/core/shared/types.d.ts index 0e3157db..e2fdff5c 100644 --- a/src/core/shared/types.d.ts +++ b/src/core/shared/types.d.ts @@ -1,6 +1,7 @@ import { Type } from '@cparra/apex-reflection'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; +import { CustomMetadataMetadata } from '../reflection/sobject/reflect-custom-metadata-source'; export type Generators = 'markdown' | 'openapi' | 'changelog'; @@ -29,7 +30,7 @@ export type CliConfigurableMarkdownConfig = { }; export type UserDefinedMarkdownConfig = { - targetGenerator: 'markdown' /** Glob patterns to exclude files from the documentation. */; + targetGenerator: 'markdown'; excludeTags: string[]; exclude: string[]; } & CliConfigurableMarkdownConfig & @@ -59,7 +60,11 @@ export type UserDefinedChangelogConfig = { export type UserDefinedConfig = UserDefinedMarkdownConfig | UserDefinedOpenApiConfig | UserDefinedChangelogConfig; -export type UnparsedSourceBundle = UnparsedApexBundle | UnparsedCustomObjectBundle | UnparsedCustomFieldBundle; +export type UnparsedSourceBundle = + | UnparsedApexBundle + | UnparsedCustomObjectBundle + | UnparsedCustomFieldBundle + | UnparsedCustomMetadataBundle; export type UnparsedCustomObjectBundle = { type: 'customobject'; @@ -76,6 +81,15 @@ export type UnparsedCustomFieldBundle = { parentName: string; }; +export type UnparsedCustomMetadataBundle = { + type: 'custommetadata'; + apiName: string; + name: string; + filePath: string; + content: string; + parentName: string; +}; + export type UnparsedApexBundle = { type: 'apex'; name: string; @@ -84,7 +98,7 @@ export type UnparsedApexBundle = { metadataContent: string | null; }; -type MetadataTypes = 'interface' | 'class' | 'enum' | 'customobject' | 'customfield'; +type MetadataTypes = 'interface' | 'class' | 'enum' | 'customobject' | 'customfield' | 'custommetadata'; export type SourceFileMetadata = { filePath: string; @@ -104,7 +118,11 @@ export type ExternalMetadata = { }; export type ParsedFile< - T extends Type | CustomObjectMetadata | CustomFieldMetadata = Type | CustomObjectMetadata | CustomFieldMetadata, + T extends Type | CustomObjectMetadata | CustomFieldMetadata | CustomMetadataMetadata = + | Type + | CustomObjectMetadata + | CustomFieldMetadata + | CustomMetadataMetadata, > = { source: SourceFileMetadata | ExternalMetadata; type: T; diff --git a/src/core/test-helpers/test-data-builders.ts b/src/core/test-helpers/test-data-builders.ts index 4bd4a20e..9c0bed2e 100644 --- a/src/core/test-helpers/test-data-builders.ts +++ b/src/core/test-helpers/test-data-builders.ts @@ -1,4 +1,4 @@ -import { UnparsedCustomFieldBundle } from '../shared/types'; +import { UnparsedCustomFieldBundle, UnparsedCustomMetadataBundle } from '../shared/types'; export const customField = ` @@ -25,3 +25,31 @@ export function unparsedFieldBundleFromRawString(meta: { parentName: meta.parentName, }; } + +export const customMetadata = ` + + + + true + + Field1__c + Sample Value + + +`; + +export function unparsedCustomMetadataFromRawString(meta: { + rawContent?: string; + filePath: string; + apiName: string; + parentName: string; +}): UnparsedCustomMetadataBundle { + return { + type: 'custommetadata', + name: meta.apiName, + filePath: meta.filePath, + content: meta.rawContent ?? customMetadata, + apiName: meta.apiName, + parentName: meta.parentName, + }; +} diff --git a/src/util/source-bundle-utils.ts b/src/util/source-bundle-utils.ts index 39fcb650..dc63676c 100644 --- a/src/util/source-bundle-utils.ts +++ b/src/util/source-bundle-utils.ts @@ -1,6 +1,7 @@ import { UnparsedApexBundle, UnparsedCustomFieldBundle, + UnparsedCustomMetadataBundle, UnparsedCustomObjectBundle, UnparsedSourceBundle, } from '../core/shared/types'; @@ -9,11 +10,11 @@ export function filterApexSourceFiles(sourceFiles: UnparsedSourceBundle[]): Unpa return sourceFiles.filter((sourceFile): sourceFile is UnparsedApexBundle => sourceFile.type === 'apex'); } -export function filterCustomObjectsAndFields( +export function filterCustomObjectsFieldsAndMetadataRecords( sourceFiles: UnparsedSourceBundle[], -): (UnparsedCustomObjectBundle | UnparsedCustomFieldBundle)[] { +): (UnparsedCustomObjectBundle | UnparsedCustomFieldBundle | UnparsedCustomMetadataBundle)[] { return sourceFiles.filter( (sourceFile): sourceFile is UnparsedCustomObjectBundle => - sourceFile.type === 'customobject' || sourceFile.type === 'customfield', + sourceFile.type === 'customobject' || sourceFile.type === 'customfield' || sourceFile.type === 'custommetadata', ); }