diff --git a/extensions/analyticsdx-vscode-templates/src/autoInstall/completions.ts b/extensions/analyticsdx-vscode-templates/src/autoInstall/completions.ts index cc32e7ac..444e5fa3 100644 --- a/extensions/analyticsdx-vscode-templates/src/autoInstall/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/autoInstall/completions.ts @@ -8,6 +8,7 @@ import { Location, Node as JsonNode } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefCompletionItemProviderDelegate } from '../variables'; @@ -26,11 +27,9 @@ export class AutoInstallVariableCompletionItemProviderDelegate extends VariableR public isSupportedLocation(location: Location) { return ( - location.isAtPropertyKey && - location.matches(['configuration', 'appConfiguration', 'values', '*']) && - // this makes sure the completion only show in prop names directly under "values" (and not in prop names in object + // makes sure the completion only show in prop names directly under "values" (and not in prop names in object // values under "values") - location.path.length === 4 + location.isAtPropertyKey && locationMatches(location, ['configuration', 'appConfiguration', 'values', '*']) ); } diff --git a/extensions/analyticsdx-vscode-templates/src/autoInstall/definitions.ts b/extensions/analyticsdx-vscode-templates/src/autoInstall/definitions.ts index 90855438..7696ad59 100644 --- a/extensions/analyticsdx-vscode-templates/src/autoInstall/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/autoInstall/definitions.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefDefinitionProvider } from '../variables'; @@ -31,7 +32,7 @@ export class AutoInstallVariableDefinitionProvider extends VariableRefDefinition location.previousNode?.type === 'property' && location.previousNode.value && // and that it's in a variable name field - location.matches(['configuration', 'appConfiguration', 'values', '*']) + locationMatches(location, ['configuration', 'appConfiguration', 'values', '*']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/autoInstall/hovers.ts b/extensions/analyticsdx-vscode-templates/src/autoInstall/hovers.ts index 6559f41f..ada9cca8 100644 --- a/extensions/analyticsdx-vscode-templates/src/autoInstall/hovers.ts +++ b/extensions/analyticsdx-vscode-templates/src/autoInstall/hovers.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { VariableRefHoverProvider } from '../variables'; /** Get hover text for a variable name in the appConfiguration.values of an auto-install.json. */ @@ -24,7 +25,7 @@ export class AutoInstallVariableHoverProvider extends VariableRefHoverProvider { return ( location.isAtPropertyKey && location.previousNode?.type === 'property' && - location.matches(['configuration', 'appConfiguration', 'values', '*']) + locationMatches(location, ['configuration', 'appConfiguration', 'values', '*']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/layout/completions.ts b/extensions/analyticsdx-vscode-templates/src/layout/completions.ts index 010e4108..c9237caa 100644 --- a/extensions/analyticsdx-vscode-templates/src/layout/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/layout/completions.ts @@ -11,10 +11,16 @@ import * as vscode from 'vscode'; import { codeCompletionUsedTelemetryCommand } from '../telemetry'; import { TemplateDirEditing } from '../templateEditing'; import { JsonCompletionItemProviderDelegate, newCompletionItem } from '../util/completions'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidVariableName } from '../util/templateUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefCompletionItemProviderDelegate } from '../variables'; -import { getLayoutItemVariableName, isInTilesEnumKey, matchesLayoutItem } from './utils'; +import { + getLayoutItemVariableName, + isInComponentLayoutVariableName, + isInTilesEnumKey, + matchesLayoutItem +} from './utils'; /** Get tags from the readiness file's templateRequirements. */ export class LayoutValidationPageTagCompletionItemProviderDelegate implements JsonCompletionItemProviderDelegate { @@ -34,7 +40,7 @@ export class LayoutValidationPageTagCompletionItemProviderDelegate implements Js // get the parent node hierarchy in the Location passed in, and it's not that big a deal if the user gets a // code-completion for this path in the layout.json file on a Configuration page since they'll already be getting // errors about the wrong type - return !location.isAtPropertyKey && location.matches(['pages', '*', 'groups', '*', 'tags', '*']); + return !location.isAtPropertyKey && locationMatches(location, ['pages', '*', 'groups', '*', 'tags', '*']); } public async getItems(range: vscode.Range | undefined, location: Location, document: vscode.TextDocument) { @@ -77,7 +83,9 @@ export class LayoutVariableCompletionItemProviderDelegate extends VariableRefCom public override isSupportedLocation(location: Location, context: vscode.CompletionContext): boolean { // make sure that it's in a variable name value - return !location.isAtPropertyKey && matchesLayoutItem(location, 'name'); + return ( + !location.isAtPropertyKey && (isInComponentLayoutVariableName(location) || matchesLayoutItem(location, 'name')) + ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/layout/definitions.ts b/extensions/analyticsdx-vscode-templates/src/layout/definitions.ts index c763a69c..3fb989cd 100644 --- a/extensions/analyticsdx-vscode-templates/src/layout/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/layout/definitions.ts @@ -14,7 +14,12 @@ import { matchJsonNodeAtPattern } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { rangeForNode } from '../util/vscodeUtils'; import { VariableRefDefinitionProvider } from '../variables'; -import { getLayoutItemVariableName, isInTilesEnumKey, matchesLayoutItem } from './utils'; +import { + getLayoutItemVariableName, + isInComponentLayoutVariableName, + isInTilesEnumKey, + matchesLayoutItem +} from './utils'; /** Handle CMD+Click from a variable name in layout.json to the variable in variables.json. */ export class LayoutVariableDefinitionProvider extends VariableRefDefinitionProvider { @@ -37,7 +42,7 @@ export class LayoutVariableDefinitionProvider extends VariableRefDefinitionProvi location.previousNode?.type === 'string' && location.previousNode.value && // and that it's in a variable name field - matchesLayoutItem(location, 'name') + (isInComponentLayoutVariableName(location) || matchesLayoutItem(location, 'name')) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/layout/hovers.ts b/extensions/analyticsdx-vscode-templates/src/layout/hovers.ts index 52747741..4064f1bd 100644 --- a/extensions/analyticsdx-vscode-templates/src/layout/hovers.ts +++ b/extensions/analyticsdx-vscode-templates/src/layout/hovers.ts @@ -9,7 +9,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; import { VariableRefHoverProvider } from '../variables'; -import { matchesLayoutItem } from './utils'; +import { isInComponentLayoutVariableName, matchesLayoutItem } from './utils'; /** Get hover text for a variable from the name in a page in a layout.json file. */ export class LayoutVariableHoverProvider extends VariableRefHoverProvider { @@ -22,6 +22,10 @@ export class LayoutVariableHoverProvider extends VariableRefHoverProvider { } protected override isSupportedLocation(location: Location) { - return !location.isAtPropertyKey && location.previousNode?.type === 'string' && matchesLayoutItem(location, 'name'); + return ( + !location.isAtPropertyKey && + location.previousNode?.type === 'string' && + (isInComponentLayoutVariableName(location) || matchesLayoutItem(location, 'name')) + ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/layout/utils.ts b/extensions/analyticsdx-vscode-templates/src/layout/utils.ts index a30d3b10..7c2db99c 100644 --- a/extensions/analyticsdx-vscode-templates/src/layout/utils.ts +++ b/extensions/analyticsdx-vscode-templates/src/layout/utils.ts @@ -7,7 +7,7 @@ import { JSONPath, Location, parseTree } from 'jsonc-parser'; import * as vscode from 'vscode'; -import { matchJsonNodeAtPattern } from '../util/jsoncUtils'; +import { locationMatches, matchJsonNodeAtPattern } from '../util/jsoncUtils'; const paths: Array> = [ ['pages', '*', 'layout', 'center', 'items', '*'], @@ -21,16 +21,23 @@ const paths: Array> = [ /** Tell if the specified json location is in a layout item * @param location the location * @param attrName the name of an item attribute to also check. + * @param exact true (default) to exactly match the item (or item attribute) path, false to just be at or under. */ -export function matchesLayoutItem(location: Location, attrName?: string) { +export function matchesLayoutItem(location: Location, attrName?: string, exact = true) { // TODO: make this more specific to the layout type (e.g. only 'center' if SingleColumn) - return paths.some(path => location.matches(attrName ? path.concat(attrName) : (path as JSONPath))); + return paths.some(path => locationMatches(location, attrName ? path.concat(attrName) : (path as JSONPath), exact)); +} + +/** Tell if the specified json location is in a `Component` layout's variable's name field. */ +export function isInComponentLayoutVariableName(location: Location) { + return locationMatches(location, ['pages', '*', 'layout', 'variables', '*', 'name']); } export function isInTilesEnumKey(location: Location) { return ( location.isAtPropertyKey && - matchesLayoutItem(location, 'tiles') && + // do non-exact match on the location to handle the jsonpath when in a key + matchesLayoutItem(location, 'tiles', false) && // when it's directly in the keys of 'tiles', then the path will be like [..., 'tiles', ''] or // [..., 'tiles', 'enumValue'], so only trigger then (to avoid triggering when down the tree in the // tile def objects). also, check the path length to avoid triggering when a tile enumValue is diff --git a/extensions/analyticsdx-vscode-templates/src/readiness/completions.ts b/extensions/analyticsdx-vscode-templates/src/readiness/completions.ts index 17b5ee7b..456e3ce6 100644 --- a/extensions/analyticsdx-vscode-templates/src/readiness/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/readiness/completions.ts @@ -8,6 +8,7 @@ import { Location, Node as JsonNode } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefCompletionItemProviderDelegate } from '../variables'; @@ -27,10 +28,9 @@ export class ReadinessVariableCompletionItemProviderDelegate extends VariableRef public isSupportedLocation(location: Location) { return ( location.isAtPropertyKey && - location.matches(['values', '*']) && // this makes sure the completion only show in prop names directly under "values" (and not in prop names in object // values under "values") - location.path.length === 2 + locationMatches(location, ['values', '*']) ); } diff --git a/extensions/analyticsdx-vscode-templates/src/readiness/definitions.ts b/extensions/analyticsdx-vscode-templates/src/readiness/definitions.ts index 41b02d12..342cfef4 100644 --- a/extensions/analyticsdx-vscode-templates/src/readiness/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/readiness/definitions.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefDefinitionProvider } from '../variables'; @@ -31,7 +32,7 @@ export class ReadinessVariableDefinitionProvider extends VariableRefDefinitionPr location.previousNode?.type === 'property' && location.previousNode.value && // and that it's in a variable name field in values - location.matches(['values', '*']) + locationMatches(location, ['values', '*']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/readiness/hovers.ts b/extensions/analyticsdx-vscode-templates/src/readiness/hovers.ts index 097077ec..31735073 100644 --- a/extensions/analyticsdx-vscode-templates/src/readiness/hovers.ts +++ b/extensions/analyticsdx-vscode-templates/src/readiness/hovers.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { VariableRefHoverProvider } from '../variables'; /** Get hover text for a variable name in the values of a readiness.json. */ @@ -21,6 +22,10 @@ export class ReadinessVariableHoverProvider extends VariableRefHoverProvider { } protected isSupportedLocation(location: Location) { - return location.isAtPropertyKey && location.previousNode?.type === 'property' && location.matches(['values', '*']); + return ( + location.isAtPropertyKey && + location.previousNode?.type === 'property' && + locationMatches(location, ['values', '*']) + ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/templateEditing.ts b/extensions/analyticsdx-vscode-templates/src/templateEditing.ts index 652f25d1..2318c86d 100644 --- a/extensions/analyticsdx-vscode-templates/src/templateEditing.ts +++ b/extensions/analyticsdx-vscode-templates/src/templateEditing.ts @@ -75,7 +75,7 @@ import { import { JsonCompletionItemProvider, newRelativeFilepathDelegate } from './util/completions'; import { JsonAttributeRelFilePathDefinitionProvider } from './util/definitions'; import { Disposable } from './util/disposable'; -import { matchJsonNodesAtPattern } from './util/jsoncUtils'; +import { locationMatches, matchJsonNodesAtPattern } from './util/jsoncUtils'; import { Logger, PrefixingOutputChannel } from './util/logger'; import { findTemplateInfoFileFor } from './util/templateUtils'; import { isValidRelpath } from './util/utils'; @@ -276,22 +276,26 @@ export class TemplateDirEditing extends Disposable { const fileCompleter = new JsonCompletionItemProvider( // locations that support *.json fies: newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.jsonRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.jsonRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: templateJsonFileFilter }), // attributes that should have html paths newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.htmlRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.htmlRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: htmlFileFilter }), // attribute that should point to images newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.imageRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.imageRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: imageFileFilter }), // the file in externalFiles should be a .csv newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: csvFileFilter }), // dataModeObjects' dataset field diff --git a/extensions/analyticsdx-vscode-templates/src/templateInfo/completions.ts b/extensions/analyticsdx-vscode-templates/src/templateInfo/completions.ts index 03b03108..f7259b8b 100644 --- a/extensions/analyticsdx-vscode-templates/src/templateInfo/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/templateInfo/completions.ts @@ -10,11 +10,12 @@ import { Location, parseTree } from 'jsonc-parser'; import * as vscode from 'vscode'; import { codeCompletionUsedTelemetryCommand } from '../telemetry'; import { JsonCompletionItemProviderDelegate, newCompletionItem } from '../util/completions'; +import { locationMatches } from '../util/jsoncUtils'; /** Provide completion items for a dataModelObject dataset field, from the datasetFiles' names. */ export class DMODatasetCompletionItemProviderDelegate implements JsonCompletionItemProviderDelegate { public isSupportedLocation(location: Location) { - return !location.isAtPropertyKey && location.matches(['dataModelObjects', '*', 'dataset']); + return !location.isAtPropertyKey && locationMatches(location, ['dataModelObjects', '*', 'dataset']); } public getItems(range: vscode.Range | undefined, location: Location, document: vscode.TextDocument) { diff --git a/extensions/analyticsdx-vscode-templates/src/templateInfo/definitions.ts b/extensions/analyticsdx-vscode-templates/src/templateInfo/definitions.ts index 0d1bed0f..956db8aa 100644 --- a/extensions/analyticsdx-vscode-templates/src/templateInfo/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/templateInfo/definitions.ts @@ -8,6 +8,7 @@ import { matchJsonNodesAtPattern } from '@salesforce/analyticsdx-template-lint'; import { Location, parseTree } from 'jsonc-parser'; import * as vscode from 'vscode'; +import { locationMatches } from '../util/jsoncUtils'; import { rangeForNode } from '../util/vscodeUtils'; import { JsonAttributeDefinitionProvider } from './../util/definitions'; @@ -28,7 +29,7 @@ export class DMODatasetDefinitionProvider extends JsonAttributeDefinitionProvide !location.isAtPropertyKey && location.previousNode?.type === 'string' && location.previousNode.value && - location.matches(['dataModelObjects', '*', 'dataset']) + locationMatches(location, ['dataModelObjects', '*', 'dataset']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/ui/completions.ts b/extensions/analyticsdx-vscode-templates/src/ui/completions.ts index b5fd7f2e..a469a421 100644 --- a/extensions/analyticsdx-vscode-templates/src/ui/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/ui/completions.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefCompletionItemProviderDelegate } from '../variables'; @@ -29,7 +30,7 @@ export class UiVariableCompletionItemProviderDelegate extends VariableRefComplet public isSupportedLocation(location: Location, context: vscode.CompletionContext): boolean { return ( // make that it's in a variable name value - !location.isAtPropertyKey && location.matches(['pages', '*', 'variables', '*', 'name']) + !location.isAtPropertyKey && locationMatches(location, ['pages', '*', 'variables', '*', 'name']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/ui/definitions.ts b/extensions/analyticsdx-vscode-templates/src/ui/definitions.ts index a6988db7..6b297eb0 100644 --- a/extensions/analyticsdx-vscode-templates/src/ui/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/ui/definitions.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidRelpath } from '../util/utils'; import { VariableRefDefinitionProvider } from '../variables'; @@ -32,7 +33,7 @@ export class UiVariableDefinitionProvider extends VariableRefDefinitionProvider location.previousNode?.type === 'string' && location.previousNode.value && // and that it's in a variable name field - location.matches(['pages', '*', 'variables', '*', 'name']) + locationMatches(location, ['pages', '*', 'variables', '*', 'name']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/ui/hovers.ts b/extensions/analyticsdx-vscode-templates/src/ui/hovers.ts index d03dc723..98b421d1 100644 --- a/extensions/analyticsdx-vscode-templates/src/ui/hovers.ts +++ b/extensions/analyticsdx-vscode-templates/src/ui/hovers.ts @@ -8,6 +8,7 @@ import { Location } from 'jsonc-parser'; import * as vscode from 'vscode'; import { TemplateDirEditing } from '../templateEditing'; +import { locationMatches } from '../util/jsoncUtils'; import { VariableRefHoverProvider } from '../variables'; /** Get hover text for a variable from the name in a page in a ui.json file. */ @@ -24,7 +25,7 @@ export class UiVariableHoverProvider extends VariableRefHoverProvider { return ( !location.isAtPropertyKey && location.previousNode?.type === 'string' && - location.matches(['pages', '*', 'variables', '*', 'name']) + locationMatches(location, ['pages', '*', 'variables', '*', 'name']) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/util/definitions.ts b/extensions/analyticsdx-vscode-templates/src/util/definitions.ts index 432b897c..d68695be 100644 --- a/extensions/analyticsdx-vscode-templates/src/util/definitions.ts +++ b/extensions/analyticsdx-vscode-templates/src/util/definitions.ts @@ -8,6 +8,7 @@ import { getLocation, JSONPath, Location } from 'jsonc-parser'; import { posix as path } from 'path'; import * as vscode from 'vscode'; +import { locationMatches } from './jsoncUtils'; import { isValidRelpath } from './utils'; /** Base class for providing definition support on fields in a json file. */ @@ -76,7 +77,7 @@ export class JsonAttributeRelFilePathDefinitionProvider extends JsonAttributeDef location.previousNode && location.previousNode.type === 'string' && location.previousNode.value && - this.patterns.some(location.matches) + this.patterns.some(p => locationMatches(location, p)) ); } } diff --git a/extensions/analyticsdx-vscode-templates/src/util/jsoncUtils.ts b/extensions/analyticsdx-vscode-templates/src/util/jsoncUtils.ts index a4c49b2c..012abf45 100644 --- a/extensions/analyticsdx-vscode-templates/src/util/jsoncUtils.ts +++ b/extensions/analyticsdx-vscode-templates/src/util/jsoncUtils.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import { FormattingOptions, getNodePath, JSONPath, Node as JsonNode } from 'jsonc-parser'; +import { FormattingOptions, getNodePath, JSONPath, Location, Node as JsonNode } from 'jsonc-parser'; export { jsonPathToString, @@ -13,6 +13,19 @@ export { matchJsonNodesAtPattern } from '@salesforce/analyticsdx-template-lint'; +/** + * Matches the location's path against a pattern consisting of strings (for properties) and numbers (for array indices). + * '*' will match a single segment of any property name or index. + * '**' will match a sequence of segments of any property name or index, or no segment. + * @param location the location. + * @param jsonpath the path pattern to match against. + * @param exact true (default) to exactly match the jsonpath length as well (doesn't work with '**'), or false + * to check that the location starts with the jsonpath. + */ +export function locationMatches(location: Location, jsonpath: JSONPath, exact = true) { + return location.matches(jsonpath) && (!exact || location.path.length === jsonpath.length); +} + /** Find the ancestor 'property' json node at or above the specified node for the specified property. * This does not support wildcard paths. * @param node the selected node. diff --git a/extensions/analyticsdx-vscode-templates/src/variables/completions.ts b/extensions/analyticsdx-vscode-templates/src/variables/completions.ts index 97755ba6..ce35491a 100644 --- a/extensions/analyticsdx-vscode-templates/src/variables/completions.ts +++ b/extensions/analyticsdx-vscode-templates/src/variables/completions.ts @@ -10,6 +10,7 @@ import * as vscode from 'vscode'; import { codeCompletionUsedTelemetryCommand } from '../telemetry'; import { TemplateDirEditing } from '../templateEditing'; import { JsonCompletionItemProviderDelegate, newCompletionItem } from '../util/completions'; +import { locationMatches } from '../util/jsoncUtils'; import { isValidVariableName } from '../util/templateUtils'; export const NEW_VARIABLE_SNIPPETS = Object.freeze([ @@ -31,7 +32,7 @@ export class NewVariableCompletionItemProviderDelegate implements JsonCompletion public isSupportedLocation(location: Location) { return ( // make sure it's in the empty part of the variables.json {} - location.isAtPropertyKey && location.matches(['*']) && location.path.length === 1 + location.isAtPropertyKey && locationMatches(location, ['*']) ); } diff --git a/extensions/analyticsdx-vscode-templates/test/unit/util/jsoncUtilsTest.ts b/extensions/analyticsdx-vscode-templates/test/unit/util/jsoncUtilsTest.ts index 80ac3e92..ceaf3b87 100644 --- a/extensions/analyticsdx-vscode-templates/test/unit/util/jsoncUtilsTest.ts +++ b/extensions/analyticsdx-vscode-templates/test/unit/util/jsoncUtilsTest.ts @@ -6,10 +6,12 @@ */ import { expect } from 'chai'; -import { JSONPath, Node as JsonNode, ParseError, parseTree } from 'jsonc-parser'; +import { getLocation, JSONPath, Node as JsonNode, ParseError, parseTree } from 'jsonc-parser'; import { findPropertyNodeFor, jsonStringifyWithOptions, + locationMatches, + matchJsonNodeAtPattern, matchJsonNodesAtPattern, pathPartsAreEquals } from '../../../src/util/jsoncUtils'; @@ -30,6 +32,39 @@ describe('jsoncUtils', () => { return jsonNode; } + describe('locationMatches()', () => { + const obj = { + layout: { + variables: [{ name: 'foo' }, { name: { name: 'bar' } }] + } + }; + const json = JSON.stringify(obj, undefined, 2); + const tree = parseOrThrow(obj); + + it('finds non-exact match', () => { + const node = matchJsonNodeAtPattern(tree, ['layout', 'variables', 1, 'name', 'name']); + expect(node, 'json node').to.be.not.undefined; + + const location = getLocation(json, node!.offset); + // both the pattern to the node and to the parent node should match in the non-exact use case + expect(locationMatches(location, ['layout', 'variables', '*', 'name', 'name'], false), 'exact path').to.equal( + true + ); + expect(locationMatches(location, ['layout', 'variables', '*', 'name'], false), 'parent path').to.equal(true); + }); + + it('finds exact match', () => { + const node = matchJsonNodeAtPattern(tree, ['layout', 'variables', 1, 'name', 'name']); + expect(node, 'json node').to.be.not.undefined; + + const location = getLocation(json, node!.offset); + // both the pattern to the node and to the parent node should match in the non-exact use case + expect(locationMatches(location, ['layout', 'variables', '*', 'name', 'name']), 'exact path').to.equal(true); + // but this should not match in the exact use case + expect(locationMatches(location, ['layout', 'variables', '*', 'name']), 'parent path').to.equal(false); + }); + }); + describe('findPropertyNodeFor()', () => { const object = parseOrThrow({ templateType: 'app', diff --git a/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateEditing/layout.test.ts b/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateEditing/layout.test.ts index 3b020e98..0a3d9f1c 100644 --- a/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateEditing/layout.test.ts +++ b/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateEditing/layout.test.ts @@ -133,7 +133,14 @@ describe('TemplateEditorManager configures layoutDefinition', () => { await verifyCompletionsContain(doc, position, 'New pages'); // and go to just after the [ in "pages" position = scan.end.translate({ characterDelta: 1 }); - await verifyCompletionsContain(doc, position, 'New SingleColumn page', 'New TwoColumn page', 'New Validation page'); + await verifyCompletionsContain( + doc, + position, + 'New Component page', + 'New SingleColumn page', + 'New TwoColumn page', + 'New Validation page' + ); // go to just before the { in "layout" node = findNodeAtLocation(tree!, ['pages', 0, 'layout']); @@ -143,7 +150,13 @@ describe('TemplateEditorManager configures layoutDefinition', () => { expect.fail("Expected to find '{' after '\"layout\":'"); } position = scan.end.translate({ characterDelta: -1 }); - await verifyCompletionsContain(doc, position, 'New SingleColumn layout', 'New TwoColumn layout'); + await verifyCompletionsContain( + doc, + position, + 'New Component layout', + 'New SingleColumn layout', + 'New TwoColumn layout' + ); // go to just after the [ in "items" node = findNodeAtLocation(tree!, ['pages', 0, 'layout', 'center', 'items']); @@ -373,6 +386,7 @@ describe('TemplateEditorManager configures layoutDefinition', () => { ['pages', 0, 'layout', 'center', 'items', 4, 'items', 0, 'name'], 'DateTimeTypeGroupBoxVar' ); + await testHover(doc, uri, tree!, ['pages', 1, 'layout', 'variables', 0, 'name'], 'StringTypeVar'); }); it('go to definition support for variable names', async () => { @@ -382,38 +396,24 @@ describe('TemplateEditorManager configures layoutDefinition', () => { await waitForDiagnostics(uri, d => d && d.length >= 4); await waitForTemplateEditorManagerHas(await getTemplateEditorManager(), uriDirname(uri), true); - const position = findPositionByJsonPath(doc, ['pages', 0, 'layout', 'center', 'items', 0, 'name']); - expect(position, 'pages[0].layout.center.items[0].name').to.not.be.undefined; - - const locations = await getDefinitionLocations(uri, position!.translate(undefined, 1)); - if (locations.length !== 1) { - expect.fail('Expected 1 location, got:\n' + JSON.stringify(locations, undefined, 2)); - } - expect(locations[0].uri.fsPath, 'location path').to.equal( - vscode.Uri.joinPath(uriDirname(uri), 'variables.json').fsPath - ); - - // Go to definition for variable defined in groupbox - const groupBoxVarPosition = findPositionByJsonPath(doc, [ - 'pages', - 0, - 'layout', - 'center', - 'items', - 4, - 'items', - 0, - 'name' - ]); - expect(groupBoxVarPosition, 'pages[0].layout.center.items[4].items[0].name').to.not.be.undefined; - - const groupBoxVarlocations = await getDefinitionLocations(uri, groupBoxVarPosition!.translate(undefined, 1)); - if (groupBoxVarlocations.length !== 1) { - expect.fail('Expected 1 location, got:\n' + JSON.stringify(groupBoxVarlocations, undefined, 2)); - } - expect(groupBoxVarlocations[0].uri.fsPath, 'location path').to.equal( - vscode.Uri.joinPath(uriDirname(uri), 'variables.json').fsPath - ); + const verifyDefinition = async (...jsonpath: JSONPath) => { + const position = findPositionByJsonPath(doc, jsonpath); + const jsonpathStr = jsonPathToString(jsonpath); + expect(position, jsonpathStr).to.not.be.undefined; + const locations = await getDefinitionLocations(uri, position!.translate(undefined, 1)); + if (locations.length !== 1) { + expect.fail(`${jsonpathStr}: expected 1 location, got:\n` + JSON.stringify(locations, undefined, 2)); + } + expect(locations[0].uri.fsPath, `${jsonpathStr} location path`).to.equal( + vscode.Uri.joinPath(uriDirname(uri), 'variables.json').fsPath + ); + }; + // check for a top-level variable item + await verifyDefinition('pages', 0, 'layout', 'center', 'items', 0, 'name'); + // check for a variable defined in groupbox + await verifyDefinition('pages', 0, 'layout', 'center', 'items', 4, 'items', 0, 'name'); + // check for a variable in an lwc Component page + await verifyDefinition('pages', 1, 'layout', 'variables', 0, 'name'); }); it('go to definition support for variable tiles keys', async () => { @@ -455,126 +455,69 @@ describe('TemplateEditorManager configures layoutDefinition', () => { await waitForDiagnostics(uri, d => d && d.length >= 4); await waitForTemplateEditorManagerHas(await getTemplateEditorManager(), uriDirname(uri), true); - const position = findPositionByJsonPath(doc, ['pages', 0, 'layout', 'center', 'items', 0, 'name']); - expect(position, 'pages[0].layout.center.items[0].name').to.not.be.undefined; - const completions = ( - await verifyCompletionsContain( - doc, - position!, - '"DatasetAnyFieldTypeVar"', - '"DateTimeTypeGroupBoxVar"', - '"DateTimeTypeVar"', - '"ObjectTypeGroupBoxVar"', - '"ObjectTypeVar"', - '"StringArrayVar"', - '"StringTypeVar"' - ) - ).sort(compareCompletionItems); - if (completions.length !== 7) { - expect.fail('Expected 7 completions, got: ' + completions.map(i => i.label).join(', ')); - } - // check some more stuff on the completion items - [ - { - detail: '(DatasetAnyFieldType) A dataset any field variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(DateTimeType) A datetime variable for groupbox', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(DateTimeType) A datetime variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(ObjectType) An object variable for groupbox', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(ObjectType) An object variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(StringType[])', - docs: undefined - }, - { - detail: '(StringType) A string variable', - docs: 'String variable description' - } - ].forEach(({ detail, docs }, i) => { - const item = completions[i]; - expect(item.kind, `${item.label} kind`).to.equal(vscode.CompletionItemKind.Variable); - expect(item.detail, `${item.label} details`).to.equal(detail); - expect(item.documentation, `${item.label} documentation`).to.equal(docs); - }); - - // Check for variable code completitons inside groupBox - const groupBoxPosition = findPositionByJsonPath(doc, [ - 'pages', - 0, - 'layout', - 'center', - 'items', - 4, - 'items', - 0, - 'name' - ]); - expect(groupBoxPosition, 'pages[0].layout.center.items[4].items[0].name').to.not.be.undefined; - const groupBoxCompletions = ( - await verifyCompletionsContain( - doc, - groupBoxPosition!, - '"DatasetAnyFieldTypeVar"', - '"DateTimeTypeGroupBoxVar"', - '"DateTimeTypeVar"', - '"ObjectTypeGroupBoxVar"', - '"ObjectTypeVar"', - '"StringArrayVar"', - '"StringTypeVar"' - ) - ).sort(compareCompletionItems); - if (groupBoxCompletions.length !== 7) { - expect.fail('Expected 7 completions, got: ' + groupBoxCompletions.map(i => i.label).join(', ')); - } - // check some more stuff on the completion items - [ - { - detail: '(DatasetAnyFieldType) A dataset any field variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(DateTimeType) A datetime variable for groupbox', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(DateTimeType) A datetime variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(ObjectType) An object variable for groupbox', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(ObjectType) An object variable', - docs: "This can't be put in a non-vfpage page" - }, - { - detail: '(StringType[])', - docs: undefined - }, - { - detail: '(StringType) A string variable', - docs: 'String variable description' + const verifyCompletions = async (...jsonpath: JSONPath) => { + const position = findPositionByJsonPath(doc, jsonpath); + const jsonpathStr = jsonPathToString(jsonpath); + expect(position, jsonpathStr).to.not.be.undefined; + const completions = ( + await verifyCompletionsContain( + doc, + position!, + '"DatasetAnyFieldTypeVar"', + '"DateTimeTypeGroupBoxVar"', + '"DateTimeTypeVar"', + '"ObjectTypeGroupBoxVar"', + '"ObjectTypeVar"', + '"StringArrayVar"', + '"StringTypeVar"' + ) + ).sort(compareCompletionItems); + if (completions.length !== 7) { + expect.fail(`${jsonpathStr}: exepcted 7 completions, got: ` + completions.map(i => i.label).join(', ')); } - ].forEach(({ detail, docs }, i) => { - const item = groupBoxCompletions[i]; - expect(item.kind, `${item.label} kind`).to.equal(vscode.CompletionItemKind.Variable); - expect(item.detail, `${item.label} details`).to.equal(detail); - expect(item.documentation, `${item.label} documentation`).to.equal(docs); - }); + // check some more stuff on the completion items + [ + { + detail: '(DatasetAnyFieldType) A dataset any field variable', + docs: "This can't be put in a non-vfpage page" + }, + { + detail: '(DateTimeType) A datetime variable for groupbox', + docs: "This can't be put in a non-vfpage page" + }, + { + detail: '(DateTimeType) A datetime variable', + docs: "This can't be put in a non-vfpage page" + }, + { + detail: '(ObjectType) An object variable for groupbox', + docs: "This can't be put in a non-vfpage page" + }, + { + detail: '(ObjectType) An object variable', + docs: "This can't be put in a non-vfpage page" + }, + { + detail: '(StringType[])', + docs: undefined + }, + { + detail: '(StringType) A string variable', + docs: 'String variable description' + } + ].forEach(({ detail, docs }, i) => { + const item = completions[i]; + expect(item.kind, `${jsonpathStr}: ${item.label} kind`).to.equal(vscode.CompletionItemKind.Variable); + expect(item.detail, `${jsonpathStr}: ${item.label} details`).to.equal(detail); + expect(item.documentation, `${jsonpathStr}: ${item.label} documentation`).to.equal(docs); + }); + }; + // top-level variable item + await verifyCompletions('pages', 0, 'layout', 'center', 'items', 0, 'name'); + // inside groupBox + await verifyCompletions('pages', 0, 'layout', 'center', 'items', 4, 'items', 0, 'name'); + // in lwc page variable + await verifyCompletions('pages', 1, 'layout', 'variables', 0, 'name'); }); it('quick fixes on bad variable names', async () => { diff --git a/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateLinter/layout.test.ts b/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateLinter/layout.test.ts index f0b7a4f3..99993926 100644 --- a/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateLinter/layout.test.ts +++ b/extensions/analyticsdx-vscode-templates/test/vscode-integration/templateLinter/layout.test.ts @@ -71,6 +71,15 @@ describe('TemplateLinterManager lints layout.json', () => { items: [{ type: 'Variable', name: 'var2' }] } } + }, + { + title: 'LWC Page', + type: 'Configuration', + layout: { + type: 'Component', + module: 'a/b', + variables: [{ name: 'var1' }, { name: 'var2' }] + } } ] }; @@ -97,14 +106,15 @@ describe('TemplateLinterManager lints layout.json', () => { let diagnostics = ( await waitForDiagnostics(layoutEditor.document.uri, undefined, 'Initial variable warnings') ).sort(sortDiagnostics); - if (diagnostics.length !== 2) { - expect.fail('Expected 2 diagnostics, got:\n' + JSON.stringify(diagnostics, undefined, 2)); + if (diagnostics.length !== 3) { + expect.fail('Expected 3 diagnostics, got:\n' + JSON.stringify(diagnostics, undefined, 2)); } expect(diagnostics[0], 'diagnostics[0]').to.be.not.undefined; expect(diagnostics[0].message, 'diagnostics[0].message').to.equal( "Cannot find variable 'badvar', did you mean 'var1'?" ); expect(diagnostics[0].code, 'diagnostics[0].message').to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(jsonpathFrom(diagnostics[0]), 'diagnostics[0].jsonpath').to.equal('pages[0].layout.left.items[0].name'); expect(argsFrom(diagnostics[0])?.name, 'diagnostics[0].args.name').to.equal('badvar'); expect(argsFrom(diagnostics[0])?.match, 'diagnostics[0].args.match').to.equal('var1'); @@ -113,9 +123,19 @@ describe('TemplateLinterManager lints layout.json', () => { "Cannot find variable 'var2', did you mean 'var1'?" ); expect(diagnostics[1].code, 'diagnostics[1].message').to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(jsonpathFrom(diagnostics[1]), 'diagnostics[1].jsonpath').to.equal('pages[1].layout.center.items[0].name'); expect(argsFrom(diagnostics[1])?.name, 'diagnostics[1].args.name').to.equal('var2'); expect(argsFrom(diagnostics[1])?.match, 'diagnostics[1].args.name').to.equal('var1'); + expect(diagnostics[2], 'diagnostics[2]').to.be.not.undefined; + expect(diagnostics[2].message, 'diagnostics[2].message').to.equal( + "Cannot find variable 'var2', did you mean 'var1'?" + ); + expect(diagnostics[2].code, 'diagnostics[2].message').to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(jsonpathFrom(diagnostics[2]), 'diagnostics[2].jsonpath').to.equal('pages[2].layout.variables[1].name'); + expect(argsFrom(diagnostics[2])?.name, 'diagnostics[2].args.name').to.equal('var2'); + expect(argsFrom(diagnostics[2])?.match, 'diagnostics[2].args.name').to.equal('var1'); + // now, change the 'badvar' ref to 'var1' in layout.json layoutJson.pages[0].layout.left!.items[0].name = 'var1'; await setDocumentText(layoutEditor, layoutJson); @@ -123,22 +143,29 @@ describe('TemplateLinterManager lints layout.json', () => { diagnostics = ( await waitForDiagnostics( layoutEditor.document.uri, - d => d && d.length === 1, + d => d && d.length === 2, 'Variable warnings after editing layout.json' ) ).sort(sortDiagnostics); - // we should still have the warning about var2 - if (diagnostics.length !== 1) { - expect.fail('Expected 1 diagnostics, got:\n' + JSON.stringify(diagnostics, undefined, 2)); - } + // we should still have the warnings about var2 expect(diagnostics[0], 'diagnostics[0]').to.be.not.undefined; expect(diagnostics[0].message, 'diagnostics[0].message').to.equal( "Cannot find variable 'var2', did you mean 'var1'?" ); expect(diagnostics[0].code, 'diagnostics[0].message').to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(jsonpathFrom(diagnostics[0]), 'diagnostics[0].jsonpath').to.equal('pages[1].layout.center.items[0].name'); expect(argsFrom(diagnostics[0])?.name, 'diagnostics[0].args.name').to.equal('var2'); expect(argsFrom(diagnostics[0])?.match, 'diagnostics[0].args.match').to.equal('var1'); + expect(diagnostics[1], 'diagnostics[1]').to.be.not.undefined; + expect(diagnostics[1].message, 'diagnostics[1].message').to.equal( + "Cannot find variable 'var2', did you mean 'var1'?" + ); + expect(diagnostics[1].code, 'diagnostics[1].message').to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(jsonpathFrom(diagnostics[1]), 'diagnostics[1].jsonpath').to.equal('pages[2].layout.variables[1].name'); + expect(argsFrom(diagnostics[1])?.name, 'diagnostics[1].args.name').to.equal('var2'); + expect(argsFrom(diagnostics[1])?.match, 'diagnostics[1].args.name').to.equal('var1'); + // now, add the 'var2' variable to variables.json variablesJson.var2 = { variableType: { diff --git a/extensions/analyticsdx-vscode-templates/test/vscode-integration/utils/completions.test.ts b/extensions/analyticsdx-vscode-templates/test/vscode-integration/utils/completions.test.ts index 59ec19d8..2f6f4e9d 100644 --- a/extensions/analyticsdx-vscode-templates/test/vscode-integration/utils/completions.test.ts +++ b/extensions/analyticsdx-vscode-templates/test/vscode-integration/utils/completions.test.ts @@ -14,6 +14,7 @@ import { newCompletionItem, newRelativeFilepathDelegate } from '../../../src/util/completions'; +import { locationMatches } from '../../../src/util/jsoncUtils'; import { closeAllEditors, findPositionByJsonPath, openTemplateInfo } from '../vscodeTestUtils'; const INVOKE_COMPLETION_CONTEXT: vscode.CompletionContext = { @@ -44,7 +45,8 @@ describe('JsonCompletionItemProvider', () => { const provider = new JsonCompletionItemProvider( // locations that support *.csv fies: newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: csvFileFilter }) ); @@ -97,7 +99,8 @@ describe('JsonCompletionItemProvider', () => { // make a provider with a filter that will match no files const provider = new JsonCompletionItemProvider( newRelativeFilepathDelegate({ - isSupportedLocation: l => !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(l.matches), + isSupportedLocation: l => + !l.isAtPropertyKey && TEMPLATE_INFO.csvRelFilePathLocationPatterns.some(p => locationMatches(l, p)), filter: () => false }) ); diff --git a/packages/analyticsdx-template-lint/src/constants.ts b/packages/analyticsdx-template-lint/src/constants.ts index f5a38697..11cc043f 100644 --- a/packages/analyticsdx-template-lint/src/constants.ts +++ b/packages/analyticsdx-template-lint/src/constants.ts @@ -189,6 +189,8 @@ export const ERRORS = Object.freeze({ LAYOUT_VALIDATION_PAGE_UNKNOWN_GROUP_TAG: 'lay-7', /** Multiple incldueUnmatched: true groups in a validation page. */ LAYOUT_VALIDATION_PAGE_MULTIPLE_INCLUDE_UNMATCHED: 'lay-8', + /** Validate page group that has no tags and no includeUnmatched true */ + LAYOUT_VALIDATION_PAGE_EMPTY_GROUP: 'lay-9', /** ApexCallback readiness definition but template has no apexCallback */ READINESS_NO_APEX_CALLBACK: 'read-1', diff --git a/packages/analyticsdx-template-lint/src/linter.ts b/packages/analyticsdx-template-lint/src/linter.ts index f110dfb9..7700cb79 100644 --- a/packages/analyticsdx-template-lint/src/linter.ts +++ b/packages/analyticsdx-template-lint/src/linter.ts @@ -60,33 +60,30 @@ function lengthJsonArrayAttributeValue(tree: JsonNode, ...pattern: JSONPath): [n return [nodes ? nodes.length : -1, node]; } -/** Find all of the panel items contained in the specified layoutDefinition file's pages' layouts. +/** Find all the names for all the variables referenced in the pages' layouts and the + * JsonNodes for the 'name' attribute. */ -function findAllItemsForLayoutDefinition(layoutJson: JsonNode): JsonNode[] { +function findAllVariableNamesForLayoutDefinition(layoutJson: JsonNode): Array<{ name: string; nameNode: JsonNode }> { return matchJsonNodesAtPattern(layoutJson, ['pages', '*', 'layout']).flatMap(layout => { const [type] = findJsonPrimitiveAttributeValue(layout, 'type'); if (type === 'SingleColumn') { - return matchJsonNodesAtPattern(layout, ['center', 'items', '*']); + return matchJsonNodesAtPattern(layout, ['center', 'items', '*']).flatMap(findAllVariableItemsForLayoutItem); } else if (type === 'TwoColumn') { - return matchJsonNodesAtPattern(layout, ['left', 'items', '*']).concat( - matchJsonNodesAtPattern(layout, ['right', 'items', '*']) - ); + return matchJsonNodesAtPattern(layout, ['left', 'items', '*']) + .concat(matchJsonNodesAtPattern(layout, ['right', 'items', '*'])) + .flatMap(findAllVariableItemsForLayoutItem); + } else if (type === 'Component') { + return matchJsonNodesAtPattern( + layout, + ['variables', '*', 'name'], + nameNode => typeof nameNode.value === 'string' && nameNode.value + ).map(nameNode => ({ name: nameNode.value, nameNode })); } return []; }); } -/** Find all the names for all the variable items in the pages' layouts and the - * JsonNodes for the 'name' attribute. - */ -function findAllVariableNamesForLayoutDefinition(layoutJson: JsonNode): Array<{ name: string; nameNode: JsonNode }> { - return findAllItemsForLayoutDefinition(layoutJson).reduce((items, item) => { - const variableItems = findAllVariableItemsForLayoutItem(item); - items.push(...variableItems); - return items; - }, [] as Array<{ name: string; nameNode: JsonNode }>); -} - +/** Find the `name` node and value for all the Variable layout items at or under the passed in layout item. */ function findAllVariableItemsForLayoutItem(item: JsonNode): Array<{ name: string; nameNode: JsonNode }> { const type = findJsonPrimitiveAttributeValue(item, 'type')[0]; if (type === 'Variable') { @@ -95,8 +92,8 @@ function findAllVariableItemsForLayoutItem(item: JsonNode): Array<{ name: string return [{ name, nameNode }]; } } else if (type === 'GroupBox') { - const [nodes, node] = findJsonArrayAttributeValue(item, 'items'); - const childrenVariableItems = nodes?.flatMap(n => findAllVariableItemsForLayoutItem(n)); + const [nodes] = findJsonArrayAttributeValue(item, 'items'); + const childrenVariableItems = nodes?.flatMap(findAllVariableItemsForLayoutItem); return childrenVariableItems ? childrenVariableItems : []; } return []; @@ -1282,6 +1279,17 @@ export abstract class TemplateLinter< if (includeUnmatched === true) { includeUnmatchedNodes.push(includeUnmatchedNode!); } + + // each group needs at least one tag or includeUnmatched, otherwise it won't ever match anything from + // the validation call + if (tagNodes.length <= 0 && includeUnmatched !== true) { + this.addDiagnostic( + doc, + 'No tags nor includeUnmatched true in group', + ERRORS.LAYOUT_VALIDATION_PAGE_EMPTY_GROUP, + group + ); + } } // warn if there's more than 1 true includeUnmatched in the page diff --git a/packages/analyticsdx-template-lint/src/schemas/layout-schema.json b/packages/analyticsdx-template-lint/src/schemas/layout-schema.json index df788f1e..b82bba62 100644 --- a/packages/analyticsdx-template-lint/src/schemas/layout-schema.json +++ b/packages/analyticsdx-template-lint/src/schemas/layout-schema.json @@ -176,6 +176,19 @@ } } }, + { + "label": "New Component page", + "body": { + "title": "${1:Page title}", + "type": "Configuration", + "layout": { + "type": "Component", + "module": "${2}", + "properties": {}, + "variables": [] + } + } + }, { "label": "New Validation page", "body": { @@ -534,7 +547,7 @@ ] }, "visibility": { - "description": "Controls if this item is visible. Value must be 'Disabled' (valid only for Variable items), 'Hidden', 'Visible', or a {{...}} expression against variables that evaluates to one of those or true (Visible) or false (Hidden).", + "description": "Controls if this item is visible. Value must be 'Disabled' (valid only for Variable items and tiles), 'Hidden', 'Visible', or a {{...}} expression against variables that evaluates to one of those or true (Visible) or false (Hidden).", "oneOf": [ { "type": "string", @@ -553,12 +566,15 @@ "type": "string", "default": "Visible", "enum": ["Disabled", "Hidden", "Visible"], - "enumDescriptions": ["The variable shows as disabled", "The item is hidden.", "The item is visible."] + "enumDescriptions": ["This item is disabled", "The item is hidden.", "The item is visible."] }, { "type": "string", "$comment": "json-schema does not support case-insensitive enums, so this simulates it, using a lower-case 1st char to avoid a double schema match.", "pattern": "^(d[Ii][Ss][Aa][Bb][Ll][Ee][Dd]|h[Ii][Dd][Dd][Ee][Nn]|v[Ii][Ss][Ii][Bb][Ll][Ee])$" + }, + { + "type": "null" } ] }, @@ -627,7 +643,8 @@ "type": ["string", "null"], "description": "Text to display as badge in the tile. This can contain {{...}} expressions. This can contain {{...}} expressions.", "defaultSnippets": [{ "label": "\"\"", "body": "${0}" }] - } + }, + "visibility": { "$ref": "#/definitions/visibility" } }, "defaultSnippets": [{ "label": "New tile", "body": { "label": "${0}" } }] }, @@ -769,24 +786,22 @@ "type": { "type": "string", "description": "Layout type", - "enum": ["SingleColumn", "TwoColumn"], + "enum": ["SingleColumn", "TwoColumn", "Component"], "enumDescriptions": [ "A page layout with a single panel of items.", - "A page layout with left and right panels of items." + "A page layout with left and right panels of items.", + "A page layout using a custom Lightning Web Component for display." ] }, "header": { "$ref": "#/definitions/header" }, - "center": { - "doNotSuggest": true - }, - "right": { - "doNotSuggest": true - }, - "left": { - "doNotSuggest": true - } + "center": { "doNotSuggest": true }, + "right": { "doNotSuggest": true }, + "left": { "doNotSuggest": true }, + "module": { "doNotSuggest": true }, + "properties": { "doNotSuggest": true }, + "variables": { "doNotSuggest": true } }, "anyOf": [ { @@ -823,7 +838,56 @@ { "properties": { "type": { - "not": { "enum": ["SingleColumn", "TwoColumn"] } + "const": "Component" + }, + "module": { + "type": "string", + "description": "The component module name.", + "pattern": "^.+/[^/]+$", + "patternErrorMessage": "Must be in the format of \"namespace/componentName\".", + "doNotSuggest": false + }, + "properties": { + "type": ["object", "null"], + "description": "Properties to set on the component. Each of these should correspond to an @api decorated property on the component. The values can include {{...}} expressions.", + "patternProperties": { + "^[^\\s]+$": {} + }, + "additionalProperties": false, + "doNotSuggest": false, + "defaultSnippets": [{ "label": "{}", "body": {} }] + }, + "variables": { + "type": ["array", "null"], + "description": "The variables to pass into this component. Only these variables will be available to the component, in the variables, variableValues, and variableVisibilities properties. Additionally, the variablevalueschanged event should only include updates to these variables.", + "items": { + "type": ["object"], + "additionalProperties": false, + "properties": { + "name": { + "description": "Variable name. Must match name of variable defined in variableDefinition file.", + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "visibility": { + "$ref": "#/definitions/visibility", + "description": "Controls if this variable is visible. Value must be 'Disabled', 'Hidden', 'Visible', or a {{...}} expression against variables that evaluates to one of those or true (Visible) or false (Hidden). This will be sent into the variableVisibilities property on the component." + } + }, + "required": ["name"], + "defaultSnippets": [ + { "label": "New Variable", "body": { "name": "${1}", "visibility": "${2:Visible}" } } + ] + }, + "doNotSuggest": false + } + }, + "required": ["type", "module"] + }, + { + "properties": { + "type": { + "not": { "enum": ["SingleColumn", "TwoColumn", "Component"] } } } } @@ -849,6 +913,15 @@ "items": [] } } + }, + { + "label": "New Component layout", + "body": { + "type": "Component", + "module": "${0}", + "properties": {}, + "variables": [] + } } ] }, diff --git a/packages/analyticsdx-template-lint/src/schemas/rules-schema.json b/packages/analyticsdx-template-lint/src/schemas/rules-schema.json index 4e455fbf..5b9e8a35 100644 --- a/packages/analyticsdx-template-lint/src/schemas/rules-schema.json +++ b/packages/analyticsdx-template-lint/src/schemas/rules-schema.json @@ -106,6 +106,11 @@ }, { "type": "null" + }, + { + "$comment": "This is valid, but don't show in autocomplete", + "type": "string", + "pattern": "^(datasetFileTemplate)$" } ] }, diff --git a/packages/analyticsdx-template-lint/src/utils.ts b/packages/analyticsdx-template-lint/src/utils.ts index 5a220488..2b89c287 100644 --- a/packages/analyticsdx-template-lint/src/utils.ts +++ b/packages/analyticsdx-template-lint/src/utils.ts @@ -245,14 +245,14 @@ export function fuzzySearcher( /** Create a function that will cache the result of the underlying function on the first call, and * return that result from there out. */ -export function caching(fn: (this: T, ...arg: A) => R): (this: T, ...arg: A) => R { +export function caching(fn: (...arg: A) => R): (...arg: A) => R { let result: R; let resultError: unknown | undefined; - let _fn: ((this: T, ...args: A) => R) | undefined = fn; - return function (this: T, ...args: A) { + let _fn: ((...args: A) => R) | undefined = fn; + return (...args: A) => { if (_fn !== undefined) { try { - result = _fn.apply(this, args); + result = _fn(...args); } catch (error) { resultError = error; } diff --git a/packages/analyticsdx-template-lint/test/unit/linter/layout.test.ts b/packages/analyticsdx-template-lint/test/unit/linter/layout.test.ts index 1092d997..cb2dceda 100644 --- a/packages/analyticsdx-template-lint/test/unit/linter/layout.test.ts +++ b/packages/analyticsdx-template-lint/test/unit/linter/layout.test.ts @@ -70,6 +70,15 @@ describe('TemplateLinter layout.json', () => { ] } } + }, + { + title: '', + type: 'Configuration', + layout: { + type: 'Component', + module: 'a/b', + variables: [{ name: 'foo' }, { name: 'bar', visibility: 'Hidden' }] + } } ] }) @@ -77,8 +86,10 @@ describe('TemplateLinter layout.json', () => { await linter.lint(); const diagnostics = getDiagnosticsForPath(linter.diagnostics, layoutPath) || []; - if (diagnostics.length !== 3) { - expect.fail('Expected 3 unknown variable errors, got' + stringifyDiagnostics(diagnostics)); + if (diagnostics.length !== 4) { + expect.fail( + `Expected 4 unknown variable errors, got ${diagnostics.length}: ` + stringifyDiagnostics(diagnostics) + ); } let diagnostic = diagnostics.find(d => d.jsonpath === 'pages[0].layout.center.items[1].name'); @@ -95,6 +106,11 @@ describe('TemplateLinter layout.json', () => { expect(diagnostic, 'bar variable error').to.not.be.undefined; expect(diagnostic!.code).to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); expect(diagnostic!.args).to.deep.equal({ name: 'bar', match: 'groupBar' }); + + diagnostic = diagnostics.find(d => d.jsonpath === 'pages[2].layout.variables[1].name'); + expect(diagnostic, 'lwc bar variable error').to.not.be.undefined; + expect(diagnostic!.code).to.equal(ERRORS.LAYOUT_PAGE_UNKNOWN_VARIABLE); + expect(diagnostic!.args).to.deep.equal({ name: 'bar', match: 'groupBar' }); }); it('validates variable types', async () => { @@ -332,8 +348,7 @@ describe('TemplateLinter layout.json', () => { title: 'valid tags', type: 'Validation', groups: [ - { text: '' }, - { text: '', tags: [] }, + { text: '', tags: [], includeUnmatched: true }, { text: '', tags: ['foo'] }, { text: '', tags: ['foo', 'bar'] } ] @@ -392,8 +407,15 @@ describe('TemplateLinter layout.json', () => { dir, { templateType: 'data', - layoutDefinition: 'layout.json' + layoutDefinition: 'layout.json', + readinessDefinition: 'readiness.json' }, + new StringDocument(path.join(dir, 'readiness.json'), { + templateRequirements: [ + { expression: '{{Variables.foo}}}', tags: ['foo'] }, + { expression: '{{Variables.bar}}}', tags: ['foo', 'bar', 'baz'] } + ] + }), new StringDocument(layoutPath, { pages: [ { @@ -401,9 +423,9 @@ describe('TemplateLinter layout.json', () => { type: 'Validation', groups: [ { text: '', includeUnmatched: true }, - { text: '', includeUnmatched: false }, - { text: '', includeUnmatched: null }, - { text: '' } + { text: '', tags: ['foo'], includeUnmatched: false }, + { text: '', tags: ['bar'], includeUnmatched: null }, + { text: '', tags: ['baz'] } ] }, { @@ -411,9 +433,9 @@ describe('TemplateLinter layout.json', () => { type: 'Validation', groups: [ { text: '', includeUnmatched: true }, - { text: '', includeUnmatched: false }, + { text: '', tags: ['foo'], includeUnmatched: false }, { text: '', includeUnmatched: true }, - { text: '' } + { text: '', tags: ['bar'] } ] } ] @@ -434,4 +456,42 @@ describe('TemplateLinter layout.json', () => { expect(diagnostic, 'group[2]').to.not.be.undefined; expect(diagnostic!.code, 'group[2] code').to.equal(ERRORS.LAYOUT_VALIDATION_PAGE_MULTIPLE_INCLUDE_UNMATCHED); }); + + it('validates empty validation group', async () => { + const dir = 'emptyValidationGroup'; + const layoutPath = path.join(dir, 'layout.json'); + linter = new TestLinter( + dir, + { + templateType: 'data', + layoutDefinition: 'layout.json', + readinessDefinition: 'readiness.json' + }, + new StringDocument(path.join(dir, 'readiness.json'), { + templateRequirements: [{ expression: '{{Variables.foo}}}', tags: ['foo'] }] + }), + new StringDocument(layoutPath, { + pages: [ + { + title: 'title', + type: 'Validation', + groups: [ + { text: 'has includeUnmatched', includeUnmatched: true }, + { text: 'has tags', tags: ['foo'] }, + { text: 'invalid' } + ] + } + ] + }) + ); + + await linter.lint(); + const diagnostics = getDiagnosticsForPath(linter.diagnostics, layoutPath) || []; + if (diagnostics.length !== 1) { + expect.fail('Expected 1 empty group error, got ' + stringifyDiagnostics(diagnostics)); + } + + expect(diagnostics[0].jsonpath, 'jsonpath').to.equal('pages[0].groups[2]'); + expect(diagnostics[0].code, 'code').to.equal(ERRORS.LAYOUT_VALIDATION_PAGE_EMPTY_GROUP); + }); }); diff --git a/packages/analyticsdx-template-lint/test/unit/schemas/invalidLayoutJsonTests.ts b/packages/analyticsdx-template-lint/test/unit/schemas/invalidLayoutJsonTests.ts index 209bb4a9..2ac0c4bb 100644 --- a/packages/analyticsdx-template-lint/test/unit/schemas/invalidLayoutJsonTests.ts +++ b/packages/analyticsdx-template-lint/test/unit/schemas/invalidLayoutJsonTests.ts @@ -27,6 +27,7 @@ describe('layout-schema.json finds errors in', () => { 'pages[0].layout.center.items[2].items[2].visibility', 'pages[0].layout.center.items[2].items[2].name', 'pages[0].layout.center.items[3].variant', + 'pages[0].layout.center.items[4].tiles.foo.visibility', 'pages[1].layout.type', 'pages[2].type', 'displayMessages[0].location', diff --git a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/invalid/invalid-enums.json b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/invalid/invalid-enums.json index 0d95b5e3..cf63e744 100644 --- a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/invalid/invalid-enums.json +++ b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/invalid/invalid-enums.json @@ -36,8 +36,18 @@ }, { "type": "Variable", - "name": "tiles", + "name": "foo", "variant": "badValue" + }, + { + "type": "Variable", + "name": "tiles", + "variant": "CheckboxTiles", + "tiles": { + "foo": { + "visibility": "badvalue" + } + } } ] } diff --git a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/all-fields.json b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/all-fields.json index 35eec378..c1209a97 100644 --- a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/all-fields.json +++ b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/all-fields.json @@ -50,7 +50,8 @@ "label": "tile label", "description": "tile description", "badge": "tile badge", - "iconName": "utility:food_and_drink" + "iconName": "utility:food_and_drink", + "visibility": "Visible" } } }, @@ -86,7 +87,8 @@ "badge": "badge", "description": "description", "iconName": "utilty:settings", - "label": "label" + "label": "label", + "visibility": "{{Variables.stringVariable == 'Yes' ? 'Visible' : 'Hidden'}}" } } }, @@ -238,6 +240,68 @@ } } }, + { + "title": "LWC With everything", + "type": "Configuration", + "backgroundImage": { + "name": "name", + "namespace": "ns" + }, + "condition": "{{true}}", + "helpUrl": "https://www.salesforce.com", + "navigation": { + "label": "label" + }, + "guidancePanel": { + "title": "title", + "backgroundImage": { + "name": "name" + }, + "items": [ + { + "type": "Text", + "text": "text" + } + ] + }, + "layout": { + "type": "Component", + "module": "a/b", + "properties": { + "stringProp": "Some string {{Variables.foo}}", + "numberProp": 42.0, + "booleanProp": true, + "nullProp": null, + "objectProp": { + "a": "b", + "c": -1, + "d": [1, false, {}] + }, + "arrayProp": [1, true, [], {}] + }, + "variables": [ + { + "name": "someVar", + "visibility": "Visible" + }, + { + "name": "someOtherVar", + "visibility": "{{Variables.booleanVariable ? 'Visible' : 'Disabled'}}" + }, + { + "name": "anotherVar" + } + ] + } + }, + { + "title": "Minimal LWC", + "type": "Configuration", + "layout": { + "type": "Component", + "module": "e/f" + } + }, { "title": "Validation Page", "type": "Validation", diff --git a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/nulls.json b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/nulls.json index aabb4e77..fbcd30d6 100644 --- a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/nulls.json +++ b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/layout/valid/nulls.json @@ -42,7 +42,8 @@ "badge": null, "description": null, "iconName": null, - "label": null + "label": null, + "visibility": null } } }, @@ -63,7 +64,8 @@ "badge": null, "description": null, "iconName": null, - "label": null + "label": null, + "visibility": null } } } @@ -74,6 +76,19 @@ } } }, + { + "title": "LWC", + "type": "Configuration", + "backgroundImage": null, + "condition": null, + "helpUrl": null, + "layout": { + "type": "Component", + "module": "foo/bar", + "properties": null, + "variables": null + } + }, { "title": "Validation page1", "type": "Validation", diff --git a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/rules/valid/all-fields.json b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/rules/valid/all-fields.json index bdc46195..87723f76 100644 --- a/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/rules/valid/all-fields.json +++ b/packages/analyticsdx-template-lint/test/unit/schemas/testfiles/rules/valid/all-fields.json @@ -90,6 +90,9 @@ }, { "type": "xmd" + }, + { + "type": "datasetFileTemplate" } ], "actions": [ diff --git a/test-assets/sfdx-simple/force-app/main/default/waveTemplates/BadVariables/layout.json b/test-assets/sfdx-simple/force-app/main/default/waveTemplates/BadVariables/layout.json index 172a8a6e..d15ea24c 100644 --- a/test-assets/sfdx-simple/force-app/main/default/waveTemplates/BadVariables/layout.json +++ b/test-assets/sfdx-simple/force-app/main/default/waveTemplates/BadVariables/layout.json @@ -25,6 +25,16 @@ ] } } + }, + { + "title": "LWC page (for testing the hover/completions/goto works", + "type": "Configuration", + "layout": { + "type": "Component", + "module": "a/b", + "properties": {}, + "variables": [{ "name": "StringTypeVar" }] + } } ] } diff --git a/test-assets/sfdx-simple/force-app/main/default/waveTemplates/allRelpaths/layout.json b/test-assets/sfdx-simple/force-app/main/default/waveTemplates/allRelpaths/layout.json index 4aa0ac88..867d0164 100644 --- a/test-assets/sfdx-simple/force-app/main/default/waveTemplates/allRelpaths/layout.json +++ b/test-assets/sfdx-simple/force-app/main/default/waveTemplates/allRelpaths/layout.json @@ -212,7 +212,8 @@ "groups": [ { "text": "group", - "tags": [] + "tags": ["Tag1"], + "includeUnmatched": true } ] }