Skip to content

Commit f9941ba

Browse files
committed
feat(DataMapper): Members of collection choice inherit collection semantics (#3167)
- Add isFieldInsideCollectionChoiceWrapper() helper to check if field is inside collection choice - Update DocumentService.isCollectionField() to consider collection choice inheritance - Update VisualizationUtilService.isCollectionField() documentation - Add comprehensive tests for collection choice inheritance - Add tests using TestDocument.xsd CollectionChoiceElement - Verify non-collection choice members are unaffected (no regression) Fixes #3167
1 parent 30f1e35 commit f9941ba

3 files changed

Lines changed: 322 additions & 2 deletions

File tree

packages/ui/src/services/document/document.service.test.ts

Lines changed: 298 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
14
import {
25
BODY_DOCUMENT_ID,
36
DocumentDefinition,
@@ -496,7 +499,7 @@ describe('DocumentService', () => {
496499
});
497500

498501
describe('isCollectionField()', () => {
499-
it('', () => {
502+
it('should identify collection fields by maxOccurs', () => {
500503
expect(DocumentService.isCollectionField(sourceDoc.fields[0])).toBeFalsy();
501504
expect(DocumentService.isCollectionField(targetDoc.fields[0])).toBeFalsy();
502505
expect(DocumentService.isCollectionField(sourceDoc.fields[0].fields[0])).toBeFalsy();
@@ -508,6 +511,300 @@ describe('DocumentService', () => {
508511
expect(DocumentService.isCollectionField(sourceDoc.fields[0].fields[3])).toBeTruthy();
509512
expect(DocumentService.isCollectionField(targetDoc.fields[0].fields[3])).toBeTruthy();
510513
});
514+
515+
it('should identify members of collection choice as collection fields', async () => {
516+
const mockApi = {
517+
getResourceContent: jest.fn().mockResolvedValue(`
518+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
519+
<xs:element name="TestDocument">
520+
<xs:complexType>
521+
<xs:sequence>
522+
<xs:element name="CollectionChoiceElement">
523+
<xs:complexType>
524+
<xs:choice maxOccurs="unbounded">
525+
<xs:element name="Email" type="xs:string"/>
526+
<xs:element name="Phone" type="xs:string"/>
527+
<xs:element name="Fax" type="xs:string"/>
528+
</xs:choice>
529+
</xs:complexType>
530+
</xs:element>
531+
</xs:sequence>
532+
</xs:complexType>
533+
</xs:element>
534+
</xs:schema>
535+
`),
536+
};
537+
538+
const result = await DocumentService.createDocument(
539+
mockApi as unknown as IMetadataApi,
540+
DocumentType.SOURCE_BODY,
541+
DocumentDefinitionType.XML_SCHEMA,
542+
'test',
543+
['test.xsd'],
544+
);
545+
546+
expect(result.validationStatus).toBe('success');
547+
const doc = result.document!;
548+
549+
// Navigate to CollectionChoiceElement
550+
const collectionChoiceElement = doc.fields[0].fields[0];
551+
expect(collectionChoiceElement.name).toBe('CollectionChoiceElement');
552+
553+
// The choice wrapper itself should be a collection
554+
const choiceWrapper = collectionChoiceElement.fields[0];
555+
expect(choiceWrapper.wrapperKind).toBe('choice');
556+
expect(DocumentService.isCollectionField(choiceWrapper)).toBeTruthy();
557+
558+
// Each member of the collection choice should inherit collection status
559+
const emailField = choiceWrapper.fields.find((f) => f.name === 'Email');
560+
const phoneField = choiceWrapper.fields.find((f) => f.name === 'Phone');
561+
const faxField = choiceWrapper.fields.find((f) => f.name === 'Fax');
562+
563+
expect(emailField).toBeDefined();
564+
expect(phoneField).toBeDefined();
565+
expect(faxField).toBeDefined();
566+
567+
// Members themselves have maxOccurs=1, but should be treated as collections
568+
// because they're inside a collection choice wrapper
569+
expect(emailField!.maxOccurs).toBe(1);
570+
expect(DocumentService.isCollectionField(emailField!)).toBeTruthy();
571+
572+
expect(phoneField!.maxOccurs).toBe(1);
573+
expect(DocumentService.isCollectionField(phoneField!)).toBeTruthy();
574+
575+
expect(faxField!.maxOccurs).toBe(1);
576+
expect(DocumentService.isCollectionField(faxField!)).toBeTruthy();
577+
});
578+
579+
it('should not treat members of non-collection choice as collection fields', async () => {
580+
const mockApi = {
581+
getResourceContent: jest.fn().mockResolvedValue(`
582+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
583+
<xs:element name="TestDocument">
584+
<xs:complexType>
585+
<xs:sequence>
586+
<xs:element name="RegularChoiceElement">
587+
<xs:complexType>
588+
<xs:choice>
589+
<xs:element name="Choice1" type="xs:string"/>
590+
<xs:element name="Choice2" type="xs:string"/>
591+
</xs:choice>
592+
</xs:complexType>
593+
</xs:element>
594+
</xs:sequence>
595+
</xs:complexType>
596+
</xs:element>
597+
</xs:schema>
598+
`),
599+
};
600+
601+
const result = await DocumentService.createDocument(
602+
mockApi as unknown as IMetadataApi,
603+
DocumentType.SOURCE_BODY,
604+
DocumentDefinitionType.XML_SCHEMA,
605+
'test',
606+
['test.xsd'],
607+
);
608+
609+
expect(result.validationStatus).toBe('success');
610+
const doc = result.document!;
611+
612+
// Navigate to RegularChoiceElement
613+
const regularChoiceElement = doc.fields[0].fields[0];
614+
expect(regularChoiceElement.name).toBe('RegularChoiceElement');
615+
616+
// The choice wrapper itself should NOT be a collection (maxOccurs=1)
617+
const choiceWrapper = regularChoiceElement.fields[0];
618+
expect(choiceWrapper.wrapperKind).toBe('choice');
619+
expect(DocumentService.isCollectionField(choiceWrapper)).toBeFalsy();
620+
621+
// Members should NOT inherit collection status from non-collection choice
622+
const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1');
623+
const choice2Field = choiceWrapper.fields.find((f) => f.name === 'Choice2');
624+
625+
expect(choice1Field).toBeDefined();
626+
expect(choice2Field).toBeDefined();
627+
628+
expect(DocumentService.isCollectionField(choice1Field!)).toBeFalsy();
629+
expect(DocumentService.isCollectionField(choice2Field!)).toBeFalsy();
630+
});
631+
});
632+
633+
describe('isFieldInsideCollectionChoiceWrapper()', () => {
634+
it('should return true for fields inside collection choice wrapper', async () => {
635+
const mockApi = {
636+
getResourceContent: jest.fn().mockResolvedValue(`
637+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
638+
<xs:element name="TestDocument">
639+
<xs:complexType>
640+
<xs:sequence>
641+
<xs:element name="CollectionChoiceElement">
642+
<xs:complexType>
643+
<xs:choice maxOccurs="unbounded">
644+
<xs:element name="Email" type="xs:string"/>
645+
<xs:element name="Phone" type="xs:string"/>
646+
</xs:choice>
647+
</xs:complexType>
648+
</xs:element>
649+
</xs:sequence>
650+
</xs:complexType>
651+
</xs:element>
652+
</xs:schema>
653+
`),
654+
};
655+
656+
const result = await DocumentService.createDocument(
657+
mockApi as unknown as IMetadataApi,
658+
DocumentType.SOURCE_BODY,
659+
DocumentDefinitionType.XML_SCHEMA,
660+
'test',
661+
['test.xsd'],
662+
);
663+
664+
const doc = result.document!;
665+
const choiceWrapper = doc.fields[0].fields[0].fields[0];
666+
const emailField = choiceWrapper.fields.find((f) => f.name === 'Email')!;
667+
668+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(emailField)).toBeTruthy();
669+
});
670+
671+
it('should return false for fields inside non-collection choice wrapper', async () => {
672+
const mockApi = {
673+
getResourceContent: jest.fn().mockResolvedValue(`
674+
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
675+
<xs:element name="TestDocument">
676+
<xs:complexType>
677+
<xs:sequence>
678+
<xs:element name="RegularChoiceElement">
679+
<xs:complexType>
680+
<xs:choice>
681+
<xs:element name="Choice1" type="xs:string"/>
682+
</xs:choice>
683+
</xs:complexType>
684+
</xs:element>
685+
</xs:sequence>
686+
</xs:complexType>
687+
</xs:element>
688+
</xs:schema>
689+
`),
690+
};
691+
692+
const result = await DocumentService.createDocument(
693+
mockApi as unknown as IMetadataApi,
694+
DocumentType.SOURCE_BODY,
695+
DocumentDefinitionType.XML_SCHEMA,
696+
'test',
697+
['test.xsd'],
698+
);
699+
700+
const doc = result.document!;
701+
const choiceWrapper = doc.fields[0].fields[0].fields[0];
702+
const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1')!;
703+
704+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice1Field)).toBeFalsy();
705+
});
706+
707+
it('should return false for fields not inside any choice wrapper', () => {
708+
const regularField = sourceDoc.fields[0].fields[0];
709+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(regularField)).toBeFalsy();
710+
});
711+
});
712+
713+
describe('isCollectionField() with TestDocument.xsd', () => {
714+
it('should identify CollectionChoiceElement members as collection fields', async () => {
715+
const mockApi = {
716+
getResourceContent: jest.fn().mockImplementation((filePath: string) => {
717+
if (filePath.includes('TestDocument.xsd')) {
718+
return Promise.resolve(
719+
fs.readFileSync(path.resolve(__dirname, '../../stubs/datamapper/xml/TestDocument.xsd'), 'utf-8'),
720+
);
721+
}
722+
return Promise.reject(new Error('File not found'));
723+
}),
724+
};
725+
726+
const result = await DocumentService.createDocument(
727+
mockApi as unknown as IMetadataApi,
728+
DocumentType.SOURCE_BODY,
729+
DocumentDefinitionType.XML_SCHEMA,
730+
'TestDocument',
731+
['TestDocument.xsd'],
732+
);
733+
734+
expect(result.validationStatus).toBe('success');
735+
const doc = result.document!;
736+
737+
const testDocument = doc.fields[0];
738+
expect(testDocument.name).toBe('TestDocument');
739+
740+
const collectionChoiceElement = testDocument.fields.find((f) => f.name === 'CollectionChoiceElement');
741+
expect(collectionChoiceElement).toBeDefined();
742+
743+
const choiceWrapper = collectionChoiceElement!.fields[0];
744+
expect(choiceWrapper.wrapperKind).toBe('choice');
745+
expect(choiceWrapper.maxOccurs).toBe('unbounded');
746+
expect(DocumentService.isCollectionField(choiceWrapper)).toBeTruthy();
747+
748+
const emailField = choiceWrapper.fields.find((f) => f.name === 'Email');
749+
const phoneField = choiceWrapper.fields.find((f) => f.name === 'Phone');
750+
const faxField = choiceWrapper.fields.find((f) => f.name === 'Fax');
751+
752+
expect(emailField).toBeDefined();
753+
expect(phoneField).toBeDefined();
754+
expect(faxField).toBeDefined();
755+
756+
expect(DocumentService.isCollectionField(emailField!)).toBeTruthy();
757+
expect(DocumentService.isCollectionField(phoneField!)).toBeTruthy();
758+
expect(DocumentService.isCollectionField(faxField!)).toBeTruthy();
759+
760+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(emailField!)).toBeTruthy();
761+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(phoneField!)).toBeTruthy();
762+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(faxField!)).toBeTruthy();
763+
});
764+
765+
it('should not treat regular ChoiceElement members as collection fields', async () => {
766+
const mockApi = {
767+
getResourceContent: jest.fn().mockImplementation((filePath: string) => {
768+
if (filePath.includes('TestDocument.xsd')) {
769+
return Promise.resolve(
770+
fs.readFileSync(path.resolve(__dirname, '../../stubs/datamapper/xml/TestDocument.xsd'), 'utf-8'),
771+
);
772+
}
773+
return Promise.reject(new Error('File not found'));
774+
}),
775+
};
776+
777+
const result = await DocumentService.createDocument(
778+
mockApi as unknown as IMetadataApi,
779+
DocumentType.SOURCE_BODY,
780+
DocumentDefinitionType.XML_SCHEMA,
781+
'TestDocument',
782+
['TestDocument.xsd'],
783+
);
784+
785+
expect(result.validationStatus).toBe('success');
786+
const doc = result.document!;
787+
788+
const testDocument = doc.fields[0];
789+
const choiceElement = testDocument.fields.find((f) => f.name === 'ChoiceElement');
790+
expect(choiceElement).toBeDefined();
791+
792+
const choiceWrapper = choiceElement!.fields[0];
793+
expect(choiceWrapper.wrapperKind).toBe('choice');
794+
expect(choiceWrapper.maxOccurs).toBe(1);
795+
expect(DocumentService.isCollectionField(choiceWrapper)).toBeFalsy();
796+
797+
const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1');
798+
const choice2Field = choiceWrapper.fields.find((f) => f.name === 'Choice2');
799+
800+
expect(choice1Field).toBeDefined();
801+
expect(choice2Field).toBeDefined();
802+
803+
expect(DocumentService.isCollectionField(choice1Field!)).toBeFalsy();
804+
expect(DocumentService.isCollectionField(choice2Field!)).toBeFalsy();
805+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice1Field!)).toBeFalsy();
806+
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice2Field!)).toBeFalsy();
807+
});
511808
});
512809

