@remoteoss/json-schema-form
Version:
Headless UI form powered by JSON Schemas
940 lines (867 loc) • 23.5 kB
JavaScript
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.
*/
});