@ngx-formly/core
Version:
Formly is a dynamic (JSON powered) form library for Angular that bring unmatched maintainability to your application's forms.
736 lines (732 loc) • 33 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable } from '@angular/core';
import { UntypedFormArray, UntypedFormGroup } from '@angular/forms';
import { ɵhasKey as _hasKey, ɵgetFieldValue as _getFieldValue, ɵclone as _clone, ɵreverseDeepMerge as _reverseDeepMerge } from '@ngx-formly/core';
import { tap } from 'rxjs/operators';
// https://stackoverflow.com/a/27865285
function decimalPlaces(a) {
if (!isFinite(a)) {
return 0;
}
let e = 1, p = 0;
while (Math.round(a * e) / e !== a) {
e *= 10;
p++;
}
return p;
}
function isEmpty(v) {
return v === '' || v == null;
}
function isObject(v) {
return v != null && typeof v === 'object' && !Array.isArray(v);
}
function isInteger(value) {
return Number.isInteger ? Number.isInteger(value) : typeof value === 'number' && Math.floor(value) === value;
}
function isConst(schema) {
return typeof schema === 'object' && (schema.hasOwnProperty('const') || (schema.enum && schema.enum.length === 1));
}
function toNumber(value) {
if (value === '' || value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (typeof value === 'number') {
return value;
}
const val = parseFloat(value);
return !isNaN(val) ? val : value;
}
function totalMatchedFields(field) {
if (!field.fieldGroup) {
return _hasKey(field) && _getFieldValue(field) !== undefined ? 1 : 0;
}
const total = field.fieldGroup.reduce((s, f) => totalMatchedFields(f) + s, 0);
if (total === 0 && _hasKey(field)) {
const value = _getFieldValue(field);
if (value === null ||
(value !== undefined && ((field.fieldArray && Array.isArray(value)) || (!field.fieldArray && isObject(value))))) {
return 1;
}
}
return total;
}
class FormlyJsonschema {
toFieldConfig(schema, options) {
schema = _clone(schema);
return this._toFieldConfig(schema, { schema, ...(options || {}) });
}
_toFieldConfig(schema, { key, isOptional, ...options }) {
schema = this.resolveSchema(schema, options);
const types = this.guessSchemaType(schema);
let field = {
type: types[0],
defaultValue: schema.default,
props: {
label: schema.title,
readonly: schema.readOnly,
description: schema.description,
},
};
if (key != null) {
field.key = key;
}
if (!options.ignoreDefault && (schema.readOnly || options.readOnly)) {
field.props.disabled = true;
options = { ...options, readOnly: true };
}
if (options.resetOnHide) {
field.resetOnHide = true;
}
if (options.shareFormControl === false) {
field.shareFormControl = false;
}
if (field.defaultValue === undefined && types.length === 1 && isOptional === false) {
switch (types[0]) {
case 'null': {
field.defaultValue = null;
break;
}
case 'string': {
field.defaultValue = '';
break;
}
case 'object': {
field.defaultValue = {};
break;
}
case 'array': {
field.defaultValue = schema.minItems > 0 ? Array.from(new Array(schema.minItems)) : [];
break;
}
}
}
if (options.ignoreDefault) {
delete field.defaultValue;
}
this.addValidator(field, 'type', {
schemaType: types,
expression: ({ value }) => {
if (value === undefined) {
return true;
}
if (value === null && types.indexOf('null') !== -1) {
return true;
}
switch (types[0]) {
case 'null': {
return typeof value === null;
}
case 'string': {
return typeof value === 'string';
}
case 'integer': {
return isInteger(value);
}
case 'number': {
return typeof value === 'number';
}
case 'object': {
return isObject(value);
}
case 'array': {
return Array.isArray(value);
}
}
return true;
},
});
switch (field.type) {
case 'number':
case 'integer': {
field.parsers = [
(v, f) => {
v = toNumber(v);
if (v === null && f) {
const input = typeof document !== 'undefined' && f.id
? document.querySelector(`#${f.id}`)
: undefined;
if (input && input.validity && !input.validity.badInput) {
v = undefined;
}
if (v !== f.formControl.value) {
f.formControl.setValue(v, { emitModelToViewChange: false });
}
}
return v;
},
];
if (schema.hasOwnProperty('minimum')) {
field.props.min = schema.minimum;
}
if (schema.hasOwnProperty('maximum')) {
field.props.max = schema.maximum;
}
if (schema.hasOwnProperty('exclusiveMinimum')) {
field.props.exclusiveMinimum = schema.exclusiveMinimum;
this.addValidator(field, 'exclusiveMinimum', ({ value }) => isEmpty(value) || value > schema.exclusiveMinimum);
}
if (schema.hasOwnProperty('exclusiveMaximum')) {
field.props.exclusiveMaximum = schema.exclusiveMaximum;
this.addValidator(field, 'exclusiveMaximum', ({ value }) => isEmpty(value) || value < schema.exclusiveMaximum);
}
if (schema.hasOwnProperty('multipleOf')) {
field.props.step = schema.multipleOf;
this.addValidator(field, 'multipleOf', ({ value }) => {
if (isEmpty(value) || typeof value !== 'number' || value === 0 || schema.multipleOf <= 0) {
return true;
}
// https://github.com/ajv-validator/ajv/issues/652#issue-283610859
const multiplier = Math.pow(10, decimalPlaces(schema.multipleOf));
return Math.round(value * multiplier) % Math.round(schema.multipleOf * multiplier) === 0;
});
}
break;
}
case 'string': {
field.parsers = [
(v, f) => {
if (types.indexOf('null') !== -1) {
v = isEmpty(v) ? null : v;
}
else if (f && !f.props.required) {
v = v === '' ? undefined : v;
}
return v;
},
];
['minLength', 'maxLength', 'pattern'].forEach((prop) => {
if (schema.hasOwnProperty(prop)) {
field.props[prop] = schema[prop];
}
});
break;
}
case 'object': {
if (!field.fieldGroup) {
field.fieldGroup = [];
}
const { propDeps, schemaDeps } = this.resolveDependencies(schema);
Object.keys(schema.properties || {}).forEach((property) => {
const isRequired = Array.isArray(schema.required) && schema.required.indexOf(property) !== -1;
const f = this._toFieldConfig(schema.properties[property], {
...options,
key: property,
isOptional: isOptional || !isRequired,
});
field.fieldGroup.push(f);
if (isRequired || propDeps[property]) {
f.expressions = {
...(f.expressions || {}),
'props.required': (f) => {
let parent = f.parent;
const model = f.fieldGroup && f.key != null ? parent.model : f.model;
while (parent.key == null && parent.parent) {
parent = parent.parent;
}
const required = parent && parent.props ? parent.props.required : false;
if (!model && !required) {
return false;
}
if (Array.isArray(schema.required) && schema.required.indexOf(property) !== -1) {
return true;
}
return propDeps[property] && f.model && propDeps[property].some((k) => !isEmpty(f.model[k]));
},
};
}
if (schemaDeps[property]) {
const getConstValue = (s) => {
return s.hasOwnProperty('const') ? s.const : s.enum[0];
};
const oneOfSchema = schemaDeps[property].oneOf;
if (oneOfSchema &&
oneOfSchema.every((o) => o.properties && o.properties[property] && isConst(o.properties[property]))) {
oneOfSchema.forEach((oneOfSchemaItem) => {
const { [property]: constSchema, ...properties } = oneOfSchemaItem.properties;
field.fieldGroup.push({
...this._toFieldConfig({ ...oneOfSchemaItem, properties }, { ...options, shareFormControl: false, resetOnHide: true }),
expressions: {
hide: (f) => !f.model || getConstValue(constSchema) !== f.model[property],
},
});
});
}
else {
field.fieldGroup.push({
...this._toFieldConfig(schemaDeps[property], options),
expressions: {
hide: (f) => !f.model || isEmpty(f.model[property]),
},
});
}
}
});
if (schema.oneOf) {
field.fieldGroup.push(this.resolveMultiSchema('oneOf', schema.oneOf, { ...options, shareFormControl: false }));
}
if (schema.anyOf) {
field.fieldGroup.push(this.resolveMultiSchema('anyOf', schema.anyOf, options));
}
// Process if/then/else conditional schemas
if (options.conditionalSchemas) {
const conditionalSchemas = options.conditionalSchemas;
conditionalSchemas.forEach((conditionalSchema) => {
const condition = conditionalSchema._ifCondition;
if (condition && conditionalSchema.properties) {
// Create a field group with the conditional fields
const conditionalFieldGroup = {
fieldGroup: [],
expressions: {
hide: (f) => {
if (!f.model) {
return true;
}
const modelValue = f.model[condition.property];
const matches = modelValue === condition.value;
// If negate is true (for "else" case), invert the condition
return condition.negate ? matches : !matches;
},
},
};
// Add each property from the conditional schema to the field group
Object.keys(conditionalSchema.properties).forEach((property) => {
const propSchema = conditionalSchema.properties[property];
if (!propSchema) {
return;
}
const isRequired = Array.isArray(conditionalSchema.required) && conditionalSchema.required.indexOf(property) !== -1;
const conditionalField = this._toFieldConfig(propSchema, {
...options,
key: property,
isOptional: !isRequired,
resetOnHide: true,
});
conditionalFieldGroup.fieldGroup.push(conditionalField);
});
field.fieldGroup.push(conditionalFieldGroup);
}
});
}
break;
}
case 'array': {
if (schema.hasOwnProperty('minItems')) {
field.props.minItems = schema.minItems;
this.addValidator(field, 'minItems', ({ value }) => {
return isEmpty(value) || value.length >= schema.minItems;
});
if (!isOptional && schema.minItems > 0 && field.defaultValue === undefined) {
field.defaultValue = Array.from(new Array(schema.minItems));
}
}
if (schema.hasOwnProperty('maxItems')) {
field.props.maxItems = schema.maxItems;
this.addValidator(field, 'maxItems', ({ value }) => {
return isEmpty(value) || value.length <= schema.maxItems;
});
}
if (schema.hasOwnProperty('uniqueItems')) {
field.props.uniqueItems = schema.uniqueItems;
this.addValidator(field, 'uniqueItems', ({ value }) => {
if (isEmpty(value) || !schema.uniqueItems) {
return true;
}
const uniqueItems = Array.from(new Set(value.map((v) => JSON.stringify(v, (k, o) => {
if (isObject(o)) {
return Object.keys(o)
.sort()
.reduce((obj, key) => {
obj[key] = o[key];
return obj;
}, {});
}
return o;
}))));
return uniqueItems.length === value.length;
});
}
// resolve items schema needed for isEnum check
if (schema.items && !Array.isArray(schema.items)) {
schema.items = this.resolveSchema(schema.items, options);
}
// TODO: remove isEnum check once adding an option to skip extension
if (!this.isEnum(schema)) {
field.fieldArray = (root) => {
const length = root.fieldGroup ? root.fieldGroup.length : 0;
const items = schema.items;
if (!Array.isArray(items)) {
if (!items) {
return {};
}
const isMultiSchema = items.oneOf || items.anyOf;
// When items is a single schema, the additionalItems keyword is meaningless, and it should not be used.
const f = this._toFieldConfig(items, isMultiSchema ? { ...options, key: `${length}`, isOptional: false } : { ...options, isOptional: false });
if (isMultiSchema && !_hasKey(f)) {
f.key = null;
}
return f;
}
const itemSchema = items[length] ? items[length] : schema.additionalItems;
const f = itemSchema ? this._toFieldConfig(itemSchema, options) : {};
if (f.props) {
f.props.required = true;
}
if (items[length]) {
f.props.removable = false;
}
return f;
};
}
break;
}
}
if (schema.hasOwnProperty('const')) {
field.props.const = schema.const;
this.addValidator(field, 'const', ({ value }) => value === schema.const);
if (!field.type) {
field.defaultValue = schema.const;
}
}
if (this.isEnum(schema)) {
const enumOptions = this.toEnumOptions(schema);
const multiple = field.type === 'array';
field.type = 'enum';
field.props.multiple = multiple;
field.props.options = enumOptions;
const enumValues = enumOptions.map((o) => o.value);
this.addValidator(field, 'enum', ({ value }) => {
if (value === undefined) {
return true;
}
if (multiple) {
return Array.isArray(value) ? value.every((o) => enumValues.includes(o)) : false;
}
return enumValues.includes(value);
});
}
if (schema.oneOf && !field.type) {
delete field.key;
field.fieldGroup = [
this.resolveMultiSchema('oneOf', schema.oneOf, { ...options, key, shareFormControl: false }),
];
}
if (schema.anyOf && !field.type) {
delete field.key;
field.fieldGroup = [
this.resolveMultiSchema('oneOf', schema.anyOf, { ...options, key, shareFormControl: false }),
];
}
// map in possible formlyConfig options from the widget property
if (schema.widget?.formlyConfig) {
field = this.mergeFields(field, schema.widget.formlyConfig);
}
field.templateOptions = field.props;
// if there is a map function passed in, use it to allow the user to
// further customize how fields are being mapped
return options.map ? options.map(field, schema) : field;
}
resolveSchema(schema, options) {
if (schema && schema.$ref) {
schema = this.resolveDefinition(schema, options);
}
if (schema && schema.allOf) {
schema = this.resolveAllOf(schema, options);
}
// Process if/then/else and store conditionals in options for later processing
if (schema && (schema.if || schema.then || schema.else)) {
const conditionalSchemas = this.resolveIfThenElse(schema, options);
if (conditionalSchemas.length > 0) {
// Store conditional schemas in options for processing in _toFieldConfig
options.conditionalSchemas = conditionalSchemas;
}
}
return schema;
}
resolveAllOf({ allOf, ...baseSchema }, options) {
if (!allOf.length) {
throw Error(`allOf array can not be empty ${allOf}.`);
}
return allOf.reduce((base, schema) => {
schema = this.resolveSchema(schema, options);
if (base.required && schema.required) {
base.required = [...base.required, ...schema.required];
}
if (schema.uniqueItems) {
base.uniqueItems = schema.uniqueItems;
}
// resolve to min value
['maxLength', 'maximum', 'exclusiveMaximum', 'maxItems', 'maxProperties'].forEach((prop) => {
if (!isEmpty(base[prop]) && !isEmpty(schema[prop])) {
base[prop] = base[prop] < schema[prop] ? base[prop] : schema[prop];
}
});
// resolve to max value
['minLength', 'minimum', 'exclusiveMinimum', 'minItems', 'minProperties'].forEach((prop) => {
if (!isEmpty(base[prop]) && !isEmpty(schema[prop])) {
base[prop] = base[prop] > schema[prop] ? base[prop] : schema[prop];
}
});
return _reverseDeepMerge(base, schema);
}, baseSchema);
}
resolveMultiSchema(mode, schemas, options) {
return {
type: 'multischema',
fieldGroup: [
{
type: 'enum',
defaultValue: -1,
props: {
multiple: mode === 'anyOf',
options: schemas.map((s, i) => ({ label: s.title, value: i, disabled: s.readOnly })),
},
hooks: {
onInit: (f) => f.formControl.valueChanges.pipe(tap(() => f.options.detectChanges(f.parent))),
},
},
{
fieldGroup: schemas.map((s, i) => ({
...this._toFieldConfig(s, { ...options, resetOnHide: true }),
expressions: {
hide: (f, forceUpdate) => {
const control = f.parent.parent.fieldGroup[0].formControl;
if (control.value === -1 || forceUpdate) {
let value = f.parent.fieldGroup
.map((f, i) => [f, i, this.isFieldValid(f, i, schemas, options)])
.sort(([f1, , f1Valid], [f2, , f2Valid]) => {
if (f1Valid !== f2Valid) {
return f2Valid ? 1 : -1;
}
const matchedFields1 = totalMatchedFields(f1);
const matchedFields2 = totalMatchedFields(f2);
if (matchedFields1 === matchedFields2) {
if (f1.props.disabled === f2.props.disabled) {
return 0;
}
return matchedFields2 > matchedFields1 ? 1 : -1;
}
return matchedFields2 > matchedFields1 ? 1 : -1;
})
.map(([, i]) => i);
if (mode === 'anyOf') {
const definedValue = value.filter((i) => totalMatchedFields(f.parent.fieldGroup[i]));
value = definedValue.length > 0 ? definedValue : [value[0] || 0];
}
value = value.length > 0 ? value : [0];
control.setValue(mode === 'anyOf' ? value : value[0]);
}
return Array.isArray(control.value) ? control.value.indexOf(i) === -1 : control.value !== i;
},
},
})),
},
],
};
}
resolveDefinition(schema, options) {
const [uri, pointer] = schema.$ref.split('#/');
if (uri) {
throw Error(`Remote schemas for ${schema.$ref} not supported yet.`);
}
const definition = !pointer
? null
: pointer
.split('/')
.reduce((def, path) => (def?.hasOwnProperty(path) ? def[path] : null), options.schema);
if (!definition) {
throw Error(`Cannot find a definition for ${schema.$ref}.`);
}
if (definition.$ref) {
return this.resolveDefinition(definition, options);
}
return {
...definition,
...['title', 'description', 'default', 'widget'].reduce((annotation, p) => {
if (schema.hasOwnProperty(p)) {
annotation[p] = schema[p];
}
return annotation;
}, {}),
};
}
resolveDependencies(schema) {
const propDeps = {};
const schemaDeps = {};
Object.keys(schema.dependencies || {}).forEach((prop) => {
const dependency = schema.dependencies[prop];
if (Array.isArray(dependency)) {
// Property dependencies
dependency.forEach((dep) => {
if (!propDeps[dep]) {
propDeps[dep] = [prop];
}
else {
propDeps[dep].push(prop);
}
});
}
else {
// schema dependencies
schemaDeps[prop] = dependency;
}
});
return { propDeps, schemaDeps };
}
extractIfCondition(ifSchema) {
// Extract the property and const value from the "if" schema
// Supports: { "properties": { "propName": { "const": value } } }
if (ifSchema.properties) {
const propName = Object.keys(ifSchema.properties)[0];
if (propName) {
const propSchema = ifSchema.properties[propName];
if (propSchema && propSchema.hasOwnProperty('const')) {
return { property: propName, value: propSchema.const };
}
}
}
return null;
}
resolveIfThenElse(schema, options) {
// Process if/then/else and return array of conditional schemas to add to fieldGroup
const conditionalSchemas = [];
if (schema.if && typeof schema.if === 'object') {
const condition = this.extractIfCondition(schema.if);
if (condition) {
// Process "then" branch
if (schema.then && typeof schema.then === 'object') {
const resolvedSchema = this.resolveConditionalSchema(schema.then, options);
conditionalSchemas.push({
...resolvedSchema,
_ifCondition: condition,
});
}
// Process "else" branch
if (schema.else && typeof schema.else === 'object') {
const resolvedSchema = this.resolveConditionalSchema(schema.else, options);
conditionalSchemas.push({
...resolvedSchema,
_ifCondition: { property: condition.property, value: condition.value, negate: true },
});
}
}
}
return conditionalSchemas;
}
resolveConditionalSchema(conditionalSchema, options) {
// Resolve $ref and allOf if present in conditional schema
// Note: We don't use resolveSchema here to avoid recursive processing of nested if/then/else
let resolved = conditionalSchema;
if (conditionalSchema.$ref) {
resolved = this.resolveDefinition(conditionalSchema, options);
}
if (resolved.allOf) {
resolved = this.resolveAllOf(resolved, options);
}
return resolved;
}
guessSchemaType(schema) {
const type = schema?.type;
if (!type && schema?.properties) {
return ['object'];
}
if (Array.isArray(type)) {
if (type.length === 1) {
return type;
}
if (type.length === 2 && type.indexOf('null') !== -1) {
return type.sort((t1) => (t1 == 'null' ? 1 : -1));
}
return type;
}
return type ? [type] : [];
}
addValidator(field, name, validator) {
field.validators = field.validators || {};
field.validators[name] = validator;
}
isEnum(schema) {
return (!!schema.enum ||
(schema.anyOf && schema.anyOf.every(isConst)) ||
(schema.oneOf && schema.oneOf.every(isConst)) ||
(schema.uniqueItems && schema.items && !Array.isArray(schema.items) && this.isEnum(schema.items)));
}
toEnumOptions(schema) {
if (schema.enum) {
return schema.enum.map((value) => ({ value, label: value }));
}
const toEnum = (s) => {
const value = s.hasOwnProperty('const') ? s.const : s.enum[0];
const option = { value, label: s.title || value };
if (s.readOnly) {
option.disabled = true;
}
return option;
};
if (schema.anyOf) {
return schema.anyOf.map(toEnum);
}
if (schema.oneOf) {
return schema.oneOf.map(toEnum);
}
return this.toEnumOptions(schema.items);
}
isFieldValid(root, i, schemas, options) {
const schema = schemas[i];
if (!schema._field) {
Object.defineProperty(schema, '_field', { enumerable: false, writable: true, configurable: true });
}
let field = schema._field;
let model = root.model ? root.model : root.fieldArray ? [] : {};
if (root.model && _hasKey(root)) {
model = { [Array.isArray(root.key) ? root.key.join('.') : root.key]: _getFieldValue(root) };
}
model = _clone(model);
if (!field) {
field = schema._field = root.options.build({
form: Array.isArray(model) ? new UntypedFormArray([]) : new UntypedFormGroup({}),
fieldGroup: [
this._toFieldConfig(schema, {
...options,
resetOnHide: true,
ignoreDefault: true,
map: null,
}),
],
model,
options: {},
});
}
else {
field.model = model;
root.options.build(field);
}
return field.form.valid;
}
mergeFields(f1, f2) {
for (const prop in f2) {
const f1Prop = prop === 'templateOptions' ? 'props' : prop;
if (isObject(f1[f1Prop]) && isObject(f2[prop])) {
f1[f1Prop] = this.mergeFields(f1[f1Prop], f2[prop]);
}
else if (f2[prop] != null) {
f1[f1Prop] = f2[prop];
}
}
return f1;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormlyJsonschema, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormlyJsonschema, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImport: i0, type: FormlyJsonschema, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* Generated bundle index. Do not edit.
*/
export { FormlyJsonschema };
//# sourceMappingURL=ngx-formly-core-json-schema.mjs.map