Skip to content

Commit 178458f

Browse files
committed
Resolve qualified button action bindings
1 parent b96ab23 commit 178458f

9 files changed

Lines changed: 168 additions & 6 deletions

acumate-plugin/src/providers/html-completion-provider.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
125125

126126
if (attributeContext.attributeName === 'state.bind') {
127127
return isActionStateBindTag(attributeContext.tagName)
128-
? this.createActionCompletions(screenClasses, classInfoLookup, elementNode)
128+
? this.createActionCompletions(screenClasses, classInfoLookup, elementNode, attributeContext.value)
129129
: this.createControlStateCompletions(attributeContext.value, screenClasses, classInfoLookup);
130130
}
131131

@@ -642,15 +642,20 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
642642
private createActionCompletions(
643643
screenClasses: CollectedClassInfo[],
644644
classInfoLookup: Map<string, CollectedClassInfo>,
645-
elementNode: any
645+
elementNode: any,
646+
currentValue?: string
646647
): vscode.CompletionItem[] {
648+
const normalizedPrefix = (currentValue ?? '').trim().toLowerCase();
647649
const actionMap = collectActionProperties(screenClasses);
648650
const items: vscode.CompletionItem[] = [];
649651
const seen = new Set<string>();
650652
const addAction = (name: string, property: ClassPropertyInfo) => {
651653
if (seen.has(name)) {
652654
return;
653655
}
656+
if (normalizedPrefix && !name.toLowerCase().startsWith(normalizedPrefix)) {
657+
return;
658+
}
654659
seen.add(name);
655660
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function);
656661
item.detail = property.typeName ?? 'PXActionState';
@@ -671,6 +676,21 @@ export class HtmlCompletionProvider implements vscode.CompletionItemProvider {
671676
}
672677
});
673678

679+
for (const screenClass of screenClasses) {
680+
for (const [propertyName, property] of screenClass.properties) {
681+
if (property.kind !== 'view' && property.kind !== 'viewCollection') {
682+
continue;
683+
}
684+
685+
const viewClass = resolveViewBinding(propertyName, screenClasses, classInfoLookup)?.viewClass;
686+
viewClass?.properties.forEach((viewProperty, actionName) => {
687+
if (viewProperty.kind === 'action') {
688+
addAction(`${propertyName}.${actionName}`, viewProperty);
689+
}
690+
});
691+
}
692+
}
693+
674694
return items;
675695
}
676696

