@blinkk/selective-edit
Version:
Selective structured text editor.
566 lines • 21 kB
JavaScript
"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