513810
describe('createDocument() error handling', () => {

packages/ui/src/services/document/document.service.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,34 @@ export class DocumentService {
481481
return field.fields.length > 0 || field.namedTypeFragmentRefs.length > 0;
482482
}
483483

484+
/**
485+
* Returns true if the field's parent is a collection choice wrapper.
486+
* A collection choice wrapper is an xs:choice with maxOccurs > 1, meaning its members
487+
* can repeat across choice instances and should be treated as collection fields.
488+
* @param field - The field to inspect
489+
* @returns true if the field is a direct child of a collection choice wrapper, false otherwise
490+
*/
491+
static isFieldInsideCollectionChoiceWrapper(field: IField): boolean {
492+
if (!('wrapperKind' in field.parent)) return false;
493+
return (
494+
field.parent.wrapperKind === 'choice' &&
495+
(field.parent.maxOccurs === 'unbounded' || Number(field.parent.maxOccurs) > 1)
496+
);
497+
}
498+
484499
/**
485500
* Returns true if the field represents a collection (maxOccurs is 'unbounded' or greater than 1).
501+
* Also returns true if the field is a member of a collection choice wrapper, as members inherit
502+
* the repeating nature from the choice wrapper.
486503
* @param field - The field to inspect
487504
* @returns true if the field is a collection, false otherwise
488505
*/
489506
static isCollectionField(field: IField) {
490-
return field.maxOccurs === 'unbounded' || Number(field.maxOccurs) > 1;
507+
return (
508+
field.maxOccurs === 'unbounded' ||
509+
Number(field.maxOccurs) > 1 ||
510+
DocumentService.isFieldInsideCollectionChoiceWrapper(field)
511+
);
491512
}
492513

493514
/**

packages/ui/src/services/visualization/visualization-util.service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export class VisualizationUtilService {
2323
/**
2424
* Returns `true` if the node's field is a collection (array/repeating element).
2525
* Also checks the choice wrapper field for collection status when the node is a selected choice member.
26+
* For unselected choice members (children of a collection choice wrapper), the collection status
27+
* is inherited from the parent wrapper via DocumentService.isCollectionField().
2628
* @param nodeData - The node to test.
2729
*/
2830
static isCollectionField(nodeData: NodeData) {

0 commit comments

Comments
 (0)