Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 298 additions & 1 deletion packages/ui/src/services/document/document.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';

import {
BODY_DOCUMENT_ID,
DocumentDefinition,
Expand Down Expand Up @@ -496,7 +499,7 @@ describe('DocumentService', () => {
});

describe('isCollectionField()', () => {
it('', () => {
it('should identify collection fields by maxOccurs', () => {
expect(DocumentService.isCollectionField(sourceDoc.fields[0])).toBeFalsy();
expect(DocumentService.isCollectionField(targetDoc.fields[0])).toBeFalsy();
expect(DocumentService.isCollectionField(sourceDoc.fields[0].fields[0])).toBeFalsy();
Expand All @@ -508,6 +511,300 @@ describe('DocumentService', () => {
expect(DocumentService.isCollectionField(sourceDoc.fields[0].fields[3])).toBeTruthy();
expect(DocumentService.isCollectionField(targetDoc.fields[0].fields[3])).toBeTruthy();
});

it('should identify members of collection choice as collection fields', async () => {
const mockApi = {
getResourceContent: jest.fn().mockResolvedValue(`
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use existing TestDocument.xsd or create a new stub XSD file in src/stubs/datamapper/xml so that it could be used to reproduce it manually? It would be better if one XSD file can serve all test cases added here.

<xs:element name="TestDocument">
<xs:complexType>
<xs:sequence>
<xs:element name="CollectionChoiceElement">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="Email" type="xs:string"/>
<xs:element name="Phone" type="xs:string"/>
<xs:element name="Fax" type="xs:string"/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
`),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'test',
['test.xsd'],
);

expect(result.validationStatus).toBe('success');
const doc = result.document!;

// Navigate to CollectionChoiceElement
const collectionChoiceElement = doc.fields[0].fields[0];
expect(collectionChoiceElement.name).toBe('CollectionChoiceElement');

// The choice wrapper itself should be a collection
const choiceWrapper = collectionChoiceElement.fields[0];
expect(choiceWrapper.wrapperKind).toBe('choice');
expect(DocumentService.isCollectionField(choiceWrapper)).toBeTruthy();

// Each member of the collection choice should inherit collection status
const emailField = choiceWrapper.fields.find((f) => f.name === 'Email');
const phoneField = choiceWrapper.fields.find((f) => f.name === 'Phone');
const faxField = choiceWrapper.fields.find((f) => f.name === 'Fax');

expect(emailField).toBeDefined();
expect(phoneField).toBeDefined();
expect(faxField).toBeDefined();

// Members themselves have maxOccurs=1, but should be treated as collections
// because they're inside a collection choice wrapper
expect(emailField!.maxOccurs).toBe(1);
expect(DocumentService.isCollectionField(emailField!)).toBeTruthy();

expect(phoneField!.maxOccurs).toBe(1);
expect(DocumentService.isCollectionField(phoneField!)).toBeTruthy();

expect(faxField!.maxOccurs).toBe(1);
expect(DocumentService.isCollectionField(faxField!)).toBeTruthy();
});

it('should not treat members of non-collection choice as collection fields', async () => {
const mockApi = {
getResourceContent: jest.fn().mockResolvedValue(`
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
<xs:element name="TestDocument">
<xs:complexType>
<xs:sequence>
<xs:element name="RegularChoiceElement">
<xs:complexType>
<xs:choice>
<xs:element name="Choice1" type="xs:string"/>
<xs:element name="Choice2" type="xs:string"/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
`),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'test',
['test.xsd'],
);

expect(result.validationStatus).toBe('success');
const doc = result.document!;

// Navigate to RegularChoiceElement
const regularChoiceElement = doc.fields[0].fields[0];
expect(regularChoiceElement.name).toBe('RegularChoiceElement');

// The choice wrapper itself should NOT be a collection (maxOccurs=1)
const choiceWrapper = regularChoiceElement.fields[0];
expect(choiceWrapper.wrapperKind).toBe('choice');
expect(DocumentService.isCollectionField(choiceWrapper)).toBeFalsy();

// Members should NOT inherit collection status from non-collection choice
const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1');
const choice2Field = choiceWrapper.fields.find((f) => f.name === 'Choice2');

expect(choice1Field).toBeDefined();
expect(choice2Field).toBeDefined();

expect(DocumentService.isCollectionField(choice1Field!)).toBeFalsy();
expect(DocumentService.isCollectionField(choice2Field!)).toBeFalsy();
});
});

describe('isFieldInsideCollectionChoiceWrapper()', () => {
it('should return true for fields inside collection choice wrapper', async () => {
const mockApi = {
getResourceContent: jest.fn().mockResolvedValue(`
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
<xs:element name="TestDocument">
<xs:complexType>
<xs:sequence>
<xs:element name="CollectionChoiceElement">
<xs:complexType>
<xs:choice maxOccurs="unbounded">
<xs:element name="Email" type="xs:string"/>
<xs:element name="Phone" type="xs:string"/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
`),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'test',
['test.xsd'],
);

const doc = result.document!;
const choiceWrapper = doc.fields[0].fields[0].fields[0];
const emailField = choiceWrapper.fields.find((f) => f.name === 'Email')!;

expect(DocumentService.isFieldInsideCollectionChoiceWrapper(emailField)).toBeTruthy();
});

it('should return false for fields inside non-collection choice wrapper', async () => {
const mockApi = {
getResourceContent: jest.fn().mockResolvedValue(`
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" targetNamespace="test" xmlns:ns0="test">
<xs:element name="TestDocument">
<xs:complexType>
<xs:sequence>
<xs:element name="RegularChoiceElement">
<xs:complexType>
<xs:choice>
<xs:element name="Choice1" type="xs:string"/>
</xs:choice>
</xs:complexType>
</xs:element>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>
`),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'test',
['test.xsd'],
);

const doc = result.document!;
const choiceWrapper = doc.fields[0].fields[0].fields[0];
const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1')!;

expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice1Field)).toBeFalsy();
});

it('should return false for fields not inside any choice wrapper', () => {
const regularField = sourceDoc.fields[0].fields[0];
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(regularField)).toBeFalsy();
});
});

describe('isCollectionField() with TestDocument.xsd', () => {
it('should identify CollectionChoiceElement members as collection fields', async () => {
const mockApi = {
getResourceContent: jest.fn().mockImplementation((filePath: string) => {
if (filePath.includes('TestDocument.xsd')) {
return Promise.resolve(
fs.readFileSync(path.resolve(__dirname, '../../stubs/datamapper/xml/TestDocument.xsd'), 'utf-8'),
);
}
return Promise.reject(new Error('File not found'));
}),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'TestDocument',
['TestDocument.xsd'],
);

expect(result.validationStatus).toBe('success');
const doc = result.document!;

const testDocument = doc.fields[0];
expect(testDocument.name).toBe('TestDocument');

const collectionChoiceElement = testDocument.fields.find((f) => f.name === 'CollectionChoiceElement');
expect(collectionChoiceElement).toBeDefined();

const choiceWrapper = collectionChoiceElement!.fields[0];
expect(choiceWrapper.wrapperKind).toBe('choice');
expect(choiceWrapper.maxOccurs).toBe('unbounded');
expect(DocumentService.isCollectionField(choiceWrapper)).toBeTruthy();

const emailField = choiceWrapper.fields.find((f) => f.name === 'Email');
const phoneField = choiceWrapper.fields.find((f) => f.name === 'Phone');
const faxField = choiceWrapper.fields.find((f) => f.name === 'Fax');

expect(emailField).toBeDefined();
expect(phoneField).toBeDefined();
expect(faxField).toBeDefined();

expect(DocumentService.isCollectionField(emailField!)).toBeTruthy();
expect(DocumentService.isCollectionField(phoneField!)).toBeTruthy();
expect(DocumentService.isCollectionField(faxField!)).toBeTruthy();

expect(DocumentService.isFieldInsideCollectionChoiceWrapper(emailField!)).toBeTruthy();
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(phoneField!)).toBeTruthy();
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(faxField!)).toBeTruthy();
});

it('should not treat regular ChoiceElement members as collection fields', async () => {
const mockApi = {
getResourceContent: jest.fn().mockImplementation((filePath: string) => {
if (filePath.includes('TestDocument.xsd')) {
return Promise.resolve(
fs.readFileSync(path.resolve(__dirname, '../../stubs/datamapper/xml/TestDocument.xsd'), 'utf-8'),
);
}
return Promise.reject(new Error('File not found'));
}),
};

const result = await DocumentService.createDocument(
mockApi as unknown as IMetadataApi,
DocumentType.SOURCE_BODY,
DocumentDefinitionType.XML_SCHEMA,
'TestDocument',
['TestDocument.xsd'],
);

expect(result.validationStatus).toBe('success');
const doc = result.document!;

const testDocument = doc.fields[0];
const choiceElement = testDocument.fields.find((f) => f.name === 'ChoiceElement');
expect(choiceElement).toBeDefined();

const choiceWrapper = choiceElement!.fields[0];
expect(choiceWrapper.wrapperKind).toBe('choice');
expect(choiceWrapper.maxOccurs).toBe(1);
expect(DocumentService.isCollectionField(choiceWrapper)).toBeFalsy();

const choice1Field = choiceWrapper.fields.find((f) => f.name === 'Choice1');
const choice2Field = choiceWrapper.fields.find((f) => f.name === 'Choice2');

expect(choice1Field).toBeDefined();
expect(choice2Field).toBeDefined();

expect(DocumentService.isCollectionField(choice1Field!)).toBeFalsy();
expect(DocumentService.isCollectionField(choice2Field!)).toBeFalsy();
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice1Field!)).toBeFalsy();
expect(DocumentService.isFieldInsideCollectionChoiceWrapper(choice2Field!)).toBeFalsy();
});
});

