@blinkk/selective-edit
Version:
Selective structured text editor.
654 lines • 24.5 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.ListFieldItem = exports.ListField = void 0;
const deepObject_1 = require("../../utility/deepObject");
const field_1 = require("../field");
const sortable_1 = require("../../mixins/sortable");
const lit_html_1 = require("lit-html");
const actions_1 = require("../../utility/actions");
const mixins_1 = require("../../mixins");
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 repeat_js_1 = require("lit-html/directives/repeat.js");
const json_stable_stringify_1 = __importDefault(require("json-stable-stringify"));
class ListField extends (0, sortable_1.SortableMixin)(field_1.Field) {
constructor(types, config, globalConfig, fieldType = 'list') {
super(types, config, globalConfig, fieldType);
this.config = config;
this.items = null;
this.usingAutoFields = false;
this.ListItemCls = ListFieldItem;
this.sortableUi.listeners.add('sort', this.handleSort.bind(this));
}
get allowAdd() {
// Check if validation rules allow for adding more items.
const value = this.value;
for (const rule of this.rules.getRulesForZone()) {
if (!rule.allowAdd(value)) {
return false;
}
}
return true;
}
get allowRemove() {
// Check if validation rules allow for removing items.
const value = this.value;
for (const rule of this.rules.getRulesForZone()) {
if (!rule.allowRemove(value)) {
return false;
}
}
return true;
}
/**
* Does the list allow for showing simple fields?
*/
get allowSimple() {
return !this.config.isComplex;
}
createFields(fieldConfigs) {
return new this.types.globals.FieldsCls(this.types, {
// Field configs should not be 'shared'. Duplicate the field configs
// before creating the fields.
fields: (0, lodash_clonedeep_1.default)(fieldConfigs),
isGuessed: this.usingAutoFields,
parentKey: this.fullKey,
previewField: this.config.previewField,
previewFields: this.config.previewFields,
previewType: this.config.previewType,
}, this.globalConfig);
}
ensureItems(editor) {
// Cannot initialize items without valid data format.
if (!this.isDataFormatValid) {
return [];
}
if (this.items === null) {
this.items = [];
let fieldConfigs = this.config.fields || [];
// Add list items for each of the values in the list already.
for (const value of this.originalValue || []) {
// If no field configs, auto guess based on first row with a value.
if (fieldConfigs.length === 0) {
this.usingAutoFields = true;
// Auto-guess fields based on the first item in the list.
const autoFields = new this.types.globals.AutoFieldsCls({});
fieldConfigs = autoFields.guessFields(value);
// Store the the auto-guessed configs for new list items.
this.config.fields = fieldConfigs;
}
const fields = this.createFields(fieldConfigs);
// When an item is not expanded it does not get the value
// updated correctly so we need to manually call the data update.
fields.updateOriginal(editor, value);
for (const field of fields.fields) {
field.updateOriginal(editor, (0, deepObject_1.autoDeepObject)(value || fields.guessDefaultValue()));
}
this.items.push(new this.ListItemCls(this, fields));
}
}
return this.items;
}
handleAddItem(evt, editor, data) {
const items = this.ensureItems(editor);
const fieldConfigs = this.config.fields || [];
const fields = this.createFields(fieldConfigs);
// When an item is not expanded it does not get the value
// updated correctly so we need to manually call the data update.
fields.updateOriginal(editor, data);
for (const field of fields.fields) {
field.updateOriginal(editor, (0, deepObject_1.autoDeepObject)(fields.guessDefaultValue()));
}
const newItem = new this.ListItemCls(this, fields);
newItem.isExpanded = true;
items.push(newItem);
this.render();
}
handleDeleteItem(evt, index) {
const items = this.items || [];
// Prevent the delete from bubbling.
evt.stopPropagation();
// Remove the value at the index.
items.splice(index, 1);
// Lock the fields to prevent the values from being updated at the same
// time as the original value.
const downstreamItems = items.slice(index);
for (const item of downstreamItems) {
item.fields.lock();
}
this.lock();
// Unlock fields after saving is complete to let the values be updated
// when clean.
// TODO: Automate this unlock without having to be done manually.
document.addEventListener(events_1.EVENT_UNLOCK, () => {
for (const item of downstreamItems) {
item.fields.unlock();
}
this.unlock();
this.render();
}, { once: true });
this.render();
}
handleSort(startIndex, endIndex) {
// Rework the arrays to have the items in the correct position.
const newListItems = [];
const oldListItems = this.items || [];
const maxIndex = Math.max(endIndex, startIndex);
const minIndex = Math.min(endIndex, startIndex);
// Did not move, don't need to sort.
if (startIndex === endIndex) {
return;
}
// Determine which direction to shift misplaced items.
let modifier = 1;
if (startIndex > endIndex) {
modifier = -1;
}
for (let i = 0; i < oldListItems.length; i++) {
if (i < minIndex || i > maxIndex) {
// Leave in the same order.
newListItems[i] = oldListItems[i];
// Lock the fields to prevent the values from being updated at the same
// time as the original value.
newListItems[i].fields.lock();
}
else if (i === endIndex) {
// This element is being moved to, place the moved value here.
newListItems[i] = oldListItems[startIndex];
// Lock the fields to prevent the values from being updated at the same
// time as the original value.
newListItems[i].fields.lock();
}
else {
// Shift the old index using the modifier to determine direction.
newListItems[i] = oldListItems[i + modifier];
// Lock the fields to prevent the values from being updated at the same
// time as the original value.
newListItems[i].fields.lock();
}
}
this.items = newListItems;
this.lock();
// Unlock fields after saving is complete to let the values be updated when clean.
document.addEventListener(events_1.EVENT_UNLOCK, () => {
for (const item of newListItems) {
item.fields.unlock();
}
this.unlock();
this.render();
}, { once: true });
// Check if sorted back to original value.
if (this.items.length === this.originalValue.length) {
let isSame = true;
for (let i = 0; i < this.items.length; i++) {
if ((0, json_stable_stringify_1.default)(this.items[i].fields.value) !==
(0, json_stable_stringify_1.default)(this.originalValue[i])) {
isSame = false;
break;
}
}
// If the list items are the same across new values and original then it
// has been sorted back to original and needs to be unlocked.
if (isSame) {
this.unlock();
for (const item of newListItems) {
item.fields.unlock();
}
}
}
this.render();
}
get isClean() {
// If there are no items, nothing has changed.
if (this.items === null) {
return true;
}
// When locked, the field is automatically considered dirty.
if (this.isLocked) {
return false;
}
// Check for a change in length.
if (this.items !== null &&
this.originalValue &&
this.originalValue.length !== this.items.length) {
return false;
}
// Check if all of the items are clean.
for (const item of this.items || []) {
if (!item.fields.isClean) {
return false;
}
}
return true;
}
/**
* Check if the data format is invalid for what the field expects to edit.
*/
get isDataFormatValid() {
if (this.originalValue === undefined || this.originalValue === null) {
return true;
}
return dataType_1.DataType.isArray(this.originalValue);
}
get isValid() {
// If there are no items, nothing has changed.
if (this.items === null) {
return true;
}
for (const item of this.items) {
if (!item.fields.isValid) {
return false;
}
}
return true;
}
/**
* Length of the list.
*/
get length() {
return this.items?.length || 0;
}
templateEmpty(editor, data, index) {
return (0, lit_html_1.html) ` <div
class="selective__list__item selective__list__item--empty"
data-index=${index}
>
${this.config.emptyLabel || 'No items in list'}
</div>`;
}
templateFooter(editor, data) {
if (!this.allowAdd) {
return (0, lit_html_1.html) ``;
}
return (0, lit_html_1.html) `<div class="selective__field__actions">
<button
class="selective__action selective__action--add"
=${(evt) => {
this.handleAddItem(evt, editor, data);
}}
>
<span>${this.config.addLabel || 'Add'}</span>
</button>
</div>`;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
templateHeader(editor, data) {
const items = this.ensureItems(editor);
if (!items.length) {
return (0, lit_html_1.html) ``;
}
const actions = [];
// Determine the ability to show simple fields.
let areSimpleFields = this.allowSimple;
// Determine the expanded/collapsed state for the item.
let areAllExpanded = true;
let areAllCollapsed = true;
for (const item of items) {
if (!item.fields.isSimple || !item.fields.allowSimple) {
areSimpleFields = false;
}
if (!item.isExpanded) {
areAllExpanded = false;
}
if (item.isExpanded) {
areAllCollapsed = false;
}
}
// Do not show the expand/collapse for simplet fields.
if (areSimpleFields) {
return (0, lit_html_1.html) ``;
}
const handleExpandAll = () => {
for (const item of items) {
item.isExpanded = true;
}
this.render();
};
actions.push((0, lit_html_1.html) `<div
?disabled=${areAllExpanded}
class="selective__action selective__action--expand selective__tooltip--bottom-left"
data-tip="Expand all"
=${handleExpandAll}
>
<i class="material-icons">unfold_more</i>
</div>`);
const handleCollapseAll = () => {
for (const item of items) {
item.isExpanded = false;
}
this.render();
};
actions.push((0, lit_html_1.html) `<div
?disabled=${areAllCollapsed}
class="selective__action selective__action--collapse selective__tooltip--bottom-left"
data-tip="Collapse all"
=${handleCollapseAll}
>
<i class="material-icons">unfold_less</i>
</div>`);
return (0, lit_html_1.html) `<div class="selective__field__actions">${actions}</div>`;
}
templateInput(editor, data) {
return (0, lit_html_1.html) `${this.templateHelp(editor, data)}
<div class="selective__list">
${(0, repeat_js_1.repeat)(this.items || [], item => item.uid, (item, index) => {
const itemValue = new deepObject_1.DeepObject(index < this.originalValue?.length || 0
? this.originalValue[index]
: item.fields.guessDefaultValue());
return item.template(editor, itemValue, index);
})}
${this.items?.length ? '' : this.templateEmpty(editor, data, 0)}
</div>
${this.templateErrors(editor, data)}`;
}
get value() {
// Return the original value if the items have never been initialized.
if (this.items === null) {
return this.originalValue || [];
}
const value = [];
for (const item of this.items) {
value.push(item.fields.value);
}
return value;
}
}
exports.ListField = ListField;
class ListFieldItem extends (0, uuid_1.UuidMixin)(mixins_1.Base) {
constructor(listField, fields) {
super();
this.listField = listField;
this.fields = fields;
this.isExpanded = false;
}
actionsCollapsedPre(editor, data, index) {
const canDrag = this.listField.length > 1;
const actions = new actions_1.Actions({
modifier: 'pre',
});
if (canDrag) {
actions.add((0, lit_html_1.html) `<div class="selective__list__item__drag">
<i class="material-icons">drag_indicator</i>
</div>`);
}
if (index !== undefined) {
actions.add((0, lit_html_1.html) `<div class="selective__list__item__index">
${index + 1}
</div>`);
}
return actions;
}
actionsCollapsedPost(editor, data, index) {
const actions = new actions_1.Actions({
modifier: 'post',
});
actions.add(this.templateRemove(editor, data, index));
return actions;
}
actionsExpandedPre(editor, data, index) {
const actions = new actions_1.Actions({
modifier: 'pre',
});
if (index !== undefined) {
actions.add((0, lit_html_1.html) `<div class="selective__list__item__index">
${index + 1}
</div>`);
}
return actions;
}
actionsExpandedPost(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editor,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
index) {
const actions = new actions_1.Actions({
modifier: 'post',
});
actions.add((0, lit_html_1.html) `<span class="material-icons">keyboard_arrow_down</span>`);
return actions;
}
actionsSimplePre(editor, data, index) {
const canDrag = this.listField.length > 1;
const actions = new actions_1.Actions({
modifier: 'pre',
});
if (canDrag) {
actions.add((0, lit_html_1.html) `<div class="selective__list__item__drag">
<i class="material-icons">drag_indicator</i>
</div>`);
}
if (index !== undefined) {
actions.add((0, lit_html_1.html) `<div class="selective__list__item__index">
${index + 1}
</div>`);
}
return actions;
}
actionsSimplePost(editor, data, index) {
const actions = new actions_1.Actions({
modifier: 'post',
});
actions.add(this.templateRemove(editor, data, index));
return actions;
}
classesCollpased(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editor,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
index) {
return {
selective__list__item: true,
'selective__list__item--collapsed': true,
'selective__list__item--no-drag': this.listField.length <= 1,
selective__sortable: true,
};
}
classesExpanded(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editor,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
index) {
return {
selective__list__item: true,
'selective__list__item--expanded': true,
selective__sortable: true,
};
}
classesSimple(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editor,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
data,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
index) {
return {
selective__list__item: true,
'selective__list__item--expanded': true,
selective__sortable: true,
};
}
handleCollapseItem() {
this.isExpanded = false;
this.listField.render();
}
handleExpandItem() {
this.isExpanded = true;
this.listField.render();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleHoverOffItem(evt, index) {
// Do nothing.
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
handleHoverOnItem(evt, index) {
// Do nothing.
}
template(editor, data, index) {
if (this.listField.allowSimple && // The list field allows for simple fields.
this.fields.allowSimple && // The fields allows for simple fields.
this.fields.isSimple // The fields are simple.
) {
return this.templateSimple(editor, data, index);
}
else if (this.isExpanded) {
return this.templateExpanded(editor, data, index);
}
return this.templateCollapsed(editor, data, index);
}
templateCollapsed(editor, data, index) {
// Need to update the original value on the collapsed items.
this.fields.updateOriginal(editor, data, true);
const canDrag = this.listField.length > 1;
const sortable = this.listField.sortableUi;
return (0, lit_html_1.html) ` <div
class=${(0, class_map_js_1.classMap)(this.classesCollpased(editor, data, index))}
draggable=${canDrag && sortable.canDrag ? 'true' : 'false'}
data-index=${index}
data-item-uid=${this.uid}
=${sortable.handleDragEnter.bind(sortable)}
=${sortable.handleDragLeave.bind(sortable)}
=${sortable.handleDragOver.bind(sortable)}
=${sortable.handleDragStart.bind(sortable)}
=${sortable.handleDrop.bind(sortable)}
=${(evt) => {
sortable.handleFocusIn(evt);
this.listField.render();
}}
=${(evt) => {
sortable.handleFocusOut(evt);
this.listField.render();
}}
=${(evt) => {
this.handleHoverOnItem(evt, index);
}}
=${(evt) => {
this.handleHoverOffItem(evt, index);
}}
>
${this.actionsCollapsedPre(editor, data, index).template()}
<div
class="selective__list__item__preview"
data-item-uid=${this.uid}
=${this.handleExpandItem.bind(this)}
>
${this.templatePreviewValue(editor, data, index)}
</div>
${this.actionsCollapsedPost(editor, data, index).template()}
</div>`;
}
templateExpanded(editor, data, index) {
const canDrag = this.listField.length > 1;
const sortable = this.listField.sortableUi;
return (0, lit_html_1.html) ` <div
class=${(0, class_map_js_1.classMap)(this.classesExpanded(editor, data, index))}
draggable=${canDrag && sortable.canDrag ? 'true' : 'false'}
data-index=${index}
data-item-uid=${this.uid}
=${sortable.handleDragEnter.bind(sortable)}
=${sortable.handleDragLeave.bind(sortable)}
=${sortable.handleDragOver.bind(sortable)}
=${sortable.handleDragStart.bind(sortable)}
=${sortable.handleDrop.bind(sortable)}
=${(evt) => {
sortable.handleFocusIn(evt);
this.listField.render();
}}
=${(evt) => {
sortable.handleFocusOut(evt);
this.listField.render();
}}
=${(evt) => {
this.handleHoverOnItem(evt, index);
}}
=${(evt) => {
this.handleHoverOffItem(evt, index);
}}
>
<div
class="selective__list__fields__header"
=${this.handleCollapseItem.bind(this)}
>
${this.actionsExpandedPre(editor, data, index).template()}
<div class="selective__list__item__preview">
${this.templatePreviewValue(editor, data, index)}
</div>
${this.actionsExpandedPost(editor, data, index).template()}
</div>
<div class="selective__list__fields">
${this.fields.template(editor, data)}
</div>
</div>`;
}
/**
* Template for how to render a preview.
*
* @param editor Selective editor used to render the template.
* @param data Data provided to render the template.
*/
templatePreviewValue(editor, data, index) {
return this.fields.templatePreviewValue(editor, data, index);
}
templateRemove(editor, data, index) {
if (!this.listField.allowRemove) {
return (0, lit_html_1.html) ``;
}
return (0, lit_html_1.html) `<div
class="selective__action selective__action--delete selective__tooltip--left"
data-item-uid=${this.uid}
=${(evt) => {
this.listField.handleDeleteItem(evt, index);
}}
aria-label="Delete item"
data-tip="Delete item"
>
<i class="material-icons icon icon--delete">remove_circle</i>
</div>`;
}
templateSimple(editor, data, index) {
const canDrag = this.listField.length > 1;
const sortable = this.listField.sortableUi;
return (0, lit_html_1.html) ` <div
class=${(0, class_map_js_1.classMap)(this.classesSimple(editor, data, index))}
draggable=${canDrag && sortable.canDrag ? 'true' : 'false'}
data-index=${index}
data-item-uid=${this.uid}
=${sortable.handleDragEnter.bind(sortable)}
=${sortable.handleDragLeave.bind(sortable)}
=${sortable.handleDragOver.bind(sortable)}
=${sortable.handleDragStart.bind(sortable)}
=${sortable.handleDrop.bind(sortable)}
=${(evt) => {
sortable.handleFocusIn(evt);
this.listField.render();
}}
=${(evt) => {
sortable.handleFocusOut(evt);
this.listField.render();
}}
=${(evt) => {
this.handleHoverOnItem(evt, index);
}}
=${(evt) => {
this.handleHoverOffItem(evt, index);
}}
>
${this.actionsSimplePre(editor, data, index).template()}
${this.fields.template(editor, data)}
${this.actionsSimplePost(editor, data, index).template()}
</div>`;
}
}
exports.ListFieldItem = ListFieldItem;
//# sourceMappingURL=list.js.map