Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: custom label beta 2 #1392

Merged
merged 32 commits into from
Aug 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d873517
feat: customLabels => customLabel files
mshanemc Aug 8, 2024
5065e62
test: move existing snapshot project
mshanemc Aug 8, 2024
951f533
fix: things snapshots uncovered
mshanemc Aug 8, 2024
38ce600
test: snapshot for mdapi => source (simple)
mshanemc Aug 8, 2024
8b80738
test: expected md snapshot
mshanemc Aug 8, 2024
e8676fb
feat: source to madapi finalizer
mshanemc Aug 8, 2024
99ceb9e
feat: sorted labels when recomposed
mshanemc Aug 8, 2024
d78be22
fix: remove original customLabelsBeta
mshanemc Aug 8, 2024
92461b8
test: remove wrong snapshot path
mshanemc Aug 8, 2024
3245b04
feat: show all decomposed labels in FileResponses
mshanemc Aug 9, 2024
26cf2cd
fix: handling single label
mshanemc Aug 9, 2024
378c61f
fix: empty string to remove props from normal registry
mshanemc Aug 9, 2024
49ee212
fix: provide mergeSet for toSourceFormat
mshanemc Aug 9, 2024
6046634
fix: error for using -m CustomLabels with the preset
mshanemc Aug 9, 2024
5acd189
chore: rename file to match class name
mshanemc Aug 9, 2024
12ec0cd
test: injectable presets into reg loader
mshanemc Aug 9, 2024
aec5d4e
test: ut for label transformer
mshanemc Aug 9, 2024
0cbd4f8
refactor: move xml parsing to shared const
mshanemc Aug 9, 2024
b61b0a1
test: ut for label (source) => labels (mdapi)
mshanemc Aug 9, 2024
9327a45
refactor: use a new name for the updated beta
mshanemc Aug 12, 2024
837c517
refactor: restore the original beta
mshanemc Aug 12, 2024
3bf5f88
Merge remote-tracking branch 'origin/main' into sm/custom-label-beta-2
mshanemc Aug 13, 2024
63ceb3c
refactor: separate logging fn
mshanemc Aug 13, 2024
715e880
chore: bump core
mshanemc Aug 13, 2024
f4d786a
chore: allow v1 and v2 of CustomLabelsBeta
mshanemc Aug 13, 2024
e3554fc
chore(release): 12.1.13-qa.0 [skip ci]
svc-cli-bot Aug 13, 2024
60e6c11
Merge branch 'sm/custom-label-beta-2' of https://github.com/forcedotc…
mshanemc Aug 13, 2024
aeca460
test: don't test with both CL presets together
mshanemc Aug 13, 2024
641e458
fix: only emit variant telemetry when there are presets/variants
mshanemc Aug 13, 2024
7cc1459
chore(release): 12.1.13-qa.1 [skip ci]
svc-cli-bot Aug 13, 2024
db9b23c
chore: manually bump pjson version
mshanemc Aug 13, 2024
15162a9
chore(release): 12.3.0-qa.1 [skip ci]
svc-cli-bot Aug 13, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions HANDBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ Be careful when instantiating classes (ex: ComponentSet) that will default a Reg

**Updating presets** If you do need to update a preset to make a breaking change, it's better to copy it to a new preset and give it a unique name (ex: `decomposeFooV2`). This preserves the existing behavior for existing projects with the old preset.

Presets **can** remove strings from the default metadataRegistry by setting values to empty string ex:

```json
{
"childTypes": {
"somethingThatIsUsuallyAChild": ""
}
}
```

### Querying registry data

