diff --git a/src/modify.js b/src/modify.js index b2c2e91..22be26c 100644 --- a/src/modify.js +++ b/src/modify.js @@ -1,5 +1,6 @@ import difference from 'lodash/difference'; import get from 'lodash/get'; +import intersection from 'lodash/intersection'; import merge from 'lodash/merge'; import mergeWith from 'lodash/mergeWith'; import set from 'lodash/set'; @@ -8,6 +9,7 @@ const WARNING_TYPES = { FIELD_TO_CHANGE_NOT_FOUND: 'FIELD_TO_CHANGE_NOT_FOUND', ORDER_MISSING_FIELDS: 'ORDER_MISSING_FIELDS', FIELD_TO_CREATE_EXISTS: 'FIELD_TO_CREATE_EXISTS', + PICK_MISSED_FIELD: 'PICK_MISSED_FIELD', }; /** * @@ -33,6 +35,34 @@ function standardizeAttrs(attrs) { }; } +function isConditionalReferencingAnyPickedField(condition, fieldsToPick) { + const { if: ifCondition, then: thenCondition, else: elseCondition } = condition; + + const inIf = intersection(ifCondition.required, fieldsToPick); + + if (inIf.length > 0) { + return true; + } + + const inThen = + intersection(thenCondition.required, fieldsToPick) || + intersection(Object.keys(thenCondition.properties), fieldsToPick); + + if (inThen.length > 0) { + return true; + } + + const inElse = + intersection(elseCondition.required, fieldsToPick) || + intersection(Object.keys(elseCondition.properties), fieldsToPick); + + if (inElse.length > 0) { + return true; + } + + return false; +} + function rewriteFields(schema, fieldsConfig) { if (!fieldsConfig) return { warnings: null }; const warnings = []; @@ -151,6 +181,112 @@ function createFields(schema, fieldsConfig) { return { warnings: warnings.flat() }; } +function pickFields(originalSchema, fieldsToPick) { + if (!fieldsToPick) { + return { schema: originalSchema, warnings: null }; + } + + const newSchema = { + properties: {}, + }; + + Object.entries(originalSchema).forEach(([attrKey, attrValue]) => { + switch (attrKey) { + case 'properties': + // TODO — handle recursive nested fields + fieldsToPick.forEach((fieldPath) => { + set(newSchema.properties, fieldPath, attrValue[fieldPath]); + }); + break; + case 'x-jsf-order': + case 'required': + newSchema[attrKey] = attrValue.filter((fieldName) => fieldsToPick.includes(fieldName)); + break; + case 'allOf': { + // remove conditionals that do not contain any reference to fieldsToPick + const newAllOf = originalSchema.allOf.filter((condition) => + isConditionalReferencingAnyPickedField(condition, fieldsToPick) + ); + + newSchema[attrKey] = newAllOf; + + break; + } + default: + newSchema[attrKey] = attrValue; + } + }); + + // Look for unpicked fields in the conditionals... + let missingFields = {}; + newSchema.allOf.forEach((condition) => { + const { if: ifCondition, then: thenCondition, else: elseCondition } = condition; + const index = originalSchema.allOf.indexOf(condition); + missingFields = { + ...missingFields, + ...findMissingFields(ifCondition, { + fields: fieldsToPick, + path: `allOf[${index}].if`, + }), + ...findMissingFields(thenCondition, { + fields: fieldsToPick, + path: `allOf[${index}].then`, + }), + ...findMissingFields(elseCondition, { + fields: fieldsToPick, + path: `allOf[${index}].else`, + }), + }; + }); + + const warnings = []; + + if (Object.keys(missingFields).length > 0) { + // Re-add them to the schema... + Object.entries(missingFields).forEach(([fieldName]) => { + set(newSchema.properties, fieldName, originalSchema.properties[fieldName]); + }); + + warnings.push({ + type: WARNING_TYPES.PICK_MISSED_FIELD, + message: `The picked fields are in conditionals that refeer other fields. They added automatically: ${Object.keys( + missingFields + ) + .map((name) => `"${name}"`) + .join(', ')}. Check "meta" for more details.`, + meta: missingFields, + }); + } + + return { schema: newSchema, warnings }; +} + +function findMissingFields(conditional, { fields, path }) { + if (!conditional) { + return null; + } + + let missingFields = {}; + + conditional.required?.forEach((fieldName) => { + if (!fields.includes(fieldName)) { + missingFields[fieldName] = { + path, + }; + } + }); + + Object.entries(conditional.properties || []).forEach(([fieldName]) => { + if (!fields.includes(fieldName)) { + missingFields[fieldName] = { path }; + } + + // TODO support nested fields (eg if properties.adddress.properties.door_number) + }); + + return missingFields; +} + export function modify(originalSchema, config) { const schema = JSON.parse(JSON.stringify(originalSchema)); // All these functions mutate "schema" that's why we create a copy above @@ -160,7 +296,10 @@ export function modify(originalSchema, config) { const resultCreate = createFields(schema, config.create); - const resultReorder = reorderFields(schema, config.orderRoot); + const resultPick = pickFields(schema, config.pick); + + const finalSchema = resultPick.schema; + const resultReorder = reorderFields(finalSchema, config.orderRoot); if (!config.muteLogging) { console.warn( @@ -168,12 +307,17 @@ export function modify(originalSchema, config) { ); } - const warnings = [resultRewrite.warnings, resultCreate.warnings, resultReorder.warnings] + const warnings = [ + resultRewrite.warnings, + resultCreate.warnings, + resultPick.warnings, + resultReorder.warnings, + ] .flat() .filter(Boolean); return { - schema, + schema: finalSchema, warnings, }; } diff --git a/src/tests/modify.test.js b/src/tests/modify.test.js index c1fa25c..9c68ab9 100644 --- a/src/tests/modify.test.js +++ b/src/tests/modify.test.js @@ -53,6 +53,9 @@ const schemaPet = { properties: { street: { title: 'Street', + 'x-jsf-presentation': { + inputType: 'text', + }, }, }, }, @@ -408,26 +411,33 @@ describe('modify() - basic mutations', () => { }; }, 'pet_address.street': { - errorMessage: { + presentation: { 'data-foo': 456, }, }, }, }); + const originalPetAgePresentation = schemaPet.properties.pet_age['x-jsf-presentation']; + expect(originalPetAgePresentation).toBeDefined(); + + const originalPetStreetPresentation = + schemaPet.properties.pet_address.properties.street['x-jsf-presentation']; + expect(originalPetStreetPresentation).toBeDefined(); + expect(result.schema).toMatchObject({ properties: { pet_age: { 'x-jsf-presentation': { - ...schemaPet.properties.pet_age['x-jsf-presentation'], + ...originalPetAgePresentation, 'data-foo': 123, }, }, pet_address: { properties: { street: { - 'x-jsf-errorMessage': { - ...schemaPet.properties.pet_address.properties.street['x-jsf-presentation'], + 'x-jsf-presentation': { + ...originalPetStreetPresentation, 'data-foo': 456, }, }, @@ -438,6 +448,69 @@ describe('modify() - basic mutations', () => { }); }); +const schemaTickets = { + properties: { + age: { + title: 'Age', + type: 'integer', + }, + quantity: { + title: 'Quantity', + type: 'integer', + }, + has_premium: { + title: 'Has premium', + type: 'string', + }, + premium_id: { + title: 'Premium ID', + type: 'boolean', + }, + reason: { + title: 'Why not premium?', + type: 'string', + }, + }, + 'x-jsf-order': ['age', 'quantity', 'has_premium', 'premium_id', 'reason'], + allOf: [ + { + // Empty conditional to sanity test empty cases + if: {}, + then: {}, + else: {}, + }, + // Create two conditionals to test both get matched + { + if: { + has_premium: { + const: 'yes', + }, + required: ['has_premium'], + }, + then: { + required: ['premium_id'], + }, + else: {}, + }, + { + if: { + properties: { + has_premium: { + const: 'no', + }, + }, + required: ['has_premium'], + }, + then: { + properties: { + reason: false, + }, + }, + else: {}, + }, + ], +}; + describe('modify() - reoder fields', () => { it('reorder fields - basic usage', () => { const baseExample = { @@ -626,3 +699,118 @@ describe('modify() - create fields', () => { ]); }); }); + +describe('modify() - pick fields', () => { + it('basic usage', () => { + const { schema, warnings } = modify(schemaTickets, { + pick: ['quantity'], + }); + + // Note how the other fields got removed from + // from the root properties, the "order" and "allOf". + expect(schema.properties).toEqual({ + quantity: { + title: 'Quantity', + type: 'integer', + }, + }); + expect(schema.properties.age).toBeUndefined(); + expect(schema.properties.has_premium).toBeUndefined(); + expect(schema.properties.premium_id).toBeUndefined(); + + expect(schema['x-jsf-order']).toEqual(['quantity']); + expect(schema.allOf).toEqual([]); // conditional got removed. + + expect(warnings).toHaveLength(0); + }); + + it('related conditionals are kept - (else)', () => { + const { schema, warnings } = modify(schemaTickets, { + pick: ['has_premium'], + }); + + expect(schema).toMatchObject({ + properties: { + has_premium: { + title: 'Has premium', + }, + premium_id: { + title: 'Premium ID', + }, + reason: { + title: 'Why not premium?', + }, + }, + allOf: [schemaTickets.allOf[1], schemaTickets.allOf[2]], + }); + + expect(schema.properties.quantity).toBeUndefined(); + expect(schema.properties.age).toBeUndefined(); + expect(warnings).toEqual([ + { + type: 'PICK_MISSED_FIELD', + message: + 'The picked fields are in conditionals that refeer other fields. They added automatically: "premium_id", "reason". Check "meta" for more details.', + meta: { premium_id: { path: 'allOf[1].then' }, reason: { path: 'allOf[2].then' } }, + }, + ]); + }); + + it('related conditionals are kept - (if)', () => { + const { schema, warnings } = modify(schemaTickets, { + pick: ['premium_id'], + }); + + expect(schema).toMatchObject({ + properties: { + has_premium: { + title: 'Has premium', + }, + premium_id: { + title: 'Premium ID', + }, + }, + allOf: [schemaTickets.allOf[0]], + }); + + expect(schema.properties.quantity).toBeUndefined(); + expect(schema.properties.age).toBeUndefined(); + expect(warnings).toEqual([ + { + type: 'PICK_MISSED_FIELD', + message: + 'The picked fields are in conditionals that refeer other fields. They added automatically: "has_premium". Check "meta" for more details.', + meta: { has_premium: { path: 'allOf[1].if' } }, + }, + ]); + }); + + it('reorder only handles the picked fields', () => { + const { schema, warnings } = modify(schemaTickets, { + pick: ['age', 'quantity'], + orderRoot: (original) => original.reverse(), + }); + + // The order only includes those 2 fields + expect(schema['x-jsf-order']).toEqual(['quantity', 'age']); + // There are no warnings about forgotten fields. + expect(warnings).toHaveLength(0); + + // Sanity check the result + expect(schema.properties.quantity).toBeDefined(); + expect(schema.properties.age).toBeDefined(); + expect(schema.properties.has_premium).toBeUndefined(); + expect(schema.properties.premium_id).toBeUndefined(); + expect(schema.allOf).toEqual([]); + }); + + // For later on when needed. + it.todo('ignore conditionals with unpicked fields'); + + it.todo('pick nested fields (fieldsets)'); + /* Use cases: + - conditionals inside fieldstes. eg properties.family.allOf[0].if... + - conditional in the root pointing to nested fields: eg if properties.family.properties.simblings is 0 then hide properties.playTogether ... + - variations of each one of these similar to the existing tests. + */ +});