Skip to content

Commit b2b90a7

Browse files
authored
fix: adds support for nested InFolder metadata types (#455)
* fix: adds support for nested InFolder metadata types * test: add more unit tests * test: fix path tests
1 parent c171ad6 commit b2b90a7

File tree

15 files changed

+377
-46
lines changed

15 files changed

+377
-46
lines changed

src/convert/streams.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { promisify } from 'util';
1212
import { SourceComponent, MetadataResolver } from '../resolve';
1313
import { SfdxFileFormat, WriteInfo, WriterFormat } from './types';
1414
import { ensureFileExists } from '../utils/fileSystemHandler';
15-
import { META_XML_SUFFIX, SourcePath, XML_DECL } from '../common';
15+
import { SourcePath, XML_DECL } from '../common';
1616
import { ConvertContext } from './convertContext';
1717
import { MetadataTransformerFactory } from './transformers';
1818
import { JsonMap } from '@salesforce/ts-types';

src/registry/registry.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,20 +1429,23 @@
14291429
"name": "Territory2Model",
14301430
"suffix": "territory2Model",
14311431
"directoryName": "territory2Models",
1432+
"inFolder": false,
14321433
"folderType": "territory2model"
14331434
},
14341435
"territory2rule": {
14351436
"id": "territory2rule",
14361437
"name": "Territory2Rule",
14371438
"suffix": "territory2Rule",
14381439
"directoryName": "rules",
1440+
"inFolder": false,
14391441
"folderType": "territory2model"
14401442
},
14411443
"territory2": {
14421444
"id": "territory2",
14431445
"name": "Territory2",
14441446
"suffix": "territory2",
14451447
"directoryName": "territories",
1448+
"inFolder": false,
14461449
"folderType": "territory2model"
14471450
},
14481451
"campaigninfluencemodel": {

src/registry/registryAccess.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { MetadataRegistry, MetadataType } from './types';
1414
export class RegistryAccess {
1515
private registry: MetadataRegistry;
1616

17+
private strictFolderTypes: MetadataType[];
18+
private folderContentTypes: MetadataType[];
19+
1720
constructor(registry: MetadataRegistry = defaultRegistry) {
1821
this.registry = registry;
1922
}
@@ -71,9 +74,31 @@ export class RegistryAccess {
7174
* @returns An array of metadata type objects that require strict parent folder names
7275
*/
7376
public getStrictFolderTypes(): MetadataType[] {
74-
return Object.values(this.registry.strictDirectoryNames).map(
75-
(typeId) => this.registry.types[typeId]
76-
);
77+
if (!this.strictFolderTypes) {
78+
this.strictFolderTypes = Object.values(this.registry.strictDirectoryNames).map(
79+
(typeId) => this.registry.types[typeId]
80+
);
81+
}
82+
return this.strictFolderTypes;
83+
}
84+
85+
/**
86+
* Query for the types that have the folderContentType property defined.
87+
* E.g., reportFolder, dashboardFolder, documentFolder, emailFolder
88+
* @see {@link MetadataType.folderContentType}
89+
*
90+
* @returns An array of metadata type objects that have folder content
91+
*/
92+
public getFolderContentTypes(): MetadataType[] {
93+
if (!this.folderContentTypes) {
94+
this.folderContentTypes = [];
95+
for (const type of Object.values(this.registry.types)) {
96+
if (type.folderContentType) {
97+
this.folderContentTypes.push(type);
98+
}
99+
}
100+
}
101+
return this.folderContentTypes;
77102
}
78103

79104
get apiVersion(): string {

src/resolve/adapters/baseSourceAdapter.ts

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { SourceAdapter, MetadataXml } from '../types';
8-
import { parseMetadataXml } from '../../utils';
8+
import { parseMetadataXml, parseNestedFullName } from '../../utils';
99
import { UnexpectedForceIgnore } from '../../errors';
10-
import { parentName } from '../../utils/path';
1110
import { ForceIgnore } from '../forceIgnore';
1211
import { dirname, basename, sep } from 'path';
1312
import { NodeFSTreeContainer, TreeContainer } from '../treeContainers';
@@ -123,14 +122,22 @@ export abstract class BaseSourceAdapter implements SourceAdapter {
123122
* @param path File path of a metadata component
124123
*/
125124
private parseAsContentMetadataXml(path: SourcePath): MetadataXml {
125+
// InFolder metadata can be nested more than 1 level beneath its
126+
// associated directoryName.
127+
if (this.type.inFolder) {
128+
const fullName = parseNestedFullName(path, this.type.directoryName);
129+
if (fullName) {
130+
return { fullName, suffix: this.type.suffix, path };
131+
}
132+
}
133+
126134
const parentPath = dirname(path);
127135
const parts = parentPath.split(sep);
128136
const typeFolderIndex = parts.lastIndexOf(this.type.directoryName);
129-
// nestedTypes (ex: territory2) have a folderType equal to their type but are themselves in a folder per metadata item, with child folders for rules/territories
137+
// nestedTypes (ex: territory2) have a folderType equal to their type but are themselves
138+
// in a folder per metadata item, with child folders for rules/territories
130139
const allowedIndex =
131-
this.type.inFolder || this.type.folderType === this.type.id
132-
? parts.length - 2
133-
: parts.length - 1;
140+
this.type.folderType === this.type.id ? parts.length - 2 : parts.length - 1;
134141

135142
if (typeFolderIndex !== allowedIndex) {
136143
return undefined;
@@ -150,14 +157,23 @@ export abstract class BaseSourceAdapter implements SourceAdapter {
150157
}
151158
}
152159

160+
// Given a MetadataXml, build a fullName from the path and type.
153161
private calculateName(rootMetadata: MetadataXml): string {
162+
const { directoryName, inFolder, folderType, folderContentType } = this.type;
163+
164+
// inFolder types (report, dashboard, emailTemplate, document) and their folder
165+
// container types (reportFolder, dashboardFolder, emailFolder, documentFolder)
166+
if (inFolder || folderContentType) {
167+
return parseNestedFullName(rootMetadata.path, directoryName);
168+
}
169+
154170
// not using folders? then name is fullname
155-
if (!this.type.folderType) {
171+
if (!folderType) {
156172
return rootMetadata.fullName;
157173
}
158-
const grandparentType = this.registry.getTypeByName(this.type.folderType);
174+
const grandparentType = this.registry.getTypeByName(folderType);
159175

160-
// type is in a nested inside another type (ex: Territory2Model). So the names are modelName.ruleName or modelName.territoryName
176+
// type is nested inside another type (ex: Territory2Model). So the names are modelName.ruleName or modelName.territoryName
161177
if (grandparentType.folderType && grandparentType.folderType !== this.type.id) {
162178
const splits = rootMetadata.path.split(sep);
163179
return `${splits[splits.indexOf(grandparentType.directoryName) + 1]}.${
@@ -168,8 +184,6 @@ export abstract class BaseSourceAdapter implements SourceAdapter {
168184
if (grandparentType.folderType === this.type.id) {
169185
return rootMetadata.fullName;
170186
}
171-
// other folderType scenarios (report, dashboard, emailTemplate, etc) where the parent is of a different type
172-
return `${parentName(rootMetadata.path)}/${rootMetadata.fullName}`;
173187
}
174188

175189
/**

src/resolve/manifestResolver.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { RegistryAccess } from '../registry';
8+
import { MetadataType, RegistryAccess } from '../registry';
99
import { NodeFSTreeContainer, TreeContainer } from './treeContainers';
1010
import { MetadataComponent } from './types';
1111
import { parse as parseXml } from 'fast-xml-parser';
@@ -64,16 +64,46 @@ export class ManifestResolver {
6464
const typeName = typeMembers.name;
6565
const type = this.registry.getTypeByName(typeName);
6666
const parentType = type.folderType ? this.registry.getTypeByName(type.folderType) : undefined;
67-
for (const fullName of normalizeToArray(typeMembers.members)) {
67+
const members = normalizeToArray(typeMembers.members);
68+
69+
for (const fullName of members) {
6870
let mdType = type;
69-
// if there is no / delimiter and it's a type in folders that aren't nestedType, infer folder component
70-
if (type.folderType && !fullName.includes('/') && parentType.folderType !== parentType.id) {
71-
mdType = this.registry.getTypeByName(type.folderType);
71+
if (this.isNestedInFolder(fullName, type, parentType, members)) {
72+
mdType = parentType;
7273
}
7374
components.push({ fullName, type: mdType });
7475
}
7576
}
7677

7778
return { components, apiVersion };
7879
}
80+
81+
// Use the folderType instead of the type from the manifest when:
82+
// 1. InFolder types: (report, dashboard, emailTemplate, document)
83+
// 1a. type.inFolder === true (from registry.json) AND
84+
// 1b. The fullName doesn't contain a forward slash character AND
85+
// 1c. The fullName with a slash appended is contained in another member entry
86+
// OR
87+
// 2. Non-InFolder, folder types: (territory2, territory2Model, territory2Type, territory2Rule)
88+
// 2a. type.inFolder !== true (from registry.json) AND
89+
// 2b. type.folderType has a value (from registry.json) AND
90+
// 2c. This type's parent type has a folderType that doesn't match its ID.
91+
private isNestedInFolder(
92+
fullName: string,
93+
type: MetadataType,
94+
parentType: MetadataType,
95+
members: string[]
96+
): boolean {
97+
// Quick short-circuit for non-folderTypes
98+
if (!type.folderType) {
99+
return false;
100+
}
101+
102+
const isInFolderType = type.inFolder;
103+
const isNestedInFolder =
104+
!fullName.includes('/') || members.some((m) => m.includes(`${fullName}/`));
105+
const isNonMatchingFolder = parentType && parentType.folderType !== parentType.id;
106+
107+
return (isInFolderType && isNestedInFolder) || (!isInFolderType && isNonMatchingFolder);
108+
}
79109
}

src/resolve/metadataResolver.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export class MetadataResolver {
2525
private sourceAdapterFactory: SourceAdapterFactory;
2626
private tree: TreeContainer;
2727
private registry: RegistryAccess;
28+
private folderContentTypeDirNames: string[];
2829

2930
/**
3031
* @param registry Custom registry data
@@ -234,19 +235,41 @@ export class MetadataResolver {
234235
return !!this.registry.getTypeBySuffix(extName(fsPath));
235236
}
236237

238+
// Get the array of directoryNames for types that have folderContentType
239+
private getFolderContentTypeDirNames(): string[] {
240+
if (!this.folderContentTypeDirNames) {
241+
this.folderContentTypeDirNames = this.registry
242+
.getFolderContentTypes()
243+
.map((t) => t.directoryName);
244+
}
245+
return this.folderContentTypeDirNames;
246+
}
247+
237248
/**
238249
* Identify metadata xml for a folder component:
239250
* .../email/TestFolder-meta.xml
251+
* .../reports/foo/bar-meta.xml
240252
*
241253
* Do not match this pattern:
242254
* .../tabs/TestFolder.tab-meta.xml
243255
*/
244256
private parseAsFolderMetadataXml(fsPath: string): string {
257+
let folderName;
245258
const match = basename(fsPath).match(/(.+)-meta\.xml/);
246259
if (match && !match[1].includes('.')) {
247260
const parts = fsPath.split(sep);
248-
return parts.length > 1 ? parts[parts.length - 2] : undefined;
261+
if (parts.length > 1) {
262+
const folderContentTypesDirs = this.getFolderContentTypeDirNames();
263+
// check if the path contains a folder content name as a directory
264+
// e.g., `/reports/` and if it does return that folder name.
265+
folderContentTypesDirs.some((dirName) => {
266+
if (fsPath.includes(`/${dirName}/`)) {
267+
folderName = dirName;
268+
}
269+
});
270+
}
249271
}
272+
return folderName;
250273
}
251274

252275
private isMetadata(fsPath: string): boolean {

src/resolve/sourceComponent.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7-
import { join, basename, sep } from 'path';
7+
import { join, basename } from 'path';
88
import { parse } from 'fast-xml-parser';
99
import { ForceIgnore } from './forceIgnore';
1010
import { NodeFSTreeContainer, TreeContainer, VirtualTreeContainer } from './treeContainers';
@@ -136,22 +136,21 @@ export class SourceComponent implements MetadataComponent {
136136
}
137137

138138
private calculateRelativePath(fsPath: string): string {
139-
const { directoryName, suffix, inFolder, folderType } = this.type;
139+
const { directoryName, suffix, inFolder, folderType, folderContentType } = this.type;
140+
140141
// if there isn't a suffix, assume this is a mixed content component that must
141142
// reside in the directoryName of its type. trimUntil maintains the folder structure
142-
// the file resides in for the new destination.
143-
if (!suffix) {
143+
// the file resides in for the new destination. This also applies to inFolder types:
144+
// (report, dashboard, emailTemplate, document) and their folder container types:
145+
// (reportFolder, dashboardFolder, emailFolder, documentFolder)
146+
if (!suffix || inFolder || folderContentType) {
144147
return trimUntil(fsPath, directoryName);
145148
}
146-
// legacy version of folderType
147-
if (inFolder) {
148-
return join(directoryName, this.fullName.split('/')[0], basename(fsPath));
149-
}
149+
150150
if (folderType) {
151151
// types like Territory2Model have child types inside them. We have to preserve those folder structures
152152
if (this.parentType?.folderType && this.parentType?.folderType !== this.type.id) {
153-
const fsPathSplits = fsPath.split(sep);
154-
return fsPathSplits.slice(fsPathSplits.indexOf(this.parentType.directoryName)).join(sep);
153+
return trimUntil(fsPath, this.parentType.directoryName);
155154
}
156155
return join(directoryName, this.fullName.split('/')[0], basename(fsPath));
157156
}

src/utils/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,12 @@
66
*/
77
export { createFiles } from './fileSystemHandler';
88
export { generateMetaXML, generateMetaXMLPath, trimMetaXmlSuffix } from './metadata';
9-
export { extName, baseName, parseMetadataXml, parentName, trimUntil } from './path';
9+
export {
10+
extName,
11+
baseName,
12+
parseMetadataXml,
13+
parentName,
14+
trimUntil,
15+
parseNestedFullName,
16+
} from './path';
1017
export { normalizeToArray, deepFreeze } from './collections';

src/utils/path.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { basename, dirname, extname, sep } from 'path';
99
import { SourcePath } from '../common';
1010
import { MetadataXml } from '../resolve';
11+
import { Optional } from '@salesforce/ts-types';
1112

1213
/**
1314
* Get the file or directory name at the end of a path. Different from `path.basename`
@@ -59,9 +60,40 @@ export function trimUntil(fsPath: SourcePath, part: string): string {
5960
* @param fsPath - File path to parse
6061
* @returns MetadataXml info or undefined
6162
*/
62-
export function parseMetadataXml(fsPath: string): MetadataXml | undefined {
63+
export function parseMetadataXml(fsPath: string): Optional<MetadataXml> {
6364
const match = basename(fsPath).match(/(.+)\.(.+)-meta\.xml/);
6465
if (match) {
6566
return { fullName: match[1], suffix: match[2], path: fsPath };
6667
}
6768
}
69+
70+
/**
71+
* Returns the fullName for a nested metadata source file. This is for metadata
72+
* types that can be nested more than 1 level such as report and reportFolder,
73+
* dashboard and dashboardFolder, etc. It uses the directory name for the metadata type
74+
* as the starting point (non-inclusively) to parse the fullName.
75+
*
76+
* Examples:
77+
* (source format path)
78+
* fsPath: force-app/main/default/reports/foo/bar/My_Report.report-meta.xml
79+
* returns: foo/bar/My_Report
80+
*
81+
* (mdapi format path)
82+
* fsPath: unpackaged/reports/foo/bar-meta.xml
83+
* returns: foo/bar
84+
*
85+
* @param fsPath - File path to parse
86+
* @param directoryName - name of directory to use as a parsing index
87+
* @returns the FullName
88+
*/
89+
export function parseNestedFullName(fsPath: string, directoryName: string): Optional<string> {
90+
const pathSplits = fsPath.split(sep);
91+
// Exit if the directoryName is not included in the file path.
92+
if (!pathSplits.includes(directoryName)) {
93+
return;
94+
}
95+
const pathPrefix = pathSplits.slice(pathSplits.lastIndexOf(directoryName) + 1);
96+
const fileName = pathSplits.pop().replace('-meta.xml', '').split('.')[0];
97+
pathPrefix[pathPrefix.length - 1] = fileName;
98+
return pathPrefix.join('/');
99+
}

test/mock/registry/mockRegistry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const mockRegistryData = {
4242
xmlinfolder: {
4343
id: 'xmlinfolder',
4444
directoryName: 'xmlinfolders',
45+
inFolder: true,
4546
name: 'XmlInFolder',
4647
suffix: 'xif',
4748
folderType: 'xmlinfolderfolder',

test/registry/registryAccess.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import { assert, expect } from 'chai';
88
import { RegistryError } from '../../src/errors';
99
import { nls } from '../../src/i18n';
10-
import { MetadataRegistry, MetadataType } from '../../src/registry';
10+
import { MetadataRegistry, MetadataType, RegistryAccess } from '../../src/registry';
1111
import { mockRegistry, mockRegistryData } from '../mock/registry';
1212

1313
describe('RegistryAccess', () => {
@@ -80,4 +80,12 @@ describe('RegistryAccess', () => {
8080
expect(mockRegistry.getStrictFolderTypes()).to.deep.equal(types);
8181
});
8282
});
83+
84+
describe('getFolderContentTypes', () => {
85+
it('should return all the types with a folderContentType property defined', () => {
86+
const type = mockRegistryData.types.xmlinfolderfolder;
87+
const type2 = mockRegistryData.types.mciffolder;
88+
expect(mockRegistry.getFolderContentTypes()).to.deep.equal([type, type2]);
89+
});
90+
});
8391
});

0 commit comments

Comments
 (0)