diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 8d72c6e..6c8dcf2 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -4,13 +4,10 @@ import { getRelatedTsFiles, loadClassInfosFromFiles, CollectedClassInfo, - createClassInfoLookup, resolveViewBinding, ClassPropertyInfo, - filterScreenLikeClasses, collectActionProperties, extractConfigPropertyNames, - filterClassesBySource, } from '../utils'; import { parseDocumentDom, @@ -18,6 +15,7 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + isActionStateBindTag, } from './html-shared'; import { ClientControlMetadata, @@ -27,6 +25,16 @@ import { getScreenTemplates } from '../services/screen-template-service'; import { getIncludeMetadata, IncludeMetadata } from '../services/include-service'; import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; import { loadBackendFieldsForView } from './html-backend-utils'; +import { getBaseScreenDocument } from '../services/screen-html-service'; +import { + HtmlIncludeFieldContext, + createHtmlFieldMetadataContext, + getFieldPropertiesFromViews, + getIncludeFieldContext, + getParentOrSelectorViewName, + getViewNameFromCustomizationSelectors, + resolveTemplateValue, +} from '../services/html-field-context-service'; // Registers completions so HTML view bindings stay in sync with PX metadata. export function registerHtmlCompletionProvider(context: vscode.ExtensionContext) { @@ -103,13 +111,8 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return undefined; } - const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths); - if (!relevantClassInfos.length) { - return undefined; - } - - const classInfoLookup = createClassInfoLookup(classInfos); - const screenClasses = filterScreenLikeClasses(relevantClassInfos); + const metadataContext = createHtmlFieldMetadataContext(classInfos, tsFilePaths); + const { classInfoLookup, screenClasses } = metadataContext; // Completions are sourced from the same metadata as validation/definitions to keep behavior consistent. if (attributeContext.attributeName === 'view.bind') { @@ -121,7 +124,9 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { } if (attributeContext.attributeName === 'state.bind') { - return this.createActionCompletions(screenClasses); + return isActionStateBindTag(attributeContext.tagName) + ? this.createActionCompletions(screenClasses, classInfoLookup, elementNode, attributeContext.value) + : this.createControlStateCompletions(attributeContext.value, screenClasses, classInfoLookup); } if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') { @@ -130,7 +135,35 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field') { // Field completions are scoped to the PXView resolved from the surrounding markup. - const viewName = findParentViewName(elementNode); + const includeContext = getIncludeFieldContext({ + documentPath: document.uri.fsPath, + elementNode, + hostTsFilePaths: tsFilePaths, + workspaceRoots: vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath), + }); + if (includeContext) { + const hostSelectorViewName = resolveTemplateValue( + getViewNameFromCustomizationSelectors(elementNode, includeContext.templateDocument), + includeContext.parameterValues + ); + const hostSelectorResolution = hostSelectorViewName + ? resolveViewBinding(hostSelectorViewName, screenClasses, classInfoLookup) + : undefined; + if (hostSelectorResolution?.viewClass) { + const backendFields = await loadBackendFieldsForView(hostSelectorViewName!, screenClasses); + return this.createFieldCompletions(hostSelectorResolution.viewClass.properties, backendFields); + } + + const items = this.createIncludeFieldNameCompletions(elementNode, includeContext); + if (items?.length) { + return items; + } + } + + const viewName = getParentOrSelectorViewName( + elementNode, + getBaseScreenDocument(document.uri.fsPath) + ); if (!viewName) { return undefined; } @@ -366,6 +399,22 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items.length ? items : undefined; } + private createIncludeFieldNameCompletions( + elementNode: any, + context: HtmlIncludeFieldContext + ): vscode.CompletionItem[] | undefined { + const selectorViewName = getViewNameFromCustomizationSelectors(elementNode, context.templateDocument); + const viewName = resolveTemplateValue(selectorViewName, context.parameterValues); + if (viewName) { + const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup); + const viewClass = resolution?.viewClass; + return viewClass ? this.createFieldCompletions(viewClass.properties) : undefined; + } + + const fields = getFieldPropertiesFromViews(context); + return fields.size ? this.createFieldCompletions(fields) : undefined; + } + private isInsideDataFeed(node: any): boolean { let current = node?.parent ?? node?.parentNode; while (current) { @@ -590,14 +639,58 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items; } - private createActionCompletions(screenClasses: CollectedClassInfo[]): vscode.CompletionItem[] { + private createActionCompletions( + screenClasses: CollectedClassInfo[], + classInfoLookup: Map, + elementNode: any, + currentValue?: string + ): vscode.CompletionItem[] { + const normalizedPrefix = (currentValue ?? '').trim().toLowerCase(); const actionMap = collectActionProperties(screenClasses); const items: vscode.CompletionItem[] = []; - actionMap.forEach((property, name) => { + const seen = new Set(); + const addAction = (name: string, property: ClassPropertyInfo) => { + if (seen.has(name)) { + return; + } + if (normalizedPrefix && !name.toLowerCase().startsWith(normalizedPrefix)) { + return; + } + seen.add(name); const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function); item.detail = property.typeName ?? 'PXActionState'; items.push(item); + }; + + actionMap.forEach((property, name) => { + addAction(name, property); }); + + const viewName = findParentViewName(elementNode); + const viewClass = viewName + ? resolveViewBinding(viewName, screenClasses, classInfoLookup)?.viewClass + : undefined; + viewClass?.properties.forEach((property, name) => { + if (property.kind === 'action') { + addAction(name, property); + } + }); + + for (const screenClass of screenClasses) { + for (const [propertyName, property] of screenClass.properties) { + if (property.kind !== 'view' && property.kind !== 'viewCollection') { + continue; + } + + const viewClass = resolveViewBinding(propertyName, screenClasses, classInfoLookup)?.viewClass; + viewClass?.properties.forEach((viewProperty, actionName) => { + if (viewProperty.kind === 'action') { + addAction(`${propertyName}.${actionName}`, viewProperty); + } + }); + } + } + return items; } diff --git a/acumate-plugin/src/providers/html-definition-provider.ts b/acumate-plugin/src/providers/html-definition-provider.ts index 7543de6..9dfe9b8 100644 --- a/acumate-plugin/src/providers/html-definition-provider.ts +++ b/acumate-plugin/src/providers/html-definition-provider.ts @@ -6,11 +6,8 @@ import { loadClassInfosFromFiles, CollectedClassInfo, ClassPropertyInfo, - createClassInfoLookup, resolveViewBinding, - filterScreenLikeClasses, collectActionProperties, - filterClassesBySource, getLineAndColumnFromIndex, } from '../utils'; import { @@ -19,6 +16,7 @@ import { elevateToElementNode, getAttributeContext, findParentViewName, + isActionStateBindTag, } from './html-shared'; import { resolveIncludeFilePath } from '../services/include-service'; import { @@ -26,8 +24,20 @@ import { isCustomizationSelectorAttribute, queryBaseScreenElements, BaseScreenDocument, - getCustomizationSelectorAttributes, + getDocumentForNode, } from '../services/screen-html-service'; +import { + HtmlFieldMetadataContext, + HtmlIncludeFieldContext, + createHtmlFieldMetadataContext, + findFieldsInAnyView, + getIncludeFieldContext, + parseFieldReference, + resolveHtmlField, +} from '../services/html-field-context-service'; + +type DefinitionMetadataContext = HtmlFieldMetadataContext; +type IncludeDefinitionContext = HtmlIncludeFieldContext; // Hooks VS Code so view/field bindings support "Go to Definition" directly from HTML. export function registerHtmlDefinitionProvider(context: vscode.ExtensionContext) { @@ -64,9 +74,16 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } const baseScreenDocument = getBaseScreenDocument(document.uri.fsPath); + const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); + const hostTsFilePaths = getRelatedTsFiles(document.uri.fsPath); + const includeContext = getIncludeFieldContext({ + documentPath: document.uri.fsPath, + elementNode, + hostTsFilePaths, + workspaceRoots, + }); if (attributeContext.attributeName === 'url' && attributeContext.tagName === 'qp-include') { - const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); const includePath = resolveIncludeFilePath(attributeContext.value, document.uri.fsPath, workspaceRoots); if (!includePath) { return; @@ -75,40 +92,45 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } if (isCustomizationSelectorAttribute(attributeContext.attributeName)) { - if (!baseScreenDocument) { - return; - } - const selector = attributeContext.value?.trim(); if (!selector) { return; } - const { nodes, error } = queryBaseScreenElements(baseScreenDocument, selector); - if (error || !nodes.length) { - return; + const includeLocations = getSelectorLocations(includeContext?.templateDocument, selector); + if (includeLocations.length) { + return includeLocations; } - const locations: vscode.Location[] = []; - const seen = new Set(); - for (const nodeCandidate of nodes) { - const startIndex = typeof nodeCandidate.startIndex === 'number' ? nodeCandidate.startIndex : undefined; - if (startIndex === undefined || seen.has(startIndex)) { - continue; - } - seen.add(startIndex); - const location = createLocationFromHtmlNode(baseScreenDocument, nodeCandidate); - if (location) { - locations.push(location); - } + const baseLocations = getSelectorLocations(baseScreenDocument, selector); + if (baseLocations.length) { + return baseLocations; } + } - if (locations.length) { - return locations; + if ( + attributeContext.attributeName === 'name' && + attributeContext.tagName === 'field' && + hasUnboundAttribute(elementNode) + ) { + return; + } + + if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field' && includeContext) { + const includeLocations = getFieldDefinitionLocations( + attributeContext.value, + elementNode, + includeContext, + includeContext.templateDocument, + includeContext.parameterValues, + true + ); + if (includeLocations.length) { + return includeLocations; } } - const tsFilePaths = getRelatedTsFiles(document.uri.fsPath); + const tsFilePaths = hostTsFilePaths; if (!tsFilePaths.length) { return; } @@ -118,13 +140,8 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { return; } - const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths); - if (!relevantClassInfos.length) { - return; - } - - const classInfoLookup = createClassInfoLookup(classInfos); - const screenClasses = filterScreenLikeClasses(relevantClassInfos); + const documentMetadataContext = createHtmlFieldMetadataContext(classInfos, tsFilePaths); + const { classInfoLookup, screenClasses } = documentMetadataContext; // Resolved metadata lets us jump from HTML bindings directly to the backing TypeScript symbol. if (attributeContext.attributeName === 'view.bind' || (attributeContext.attributeName === 'view' && attributeContext.tagName === 'using')) { @@ -162,11 +179,29 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { } if (attributeContext.attributeName === 'state.bind') { - const actionProperty = findActionProperty(attributeContext.value, screenClasses); - if (!actionProperty) { - return; + if (isActionStateBindTag(attributeContext.tagName)) { + const actionProperty = + findQualifiedViewActionProperty(attributeContext.value, documentMetadataContext) ?? + findActionProperty(attributeContext.value, screenClasses) ?? + findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext); + if (!actionProperty) { + return; + } + return createLocationFromProperty(actionProperty); + } + + const locations = getFieldDefinitionLocations( + attributeContext.value, + elementNode, + documentMetadataContext, + includeContext?.templateDocument ?? baseScreenDocument, + includeContext?.parameterValues, + false, + false + ); + if (locations.length) { + return locations; } - return createLocationFromProperty(actionProperty); } if (attributeContext.attributeName === 'control-state.bind' && attributeContext.tagName === 'qp-field') { @@ -184,30 +219,97 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider { if (attributeContext.attributeName === 'name' && attributeContext.tagName === 'field') { // Field names dereference through the closest parent view to locate the property in TS. - let viewName = findParentViewName(elementNode); - if (!viewName) { - viewName = getViewNameFromCustomizationSelectors(attributeContext.node, baseScreenDocument); - } - if (!viewName) { - return; + const locations = getFieldDefinitionLocations( + attributeContext.value, + elementNode, + documentMetadataContext, + includeContext?.templateDocument ?? baseScreenDocument, + includeContext?.parameterValues + ); + if (locations.length) { + return locations; } + } - const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); - const viewClass = resolution?.viewClass; - if (!viewClass) { - return; - } + return undefined; + } +} - const fieldProperty = viewClass.properties.get(attributeContext.value); - if (!fieldProperty || fieldProperty.kind !== 'field') { - return; - } +function hasUnboundAttribute(elementNode: any): boolean { + return Boolean(elementNode?.attribs && Object.prototype.hasOwnProperty.call(elementNode.attribs, 'unbound')); +} - return createLocationFromProperty(fieldProperty); +function getSelectorLocations( + document: BaseScreenDocument | undefined, + selector: string +): vscode.Location[] { + if (!document) { + return []; + } + + const { nodes, matches, error } = queryBaseScreenElements(document, selector); + if (error || !nodes.length) { + return []; + } + + const locations: vscode.Location[] = []; + const seen = new Set(); + const selectorMatches = matches.length + ? matches + : nodes.map(node => ({ node, document: getDocumentForNode(document, node) })); + for (const { node: nodeCandidate, document: sourceDocument } of selectorMatches) { + const startIndex = typeof nodeCandidate.startIndex === 'number' ? nodeCandidate.startIndex : undefined; + const key = startIndex === undefined ? undefined : `${sourceDocument.filePath}:${startIndex}`; + if (key === undefined || seen.has(key)) { + continue; + } + seen.add(key); + const location = createLocationFromHtmlNode(sourceDocument, nodeCandidate); + if (location) { + locations.push(location); } + } - return undefined; + return locations; +} + +function getFieldDefinitionLocations( + rawFieldName: string | undefined, + elementNode: any, + metadataContext: DefinitionMetadataContext, + selectorDocument?: BaseScreenDocument, + parameterValues?: Map, + allowAnyViewFallback = false, + useParentView = true +): vscode.Location[] { + if (!rawFieldName) { + return []; + } + + const parsed = parseFieldReference(rawFieldName); + const resolution = resolveHtmlField({ + fieldReference: parsed, + elementNode, + metadataContext, + selectorDocument, + parameterValues, + allowAnyViewFallback, + useParentView, + }); + if (!resolution || resolution.hasTemplatedBinding) { + return []; + } + + if (resolution.fieldProperty?.kind === 'field' && !resolution.usedAnyViewFallback) { + return [createLocationFromProperty(resolution.fieldProperty)]; + } + + if (!allowAnyViewFallback) { + return []; } + + return findFieldsInAnyView(resolution.fieldName, metadataContext) + .map(match => createLocationFromProperty(match.fieldProperty)); } function findActionProperty(actionName: string | undefined, screenClasses: CollectedClassInfo[]): ClassPropertyInfo | undefined { @@ -218,6 +320,60 @@ function findActionProperty(actionName: string | undefined, screenClasses: Colle return actions.get(actionName); } +function findViewActionProperty( + actionName: string | undefined, + elementNode: any, + metadataContext: DefinitionMetadataContext +): ClassPropertyInfo | undefined { + if (!actionName) { + return undefined; + } + + const viewName = findParentViewName(elementNode); + const viewClass = viewName + ? resolveViewBinding(viewName, metadataContext.screenClasses, metadataContext.classInfoLookup)?.viewClass + : undefined; + const actionProperty = viewClass?.properties.get(actionName); + return actionProperty?.kind === 'action' ? actionProperty : undefined; +} + +function findQualifiedViewActionProperty( + actionBinding: string | undefined, + metadataContext: DefinitionMetadataContext +): ClassPropertyInfo | undefined { + const parsed = parseQualifiedActionBinding(actionBinding); + if (!parsed) { + return undefined; + } + + const viewClass = resolveViewBinding( + parsed.viewName, + metadataContext.screenClasses, + metadataContext.classInfoLookup + )?.viewClass; + const actionProperty = viewClass?.properties.get(parsed.actionName); + return actionProperty?.kind === 'action' ? actionProperty : undefined; +} + +function parseQualifiedActionBinding(value: string | undefined): { viewName: string; actionName: string } | undefined { + if (!value) { + return undefined; + } + + const parts = value.split('.'); + if (parts.length !== 2) { + return undefined; + } + + const viewName = parts[0]?.trim(); + const actionName = parts[1]?.trim(); + if (!viewName || !actionName) { + return undefined; + } + + return { viewName, actionName }; +} + function parseControlStateBinding(value: string | undefined): { viewName: string; fieldName: string } | undefined { if (!value) { return undefined; @@ -250,45 +406,6 @@ function createLocationFromHtmlNode(document: BaseScreenDocument, node: any): vs ); } -function getViewNameFromCustomizationSelectors( - node: any, - baseDocument: BaseScreenDocument | undefined -): string | undefined { - if (!baseDocument?.dom?.length) { - return undefined; - } - - const attributes = node?.attribs; - if (!attributes) { - return undefined; - } - - for (const attributeName of getCustomizationSelectorAttributes()) { - const rawValue = attributes[attributeName]; - if (typeof rawValue !== 'string') { - continue; - } - const normalizedValue = rawValue.trim(); - if (!normalizedValue.length) { - continue; - } - - const { nodes, error } = queryBaseScreenElements(baseDocument, normalizedValue); - if (error || !nodes.length) { - continue; - } - - for (const candidate of nodes) { - const viewName = findParentViewName(candidate); - if (viewName) { - return viewName; - } - } - } - - return undefined; -} - // Converts a collected property back into a VS Code location for navigation. function createLocationFromProperty(property: ClassPropertyInfo): vscode.Location { return createLocationFromTsNode(property.sourceFile, property.node); diff --git a/acumate-plugin/src/providers/html-hover-provider.ts b/acumate-plugin/src/providers/html-hover-provider.ts index a5293ef..8cac7f4 100644 --- a/acumate-plugin/src/providers/html-hover-provider.ts +++ b/acumate-plugin/src/providers/html-hover-provider.ts @@ -4,19 +4,19 @@ import { findNodeAtOffset, elevateToElementNode, getAttributeContext, - findParentViewName, HtmlAttributeContext, + isActionStateBindTag, } from './html-shared'; -import { - getRelatedTsFiles, - loadClassInfosFromFiles, - filterClassesBySource, - createClassInfoLookup, - filterScreenLikeClasses, - resolveViewBinding, -} from '../utils'; +import { getRelatedTsFiles } from '../utils'; import { loadBackendFieldsForView } from './html-backend-utils'; import { BackendFieldMetadata, normalizeMetaName } from '../backend-metadata-utils'; +import { getBaseScreenDocument } from '../services/screen-html-service'; +import { + getIncludeFieldContext, + loadHtmlFieldMetadataContext, + parseFieldReference, + resolveHtmlField, +} from '../services/html-field-context-service'; export function registerHtmlHoverProvider(context: vscode.ExtensionContext) { const provider = vscode.languages.registerHoverProvider( @@ -53,11 +53,13 @@ export async function provideHtmlFieldHover( } const attributeContext = getAttributeContext(document, offset, elementNode); - if (!attributeContext || !isFieldNameAttribute(attributeContext)) { + if (!attributeContext || !isFieldHoverAttribute(attributeContext)) { return undefined; } - return buildFieldHover(document.uri.fsPath, attributeContext, elementNode); + return buildFieldHover(document.uri.fsPath, attributeContext, elementNode, { + useParentView: isFieldNameAttribute(attributeContext), + }); } function isFieldNameAttribute(attribute: HtmlAttributeContext): boolean { @@ -69,10 +71,19 @@ function isFieldNameAttribute(attribute: HtmlAttributeContext): boolean { return tagName === 'field' || tagName === 'qp-field'; } +function isFieldHoverAttribute(attribute: HtmlAttributeContext): boolean { + if (isFieldNameAttribute(attribute)) { + return true; + } + + return attribute.attributeName === 'state.bind' && !isActionStateBindTag(attribute.tagName); +} + async function buildFieldHover( htmlFilePath: string, attributeContext: HtmlAttributeContext, - elementNode: any + elementNode: any, + options: { useParentView: boolean } ): Promise { const fieldName = attributeContext.value?.trim(); if (!fieldName) { @@ -80,42 +91,69 @@ async function buildFieldHover( } const tsFilePaths = getRelatedTsFiles(htmlFilePath); - if (!tsFilePaths.length) { - return undefined; - } - - const classInfos = loadClassInfosFromFiles(tsFilePaths); - if (!classInfos.length) { - return undefined; - } - - const relevantClassInfos = filterClassesBySource(classInfos, tsFilePaths); - if (!relevantClassInfos.length) { - return undefined; - } - - const screenClasses = filterScreenLikeClasses(relevantClassInfos); - if (!screenClasses.length) { - return undefined; - } - - const viewName = findParentViewName(elementNode); - if (!viewName) { - return undefined; - } - - const classInfoLookup = createClassInfoLookup(classInfos); - const resolution = resolveViewBinding(viewName, screenClasses, classInfoLookup); - if (!resolution) { - return undefined; - } - - const backendFields = await loadBackendFieldsForView(viewName, screenClasses); + const hostContext = loadHtmlFieldMetadataContext(tsFilePaths); + if (!hostContext) { + return undefined; + } + + const fieldReference = parseFieldReference(fieldName); + const workspaceRoots = vscode.workspace.workspaceFolders?.map(folder => folder.uri.fsPath); + const includeContext = getIncludeFieldContext({ + documentPath: htmlFilePath, + elementNode, + hostTsFilePaths: tsFilePaths, + hostScreenClasses: hostContext.screenClasses, + workspaceRoots, + }); + const includeResolution = includeContext + ? resolveHtmlField({ + fieldReference, + elementNode, + metadataContext: includeContext, + selectorDocument: includeContext.templateDocument, + parameterValues: includeContext.parameterValues, + allowAnyViewWhenUnscoped: true, + useParentView: false, + }) + : undefined; + const hostIncludeSelectorResolution = includeContext + ? resolveHtmlField({ + fieldReference, + elementNode, + metadataContext: hostContext, + selectorDocument: includeContext.templateDocument, + useParentView: options.useParentView, + }) + : undefined; + const hostBaseResolution = resolveHtmlField({ + fieldReference, + elementNode, + metadataContext: hostContext, + selectorDocument: getBaseScreenDocument(htmlFilePath), + useParentView: options.useParentView, + }); + const hostResolution = hostIncludeSelectorResolution?.viewResolution + ? hostIncludeSelectorResolution + : hostBaseResolution; + const hoverResolution = + includeResolution?.viewName && includeResolution.viewResolution && !includeResolution.hasTemplatedBinding + ? includeResolution + : hostResolution; + if (!hoverResolution?.viewName || hoverResolution.hasTemplatedBinding) { + return undefined; + } + + const backendFields = await loadBackendFieldsForView( + hoverResolution.viewName, + hoverResolution === includeResolution + ? includeContext?.hostScreenClasses ?? hostContext.screenClasses + : hostContext.screenClasses + ); if (!backendFields?.size) { return undefined; } - const normalizedFieldName = normalizeMetaName(fieldName); + const normalizedFieldName = normalizeMetaName(hoverResolution.fieldName); if (!normalizedFieldName) { return undefined; } @@ -125,7 +163,7 @@ async function buildFieldHover( return undefined; } - const markdown = buildFieldMarkdown(backendField, fieldName, viewName); + const markdown = buildFieldMarkdown(backendField, hoverResolution.fieldName, hoverResolution.viewName); if (!markdown) { return undefined; } @@ -156,4 +194,4 @@ function buildFieldMarkdown( const markdown = new vscode.MarkdownString(lines.join('\n')); markdown.isTrusted = false; return markdown; -} \ No newline at end of file +} diff --git a/acumate-plugin/src/providers/html-shared.ts b/acumate-plugin/src/providers/html-shared.ts index f679383..ac917c3 100644 --- a/acumate-plugin/src/providers/html-shared.ts +++ b/acumate-plugin/src/providers/html-shared.ts @@ -234,30 +234,47 @@ export function readAttributeAtOffset(text: string, offset: number) { }; } -// Climbs ancestors until a view.bind is found, mirroring runtime scoping. -export function findParentViewName(node: any): string | undefined { - let current: any = node?.parent ?? node?.parentNode; - while (current) { - const viewBinding = current.attribs?.['view.bind']; - if (typeof viewBinding === 'string' && viewBinding.length) { - return viewBinding; +export function getElementViewName(node: any): string | undefined { + const viewBinding = node?.attribs?.['view.bind']; + if (typeof viewBinding === 'string' && viewBinding.trim().length) { + return viewBinding.trim(); + } + + if (node?.name === 'qp-panel') { + const panelId = node.attribs?.['id']; + if (typeof panelId === 'string' && panelId.trim().length) { + return panelId.trim(); } + } - if (current.name === 'qp-panel') { - const panelId = current.attribs?.['id']; - if (typeof panelId === 'string' && panelId.trim().length) { - return panelId.trim(); - } + if (node?.name === 'using') { + const usingView = node.attribs?.view; + if (typeof usingView === 'string' && usingView.trim().length) { + return usingView.trim(); } + } - if (current.name === 'using') { - const usingView = current.attribs?.view; - if (usingView) { - return usingView; - } + return undefined; +} + +export function findViewNameAtOrAbove(node: any): string | undefined { + let current: any = node; + while (current) { + const viewName = getElementViewName(current); + if (viewName) { + return viewName; } current = current.parent ?? current.parentNode; } return undefined; } + +// Climbs ancestors until a view scope is found, mirroring runtime scoping. +export function findParentViewName(node: any): string | undefined { + return findViewNameAtOrAbove(node?.parent ?? node?.parentNode); +} + +export function isActionStateBindTag(tagName: string | undefined): boolean { + return typeof tagName === 'string' && tagName.toLowerCase() === 'qp-button'; +} diff --git a/acumate-plugin/src/services/client-controls-service.ts b/acumate-plugin/src/services/client-controls-service.ts index e51d840..844c8d2 100644 --- a/acumate-plugin/src/services/client-controls-service.ts +++ b/acumate-plugin/src/services/client-controls-service.ts @@ -17,6 +17,10 @@ export interface ControlConfigDefinition { properties: ControlConfigProperty[]; } +interface InterfaceDeclarationInfo extends ControlConfigDefinition { + baseTypes: string[]; +} + export interface ClientControlConfigInfo { typeName: string; displayName: string; @@ -150,7 +154,7 @@ function collectClientControls(packageRoot: string): ClientControlMetadata[] { } const sourceFiles: ts.SourceFile[] = []; - const interfaceMap = new Map(); + const interfaceDeclarations = new Map(); for (const filePath of declarationFiles) { const sourceFile = parseSourceFile(filePath); @@ -158,9 +162,10 @@ function collectClientControls(packageRoot: string): ClientControlMetadata[] { continue; } sourceFiles.push(sourceFile); - collectInterfaceDeclarations(sourceFile, interfaceMap); + collectInterfaceDeclarations(sourceFile, interfaceDeclarations); } + const interfaceMap = resolveInterfaceDefinitions(interfaceDeclarations); const controls = new Map(); for (const sourceFile of sourceFiles) { collectControlDeclarations(sourceFile, packageRoot, interfaceMap, controls); @@ -223,7 +228,7 @@ function parseSourceFile(filePath: string): ts.SourceFile | undefined { } } -function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: Map) { +function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: Map) { const visit = (node: ts.Node) => { if (ts.isInterfaceDeclaration(node)) { const name = node.name.text; @@ -243,10 +248,12 @@ function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: M description: extractJsDoc(node.getSourceFile(), member), }); } + const existing = interfaceMap.get(name); interfaceMap.set(name, { typeName: name, - description: extractJsDoc(sourceFile, node), - properties, + description: existing?.description ?? extractJsDoc(sourceFile, node), + properties: [...(existing?.properties ?? []), ...properties], + baseTypes: [...(existing?.baseTypes ?? []), ...getInterfaceBaseTypes(node)], }); } @@ -256,6 +263,89 @@ function collectInterfaceDeclarations(sourceFile: ts.SourceFile, interfaceMap: M visit(sourceFile); } +function getInterfaceBaseTypes(node: ts.InterfaceDeclaration): string[] { + const baseTypes: string[] = []; + for (const clause of node.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + + for (const heritageType of clause.types) { + const baseName = getHeritageTypeName(heritageType); + if (baseName) { + baseTypes.push(baseName); + } + } + } + + return baseTypes; +} + +function getHeritageTypeName(heritageType: ts.ExpressionWithTypeArguments): string | undefined { + const expression = heritageType.expression; + if (ts.isIdentifier(expression)) { + return expression.text; + } + + if (ts.isPropertyAccessExpression(expression)) { + return expression.name.text; + } + + return undefined; +} + +function resolveInterfaceDefinitions( + interfaceDeclarations: Map +): Map { + const resolved = new Map(); + for (const typeName of interfaceDeclarations.keys()) { + resolveInterfaceDefinition(typeName, interfaceDeclarations, resolved, new Set()); + } + + return resolved; +} + +function resolveInterfaceDefinition( + typeName: string, + interfaceDeclarations: Map, + resolved: Map, + active: Set +): ControlConfigDefinition | undefined { + const cached = resolved.get(typeName); + if (cached) { + return cached; + } + + const declaration = interfaceDeclarations.get(typeName); + if (!declaration || active.has(typeName)) { + return undefined; + } + + active.add(typeName); + const properties = new Map(); + for (const baseType of declaration.baseTypes) { + const baseDefinition = resolveInterfaceDefinition(baseType, interfaceDeclarations, resolved, active); + for (const property of baseDefinition?.properties ?? []) { + if (!properties.has(property.name)) { + properties.set(property.name, property); + } + } + } + + for (const property of declaration.properties) { + properties.set(property.name, property); + } + + active.delete(typeName); + const definition: ControlConfigDefinition = { + typeName: declaration.typeName, + description: declaration.description, + properties: [...properties.values()], + }; + resolved.set(typeName, definition); + return definition; +} + function collectControlDeclarations( sourceFile: ts.SourceFile, packageRoot: string, diff --git a/acumate-plugin/src/services/html-field-context-service.ts b/acumate-plugin/src/services/html-field-context-service.ts new file mode 100644 index 0000000..889ed07 --- /dev/null +++ b/acumate-plugin/src/services/html-field-context-service.ts @@ -0,0 +1,516 @@ +import path from 'path'; + +import { + ClassPropertyInfo, + CollectedClassInfo, + ViewResolution, + createClassInfoLookup, + filterClassesBySource, + filterScreenLikeClasses, + getRelatedTsFiles, + loadClassInfosFromFiles, + resolveClassInfoForProperty, + resolveViewBinding, +} from '../utils'; +import { findParentViewName, findViewNameAtOrAbove } from '../providers/html-shared'; +import { resolveIncludeFilePath } from './include-service'; +import { + BaseScreenDocument, + createParameterizedHtmlDocument, + getCustomizationSelectorAttributes, + isCustomizationSelectorAttribute, + loadHtmlDocument, + queryBaseScreenElements, +} from './screen-html-service'; + +export interface HtmlFieldMetadataContext { + classInfoLookup: Map; + screenClasses: CollectedClassInfo[]; + viewResolutionCache?: Map; +} + +export interface HtmlIncludeTemplateFieldContext extends HtmlFieldMetadataContext { + templateDocument?: BaseScreenDocument; + viewResolutionCache: Map; +} + +export interface HtmlIncludeFieldContext extends HtmlIncludeTemplateFieldContext { + includeNode: any; + includePath: string; + parameterValues: Map; + hostScreenClasses?: CollectedClassInfo[]; +} + +export interface FieldReference { + viewName?: string; + fieldName: string; +} + +export interface HtmlFieldResolution { + fieldName: string; + viewName?: string; + viewResolution?: ViewResolution; + fieldProperty?: ClassPropertyInfo; + usedAnyViewFallback: boolean; + hasTemplatedBinding: boolean; +} + +export interface FieldInViewResolution { + viewName: string; + viewResolution: ViewResolution; + fieldProperty: ClassPropertyInfo; +} + +export type HtmlIncludeTemplateFieldContextCache = + Map; + +export function createHtmlFieldMetadataContext( + classInfos: CollectedClassInfo[], + sourceFilePaths: string[] +): HtmlFieldMetadataContext { + const relevantClassInfos = filterClassesBySource(classInfos, sourceFilePaths); + return { + classInfoLookup: createClassInfoLookup(classInfos), + screenClasses: filterScreenLikeClasses(relevantClassInfos), + viewResolutionCache: new Map(), + }; +} + +export function loadHtmlFieldMetadataContext( + tsFilePaths: string[] +): HtmlFieldMetadataContext | undefined { + if (!tsFilePaths.length) { + return undefined; + } + + const classInfos = loadClassInfosFromFiles(tsFilePaths); + if (!classInfos.length) { + return undefined; + } + + const context = createHtmlFieldMetadataContext(classInfos, tsFilePaths); + if (!context.screenClasses.length) { + return undefined; + } + + return context; +} + +export function resolveHtmlView( + viewName: string | undefined, + context: HtmlFieldMetadataContext +): ViewResolution | undefined { + if (!viewName) { + return undefined; + } + + const cache = context.viewResolutionCache; + if (cache?.has(viewName)) { + return cache.get(viewName); + } + + const resolution = resolveViewBinding(viewName, context.screenClasses, context.classInfoLookup); + cache?.set(viewName, resolution); + return resolution; +} + +export function parseFieldReference(rawFieldName: string): FieldReference { + const trimmed = rawFieldName.trim(); + const dotIndex = trimmed.indexOf('.'); + if (dotIndex === -1) { + return { fieldName: trimmed }; + } + + const viewName = trimmed.substring(0, dotIndex).trim(); + const fieldName = trimmed.substring(dotIndex + 1).trim(); + return { viewName, fieldName }; +} + +export function resolveHtmlField( + options: { + rawFieldName?: string; + fieldReference?: FieldReference; + elementNode: any; + metadataContext: HtmlFieldMetadataContext; + selectorDocument?: BaseScreenDocument; + parameterValues?: Map; + allowAnyViewFallback?: boolean; + allowAnyViewWhenUnscoped?: boolean; + useParentView?: boolean; + } +): HtmlFieldResolution | undefined { + const fieldReference = + options.fieldReference ?? + (options.rawFieldName ? parseFieldReference(options.rawFieldName) : undefined); + if (!fieldReference) { + return undefined; + } + + let viewName = fieldReference.viewName; + let fieldName = fieldReference.fieldName; + let allowAnyViewFallback = Boolean(options.allowAnyViewFallback); + + if (!viewName && options.useParentView !== false) { + viewName = findParentViewName(options.elementNode); + } + + if (!viewName) { + const selectorViewName = getViewNameFromCustomizationSelectors( + options.elementNode, + options.selectorDocument + ); + allowAnyViewFallback ||= hasTemplateExpression(selectorViewName); + viewName = selectorViewName; + } + + if (options.parameterValues) { + allowAnyViewFallback ||= hasTemplateExpression(viewName); + viewName = resolveTemplateValue(viewName, options.parameterValues); + fieldName = resolveTemplateValue(fieldName, options.parameterValues) ?? fieldName; + } + + const hasTemplatedBinding = + hasTemplateExpression(viewName) || + hasTemplateExpression(fieldName); + if (!fieldName || hasTemplatedBinding) { + return { + fieldName, + viewName, + usedAnyViewFallback: false, + hasTemplatedBinding, + }; + } + + if (viewName) { + const viewResolution = resolveHtmlView(viewName, options.metadataContext); + const fieldProperty = viewResolution?.viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === 'field') { + return { + fieldName, + viewName, + viewResolution, + fieldProperty, + usedAnyViewFallback: false, + hasTemplatedBinding: false, + }; + } + + if (!allowAnyViewFallback) { + return { + fieldName, + viewName, + viewResolution, + usedAnyViewFallback: false, + hasTemplatedBinding: false, + }; + } + } + + if (allowAnyViewFallback || (!viewName && options.allowAnyViewWhenUnscoped)) { + const anyViewResolution = findFieldInAnyView(fieldName, options.metadataContext); + if (anyViewResolution) { + return { + fieldName, + viewName: anyViewResolution.viewName, + viewResolution: anyViewResolution.viewResolution, + fieldProperty: anyViewResolution.fieldProperty, + usedAnyViewFallback: true, + hasTemplatedBinding: false, + }; + } + } + + return { + fieldName, + viewName, + usedAnyViewFallback: Boolean(allowAnyViewFallback), + hasTemplatedBinding: false, + }; +} + +export function findFieldInAnyView( + fieldName: string | undefined, + context: HtmlFieldMetadataContext +): FieldInViewResolution | undefined { + return findFieldsInAnyView(fieldName, context)[0]; +} + +export function findFieldsInAnyView( + fieldName: string | undefined, + context: HtmlFieldMetadataContext +): FieldInViewResolution[] { + if (!fieldName || hasTemplateExpression(fieldName)) { + return []; + } + + const matches: FieldInViewResolution[] = []; + const seen = new Set(); + for (const screenClass of context.screenClasses) { + for (const [viewName, property] of screenClass.properties) { + if (property.kind !== 'view' && property.kind !== 'viewCollection') { + continue; + } + + const viewResolution = resolveViewBinding(viewName, [screenClass], context.classInfoLookup); + const viewClass = + viewResolution?.viewClass ?? + resolveClassInfoForProperty(property, context.classInfoLookup); + const fieldProperty = viewClass?.properties.get(fieldName); + if (fieldProperty?.kind === 'field') { + const key = `${fieldProperty.sourceFile.fileName}:${fieldProperty.node.getStart()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + matches.push({ + viewName, + viewResolution: viewResolution ?? { screenClass, property, viewClass }, + fieldProperty, + }); + } + } + } + + return matches; +} + +export function getFieldPropertiesFromViews( + context: HtmlFieldMetadataContext +): Map { + const fields = new Map(); + for (const screenClass of context.screenClasses) { + for (const [viewName, property] of screenClass.properties) { + if (property.kind !== 'view' && property.kind !== 'viewCollection') { + continue; + } + + const viewClass = + resolveViewBinding(viewName, [screenClass], context.classInfoLookup)?.viewClass ?? + resolveClassInfoForProperty(property, context.classInfoLookup); + if (!viewClass) { + continue; + } + + for (const [fieldName, fieldProperty] of viewClass.properties) { + if (fieldProperty.kind === 'field' && !fields.has(fieldName)) { + fields.set(fieldName, fieldProperty); + } + } + } + } + return fields; +} + +export function getIncludeFieldContext(options: { + documentPath: string; + hostTsFilePaths: string[]; + elementNode?: any; + includeNode?: any; + workspaceRoots?: string[]; + hostScreenClasses?: CollectedClassInfo[]; + cache?: HtmlIncludeTemplateFieldContextCache; +}): HtmlIncludeFieldContext | undefined { + const includeNode = options.includeNode ?? findNearestIncludeNode(options.elementNode); + const includeUrl = includeNode?.attribs?.url; + if (typeof includeUrl !== 'string' || !includeUrl.length || hasTemplateExpression(includeUrl)) { + return undefined; + } + + const includePath = resolveIncludeFilePath(includeUrl, options.documentPath, options.workspaceRoots); + if (!includePath) { + return undefined; + } + + const normalizedIncludePath = path.normalize(includePath); + const cacheKey = [ + normalizedIncludePath, + ...options.hostTsFilePaths.map(filePath => path.normalize(filePath)), + ].join('|'); + const cache = options.cache; + let templateContext: HtmlIncludeTemplateFieldContext | undefined; + if (cache) { + templateContext = cache.get(cacheKey); + } + if (cache && !cache.has(cacheKey)) { + templateContext = loadIncludeTemplateFieldContext(normalizedIncludePath, options.hostTsFilePaths); + cache.set(cacheKey, templateContext); + } + if (!cache) { + templateContext = loadIncludeTemplateFieldContext(normalizedIncludePath, options.hostTsFilePaths); + } + + if (!templateContext) { + return undefined; + } + + const parameterValues = getIncludeParameterValues(includeNode); + const templateDocument = templateContext.templateDocument + ? createParameterizedHtmlDocument(templateContext.templateDocument, parameterValues) + : undefined; + + return { + ...templateContext, + templateDocument, + includeNode, + includePath: normalizedIncludePath, + parameterValues, + hostScreenClasses: options.hostScreenClasses, + }; +} + +export function loadIncludeTemplateFieldContext( + includeHtmlPath: string, + hostTsFilePaths: string[] +): HtmlIncludeTemplateFieldContext | undefined { + const includeTsFilePaths = getRelatedTsFiles(includeHtmlPath); + const combinedTsFilePaths = dedupeFilePaths([...hostTsFilePaths, ...includeTsFilePaths]); + const classInfos = combinedTsFilePaths.length + ? loadClassInfosFromFiles(combinedTsFilePaths) + : []; + const metadataContext = createHtmlFieldMetadataContext(classInfos, includeTsFilePaths); + const templateDocument = loadHtmlDocument(includeHtmlPath); + + if (!metadataContext.screenClasses.length && !templateDocument) { + return undefined; + } + + return { + ...metadataContext, + templateDocument, + viewResolutionCache: new Map(), + }; +} + +export function findNearestIncludeNode(node: any): any | undefined { + let current = node; + while (current) { + if (current.type === 'tag' && current.name === 'qp-include') { + return current; + } + + current = current.parent ?? current.parentNode; + } + return undefined; +} + +export function getIncludeParameterValues(includeNode: any): Map { + const values = new Map(); + const attributes = includeNode?.attribs ?? {}; + for (const [attributeName, attributeValue] of Object.entries(attributes)) { + if (typeof attributeValue === 'string') { + values.set(attributeName, attributeValue); + } + } + return values; +} + +export function getViewNameFromCustomizationSelectors( + node: any, + document: BaseScreenDocument | undefined +): string | undefined { + if (!document?.dom?.length) { + return undefined; + } + + const attributes = node?.attribs; + if (!attributes) { + return undefined; + } + + for (const attributeName of getCustomizationSelectorAttributes()) { + const rawValue = attributes[attributeName]; + if (typeof rawValue !== 'string') { + continue; + } + + const normalizedValue = rawValue.trim(); + if (!normalizedValue.length || hasTemplateExpression(normalizedValue)) { + continue; + } + + const { nodes, error } = queryBaseScreenElements(document, normalizedValue); + if (error || !nodes.length) { + continue; + } + + for (const target of nodes) { + const viewName = findViewNameAtOrAbove(target); + if (viewName) { + return viewName; + } + } + } + + return undefined; +} + +export function getParentOrSelectorViewName( + node: any, + selectorDocument: BaseScreenDocument | undefined +): string | undefined { + return findParentViewName(node) ?? getViewNameFromCustomizationSelectors(node, selectorDocument); +} + +export function forEachCustomizationSelector( + node: any, + callback: (attributeName: string, rawValue: string, normalizedValue: string) => void +) { + if (!node?.attribs) { + return; + } + + for (const [attributeName, attributeValue] of Object.entries(node.attribs)) { + if (!isCustomizationSelectorAttribute(attributeName) || typeof attributeValue !== 'string') { + continue; + } + + const normalizedValue = attributeValue.trim(); + if (!normalizedValue.length) { + continue; + } + + callback(attributeName, attributeValue, normalizedValue); + } +} + +export function hasTemplateCustomizationSelector(node: any): boolean { + let hasTemplateSelector = false; + forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { + if (hasTemplateExpression(normalizedValue)) { + hasTemplateSelector = true; + } + }); + return hasTemplateSelector; +} + +export function resolveTemplateValue( + value: string | undefined, + parameterValues: Map +): string | undefined { + if (!value) { + return value; + } + + return value.replace(/{{\s*([^}\s]+)\s*}}/g, (match, parameterName: string) => { + const parameterValue = parameterValues.get(parameterName)?.trim(); + return parameterValue || match; + }).trim(); +} + +export function hasTemplateExpression(value: string | undefined): boolean { + return typeof value === 'string' && /{{\s*[^}]+\s*}}/.test(value); +} + +export function dedupeFilePaths(filePaths: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const filePath of filePaths) { + const normalized = path.normalize(filePath); + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + result.push(filePath); + } + return result; +} diff --git a/acumate-plugin/src/services/screen-html-service.ts b/acumate-plugin/src/services/screen-html-service.ts index 06cf010..d0cc4f3 100644 --- a/acumate-plugin/src/services/screen-html-service.ts +++ b/acumate-plugin/src/services/screen-html-service.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import ts from "typescript"; import { DomHandler, Parser } from "htmlparser2"; import { selectAll } from "css-select"; @@ -7,14 +8,22 @@ export interface BaseScreenDocument { filePath: string; content: string; dom: any[]; + sourceDocuments?: readonly BaseScreenDocument[]; + nodeDocumentMap?: WeakMap; +} + +export interface SelectorQueryMatch { + node: any; + document: BaseScreenDocument; } export interface SelectorQueryResult { nodes: any[]; + matches: SelectorQueryMatch[]; error?: string; } -const customizationSelectorAttributes = ["before", "after", "append", "prepend", "prepand", "move"] as const; +const customizationSelectorAttributes = ["modify", "before", "after", "append", "prepend", "prepand", "move", "remove"] as const; const customizationSelectorAttributeSet = new Set( customizationSelectorAttributes.map(attribute => attribute.toLowerCase()) ); @@ -40,17 +49,32 @@ export function getBaseScreenDocument(htmlFilePath: string): BaseScreenDocument return undefined; } - const mtime = tryGetMtime(baseHtmlPath); - const cached = cache.get(baseHtmlPath); + const baseDocument = loadHtmlDocument(baseHtmlPath); + if (!baseDocument) { + return undefined; + } + + const dependencyDocuments = getExtensionDependencyHtmlDocuments(htmlFilePath); + if (!dependencyDocuments.length) { + return baseDocument; + } + + return createCompositeScreenDocument([baseDocument, ...dependencyDocuments]); +} + +export function loadHtmlDocument(htmlFilePath: string): BaseScreenDocument | undefined { + const normalizedPath = path.normalize(htmlFilePath); + const mtime = tryGetMtime(normalizedPath); + const cached = cache.get(normalizedPath); if (cached && cached.mtime === mtime) { return cached; } try { - const content = fs.readFileSync(baseHtmlPath, "utf-8"); + const content = fs.readFileSync(normalizedPath, "utf-8"); const dom = parseHtml(content); - const entry: CachedDocument = { filePath: baseHtmlPath, content, dom, mtime }; - cache.set(baseHtmlPath, entry); + const entry: CachedDocument = { filePath: normalizedPath, content, dom, mtime }; + cache.set(normalizedPath, entry); return entry; } catch { @@ -90,21 +114,63 @@ export function resolveBaseScreenHtmlPath(htmlFilePath: string): string | undefi export function queryBaseScreenElements(document: BaseScreenDocument, selector: string): SelectorQueryResult { const normalized = selector?.trim(); if (!normalized) { - return { nodes: [] }; + return { nodes: [], matches: [] }; } try { const nodes = selectAll(normalized, document.dom); - return { nodes }; + const matches = nodes.map(node => ({ + node, + document: getDocumentForNode(document, node), + })); + return { nodes, matches }; } catch (error) { return { nodes: [], + matches: [], error: error instanceof Error ? error.message : "Unknown selector error", }; } } +export function createParameterizedHtmlDocument( + document: BaseScreenDocument, + parameterValues: Map +): BaseScreenDocument { + const dom = parseHtml(document.content); + applyTemplateParametersToNodes(dom, parameterValues); + + return { + filePath: document.filePath, + content: document.content, + dom, + }; +} + +export function getDocumentForNode( + document: BaseScreenDocument, + node: any +): BaseScreenDocument { + if (node && typeof node === "object") { + const sourceDocument = document.nodeDocumentMap?.get(node); + if (sourceDocument) { + return sourceDocument; + } + } + + return document; +} + +export function getScreenDocumentDisplayName(document: BaseScreenDocument): string { + const sourceDocuments = document.sourceDocuments; + if (!sourceDocuments || sourceDocuments.length <= 1) { + return path.basename(document.filePath); + } + + return `${path.basename(sourceDocuments[0].filePath)} and its extension dependencies`; +} + function parseHtml(content: string): any[] { let domTree: any[] = []; const handler = new DomHandler( @@ -121,6 +187,33 @@ function parseHtml(content: string): any[] { return domTree; } +function applyTemplateParametersToNodes( + nodes: any[] | undefined, + parameterValues: Map +) { + if (!nodes) { + return; + } + + for (const node of nodes) { + if (node?.attribs) { + for (const [attributeName, attributeValue] of Object.entries(node.attribs)) { + if (typeof attributeValue === "string") { + node.attribs[attributeName] = applyTemplateParameters(attributeValue, parameterValues); + } + } + } + + applyTemplateParametersToNodes(node?.children, parameterValues); + } +} + +function applyTemplateParameters(value: string, parameterValues: Map): string { + return value.replace(/{{\s*([^#\/^}\s]+)\s*}}/g, (match, parameterName: string) => ( + parameterValues.has(parameterName) ? parameterValues.get(parameterName) ?? "" : match + )); +} + function tryGetMtime(targetPath: string): number | undefined { try { return fs.statSync(targetPath).mtimeMs; @@ -129,3 +222,310 @@ function tryGetMtime(targetPath: string): number | undefined { return undefined; } } + +function createCompositeScreenDocument(documents: BaseScreenDocument[]): BaseScreenDocument { + const nodeDocumentMap = new WeakMap(); + for (const document of documents) { + mapNodeDocuments(document.dom, document, nodeDocumentMap); + } + + return { + filePath: documents[0].filePath, + content: documents[0].content, + dom: documents.flatMap(document => document.dom), + sourceDocuments: documents, + nodeDocumentMap, + }; +} + +function mapNodeDocuments( + nodes: any[] | undefined, + document: BaseScreenDocument, + nodeDocumentMap: WeakMap +) { + if (!nodes) { + return; + } + + for (const node of nodes) { + if (node && typeof node === "object") { + nodeDocumentMap.set(node, document); + mapNodeDocuments(node.children, document, nodeDocumentMap); + } + } +} + +function getExtensionDependencyHtmlDocuments(htmlFilePath: string): BaseScreenDocument[] { + const extensionDirectory = getExtensionDirectory(htmlFilePath); + if (!extensionDirectory) { + return []; + } + + const tsFilePath = getCorrespondingTsFilePath(htmlFilePath); + if (!tsFilePath) { + return []; + } + + const documents: BaseScreenDocument[] = []; + const emittedHtmlPaths = new Set(); + const activeTsPaths = new Set(); + const rootInterfaceName = getExtensionNameFromFilePath(htmlFilePath); + + collectDependencyHtmlDocuments( + tsFilePath, + rootInterfaceName, + extensionDirectory, + activeTsPaths, + emittedHtmlPaths, + documents + ); + + return documents; +} + +function collectDependencyHtmlDocuments( + tsFilePath: string, + interfaceName: string, + extensionDirectory: string, + activeTsPaths: Set, + emittedHtmlPaths: Set, + documents: BaseScreenDocument[] +) { + const normalizedTsPath = path.normalize(tsFilePath); + if (activeTsPaths.has(normalizedTsPath)) { + return; + } + + activeTsPaths.add(normalizedTsPath); + + for (const dependency of getInterfaceDependencies(normalizedTsPath, interfaceName)) { + const dependencyTsPath = resolveDependencyTsPath(normalizedTsPath, dependency); + const dependencyHtmlPath = resolveDependencyHtmlPath( + normalizedTsPath, + dependency, + extensionDirectory, + dependencyTsPath + ); + const dependencyInterfaceName = dependencyTsPath + ? getExtensionNameFromFilePath(dependencyTsPath) + : dependency.typeName; + + if (dependencyTsPath) { + collectDependencyHtmlDocuments( + dependencyTsPath, + dependencyInterfaceName, + extensionDirectory, + activeTsPaths, + emittedHtmlPaths, + documents + ); + } + + if (!dependencyHtmlPath || !isHtmlPathInDirectory(dependencyHtmlPath, extensionDirectory)) { + continue; + } + + const normalizedHtmlPath = path.normalize(dependencyHtmlPath); + if (emittedHtmlPaths.has(normalizedHtmlPath)) { + continue; + } + + const dependencyDocument = loadHtmlDocument(normalizedHtmlPath); + if (!dependencyDocument) { + continue; + } + + emittedHtmlPaths.add(normalizedHtmlPath); + documents.push(dependencyDocument); + } + + activeTsPaths.delete(normalizedTsPath); +} + +interface InterfaceDependency { + typeName: string; + moduleSpecifier?: string; +} + +function getInterfaceDependencies( + tsFilePath: string, + interfaceName: string +): InterfaceDependency[] { + const sourceFile = tryReadTsSourceFile(tsFilePath); + if (!sourceFile) { + return []; + } + + const importMap = getImportMap(sourceFile); + const dependencies: InterfaceDependency[] = []; + + const visit = (node: ts.Node) => { + if (ts.isInterfaceDeclaration(node) && node.name.text === interfaceName) { + for (const clause of node.heritageClauses ?? []) { + if (clause.token !== ts.SyntaxKind.ExtendsKeyword) { + continue; + } + + for (const heritageType of clause.types) { + const typeName = getHeritageIdentifierName(heritageType); + if (!typeName) { + continue; + } + + dependencies.push({ + typeName, + moduleSpecifier: importMap.get(typeName), + }); + } + } + return; + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + return dependencies; +} + +function getImportMap(sourceFile: ts.SourceFile): Map { + const imports = new Map(); + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement) || !statement.importClause || !ts.isStringLiteral(statement.moduleSpecifier)) { + continue; + } + + const moduleSpecifier = statement.moduleSpecifier.text; + const importClause = statement.importClause; + + if (importClause.name) { + imports.set(importClause.name.text, moduleSpecifier); + } + + const namedBindings = importClause.namedBindings; + if (namedBindings && ts.isNamedImports(namedBindings)) { + for (const element of namedBindings.elements) { + imports.set(element.name.text, moduleSpecifier); + } + } + } + + return imports; +} + +function getHeritageIdentifierName(heritageType: ts.ExpressionWithTypeArguments): string | undefined { + const expression = heritageType.expression; + return ts.isIdentifier(expression) ? expression.text : undefined; +} + +function resolveDependencyTsPath( + sourceTsPath: string, + dependency: InterfaceDependency +): string | undefined { + if (dependency.moduleSpecifier?.startsWith(".")) { + return resolveModuleTsPath(sourceTsPath, dependency.moduleSpecifier); + } + + return findFileWithExtensions(path.join(path.dirname(sourceTsPath), dependency.typeName), [".ts", ".tsx"]); +} + +function resolveDependencyHtmlPath( + sourceTsPath: string, + dependency: InterfaceDependency, + extensionDirectory: string, + dependencyTsPath: string | undefined +): string | undefined { + if (dependencyTsPath) { + const fromTsPath = findHtmlNextToTsFile(dependencyTsPath); + if (fromTsPath) { + return fromTsPath; + } + } + + if (dependency.moduleSpecifier?.startsWith(".")) { + const fromModulePath = findFileWithExtensions( + path.resolve(path.dirname(sourceTsPath), dependency.moduleSpecifier), + [".html", ".htm"] + ); + if (fromModulePath) { + return fromModulePath; + } + } + + return findFileWithExtensions(path.join(extensionDirectory, dependency.typeName), [".html", ".htm"]); +} + +function resolveModuleTsPath(sourceTsPath: string, moduleSpecifier: string): string | undefined { + const basePath = path.resolve(path.dirname(sourceTsPath), moduleSpecifier); + return findFileWithExtensions(basePath, [".ts", ".tsx", ".d.ts"]); +} + +function findHtmlNextToTsFile(tsFilePath: string): string | undefined { + const parsedPath = path.parse(tsFilePath); + return findFileWithExtensions(path.join(parsedPath.dir, parsedPath.name), [".html", ".htm"]); +} + +function getCorrespondingTsFilePath(htmlFilePath: string): string | undefined { + const normalized = path.normalize(htmlFilePath); + const directCandidate = normalized.replace(/\.html?$/i, ".ts"); + if (fs.existsSync(directCandidate)) { + return directCandidate; + } + + const withoutHtml = normalized.replace(/\.html?$/i, ""); + const trimmedBase = withoutHtml.replace(/\.+$/, ""); + if (trimmedBase && trimmedBase !== withoutHtml) { + const trimmedCandidate = `${trimmedBase}.ts`; + if (fs.existsSync(trimmedCandidate)) { + return trimmedCandidate; + } + } + + return undefined; +} + +function findFileWithExtensions(basePath: string, extensions: string[]): string | undefined { + for (const extension of extensions) { + const candidate = `${basePath}${extension}`; + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return undefined; +} + +function getExtensionDirectory(htmlFilePath: string): string | undefined { + const normalized = path.normalize(htmlFilePath); + const lower = normalized.toLowerCase(); + const marker = `${path.sep}extensions${path.sep}`.toLowerCase(); + const markerIndex = lower.lastIndexOf(marker); + if (markerIndex === -1) { + return undefined; + } + + return normalized.substring(0, markerIndex + marker.length - 1); +} + +function getExtensionNameFromFilePath(filePath: string): string { + const parsedPath = path.parse(filePath); + return parsedPath.name.replace(/\.+$/, ""); +} + +function isHtmlPathInDirectory(htmlFilePath: string, directoryPath: string): boolean { + const normalizedHtmlDirectory = path.normalize(path.dirname(htmlFilePath)).toLowerCase(); + const normalizedDirectory = path.normalize(directoryPath).toLowerCase(); + return normalizedHtmlDirectory === normalizedDirectory; +} + +function tryReadTsSourceFile(tsFilePath: string): ts.SourceFile | undefined { + const normalized = path.normalize(tsFilePath); + try { + const content = fs.readFileSync(normalized, "utf-8"); + return ts.createSourceFile(normalized, content, ts.ScriptTarget.Latest, true); + } + catch { + return undefined; + } +} diff --git a/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html b/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html index 618df0a..bab88e0 100644 --- a/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html +++ b/acumate-plugin/src/test/fixtures/html/TestConfigBindingValid.html @@ -1,3 +1,3 @@ - + diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html index 77c15ab..713b5d9 100644 --- a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html @@ -1,4 +1,12 @@ + + + + + + + + @@ -10,4 +18,5 @@ fs-wg-container="Shipping_Contact_formD" fs-caption="Ship-To Contact" > + diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html new file mode 100644 index 0000000..834cc78 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts new file mode 100644 index 0000000..2a26652 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.models.ts @@ -0,0 +1,3 @@ +export class SelectionFilter extends PXView { + WcID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts new file mode 100644 index 0000000..817d66f --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.other.ts @@ -0,0 +1,3 @@ +export class SelectionFilter extends PXView { + OtherField!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts new file mode 100644 index 0000000..c4d2778 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestDuplicateViewNames.ts @@ -0,0 +1,6 @@ +import { SelectionFilter } from "./TestDuplicateViewNames.models"; +import "./TestDuplicateViewNames.other"; + +export class TestDuplicateViewNames extends PXScreen { + SelectionFilter = createSingle(SelectionFilter); +} diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html new file mode 100644 index 0000000..0c5932b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts new file mode 100644 index 0000000..a8178d5 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHost.ts @@ -0,0 +1,7 @@ +export class IncludeHostView extends PXView { + HostField!: PXFieldState; +} + +export class IncludeHostMaint extends PXScreen { + hostView!: PXView; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html new file mode 100644 index 0000000..b9d5093 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts new file mode 100644 index 0000000..c1f753d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestIncludeFieldModificationHostInvalid.ts @@ -0,0 +1,7 @@ +export class InvalidIncludeHostView extends PXView { + HostField!: PXFieldState; +} + +export class InvalidIncludeHostMaint extends PXScreen { + hostView!: PXView; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html new file mode 100644 index 0000000..4c016b3 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectors.html @@ -0,0 +1,9 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html new file mode 100644 index 0000000..a66e7ba --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestParameterizedIncludeSelectorsInvalid.html @@ -0,0 +1,9 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html new file mode 100644 index 0000000..b59fb7b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.html @@ -0,0 +1,4 @@ + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts new file mode 100644 index 0000000..c6dd7c6 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBinding.ts @@ -0,0 +1,9 @@ +export class TestQualifiedActionBinding extends PXScreen { + Document = createSingle(QualifiedActionDocument); + SaveAction!: PXActionState; +} + +export class QualifiedActionDocument extends PXView { + AdjustDocAmt!: PXActionState; + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html new file mode 100644 index 0000000..89a4436 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts new file mode 100644 index 0000000..8c990ca --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQualifiedActionBindingInvalid.ts @@ -0,0 +1,7 @@ +export class TestQualifiedActionBindingInvalid extends PXScreen { + Document = createSingle(QualifiedActionDocumentInvalid); +} + +export class QualifiedActionDocumentInvalid extends PXView { + AdjustDocAmt!: PXActionState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html b/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html index e6c5c6a..6102bed 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUnboundField.html @@ -1,7 +1,7 @@ - - + + diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html index 46d322f..9093f19 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.html @@ -15,6 +15,8 @@ + + diff --git a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts index ba3343f..8041b4e 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts +++ b/acumate-plugin/src/test/fixtures/html/TestScreenUsing.ts @@ -11,6 +11,8 @@ export class ProdItemSelectedView extends PXView { export class ItemConfigurationView extends PXView { ConfigurationID!: PXFieldState; Revision!: PXFieldState; + ConfigureEntry!: PXActionState; + Reconfigure!: PXActionState; } export class TestScreenUsingMaint extends PXScreen { diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html new file mode 100644 index 0000000..a33d883 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts new file mode 100644 index 0000000..1678a8b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBinding.ts @@ -0,0 +1,12 @@ +@graphInfo({ + graphType: "PX.SM.ProjectNewUiFrontendFileMaintenance", + primaryView: "EstimateRecordSelected", +}) +export class TestStateFieldBinding extends PXScreen { + EstimateRecordSelected = createSingle(EstimateRecordSelected); + SaveAction!: PXActionState; +} + +export class EstimateRecordSelected extends PXView { + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html new file mode 100644 index 0000000..60ee926 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts new file mode 100644 index 0000000..573c69e --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestStateFieldBindingInvalid.ts @@ -0,0 +1,7 @@ +export class TestStateFieldBindingInvalid extends PXScreen { + EstimateRecordSelected = createSingle(EstimateRecordSelectedInvalid); +} + +export class EstimateRecordSelectedInvalid extends PXView { + ImageUrl!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html new file mode 100644 index 0000000..d6f463d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts new file mode 100644 index 0000000..ee80349 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestTemplateBindings.ts @@ -0,0 +1,3 @@ +import { HtmlTestMaint } from "./TestScreen"; + +export class TestTemplateBindings extends HtmlTestMaint {} diff --git a/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html new file mode 100644 index 0000000..7559610 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts new file mode 100644 index 0000000..32f7f5d --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestViewBindingImportedBase.ts @@ -0,0 +1,12 @@ +declare class PXScreen {} +declare class PXFieldState {} +declare function createSingle(view: unknown): unknown; +declare class ImportedContactBase {} + +export class TestViewBindingImportedBase extends PXScreen { + ImportedContact = createSingle(ImportedContact); +} + +export class ImportedContact extends ImportedContactBase { + CustomField!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html new file mode 100644 index 0000000..1705a59 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts new file mode 100644 index 0000000..bc68ca9 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-extension-mixin-template.ts @@ -0,0 +1,7 @@ +export abstract class IncludeExtensionMixinBase { + addItemParameters = createSingle(IncludeExtensionMixinParameters); +} + +export class IncludeExtensionMixinParameters extends PXView { + BaseParam!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html new file mode 100644 index 0000000..78e8fa6 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts new file mode 100644 index 0000000..86cb17e --- /dev/null +++ b/acumate-plugin/src/test/fixtures/includes/include-field-modification-template.ts @@ -0,0 +1,9 @@ +export class IncludeModificationView extends PXView { + Anchor!: PXFieldState; + IncludedAlpha!: PXFieldState; + IncludedBeta!: PXFieldState; +} + +export class IncludeModificationMaint extends PXScreen { + includedView!: PXView; +} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html new file mode 100644 index 0000000..a7f9be4 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.html @@ -0,0 +1,3 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts new file mode 100644 index 0000000..d1e1521 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Approvals.ts @@ -0,0 +1,5 @@ +import { SO301000 } from "../SO301000"; + +export interface SO301000_Approvals extends SO301000 {} + +export class SO301000_Approvals {} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html new file mode 100644 index 0000000..a92e699 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.html @@ -0,0 +1,5 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts new file mode 100644 index 0000000..52eb4eb --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_Discounts.ts @@ -0,0 +1,12 @@ +import { SO301000 } from "../SO301000"; +import { SO301000_Approvals } from "./SO301000_Approvals"; + +export interface SO301000_Discounts extends SO301000, SO301000_Approvals {} + +export class SO301000_Discounts { + DiscountDetails = createCollection(SODiscountDetails); +} + +export class SODiscountDetails extends PXView { + DiscountID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html new file mode 100644 index 0000000..4ddad52 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.html @@ -0,0 +1,6 @@ + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts new file mode 100644 index 0000000..05cfe0b --- /dev/null +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_IncludeExtensionMixin.ts @@ -0,0 +1,13 @@ +import { SO301000 } from "../SO301000"; +import { + IncludeExtensionMixinBase, + IncludeExtensionMixinParameters, +} from "../../../../includes/include-extension-mixin-template"; + +export interface SO301000_IncludeExtensionMixin extends SO301000, IncludeExtensionMixinBase { } +export class SO301000_IncludeExtensionMixin extends IncludeExtensionMixinBase { } + +export interface SO301000_IncludeExtensionMixinParameters extends IncludeExtensionMixinParameters { } +export class SO301000_IncludeExtensionMixinParameters { + VendorID!: PXFieldState; +} diff --git a/acumate-plugin/src/test/suite/clientControlsService.test.ts b/acumate-plugin/src/test/suite/clientControlsService.test.ts index a7f3323..3677d1b 100644 --- a/acumate-plugin/src/test/suite/clientControlsService.test.ts +++ b/acumate-plugin/src/test/suite/clientControlsService.test.ts @@ -42,5 +42,13 @@ function assertBarcodeControl(controls: ReturnType prop.name) ?? []; assert.ok(propNames.includes('soundControl')); assert.ok(propNames.includes('soundPath')); + + const button = controls.find(control => control.tagName === 'qp-button'); + assert.ok(button, 'Expected qp-button control to be discovered'); + assert.strictEqual(button?.config?.typeName, 'IButtonControlConfig'); + const buttonProps = button?.config?.definition?.properties.map(prop => prop.name) ?? []; + assert.ok(buttonProps.includes('id'), 'Expected inherited id config property'); + assert.ok(buttonProps.includes('tabIndex'), 'Expected inherited tabIndex config property'); + assert.ok(buttonProps.includes('text'), 'Expected own text config property'); } diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index 5992626..ae3b824 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -19,9 +19,13 @@ const qpTemplateFixturePath = path.join(fixturesRoot, 'TestQpTemplate.html'); const qpTemplateRecordInsidePath = path.join(fixturesRoot, 'TestQpTemplateRecordInside.html'); const qpTemplateRecordOutsidePath = path.join(fixturesRoot, 'TestQpTemplateRecordOutside.html'); const includeHostPath = path.join(fixturesRoot, 'TestIncludeHost.html'); +const includeFieldModificationHostPath = path.join(fixturesRoot, 'TestIncludeFieldModificationHost.html'); const importedFixturePath = path.join(fixturesRoot, 'TestScreenImported.html'); const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletion.html'); const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html'); +const duplicateViewNamesFixturePath = path.join(fixturesRoot, 'TestDuplicateViewNames.html'); +const stateFieldBindingPath = path.join(fixturesRoot, 'TestStateFieldBinding.html'); +const qualifiedActionBindingPath = path.join(fixturesRoot, 'TestQualifiedActionBinding.html'); const screenFixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/screens'); const screenExtensionHtmlPath = path.join( screenFixturesRoot, @@ -51,6 +55,20 @@ const screenSelectorHtmlPath = path.join( 'extensions', 'SO301000_FieldSelectors.html' ); +const screenDependentExtensionHtmlPath = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_Discounts.html' +); +const screenIncludeExtensionMixinHtmlPath = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_IncludeExtensionMixin.html' +); const fieldControlHtmlPath = path.join(fixturesRoot, 'FieldControlInfo.html'); const backendGraphName = 'PX.SM.ProjectNewUiFrontendFileMaintenance'; @@ -198,6 +216,97 @@ describe('HTML completion provider integration', () => { assert.ok(/qp-drop-down/.test(value), 'Hover should show default control type'); }); + it('shows backend metadata for fields scoped by selector target view', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + CurrentDocument: { + name: 'CurrentDocument', + fields: { + AMCuryEstimateTotal: { + name: 'AMCuryEstimateTotal', + displayName: 'Manufacturing Estimate Total', + typeName: 'PX.Objects.CS.PXDecimal' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); + const caret = positionAt(document, 'name="AMCuryEstimateTotal"', 'name="'.length + 1); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for selector-scoped HTML field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Manufacturing Estimate Total/.test(value), 'Hover should show selector-scoped backend display name'); + assert.ok(/CurrentDocument/.test(value), 'Hover should show selector target view name'); + }); + + it('shows backend metadata for qp-include child fields from host extension mixins', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + addItemParameters: { + name: 'addItemParameters', + fields: { + VendorID: { + name: 'VendorID', + displayName: 'Vendor', + typeName: 'PX.Objects.AP.Vendor' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for include child extension-mixin field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Vendor/.test(value), 'Hover should show include child backend display name'); + assert.ok(/addItemParameters/.test(value), 'Hover should show include template view name'); + }); + + it('shows backend metadata for non-button state.bind field bindings', async () => { + const graphStructure: GraphStructure = { + name: backendGraphName, + views: { + EstimateRecordSelected: { + name: 'EstimateRecordSelected', + fields: { + ImageUrl: { + name: 'ImageUrl', + displayName: 'Estimate Image', + typeName: 'System.String' + } + } + } + } + }; + AcuMateContext.ApiService = new HtmlMockApiClient({ [backendGraphName]: graphStructure }); + + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const caret = positionAt( + document, + 'state.bind="EstimateRecordSelected.ImageUrl"', + 'state.bind="EstimateRecordSelected.'.length + 1 + ); + const hover = await provideHtmlFieldHover(document, caret); + assert.ok(hover, 'Expected hover result for state.bind field'); + const contents = Array.isArray(hover!.contents) ? hover!.contents : [hover!.contents]; + const first = contents[0]; + const value = first instanceof vscode.MarkdownString ? first.value : `${first}`; + assert.ok(/Estimate Image/.test(value), 'Hover should show state.bind backend display name'); + assert.ok(/EstimateRecordSelected/.test(value), 'Hover should show state.bind view name'); + }); + it('suggests view names for using view attribute', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); @@ -281,6 +390,61 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('AddBlanketLineOK'), 'AddBlanketLineOK action not suggested'); }); + it('suggests PXAction names from using view context', async () => { + const document = await vscode.workspace.openTextDocument(usingFixturePath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'state.bind="ConfigureEntry"', 'state.bind="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for using view state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view'); + }); + + it('suggests qualified view action names for qp-button state.bind attributes', async () => { + const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt( + document, + 'state.bind="Document.AdjustDocAmt"', + 'state.bind="Document.'.length + ); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for qualified action state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('Document.AdjustDocAmt'), 'Document.AdjustDocAmt not suggested'); + }); + + it('suggests view + field pairs for non-button state.bind attributes', async () => { + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'state.bind=""', 'state.bind="'.length); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No completions returned for non-button state.bind'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('EstimateRecordSelected.ImageUrl'), 'EstimateRecordSelected.ImageUrl not suggested'); + assert.ok(!labels.includes('SaveAction'), 'Non-button state.bind should not suggest PXAction names'); + }); + + it('suggests field names from selector target view context', async () => { + const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="AMCuryEstimateTotal"', 'name="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No field completions returned for selector target view'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('AMCuryEstimateTotal'), 'AMCuryEstimateTotal not suggested from selector target view'); + }); + + it('suggests qp-include child field names from host extension mixins', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'No field completions returned for include child field'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('VendorID'), 'VendorID not suggested from include host extension mixin'); + }); + it('suggests field names for using containers inheriting parent views', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlCompletionProvider(); @@ -369,6 +533,8 @@ describe('HTML completion provider integration', () => { const completions = await provider.provideCompletionItems(document, caret); assert.ok(completions && completions.length > 0, 'No completions returned for config.bind'); const labels = completions.map(item => item.label); + assert.ok(labels.includes('id'), 'id config property not suggested'); + assert.ok(labels.includes('tabIndex'), 'tabIndex config property not suggested'); assert.ok(labels.includes('enabled'), 'enabled config property not suggested'); assert.ok(labels.includes('dialogResult'), 'dialogResult config property not suggested'); }); @@ -403,6 +569,29 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates to the imported view class when class names collide', async () => { + const document = await vscode.workspace.openTextDocument(duplicateViewNamesFixturePath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="WcID"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestDuplicateViewNames.models.ts')), + 'Expected definition inside the directly imported SelectionFilter model' + ); + }); + + it('does not navigate from unbound field names', async () => { + const htmlPath = path.join(fixturesRoot, 'TestScreenUnboundField.html'); + const document = await vscode.workspace.openTextDocument(htmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="actions"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.strictEqual(locations.length, 0, 'Expected no definitions for unbound field name'); + }); + it('navigates from field inside using container with custom view', async () => { const document = await vscode.workspace.openTextDocument(usingFixturePath); const provider = new HtmlDefinitionProvider(); @@ -533,6 +722,53 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from using view state.bind attribute to PXAction definition', async () => { + const document = await vscode.workspace.openTextDocument(usingFixturePath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'state.bind="ConfigureEntry"', 'state.bind="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestScreenUsing.ts')), + 'Expected using view action definition inside TestScreenUsing.ts' + ); + }); + + it('navigates from qualified qp-button state.bind attribute to view PXAction definition', async () => { + const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'state.bind="Document.AdjustDocAmt"', + 'state.bind="Document.'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qualified view action'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestQualifiedActionBinding.ts')), + 'Expected qualified view action definition inside TestQualifiedActionBinding.ts' + ); + }); + + it('navigates from non-button state.bind attribute to PXField property', async () => { + const document = await vscode.workspace.openTextDocument(stateFieldBindingPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'state.bind="EstimateRecordSelected.ImageUrl"', + 'state.bind="EstimateRecordSelected.'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for non-button state.bind field'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestStateFieldBinding.ts')), + 'Expected state.bind field definition inside TestStateFieldBinding.ts' + ); + }); + it('navigates from qp-include url to referenced file', async () => { const document = await vscode.workspace.openTextDocument(includeHostPath); const provider = new HtmlDefinitionProvider(); @@ -567,6 +803,96 @@ describe('HTML definition provider integration', () => { ); }); + it('navigates from customization selector attributes to dependent extension HTML', async () => { + const document = await vscode.workspace.openTextDocument(screenDependentExtensionHtmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + 'after="#tab-Approval"', + 'after="'.length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for dependent extension selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('SO301000_Approvals.html')), + 'Expected navigation to dependent extension HTML' + ); + }); + + it('navigates from qp-include child selector attributes to include template HTML', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + "before=\"#includedView [name='Anchor']\"", + "before=\"".length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include customization selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.html')), + 'Expected navigation to include template HTML' + ); + }); + + it('navigates from qp-include child remove selectors to include template HTML', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt( + document, + "remove=\"#includedView [name='Anchor']\"", + "remove=\"".length + 1 + ); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include remove selector'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.html')), + 'Expected navigation to include template HTML' + ); + }); + + it('navigates from qp-include child field name to include template TS definition', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="IncludedAlpha"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for qp-include child field name'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('include-field-modification-template.ts')), + 'Expected navigation to include template TS file' + ); + }); + + it('navigates from qp-include child field name to host TS definition via include selector target', async () => { + const document = await vscode.workspace.openTextDocument(includeFieldModificationHostPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="HostField"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for host field inside qp-include'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('TestIncludeFieldModificationHost.ts')), + 'Expected navigation to host screen TS file' + ); + }); + + it('navigates from qp-include child field name to host extension mixin definition', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinHtmlPath); + const provider = new HtmlDefinitionProvider(); + const caret = positionAt(document, 'name="VendorID"', 'name="'.length + 1); + const definition = await provider.provideDefinition(document, caret); + const locations = Array.isArray(definition) ? definition : definition ? [definition] : []; + assert.ok(locations.length >= 1, 'No definitions returned for include extension mixin field'); + assert.ok( + locations.some(loc => loc.uri.fsPath.endsWith('SO301000_IncludeExtensionMixin.ts')), + 'Expected navigation to host extension mixin TS file' + ); + }); + it('navigates from selector-injected field name to TS definition', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorHtmlPath); const provider = new HtmlDefinitionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlShared.test.ts b/acumate-plugin/src/test/suite/htmlShared.test.ts index 6a5d0f8..8bac445 100644 --- a/acumate-plugin/src/test/suite/htmlShared.test.ts +++ b/acumate-plugin/src/test/suite/htmlShared.test.ts @@ -8,6 +8,7 @@ import { getAttributeContext, readAttributeAtOffset, findParentViewName, + findViewNameAtOrAbove, } from '../../providers/html-shared'; function extractOffset(template: string): { text: string; offset: number } { @@ -90,4 +91,15 @@ describe('findParentViewName', () => { const viewName = findParentViewName(element); assert.strictEqual(viewName, 'CurrentDocument'); }); + + it('returns the current node view scope for selector targets', async () => { + const { text, offset } = extractOffset(''); + await vscode.workspace.openTextDocument({ language: 'html', content: text }); + const dom = parseDocumentDom(text); + assert.ok(dom); + const node = findNodeAtOffset(dom!, offset); + const element = elevateToElementNode(node); + const viewName = findViewNameAtOrAbove(element); + assert.strictEqual(viewName, 'HeaderView'); + }); }); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index ab24bb1..0a4fb6f 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -22,6 +22,20 @@ const screenSelectorExtensionFixture = path.join( 'extensions', 'SO301000_FieldSelectors.html' ); +const screenDependentExtensionFixture = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_Discounts.html' +); +const screenIncludeExtensionMixinFixture = path.join( + screenFixturesRoot, + 'SO', + 'SO301000', + 'extensions', + 'SO301000_IncludeExtensionMixin.html' +); async function openFixtureDocument(fileName: string) { const fullPath = path.join(fixturesRoot, fileName); @@ -83,6 +97,39 @@ describe('HTML validation diagnostics', () => { assert.ok(diagnostics.some(d => d.message.includes('')), 'Expected invalid using view diagnostic'); }); + it('accepts view bindings whose view class extends an unresolved imported base', async () => { + const document = await openFixtureDocument('TestViewBindingImportedBase.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('must be bound to a valid view') || d.message.includes('must reference a valid view')).length, + 0, + 'Expected no view diagnostics when createSingle points to a local class with an unresolved imported base' + ); + }); + + it('accepts actions declared on the current using view', async () => { + const document = await openFixtureDocument('TestScreenUsing.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute must reference a valid PXAction')).length, + 0, + 'Expected no invalid PXAction diagnostics when binding to using view actions' + ); + }); + + it('resolves view classes by import source when class names collide', async () => { + const document = await openFixtureDocument('TestDuplicateViewNames.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('WcID')).length, + 0, + 'Expected WcID to resolve against the imported SelectionFilter view class' + ); + }); + it('accepts valid screen extension html by combining screen metadata', async () => { const document = await vscode.workspace.openTextDocument(screenExtensionFixture); await validateHtmlFile(document); @@ -120,11 +167,11 @@ describe('HTML validation diagnostics', () => { assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for PXView mixin html'); }); - it('ignores fake fields marked unbound replace-content', async () => { + it('ignores unbound field names', async () => { const document = await openFixtureDocument('TestScreenUnboundField.html'); await validateHtmlFile(document); const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; - assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for unbound replace-content fields'); + assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for unbound fields'); }); it('reports invalid PXAction references in state.bind attributes', async () => { @@ -137,6 +184,48 @@ describe('HTML validation diagnostics', () => { ); }); + it('accepts qualified view action references in qp-button state.bind attributes', async () => { + const document = await openFixtureDocument('TestQualifiedActionBinding.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute must reference a valid PXAction')).length, + 0, + 'Expected no PXAction diagnostics for qualified view action binding' + ); + }); + + it('reports missing qualified view action references in qp-button state.bind attributes', async () => { + const document = await openFixtureDocument('TestQualifiedActionBindingInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('state.bind attribute must reference a valid PXAction')), + 'Expected PXAction diagnostic for invalid qualified view action binding' + ); + }); + + it('accepts non-button state.bind values that reference fields', async () => { + const document = await openFixtureDocument('TestStateFieldBinding.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('state.bind attribute')).length, + 0, + 'Expected no state.bind diagnostics for non-button field binding' + ); + }); + + it('reports non-button state.bind values that reference missing fields', async () => { + const document = await openFixtureDocument('TestStateFieldBindingInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('state.bind attribute references unknown field "MissingImage"')), + 'Expected diagnostic for invalid non-button state.bind field binding' + ); + }); + it('accepts qp-panel ids that map to known views', async () => { const document = await openFixtureDocument('TestPanelValid.html'); await validateHtmlFile(document); @@ -213,7 +302,7 @@ describe('HTML validation diagnostics', () => { ); }); - it('accepts qp-field, qp-label, and qp-include without id attributes', async () => { + it('accepts id-optional qp controls without id attributes', async () => { const document = await openFixtureDocument('TestControlIdOptional.html'); await validateHtmlFile(document); const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; @@ -247,6 +336,48 @@ describe('HTML validation diagnostics', () => { ); }); + it('accepts field modifications that target a qp-include template TS model', async () => { + const document = await openFixtureDocument('TestIncludeFieldModificationHost.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('') || d.message.includes('field "')).length, + 0, + 'Expected no field diagnostics when qp-include children use fields from include TS metadata' + ); + }); + + it('reports qp-include field modifications missing from host and include TS models', async () => { + const document = await openFixtureDocument('TestIncludeFieldModificationHostInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('') || d.message.includes('field "')), + 'Expected field diagnostic when qp-include child field is unknown in include TS metadata' + ); + }); + + it('validates qp-include child selectors after applying include parameters', async () => { + const document = await openFixtureDocument('TestParameterizedIncludeSelectors.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => d.message.includes('selector')).length, + 0, + 'Expected no selector diagnostics for parameterized qp-include selectors' + ); + }); + + it('reports invalid qp-include modify selectors after applying include parameters', async () => { + const document = await openFixtureDocument('TestParameterizedIncludeSelectorsInvalid.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('modify selector') && d.message.includes('MissingEmail')), + 'Expected diagnostic for invalid parameterized qp-include modify selector' + ); + }); + it('accepts qp-template name values defined by ScreenTemplates', async () => { const document = await openFixtureDocument('TestQpTemplate.html'); await validateHtmlFile(document); @@ -318,6 +449,13 @@ describe('HTML validation diagnostics', () => { ); }); + it('ignores validations for mustache-templated HTML bindings', async () => { + const document = await openFixtureDocument('TestTemplateBindings.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics for runtime-templated bindings'); + }); + it('accepts qp-button config.bind when config matches schema', async () => { const document = await openFixtureDocument('TestConfigBindingValid.html'); await validateHtmlFile(document); @@ -380,6 +518,30 @@ describe('HTML validation diagnostics', () => { ); }); + it('resolves customization selectors against dependent extension HTML', async () => { + const document = await vscode.workspace.openTextDocument(screenDependentExtensionFixture); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + !diagnostics.some(d => d.message.includes('after selector') && d.message.includes('tab-Approval')), + 'Expected selector targeting a dependent extension element to be valid' + ); + }); + + it('resolves qp-include child selectors and fields against include template metadata with host mixins', async () => { + const document = await vscode.workspace.openTextDocument(screenIncludeExtensionMixinFixture); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.filter(d => + d.message.includes('fsColumnB-IncludeMixin') || + d.message.includes('"VendorID"') + ).length, + 0, + 'Expected include child selector and extension-mixin field to validate' + ); + }); + it('derives view metadata from selector targets when lacks a parent view', async () => { const document = await vscode.workspace.openTextDocument(screenSelectorExtensionFixture); await validateHtmlFile(document); diff --git a/acumate-plugin/src/utils.ts b/acumate-plugin/src/utils.ts index e9f535e..468feff 100644 --- a/acumate-plugin/src/utils.ts +++ b/acumate-plugin/src/utils.ts @@ -550,7 +550,47 @@ export function getClassPropertiesFromTs( } export function createClassInfoLookup(classInfos: CollectedClassInfo[]): Map { - return new Map(classInfos.map(info => [info.className, info])); + const lookup = new Map(); + for (const info of classInfos) { + if (!lookup.has(info.className)) { + lookup.set(info.className, info); + } + lookup.set(getClassInfoLookupKey(info.sourceFile.fileName, info.className), info); + } + return lookup; +} + +function getClassInfoLookupKey(fileName: string | undefined, className: string): string { + return `${path.normalize(fileName ?? '')}::${className}`; +} + +export function resolveClassInfoForProperty( + property: ClassPropertyInfo, + classInfoLookup: Map +): CollectedClassInfo | undefined { + const className = property.viewClassName; + if (!className) { + return undefined; + } + + const sourceFileName = property.sourceFile.fileName; + const localClass = classInfoLookup.get(getClassInfoLookupKey(sourceFileName, className)); + if (localClass) { + return localClass; + } + + const moduleSpecifier = getImportModuleSpecifier(property.sourceFile, className); + if (moduleSpecifier) { + const resolvedPath = resolveModulePath(sourceFileName, moduleSpecifier); + const importedClass = resolvedPath + ? classInfoLookup.get(getClassInfoLookupKey(resolvedPath, className)) + : undefined; + if (importedClass) { + return importedClass; + } + } + + return classInfoLookup.get(className); } export interface ViewResolution { @@ -578,7 +618,7 @@ export function resolveViewBinding( continue; } - const viewClass = property.viewClassName ? classInfoLookup.get(property.viewClassName) : undefined; + const viewClass = resolveClassInfoForProperty(property, classInfoLookup); return { screenClass, property, viewClass }; } diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index f8ebdfd..994fd71 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -1,4 +1,3 @@ -import path from "path"; import vscode from "vscode"; import { Parser, DomHandler } from "htmlparser2"; import { @@ -14,24 +13,48 @@ import { parseConfigObject, filterClassesBySource, } from "../../utils"; -import { findParentViewName } from "../../providers/html-shared"; +import { findParentViewName, isActionStateBindTag } from "../../providers/html-shared"; import { getIncludeMetadata } from "../../services/include-service"; import { getScreenTemplates } from "../../services/screen-template-service"; import { getClientControlsMetadata, ClientControlMetadata } from "../../services/client-controls-service"; import { AcuMateContext } from "../../plugin-context"; import { getBaseScreenDocument, - isCustomizationSelectorAttribute, queryBaseScreenElements, BaseScreenDocument, - getCustomizationSelectorAttributes, + getScreenDocumentDisplayName, } from "../../services/screen-html-service"; import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; +import { + HtmlIncludeFieldContext, + HtmlIncludeTemplateFieldContextCache, + forEachCustomizationSelector, + getIncludeFieldContext, + hasTemplateCustomizationSelector, + hasTemplateExpression, + resolveHtmlField, +} from "../../services/html-field-context-service"; // The validator turns the TypeScript model into CollectedClassInfo entries for every PXScreen/PXView // and then uses that metadata when validating the HTML DOM. const includeIntrinsicAttributes = new Set(["id", "class", "style", "slot"]); -const idOptionalTags = new Set(["qp-field", "qp-label", "qp-include"]); +const idOptionalTags = new Set([ + "qp-field", + "qp-data-component", + "qp-data-components", + "qp-label", + "qp-include", + "qp-informer-rack", + "qp-longrun-indicator", + "qp-nested-screen", + "qp-screen-configuration-menu", + "qp-translation-validation", + "qp-wait-cursor", + "qp-wiki-tooltip", +]); + +type IncludeFieldValidationContext = HtmlIncludeFieldContext; +type IncludeTemplateFieldValidationCache = HtmlIncludeTemplateFieldContextCache; function pushHtmlDiagnostic( diagnostics: vscode.Diagnostic[], @@ -104,7 +127,9 @@ export async function validateHtmlFile(document: vscode.TextDocument) { baseScreenDocument, suppression, undefined, - false + false, + undefined, + new Map() ); } }, @@ -137,7 +162,9 @@ function validateDom( baseScreenDocument: BaseScreenDocument | undefined, suppression: SuppressionEngine, panelViewContext?: CollectedClassInfo, - isInsideDataFeed = false + isInsideDataFeed = false, + includeFieldContext: IncludeFieldValidationContext | undefined = undefined, + includeFieldContextCache: IncludeTemplateFieldValidationCache = new Map() ) { const classInfoMap = createClassInfoLookup(classProperties); const screenClasses = filterScreenLikeClasses(relevantClassInfos); @@ -145,6 +172,11 @@ function validateDom( const hasScreenMetadata = screenClasses.length > 0; const canValidateActions = classProperties.length > 0; const viewResolutionCache = new Map(); + const hostFieldMetadataContext = { + classInfoLookup: classInfoMap, + screenClasses, + viewResolutionCache, + }; // Screen classes contain PXView and PXViewCollection properties. We cache resolutions so // repeated use of the same view name does not require scanning every screen class again. @@ -162,9 +194,28 @@ function validateDom( return resolution; } + function isValidSingleViewBinding(viewResolution: ViewResolution | undefined): boolean { + if (!viewResolution || viewResolution.property.kind !== "view" || !viewResolution.property.viewClassName) { + return false; + } + + return !viewResolution.viewClass || viewResolution.viewClass.type === "PXView" || viewResolution.viewClass.type === undefined; + } + + function getIncludeFieldValidationContext(node: any): IncludeFieldValidationContext | undefined { + return getIncludeFieldContext({ + documentPath: htmlFilePath, + includeNode: node, + hostTsFilePaths: getRelatedTsFiles(htmlFilePath), + workspaceRoots, + cache: includeFieldContextCache, + }); + } + // Custom validation logic goes here dom.forEach((node) => { let nextPanelViewContext = panelViewContext; + let nextIncludeFieldContext = includeFieldContext; const normalizedTagName = node.type === "tag" && typeof node.name === "string" ? node.name.toLowerCase() : ""; const elementId = node.type === "tag" ? getElementId(node) : ""; @@ -175,7 +226,7 @@ function validateDom( const requiresIdAttribute = normalizedTagName === "qp-panel" || (normalizedTagName && controlMetadata.has(normalizedTagName) && !idOptionalTags.has(normalizedTagName)); - if (requiresIdAttribute && !elementId.length) { + if (requiresIdAttribute && !elementId.length && !hasConfigId(node)) { const range = getRange(content, node); const message = normalizedTagName === "qp-panel" @@ -191,15 +242,12 @@ function validateDom( hasScreenMetadata && node.type === "tag" && node.name === "qp-fieldset" && - node.attribs[`view.bind`] + node.attribs[`view.bind`] && + !hasTemplateExpression(node.attribs[`view.bind`]) ) { const viewName = node.attribs[`view.bind`]; const viewResolution = resolveView(viewName); - const hasValidView = - viewResolution && - viewResolution.property.viewClassName && - viewResolution.viewClass && - viewResolution.viewClass.type === "PXView"; + const hasValidView = isValidSingleViewBinding(viewResolution); if (!hasValidView) { const range = getRange(content, node); @@ -213,7 +261,7 @@ function validateDom( } if (hasScreenMetadata && node.type === "tag" && node.name === "qp-panel") { - if (elementId.length) { + if (elementId.length && !hasTemplateExpression(elementId)) { const viewResolution = resolveView(elementId); if (!viewResolution) { const range = getRange(content, node); @@ -229,14 +277,16 @@ function validateDom( } } - if (hasScreenMetadata && node.type === "tag" && node.name === "using" && node.attribs.view) { + if ( + hasScreenMetadata && + node.type === "tag" && + node.name === "using" && + node.attribs.view && + !hasTemplateExpression(node.attribs.view) + ) { const viewName = node.attribs.view; const viewResolution = resolveView(viewName); - const hasValidView = - viewResolution && - viewResolution.property.viewClassName && - viewResolution.viewClass && - viewResolution.viewClass.type === "PXView"; + const hasValidView = isValidSingleViewBinding(viewResolution); if (!hasValidView) { const range = getRange(content, node); @@ -250,9 +300,14 @@ function validateDom( } const actionBinding = node.attribs?.["state.bind"]; - if (canValidateActions && typeof actionBinding === "string" && actionBinding.length) { - const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; - if (!actionLookup.has(actionBinding) && !panelHasAction) { + if ( + canValidateActions && + typeof actionBinding === "string" && + actionBinding.length && + !hasTemplateExpression(actionBinding) && + isActionStateBindTag(normalizedTagName) + ) { + if (!resolveActionBinding(actionBinding, node)) { const range = getRange(content, node); pushHtmlDiagnostic( diagnostics, @@ -262,9 +317,19 @@ function validateDom( ); } } + else if ( + hasScreenMetadata && + typeof actionBinding === "string" && + actionBinding.length && + !hasTemplateExpression(actionBinding) && + !isActionStateBindTag(normalizedTagName) + ) { + validateStateFieldBinding(actionBinding, node); + } if (node.type === "tag" && node.name === "qp-include") { validateIncludeNode(node, diagnostics, content, htmlFilePath, workspaceRoots, suppression); + nextIncludeFieldContext = getIncludeFieldValidationContext(node); } if ( @@ -308,25 +373,52 @@ function validateDom( node.name === "field" && node.attribs.name ) { - const viewSpecified = node.attribs.name.includes("."); - const [viewFromNameAttribute, fieldFromNameAttribute] = viewSpecified ? node.attribs.name.split(".") : []; - - const isUnboundReplacement = - Object.prototype.hasOwnProperty.call(node.attribs, "unbound") && - Object.prototype.hasOwnProperty.call(node.attribs, "replace-content"); + const isUnboundField = Object.prototype.hasOwnProperty.call(node.attribs, "unbound"); + + if (!isUnboundField) { + const hostIncludeSelectorResolution = includeFieldContext + ? resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: includeFieldContext.templateDocument, + }) + : undefined; + const hostBaseResolution = resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: baseScreenDocument, + }); + const hostResolution = hostIncludeSelectorResolution?.fieldProperty + ? hostIncludeSelectorResolution + : hostBaseResolution; + if (hostResolution?.hasTemplatedBinding || (!hostResolution?.viewName && hasTemplateCustomizationSelector(node))) { + return; + } - if (!isUnboundReplacement) { - let viewName = viewSpecified ? viewFromNameAttribute : findParentViewName(node); - if (!viewName) { - viewName = getViewNameFromCustomizationSelectors(node); + const includeResolution = !hostResolution?.fieldProperty && includeFieldContext + ? resolveHtmlField({ + rawFieldName: node.attribs.name, + elementNode: node, + metadataContext: includeFieldContext, + selectorDocument: includeFieldContext.templateDocument, + parameterValues: includeFieldContext.parameterValues, + allowAnyViewWhenUnscoped: true, + useParentView: false, + }) + : undefined; + if (includeResolution?.hasTemplatedBinding) { + return; } - const fieldName = viewSpecified ? fieldFromNameAttribute : node.attribs.name; - const viewResolution = resolveView(viewName); - const viewClass = viewResolution?.viewClass; - const fieldProperty = viewClass?.properties.get(fieldName); - const isValidField = fieldProperty?.kind === "field"; + + const fieldResolution = hostResolution?.fieldProperty ? hostResolution : includeResolution ?? hostResolution; + const isValidField = + fieldResolution?.fieldProperty?.kind === "field"; if (!isValidField) { const range = getRange(content, node); + const viewName = fieldResolution?.viewName; + const fieldName = fieldResolution?.fieldName ?? node.attribs.name; pushHtmlDiagnostic( diagnostics, suppression, @@ -353,12 +445,18 @@ function validateDom( baseScreenDocument, suppression, nextPanelViewContext, - currentDataFeedContext + currentDataFeedContext, + nextIncludeFieldContext, + includeFieldContextCache ); } }); function validateTemplateName(templateName: string, node: any, insideDataFeed: boolean) { const normalizedTemplateName = templateName.trim(); + if (hasTemplateExpression(normalizedTemplateName)) { + return; + } + if (normalizedTemplateName.startsWith("record-") && !insideDataFeed) { const range = getRange(content, node); pushHtmlDiagnostic( @@ -386,84 +484,72 @@ function validateDom( } function validateCustomizationSelectors(node: any) { - if (!baseScreenDocument) { + const selectorDocuments = getSelectorValidationDocuments(); + if (!selectorDocuments.length) { return; } forEachCustomizationSelector(node, (attributeName, rawValue, normalizedValue) => { + if (hasTemplateExpression(normalizedValue)) { + return; + } + const range = getAttributeValueRange(content, node, attributeName, rawValue) ?? getRange(content, node); - const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); - if (error) { - pushHtmlDiagnostic( - diagnostics, - suppression, - range, - `The ${attributeName} selector "${rawValue}" is not a valid CSS selector (${error}).` - ); - return; + + let selectorMatched = false; + for (const document of selectorDocuments) { + const { nodes, error } = queryBaseScreenElements(document, normalizedValue); + if (error) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The ${attributeName} selector "${rawValue}" is not a valid CSS selector (${error}).` + ); + return; + } + + if (nodes.length) { + selectorMatched = true; + break; + } } - if (!nodes.length) { - const baseName = path.basename(baseScreenDocument.filePath); + if (!selectorMatched) { + const documentNames = selectorDocuments.map(getScreenDocumentDisplayName).join(" or "); pushHtmlDiagnostic( diagnostics, suppression, range, - `The ${attributeName} selector "${rawValue}" does not match any elements in ${baseName}.` + `The ${attributeName} selector "${rawValue}" does not match any elements in ${documentNames}.` ); } }); } - function getViewNameFromCustomizationSelectors(node: any): string | undefined { - if (!baseScreenDocument) { - return undefined; + function resolveActionBinding(actionBinding: string, node: any): boolean { + const qualifiedAction = parseQualifiedActionBinding(actionBinding); + if (qualifiedAction) { + return resolveView(qualifiedAction.viewName)?.viewClass?.properties.get(qualifiedAction.actionName)?.kind === "action"; } - let selectorViewName: string | undefined; - forEachCustomizationSelector(node, (_attributeName, _rawValue, normalizedValue) => { - if (selectorViewName) { - return; - } - - const { nodes, error } = queryBaseScreenElements(baseScreenDocument, normalizedValue); - if (error || !nodes.length) { - return; - } - - for (const target of nodes) { - const candidateViewName = findParentViewName(target); - if (candidateViewName) { - selectorViewName = candidateViewName; - return; - } - } - }); - - return selectorViewName; + const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action"; + const scopedViewName = findParentViewName(node); + const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action"; + return actionLookup.has(actionBinding) || panelHasAction || viewHasAction; } - function forEachCustomizationSelector( - node: any, - callback: (attributeName: string, rawValue: string, normalizedValue: string) => void - ) { - if (!node?.attribs) { - return; + function getSelectorValidationDocuments(): BaseScreenDocument[] { + const documents: BaseScreenDocument[] = []; + const includeDocument = includeFieldContext?.templateDocument; + if (includeDocument) { + documents.push(includeDocument); } - - for (const [attributeName, attributeValue] of Object.entries(node.attribs)) { - if (!isCustomizationSelectorAttribute(attributeName) || typeof attributeValue !== "string") { - continue; - } - - const normalizedValue = attributeValue.trim(); - if (!normalizedValue.length) { - continue; - } - - callback(attributeName, attributeValue, normalizedValue); + if (baseScreenDocument) { + documents.push(baseScreenDocument); } + return documents; } function validateConfigBinding(bindingValue: string, node: any) { @@ -482,6 +568,10 @@ function validateDom( const configObject = parseConfigObject(bindingValue); const range = getRange(content, node); if (!configObject) { + if (hasTemplateExpression(bindingValue)) { + return; + } + pushHtmlDiagnostic( diagnostics, suppression, @@ -527,7 +617,7 @@ function validateDom( } const normalizedValue = rawValue.trim(); - if (!normalizedValue.length) { + if (!normalizedValue.length || hasTemplateExpression(normalizedValue)) { return; } @@ -548,6 +638,10 @@ function validateDom( function validateControlStateBinding(bindingValue: string, node: any) { + if (hasTemplateExpression(bindingValue)) { + return; + } + const parts = bindingValue.split("."); const range = getRange(content, node); if (parts.length !== 2) { @@ -594,6 +688,63 @@ function validateDom( ); } } + + function validateStateFieldBinding(bindingValue: string, node: any) { + const fieldResolution = resolveHtmlField({ + rawFieldName: bindingValue, + elementNode: node, + metadataContext: hostFieldMetadataContext, + selectorDocument: baseScreenDocument, + useParentView: false, + }); + if (fieldResolution?.hasTemplatedBinding) { + return; + } + + const range = getRange(content, node); + if (!fieldResolution?.viewName) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + "The state.bind attribute must use the . format for non-button controls." + ); + return; + } + + if (!fieldResolution.viewResolution) { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The state.bind attribute references unknown view "${fieldResolution.viewName}".` + ); + return; + } + + if (fieldResolution.fieldProperty?.kind !== "field") { + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + `The state.bind attribute references unknown field "${fieldResolution.fieldName}" on view "${fieldResolution.viewName}".` + ); + } + } + + function hasConfigId(node: any): boolean { + const rawConfig = node.attribs?.["config.bind"]; + if (typeof rawConfig !== "string" || !rawConfig.length) { + return false; + } + + const configObject = parseConfigObject(rawConfig); + if (configObject) { + return Object.prototype.hasOwnProperty.call(configObject, "id"); + } + + return hasTemplateExpression(rawConfig); + } } function validateIncludeNode( @@ -605,7 +756,7 @@ function validateIncludeNode( suppression: SuppressionEngine ) { const includeUrl = node.attribs?.url; - if (typeof includeUrl !== "string" || !includeUrl.length) { + if (typeof includeUrl !== "string" || !includeUrl.length || hasTemplateExpression(includeUrl)) { return; } @@ -669,6 +820,25 @@ function shouldIgnoreIncludeAttribute(attributeName: string): boolean { return false; } +function parseQualifiedActionBinding(value: string | undefined): { viewName: string; actionName: string } | undefined { + if (!value) { + return undefined; + } + + const parts = value.split("."); + if (parts.length !== 2) { + return undefined; + } + + const viewName = parts[0]?.trim(); + const actionName = parts[1]?.trim(); + if (!viewName || !actionName) { + return undefined; + } + + return { viewName, actionName }; +} + function getAttributeValueRange( content: string, node: any, diff --git a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts index 0f50d86..1821cb8 100644 --- a/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts +++ b/acumate-plugin/src/validation/tsValidation/graph-info-validation.ts @@ -15,6 +15,7 @@ import { ClassPropertyInfo, createClassInfoLookup, isScreenLikeClass, + resolveClassInfoForProperty, tryGetGraphTypeFromExtension } from '../../utils'; import { buildBackendActionSet, buildBackendViewMap, normalizeMetaName } from '../../backend-metadata-utils'; @@ -269,7 +270,7 @@ function compareViewClassesWithGraph( } const backendView = backendViewMap.get(normalizedViewName); - const viewClassInfo = classInfoLookup.get(property.viewClassName); + const viewClassInfo = resolveClassInfoForProperty(property, classInfoLookup); if (!viewClassInfo) { continue; }