diff --git a/examples/vitepress/docs/.vitepress/sidebar.json b/examples/vitepress/docs/.vitepress/sidebar.json index 7ea27462..6f2444a3 100644 --- a/examples/vitepress/docs/.vitepress/sidebar.json +++ b/examples/vitepress/docs/.vitepress/sidebar.json @@ -58,6 +58,10 @@ { "text": "Object Reference", "items": [ + { + "text": "Contact", + "link": "custom-objects/Contact.md" + }, { "text": "Event__c", "link": "custom-objects/Event__c.md" diff --git a/examples/vitepress/docs/changelog.md b/examples/vitepress/docs/changelog.md index 9da7475d..48cb2485 100644 --- a/examples/vitepress/docs/changelog.md +++ b/examples/vitepress/docs/changelog.md @@ -67,4 +67,12 @@ Represents a line item on a sales order. Custom object for tracking sales orders. ### Speaker__c -Represents a speaker at an event. \ No newline at end of file +Represents a speaker at an event. + +## New or Removed Fields to Custom Objects or Standard Objects + +These custom fields have been added or removed. + +### Contact + +- New Field: PhotoUrl__c \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/Contact.md b/examples/vitepress/docs/custom-objects/Contact.md new file mode 100644 index 00000000..9f96853f --- /dev/null +++ b/examples/vitepress/docs/custom-objects/Contact.md @@ -0,0 +1,19 @@ +--- +title: Contact +--- + +# Contact + +## API Name +`apexdocs__Contact` + +## Fields +### PhotoUrl + +**API Name** + +`apexdocs__PhotoUrl__c` + +**Type** + +*Url* \ No newline at end of file diff --git a/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md b/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md index 4575d7ab..69a485d3 100644 --- a/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md +++ b/examples/vitepress/docs/custom-objects/Product_Inline_Fields__c.md @@ -20,4 +20,93 @@ The date the product got discontinued **Type** -*DateTime* \ No newline at end of file +*DateTime* + +--- +### ID + +**API Name** + +`apexdocs__ID__c` + +**Type** + +*Number* + +--- +### Name + +Product name + +**API Name** + +`apexdocs__Name__c` + +**Type** + +*Text* + +--- +### Price + +Product price in the default currency + +**API Name** + +`apexdocs__Price__c` + +**Type** + +*Number* + +--- +### Products + +**API Name** + +`apexdocs__Products__c` + +**Type** + +*ExternalLookup* + +--- +### Rating + +Rating + +**API Name** + +`apexdocs__Rating__c` + +**Type** + +*Number* + +--- +### ReleaseDate + +ReleaseDate + +**API Name** + +`apexdocs__ReleaseDate__c` + +**Type** + +*DateTime* + +--- +### Type + +**API Name** + +`apexdocs__Type__c` + +**Type** + +*Picklist* + +#### Possible values are +* Merchandise +* Bundle \ No newline at end of file diff --git a/examples/vitepress/docs/index.md b/examples/vitepress/docs/index.md index aa8ffdd8..6d8ce3bb 100644 --- a/examples/vitepress/docs/index.md +++ b/examples/vitepress/docs/index.md @@ -19,6 +19,8 @@ hero: ## Custom Objects +### [Contact](custom-objects/Contact) + ### [Event__c](custom-objects/Event__c) Represents an event that people can register for. diff --git a/src/core/changelog/__test__/generating-change-log.spec.ts b/src/core/changelog/__test__/generating-change-log.spec.ts index 9bdd328d..4b33b6b3 100644 --- a/src/core/changelog/__test__/generating-change-log.spec.ts +++ b/src/core/changelog/__test__/generating-change-log.spec.ts @@ -4,6 +4,7 @@ import { assertEither } from '../../test-helpers/assert-either'; import { isSkip } from '../../shared/utils'; import { unparsedFieldBundleFromRawString } from '../../test-helpers/test-data-builders'; import { CustomObjectXmlBuilder } from '../../test-helpers/test-data-builders/custom-object-xml-builder'; +import { CustomFieldXmlBuilder } from '../../test-helpers/test-data-builders/custom-field-xml-builder'; const config = { fileName: 'changelog', @@ -347,7 +348,9 @@ describe('when generating a changelog', () => { const result = await generateChangeLog(oldBundle, newBundle, config)(); assertEither(result, (data) => - expect((data as ChangeLogPageData).content).toContain('## New or Removed Fields in Existing Objects'), + expect((data as ChangeLogPageData).content).toContain( + '## New or Removed Fields to Custom Objects or Standard Objects', + ), ); }); @@ -393,4 +396,74 @@ describe('when generating a changelog', () => { ); }); }); + + describe('that includes extension fields', () => { + it('does not include the fields when they are in both versions', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + const newBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).not.toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).not.toContain('PhotoUrl__c')); + }); + + it('includes added fields when they are not in the old version', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = []; + const newBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('PhotoUrl__c')); + }); + + it('includes removed fields when they are not in the new version', async () => { + const fieldSource = new CustomFieldXmlBuilder().build(); + + const oldBundle: UnparsedSourceBundle[] = [ + { + type: 'customfield', + name: 'PhotoUrl__c', + content: fieldSource, + filePath: 'PhotoUrl__c.field-meta.xml', + parentName: 'MyTestObject', + }, + ]; + const newBundle: UnparsedSourceBundle[] = []; + + const result = await generateChangeLog(oldBundle, newBundle, config)(); + + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('MyTestObject')); + assertEither(result, (data) => expect((data as ChangeLogPageData).content).toContain('PhotoUrl__c')); + }); + }); }); diff --git a/src/core/changelog/__test__/processing-changelog.spec.ts b/src/core/changelog/__test__/processing-changelog.spec.ts index 9bb8a964..0168d724 100644 --- a/src/core/changelog/__test__/processing-changelog.spec.ts +++ b/src/core/changelog/__test__/processing-changelog.spec.ts @@ -13,12 +13,19 @@ function apexTypeFromRawString(raw: string): Type { } class CustomFieldMetadataBuilder { + name: string = 'MyField'; + + withName(name: string): CustomFieldMetadataBuilder { + this.name = name; + return this; + } + build(): CustomFieldMetadata { return { type: 'Text', type_name: 'customfield', label: 'MyField', - name: 'MyField', + name: this.name, description: null, parentName: 'MyObject', }; @@ -29,11 +36,6 @@ class CustomObjectMetadataBuilder { label: string = 'MyObject'; fields: CustomFieldMetadata[] = []; - withLabel(label: string): CustomObjectMetadataBuilder { - this.label = label; - return this; - } - withField(field: CustomFieldMetadata): CustomObjectMetadataBuilder { this.fields.push(field); return this; @@ -570,4 +572,65 @@ describe('when generating a changelog', () => { ]); }); }); + + describe('with custom field code', () => { + it('does not list fields that are the same in both versions', () => { + // A field that is the same in both versions is defined as one + // with both the same name and the same parent + + const oldField = new CustomFieldMetadataBuilder().build(); + const newField = new CustomFieldMetadataBuilder().build(); + + const oldManifest = { types: [oldField] }; + const newManifest = { types: [newField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([]); + }); + + it('lists new fields of a custom object', () => { + const oldField = new CustomFieldMetadataBuilder().build(); + const newField = new CustomFieldMetadataBuilder().withName('NewField').build(); + + const oldManifest = { types: [oldField] }; + const newManifest = { types: [oldField, newField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: newField.parentName, + modifications: [ + { + __typename: 'NewField', + name: newField.name, + }, + ], + }, + ]); + }); + + it('lists removed fields of a custom object', () => { + const oldField = new CustomFieldMetadataBuilder().withName('OldField').build(); + const unchangedField = new CustomFieldMetadataBuilder().build(); + + const oldManifest = { types: [unchangedField, oldField] }; + const newManifest = { types: [unchangedField] }; + + const changeLog = processChangelog(oldManifest, newManifest); + + expect(changeLog.customObjectModifications).toEqual([ + { + typeName: oldField.parentName, + modifications: [ + { + __typename: 'RemovedField', + name: oldField.name, + }, + ], + }, + ]); + }); + }); }); diff --git a/src/core/changelog/generate-change-log.ts b/src/core/changelog/generate-change-log.ts index 4071b6df..b5dc13ea 100644 --- a/src/core/changelog/generate-change-log.ts +++ b/src/core/changelog/generate-change-log.ts @@ -15,11 +15,12 @@ import { changelogTemplate } from './templates/changelog-template'; import { ReflectionErrors } from '../errors/errors'; import { apply } from '#utils/fp'; import { filterScope } from '../reflection/apex/filter-scope'; -import { skip } from '../shared/utils'; +import { isInSource, skip } from '../shared/utils'; import { reflectCustomFieldsAndObjects } from '../reflection/sobject/reflectCustomFieldsAndObjects'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { Type } from '@cparra/apex-reflection'; import { filterApexSourceFiles, filterCustomObjectsAndFields } from '#utils/source-bundle-utils'; +import { CustomFieldMetadata } from '../reflection/sobject/reflect-custom-field-source'; export type ChangeLogPageData = { content: string; @@ -71,16 +72,20 @@ function reflect(bundles: UnparsedSourceBundle[], config: Omit[]; - newVersion: ParsedFile[]; -}) { - function parsedFilesToManifest(parsedFiles: ParsedFile[]): VersionManifest { +function toManifests({ oldVersion, newVersion }: { oldVersion: ParsedFile[]; newVersion: ParsedFile[] }) { + function parsedFilesToManifest(parsedFiles: ParsedFile[]): VersionManifest { return { - types: parsedFiles.map((parsedFile) => parsedFile.type), + types: parsedFiles.reduce( + (previousValue: (Type | CustomObjectMetadata | CustomFieldMetadata)[], 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. + return [...previousValue, ...parsedFile.type.fields]; + } + return [...previousValue, parsedFile.type]; + }, + [] as (Type | CustomObjectMetadata | CustomFieldMetadata)[], + ), }; } diff --git a/src/core/changelog/process-changelog.ts b/src/core/changelog/process-changelog.ts index b9d8695d..c5ab2b30 100644 --- a/src/core/changelog/process-changelog.ts +++ b/src/core/changelog/process-changelog.ts @@ -2,9 +2,10 @@ import { ClassMirror, EnumMirror, InterfaceMirror, MethodMirror, Type } from '@c 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'; export type VersionManifest = { - types: (Type | CustomObjectMetadata)[]; + types: (Type | CustomObjectMetadata | CustomFieldMetadata)[]; }; type ModificationTypes = @@ -53,7 +54,10 @@ export function processChangelog(oldVersion: VersionManifest, newVersion: Versio newOrModifiedApexMembers: getNewOrModifiedApexMembers(oldVersion, newVersion), newCustomObjects: getNewCustomObjects(oldVersion, newVersion), removedCustomObjects: getRemovedCustomObjects(oldVersion, newVersion), - customObjectModifications: getCustomObjectModifications(oldVersion, newVersion), + customObjectModifications: [ + ...getCustomObjectModifications(oldVersion, newVersion), + ...getNewOrModifiedExtensionFields(oldVersion, newVersion), + ], }; } @@ -105,6 +109,57 @@ function getCustomObjectModifications(oldVersion: VersionManifest, newVersion: V ); } +function getNewOrModifiedExtensionFields( + oldVersion: VersionManifest, + newVersion: VersionManifest, +): NewOrModifiedMember[] { + const extensionFieldsInOldVersion = oldVersion.types.filter( + (type): type is CustomFieldMetadata => type.type_name === 'customfield', + ); + const extensionFieldsInNewVersion = newVersion.types.filter( + (type): type is CustomFieldMetadata => type.type_name === 'customfield', + ); + + // An extension field is equal if it has the same name and the same parent name + function areFieldEquals(oldField: CustomFieldMetadata, newField: CustomFieldMetadata): boolean { + return ( + oldField.name.toLowerCase() === newField.name.toLowerCase() && + oldField.parentName.toLowerCase() === newField.parentName.toLowerCase() + ); + } + + const fieldsOnlyInNewVersion = extensionFieldsInNewVersion.filter( + (newField) => !extensionFieldsInOldVersion.some((oldField) => areFieldEquals(oldField, newField)), + ); + const fieldsOnlyInOldVersion = extensionFieldsInOldVersion.filter( + (oldField) => !extensionFieldsInNewVersion.some((newField) => areFieldEquals(oldField, newField)), + ); + + const newMemberModifications = fieldsOnlyInNewVersion.reduce((previous, currentField) => { + const parentName = currentField.parentName; + const additionsToParent = previous.find((parent) => parent.typeName === parentName)?.modifications ?? []; + return [ + ...previous.filter((parent) => parent.typeName !== parentName), + { + typeName: parentName, + modifications: [...additionsToParent, { __typename: 'NewField', name: currentField.name }], + }, + ] as NewOrModifiedMember[]; + }, [] as NewOrModifiedMember[]); + + return fieldsOnlyInOldVersion.reduce((previous, currentField) => { + const parentName = currentField.parentName; + const removalsFromParent = previous.find((parent) => parent.typeName === parentName)?.modifications ?? []; + return [ + ...previous.filter((parent) => parent.typeName !== parentName), + { + typeName: parentName, + modifications: [...removalsFromParent, { __typename: 'RemovedField', name: currentField.name }], + }, + ] as NewOrModifiedMember[]; + }, newMemberModifications); +} + function getNewOrRemovedCustomFields(typesInBoth: TypeInBoth[]): NewOrModifiedMember[] { return typesInBoth.map(({ oldType, newType }) => { const oldCustomObject = oldType; diff --git a/src/core/changelog/renderable-changelog.ts b/src/core/changelog/renderable-changelog.ts index dead2477..7b898c8e 100644 --- a/src/core/changelog/renderable-changelog.ts +++ b/src/core/changelog/renderable-changelog.ts @@ -3,6 +3,7 @@ import { ClassMirror, EnumMirror, InterfaceMirror, Type } from '@cparra/apex-ref 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'; type NewTypeRenderable = { name: string; @@ -46,7 +47,7 @@ export type RenderableChangelog = { export function convertToRenderableChangelog( changelog: Changelog, - newManifest: (Type | CustomObjectMetadata)[], + newManifest: (Type | CustomObjectMetadata | CustomFieldMetadata)[], ): RenderableChangelog { const allNewTypes = [...changelog.newApexTypes, ...changelog.newCustomObjects].map( (newType) => newManifest.find((type) => type.name.toLowerCase() === newType.toLowerCase())!, @@ -122,7 +123,7 @@ export function convertToRenderableChangelog( newOrRemovedCustomFields: changelog.customObjectModifications.length > 0 ? { - heading: 'New or Removed Fields in Existing Objects', + 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), } diff --git a/src/core/markdown/adapters/type-to-renderable.ts b/src/core/markdown/adapters/type-to-renderable.ts index 85936078..06edd6c9 100644 --- a/src/core/markdown/adapters/type-to-renderable.ts +++ b/src/core/markdown/adapters/type-to-renderable.ts @@ -17,9 +17,9 @@ import { adaptDescribable, adaptDocumentable } from '../../renderables/documenta import { adaptConstructor, adaptMethod } from './methods-and-constructors'; import { adaptFieldOrProperty } from './fields-and-properties'; import { MarkdownGeneratorConfig } from '../generate-docs'; -import { SourceFileMetadata } from '../../shared/types'; +import { ExternalMetadata, SourceFileMetadata } from '../../shared/types'; import { CustomObjectMetadata } from '../../reflection/sobject/reflect-custom-object-sources'; -import { getTypeGroup } from '../../shared/utils'; +import { getTypeGroup, isInSource } from '../../shared/utils'; import { CustomFieldMetadata } from '../../reflection/sobject/reflect-custom-field-source'; type GetReturnRenderable = T extends InterfaceMirror @@ -31,10 +31,10 @@ type GetReturnRenderable = T extends Inte : RenderableCustomObject; export function typeToRenderable( - parsedFile: { source: SourceFileMetadata; type: T }, + parsedFile: { source: SourceFileMetadata | ExternalMetadata; type: T }, linkGenerator: GetRenderableContentByTypeName, config: MarkdownGeneratorConfig, -): GetReturnRenderable & { filePath: string; namespace?: string } { +): GetReturnRenderable & { filePath: string | undefined; namespace?: string } { function getRenderable(): RenderableInterface | RenderableClass | RenderableEnum | RenderableCustomObject { const { type } = parsedFile; switch (type.type_name) { @@ -51,7 +51,7 @@ export function typeToRenderable( return { ...(getRenderable() as GetReturnRenderable), - filePath: parsedFile.source.filePath, + filePath: isInSource(parsedFile.source) ? parsedFile.source.filePath : undefined, namespace: config.namespace, }; } @@ -281,11 +281,13 @@ function fieldMetadataToRenderable( description: field.description ? [field.description] : [], apiName: getApiName(field.name, config), fieldType: field.type, - pickListValues: field.pickListValues ? { - headingLevel: headingLevel + 1, - heading: 'Possible values are', - value: field.pickListValues, - } : undefined, + pickListValues: field.pickListValues + ? { + headingLevel: headingLevel + 1, + heading: 'Possible values are', + value: field.pickListValues, + } + : undefined, }; } diff --git a/src/core/reflection/apex/reflect-apex-source.ts b/src/core/reflection/apex/reflect-apex-source.ts index 67206fcb..cbbb149a 100644 --- a/src/core/reflection/apex/reflect-apex-source.ts +++ b/src/core/reflection/apex/reflect-apex-source.ts @@ -11,6 +11,7 @@ import { Semigroup } from 'fp-ts/Semigroup'; import { ParsedFile, UnparsedApexBundle } from '../../shared/types'; import { ReflectionError, ReflectionErrors } from '../../errors/errors'; import { parseApexMetadata } from './parse-apex-metadata'; +import { isInSource } from '../../shared/utils'; async function reflectAsync(rawSource: string): Promise { return new Promise((resolve, reject) => { @@ -69,7 +70,9 @@ function addMetadata( parsedFile.type, (type) => addFileMetadataToTypeAnnotation(type as Type, rawMetadataContent), E.map((type) => ({ ...parsedFile, type })), - E.mapLeft((error) => errorToReflectionErrors(error, parsedFile.source.filePath)), + E.mapLeft((error) => + errorToReflectionErrors(error, isInSource(parsedFile.source) ? parsedFile.source.filePath : ''), + ), ), ); } diff --git a/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts b/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts index 02e75eef..8b1e776b 100644 --- a/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts +++ b/src/core/reflection/sobject/__test__/reflect-custom-field-sources.spec.ts @@ -2,6 +2,7 @@ import { UnparsedCustomFieldBundle } from '../../../shared/types'; import { reflectCustomFieldSources } from '../reflect-custom-field-source'; import { assertEither } from '../../../test-helpers/assert-either'; import * as E from 'fp-ts/Either'; +import { isInSource } from '../../../shared/utils'; const customFieldContent = ` @@ -27,7 +28,13 @@ describe('when parsing custom field metadata', () => { const result = await reflectCustomFieldSources([unparsed])(); - assertEither(result, (data) => expect(data[0].source.filePath).toBe('src/field/PhotoUrl__c.field-meta.xml')); + assertEither(result, (data) => { + if (isInSource(data[0].source)) { + expect(data[0].source.filePath).toBe('src/field/PhotoUrl__c.field-meta.xml'); + } else { + fail('Expected the source to be in the source'); + } + }); }); test('the resulting type contains the correct name', async () => { @@ -100,7 +107,7 @@ describe('when parsing custom field metadata', () => { assertEither(result, (data) => expect(data[0].type.description).toBe('A Photo URL field')); }); - test('can parse picklist values', async() => { + test('can parse picklist values', async () => { const unparsed: UnparsedCustomFieldBundle = { type: 'customfield', name: 'Status__c', diff --git a/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts b/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts index b1c80f59..e92ed976 100644 --- a/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts +++ b/src/core/reflection/sobject/__test__/reflect-custom-object-sources.spec.ts @@ -6,6 +6,7 @@ import { CustomObjectXmlBuilder, InlineFieldBuilder, } from '../../../test-helpers/test-data-builders/custom-object-xml-builder'; +import { isInSource } from '../../../shared/utils'; describe('when parsing SObject metadata', () => { test('the resulting type contains the file path', async () => { @@ -18,7 +19,13 @@ describe('when parsing SObject metadata', () => { const result = await reflectCustomObjectSources([unparsed])(); - assertEither(result, (data) => expect(data[0].source.filePath).toBe('src/object/MyFirstObject__c.object-meta.xml')); + assertEither(result, (data) => { + if (isInSource(data[0].source)) { + expect(data[0].source.filePath).toBe('src/object/MyFirstObject__c.object-meta.xml'); + } else { + fail('Expected the source to be in the source'); + } + }); }); test('the resulting type contains the correct label', async () => { diff --git a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts b/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts index f9a3439f..083865a4 100644 --- a/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts +++ b/src/core/reflection/sobject/reflectCustomFieldsAndObjects.ts @@ -38,18 +38,66 @@ export function reflectCustomFieldsAndObjects( TE.map(filterNonPublic), TE.bindTo('objects'), TE.bind('fields', () => generateForFields(customFields)), - // Locate the fields for each object by using the parentName property TE.map(({ objects, fields }) => { - return objects.map((object) => { - const objectFields = fields.filter((field) => field.type.parentName === object.type.name); - return { - ...object, - type: { - ...object.type, - fields: [...object.type.fields, ...objectFields.map((field) => field.type)], - }, - }; - }); + return [...mapFieldsToObjects(objects, fields), ...mapExtensionFields(objects, fields)]; }), ); } + +function mapFieldsToObjects( + objects: ParsedFile[], + fields: 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); + return { + ...object, + type: { + ...object.type, + fields: [...object.type.fields, ...objectFields.map((field) => field.type)], + }, + }; + }); +} + +// "Extension" fields are fields that are in the source code without the corresponding object-meta.xml file. +// These are fields that either extend a standard Salesforce object, or an object in a different package. +function mapExtensionFields( + objects: ParsedFile[], + fields: ParsedFile[], +): ParsedFile[] { + const extensionFields = fields.filter( + (field) => !objects.some((object) => object.type.name === field.type.parentName), + ); + // There might be many objects for the same parent name, so we need to group the fields by parent name + const extensionFieldsByParent = extensionFields.reduce( + (acc, field) => { + if (!acc[field.type.parentName]) { + acc[field.type.parentName] = []; + } + acc[field.type.parentName].push(field.type); + return acc; + }, + {} as Record, + ); + + return Object.keys(extensionFieldsByParent).map((key) => { + const fields = extensionFieldsByParent[key]; + return { + source: { + name: key, + type: 'customobject', + }, + type: { + type_name: 'customobject', + deploymentStatus: 'Deployed', + visibility: 'Public', + label: key, + name: key, + description: null, + fields: fields, + }, + }; + }); +} diff --git a/src/core/renderables/types.d.ts b/src/core/renderables/types.d.ts index 3b646264..46814207 100644 --- a/src/core/renderables/types.d.ts +++ b/src/core/renderables/types.d.ts @@ -189,11 +189,11 @@ export type RenderableCustomField = { heading: string; apiName: string; description: RenderableContent[]; - pickListValues?: RenderableSection + pickListValues?: RenderableSection; type: 'field'; fieldType?: string | null; }; export type Renderable = (RenderableClass | RenderableInterface | RenderableEnum | RenderableCustomObject) & { - filePath: string; + filePath: string | undefined; }; diff --git a/src/core/shared/types.d.ts b/src/core/shared/types.d.ts index e9ce8fd3..560a51cf 100644 --- a/src/core/shared/types.d.ts +++ b/src/core/shared/types.d.ts @@ -85,21 +85,34 @@ export type UnparsedApexBundle = { metadataContent: string | null; }; +type MetadataTypes = 'interface' | 'class' | 'enum' | 'customobject' | 'customfield'; + export type SourceFileMetadata = { filePath: string; name: string; - type: 'interface' | 'class' | 'enum' | 'customobject' | 'customfield'; + type: MetadataTypes; +}; + +// External metadata is metadata that does not live directly in the source code, and thus we don't +// have a file path for it. +// This is metadata derived from other information. +// For example, for an "extension" +// field that extends a Salesforce object or object in a different package, we want to capture the parent +// object, even if the file for that object was not parsed. +export type ExternalMetadata = { + name: string; + type: MetadataTypes; }; export type ParsedFile< T extends Type | CustomObjectMetadata | CustomFieldMetadata = Type | CustomObjectMetadata | CustomFieldMetadata, > = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; type: T; }; export type DocPageReference = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; // The name under which the type should be displayed in the documentation. // By default, this will match the source.name, but it can be configured by the user. displayName: string; @@ -121,7 +134,7 @@ export type ReferenceGuidePageData = { }; export type DocPageData = { - source: SourceFileMetadata; + source: SourceFileMetadata | ExternalMetadata; group: string | null; outputDocPath: string; frontmatter: Frontmatter; diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 3d11cebc..096719fe 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -1,4 +1,4 @@ -import { Skip } from './types'; +import { ExternalMetadata, Skip, SourceFileMetadata } from './types'; import { Type } from '@cparra/apex-reflection'; import { CustomObjectMetadata } from '../reflection/sobject/reflect-custom-object-sources'; import { MarkdownGeneratorConfig } from '../markdown/generate-docs'; @@ -25,6 +25,10 @@ export function isApexType(type: Type | CustomObjectMetadata | CustomFieldMetada return !isObjectType(type); } +export function isInSource(source: SourceFileMetadata | ExternalMetadata): source is SourceFileMetadata { + return 'filePath' in source; +} + export function getTypeGroup(type: Type | CustomObjectMetadata, config: MarkdownGeneratorConfig): string { function getGroup(type: Type, config: MarkdownGeneratorConfig): string { const groupAnnotation = type.docComment?.annotations.find( diff --git a/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts b/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts new file mode 100644 index 00000000..94327216 --- /dev/null +++ b/src/core/test-helpers/test-data-builders/custom-field-xml-builder.ts @@ -0,0 +1,15 @@ +export class CustomFieldXmlBuilder { + build(): string { + return ` + + + PhotoUrl__c + false + + false + false + Url + A URL that points to a photo + `; + } +}