Skip to content

Commit

Permalink
Org Validation layout page support (#165)
Browse files Browse the repository at this point in the history
* Adding org validation page to schema

* Add lint check on group tags and includeUnmatched

* Add editor support for quick fix on invalid group tag

* Add code completions on validate page group tags

* Cleanup readiness schema a bit
  • Loading branch information
smithgp authored Nov 7, 2023
1 parent 78f77e9 commit 4bf2db3
Show file tree
Hide file tree
Showing 24 changed files with 785 additions and 131 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 { matchJsonNodeAtPattern } from '@salesforce/analyticsdx-template-lint';
import { matchJsonNodeAtPattern, matchJsonNodesAtPattern } from '@salesforce/analyticsdx-template-lint';
import { Location, parseTree } from 'jsonc-parser';
import * as vscode from 'vscode';
import { codeCompletionUsedTelemetryCommand } from '../telemetry';
Expand All @@ -16,6 +16,50 @@ import { isValidRelpath } from '../util/utils';
import { VariableRefCompletionItemProviderDelegate } from '../variables';
import { getLayoutItemVariableName, isInTilesEnumKey, matchesLayoutItem } from './utils';

/** Get tags from the readiness file's templateRequirements. */
export class LayoutValidationPageTagCompletionItemProviderDelegate implements JsonCompletionItemProviderDelegate {
constructor(private readonly templateEditing: TemplateDirEditing) {}

public isSupportedDocument(document: vscode.TextDocument): boolean {
return (
// make sure the template has a readinessDefinition file
isValidRelpath(this.templateEditing.readinessDefinitionPath) &&
// and that we're in the layoutDefinition file of the template
this.templateEditing.isLayoutDefinitionFile(document.uri)
);
}

public isSupportedLocation(location: Location): boolean {
// Note: we should be checking that it's a validation page (and not a Configuration page, e.g.), but we don't
// 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', '*']);
}

public async getItems(range: vscode.Range | undefined, location: Location, document: vscode.TextDocument) {
const varUri = vscode.Uri.joinPath(this.templateEditing.dir, this.templateEditing.readinessDefinitionPath!);
const doc = await vscode.workspace.openTextDocument(varUri);
const tree = parseTree(doc.getText());
const items: vscode.CompletionItem[] = [];
if (tree?.type === 'object') {
const tags = new Set(
matchJsonNodesAtPattern(
tree,
['templateRequirements', '*', 'tags', '*'],
tagNode => typeof tagNode.value === 'string'
).map(tagNode => tagNode.value as string)
);
tags.forEach(tag => {
const item = newCompletionItem(tag, range, vscode.CompletionItemKind.EnumMember);
item.command = codeCompletionUsedTelemetryCommand(item.label, 'tag', location.path, document.uri);
items.push(item);
});
}
return items;
}
}

/** Get variable names for the variable name in the pages in ui.json. */
export class LayoutVariableCompletionItemProviderDelegate extends VariableRefCompletionItemProviderDelegate {
constructor(templateEditing: TemplateDirEditing) {
Expand Down
11 changes: 11 additions & 0 deletions extensions/analyticsdx-vscode-templates/src/templateEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from './constants';
import { ERRORS } from './constants';
import {
LayoutValidationPageTagCompletionItemProviderDelegate,
LayoutVariableCodeActionProvider,
LayoutVariableCompletionItemProviderDelegate,
LayoutVariableDefinitionProvider,
Expand Down Expand Up @@ -367,6 +368,8 @@ export class TemplateDirEditing extends Disposable {
new JsonCompletionItemProvider(
// hookup code-completion for variables names in page in ui.json's
new UiVariableCompletionItemProviderDelegate(this),
// hoopkup code-completion for validation page group tags in layout.json's
new LayoutValidationPageTagCompletionItemProviderDelegate(this),
// hookup code-completion for variables names in page in layout.json's
new LayoutVariableCompletionItemProviderDelegate(this),
// hookup completions for tile names in variable items in layout.json's
Expand All @@ -390,6 +393,14 @@ export class TemplateDirEditing extends Disposable {
}
),

vscode.languages.registerCodeActionsProvider(
relatedFileSelector,
new FuzzyMatchCodeActionProvider(ERRORS.LAYOUT_VALIDATION_PAGE_UNKNOWN_GROUP_TAG),
{
providedCodeActionKinds: FuzzyMatchCodeActionProvider.providedCodeActionKinds
}
),

// hookup quick fixes for variable names in ui.json's
vscode.languages.registerCodeActionsProvider(relatedFileSelector, new UiVariableCodeActionProvider(this), {
providedCodeActionKinds: UiVariableCodeActionProvider.providedCodeActionKinds
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe('layout-schema.json hookup', () => {
pages: [
{
title: '',
type: 'Configuration',
layout: {
type: 'SingleColumn',
center: {
Expand Down Expand Up @@ -109,6 +110,7 @@ describe('layout-schema.json hookup', () => {
pages: [
{
title: '',
type: 'Configuration',
layout: {
type: 'SingleColumn',
center: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { findNodeAtLocation, JSONPath, Node as JsonNode, parseTree } from 'jsonc
import * as vscode from 'vscode';
import { ERRORS } from '../../../src/constants';
import { jsonpathFrom, scanLinesUntil, uriDirname, uriStat } from '../../../src/util/vscodeUtils';
import { waitFor } from '../../testutils';
import { jsoncParse, waitFor } from '../../testutils';
import {
closeAllEditors,
compareCompletionItems,
Expand Down Expand Up @@ -133,7 +133,7 @@ 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');
await verifyCompletionsContain(doc, position, 'New SingleColumn page', 'New TwoColumn page', 'New Validation page');

// go to just before the { in "layout"
node = findNodeAtLocation(tree!, ['pages', 0, 'layout']);
Expand Down Expand Up @@ -582,6 +582,7 @@ describe('TemplateEditorManager configures layoutDefinition', () => {
pages: [
{
title: 'Test Title',
type: 'Configuration',
layout: {
type: 'SingleColumn',
center: {
Expand Down Expand Up @@ -746,6 +747,7 @@ describe('TemplateEditorManager configures layoutDefinition', () => {
pages: [
{
title: 'Test Title',
type: 'Configuration',
layout: {
type: 'SingleColumn',
center: {
Expand Down Expand Up @@ -864,6 +866,7 @@ describe('TemplateEditorManager configures layoutDefinition', () => {
pages: [
{
title: 'Test Title',
type: 'Configuration',
navigation: {
label: 'Test Label'
},
Expand Down Expand Up @@ -926,7 +929,94 @@ describe('TemplateEditorManager configures layoutDefinition', () => {
// and the tile keys should have been updated
const layoutTree = parseTree(layoutEditor.document.getText());
expect(layoutTree, 'layout.json').to.not.be.undefined;
console.log(findNodeAtLocation(layoutTree!, ['pages', 0, 'navigation']));
expect(findNodeAtLocation(layoutTree!, ['pages', 0, 'navigation']), 'navigation node').to.be.undefined;
});

it('quick fixes on unrecongized validation page group tag', async () => {
const [t, [layoutEditor]] = await createTemplateWithRelatedFiles(
{
field: 'layoutDefinition',
path: 'layout.json',
initialJson: {
pages: [
{
title: 'validation',
type: 'Validation',
groups: [{ text: '', tags: ['bar', 'fo'] }]
}
]
}
},
{
field: 'readinessDefinition',
path: 'readiness.json',
initialJson: {
templateRequirements: [
{
expression: '{{Variables.foo}}',
tags: ['foo']
}
]
}
}
);
tmpdir = t;
// should have 2 diagnostics about the tags
const diagnosticFilter = (d: vscode.Diagnostic) => d.code === ERRORS.LAYOUT_VALIDATION_PAGE_UNKNOWN_GROUP_TAG;
let diagnostics = (
await waitForDiagnostics(
layoutEditor.document.uri,
ds => ds && ds.filter(diagnosticFilter).length === 2,
'Initial 2 warnings on layout.json'
)
)
.filter(diagnosticFilter)
.sort(sortDiagnostics);

expect(jsonpathFrom(diagnostics[0]), 'diagnostics[0] jsonpath').to.equal('pages[0].groups[0].tags[0]');
expect(jsonpathFrom(diagnostics[1]), 'diagnostics[1] jsonpath').to.equal('pages[0].groups[0].tags[1]');

// the 1st tag warning should not have any quick fixes
let actions = await getCodeActions(layoutEditor.document.uri, diagnostics[0].range);
if (actions.length !== 0) {
expect.fail("Expected no code actions on 'bar', got [" + actions.map(a => a.title).join(', ') + ']');
}
// the 2nd tag warning should have a quick fix
actions = await getCodeActions(layoutEditor.document.uri, diagnostics[1].range);
if (actions.length !== 1) {
expect.fail("Expected 1 code actions on 'fo', got [" + actions.map(a => a.title).join(', ') + ']');
}
expect(actions[0].title, 'quick fix action title').to.equal("Switch to 'foo'");
expect(actions[0].edit, 'quick fix action edit').to.not.be.undefined;
// run the action
if (!(await vscode.workspace.applyEdit(actions[0].edit!))) {
expect.fail(`Quick fix '${actions[0].title}' failed`);
}

// that should make that diagnostic go away
diagnostics = (
await waitForDiagnostics(
layoutEditor.document.uri,
ds => ds && ds.filter(diagnosticFilter).length === 1,
'Only 1 warning on layout.json'
)
).filter(diagnosticFilter);
// and the tag should be fixed up
const layoutJson = jsoncParse(layoutEditor.document.getText());
expect(layoutJson.pages[0].groups[0].tags[1], 'fixed tag').to.equal('foo');
});

it('code completions on validation page group tags', async () => {
const uri = uriFromTestRoot(waveTemplatesUriPath, 'allRelpaths', 'layout.json');
const [doc] = await openFile(uri, true);
await waitForDiagnostics(uri, d => d && d.length >= 1);
await waitForTemplateEditorManagerHas(await getTemplateEditorManager(), uriDirname(uri), true);

const position = findPositionByJsonPath(doc, ['pages', 3, 'groups', 0, 'tags']);
expect(position, 'pages[3].groups[0].tags').to.not.be.undefined;
const completions = await verifyCompletionsContain(doc, position!.translate(0, 1), '"Tag1"', '"Tag2"');
if (completions.length !== 2) {
expect.fail('Expected 2 completions, got: ' + completions.map(i => i.label).join(', '));
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('TemplateLinterManager lints layout.json', () => {
pages: [
{
title: 'Page1',
type: 'Configuration',
layout: {
type: 'TwoColumn',
left: {
Expand All @@ -63,6 +64,7 @@ describe('TemplateLinterManager lints layout.json', () => {
},
{
title: 'Page2',
type: 'Configuration',
layout: {
type: 'SingleColumn',
center: {
Expand Down Expand Up @@ -204,6 +206,7 @@ describe('TemplateLinterManager lints layout.json', () => {
pages: [
{
title: 'Page1',
type: 'Configuration',
layout: {
type: 'SingleColumn',
right: {
Expand Down
4 changes: 4 additions & 0 deletions packages/analyticsdx-template-lint/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ export const ERRORS = Object.freeze({
LAYOUT_INVALID_TILE_NAME: 'lay-5',
/** Unsupported variable type in layout page */
LAYOUT_PAGE_UNNECESSARY_NAVIGATION_OBJECT: 'lay-6',
/** Validation page group tag doesn't match a readiness templateRequirement tag. */
LAYOUT_VALIDATION_PAGE_UNKNOWN_GROUP_TAG: 'lay-7',
/** Multiple incldueUnmatched: true groups in a validation page. */
LAYOUT_VALIDATION_PAGE_MULTIPLE_INCLUDE_UNMATCHED: 'lay-8',

/** ApexCallback readiness definition but template has no apexCallback */
READINESS_NO_APEX_CALLBACK: 'read-1',
Expand Down
80 changes: 77 additions & 3 deletions packages/analyticsdx-template-lint/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { findNodeAtLocation, JSONPath, Node as JsonNode, parseTree } from 'jsonc-parser';
import { ERRORS, LINTER_MAX_EXTERNAL_FILE_SIZE, TEMPLATE_INFO } from './constants';
import {
caching,
fuzzySearcher,
isValidRelpath,
isValidVariableName,
Expand Down Expand Up @@ -1132,10 +1133,12 @@ export abstract class TemplateLinter<
private async lintLayout(templateInfo: JsonNode) {
const { doc, json: layout } = await this.loadTemplateRelPathJson(templateInfo, ['layoutDefinition']);
if (doc && layout) {
await Promise.all([
const promises = Promise.all([
this.lintLayoutCheckVariables(templateInfo, doc, layout),
this.lintLayoutCheckNavigationObjects(doc, layout)
this.lintLayoutValidationPages(templateInfo, doc, layout)
]);
this.lintLayoutCheckNavigationObjects(doc, layout);
return promises;
}
}

Expand Down Expand Up @@ -1233,7 +1236,78 @@ export abstract class TemplateLinter<
}
}

private async lintLayoutCheckNavigationObjects(doc: Document, layoutJson: JsonNode): Promise<void> {
private async lintLayoutValidationPages(templateInfo: JsonNode, doc: Document, layoutJson: JsonNode) {
// function to read all the templateRequirement tags from the readiness file
const readinessTags = caching(async () => {
const { json: readinessJson } = await this.loadTemplateRelPathJson(templateInfo, ['readinessDefinition']);
return new Set(
matchJsonNodesAtPattern(
readinessJson,
['templateRequirements', '*', 'tags', '*'],
tag => typeof tag.value === 'string'
).map(tag => tag.value as string)
);
});
const fuzzyMatcher = caching((tags: Set<string>) => fuzzySearcher(tags));

// loop through each Validation page
const pages = matchJsonNodesAtPattern(
layoutJson,
['pages', '*'],
page => findJsonPrimitiveAttributeValue(page, 'type')[0] === 'Validation'
);
for (const page of pages) {
const includeUnmatchedNodes = [] as JsonNode[];
// go through this page's groups
for (const group of matchJsonNodesAtPattern(page, ['groups', '*'])) {
// make sure any tags have corresponding entries in the readiness file
const tagNodes = matchJsonNodesAtPattern(group, ['tags', '*'], tagNode => typeof tagNode.value === 'string');
for (const tagNode of tagNodes) {
const tags = await readinessTags();
const tag = tagNode.value as string;
if (!tags.has(tag)) {
const args: Record<string, any> = { name: tag };
let mesg = `Tag '${tag}' not found in readiness definition`;
const [match] = fuzzyMatcher(tags)(tag);
if (match) {
mesg += `, did you mean '${match}'?`;
args.match = match;
}
this.addDiagnostic(doc, mesg, ERRORS.LAYOUT_VALIDATION_PAGE_UNKNOWN_GROUP_TAG, tagNode, { args });
}
}

// keep track of any true includeUnmatched's
const [includeUnmatched, includeUnmatchedNode] = findJsonPrimitiveAttributeValue(group, 'includeUnmatched');
if (includeUnmatched === true) {
includeUnmatchedNodes.push(includeUnmatchedNode!);
}
}

// warn if there's more than 1 true includeUnmatched in the page
if (includeUnmatchedNodes.length > 1) {
includeUnmatchedNodes.forEach(includeUnmatchedNode =>
this.addDiagnostic(
doc,
'Multiple groups found with includeUnmatched true',
ERRORS.LAYOUT_VALIDATION_PAGE_MULTIPLE_INCLUDE_UNMATCHED,
includeUnmatchedNode,
{
relatedInformation: includeUnmatchedNodes
.filter(other => other !== includeUnmatchedNode)
.map(other => ({
doc,
mesg: 'Other includeUnmatched',
node: other
}))
}
)
);
}
}
}

private lintLayoutCheckNavigationObjects(doc: Document, layoutJson: JsonNode) {
// Check to see if there is a navigationPanel object
const navigationPanelNode = matchJsonNodeAtPattern(layoutJson, ['navigationPanel']);
if (!navigationPanelNode) {
Expand Down
Loading

0 comments on commit 4bf2db3

Please sign in to comment.