import { observable, observableArray, computed, unwrap } from 'knockout';
import { createViewModel } from 'scalejs.metadataFactory';
import { evaluate } from 'scalejs.expression-jsep';
import noticeboard from 'scalejs.noticeboard';
import { merge, has, is } from 'scalejs';
import _ from 'lodash';
// todo: revisit comments below
// listViewModel is a component which manages a simple list
// - items - items are what are used to make up the rows in the list
// - options
// -- addRows - if false add button does not appear
// -- deleteRows - if false delete button does not appear
// -- minRequiredRows - initializes list with # of rows and wont let user delete
// TODO: Refactor Session
// - implement "parent passes to children" pattern for labels
// - brainstorm cleaner "itemViewModel" imp.
// - general clean up/renaming/documenting session
// ...add more refactor session goals here!
/**
* list is the component to use when wanting to group items into enumerable lists.
* There are two types of lists:
* responsive form lists (default) and table lists (+listAdvanced wrapper)
* The underlying data model for a list is an array of objects.
*
* @module list
*
* @param {object} node
* The configuration specs for the component.
* @param {string} [node.id]
* The id of the list becomes the key in the data for all the children of the list.
*
*/
const listItems = {
DELETE: del,
DELETE_FLAG: deleteFlag
};
function del(itemDef) {
const context = this,
clonedItem = _.cloneDeep(itemDef);
delete clonedItem.template; // prevent scalejs merge issue
return merge(clonedItem, {
id: undefined,
template: {
name: itemDef.template || 'list_del_template',
data: context
}
});
}
function deleteFlag(itemDef) {
const context = this;
// the id will be the propertu
// getValue - return if it was deleted or not
return context.isNew ? del.call(context, itemDef) : merge(context, {
template: 'list_del_flag_template',
getValue: function () {
return context.deleteFlag() ? 'T' : 'F';
},
deleteRow: function () {
context.deleteFlag(true);
if (itemDef.options && itemDef.options.clearOnDelete) {
const item = context.itemDictionary()[itemDef.options.clearOnDelete];
if (item && item.setValue) {
item.setValue(0);
}
}
}
}, itemDef);
}
export default function listViewModel(node) {
const rows = observableArray(),
options = node.options || {},
isShown = observable(true),
context = this || {},
// initialize to the context's state as determined by the form generally
readonly = observable((context.readonly && context.readonly()) || false),
deleteRows = observable(options.deleteRows !== false),
// addButtonContext = node.addButtonContext,
mappedChildNodes = observableArray(),
data = observable(node.data),
unique = {},
visibleRows = observableArray(),
initialData = _.cloneDeep(node.data) || [],
addButtonRendered = is(node.addButtonRendered, 'string') ?
computed(evaluate.bind(null, node.addButtonRendered, context.getValue))
: observable(node.addButtonRendered !== false);
let initial = node.nodeDataAsInitial !== false,
minRequiredRows = 0,
showRemoveButton = null,
sub = null,
scrolled,
onlyIf;
function setReadonly(bool) {
readonly(bool); // sets readonly state of the list
rows().forEach((row) => { // sets readonly state of each row
row.readonly(bool);
});
}
// rowViewModel
// called on each add
// or when data is set with initial values
function rowViewModel(initialValues, isNew, initialOverride) {
const items = observableArray(), // observable array to hold the items in the row
// observable dictionary to hold the items and other properties
itemDictionary = observable({}),
rowContext = {
metadata: context.metadata, // reference to the parent metadata
rows: rows,
unique: unique,
isNew: isNew,
itemDictionary: itemDictionary,
editMode: observable(false), // for styling - maybe better if called isActiveRow
deleteFlag: observable(false),
data: computed(() => {
const dict = itemDictionary();
return merge(initialValues || {}, Object.keys(dict).reduce((d, id) => {
const item = dict[id];
if (item && item.getValue) {
d[id] = item.getValue();
} else {
d[id] = item;
}
return d;
}, {}));
})
},
row = {}; // the row itself
let prop,
itemViewModels = null,
rowReadonly;
// initialize row readonly as the list's state
rowContext.readonly = observable(readonly());
// rowReadonly - string to run thrown expression parser to show/hide rows
if (is(options.rowReadonly, 'string')) {
rowReadonly = computed(() => {
if (rowContext.readonly && rowContext.readonly()) {
return true; // if readonly is true on context, then row is readonly
}
// else, eval the expression to determine if the row is readonly
return evaluate(options.rowReadonly, (id) => {
const item = itemDictionary()[id];
if (item && item.getValue) {
return item.getValue();
}
});
});
}
// can be utilized by expression parser to get error for an id
function error(id) {
const item = itemDictionary()[id];
if (item && item.inputValue && item.inputValue.error) {
return item.inputValue.error();
}
}
// accurately calculates the index of the row in the list
rowContext.index = computed(() => rows().indexOf(row));
// getValueById function for expression parsing
// todo. refactor this
rowContext.getValue = function (id) {
if (id === 'index') {
return rowContext.index();
}
if (id === 'list') {
return rows();
}
if (id === 'row') {
return rows()[rowContext.index()];
}
if (id === 'error') {
return error;
}
// check the item dictionary
const item = itemDictionary()[id];
if (item && item.getValue) {
return item.getValue();
}
// if the item doesnt have getValue, return itself
if (has(item)) {
return unwrap(item);
}
prop = rowContext[id];
if (has(prop)) {
return unwrap(prop);
}
return context.getValue(id);
};
itemViewModels = node.items.map((_item) => {
// deep clone the item as we might mutate it before passing to createViewModels
const item = _.cloneDeep(_item);
// add readonly computed to the item before passing it to input
// input will use the already defined observable if it exists
// but, if the input already has readonly set on it, dont get readonly from row..
if (rowReadonly && item.input && !has(item.input.readonly)) {
item.input.readonly = rowReadonly;
}
if (item.options && item.options.unique) {
if (!item.id) {
console.error('Cannot set unique on item without id');
} else if (!unique[item.id]) { // only create once
unique[item.id] = observableArray();
}
}
// todo - clean this up?
if (listItems[item.type]) {
const ret = listItems[item.type].call(rowContext, item);
if (item.visible) {
ret.visible = computed(() => evaluate(item.visible, rowContext.getValue));
}
return ret;
}
return createViewModel.call(rowContext, item);
});
// if there are initial values, update the children
if (initialValues) {
itemViewModels.forEach((item) => {
// allow for JSON default values don't get overwritten
// by server data that doesn't contain data
if (initialValues[item.id]) {
item.setValue && item.setValue(initialValues[item.id], { initial: initialOverride !== false });
}
});
}
// update items obsArr
items(itemViewModels);
// generate itemDictionary from the itemViewModels
// also add each item's inputValue directly on the row
// this is for MemberExpressions to work properly (list[0].Status)
itemDictionary(itemViewModels.reduce((dict, item) => {
if (has(item.id)) {
dict[item.id] = item;
row[item.id] = item.inputValue;
}
return dict;
}, merge(initialValues || {})));
// just in case some data doesnt have a column, keep it in the item dict
// TODO: ItemDict or Row? which one is better?
// rowVM
row.items = items;
row.itemDictionary = itemDictionary;
row.mappedChildNodes = items;
row.editMode = rowContext.editMode;
row.deleteFlag = rowContext.deleteFlag;
row.readonly = function (bool) {
items().forEach((item) => {
if (item.setReadonly) {
item.setReadonly(bool);
} else if (item.readonly) {
item.readonly(bool);
}
});
};
return row;
}
// generates a new row and add to list
function add(row, isNew, initialOverride) {
const rowVm = rowViewModel(row, isNew, initialOverride);
// add remove function to rowVM
rowVm.remove = function () {
rowVm.items().forEach((item) => {
if (item.dispose) {
item.dispose();
}
});
rows.remove(rowVm);
};
if (options.push) {
rows.push(rowVm);
} else {
rows.unshift(rowVm);
}
if (isNew === true && options.focusNew !== false) {
// auto-focus on the newly added row
setTimeout(() => {
// need to wait for clickOff events to stop firing.
rowVm.editMode(true);
(rowVm.items() || []).some((item) => {
if (item.rendered() && item.hasFocus) {
item.hasFocus(true);
return true;
}
return false;
});
});
}
}
// returns the values of the list
// e.g. [{item1:'Value1',item2:'Value2'}]
// dontSendIfEmpty - this prevents items from getting
// sent in the data if that property is empty
// if array is empty send null
function getValue() {
let listData = _.cloneDeep(rows().map((row) => {
const originalRowItems = row.itemDictionary.peek();
return Object.keys(originalRowItems).reduce((dataObj, itemKey) => {
const item = row.itemDictionary.peek()[itemKey];
if (item && item.getValue) {
dataObj[item.id] = item.getValue();
} else if (has(item) && item.type !== 'DELETE') {
dataObj[itemKey] = item;
}
return dataObj;
}, {});
}).filter(obj => !(options.dontSendIfEmpty &&
(!obj[options.dontSendIfEmpty] && obj[options.dontSendIfEmpty] !== 0))));
if (options.sendNullIfEmpty && listData.length === 0) {
listData = null;
}
return listData;
}
// on initialization if the node already has data defined, add rows
// else generate the minReqiredRows
function initialize() {
// console.time('List init');
rows().forEach((row) => {
row.items().forEach((item) => {
item.dispose && item.dispose();
});
});
rows.removeAll();
if (data() && Array.isArray(data()) && data().length > 0) {
data().forEach((item) => {
add(item, false, initial);
});
// if trackDiffChanges set to true store the original data to noticeboard
if (node.trackDiffChanges) {
noticeboard.set(node.id, data());
}
} else {
for (let i = rows().length; i < minRequiredRows; i++) {
add(null, true, initial);
}
}
initial = undefined;
// console.timeEnd('List init');
}
// sets value in list
// or re-inits if data is empty or invalid
function setValue(newData) {
if ((newData === null ||
(Array.isArray(newData) && newData.length === 0)) && getValue() === null) {
return; // new data is same as current one (empty array)
}
// reverse the data because adding now unshifts the rows.
if (Array.isArray(newData) && !options.push) {
newData.reverse();
}
data(newData || (initialData || []));
initialize();
}
function update(value) {
console.info('List only supports udate for value');
setValue(value);
}
// returns last row
function lastRow() {
return rows()[rows().length - 1];
}
// sets minrequired rows
if (node.validations && node.validations.required) {
const minRows = node.validations.required.params || node.validations.required;
minRequiredRows = minRows === true ? 1 : minRows;
if (node.validations.required.onlyIf) {
onlyIf = node.validations.required.onlyIf;
}
} else if (node.data) {
minRequiredRows = node.data.length;
}
// only show remove button if rows is greater than min req rows
showRemoveButton = computed(() => {
let isRequired = true;
if (onlyIf) {
isRequired = evaluate(onlyIf, context.getValue);
}
return !isRequired ||
rows().filter(r => !r.deleteFlag || !r.deleteFlag()).length > minRequiredRows;
});
// get data from data parent if exists
if (context.data && !options.subscribeToData) {
console.warn('Please make sure you get the Data from setValue or set node.subscribeToData to true! Removing data-subscribe as a default', node);
}
if (options.subscribeToData && context.data) {
if (context.data()[node.id]) {
data(context.data()[node.id]);
} else {
context.data.subscribe((newData) => {
if (newData[node.id]) {
data(newData[node.id]);
initialize();
}
});
}
}
initialize();
// will "remove" mapped child nodes if the list is hidden
// this is required for validations to work properly
// todo: remove this workaround and implement validation on list itself
sub = computed(() => {
let rendered = true;
if (node.rendered) {
rendered = evaluate(node.rendered, context.getValue);
}
if (isShown() && rendered) {
mappedChildNodes(rows().filter(row => !row.deleteFlag()));
} else {
mappedChildNodes([]);
}
});
if (node.infinite) {
rows.subscribe((newRows) => {
visibleRows((newRows || []).slice(0, 20));
});
scrolled = function (event) {
const elem = event.target,
currentRows = rows();
if (elem.scrollTop > (elem.scrollHeight - elem.offsetHeight - 35)) {
const seed = visibleRows().length;
for (let i = 0; i < 20; i++) {
visibleRows.push(currentRows[seed + i]);
if (!currentRows[seed + i]) {
// no more rows stahp
break;
}
}
}
};
}
return merge(node, {
add: add,
rows: node.infinite ? visibleRows : rows,
options: options,
allRows: rows,
scrolled: scrolled,
mappedChildNodes: mappedChildNodes,
isShown: isShown,
showRemove: showRemoveButton,
getValue: getValue,
setValue: setValue,
readonly: readonly,
deleteRows: deleteRows,
lastRow: lastRow,
setReadonly: setReadonly,
addButtonRendered: addButtonRendered,
update: update,
dispose() {
sub.dispose();
}
});
}