feat(dynform): enhance dataset and field referencing for master-detail structures

- Implemented comprehensive reference and completion support for <FOREIGN-DATASETS> and <MASTER-DATA> tags.
- Enhanced dataset resolution to support recursive scanning across included .frml files.
- Improved field resolution logic for MASTER-FIELDS and DETAIL-FIELDS to resolve from datasets specified by DATASET-ID or DATAID.
- Bumped plugin version to 3.2.3 and updated change notes.
This commit is contained in:
2026-04-16 19:39:00 +07:00
parent b13cb216db
commit 4da00c10e4
4 changed files with 420 additions and 14 deletions

View File

@@ -102,19 +102,60 @@ public class DynFormCompletionContributor extends CompletionContributor {
}
});
// XML completion for Dataset ID
// XML completion for Dataset ID (DATAID, DATASET, DATASET-ID, VIEW-DATASET)
extend(CompletionType.BASIC, XmlPatterns.psiElement()
.inside(XmlPatterns.xmlAttributeValue()
.withParent(XmlPatterns.xmlAttribute().withName("DATAID", "DATASET", "DATASET-ID"))),
.withParent(XmlPatterns.xmlAttribute().withName("DATAID", "DATASET", "DATASET-ID", "VIEW-DATASET"))),
new CompletionProvider<CompletionParameters>() {
@Override
protected void addCompletions(@NotNull CompletionParameters parameters,
@NotNull ProcessingContext context,
@NotNull CompletionResultSet resultSet) {
addDatasetsInFile(parameters.getOriginalFile(), resultSet);
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(parameters.getOriginalFile());
if (ajaxFile != null) {
addDatasetsInFile(ajaxFile, resultSet);
addDatasetsInFileRecursive(parameters.getOriginalFile(), resultSet, new HashSet<>());
}
});
// XML completion for MASTER-FIELDS and DETAIL-FIELDS in MASTER-DATA or FOREIGN-DATASETS > DATASET
extend(CompletionType.BASIC, XmlPatterns.psiElement()
.inside(XmlPatterns.xmlAttributeValue()
.withParent(XmlPatterns.xmlAttribute().withName("MASTER-FIELDS", "DETAIL-FIELDS")
.withParent(XmlPatterns.xmlTag().withName("MASTER-DATA", "DATASET")))),
new CompletionProvider<CompletionParameters>() {
@Override
protected void addCompletions(@NotNull CompletionParameters parameters,
@NotNull ProcessingContext context,
@NotNull CompletionResultSet resultSet) {
PsiElement position = parameters.getPosition();
XmlAttribute attr = PsiTreeUtil.getParentOfType(position, XmlAttribute.class);
if (attr == null) return;
XmlTag tag = (XmlTag) attr.getParent();
String tagName = tag.getName();
String attrName = attr.getName();
if ("MASTER-DATA".equals(tagName)) {
if ("MASTER-FIELDS".equals(attrName)) {
addFieldsFromDatasetId(tag, resultSet);
} else if ("DETAIL-FIELDS".equals(attrName)) {
XmlTag gridTag = tag.getParentTag();
if (gridTag != null && "DATA-GRID".equals(gridTag.getName())) {
addFieldsFromGrid(gridTag, resultSet);
}
}
} else if ("DATASET".equals(tagName) && hasAncestorWithName(tag, "FOREIGN-DATASETS")) {
if ("DETAIL-FIELDS".equals(attrName)) {
addFieldsFromDatasetId(tag, resultSet);
} else if ("MASTER-FIELDS".equals(attrName)) {
XmlTag foreignDatasetsTag = tag.getParentTag();
if (foreignDatasetsTag != null) {
XmlTag parentDataset = foreignDatasetsTag.getParentTag();
if (parentDataset != null && "DATASET".equals(parentDataset.getName())) {
XmlTag fieldsTag = parentDataset.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
addFieldsInTagRecursive(fieldsTag, resultSet);
}
}
}
}
}
}
});
@@ -195,6 +236,36 @@ public class DynFormCompletionContributor extends CompletionContributor {
}
}
private void addDatasetsInFileRecursive(PsiFile file, @NotNull CompletionResultSet resultSet, Set<PsiFile> visited) {
if (file == null || !visited.add(file)) return;
// 1. Add datasets from current file
addDatasetsInFile(file, resultSet);
// 2. Add datasets from ajax.xml (only if not already added)
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(file);
if (ajaxFile != null && ajaxFile != file) {
addDatasetsInFile(ajaxFile, resultSet);
}
// 3. Add datasets from included files
if (file instanceof XmlFile xmlFile) {
XmlTag rootTag = xmlFile.getRootTag();
if (rootTag != null) {
XmlTag includesTag = rootTag.findFirstSubTag("INCLUDES");
if (includesTag != null) {
for (XmlTag includeTag : includesTag.findSubTags("INCLUDE")) {
String path = includeTag.getAttributeValue("FILE");
if (path != null) {
PsiFile includedFile = DynFormPathUtils.findIncludedFile(file, path);
addDatasetsInFileRecursive(includedFile, resultSet, visited);
}
}
}
}
}
}
private void addDatasetsInFile(PsiFile file, @NotNull CompletionResultSet resultSet) {
if (!(file instanceof XmlFile xmlFile)) return;
XmlTag rootTag = xmlFile.getRootTag();
@@ -242,6 +313,110 @@ public class DynFormCompletionContributor extends CompletionContributor {
}
}
private void addFieldsFromDatasetId(XmlTag tag, @NotNull CompletionResultSet resultSet) {
String datasetId = tag.getAttributeValue("DATASET-ID");
if (datasetId == null || datasetId.isEmpty()) return;
PsiFile file = tag.getContainingFile();
PsiElement datasetElement = findDatasetElement(file, datasetId);
if (datasetElement instanceof XmlTag datasetTag) {
XmlTag fieldsTag = datasetTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
addFieldsInTagRecursive(fieldsTag, resultSet);
}
}
}
private void addFieldsFromGrid(XmlTag gridTag, @NotNull CompletionResultSet resultSet) {
// 1. Try to get DATAID from GRID-LIST or GRID-EDITOR
String dataId = null;
XmlTag listTag = gridTag.findFirstSubTag("GRID-LIST");
if (listTag != null) {
dataId = listTag.getAttributeValue("DATAID");
}
if (dataId == null) {
XmlTag editorTag = gridTag.findFirstSubTag("GRID-EDITOR");
if (editorTag != null) {
dataId = editorTag.getAttributeValue("DATAID");
}
}
// 2. Add fields from that DATASET
if (dataId != null && !dataId.isEmpty()) {
PsiElement datasetElement = findDatasetElement(gridTag.getContainingFile(), dataId);
if (datasetElement instanceof XmlTag datasetTag) {
XmlTag fieldsTag = datasetTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
addFieldsInTagRecursive(fieldsTag, resultSet);
}
}
}
// Fallback: Check for inline FIELDS in GRID-LIST/GRID-EDITOR (if any)
if (listTag != null) {
XmlTag fieldsTag = listTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
addFieldsInTagRecursive(fieldsTag, resultSet);
}
}
XmlTag editorTag = gridTag.findFirstSubTag("GRID-EDITOR");
if (editorTag != null) {
XmlTag fieldsTag = editorTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
addFieldsInTagRecursive(fieldsTag, resultSet);
}
}
}
private PsiElement findDatasetElement(PsiFile file, String id) {
return findDatasetInFileRecursiveForCompletion(file, id, new HashSet<>());
}
private PsiElement findDatasetInFileRecursiveForCompletion(PsiFile file, String id, Set<PsiFile> visited) {
if (file == null || !visited.add(file)) return null;
if (!(file instanceof XmlFile xmlFile)) return null;
XmlTag rootTag = xmlFile.getRootTag();
if (rootTag == null) return null;
XmlTag datasetsContainer = rootTag.findFirstSubTag("DATASETS");
if (datasetsContainer == null) datasetsContainer = rootTag;
for (XmlTag datasetTag : datasetsContainer.findSubTags("DATASET")) {
if (id.equals(datasetTag.getAttributeValue("ID"))) {
return datasetTag;
}
}
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(file);
if (ajaxFile != null && ajaxFile != file) {
PsiElement found = findDatasetInFileRecursiveForCompletion(ajaxFile, id, visited);
if (found != null) return found;
}
XmlTag includesTag = rootTag.findFirstSubTag("INCLUDES");
if (includesTag != null) {
for (XmlTag includeTag : includesTag.findSubTags("INCLUDE")) {
String path = includeTag.getAttributeValue("FILE");
if (path != null) {
PsiFile includedFile = DynFormPathUtils.findIncludedFile(file, path);
PsiElement found = findDatasetInFileRecursiveForCompletion(includedFile, id, visited);
if (found != null) return found;
}
}
}
return null;
}
private static boolean hasAncestorWithName(XmlTag tag, String name) {
XmlTag current = tag.getParentTag();
while (current != null) {
if (name.equals(current.getName())) return true;
current = current.getParentTag();
}
return false;
}
private PsiNewExpression getNewDynFormExpression(PsiLiteralExpression literal) {
PsiElement parent = literal.getParent();
if (parent instanceof PsiExpressionList) {

View File

@@ -83,9 +83,9 @@ public class DynFormReferenceContributor extends PsiReferenceContributor {
}
});
// XML Reference for Dataset ID (DATAID, DATASET, DATASET-ID)
// XML Reference for Dataset ID (DATAID, DATASET, DATASET-ID, VIEW-DATASET)
registrar.registerReferenceProvider(XmlPatterns.xmlAttributeValue()
.withParent(XmlPatterns.xmlAttribute().withName("DATAID", "DATASET", "DATASET-ID")),
.withParent(XmlPatterns.xmlAttribute().withName("DATAID", "DATASET", "DATASET-ID", "VIEW-DATASET")),
new PsiReferenceProvider() {
@NotNull
@Override
@@ -97,6 +97,41 @@ public class DynFormReferenceContributor extends PsiReferenceContributor {
}
});
// XML Reference for MASTER-FIELDS and DETAIL-FIELDS in MASTER-DATA or FOREIGN-DATASETS > DATASET
registrar.registerReferenceProvider(XmlPatterns.xmlAttributeValue()
.withParent(XmlPatterns.xmlAttribute().withName("MASTER-FIELDS", "DETAIL-FIELDS")
.withParent(XmlPatterns.xmlTag().withName("MASTER-DATA", "DATASET"))),
new PsiReferenceProvider() {
@NotNull
@Override
public PsiReference @NotNull [] getReferencesByElement(@NotNull PsiElement element, @NotNull ProcessingContext context) {
XmlAttributeValue attrValue = (XmlAttributeValue) element;
String value = attrValue.getValue();
if (value == null || value.isEmpty()) return PsiReference.EMPTY_ARRAY;
XmlAttribute attr = (XmlAttribute) attrValue.getParent();
XmlTag tag = (XmlTag) attr.getParent();
String tagName = tag.getName();
// Only process DATASET tag if it's inside FOREIGN-DATASETS
if ("DATASET".equals(tagName) && !hasAncestorWithName(tag, "FOREIGN-DATASETS")) {
return PsiReference.EMPTY_ARRAY;
}
// Support comma-separated fields
String[] fields = value.split(",");
PsiReference[] refs = new PsiReference[fields.length];
int currentOffset = 1;
for (int i = 0; i < fields.length; i++) {
String field = fields[i].trim();
int start = value.indexOf(field, currentOffset - 1);
refs[i] = new DynFormMasterDataFieldReference(attrValue, new TextRange(start + 1, start + 1 + field.length()), field);
currentOffset = start + field.length();
}
return refs;
}
});
// XML Reference for Grid ID
registrar.registerReferenceProvider(XmlPatterns.xmlAttributeValue()
.withParent(XmlPatterns.xmlAttribute().withName("GRID-ID")),
@@ -324,12 +359,39 @@ public class DynFormReferenceContributor extends PsiReferenceContributor {
this.datasetId = datasetId;
}
@Nullable @Override public PsiElement resolve() {
PsiElement found = findDatasetInFile(myElement.getContainingFile(), datasetId);
return findDatasetInFileRecursive(myElement.getContainingFile(), datasetId, new HashSet<>());
}
@Nullable
private PsiElement findDatasetInFileRecursive(PsiFile file, String id, Set<PsiFile> visited) {
if (file == null || !visited.add(file)) return null;
if (!(file instanceof XmlFile xmlFile)) return null;
// 1. Search in current file
PsiElement found = findDatasetInFile(xmlFile, id);
if (found != null) return found;
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(myElement.getContainingFile());
if (ajaxFile != null) {
return findDatasetInFile(ajaxFile, datasetId);
// 2. Search in ajax.xml (only from main file)
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(file);
if (ajaxFile != null && ajaxFile != file) {
found = findDatasetInFile(ajaxFile, id);
if (found != null) return found;
}
// 3. Search in included files
XmlTag rootTag = xmlFile.getRootTag();
if (rootTag != null) {
XmlTag includesTag = rootTag.findFirstSubTag("INCLUDES");
if (includesTag != null) {
for (XmlTag includeTag : includesTag.findSubTags("INCLUDE")) {
String path = includeTag.getAttributeValue("FILE");
if (path != null) {
PsiFile includedFile = DynFormPathUtils.findIncludedFile(file, path);
found = findDatasetInFileRecursive(includedFile, id, visited);
if (found != null) return found;
}
}
}
}
return null;
}
@@ -353,6 +415,163 @@ public class DynFormReferenceContributor extends PsiReferenceContributor {
}
}
private static class DynFormMasterDataFieldReference extends PsiReferenceBase<XmlAttributeValue> {
private final String fieldName;
public DynFormMasterDataFieldReference(@NotNull XmlAttributeValue element, TextRange range, String fieldName) {
super(element, range, true);
this.fieldName = fieldName;
}
@Nullable
@Override
public PsiElement resolve() {
XmlAttribute attr = (XmlAttribute) myElement.getParent();
XmlTag tag = (XmlTag) attr.getParent();
String tagName = tag.getName();
String attrName = attr.getName();
if ("MASTER-DATA".equals(tagName)) {
if ("MASTER-FIELDS".equals(attrName)) {
// MASTER-FIELDS refers to the DATASET specified in DATASET-ID
return resolveInDataset(tag.getAttributeValue("DATASET-ID"));
} else if ("DETAIL-FIELDS".equals(attrName)) {
// DETAIL-FIELDS refers to fields in current DATA-GRID
XmlTag gridTag = tag.getParentTag();
if (gridTag != null && "DATA-GRID".equals(gridTag.getName())) {
return findFieldInGrid(gridTag);
}
}
} else if ("DATASET".equals(tagName)) {
if ("DETAIL-FIELDS".equals(attrName)) {
// DETAIL-FIELDS refers to the DATASET specified in DATASET-ID
return resolveInDataset(tag.getAttributeValue("DATASET-ID"));
} else if ("MASTER-FIELDS".equals(attrName)) {
// MASTER-FIELDS refers to parent DATASET
XmlTag foreignDatasetsTag = tag.getParentTag();
if (foreignDatasetsTag != null) {
XmlTag parentDataset = foreignDatasetsTag.getParentTag();
if (parentDataset != null && "DATASET".equals(parentDataset.getName())) {
XmlTag fieldsTag = parentDataset.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
return findFieldInTag(fieldsTag, fieldName);
}
}
}
}
}
return null;
}
@Nullable
private PsiElement resolveInDataset(String datasetId) {
if (datasetId == null || datasetId.isEmpty()) return null;
// Re-use DynFormDatasetReference's finding logic if possible, or just look up
PsiElement datasetElement = findDatasetElement(datasetId);
if (datasetElement instanceof XmlTag datasetTag) {
XmlTag fieldsTag = datasetTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
return findFieldInTag(fieldsTag, fieldName);
}
}
return null;
}
@Nullable
private PsiElement findDatasetElement(String id) {
// This is a simplified lookup, similar to DynFormDatasetReference's resolve
PsiFile file = myElement.getContainingFile();
PsiElement found = findDatasetInFileRecursive(file, id, new HashSet<>());
if (found instanceof XmlAttributeValue attrValue) {
PsiElement parent = attrValue.getParent();
if (parent instanceof XmlAttribute attr) {
return attr.getParent();
}
}
if (found instanceof XmlTag) return found;
return null;
}
@Nullable
private PsiElement findDatasetInFileRecursive(PsiFile file, String id, Set<PsiFile> visited) {
if (file == null || !visited.add(file)) return null;
if (!(file instanceof XmlFile xmlFile)) return null;
XmlTag rootTag = xmlFile.getRootTag();
if (rootTag == null) return null;
XmlTag datasetsContainer = rootTag.findFirstSubTag("DATASETS");
if (datasetsContainer == null) datasetsContainer = rootTag;
for (XmlTag datasetTag : datasetsContainer.findSubTags("DATASET")) {
if (id.equals(datasetTag.getAttributeValue("ID"))) {
XmlAttribute idAttr = datasetTag.getAttribute("ID");
return idAttr != null ? idAttr.getValueElement() : datasetTag;
}
}
// Search in ajax.xml
PsiFile ajaxFile = DynFormPathUtils.findAjaxXml(file);
if (ajaxFile != null && ajaxFile != file) {
PsiElement found = findDatasetInFileRecursive(ajaxFile, id, visited);
if (found != null) return found;
}
// Search in includes
XmlTag includesTag = rootTag.findFirstSubTag("INCLUDES");
if (includesTag != null) {
for (XmlTag includeTag : includesTag.findSubTags("INCLUDE")) {
String path = includeTag.getAttributeValue("FILE");
if (path != null) {
PsiFile includedFile = DynFormPathUtils.findIncludedFile(file, path);
PsiElement found = findDatasetInFileRecursive(includedFile, id, visited);
if (found != null) return found;
}
}
}
return null;
}
@Nullable
private PsiElement findFieldInGrid(XmlTag gridTag) {
// 1. Try to get DATAID from GRID-LIST or GRID-EDITOR
String dataId = null;
XmlTag listTag = gridTag.findFirstSubTag("GRID-LIST");
if (listTag != null) {
dataId = listTag.getAttributeValue("DATAID");
}
if (dataId == null) {
XmlTag editorTag = gridTag.findFirstSubTag("GRID-EDITOR");
if (editorTag != null) {
dataId = editorTag.getAttributeValue("DATAID");
}
}
// 2. Resolve fields from that DATASET
if (dataId != null && !dataId.isEmpty()) {
return resolveInDataset(dataId);
}
// Fallback: Check for inline FIELDS in GRID-LIST/GRID-EDITOR (if any)
if (listTag != null) {
XmlTag fieldsTag = listTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
PsiElement found = findFieldInTag(fieldsTag, fieldName);
if (found != null) return found;
}
}
XmlTag editorTag = gridTag.findFirstSubTag("GRID-EDITOR");
if (editorTag != null) {
XmlTag fieldsTag = editorTag.findFirstSubTag("FIELDS");
if (fieldsTag != null) {
return findFieldInTag(fieldsTag, fieldName);
}
}
return null;
}
}
private static class DynFormGridReference extends PsiReferenceBase<XmlAttributeValue> {
private final String gridId;
public DynFormGridReference(@NotNull XmlAttributeValue element, TextRange range, String gridId) {

View File

@@ -34,6 +34,12 @@
]]></description>
<change-notes><![CDATA[
<h2>[3.2.3]</h2>
<ul>
<li><strong>Advanced Data Referencing:</strong> Implemented comprehensive reference and completion support for <code>&lt;FOREIGN-DATASETS&gt;</code> and <code>&lt;MASTER-DATA&gt;</code> structures.</li>
<li><strong>Contextual Field Resolution:</strong> Enhanced field resolution logic to resolve fields from datasets specified by <code>DATASET-ID</code> and <code>DATAID</code> attributes within their respective tags.</li>
<li><strong>Recursive Resource Scanning:</strong> Improved dataset and grid scanning to search across recursively included <code>.frml</code> files, ensuring consistent navigation and completion throughout the project.</li>
</ul>
<h2>[3.2.2]</h2>
<ul>
<li><strong>UI/UX Improvement:</strong> Updated the I18n settings to allow selecting the message bundle XML file directly via a file browser, improving configuration usability.</li>