describe('createDocument() error handling', () => {
Expand Down
23 changes: 22 additions & 1 deletion packages/ui/src/services/document/document.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,13 +484,34 @@ export class DocumentService {
return field.fields.length > 0 || field.namedTypeFragmentRefs.length > 0;
}

/**
* Returns true if the field's parent is a collection choice wrapper.
* A collection choice wrapper is an xs:choice with maxOccurs > 1, meaning its members
* can repeat across choice instances and should be treated as collection fields.
* @param field - The field to inspect
* @returns true if the field is a direct child of a collection choice wrapper, false otherwise
*/
static isFieldInsideCollectionChoiceWrapper(field: IField): boolean {
if (!('wrapperKind' in field.parent)) return false;
return (
field.parent.wrapperKind === 'choice' &&
(field.parent.maxOccurs === 'unbounded' || Number(field.parent.maxOccurs) > 1)
);
}

/**
* Returns true if the field represents a collection (maxOccurs is 'unbounded' or greater than 1).
* Also returns true if the field is a member of a collection choice wrapper, as members inherit
* the repeating nature from the choice wrapper.
* @param field - The field to inspect
* @returns true if the field is a collection, false otherwise
*/
static isCollectionField(field: IField) {
return field.maxOccurs === 'unbounded' || Number(field.maxOccurs) > 1;
return (
field.maxOccurs === 'unbounded' ||
Number(field.maxOccurs) > 1 ||
DocumentService.isFieldInsideCollectionChoiceWrapper(field)
);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export class VisualizationUtilService {
/**
* Returns `true` if the node's field is a collection (array/repeating element).
* Also checks the choice wrapper field for collection status when the node is a selected choice member.
* For unselected choice members (children of a collection choice wrapper), the collection status
* is inherited from the parent wrapper via DocumentService.isCollectionField().
* @param nodeData - The node to test.
*/
static isCollectionField(nodeData: NodeData) {
Expand Down
Loading