UNPKG

@remoteoss/json-schema-form

Version:

Headless UI form powered by JSON Schemas

940 lines (867 loc) 23.5 kB
import { modify } from '@/modify'; const schemaPet = { type: 'object', additionalProperties: false, properties: { has_pet: { title: 'Has Pet', description: 'Do you have a pet?', oneOf: [ { title: 'Yes', const: 'yes', }, { title: 'No', const: 'no', }, ], 'x-jsf-presentation': { inputType: 'radio', }, type: 'string', }, pet_name: { title: "Pet's name", description: "What's your pet's name?", 'x-jsf-presentation': { inputType: 'text', }, type: 'string', }, pet_age: { title: "Pet's age in months", maximum: 24, 'x-jsf-presentation': { inputType: 'number', }, 'x-jsf-errorMessage': { maximum: 'Your pet cannot be older than 24 months.', }, type: 'integer', }, pet_fat: { title: 'Pet fat percentage', 'x-jsf-presentation': { inputType: 'number', percentage: true, }, type: 'integer', }, pet_address: { properties: { street: { title: 'Street', 'x-jsf-presentation': { inputType: 'text', }, }, }, }, }, required: ['has_pet'], 'x-jsf-order': ['has_pet', 'pet_name', 'pet_age', 'pet_fat', 'pet_address'], allOf: [ { id: 'pet_conditional_id', if: { properties: { has_pet: { const: 'yes', }, }, required: ['has_pet'], }, then: { required: ['pet_name', 'pet_age', 'pet_fat'], }, else: { properties: { pet_name: false, pet_age: false, pet_fat: false, }, }, }, ], }; const schemaAddress = { properties: { address: { properties: { street: { title: 'Street', }, number: { title: 'Number', }, city: { title: 'City', }, }, }, }, }; beforeAll(() => { jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterAll(() => { console.warn.mockRestore(); }); describe('modify() - warnings', () => { it('logs a warning by default', () => { const result = modify(schemaPet, {}); expect(console.warn).toBeCalledWith( 'json-schema-form modify(): We highly recommend you to handle/report the returned `warnings` as they highlight possible bugs in your modifications. To mute this log, pass `muteLogging: true` to the config.' ); console.warn.mockClear(); expect(result.warnings).toEqual([]); }); it('given muteLogging, it does not log the warning', () => { const result = modify(schemaPet, { muteLogging: true, }); expect(console.warn).not.toBeCalled(); expect(result.warnings).toEqual([]); }); }); describe('modify() - basic mutations', () => { it('replace base field', () => { const result = modify(schemaPet, { fields: { pet_name: { title: 'Your pet name', }, has_pet: (fieldAttrs) => { const options = fieldAttrs.oneOf?.map(({ title }) => title).join(' or ') || ''; return { title: 'Pet owner', description: `Do you own a pet? ${options}?`, // "Do you own a pet? Yes or No?" }; }, }, }); expect(result.schema).toMatchObject({ properties: { pet_name: { title: 'Your pet name', }, has_pet: { title: 'Pet owner', description: 'Do you own a pet? Yes or No?', }, }, }); }); it('replace nested field', () => { const result = modify(schemaAddress, { fields: { // You can use dot notation 'address.street': { title: 'Street name', }, 'address.city': () => ({ title: 'City name', }), // Or pass the native object address: (fieldAttrs) => { return { properties: { number: (nestedAttrs) => { return { 'x-test-siblings': Object.keys(fieldAttrs.properties), title: `Door ${nestedAttrs.title}`, }; }, }, }; }, }, }); expect(result.schema).toMatchObject({ properties: { address: { properties: { street: { title: 'Street name', }, number: { title: 'Door Number', 'x-test-siblings': ['street', 'number', 'city'], }, city: { title: 'City name', }, }, }, }, }); }); it('replace fields that dont exist gets ignored', () => { // IMPORTANT NOTE on this behavior: // Context: At Remote we have a lot of global customization that run equally across multiple different JSON Schemas. // With this, we avoid applying customizations to non-existing fields. (aka create fields) // That's why we have the "create" config, specific to create new fields. const result = modify(schemaPet, { fields: { unknown_field: { title: 'This field does not exist in the original schema', }, 'nested.field': { title: 'Nop', }, pet_name: { title: 'New pet title', }, }, }); expect(result.schema.properties.unknown_field).toBeUndefined(); expect(result.schema.properties.nested).toBeUndefined(); expect(result.schema.properties.pet_name).toEqual({ ...schemaPet.properties.pet_name, title: 'New pet title', }); expect(result.warnings).toEqual([ { type: 'FIELD_TO_CHANGE_NOT_FOUND', message: 'Changing field "unknown_field" was ignored because it does not exist.', }, { type: 'FIELD_TO_CHANGE_NOT_FOUND', message: 'Changing field "nested.field" was ignored because it does not exist.', }, ]); }); it('replace all fields', () => { const result = modify(schemaPet, { allFields: (fieldName, fieldAttrs) => { const { inputType, percentage } = fieldAttrs?.['x-jsf-presentation'] || {}; if (inputType === 'number' && percentage === true) { return { styleDecimals: 2, }; } return { dataFoo: 'abc', }; }, }); expect(result.schema).toMatchObject({ properties: { has_pet: { dataFoo: 'abc', }, pet_name: { dataFoo: 'abc', }, pet_age: { dataFoo: 'abc', }, pet_fat: { styleDecimals: 2, }, pet_address: { // assert recursivity properties: { street: { dataFoo: 'abc', }, }, }, }, }); }); it('replace field attrs that are arrays (partial)', () => { const result = modify(schemaPet, { fields: { has_pet: (fieldAttrs) => { const labelsMap = { yes: 'Yes, I have', }; return { oneOf: fieldAttrs.oneOf.map((option) => { const customTitle = labelsMap[option.const]; if (!customTitle) { // TODO - test this // console.error('The option is not handled.'); return option; } return { ...option, title: customTitle, }; }), }; }, }, }); expect(result.schema).toMatchObject({ properties: { has_pet: { oneOf: [ { title: 'Yes, I have', const: 'yes', }, { title: 'No', const: 'no', }, ], }, }, }); }); it('replace field attrs that are arrays (full)', () => { const result = modify(schemaPet, { fields: { has_pet: { oneOf: [{ const: 'yaaas', title: 'YAAS!' }], }, }, }); expect(result.schema).toMatchObject({ properties: { has_pet: { oneOf: [ { const: 'yaaas', title: 'YAAS!', }, ], }, }, }); }); }); describe('supporting shorthands', () => { const invoiceSchema = { properties: { title: { title: 'Invoice title', 'x-jsf-presentation': { inputType: 'text', }, 'x-jsf-errorMessage': { required: 'Cannot be empty.', }, type: 'string', }, total: { title: 'Invoice amount', 'x-jsf-presentation': { inputType: 'money', }, type: 'number', }, taxes: { title: 'Taxes details', properties: { country: { title: 'Country', 'x-jsf-presentation': { inputType: 'country', }, type: 'string', }, percentage: { title: 'Percentage', 'x-jsf-presentation': { inputType: 'number', }, }, type: 'integer', }, }, }, required: ['title'], }; it('basic support for x-jsf-presentation and x-jsf-errorMessage in config.fields', () => { const result = modify(invoiceSchema, { fields: { title: { errorMessage: { maxLength: 'Must be shorter.', }, presentation: { dataFoo: 123, }, }, 'taxes.country': { errorMessage: { required: 'The country is required.', }, presentation: { flags: true, }, }, }, }); // Assert all the other propreties are kept expect(result.schema).toMatchObject(invoiceSchema); expect(result.schema).toMatchObject({ properties: { title: { 'x-jsf-errorMessage': { maxLength: 'Must be shorter.', }, 'x-jsf-presentation': { dataFoo: 123, }, }, taxes: { properties: { country: { 'x-jsf-errorMessage': { required: 'The country is required.', }, 'x-jsf-presentation': { flags: true, }, }, }, }, }, }); }); it('shorthands work in config.allFields', () => { const result = modify(invoiceSchema, { allFields: (fieldName, attrs) => ({ errorMessage: { required: `${attrs.title} cannot be empty.`, }, presentation: { dataName: fieldName, }, }), }); expect(result.schema).toMatchObject({ properties: { title: { 'x-jsf-errorMessage': { required: 'Invoice title cannot be empty.', }, 'x-jsf-presentation': { dataName: 'title', }, }, total: { 'x-jsf-errorMessage': { required: 'Invoice amount cannot be empty.', }, 'x-jsf-presentation': { dataName: 'total', }, }, taxes: { 'x-jsf-errorMessage': { required: 'Taxes details cannot be empty.', }, 'x-jsf-presentation': { dataName: 'taxes', }, properties: { country: { 'x-jsf-errorMessage': { required: 'Country cannot be empty.', }, 'x-jsf-presentation': { dataName: 'taxes.country', }, }, percentage: { 'x-jsf-errorMessage': { required: 'Percentage cannot be empty.', }, 'x-jsf-presentation': { dataName: 'taxes.percentage', }, }, }, }, }, }); }); it('shorthands work in config.create', () => { const result = modify(invoiceSchema, { create: { invoice_id: { title: 'Invoice ID', presentation: { inputType: 'text', }, errorMessage: { pattern: 'Must have 5 characters.', }, }, }, }); expect(result.schema).toMatchObject({ properties: { invoice_id: { title: 'Invoice ID', 'x-jsf-presentation': { inputType: 'text', }, 'x-jsf-errorMessage': { pattern: 'Must have 5 characters.', }, }, }, }); }); }); 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() - reorder fields', () => { it('reorder fields - basic usage', () => { const baseExample = { properties: { /* does not matter */ }, 'x-jsf-order': ['field_a', 'field_b', 'field_c', 'field_d'], }; const result = modify(baseExample, { orderRoot: ['field_c', 'field_b'], }); // 💡 Note how the missing field (field_d) was added to the end as safety measure. expect(result.schema).toMatchObject({ 'x-jsf-order': ['field_c', 'field_b', 'field_a', 'field_d'], }); expect(result.warnings).toMatchObject([ { type: 'ORDER_MISSING_FIELDS', message: 'Some fields got forgotten in the new order. They were automatically appended: field_a, field_d', }, ]); }); it('reorder fields - basic usage fallback', () => { const baseExample = { properties: { /* does not matter */ }, }; const result = modify(baseExample, { orderRoot: ['field_c', 'field_b'], }); // Does not explode if it doesn't have an original order. expect(result.schema).toMatchObject({ 'x-jsf-order': ['field_c', 'field_b'], }); expect(result.warnings).toEqual([]); }); it('reorder fields - as callback based on original order', () => { const baseExample = { properties: { /* does not matter */ }, 'x-jsf-order': ['field_a', 'field_b', 'field_c', 'field_d'], }; const result = modify(baseExample, { orderRoot: (original) => original.reverse(), }); expect(result.schema).toMatchObject({ 'x-jsf-order': ['field_d', 'field_c', 'field_b', 'field_a'], }); }); it('reorder fields in fieldsets (through config.fields)', () => { // NOTE: A better API is needed but we decided to not implement it yet // as we didn't agreed on the best DX. Check PR #78 for proposed APIs. // Until then this is the workaround. // Note the warning "ORDER_MISSING_FIELDS" won't be added. const baseExample = { properties: { address: { properties: { /* does not matter */ }, 'x-jsf-order': ['first_line', 'zipcode', 'city'], }, age: { /* ... */ }, }, 'x-jsf-order': ['address', 'age'], }; const result = modify(baseExample, { fields: { address: (attrs) => { // eslint-disable-next-line no-unused-vars const [_firstLine, ...restOrder] = attrs['x-jsf-order']; return { 'x-jsf-order': restOrder.reverse() }; // ['city', 'zipcode'] }, }, }); expect(result.schema).toMatchObject({ properties: { address: { // Note how first_line was NOT appended 'x-jsf-order': ['city', 'zipcode'], }, }, }); }); }); describe('modify() - create fields', () => { it('create base field', () => { const result = modify(schemaAddress, { create: { new_field: { title: 'New field', type: 'string', }, address: { someAttr: 'foo', }, }, }); expect(result.schema).toMatchObject({ properties: { new_field: { title: 'New field', type: 'string', }, address: schemaAddress.properties.address, }, }); // this is ignored because the field already exists expect(result.schema.properties.address.someAttr).toBe(undefined); expect(result.warnings).toEqual([ { type: 'FIELD_TO_CREATE_EXISTS', message: 'Creating field "address" was ignored because it already exists.', }, ]); }); it('create nested field', () => { const result = modify(schemaAddress, { create: { // Pointer as string 'address.state': { title: 'State', }, // Pointer as object address: { someAttr: 'foo', properties: { district: { title: 'District', }, }, }, // Ignore field street because the field already exists [1] 'address.street': { title: 'Foo', }, }, }); expect(result.schema.properties.address.properties).toMatchObject({ ...schemaAddress.properties.address.properties, state: { title: 'State', }, district: { title: 'District', }, }); // Ignore address.someAttr because the address itself already exists. expect(result.schema.properties.address.someAttr).toBeUndefined(); // Ignore field street because it already exists [1] expect(result.schema.properties.address.properties.street.title).toBe('Street'); expect(result.warnings).toEqual([ { type: 'FIELD_TO_CREATE_EXISTS', message: 'Creating field "address" was ignored because it already exists.', }, { type: 'FIELD_TO_CREATE_EXISTS', message: 'Creating field "address.street" was ignored because it already exists.', }, ]); }); }); 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('basic usage without conditionals', () => { const schemaMinimal = { properties: { age: { title: 'Age', type: 'integer', }, quantity: { title: 'Quantity', type: 'integer', }, }, 'x-jsf-order': ['age', 'quantity'], }; const { schema, warnings } = modify(schemaMinimal, { pick: ['quantity'], }); expect(schema.properties.quantity).toBeDefined(); expect(schema.properties.age).toBeUndefined(); // `allOf` conditionals were not defined, and continue to be so. // This test guards against a regression where lack of `allOf` caused a TypeError. expect(schema.allOf).toBeUndefined(); 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. */ });