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);