acumate-plugin/src/providers/html-definition-provider.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ export class HtmlDefinitionProvider implements vscode.DefinitionProvider {
181181
if (attributeContext.attributeName === 'state.bind') {
182182
if (isActionStateBindTag(attributeContext.tagName)) {
183183
const actionProperty =
184+
findQualifiedViewActionProperty(attributeContext.value, documentMetadataContext) ??
184185
findActionProperty(attributeContext.value, screenClasses) ??
185186
findViewActionProperty(attributeContext.value, elementNode, documentMetadataContext);
186187
if (!actionProperty) {
@@ -336,6 +337,43 @@ function findViewActionProperty(
336337
return actionProperty?.kind === 'action' ? actionProperty : undefined;
337338
}
338339

340+
function findQualifiedViewActionProperty(
341+
actionBinding: string | undefined,
342+
metadataContext: DefinitionMetadataContext
343+
): ClassPropertyInfo | undefined {
344+
const parsed = parseQualifiedActionBinding(actionBinding);
345+
if (!parsed) {
346+
return undefined;
347+
}
348+
349+
const viewClass = resolveViewBinding(
350+
parsed.viewName,
351+
metadataContext.screenClasses,
352+
metadataContext.classInfoLookup
353+
)?.viewClass;
354+
const actionProperty = viewClass?.properties.get(parsed.actionName);
355+
return actionProperty?.kind === 'action' ? actionProperty : undefined;
356+
}
357+
358+
function parseQualifiedActionBinding(value: string | undefined): { viewName: string; actionName: string } | undefined {
359+
if (!value) {
360+
return undefined;
361+
}
362+
363+
const parts = value.split('.');
364+
if (parts.length !== 2) {
365+
return undefined;
366+
}
367+
368+
const viewName = parts[0]?.trim();
369+
const actionName = parts[1]?.trim();
370+
if (!viewName || !actionName) {
371+
return undefined;
372+
}
373+
374+
return { viewName, actionName };
375+
}
376+
339377
function parseControlStateBinding(value: string | undefined): { viewName: string; fieldName: string } | undefined {
340378
if (!value) {
341379
return undefined;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<qp-root>
2+
<qp-button id="AdjustButton" state.bind="Document.AdjustDocAmt"></qp-button>
3+
<qp-button id="CompletionButton" state.bind="Document.AdjustDocAmt"></qp-button>
4+
</qp-root>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export class TestQualifiedActionBinding extends PXScreen {
2+
Document = createSingle(QualifiedActionDocument);
3+
SaveAction!: PXActionState;
4+
}
5+
6+
export class QualifiedActionDocument extends PXView {
7+
AdjustDocAmt!: PXActionState;
8+
ImageUrl!: PXFieldState;
9+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<qp-root>
2+
<qp-button id="AdjustButton" state.bind="Document.MissingAction"></qp-button>
3+
</qp-root>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export class TestQualifiedActionBindingInvalid extends PXScreen {
2+
Document = createSingle(QualifiedActionDocumentInvalid);
3+
}
4+
5+
export class QualifiedActionDocumentInvalid extends PXView {
6+
AdjustDocAmt!: PXActionState;
7+
}

acumate-plugin/src/test/suite/htmlProviders.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const configCompletionPath = path.join(fixturesRoot, 'TestConfigBindingCompletio
2525
const controlTypeCompletionPath = path.join(fixturesRoot, 'TestControlTypeCompletion.html');
2626
const duplicateViewNamesFixturePath = path.join(fixturesRoot, 'TestDuplicateViewNames.html');
2727
const stateFieldBindingPath = path.join(fixturesRoot, 'TestStateFieldBinding.html');
28+
const qualifiedActionBindingPath = path.join(fixturesRoot, 'TestQualifiedActionBinding.html');
2829
const screenFixturesRoot = path.resolve(__dirname, '../../../src/test/fixtures/screens');
2930
const screenExtensionHtmlPath = path.join(
3031
screenFixturesRoot,
@@ -399,6 +400,20 @@ describe('HTML completion provider integration', () => {
399400
assert.ok(labels.includes('ConfigureEntry'), 'ConfigureEntry not suggested from using view');
400401
});
401402

403+
it('suggests qualified view action names for qp-button state.bind attributes', async () => {
404+
const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath);
405+
const provider = new HtmlCompletionProvider();
406+
const caret = positionAt(
407+
document,
408+
'state.bind="Document.AdjustDocAmt"',
409+
'state.bind="Document.'.length
410+
);
411+
const completions = await provider.provideCompletionItems(document, caret);
412+
assert.ok(completions && completions.length > 0, 'No completions returned for qualified action state.bind');
413+
const labels = completions.map(item => item.label);
414+
assert.ok(labels.includes('Document.AdjustDocAmt'), 'Document.AdjustDocAmt not suggested');
415+
});
416+
402417
it('suggests view + field pairs for non-button state.bind attributes', async () => {
403418
const document = await vscode.workspace.openTextDocument(stateFieldBindingPath);
404419
const provider = new HtmlCompletionProvider();
@@ -720,6 +735,23 @@ describe('HTML definition provider integration', () => {
720735
);
721736
});
722737

738+
it('navigates from qualified qp-button state.bind attribute to view PXAction definition', async () => {
739+
const document = await vscode.workspace.openTextDocument(qualifiedActionBindingPath);
740+
const provider = new HtmlDefinitionProvider();
741+
const caret = positionAt(
742+
document,
743+
'state.bind="Document.AdjustDocAmt"',
744+
'state.bind="Document.'.length + 1
745+
);
746+
const definition = await provider.provideDefinition(document, caret);
747+
const locations = Array.isArray(definition) ? definition : definition ? [definition] : [];
748+
assert.ok(locations.length >= 1, 'No definitions returned for qualified view action');
749+
assert.ok(
750+
locations.some(loc => loc.uri.fsPath.endsWith('TestQualifiedActionBinding.ts')),
751+
'Expected qualified view action definition inside TestQualifiedActionBinding.ts'
752+
);
753+
});
754+
723755
it('navigates from non-button state.bind attribute to PXField property', async () => {
724756
const document = await vscode.workspace.openTextDocument(stateFieldBindingPath);
725757
const provider = new HtmlDefinitionProvider();

acumate-plugin/src/test/suite/htmlValidation.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ describe('HTML validation diagnostics', () => {
184184
);
185185
});
186186

187+
it('accepts qualified view action references in qp-button state.bind attributes', async () => {
188+
const document = await openFixtureDocument('TestQualifiedActionBinding.html');
189+
await validateHtmlFile(document);
190+
const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? [];
191+
assert.strictEqual(
192+
diagnostics.filter(d => d.message.includes('state.bind attribute must reference a valid PXAction')).length,
193+
0,
194+
'Expected no PXAction diagnostics for qualified view action binding'
195+
);
196+
});
197+
198+
it('reports missing qualified view action references in qp-button state.bind attributes', async () => {
199+
const document = await openFixtureDocument('TestQualifiedActionBindingInvalid.html');
200+
await validateHtmlFile(document);
201+
const diagnostics = AcuMateContext.HtmlValidator?.get(document.uri) ?? [];
202+
assert.ok(
203+
diagnostics.some(d => d.message.includes('state.bind attribute must reference a valid PXAction')),
204+
'Expected PXAction diagnostic for invalid qualified view action binding'
205+
);
206+
});
207+
187208
it('accepts non-button state.bind values that reference fields', async () => {
188209
const document = await openFixtureDocument('TestStateFieldBinding.html');
189210
await validateHtmlFile(document);

acumate-plugin/src/validation/htmlValidation/html-validation.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -307,10 +307,7 @@ function validateDom(
307307
!hasTemplateExpression(actionBinding) &&
308308
isActionStateBindTag(normalizedTagName)
309309
) {
310-
const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action";
311-
const scopedViewName = findParentViewName(node);
312-
const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action";
313-
if (!actionLookup.has(actionBinding) && !panelHasAction && !viewHasAction) {
310+
if (!resolveActionBinding(actionBinding, node)) {
314311
const range = getRange(content, node);
315312
pushHtmlDiagnostic(
316313
diagnostics,
@@ -531,6 +528,18 @@ function validateDom(
531528
});
532529
}
533530

531+
function resolveActionBinding(actionBinding: string, node: any): boolean {
532+
const qualifiedAction = parseQualifiedActionBinding(actionBinding);
533+
if (qualifiedAction) {
534+
return resolveView(qualifiedAction.viewName)?.viewClass?.properties.get(qualifiedAction.actionName)?.kind === "action";
535+
}
536+
537+
const panelHasAction = panelViewContext?.properties.get(actionBinding)?.kind === "action";
538+
const scopedViewName = findParentViewName(node);
539+
const viewHasAction = resolveView(scopedViewName)?.viewClass?.properties.get(actionBinding)?.kind === "action";
540+
return actionLookup.has(actionBinding) || panelHasAction || viewHasAction;
541+
}
542+
534543
function getSelectorValidationDocuments(): BaseScreenDocument[] {
535544
const documents: BaseScreenDocument[] = [];
536545
const includeDocument = includeFieldContext?.templateDocument;
@@ -811,6 +820,25 @@ function shouldIgnoreIncludeAttribute(attributeName: string): boolean {
811820
return false;
812821
}
813822

823+
function parseQualifiedActionBinding(value: string | undefined): { viewName: string; actionName: string } | undefined {
824+
if (!value) {
825+
return undefined;
826+
}
827+
828+
const parts = value.split(".");
829+
if (parts.length !== 2) {
830+
return undefined;
831+
}
832+
833+
const viewName = parts[0]?.trim();
834+
const actionName = parts[1]?.trim();
835+
if (!viewName || !actionName) {
836+
return undefined;
837+
}
838+
839+
return { viewName, actionName };
840+
}
841+
814842
function getAttributeValueRange(
815843
content: string,
816844
node: any,

0 commit comments

Comments
 (0)