/** * koForm provides a number of styled elements (buttons, checkbox, etc) that are tied to * observables to simplify and standardize the way we construct UI elements (e.g. forms). * * TODO: There is some divergence in class names that we use throughout Grist. For example, * active vs mod-active and disabled vs mod-disabled. We should standardize. */ // Use the browser globals in a way that allows replacing them with mocks in tests. var G = require('./browserGlobals').get('$', 'window', 'document'); const identity = require('lodash/identity'); const defaults = require('lodash/defaults'); const debounce = require('lodash/debounce'); const pick = require('lodash/pick'); var ko = require('knockout'); var Promise = require('bluebird'); var gutil = require('app/common/gutil'); var commands = require('../components/commands'); var dom = require('./dom'); var kd = require('./koDom'); var koArray = require('./koArray'); var modelUtil = require('../models/modelUtil'); var setSaveValue = modelUtil.setSaveValue; /** * Creates a button-looking div inside a buttonGroup; when clicked, clickFunc() will be called. * The button is not clickable if it contains the class 'disabled'. */ exports.button = function(clickFunc, ...moreContentArgs) { return dom('div.kf_button.flexitem', dom.on('click', function() { if (!this.classList.contains('disabled')) { clickFunc(); } }), moreContentArgs ); }; /** * Creates a button with an accented appearance. * The button is not clickable if it contains the class 'disabled'. */ exports.accentButton = function(clickFunc, ...moreContentArgs) { return this.button(clickFunc, {'class': 'kf_button flexitem accent'}, moreContentArgs ); }; /** * Creates a button with a minimal appearance for use in prompts. * The button is not clickable if it contains the class 'disabled'. */ exports.liteButton = function(clickFunc, ...moreContentArgs) { return this.button(clickFunc, {'class': 'kf_button flexitem lite'}, moreContentArgs ); }; /** * Creates a bigger button with a logo, used for "sign in with google/github/etc" buttons. * The button is not clickable if it contains the class 'disabled'. */ exports.logoButton = function(clickFunc, logoUrl, text, ...moreContentArgs) { return this.button(clickFunc, {'class': 'kf_button kf_logo_button flexitem flexhbox'}, dom('div.kf_btn_logo', { style: `background-image: url(${logoUrl})` }), dom('div.kf_btn_text', text), moreContentArgs ); }; /** * Creates a button group. Arguments should be `button` and `checkButton` objects. */ exports.buttonGroup = function(moreButtonArgs) { return dom('div.kf_button_group.kf_elem.flexhbox', dom.fwdArgs(arguments, 0)); }; /** * Creates a button group with an accented appearance. * Arguments should be `button` and `checkButton` objects. */ exports.accentButtonGroup = function(moreButtonArgs) { return this.buttonGroup( [{'class': 'kf_button_group kf_elem flexhbox accent'}].concat(dom.fwdArgs(arguments, 0)) ); }; /** * Creates a button group with a minimal appearance. * Arguments should be `button` and `checkButton` objects. */ exports.liteButtonGroup = function(moreButtonArgs) { return this.buttonGroup( [{'class': 'kf_button_group kf_elem flexhbox lite'}].concat(dom.fwdArgs(arguments, 0)) ); }; /** * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click. */ exports.checkButton = function(valueObservable, moreContentArgs) { return dom('div.kf_button.kf_check_button.flexitem', kd.toggleClass('active', valueObservable), dom.on('click', function() { if (!this.classList.contains('disabled')) { setSaveValue(valueObservable, !valueObservable()); } }), dom.fwdArgs(arguments, 1)); }; /** * Creates a button-looking div that acts as a checkbox, toggling `valueObservable` on click. * Very similar to `checkButton` but looks flat and does not need to be in a group. * * TODO: checkButton and flatCheckButton are identical in function but differ in style and * class name conventions. We should reconcile them. */ exports.flatCheckButton = function(valueObservable, moreContentArgs) { return dom('div.flexnone', kd.toggleClass('mod-active', valueObservable), dom.on('click', function() { if (!this.classList.contains('mod-disabled')) { setSaveValue(valueObservable, !valueObservable()); } }), dom.fwdArgs(arguments, 1)); }; /** * Creates a group of buttons of which only one may be chosen. Arguments should be `optionButton` * objects. The single `valueObservable` reflects the value of the selected `optionButton`. */ exports.buttonSelect = function(valueObservable, moreButtonArgs) { var groupElem = dom('div.kf_button_group.kf_elem.flexhbox', dom.fwdArgs(arguments, 1)); // TODO: Is adding ":not(.disabled)" the best way to avoid execution? G.$(groupElem).on('click', '.kf_button:not(.disabled)', function() { setSaveValue(valueObservable, ko.utils.domData.get(this, 'kfOptionValue')); }); kd.makeBinding(valueObservable, function(groupElem, value) { Array.prototype.forEach.call(groupElem.querySelectorAll('.kf_button'), function(elem, i) { var v = ko.utils.domData.get(elem, 'kfOptionValue'); elem.classList.toggle('active', v === value); }); })(groupElem); return groupElem; }; /** * Creates a button-like div to use inside a `buttonSelect` group. The `value` will become the * value of the `buttonSelect` observable when this button is selected. */ exports.optionButton = function(value, moreContentArgs) { return dom('div.kf_button.flexitem', function(elem) { ko.utils.domData.set(elem, 'kfOptionValue', value); }, dom.fwdArgs(arguments, 1)); }; /** * Creates a speech-bubble-like div intended to give more information and options affecting * its parent when hovered. */ exports.toolTip = function(contentArgs) { return dom('div.kf_tooltip', dom('div.kf_tooltip_pointer'), dom('div.kf_tooltip_content', dom.fwdArgs(arguments, 0)), dom.defer(function(elem) { var elemWidth = elem.getBoundingClientRect().width; var parentRect = elem.parentNode.getBoundingClientRect(); elem.style.left = (-elemWidth/2 + parentRect.width/2) + 'px'; elem.style.top = parentRect.height + 'px'; }) ); }; /** * Creates a prompt to provide feedback or request more information in the sidepane. */ exports.prompt = function(contentArgs) { return dom('div.kf_prompt', dom('div.kf_prompt_pointer'), dom('div.kf_prompt_pointer_overlap'), dom('div.kf_prompt_content', dom.fwdArgs(arguments, 0)) ); }; /** * Checkbox which toggles `valueObservable`. Other arguments become part of the clickable label. */ exports.checkbox = function(valueObservable, moreContentArgs) { return dom('label.kf_checkbox_label.kf_elem', dom('input.kf_checkbox', {type: 'checkbox'}, kd.makeBinding(valueObservable, function(elem, value) { elem.checked = value; }), dom.on('change', function() { setSaveValue(valueObservable, this.checked); }) ), dom.fwdArgs(arguments, 1)); }; /** * Radio button for a particular value of the given observable. It is checked when the observable * matches the value, and selecting it sets the observable to the value. Other arguments become * part of the clickable label. */ exports.radio = function(value, valueObservable, ...domArgs) { return dom('label.kf_radio_label', dom('input.kf_radio', {type: 'radio'}, kd.makeBinding(valueObservable, (elem, val) => { elem.checked = (val === value); }), dom.on('change', function() { if (this.checked) { setSaveValue(valueObservable, value); } }) ), ...domArgs ); }; /** * Create and return DOM for a spinner widget. * valueObservable: observable for the value, may have save interface. * This value is not displayed by the created widget. * getNewValue(value, dir): called with the current value and 1 or -1 direction, * should return the new value for valueObservable. * shouldDisable(value, dir): called with current value and 1 or -1 direction, * should return whether the button in that direction should be enabled. */ function genSpinner(valueObservable, getNewValue, shouldDisable) { let timeout = null; let origValue = null; function startChange(elem, direction) { stopAutoRepeat(); G.$(G.window).on('mouseup', onMouseUp); origValue = valueObservable.peek(); doChange(direction, true); } function onMouseUp() { G.$(G.window).off('mouseup', onMouseUp); stopAutoRepeat(); setSaveValue(valueObservable, valueObservable.peek(), origValue); } function doChange(direction, isFirst) { const newValue = getNewValue(valueObservable.peek(), direction); if (newValue !== valueObservable.peek()) { valueObservable(newValue); timeout = setTimeout(doChange, isFirst ? 600 : 100, direction, false); } } function stopAutoRepeat() { if (timeout) { clearTimeout(timeout); timeout = null; } } return dom('div.kf_spinner', dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.up'), kd.toggleClass('disabled', () => shouldDisable(valueObservable(), 1)), dom.on('mousedown', () => { startChange(this, 1); }) ), dom('div.kf_spinner_half', dom('div.kf_spinner_arrow.down'), kd.toggleClass('disabled', () => shouldDisable(valueObservable(), -1)), dom.on('mousedown', () => { startChange(this, -1); }) ), dom.on('dblclick', () => false) ); } /** * Creates a spinner item linked to `valueObservable`. * @param {Number} optMin - Optional spinner lower bound * @param {Number} optMax - Optional spinner upper bound */ exports.spinner = function(valueObservable, stepSizeObservable, optMin, optMax) { var max = optMax !== undefined ? optMax : Infinity; var min = optMin !== undefined ? optMin : -Infinity; function getNewValue(value, direction) { const step = (ko.unwrap(stepSizeObservable) || 1) * direction; // Adding step quickly accumulates floating-point errors. We want to keep the value a multiple // of step, as well as only keep significant decimal digits. The latter is done by converting // to string and back using 15 digits of precision (max guaranteed to be significant). value = value || 0; value = Math.round(value / step) * step + step; value = parseFloat(value.toPrecision(15)); return gutil.clamp(value, min, max); } function shouldDisable(value, direction) { return (direction > 0) ? (value >= max) : (value <= min); } return genSpinner(valueObservable, getNewValue, shouldDisable); }; /** * Creates a select spinner item to loop through the `optionObservable` array, * setting visible value to `valueObservable`. */ exports.selectSpinner = function(valueObservable, optionObservable) { function getNewValue(value, direction) { const choices = optionObservable.peek(); const index = choices.indexOf(value); const newIndex = gutil.mod(index + direction, choices.length); return choices[newIndex]; } function shouldDisable(value, direction) { return optionObservable().length <= 1; } return genSpinner(valueObservable, getNewValue, shouldDisable); }; /** * Creates an alignment selector linked to `valueObservable`. */ exports.alignmentSelector = function(valueObservable) { return this.buttonSelect(valueObservable, this.optionButton("left", dom('span.glyphicon.glyphicon-align-left'), dom.testId('koForm_alignLeft')), this.optionButton("center", dom('span.glyphicon.glyphicon-align-center'), dom.testId('koForm_alignCenter')), this.optionButton("right", dom('span.glyphicon.glyphicon-align-right'), dom.testId('koForm_alignRight')) ); }; /** * Label with a collapser triangle in front, which may be clicked to toggle `isCollapsedObs` * observable. */ exports.collapserLabel = function(isCollapsedObs, moreContentArgs) { return dom('div.kf_collapser.kf_elem', dom('span.kf_triangle_toggle', kd.text(function() { return isCollapsedObs() ? '\u25BA' : '\u25BC'; }) ), dom.on('click', function() { isCollapsedObs(!isCollapsedObs.peek()); }), dom.fwdArgs(arguments, 1)); }; /** * Creates a collapsible section. The argument must be a function which takes a boolean observable * (isCollapsed) as input, and should return an array of elements. The first element is always * shown, while the rest will be toggled by `isCollapsed` observable. The isMountedCollapsed * parameter controls the initial state of the collapsible. When true or omitted, the collapsible * will be closed on load. Otherwise, the collapsible will initialize expanded/uncollapsed. * * kf.collapsible(function(isCollapsed) { * return [ * kf.collapserLabel(isCollapsed, 'Indents'), * kf.row(...), * kf.row(...) * ]; * }); * Returns an array of two items: the always-shown element, and a div containing the rest. */ exports.collapsible = function(contentFunc, isMountedCollapsed) { var isCollapsed = ko.observable(isMountedCollapsed === undefined ? true : isMountedCollapsed); var content = contentFunc(isCollapsed); return [ content[0], dom('div', kd.hide(isCollapsed), dom.fwdArgs(content, 1)) ]; }; /** * Creates a draggable list of rows. The contentArray argument must be an observable array. * The callbackObj argument should include some or all of the following methods: * reorder, remove, and receive. * The reorder callback is executed if an item is dragged and dropped to a new position * within the same collection or draggable container. The remove and receive callbacks * are executed together only when an item from one collection is dropped on a different * collection. The remove callback may be executed alone when users click on the "minus" icon * for draggable items. The connectAllDraggables function must be called on draggables to * enable the remove/receive operation between separate draggables. * * Each callback must update the respective model tied to the draggable component, * or the equivalency between the UI and the observable array may be broken. When * a method is implemented, but the callback cannot update the model for any reason * (e.g., failure), then this failure should be communicated to the component either * by throwing an Error in the callback, or by returning a rejected Promise. * * * reorder(item, nextItem) * @param {Object} item The item being relocated/moved * @param {Object} nextItem The next item immediately following the new position, * or null, when the item is moved to the end of the collection. * remove(item) * @param {Object} item The item that should be removed from the collection. * @returns {Object} The item removed from the observable array. This * value is passed to the receive function as the * its item parameter. This value must include all the * necessary data required for connected draggables * to successfully insert the new value within their * respective receive functions. * receive(item, nextItem) * @param {Object} item The item to insert in the collection. * @param {Object} nextItem The next item from item's new position. This value * will be null when item is moved to the end of the list. * * @param {Array} contentArray KoArray of model items * @param {Function} itemCreateFunc Identical to koDom.foreach's itemCreateFunc, this * function is called as `itemCreateFunc(item)` for each * array element. Must return a single Node, or null or * undefined to omit that node. * @param {Object} options An object containing the reorder, remove, receive * callback functions, and all other draggable configuration * options -- * @param {Boolean} options.removeButton Controls whether the clickable remove/minus icon is * displayed. If true, this button triggers the remove * function on click. * @param {String} options.axis Determines if the list is displayed vertically 'y' or * horizontally 'x'. * @param {Boolean|Function} drag_indicator Include the drag indicator. Defaults to true. Accepts * also a function that returns a dom element. In which * case, it will be used to create the drag indicator. * @returns {Node} The DOM Node for the draggable container */ exports.draggableList = function(contentArray, itemCreateFunc, options) { options = options || {}; defaults(options, { removeButton: true, axis: "y", drag_indicator: true, itemClass: 'kf_draggable__item' }); var reorderFunc, removeFunc; itemCreateFunc = itemCreateFunc || identity; var list = dom('div.kf_drag_container', function(elem) { if (options.reorder) { reorderFunc = Promise.method(options.reorder); ko.utils.domData.set(elem, 'reorderFunc', reorderFunc); } if (options.remove) { removeFunc = Promise.method(options.remove); ko.utils.domData.set(elem, 'removeFunc', removeFunc); } if (options.receive) { ko.utils.domData.set(elem, 'receiveFunc', Promise.method(options.receive)); } }, kd.foreach(contentArray, item => { var row = itemCreateFunc(item); if (row) { return dom('div.kf_draggable', // Fix for JQueryUI bug where mousedown on draggable elements fail to blur // active element. See: https://bugs.jqueryui.com/ticket/4261 dom.on('mousedown', () => G.document.activeElement.blur()), kd.cssClass(options.itemClass), (options.drag_indicator ? (typeof options.drag_indicator === 'boolean' ? dom('span.kf_drag_indicator.glyphicon.glyphicon-option-vertical') : options.drag_indicator() ) : null), kd.style('display', options.axis === 'x' ? 'inline-block' : 'block'), kd.domData('model', item), kd.maybe(removeFunc !== undefined && options.removeButton, function() { return dom('span.drag_delete.glyphicon.glyphicon-remove', dom.on('click', function() { removeFunc(item) .catch(function(err) { console.warn('Failed to remove item', err); }); }) ); }), dom('span.kf_draggable_content.flexauto', row)); } else { return null; } }) ); G.$(list).sortable({ axis: options.axis, tolerance: "pointer", forcePlaceholderSize: true, placeholder: 'kf_draggable__placeholder--' + (options.axis === 'x' ? 'horizontal' : 'vertical') }); if (reorderFunc === undefined) { G.$(list).sortable("option", {disabled: true}); } G.$(list).on('sortstart', function(e, ui) { ko.utils.domData.set(ui.item[0], 'originalParent', ui.item.parent()); ko.utils.domData.set(ui.item[0], 'originalPrev', ui.item.prev()); }); G.$(list).on('sortstop', function(e, ui) { if (!ko.utils.domData.get(ui.item[0], 'crossedContainers')) { handleReorderStop.bind(null, list).call(this, e, ui); } else { handleConnectedStop.call(list, e, ui); } }); return list; }; function handleReorderStop(container, e, ui) { var reorderFunc = ko.utils.domData.get(container, 'reorderFunc'); var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev'); if (reorderFunc && !ui.item.prev().is(originalPrev)) { var movingItem = ko.utils.domData.get(ui.item[0], 'model'); reorderFunc(movingItem, getNextDraggableItemModel(ui.item)) .catch(function(err) { console.warn('Failed to reorder item', err); G.$(container).sortable('cancel'); }); } resetDraggedItem(ui.item[0]); } function handleConnectedStop(e, ui) { var originalParent = ko.utils.domData.get(ui.item[0], 'originalParent'); var removeOriginal = ko.utils.domData.get(originalParent[0], 'removeFunc'); var receive = ko.utils.domData.get(ui.item.parent()[0], 'receiveFunc'); if (removeOriginal && receive) { removeOriginal(ko.utils.domData.get(ui.item[0], 'model')) .then(function(removedItem) { return receive(removedItem, getNextDraggableItemModel(ui.item)) .then(function() { ui.item.remove(); }) .catch(revertRemovedItem.bind(null, ui, originalParent, removedItem)); }) .catch(function(err) { console.warn('Error removing item', err); G.$(originalParent).sortable('cancel'); }) .finally(function() { resetDraggedItem(ui.item[0]); }); } else { console.warn('Missing remove or receive'); } } function revertRemovedItem(ui, parent, item, err) { console.warn('Error receiving item. Trying to return removed item.', err); var originalReceiveFunc = ko.utils.domData.get(parent[0], 'receiveFunc'); if (originalReceiveFunc) { var originalPrev = ko.utils.domData.get(ui.item[0], 'originalPrev'); var originalNextItem = originalPrev.length > 0 ? getNextDraggableItemModel(originalPrev) : getDraggableItemModel(parent.children('.kf_draggable').first()); originalReceiveFunc(item, originalNextItem) .catch(function(err) { console.warn('Failed to receive item in original collection.', err); }).finally(function() { ui.item.remove(); }); } } function getDraggableItemModel(elem) { if (elem.length && elem.length > 0) { return ko.utils.domData.get(elem[0], 'model'); } return null; } function getNextDraggableItemModel(elem) { return elem.next ? getDraggableItemModel(elem.next('.kf_draggable')) : null; } function resetDraggedItem(elem) { ko.utils.domData.set(elem, 'originalPrev', null); ko.utils.domData.set(elem, 'originalParent', null); ko.utils.domData.set(elem, 'crossedContainers', false); } function enableDraggableConnection(draggable) { G.$(draggable).on('sortremove', function(e, ui) { ko.utils.domData.set(ui.item[0], 'crossedContainers', true); ko.utils.domData.set(ui.item[0], 'stopIndex', ui.item.index()); }); if (G.$(draggable).sortable("option", "disabled") && ( ko.utils.domData.get(draggable, 'receiveFunc') || ko.utils.domData.get(draggable, 'removeFunc') )) { G.$(draggable).sortable( "option", { disabled: false }); } } function connectDraggableToClass(draggable, className) { enableDraggableConnection(draggable); G.$(draggable).addClass(className); G.$(draggable).sortable("option", {connectWith: "." + className}); } /** * Connects 2 or more draggableList components together. This connection allows any of the * draggable components to drag & drop items into and out of any other connected draggable. * @param {Object} draggableArgs 2 or more draggableList objects */ var connectedDraggables = 0; exports.connectAllDraggables = function(draggableArgs) { if (draggableArgs.length < 2) { console.warn('connectAllDraggables requires at least 2 draggable components'); } var className = "connected-draggable-" + connectedDraggables++; for (var i=0; i