UNPKG

@blinkk/selective-edit

Version:
566 lines 21 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Field = void 0; const validation_1 = require("./validation"); const validationRules_1 = require("./validationRules"); const template_1 = require("./template"); const lit_html_1 = require("lit-html"); const mixins_1 = require("../mixins"); const data_1 = require("../mixins/data"); const dataType_1 = require("../utility/dataType"); const events_1 = require("./events"); const uuid_1 = require("../mixins/uuid"); const class_map_js_1 = require("lit-html/directives/class-map.js"); const lodash_clonedeep_1 = __importDefault(require("lodash.clonedeep")); const autoFields_1 = require("./autoFields"); const repeat_js_1 = require("lit-html/directives/repeat.js"); const json_stable_stringify_1 = __importDefault(require("json-stable-stringify")); class Field extends (0, uuid_1.UuidMixin)((0, data_1.DataMixin)(mixins_1.Base)) { constructor(types, config, globalConfig, fieldType = 'unknown') { super(); this.types = types; this.config = config; this.globalConfig = globalConfig; this.fieldType = fieldType; this.isLocked = false; this.isDeepLinked = false; this.usingAutoFields = false; } /** * Generates a list of classes to apply to the field element. */ classesForField() { const classes = { selective__field: true, 'selective__field--auto': this.usingAutoFields, 'selective__field--dirty': !this.isClean, 'selective__field--guess': this.config.isGuessed || false, 'selective__field--invalid': !this.isValid, 'selective__field--linked': this.isDeepLinked, 'selective__field--required': this.validation?.isRequired() || false, }; classes[`selective__field__type__${this.fieldType}`] = true; for (const className of this.config.classes || []) { classes[className] = true; } return classes; } /** * Generates a list of classes to apply to the input element. */ classesForInput(zoneKey = validation_1.DEFAULT_ZONE_KEY) { const classes = { selective__field__input: true, }; if (!this.isValid) { for (const level of [ validation_1.ValidationLevel.Error, validation_1.ValidationLevel.Warning, validation_1.ValidationLevel.Info, ]) { if (this.validation?.hasAnyResults(zoneKey, level)) { classes[`selective__field__input--${level}`] = true; } } } return classes; } /** * Generates a list of classes to apply to the label element. */ classesForLabel(zoneKey = validation_1.DEFAULT_ZONE_KEY) { const classes = { selective__field__label: true, }; if (!this.isValid) { if (!this.isValid) { for (const level of [ validation_1.ValidationLevel.Error, validation_1.ValidationLevel.Warning, validation_1.ValidationLevel.Info, ]) { if (this.validation?.hasAnyResults(zoneKey, level)) { classes[`selective__field__label--${level}`] = true; } } } } return classes; } /** * The format of the original value may need to be cleaned up to be used * by the editor in a consistent format. * * @param value Original value from the source. */ cleanOriginalValue(value) { // Deep copy the value to prevent shared reference modification. return (0, lodash_clonedeep_1.default)(value); } /** * Store the validation to keep from having to repeat the validation. * * Validation is reset every time the updateOriginal is called (every render). * * @param editor Selective editor being rendered. */ ensureValidation(editor) { if (!this.validation) { this.validation = new validation_1.Validation(this.rules); } // Only validate when the editor is marked for validation // or the field has lost the user focus unless delayed. if ((!editor?.config.delayValidation && this.hasLostFocus()) || editor?.markValidation) { const zoneKeys = Object.keys(this.zones ?? {}); // If there is only the default zone with a default key use simple validation. const onlySimpleDefaultZone = !this.zones || (zoneKeys.length === 1 && zoneKeys[0] === validation_1.DEFAULT_ZONE_KEY && this.zones[validation_1.DEFAULT_ZONE_KEY].key === validation_1.DEFAULT_ZONE_KEY); if (!this.zones || onlySimpleDefaultZone) { // Simple field with only the default zone. // Simple field. this.validation.validate(this.currentValue); } else { // Complex field, validate each zone separately. const value = this.currentValue || {}; for (const zoneKey of zoneKeys) { const valueKey = this.zones[zoneKey].key; this.validation.validate(value[valueKey], zoneKey); } } } } get fullKey() { if (this.config.parentKey) { return `${this.config.parentKey}.${this.key}`; } return this.key; } /** * Handle when the input changes value. * * @param evt Input event from changing value. */ handleInput(evt) { const target = evt.target; this.currentValue = target.value; this.render(); } /** * Handle when the input loses focus. */ handleBlur() { // Mark that the field has lost focus. this.lostFocus(); this.render(); } /** * Determines if the field has lost focus before for a zone. * * This is used for UI to determine when to display validation * messsages for a better UX when they have not interacted with * the field. */ hasLostFocus(zoneKey = validation_1.DEFAULT_ZONE_KEY) { if (!this.zones) { return false; } return this.zones[zoneKey]?.hasLostFocus ?? false; } get isClean() { // When locked, the field is automatically considered dirty. if (this.isLocked) { return false; } return (0, json_stable_stringify_1.default)(this.currentValue) === (0, json_stable_stringify_1.default)(this.originalValue); } /** * Check if the data format is invalid for what the field expects to edit. */ get isDataFormatValid() { // If there is no value, it is considered valid. if (this.originalValue === undefined || this.originalValue === null) { return true; } // Simple fields cannot handle complex data like objects and arrays. if (this.isSimple && (dataType_1.DataType.isObject(this.originalValue) || dataType_1.DataType.isArray(this.originalValue))) { return false; } return true; } get isSimple() { // Normal fields are not complex. return true; } get isValid() { // Is valid if there are no results in any zone. // When the validation has not been triggered it is also valid. return !this.validation || !this.validation.hasAnyResults(null); } get key() { return this.config.key; } /** * Certain cases require the field to be locked while updating to prevent * bad data mixing. This allows for manually locking the fields. */ lock() { this.isLocked = true; } /** * Mark that the field has lost focus for a zone. * * This is used for UI to determine when to display validation * messsages for a better UX when they have not interacted with * the field. */ lostFocus(zoneKey = validation_1.DEFAULT_ZONE_KEY) { this.zones = this.zones ?? {}; this.zones[zoneKey] = this.zones[zoneKey] ?? { key: zoneKey, }; this.zones[zoneKey].hasLostFocus = true; } /** * Signal for the editor to re-render. */ render() { document.dispatchEvent(new CustomEvent(events_1.EVENT_RENDER)); } get rules() { if (this._rules) { return this._rules; } // Each field has separate validation rule definitions. this._rules = new validationRules_1.Rules(this.types.rules); let ruleConfigs = this.config?.validation || []; if (dataType_1.DataType.isArray(ruleConfigs)) { // Validation is an array when it is all one zone. ruleConfigs = ruleConfigs; for (const ruleConfig of ruleConfigs) { this.rules.addRuleFromConfig(ruleConfig); } } else if (dataType_1.DataType.isObject(ruleConfigs)) { // Complex fields define rules into separate zones. ruleConfigs = ruleConfigs; for (const zoneKey of Object.keys(ruleConfigs)) { for (const ruleConfig of ruleConfigs[zoneKey]) { this.rules.addRuleFromConfig(ruleConfig, zoneKey); } } } else if (ruleConfigs) { console.error('Validation rules in an invalid format.', 'Expecting array or Record<zoneKey, array>.', ruleConfigs); } return this._rules; } /** * Template for determining how to render the field. * * The default field template has several levels of templates * to make it easier for individual fields to override parts of * template without needing to replicate a lot of internal template * features. * * Base field template structure: * * ``` * template * └── templateWrapper * └── templateStructure * ├── templateHeaderStructure * │ ├── templateHeader * │ └── templateLabel * ├── templateInputStructure * │ └── templateInput * └── templateFooterStructure * └── templateFooter * ``` * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ template(editor, data) { // Update the original every time the template is used. this.updateOriginal(editor, data); this.ensureValidation(editor); return this.templateWrapper(editor, data); } /** * Template for showing the invalid data format messaging. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateDataFormatInvalid( // eslint-disable-next-line @typescript-eslint/no-unused-vars editor, // eslint-disable-next-line @typescript-eslint/no-unused-vars data) { return (0, template_1.templateInfo)((0, lit_html_1.html) `The value for this field is not in the expected format and cannot be edited in the editor interface.`); } /** * Template for rendering the errors. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. * @param zoneKey Zone to provide the error messages for. */ templateErrors(editor, data, zoneKey) { if (this.isValid) { return (0, lit_html_1.html) ``; } const results = this.validation?.getResults(zoneKey) || []; if (!results.length) { return (0, lit_html_1.html) ``; } return (0, lit_html_1.html) `<div class="selective__field__errors"> ${(0, repeat_js_1.repeat)(results, result => result.uuid, result => (0, lit_html_1.html) ` <div class="selective__field__error selective__field__error--level__${result.level}" data-error-level="${result.level}" > ${result.message} </div> `)} </div>`; } /** * Template for rendering the field footer. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars templateFooter(editor, data) { return (0, lit_html_1.html) ``; } /** * Template for rendering the field footer structure. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars templateFooterStructure(editor, data) { return (0, lit_html_1.html) `<div class="selective__field__footer"> ${this.templateFooter(editor, data)} </div>`; } /** * Template for rendering the field header. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars templateHeader(editor, data) { return (0, lit_html_1.html) ``; } /** * Template for rendering the field header structure. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars templateHeaderStructure(editor, data) { return (0, lit_html_1.html) `<div class="selective__field__header"> ${this.templateHeader(editor, data)} ${this.templateLabel(editor, data)} </div>`; } /** * Template for rendering the field help. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. * @param zoneKey Zone to provide the error messages for. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars templateHelp(editor, data, zoneKey) { let helpMessage = this.config.help; if (!helpMessage) { return (0, lit_html_1.html) ``; } // Allow for help messages to be broken up into zones. if (zoneKey && dataType_1.DataType.isObject(helpMessage)) { helpMessage = helpMessage; helpMessage = helpMessage[zoneKey]; } return (0, lit_html_1.html) `<div class="selective__field__help">${helpMessage}</div>`; } /** * Template for rendering the icon for deep linking. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateIconDeepLink( // eslint-disable-next-line @typescript-eslint/no-unused-vars editor, // eslint-disable-next-line @typescript-eslint/no-unused-vars data) { return (0, lit_html_1.html) ``; } /** * Template for rendering the icon for validation. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateIconValidation( // eslint-disable-next-line @typescript-eslint/no-unused-vars editor, // eslint-disable-next-line @typescript-eslint/no-unused-vars data) { if (this.isValid) { return (0, lit_html_1.html) ``; } return (0, lit_html_1.html) `<span class="selective__field__invalid"> <i class="material-icons">error</i> </span>`; } /** * Template for rendering the field input. * * The help text is part of the input template so complex inputs can * use zones for the help text. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateInput(editor, data) { return (0, lit_html_1.html) `${this.templateHelp(editor, data)} <div class="selective__field__input">Input not defined.</div>`; } /** * Template for rendering the field input structure. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateInputStructure(editor, data) { const parts = []; if (!this.isDataFormatValid) { parts.push(this.templateDataFormatInvalid(editor, data)); } else { parts.push(this.templateInput(editor, data)); } return (0, lit_html_1.html) `<div class="selective__field__input__structure">${parts}</div>`; } /** * Template for rendering the field label. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateLabel(editor, data) { if (!this.config.label) { return (0, lit_html_1.html) ``; } if (!this.config.label) { this.config.label = (0, autoFields_1.guessLabel)(this.config.key); } let requiredMark = (0, lit_html_1.html) ``; if (this.validation?.isRequired()) { requiredMark = (0, lit_html_1.html) `<span class="selective__field__label__required" >*</span >`; } return (0, lit_html_1.html) `<div class=${(0, class_map_js_1.classMap)(this.classesForLabel())}> ${this.templateIconDeepLink(editor, data)} ${this.templateIconValidation(editor, data)} <label for=${this.uid}>${this.config.label}${requiredMark}</label> </div>`; } /** * Template for rendering the field structure. * * Used for controlling the order that parts of the field are rendered. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateStructure(editor, data) { return (0, lit_html_1.html) `${this.templateHeaderStructure(editor, data)} ${this.templateInputStructure(editor, data)} ${this.templateFooterStructure(editor, data)}`; } /** * Template for rendering the field wrapper. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ templateWrapper(editor, data) { return (0, lit_html_1.html) `<div class=${(0, class_map_js_1.classMap)(this.classesForField())} data-field-type=${this.fieldType} data-field-full-key=${this.fullKey} > ${this.templateStructure(editor, data)} </div>`; } /** * Certain cases require the field to be locked while updating to prevent bad * data mixing. This allows for manually unlocking the fields. */ unlock() { this.isLocked = false; } /** * Use the data passed to render to update the original value. * Also update the clean value when applicable. * * @param editor Selective editor used to render the template. * @param data Data provided to render the template. */ updateOriginal(editor, data) { // Clears the validation each time the original value is updated. // Needs to happen every time, even for locked fields. this.validation = undefined; // Manual locking prevents the original value overwriting the value // in special cases when it should not. if (this.isLocked) { return; } // Where there is no key, just assume that the current value is undefined. // if (!this.key) { // this.currentValue = undefined; // return; // } let newValue = data.get(this.key); const isClean = this.isClean; // Cleaning up the origina value. newValue = this.cleanOriginalValue(newValue); this.originalValue = newValue; // Only if the field is clean, update the value. if (isClean) { // Clean the value again to cleanup references like arrays. this.currentValue = this.cleanOriginalValue(newValue); if (this.currentValue === undefined) { this.currentValue = this.config.default; } } if (isClean !== this.isClean) { // Clean state has changed. Re-render. this.render(); } } get value() { return this.currentValue; } } exports.Field = Field; //# sourceMappingURL=field.js.map