diff --git a/acumate-plugin/readme.md b/acumate-plugin/readme.md index 60668bf..6e93dd9 100644 --- a/acumate-plugin/readme.md +++ b/acumate-plugin/readme.md @@ -62,7 +62,9 @@ The **AcuMate** extension for Visual Studio Code offers a range of powerful feat - Verifies `` entries against the PXView resolved from the surrounding markup and ignores deliberate `unbound replace-content` placeholders. - Validates `state.bind` attributes point to PXAction members, and `qp-field control-state.bind` values follow the `.` format with existing fields. - Enforces `` bindings by making sure the id maps to an existing PXView, and reuses that view context when checking footer `` actions so dialogs only reference actions exposed by their owning view. + - Requires `qp-panel` nodes and all `qp-*` controls to define `id` attributes (except for `qp-field`, `qp-label`, and `qp-include`) so missing identifiers and misbound panels are caught before packaging. - Enforces Acumatica-specific constructs: required qp-include parameters, rejection of undeclared include attributes, and qp-template name checks sourced from ScreenTemplates metadata. + - Guards `` usages so record templates only validate when the markup sits inside a `` container, matching runtime restrictions. - Leverages client-controls config schemas to inspect `config.bind` JSON on qp-* controls, reporting malformed JSON, missing required properties, and unknown keys before runtime. - Parses customization attributes such as `before`, `after`, `append`, `prepend`, `move`, and ensures their CSS selectors resolve against the base screen HTML so misplaced selectors surface immediately instead of at publish time. - Integrates these diagnostics with ESLint + VS Code so warnings surface consistently in editors and CI. @@ -76,6 +78,13 @@ The **AcuMate** extension for Visual Studio Code offers a range of powerful feat - Offers IntelliSense suggestions for available `view.bind` values sourced from the PXScreen metadata. - Provides field name suggestions that automatically scope to the closest parent view binding, so only valid fields appear. - Attribute parsing tolerates empty values (`view.bind=""`) to keep suggestions responsive while editing. + - Template name completions automatically filter out `record-*` entries unless the caret is inside a ``, keeping suggestions aligned with validation rules. + + ### Logging & Observability + + - All extension subsystems log to a single **AcuMate** output channel, making it easy to trace backend requests, caching behavior, and command execution without hunting through multiple panes. + - Every AcuMate command writes a structured log entry (arguments + timing) so build/validation flows can be audited when integrating with CI or troubleshooting user reports. + - Backend API calls, cache hits/misses, and configuration reloads emit detailed log lines, giving immediate visibility into why a control lookup or metadata request might have failed. 4. **Backend Field Hovers** - Hovering over `` or `` immediately shows backend metadata (display name, type, default control type, originating view) sourced from the same data that powers TypeScript hovers. diff --git a/acumate-plugin/snippets.json b/acumate-plugin/snippets.json index 18a4842..62b4d16 100644 --- a/acumate-plugin/snippets.json +++ b/acumate-plugin/snippets.json @@ -3,8 +3,8 @@ "prefix": ["hook", "handleEvent", "currentRowChanged"], "body": [ "@handleEvent(CustomEventType.CurrentRowChanged, { view: \"${1:ViewName}\" })", - "on${2:Row}Changed(args: CurrentRowChangedHandlerArgs<${3:ViewType}>) {", - " $4", + "on${1:ViewName}Changed(args: CurrentRowChangedHandlerArgs<${2:ViewType}>) {", + " $3", "}" ], "description": "CurrentRowChanged event hook" @@ -13,8 +13,8 @@ "prefix": ["hook", "handleEvent", "rowSelected"], "body": [ "@handleEvent(CustomEventType.RowSelected, { view: \"${1:ViewName}\" })", - "on${2:Row}Changed(args: RowSelectedHandlerArgs<${3:ViewType}>) {", - " $4", + "on${1:ViewName}Changed(args: RowSelectedHandlerArgs<${2:ViewType}>) {", + " $3", "}" ], "description": "RowSelected event hook" @@ -22,9 +22,9 @@ "ValueChanged": { "prefix": ["hook", "handleEvent", "valueChanged"], "body": [ - "@handleEvent(CustomEventType.ValueChanged, { view: \"${1:ViewName}\" })", - "on${1:ViewName}Changed(args: ValueChangedHandlerArgs<${2:ViewType}>) {", - " $3", + "@handleEvent(CustomEventType.ValueChanged, { view: \"${1:ViewName}\", field: \"${2:FieldName}\" })", + "on${2:FieldName}Changed(args: ValueChangedHandlerArgs<${3:ViewType}>) {", + " $4", "}" ], "description": "ValueChanged event hook" @@ -43,8 +43,8 @@ "prefix": ["hook", "handleEvent", "getRowCss"], "body": [ "@handleEvent(CustomEventType.GetRowCss, { view: \"${1:ViewName}\" })", - "get${2:Row}RowCss(args: RowCssHandlerArgs): string | undefined {", - " $3", + "get${1:ViewName}RowCss(args: RowCssHandlerArgs): string | undefined {", + " $2", "}" ], "description": "GetRowCss event hook" @@ -53,8 +53,8 @@ "prefix": ["hook", "handleEvent", "getCellCss"], "body": [ "@handleEvent(CustomEventType.GetCellCss, { view: \"${1:ViewName}\", allColumns: true })", - "get${2:Cell}CellCss(args: CellCssHandlerArgs): string | undefined {", - " $3", + "get${1:ViewName}CellCss(args: CellCssHandlerArgs): string | undefined {", + " $2", "}" ], "description": "GetCellCss event hook" diff --git a/acumate-plugin/src/api/api-service.ts b/acumate-plugin/src/api/api-service.ts index 0a849d8..ad1664d 100644 --- a/acumate-plugin/src/api/api-service.ts +++ b/acumate-plugin/src/api/api-service.ts @@ -4,6 +4,7 @@ import { GraphAPIRoute, GraphAPIStructureRoute, AuthEndpoint, LogoutEndpoint, Fe import { IAcuMateApiClient } from "./acu-mate-api-client"; import { AcuMateContext } from "../plugin-context"; import { FeatureModel } from "../model/FeatureModel"; +import { logError, logInfo } from "../logging/logger"; interface FeatureSetsResponse { sets?: FeatureSetEntry[]; @@ -63,6 +64,7 @@ export class AcuMateApiClient implements IAcuMateApiClient { return; } + logInfo('Logging out from AcuMate backend.'); const response = await fetch(AcuMateContext.ConfigurationService.backedUrl!+LogoutEndpoint, { method:'POST', headers: { @@ -74,24 +76,26 @@ export class AcuMateApiClient implements IAcuMateApiClient { this.sessionCookieHeader = undefined; if (response.ok) { - console.log('Logged out successfully.'); + logInfo('Backend session closed successfully.'); } else { const errorBody = await response.text().catch(() => ""); - console.error(`Authentication failed with status ${response.status}: ${errorBody}`); + logError('Backend logout failed.', { status: response.status, errorBody }); } } private async makeGetRequest(route: string): Promise { if (!AcuMateContext.ConfigurationService.useBackend) { + logInfo('Skipped backend request because acuMate.useBackend is disabled.', { route }); return undefined; } try { + logInfo('Authenticating before backend request.', { route }); const authResponse = await this.auth(); if (authResponse.status !== 200 && authResponse.status !== 204) { const errorBody = await authResponse.text().catch(() => ""); - console.error(`Authentication failed with status ${authResponse.status}: ${errorBody}`); + logError('AcuMate backend authentication failed.', { status: authResponse.status, errorBody }); return undefined; } @@ -106,29 +110,33 @@ export class AcuMateApiClient implements IAcuMateApiClient { headers }; settings.credentials = `include`; + logInfo('Issuing backend GET request.', { url }); const response = await fetch(url, settings); if (!response.ok) { const errorBody = await response.text().catch(() => ""); - console.error(`GET ${url} failed with status ${response.status}: ${errorBody}`); + logError('Backend GET request failed.', { url, status: response.status, errorBody }); return undefined; } const contentType = response.headers.get("content-type") ?? ""; if (!contentType.includes("application/json")) { const errorBody = await response.text().catch(() => ""); - console.error(`GET ${url} returned non-JSON content (${contentType}): ${errorBody}`); + logError('Backend GET returned unexpected content type.', { url, contentType, errorBody }); return undefined; } const data = await response.json(); - - console.log(data); + const summary: Record = { url }; + if (Array.isArray(data)) { + summary.items = data.length; + } + logInfo('Backend GET succeeded.', summary); return data as T; } catch (error) { - console.error('Error making GET request:', error); + logError('Unexpected error during backend GET request.', { route, error }); } finally { await this.logout(); diff --git a/acumate-plugin/src/api/layered-data-service.ts b/acumate-plugin/src/api/layered-data-service.ts index 0a9f6af..52eecad 100644 --- a/acumate-plugin/src/api/layered-data-service.ts +++ b/acumate-plugin/src/api/layered-data-service.ts @@ -5,6 +5,7 @@ import { IAcuMateApiClient } from "./acu-mate-api-client"; import { CachedDataService } from "./cached-data-service"; import { FeaturesCache, GraphAPICache, GraphAPIStructureCachePrefix } from "./constants"; import { FeatureModel } from "../model/FeatureModel"; +import { logInfo } from "../logging/logger"; export class LayeredDataService implements IAcuMateApiClient { @@ -19,9 +20,11 @@ export class LayeredDataService implements IAcuMateApiClient { async getGraphs(): Promise { const cachedResult = await this.cacheService.getGraphs(); if (cachedResult) { + logInfo('Serving graphs from cache.', { count: cachedResult.length }); return cachedResult; } + logInfo('Graph cache miss. Fetching from backend...'); if (this.inflightGraphs) { return this.inflightGraphs; } @@ -29,6 +32,7 @@ export class LayeredDataService implements IAcuMateApiClient { this.inflightGraphs = this.apiService .getGraphs() .then(result => { + logInfo('Graphs fetched from backend.', { count: result?.length ?? 0 }); this.cacheService.store(GraphAPICache, result); return result; }) @@ -43,6 +47,7 @@ export class LayeredDataService implements IAcuMateApiClient { async getGraphStructure(graphName: string): Promise { const cachedResult = await this.cacheService.getGraphStructure(graphName); if (cachedResult) { + logInfo('Serving cached graph structure.', { graphName }); return cachedResult; } @@ -51,9 +56,11 @@ export class LayeredDataService implements IAcuMateApiClient { return existing; } + logInfo('Graph structure cache miss. Fetching from backend...', { graphName }); const pending = this.apiService .getGraphStructure(graphName) .then(result => { + logInfo('Graph structure fetched from backend.', { graphName, hasResult: Boolean(result) }); this.cacheService.store(GraphAPIStructureCachePrefix + graphName, result); return result; }) @@ -68,9 +75,11 @@ export class LayeredDataService implements IAcuMateApiClient { async getFeatures(): Promise { const cachedResult = await this.cacheService.getFeatures(); if (cachedResult) { + logInfo('Serving feature metadata from cache.', { count: cachedResult.length }); return cachedResult; } + logInfo('Feature cache miss. Fetching from backend...'); if (this.inflightFeatures) { return this.inflightFeatures; } @@ -78,6 +87,7 @@ export class LayeredDataService implements IAcuMateApiClient { this.inflightFeatures = this.apiService .getFeatures() .then(result => { + logInfo('Features fetched from backend.', { count: result?.length ?? 0 }); this.cacheService.store(FeaturesCache, result); return result; }) diff --git a/acumate-plugin/src/extension.ts b/acumate-plugin/src/extension.ts index 9e28b92..892f5f8 100644 --- a/acumate-plugin/src/extension.ts +++ b/acumate-plugin/src/extension.ts @@ -21,9 +21,80 @@ import { registerSuppressionCodeActions } from './providers/suppression-code-act import { registerTsHoverProvider } from './providers/ts-hover-provider'; import { getFrontendSourcesPath } from './utils'; import { runWorkspaceScreenValidation, runWorkspaceTypeScriptValidation } from './validation/workspace-validation'; +import { logInfo, logWarn, registerLogger } from './logging/logger'; const HTML_VALIDATION_DEBOUNCE_MS = 250; const pendingHtmlValidationTimers = new Map(); +const OPEN_SETTINGS_LABEL = 'Open AcuMate Settings'; + +let backendConfigWarningShown = false; +let backendDisabledLogged = false; +let backendFeaturesInitialized = false; + +function reportBackendConfigurationState() { + if (!AcuMateContext.ConfigurationService.useBackend) { + if (!backendDisabledLogged) { + backendDisabledLogged = true; + logWarn('acuMate.useBackend is disabled; backend-powered features will remain inactive.'); + } + return; + } + + const missing: string[] = []; + if (!AcuMateContext.ConfigurationService.backedUrl?.trim()) { + missing.push('acuMate.backedUrl'); + } + if (!AcuMateContext.ConfigurationService.login?.trim()) { + missing.push('acuMate.login'); + } + if (!AcuMateContext.ConfigurationService.password?.trim()) { + missing.push('acuMate.password'); + } + if (!AcuMateContext.ConfigurationService.tenant?.trim()) { + missing.push('acuMate.tenant'); + } + + if (missing.length) { + const warning = `AcuMate backend is enabled but missing required settings: ${missing.join(', ')}`; + logWarn(warning); + if (!backendConfigWarningShown) { + backendConfigWarningShown = true; + vscode.window.showWarningMessage(warning, OPEN_SETTINGS_LABEL).then(selection => { + if (selection === OPEN_SETTINGS_LABEL) { + vscode.commands.executeCommand('workbench.action.openSettings', 'acuMate'); + } + }); + } + return; + } + + logInfo('AcuMate backend is enabled and configured.', { backedUrl: AcuMateContext.ConfigurationService.backedUrl }); +} + +function logCommandInvocation(commandId: string, details?: Record) { + logInfo(`Command ${commandId} invoked`, details ?? {}); +} + +function registerConfigurationWatcher(context: vscode.ExtensionContext) { + const disposable = vscode.workspace.onDidChangeConfiguration(event => { + if (!event.affectsConfiguration('acuMate')) { + return; + } + + logInfo('Detected acuMate settings change. Reloading configuration.'); + AcuMateContext.ConfigurationService.reload(); + backendConfigWarningShown = false; + backendDisabledLogged = false; + reportBackendConfigurationState(); + + if (AcuMateContext.ConfigurationService.useBackend && !backendFeaturesInitialized) { + logInfo('Backend enabled after settings change. Initializing IntelliSense providers.'); + createIntelliSenseProviders(context); + } + }); + + context.subscriptions.push(disposable); +} export function activate(context: vscode.ExtensionContext) { init(context); @@ -121,17 +192,23 @@ function createCommands(context: vscode.ExtensionContext) { let disposable; disposable = vscode.commands.registerCommand('acumate.createScreen', async () => { + logCommandInvocation('acumate.createScreen'); createScreen(); }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.createScreenExtension', async () => { + logCommandInvocation('acumate.createScreenExtension'); createScreenExtension(); }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildMenu', async () => { + logCommandInvocation('acumate.buildMenu'); const command = await openBuildMenu(); + if (command) { + logInfo('acumate.buildMenu returned selection', { command }); + } if (command) { vscode.commands.executeCommand(command); } @@ -139,125 +216,149 @@ function createCommands(context: vscode.ExtensionContext) { context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreensDev', async () => { + const options = { + devMode: true, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreensDev', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - devMode: true, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreens', async () => { + const options = { + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreens', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreensByNamesDev', async () => { + const options = { + devMode: true, + byNames: true, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreensByNamesDev', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - devMode: true, - byNames: true, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreensByNames', async () => { + const options = { + byNames: true, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreensByNames', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - byNames: true, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreensByModulesDev', async () => { + const options = { + devMode: true, + byModules: true, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreensByModulesDev', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - devMode: true, - byModules: true, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildScreensByModules', async () => { + const options = { + byModules: true, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.buildScreensByModules', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - byModules: true, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildCurrentScreenDev', async () => { + const options = { + currentScreen: true, + devMode: true, + }; + logCommandInvocation('acumate.buildCurrentScreenDev', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - currentScreen: true, - devMode: true, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.buildCurrentScreen', async () => { + const options = { + currentScreen: true, + }; + logCommandInvocation('acumate.buildCurrentScreen', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - currentScreen: true, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.repeatLastBuildCommand', async () => { + const options = { + noPrompt: true, + byNames: buildCommandsCache?.byNames, + byModules: buildCommandsCache?.byModules, + cache: buildCommandsCache, + }; + logCommandInvocation('acumate.repeatLastBuildCommand', options); buildCommandsCache = { ...buildCommandsCache, - ...await buildScreens({ - noPrompt: true, - byNames: buildCommandsCache.byNames, - byModules: buildCommandsCache.byModules, - cache: buildCommandsCache, - }) + ...await buildScreens(options) }; }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.watchCurrentScreen', async () => { - await buildScreens({ + const options = { watch: true, currentScreen: true, - }); + }; + logCommandInvocation('acumate.watchCurrentScreen', options); + await buildScreens(options); }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.dropCache', async () => { - context.globalState.keys().forEach(key => context.globalState.update(key, undefined)); + const keys = context.globalState.keys(); + logCommandInvocation('acumate.dropCache', { clearedKeys: keys.length }); + keys.forEach(key => context.globalState.update(key, undefined)); }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.validateScreens', async () => { + logCommandInvocation('acumate.validateScreens'); await runWorkspaceScreenValidation(); }); context.subscriptions.push(disposable); disposable = vscode.commands.registerCommand('acumate.validateTypeScriptScreens', async () => { + logCommandInvocation('acumate.validateTypeScriptScreens'); await runWorkspaceTypeScriptValidation(); }); context.subscriptions.push(disposable); @@ -265,7 +366,12 @@ function createCommands(context: vscode.ExtensionContext) { function createIntelliSenseProviders(context: vscode.ExtensionContext) { + if (backendFeaturesInitialized) { + return; + } + if (!AcuMateContext.ConfigurationService.useBackend) { + logWarn('Skipping TypeScript IntelliSense providers because acuMate.useBackend is disabled.'); return; } @@ -284,6 +390,8 @@ function createIntelliSenseProviders(context: vscode.ExtensionContext) { context.subscriptions.push(provider); registerTsHoverProvider(context); + backendFeaturesInitialized = true; + logInfo('TypeScript IntelliSense providers initialized.'); /*provider = vscode.languages.registerCompletionItemProvider( { language:'html', scheme:'file'}, @@ -299,10 +407,13 @@ function createIntelliSenseProviders(context: vscode.ExtensionContext) { } function init(context: vscode.ExtensionContext) { + registerLogger(context); AcuMateContext.ConfigurationService = new ConfigurationService(); const cacheService = new CachedDataService(context.globalState); const apiClient = new AcuMateApiClient(); AcuMateContext.ApiService = new LayeredDataService(cacheService, apiClient); + reportBackendConfigurationState(); + registerConfigurationWatcher(context); AcuMateContext.HtmlValidator = vscode.languages.createDiagnosticCollection('htmlValidator'); diff --git a/acumate-plugin/src/logging/backend-logger.ts b/acumate-plugin/src/logging/backend-logger.ts new file mode 100644 index 0000000..3bc7da4 --- /dev/null +++ b/acumate-plugin/src/logging/backend-logger.ts @@ -0,0 +1,6 @@ +export { + registerLogger as registerBackendLogger, + logInfo as backendInfo, + logWarn as backendWarn, + logError as backendError, +} from './logger'; diff --git a/acumate-plugin/src/logging/logger.ts b/acumate-plugin/src/logging/logger.ts new file mode 100644 index 0000000..876945c --- /dev/null +++ b/acumate-plugin/src/logging/logger.ts @@ -0,0 +1,28 @@ +import vscode from 'vscode'; + +let logChannel: vscode.LogOutputChannel | undefined; + +function getLogChannel(): vscode.LogOutputChannel { + if (!logChannel) { + logChannel = vscode.window.createOutputChannel('AcuMate', { log: true }); + } + + return logChannel; +} + +export function registerLogger(context: vscode.ExtensionContext): void { + const channel = getLogChannel(); + context.subscriptions.push(channel); +} + +export function logInfo(message: string, details?: Record | unknown): void { + getLogChannel().info(message, ...(details ? [details] : [])); +} + +export function logWarn(message: string, details?: Record | unknown): void { + getLogChannel().warn(message, ...(details ? [details] : [])); +} + +export function logError(message: string, details?: Record | unknown): void { + getLogChannel().error(message, ...(details ? [details] : [])); +} diff --git a/acumate-plugin/src/providers/html-completion-provider.ts b/acumate-plugin/src/providers/html-completion-provider.ts index 0ad1aa8..8d72c6e 100644 --- a/acumate-plugin/src/providers/html-completion-provider.ts +++ b/acumate-plugin/src/providers/html-completion-provider.ts @@ -74,7 +74,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return includeCompletions; } - const templateCompletions = this.tryProvideTemplateNameCompletions(document, attributeContext); + const templateCompletions = this.tryProvideTemplateNameCompletions(document, attributeContext, position); if (templateCompletions) { return templateCompletions; } @@ -330,7 +330,8 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { private tryProvideTemplateNameCompletions( document: vscode.TextDocument, - attributeContext: ReturnType + attributeContext: ReturnType, + position: vscode.Position ): vscode.CompletionItem[] | undefined { if (!attributeContext || attributeContext.attributeName !== 'name' || attributeContext.tagName !== 'qp-template') { return undefined; @@ -342,12 +343,21 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return undefined; } - const prefix = (attributeContext.value ?? '').toLowerCase(); + const valueText = attributeContext.value ?? ''; + const valueStart = document.offsetAt(attributeContext.valueRange.start); + const caretOffset = document.offsetAt(position); + const relativeLength = Math.max(0, Math.min(valueText.length, caretOffset - valueStart)); + const prefix = valueText.substring(0, relativeLength).toLowerCase(); + const insideDataFeed = this.isInsideDataFeed(attributeContext.node); const items: vscode.CompletionItem[] = []; - for (const templateName of templates) { + for (const rawName of templates) { + const templateName = rawName.trim(); if (prefix && !templateName.toLowerCase().startsWith(prefix)) { continue; } + if (templateName.startsWith('record-') && !insideDataFeed) { + continue; + } const item = new vscode.CompletionItem(templateName, vscode.CompletionItemKind.EnumMember); item.detail = 'qp-template'; items.push(item); @@ -356,6 +366,20 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider { return items.length ? items : undefined; } + private isInsideDataFeed(node: any): boolean { + let current = node?.parent ?? node?.parentNode; + while (current) { + if (current.type === 'tag') { + const name = typeof current.name === 'string' ? current.name.toLowerCase() : undefined; + if (name === 'qp-data-feed') { + return true; + } + } + current = current.parent ?? current.parentNode; + } + return false; + } + private getIncludeAttributeNameContext( document: vscode.TextDocument, position: vscode.Position, diff --git a/acumate-plugin/src/services/configuration-service.ts b/acumate-plugin/src/services/configuration-service.ts index 836a6b6..c6d8e9a 100644 --- a/acumate-plugin/src/services/configuration-service.ts +++ b/acumate-plugin/src/services/configuration-service.ts @@ -7,6 +7,10 @@ export class ConfigurationService { this.config = workspace.getConfiguration("acuMate"); } + reload(): void { + this.config = workspace.getConfiguration("acuMate"); + } + get backedUrl() : string | undefined { return this.config.get("backedUrl"); } diff --git a/acumate-plugin/src/test/fixtures/html/InvalidActionScreen.html b/acumate-plugin/src/test/fixtures/html/InvalidActionScreen.html index 2ab1945..5e99d5f 100644 --- a/acumate-plugin/src/test/fixtures/html/InvalidActionScreen.html +++ b/acumate-plugin/src/test/fixtures/html/InvalidActionScreen.html @@ -2,5 +2,5 @@ - + diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html new file mode 100644 index 0000000..77c15ab --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.html @@ -0,0 +1,13 @@ + + + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.ts b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.ts new file mode 100644 index 0000000..7e69c91 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestControlIdOptional.ts @@ -0,0 +1,3 @@ +import { HtmlTestMaint } from "./TestScreen"; + +export class TestControlIdOptional extends HtmlTestMaint {} diff --git a/acumate-plugin/src/test/fixtures/html/TestControlMissingId.html b/acumate-plugin/src/test/fixtures/html/TestControlMissingId.html new file mode 100644 index 0000000..1956c86 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestControlMissingId.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestControlMissingId.ts b/acumate-plugin/src/test/fixtures/html/TestControlMissingId.ts new file mode 100644 index 0000000..33721da --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestControlMissingId.ts @@ -0,0 +1,3 @@ +import { HtmlTestMaint } from "./TestScreen"; + +export class TestControlMissingId extends HtmlTestMaint {} diff --git a/acumate-plugin/src/test/fixtures/html/TestPanelActionInvalid.html b/acumate-plugin/src/test/fixtures/html/TestPanelActionInvalid.html index 206f1f0..ad82b41 100644 --- a/acumate-plugin/src/test/fixtures/html/TestPanelActionInvalid.html +++ b/acumate-plugin/src/test/fixtures/html/TestPanelActionInvalid.html @@ -1,7 +1,7 @@
- +
diff --git a/acumate-plugin/src/test/fixtures/html/TestPanelActionValid.html b/acumate-plugin/src/test/fixtures/html/TestPanelActionValid.html index d4a2b66..d964396 100644 --- a/acumate-plugin/src/test/fixtures/html/TestPanelActionValid.html +++ b/acumate-plugin/src/test/fixtures/html/TestPanelActionValid.html @@ -1,7 +1,7 @@
- +
diff --git a/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.html b/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.html new file mode 100644 index 0000000..ab60df4 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.ts b/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.ts new file mode 100644 index 0000000..0ef6d73 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestPanelMissingId.ts @@ -0,0 +1,3 @@ +import { HtmlTestMaint } from "./TestScreen"; + +export class TestPanelMissingId extends HtmlTestMaint {} diff --git a/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordInside.html b/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordInside.html new file mode 100644 index 0000000..4c9bac8 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordInside.html @@ -0,0 +1,5 @@ + + + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordOutside.html b/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordOutside.html new file mode 100644 index 0000000..a6d9c15 --- /dev/null +++ b/acumate-plugin/src/test/fixtures/html/TestQpTemplateRecordOutside.html @@ -0,0 +1,3 @@ + + + diff --git a/acumate-plugin/src/test/fixtures/html/TestScreen.html b/acumate-plugin/src/test/fixtures/html/TestScreen.html index 82b2250..1b369c5 100644 --- a/acumate-plugin/src/test/fixtures/html/TestScreen.html +++ b/acumate-plugin/src/test/fixtures/html/TestScreen.html @@ -4,5 +4,5 @@ - + diff --git a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_AddBlanketOrderLine.html b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_AddBlanketOrderLine.html index d02ffd8..7c89fe4 100644 --- a/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_AddBlanketOrderLine.html +++ b/acumate-plugin/src/test/fixtures/screens/SO/SO301000/extensions/SO301000_AddBlanketOrderLine.html @@ -5,6 +5,6 @@ - - + + diff --git a/acumate-plugin/src/test/suite/htmlProviders.test.ts b/acumate-plugin/src/test/suite/htmlProviders.test.ts index a287c42..5992626 100644 --- a/acumate-plugin/src/test/suite/htmlProviders.test.ts +++ b/acumate-plugin/src/test/suite/htmlProviders.test.ts @@ -16,6 +16,8 @@ import { ConfigurationService } from '../../services/configuration-service'; const fixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/html'); const usingFixturePath = path.join(fixturesRoot, 'TestScreenUsing.html'); 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 importedFixturePath = path.join(fixturesRoot, 'TestScreenImported.html'); const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletion.html'); @@ -320,6 +322,26 @@ describe('HTML completion provider integration', () => { assert.ok(labels.includes('17-17-14'), '17-17-14 template not suggested'); }); + it('suggests record-prefixed qp-template names inside qp-data-feed', async () => { + const document = await vscode.workspace.openTextDocument(qpTemplateRecordInsidePath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="record-1"', 'name="'.length); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'Expected qp-template completions inside qp-data-feed'); + const labels = completions.map(item => item.label); + assert.ok(labels.includes('record-1'), 'record-1 template should be suggested inside qp-data-feed'); + }); + + it('omits record-prefixed qp-template names outside qp-data-feed', async () => { + const document = await vscode.workspace.openTextDocument(qpTemplateRecordOutsidePath); + const provider = new HtmlCompletionProvider(); + const caret = positionAt(document, 'name="record-1"', 'name="'.length); + const completions = await provider.provideCompletionItems(document, caret); + assert.ok(completions && completions.length > 0, 'Expected qp-template completions outside qp-data-feed'); + const labels = completions.map(item => item.label); + assert.ok(!labels.includes('record-1'), 'record-1 template should not be suggested outside qp-data-feed'); + }); + it('suggests view + field pairs for control-state.bind', async () => { const document = await vscode.workspace.openTextDocument(path.join(fixturesRoot, 'TestScreen.html')); const provider = new HtmlCompletionProvider(); diff --git a/acumate-plugin/src/test/suite/htmlValidation.test.ts b/acumate-plugin/src/test/suite/htmlValidation.test.ts index 56f2681..8b5e888 100644 --- a/acumate-plugin/src/test/suite/htmlValidation.test.ts +++ b/acumate-plugin/src/test/suite/htmlValidation.test.ts @@ -158,6 +158,16 @@ describe('HTML validation diagnostics', () => { ); }); + it('reports qp-panel elements that omit id attributes', async () => { + const document = await openFixtureDocument('TestPanelMissingId.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes(' element must define an id attribute')), + 'Expected diagnostic when qp-panel is missing id attribute' + ); + }); + it('allows suppressing html diagnostics via acumate-disable-next-line directives', async () => { const document = await openFixtureDocument('TestPanelInvalidSuppressed.html'); await validateHtmlFile(document); @@ -193,6 +203,23 @@ describe('HTML validation diagnostics', () => { ); }); + it('reports qp controls that omit id attributes', async () => { + const document = await openFixtureDocument('TestControlMissingId.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes(' element must define an id attribute')), + 'Expected diagnostic when qp-* control does not define id attribute' + ); + }); + + it('accepts qp-field, qp-label, and qp-include without id attributes', async () => { + const document = await openFixtureDocument('TestControlIdOptional.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual(diagnostics.length, 0, 'Expected no diagnostics when id-less qp controls are whitelisted'); + }); + it('accepts qp-include markup when required parameters are satisfied', async () => { const document = await openFixtureDocument('TestIncludeHost.html'); await validateHtmlFile(document); @@ -241,6 +268,27 @@ describe('HTML validation diagnostics', () => { ); }); + it('allows record-prefixed qp-template names inside qp-data-feed', async () => { + const document = await openFixtureDocument('TestQpTemplateRecordInside.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.strictEqual( + diagnostics.length, + 0, + 'Expected no diagnostics when record-* templates reside inside qp-data-feed' + ); + }); + + it('reports record-prefixed qp-template names outside qp-data-feed', async () => { + const document = await openFixtureDocument('TestQpTemplateRecordOutside.html'); + await validateHtmlFile(document); + const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? []; + assert.ok( + diagnostics.some(d => d.message.includes('record-') && d.message.includes('qp-data-feed')), + 'Expected diagnostic when record-* qp-template is outside qp-data-feed' + ); + }); + it('accepts qp-field control-state bindings when view + field exist', async () => { const document = await openFixtureDocument('TestScreen.html'); await validateHtmlFile(document); diff --git a/acumate-plugin/src/validation/htmlValidation/html-validation.ts b/acumate-plugin/src/validation/htmlValidation/html-validation.ts index a253554..a57683c 100644 --- a/acumate-plugin/src/validation/htmlValidation/html-validation.ts +++ b/acumate-plugin/src/validation/htmlValidation/html-validation.ts @@ -18,6 +18,7 @@ import { findParentViewName } 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, @@ -25,13 +26,12 @@ import { BaseScreenDocument, getCustomizationSelectorAttributes, } from "../../services/screen-html-service"; +import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; // The validator turns the TypeScript model into CollectedClassInfo entries for every PXScreen/PXView // and then uses that metadata when validating the HTML DOM. -import { AcuMateContext } from "../../plugin-context"; -import { createSuppressionEngine, SuppressionEngine } from "../../diagnostics/suppression"; - const includeIntrinsicAttributes = new Set(["id", "class", "style", "slot"]); +const idOptionalTags = new Set(["qp-field", "qp-label", "qp-include"]); function pushHtmlDiagnostic( diagnostics: vscode.Diagnostic[], @@ -103,7 +103,8 @@ export async function validateHtmlFile(document: vscode.TextDocument) { controlMetadata, baseScreenDocument, suppression, - undefined + undefined, + false ); } }, @@ -135,7 +136,8 @@ function validateDom( controlMetadata: Map, baseScreenDocument: BaseScreenDocument | undefined, suppression: SuppressionEngine, - panelViewContext?: CollectedClassInfo + panelViewContext?: CollectedClassInfo, + isInsideDataFeed = false ) { const classInfoMap = createClassInfoLookup(classProperties); const screenClasses = filterScreenLikeClasses(relevantClassInfos); @@ -163,6 +165,26 @@ function validateDom( // Custom validation logic goes here dom.forEach((node) => { let nextPanelViewContext = panelViewContext; + const normalizedTagName = + node.type === "tag" && typeof node.name === "string" ? node.name.toLowerCase() : ""; + const elementId = node.type === "tag" ? getElementId(node) : ""; + const nodeIsDataFeed = normalizedTagName === "qp-data-feed"; + const currentDataFeedContext = isInsideDataFeed || nodeIsDataFeed; + + if (node.type === "tag") { + const requiresIdAttribute = + normalizedTagName === "qp-panel" || + (normalizedTagName && controlMetadata.has(normalizedTagName) && !idOptionalTags.has(normalizedTagName)); + if (requiresIdAttribute && !elementId.length) { + const range = getRange(content, node); + const message = + normalizedTagName === "qp-panel" + ? "The element must define an id attribute." + : `The <${node.name}> element must define an id attribute.`; + pushHtmlDiagnostic(diagnostics, suppression, range, message); + } + } + if ( hasScreenMetadata && node.type === "tag" && @@ -189,9 +211,8 @@ function validateDom( } if (hasScreenMetadata && node.type === "tag" && node.name === "qp-panel") { - const panelId = typeof node.attribs?.id === "string" ? node.attribs.id.trim() : ""; - if (panelId.length) { - const viewResolution = resolveView(panelId); + if (elementId.length) { + const viewResolution = resolveView(elementId); if (!viewResolution) { const range = getRange(content, node); pushHtmlDiagnostic( @@ -250,7 +271,7 @@ function validateDom( typeof node.attribs?.name === "string" && node.attribs.name.length ) { - validateTemplateName(node.attribs.name, node); + validateTemplateName(node.attribs.name, node, currentDataFeedContext); } if ( @@ -291,7 +312,6 @@ function validateDom( ) { const viewSpecified = node.attribs.name.includes("."); const [viewFromNameAttribute, fieldFromNameAttribute] = viewSpecified ? node.attribs.name.split(".") : []; - const isUnboundReplacement = Object.prototype.hasOwnProperty.call(node.attribs, "unbound") && @@ -320,7 +340,7 @@ function validateDom( } } } - // Recursively validate child nodes + if ((node).children) { validateDom( (node).children, @@ -334,22 +354,35 @@ function validateDom( controlMetadata, baseScreenDocument, suppression, - nextPanelViewContext + nextPanelViewContext, + currentDataFeedContext ); } }); - function validateTemplateName(templateName: string, node: any) { + function validateTemplateName(templateName: string, node: any, insideDataFeed: boolean) { + const normalizedTemplateName = templateName.trim(); + if (normalizedTemplateName.startsWith("record-") && !insideDataFeed) { + const range = getRange(content, node); + pushHtmlDiagnostic( + diagnostics, + suppression, + range, + "Templates prefixed with record- can only be used inside a element." + ); + return; + } + if (!screenTemplateNames.size) { return; } - if (!screenTemplateNames.has(templateName)) { + if (!screenTemplateNames.has(normalizedTemplateName)) { const range = getRange(content, node); pushHtmlDiagnostic( diagnostics, suppression, range, - `The qp-template name "${templateName}" is not one of the predefined screen templates.` + `The qp-template name "${normalizedTemplateName}" is not one of the predefined screen templates.` ); } } @@ -722,6 +755,11 @@ function getAttributeValueRange( return undefined; } +function getElementId(node: any): string { + const rawId = node.attribs?.id; + return typeof rawId === "string" ? rawId.trim() : ""; +} + // Converts parser indices into VS Code ranges for diagnostics. function getRange(content: string, node: any) { const startPosition = getLineAndColumnFromIndex(content, node.startIndex);