diff --git a/package-lock.json b/package-lock.json
index cbe4f19..7fa3e50 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@api-components/api-type-document",
- "version": "4.2.45",
+ "version": "4.2.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@api-components/api-type-document",
- "version": "4.2.45",
+ "version": "4.2.46",
"license": "Apache-2.0",
"dependencies": {
"@advanced-rest-client/arc-marked": "^1.1.0",
diff --git a/package.json b/package.json
index ad6e419..cf5d048 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@api-components/api-type-document",
"description": "A documentation table for type (resource) properties. Works with AMF data model",
- "version": "4.2.45",
+ "version": "4.2.46",
"license": "Apache-2.0",
"main": "index.js",
"module": "index.js",
diff --git a/src/ApiTypeDocument.js b/src/ApiTypeDocument.js
index 0a858d2..d9b15fb 100644
--- a/src/ApiTypeDocument.js
+++ b/src/ApiTypeDocument.js
@@ -139,6 +139,22 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
* Each item has `label` and `isScalar` property.
*/
anyOfTypes: { type: Array },
+ /**
+ * True if given `type` carries JSON Schema `if`/`then`/`else` conditional keywords.
+ */
+ isConditional: { type: Boolean },
+ /**
+ * Resolved AMF shape for the `if` condition branch.
+ */
+ ifShape: { type: Object },
+ /**
+ * Resolved AMF shape for the `then` branch.
+ */
+ thenShape: { type: Object },
+ /**
+ * Resolved AMF shape for the `else` branch. Optional per JSON Schema (else may be omitted).
+ */
+ elseShape: { type: Object },
/**
* List of types definition and name for OAS' "and" type
*/
@@ -529,6 +545,20 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
isScalar = true;
}
}
+ const ifKey = this._getAmfKey(this._shaclConditionalKey('if'));
+ let isConditional = false;
+ let ifShape;
+ let thenShape;
+ let elseShape;
+ if (this._hasProperty(type, ifKey)) {
+ isConditional = true;
+ isScalar = false; // a conditional shape is never purely scalar
+ const thenKey = this._getAmfKey(this._shaclConditionalKey('then'));
+ const elseKey = this._getAmfKey(this._shaclConditionalKey('else'));
+ ifShape = this._resolveConditionalBranch(type[ifKey]);
+ thenShape = this._resolveConditionalBranch(type[thenKey]);
+ elseShape = this._resolveConditionalBranch(type[elseKey]);
+ }
this.isScalar = isScalar;
this.isArray = isArray;
this.isObject = isObject;
@@ -536,6 +566,10 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
this.isAnd = isAnd;
this.isOneOf = isOneOf;
this.isAnyOf = isAnyOf;
+ this.isConditional = isConditional;
+ this.ifShape = ifShape;
+ this.thenShape = thenShape;
+ this.elseShape = elseShape;
// Compute properties for objects - this needs to be reactive
if (isObject) {
@@ -565,6 +599,33 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
this._exampleMediaType = this.mediaType || (isObject || isArray ? 'application/json' : undefined);
}
+ /**
+ * Builds the raw SHACL IRI for `if`/`then`/`else`. These are JSON Schema 2020-12
+ * conditional keywords and are not present in the frozen `ns.w3.shacl` namespace
+ * from amf-helper-mixin, so the raw IRI is constructed directly. The result is a
+ * valid input to `_getAmfKey`/`_hasProperty`, same as `this.ns.w3.shacl.or` etc.
+ * @param {'if'|'then'|'else'} suffix
+ * @returns {string}
+ * @private
+ */
+ _shaclConditionalKey(suffix) {
+ return `${this.ns.w3.shacl.key}${suffix}`;
+ }
+
+ /**
+ * Extracts and resolves a single if/then/else branch value. Each of
+ * `shacl#if`/`then`/`else` holds exactly one shape (unlike `shacl#and`/`or`/`xone`,
+ * which are arrays), but defends with `_ensureArray` in case a producer wraps it,
+ * matching the pattern already used for `items`/`and`/`or` elsewhere in this file.
+ * @param {Object|Object[]} value Raw value of the if/then/else key
+ * @returns {Object|undefined} Resolved branch shape
+ * @private
+ */
+ _resolveConditionalBranch(value) {
+ const [branch] = this._ensureArray(value) || [];
+ return branch ? this._resolve(branch) : undefined;
+ }
+
/**
* Computes parent name for the array type table.
*
@@ -1222,6 +1283,64 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
)}`;
}
+ /**
+ * @return {TemplateResult|string} Template for a JSON Schema if/then/else conditional shape
+ * @private
+ */
+ _conditionalTemplate() {
+ const { ifShape, thenShape, elseShape } = this;
+ // A conditional needs the `if` trigger plus at least one consequent branch.
+ // `then` and `else` are each independently optional per JSON Schema, so render
+ // whichever branches are present rather than requiring `then`.
+ if (!ifShape || (!thenShape && !elseShape)) {
+ return '';
+ }
+ return html`
+
If:
+
+ ${thenShape
+ ? html`
+ Then:
+ `
+ : ''}
+ ${elseShape
+ ? html`
+ Else:
+ `
+ : ''}
+ `;
+ }
+
/**
* @return {TemplateResult} Template for the element
*/
@@ -1290,7 +1409,8 @@ export class ApiTypeDocument extends PropertyDocumentMixin(LitElement) {
${this.isUnion ? this._unionTemplate() : ''}
${this.isAnd ? this._anyTemplate() : ''}
${this.isAnyOf ? this._anyOfTemplate() : ''}
- ${this.isOneOf ? this._oneOfTemplate() : ''}`;
+ ${this.isOneOf ? this._oneOfTemplate() : ''}
+ ${this.isConditional ? this._conditionalTemplate() : ''}`;
}
_filterReadOnlyProperties(properties) {
diff --git a/test/api-type-document.test.js b/test/api-type-document.test.js
index 632cf02..59369b5 100644
--- a/test/api-type-document.test.js
+++ b/test/api-type-document.test.js
@@ -777,6 +777,213 @@ describe('', () => {
});
});
+ describe('Conditional type (if/then/else)', () => {
+ let element = /** @type ApiTypeDocument */ (null);
+
+ beforeEach(async () => {
+ element = await basicFixture();
+ });
+
+ // @covers AC1
+ it('isConditional is true for PetKindFields', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element._typeChanged(element._resolve(data[1]));
+ assert.isTrue(element.isConditional);
+ });
+
+ // @covers AC1
+ it('isScalar is false for PetKindFields (does not collapse to "Any")', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element._typeChanged(element._resolve(data[1]));
+ assert.isFalse(element.isScalar);
+ });
+
+ // @covers AC1
+ it('resolves ifShape, thenShape and elseShape', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element._typeChanged(element._resolve(data[1]));
+ assert.isOk(element.ifShape, 'ifShape is set');
+ assert.isOk(element.thenShape, 'thenShape is set');
+ assert.isOk(element.elseShape, 'elseShape is set');
+ });
+
+ // @covers AC1
+ it('if/then/else branches are NodeShape shapes', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element._typeChanged(element._resolve(data[1]));
+ assert.isTrue(
+ element._hasType(element.ifShape, element.ns.w3.shacl.NodeShape),
+ 'ifShape is a NodeShape'
+ );
+ assert.isTrue(
+ element._hasType(element.thenShape, element.ns.w3.shacl.NodeShape),
+ 'thenShape is a NodeShape'
+ );
+ assert.isTrue(
+ element._hasType(element.elseShape, element.ns.w3.shacl.NodeShape),
+ 'elseShape is a NodeShape'
+ );
+ });
+
+ // @covers AC2
+ it('renders if/then/else branch documents with kind/breed/indoor', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element.type = data[1];
+ await aTimeout(0);
+ await nextFrame();
+
+ const ifDoc = element.shadowRoot.querySelector(
+ '.conditional-if-document'
+ );
+ const thenDoc = element.shadowRoot.querySelector(
+ '.conditional-then-document'
+ );
+ const elseDoc = element.shadowRoot.querySelector(
+ '.conditional-else-document'
+ );
+ assert.ok(ifDoc, 'If document is rendered');
+ assert.ok(thenDoc, 'Then document is rendered');
+ assert.ok(elseDoc, 'Else document is rendered');
+
+ const branchPropertyNames = async (nestedDoc) => {
+ let names = [];
+ await waitUntil(() => {
+ names = Array.from(
+ nestedDoc.shadowRoot.querySelectorAll('property-shape-document')
+ ).map((node) => node.propertyName);
+ return names.length > 0;
+ }, 'Nested branch property-shape-document did not render');
+ return names;
+ };
+
+ assert.include(
+ await branchPropertyNames(ifDoc),
+ 'kind',
+ 'If branch renders the "kind" property'
+ );
+ assert.include(
+ await branchPropertyNames(thenDoc),
+ 'breed',
+ 'Then branch renders the "breed" property'
+ );
+ assert.include(
+ await branchPropertyNames(elseDoc),
+ 'indoor',
+ 'Else branch renders the "indoor" property'
+ );
+ });
+
+ // @covers AC2
+ it('renders the If condition const value "dog"', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element.type = data[1];
+ await aTimeout(0);
+ await nextFrame();
+
+ const ifDoc = element.shadowRoot.querySelector(
+ '.conditional-if-document'
+ );
+ assert.ok(ifDoc, 'If document is rendered');
+
+ let enumText = '';
+ await waitUntil(() => {
+ const propertyShapeDoc = ifDoc.shadowRoot.querySelector(
+ 'property-shape-document'
+ );
+ if (!propertyShapeDoc) {
+ return false;
+ }
+ const rangeDoc = propertyShapeDoc.shadowRoot.querySelector(
+ 'property-range-document'
+ );
+ if (!rangeDoc) {
+ return false;
+ }
+ const enumNode = rangeDoc.shadowRoot.querySelector('.enum-values');
+ if (!enumNode) {
+ return false;
+ }
+ enumText = enumNode.textContent;
+ return true;
+ }, 'If branch did not render enum values for the "kind" property');
+
+ assert.include(
+ enumText,
+ 'dog',
+ 'If branch shows the const value "dog"'
+ );
+ });
+
+ // @covers AC1
+ it('renders If and Else (no Then) without a silent blank', async () => {
+ const data = await AmfLoader.loadType(
+ 'PetKindFields',
+ item[1],
+ 'oas31-webhooks'
+ );
+ element.amf = data[0];
+ element.type = data[1];
+ await aTimeout(0);
+
+ // Reuse the real resolved PetKindFields branch shapes, but force a
+ // Then-less conditional to exercise the `{if, else}` path. Overriding
+ // the shape props after `_typeChanged` sticks because only an `amf`
+ // change re-runs detection (see ApiTypeDocument.updated()).
+ const { ifShape, elseShape } = element;
+ assert.isOk(ifShape, 'ifShape resolved from PetKindFields');
+ assert.isOk(elseShape, 'elseShape resolved from PetKindFields');
+ element.isConditional = true;
+ element.ifShape = ifShape;
+ element.thenShape = undefined;
+ element.elseShape = elseShape;
+ await element.updateComplete;
+ await nextFrame();
+
+ assert.ok(
+ element.shadowRoot.querySelector('.conditional-if-document'),
+ 'If document renders'
+ );
+ assert.ok(
+ element.shadowRoot.querySelector('.conditional-else-document'),
+ 'Else document renders'
+ );
+ assert.isNull(
+ element.shadowRoot.querySelector('.conditional-then-document'),
+ 'Then document is absent when thenShape is undefined'
+ );
+ });
+ });
+
describe('readOnly properties', () => {
let element = /** @type ApiTypeDocument */ (null);