While it’s perfectly fine to reference the registry export directly, the `RegistryAccess` class was created to make accessing the object a bit more streamlined. Querying types and searching the registry is oftentimes easier and cleaner this way and contains built-in checking for whether or not a metadata type exists. Here’s a comparison of using each:
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@salesforce/source-deploy-retrieve",
"version": "12.2.1",
"version": "12.3.0-qa.1",
"description": "JavaScript library to run Salesforce metadata deploys and retrieves",
"main": "lib/src/index.js",
"author": "Salesforce",
Expand All @@ -25,8 +25,8 @@
"node": ">=18.0.0"
},
"dependencies": {
"@salesforce/core": "^8.3.0",
"@salesforce/kit": "^3.1.6",
"@salesforce/core": "^8.4.0",
"@salesforce/kit": "^3.2.1",
"@salesforce/ts-types": "^2.0.12",
"fast-levenshtein": "^3.0.0",
"fast-xml-parser": "^4.4.1",
Expand Down
19 changes: 18 additions & 1 deletion src/Presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ Simple fields (ex: `fullName`) can remain in the top-level `Account.workflow-met

## `decomposeCustomLabelsBeta`

> This will definitely not become GA. Based on user feedback, we replaced it with `decomposeCustomLabelsBeta2`

CustomLabels are decomposed to a folder named `CustomLabels` the labels are then placed into individual files

metadata format
`/labels/CustomLabels.customlabes-meta.xml`
`/labels/CustomLabels.customlabels-meta.xml`

source format

Expand All @@ -77,3 +79,18 @@ source format
/labels/CustomLabels/b.label-meta.xml
/labels/CustomLabels/c.label-meta.xml
```

## `decomposeCustomLabelsBeta2`

CustomLabels are decomposed to a folder named `labels`; the labels are then placed into individual files. There is no top-level file.

metadata format
`/labels/CustomLabels.customlabels-meta.xml`

source format

```txt
/labels/a.label-meta.xml
/labels/b.label-meta.xml
/labels/c.label-meta.xml
```
8 changes: 2 additions & 6 deletions src/collections/componentSetBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,8 @@ export const entryToTypeAndName =
// split on the first colon, and then join the rest back together to support names that include colons
const [typeName, ...name] = rawEntry.split(':');
const type = reg.getTypeByName(typeName.trim());
const parent = reg.getParentType(type.name);
// If a user is requesting a child type that is unaddressable (more common with custom registries to create proper behavior)
// throw an error letting them know to use the entire parent instead
// or if they're requesting a COFT, unadressable without parent, don't throw because the parent could be requested - we don't know at this point
if (type.isAddressable === false && parent !== undefined && !type.unaddressableWithoutParent) {
throw new Error(`Cannot use this type, instead use ${parent.name}`);
if (type.name === 'CustomLabels' && type.strategies?.transformer === 'decomposedLabels') {
throw new Error('Use CustomLabel instead of CustomLabels for decomposed labels');
}
return { type, metadataName: name.length ? name.join(':').trim() : '*' };
};
Expand Down
3 changes: 2 additions & 1 deletion src/convert/convertContext/convertContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ import { RecompositionFinalizer } from './recompositionFinalizer';
import { NonDecompositionFinalizer } from './nonDecompositionFinalizer';
import { DecompositionFinalizer } from './decompositionFinalizer';
import { ConvertTransactionFinalizer } from './transactionFinalizer';

import { DecomposedLabelsFinalizer } from './decomposedLabelsFinalizer';
/**
* A state manager over the course of a single metadata conversion call.
*/
export class ConvertContext {
public readonly decomposition = new DecompositionFinalizer();
public readonly recomposition = new RecompositionFinalizer();
public readonly nonDecomposition = new NonDecompositionFinalizer();
public readonly decomposedLabels = new DecomposedLabelsFinalizer();

// eslint-disable-next-line @typescript-eslint/require-await
public async *executeFinalizers(defaultDirectory?: string): AsyncIterable<WriterFormat[]> {
Expand Down
76 changes: 76 additions & 0 deletions src/convert/convertContext/decomposedLabelsFinalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { join } from 'node:path';
import { ensure, JsonMap } from '@salesforce/ts-types';
import type { CustomLabel } from '@jsforce/jsforce-node/lib/api/metadata';
import { customLabelHasFullName } from '../../utils/metadata';
import { MetadataType } from '../../registry';
import { XML_NS_KEY, XML_NS_URL } from '../../common/constants';
import { JsToXml } from '../streams';
import { WriterFormat } from '../types';
import { ConvertTransactionFinalizer } from './transactionFinalizer';

type CustomLabelState = {
/*
* Incoming child xml (CustomLabel) keyed by label fullname
*/
customLabelByFullName: Map<string, CustomLabel>;
};

/**
* Merges child components that share the same parent in the conversion pipeline
* into a single file.
*
* Inserts unclaimed child components into the parent that belongs to the default package
*/
export class DecomposedLabelsFinalizer extends ConvertTransactionFinalizer<CustomLabelState> {
public transactionState: CustomLabelState = {
customLabelByFullName: new Map(),
};

/** to support custom presets (the only way this code should get hit at all pass in the type from a transformer that has registry access */
public customLabelsType?: MetadataType;

// have to maintain the existing interface
// eslint-disable-next-line @typescript-eslint/require-await, @typescript-eslint/no-unused-vars
public async finalize(defaultDirectory?: string): Promise<WriterFormat[]> {
if (this.transactionState.customLabelByFullName.size === 0) {
return [];
}
return [
{
component: {
type: ensure(this.customLabelsType, 'DecomposedCustomLabelsFinalizer should have set customLabelsType'),
fullName: 'CustomLabels',
},
writeInfos: [
{
output: join(
ensure(this.customLabelsType?.directoryName, 'directoryName missing from customLabels type'),
'CustomLabels.labels'
),
source: new JsToXml(generateXml(this.transactionState.customLabelByFullName)),
},
],
},
];
}
}

/** Return a json object that's built up from the mergeMap children */
const generateXml = (children: Map<string, CustomLabel>): JsonMap => ({
['CustomLabels']: {
[XML_NS_KEY]: XML_NS_URL,
// for CustomLabels, that's `labels`
labels: Array.from(children.values()).filter(customLabelHasFullName).sort(sortLabelsByFullName),
},
});

type CustomLabelWithFullName = CustomLabel & { fullName: string };

const sortLabelsByFullName = (a: CustomLabelWithFullName, b: CustomLabelWithFullName): number =>
a.fullName.localeCompare(b.fullName);
3 changes: 1 addition & 2 deletions src/convert/convertContext/recompositionFinalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { extractUniqueElementValue, getXmlElement, unwrapAndOmitNS } from '../..
import { MetadataComponent } from '../../resolve/types';
import { XML_NS_KEY, XML_NS_URL } from '../../common/constants';
import { ComponentSet } from '../../collections/componentSet';
import { RecompositionStrategy } from '../../registry/types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { JsToXml } from '../streams';
import { WriterFormat } from '../types';
Expand Down Expand Up @@ -127,7 +126,7 @@ const recompose =
const getStartingXml =
(cache: XmlCache) =>
async (parent: SourceComponent): Promise<JsonMap> =>
parent.type.strategies?.recomposition === RecompositionStrategy.StartEmpty
parent.type.strategies?.recomposition === 'startEmpty'
? {}
: unwrapAndOmitNS(parent.type.name)(await getXmlFromCache(cache)(parent)) ?? {};

Expand Down
14 changes: 11 additions & 3 deletions src/convert/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,13 @@ export class ComponentConverter extends Transform {
case 'source':
if (mergeWith) {
for (const mergeComponent of mergeWith) {
converts.push(transformer.toSourceFormat(chunk, mergeComponent));
converts.push(
transformer.toSourceFormat({ component: chunk, mergeWith: mergeComponent, mergeSet: this.mergeSet })
);
}
}
if (converts.length === 0) {
converts.push(transformer.toSourceFormat(chunk));
converts.push(transformer.toSourceFormat({ component: chunk, mergeSet: this.mergeSet }));
}
break;
case 'metadata':
Expand Down Expand Up @@ -158,7 +160,13 @@ export class StandardWriter extends ComponentWriter {
}

// if there are children, resolve each file. o/w just pick one of the files to resolve
if (toResolve.size === 0 || chunk.component.type.children) {
// "resolve" means "make these show up in the FileResponses"
if (
toResolve.size === 0 ||
chunk.component.type.children !== undefined ||
// make each decomposed label show up in the fileResponses
chunk.component.type.strategies?.transformer === 'decomposedLabels'
) {
// This is a workaround for a server side ListViews bug where
// duplicate components are sent. W-9614275
if (toResolve.has(info.output)) {
Expand Down
5 changes: 4 additions & 1 deletion src/convert/transformers/baseMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ export abstract class BaseMetadataTransformer implements MetadataTransformer {
}

public abstract toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]>;
public abstract toSourceFormat(component: SourceComponent, mergeWith?: SourceComponent): Promise<WriteInfo[]>;
public abstract toSourceFormat(input: {
component: SourceComponent;
mergeWith?: SourceComponent;
}): Promise<WriteInfo[]>;
}
53 changes: 53 additions & 0 deletions src/convert/transformers/decomposeLabelsTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2023, salesforce.com, inc.
* All rights reserved.
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import type { CustomLabel } from '@jsforce/jsforce-node/lib/api/metadata';
import { ensureArray } from '@salesforce/kit';
import { customLabelHasFullName } from '../../utils/metadata';
import { calculateRelativePath } from '../../utils/path';
import { SourceComponent } from '../../resolve/sourceComponent';
import { ToSourceFormatInput, WriteInfo } from '../types';
import { JsToXml } from '../streams';
import { unwrapAndOmitNS } from '../../utils/decomposed';
import { DefaultMetadataTransformer } from './defaultMetadataTransformer';

/* Use for the metadata type CustomLabels */
export class LabelsMetadataTransformer extends DefaultMetadataTransformer {
/** CustomLabels file => Array of CustomLabel WriteInfo (one for each label) */
public async toSourceFormat({ component, mergeSet }: ToSourceFormatInput): Promise<WriteInfo[]> {
const labelType = this.registry.getTypeByName('CustomLabel');
const partiallyAppliedPathCalculator = calculateRelativePath('source')({
self: labelType,
});
const xml = unwrapAndOmitNS('CustomLabels')(await component.parseXml()) as { labels: CustomLabel | CustomLabel[] };
return ensureArray(xml.labels) // labels could parse to a single object and not an array if there's only 1 label
.filter(customLabelHasFullName)
.map((l) => ({
// split each label into a separate label file
output:
// if present in the merge set, use that xml path, otherwise use the default path
mergeSet?.getComponentFilenamesByNameAndType({ fullName: l.fullName, type: labelType.name })?.[0] ??
partiallyAppliedPathCalculator(l.fullName)(`${l.fullName}.label-meta.xml`),
source: new JsToXml({ CustomLabel: l }),
}));
}
}

/* Use for the metadata type CustomLabel */
export class LabelMetadataTransformer extends DefaultMetadataTransformer {
public async toMetadataFormat(component: SourceComponent): Promise<WriteInfo[]> {
// only need to do this once
this.context.decomposedLabels.customLabelsType ??= this.registry.getTypeByName('CustomLabels');
this.context.decomposedLabels.transactionState.customLabelByFullName.set(
component.fullName,
unwrapAndOmitNS('CustomLabel')(await component.parseXml()) as CustomLabel
);
return [];
}

// toSourceFormat uses the default (merge them with the existing label)
}
8 changes: 4 additions & 4 deletions src/convert/transformers/decomposedMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ import { calculateRelativePath } from '../../utils/path';
import { ForceIgnore } from '../../resolve/forceIgnore';
import { extractUniqueElementValue, objectHasSomeRealValues } from '../../utils/decomposed';
import type { MetadataComponent } from '../../resolve/types';
import { DecompositionStrategy, type MetadataType } from '../../registry/types';
import { type MetadataType } from '../../registry/types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { JsToXml } from '../streams';
import type { WriteInfo, XmlObj } from '../types';
import type { ToSourceFormatInput, WriteInfo, XmlObj } from '../types';
import { META_XML_SUFFIX, XML_NS_KEY, XML_NS_URL } from '../../common/constants';
import type { SourcePath } from '../../common/types';
import { ComponentSet } from '../../collections/componentSet';
Expand Down Expand Up @@ -60,7 +60,7 @@ export class DecomposedMetadataTransformer extends BaseMetadataTransformer {
return [];
}

public async toSourceFormat(component: SourceComponent, mergeWith?: SourceComponent): Promise<WriteInfo[]> {
public async toSourceFormat({ component, mergeWith }: ToSourceFormatInput): Promise<WriteInfo[]> {
const forceIgnore = component.getForceIgnore();

// if the whole parent is ignored, we won't worry about decomposing things
Expand Down Expand Up @@ -265,7 +265,7 @@ const getDefaultOutput = (component: MetadataComponent): SourcePath => {
// there could be a '.' inside the child name (ex: PermissionSet.FieldPermissions.field uses Obj__c.Field__c)
const childName = tail.length ? tail.join('.') : undefined;
const output = join(
parent?.type.strategies?.decomposition === DecompositionStrategy.FolderPerType ? type.directoryName : '',
parent?.type.strategies?.decomposition === 'folderPerType' ? type.directoryName : '',
`${childName ?? baseName}.${ensureString(component.type.suffix)}${META_XML_SUFFIX}`
);
return join(calculateRelativePath('source')({ self: parent?.type ?? type })(fullName)(baseName), output);
Expand Down
8 changes: 7 additions & 1 deletion src/convert/transformers/defaultMetadataTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,13 @@ export class DefaultMetadataTransformer extends BaseMetadataTransformer {
}

// eslint-disable-next-line @typescript-eslint/require-await, class-methods-use-this
public async toSourceFormat(component: SourceComponent, mergeWith?: SourceComponent): Promise<WriteInfo[]> {
public async toSourceFormat({
component,
mergeWith,
}: {
component: SourceComponent;
mergeWith?: SourceComponent;
}): Promise<WriteInfo[]> {
return getWriteInfos(component, 'source', mergeWith);
}
}
Expand Down
14 changes: 9 additions & 5 deletions src/convert/transformers/metadataTransformerFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import { MetadataTransformer } from '../types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { ConvertContext } from '../convertContext/convertContext';
import { RegistryAccess } from '../../registry/registryAccess';
import { TransformerStrategy } from '../../registry/types';
import { DefaultMetadataTransformer } from './defaultMetadataTransformer';
import { DecomposedMetadataTransformer } from './decomposedMetadataTransformer';
import { StaticResourceMetadataTransformer } from './staticResourceMetadataTransformer';
import { NonDecomposedMetadataTransformer } from './nonDecomposedMetadataTransformer';
import { LabelMetadataTransformer, LabelsMetadataTransformer } from './decomposeLabelsTransformer';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
Expand All @@ -32,15 +32,19 @@ export class MetadataTransformerFactory {
const type = component.parent ? component.parent.type : component.type;
const transformerId = type.strategies?.transformer;
switch (transformerId) {
case TransformerStrategy.Standard:
case 'standard':
shetzel marked this conversation as resolved.
Show resolved Hide resolved
case undefined:
return new DefaultMetadataTransformer(this.registry, this.context);
case TransformerStrategy.Decomposed:
case 'decomposed':
return new DecomposedMetadataTransformer(this.registry, this.context);
case TransformerStrategy.StaticResource:
case 'staticResource':
return new StaticResourceMetadataTransformer(this.registry, this.context);
case TransformerStrategy.NonDecomposed:
case 'nonDecomposed':
return new NonDecomposedMetadataTransformer(this.registry, this.context);
case 'decomposedLabels':
return component.type.name === 'CustomLabels'
? new LabelsMetadataTransformer(this.registry, this.context)
: new LabelMetadataTransformer(this.registry, this.context);
default:
throw messages.createError('error_missing_transformer', [type.name, transformerId]);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
import { get, getString, JsonMap } from '@salesforce/ts-types';
import { ensureArray } from '@salesforce/kit';
import { Messages } from '@salesforce/core';
import { WriteInfo } from '../types';
import { SourceComponent } from '../../resolve/sourceComponent';
import { ToSourceFormatInput, WriteInfo } from '../types';
import { DecomposedMetadataTransformer } from './decomposedMetadataTransformer';
Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sdr');
Expand All @@ -22,7 +21,7 @@ const messages = Messages.loadMessages('@salesforce/source-deploy-retrieve', 'sd
export class NonDecomposedMetadataTransformer extends DecomposedMetadataTransformer {
// streams uses mergeWith for all types. Removing it would break the interface
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async toSourceFormat(component: SourceComponent, mergeWith?: SourceComponent): Promise<WriteInfo[]> {
public async toSourceFormat({ component, mergeWith }: ToSourceFormatInput): Promise<WriteInfo[]> {
// this will only include the incoming (retrieved) labels, not the local file
const parentXml = await component.parseXml();
const xmlPathToChildren = `${component.type.name}.${component.type.directoryName}`;
Expand Down
Loading