(core) move client code to core

Summary:
This moves all client code to core, and makes minimal fix-ups to
get grist and grist-core to compile correctly.  The client works
in core, but I'm leaving clean-up around the build and bundles to
follow-up.

Test Plan: existing tests pass; server-dev bundle looks sane

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D2627
This commit is contained in:
Paul Fitzpatrick 2020-10-02 11:10:00 -04:00
parent 5d60d51763
commit 1654a2681f
395 changed files with 52651 additions and 47 deletions

174
app/client/app.css Normal file
View File

@ -0,0 +1,174 @@
/* global variables */
:root {
--color-logo-row: #F9AE41;
--color-logo-col: #2CB0AF;
--color-logo-cell: #DEDDDD;
--color-logo-bg: #42494B;
--color-link-default: #336;
--color-link-visited: #336;
--color-link-hover: #66c;
--color-link-active: #66c;
--color-link-bright: orange;
--color-start-page-bg: #f0f0f0;
--color-doclist-bg: #fcfcfc;
--color-navbar-bg: var(--color-logo-bg);
--color-navbar-btn-bg: #fefefe;
--color-navbar-btn-bg-hover: #f6f6f6;
--color-navbar-btn-disabled: #ccc;
--color-header-doclist: #6d6dde;
--color-tab-bar-bg: #d6d6d6;
--color-border-light: #ddd;
--color-border-medium: #bbb;
--color-btn-login: #ffb749;
--color-btn-login-background: #fff1dc;
--color-btn-createdoc: #3fda2c;
--color-btn-uploaddoc: #00dcff;
--color-btn-decline: #c74646;
--color-btn-accept: #3eda2c;
--layout-top-spacer: 20px;
--layout-doclist-header-height: 42px;
--layout-doclist-height: calc(100vh - 2*var(--layout-top-spacer));
--color-list-row-hover: #f0f0f0;
--color-list-item: #f6f6f6;
--color-list-item-hover: #e0e0e0;
--color-list-item-selected: #e8d53d;
--color-list-item-disabled: #ccc;
--color-list-item-action: #6eec6e;
--color-hint-text: #888;
--scroll-bar-width: 12px;
--scroll-bar-bg: #f0f0f0;
/* fonts */
--font-navbar-title: "Helvetica", "Arial", sans-serif;
--font-btn-symbols: "Apple Symbols", "Arial Unicode MS";
}
.flexhbox {
display: -webkit-flex;
display: flex;
}
.flexvbox {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
}
.flexitem {
/* Makes the flex item flexible and sets the flex basis to zero (disregards content size). */
-webkit-flex: 1 1 0px;
flex: 1 1 0px;
/* Min-width of 0 is needed to allow the flex box to shrink below its minimum content size. */
min-width: 0px;
}
.flexnone {
/* Sizes the item based on content or width/height, and makes it fully inflexible. */
-webkit-flex: none;
flex: none;
}
.flexauto {
/* Sizes the item based on content or width/height, and makes it fully flexible. */
-webkit-flex: auto;
flex: auto;
}
.clipped {
overflow: hidden;
}
body {
/* This seems logically appropriate since we never want body to scroll, but the real reason is
* to avoid a major slowdown when using $().modal() dialogs (a JQuery plugin in bootstrap).
* Those add/remove a class to body which sets "overflow: hidden", which causes great slowness on
* Firefox (not Chrome). If body is already "overflow: hidden", it's much faster.
*/
overflow: hidden;
}
.show_scrollbar::-webkit-scrollbar {
width: var(--scroll-bar-width);
height: var(--scroll-bar-width);
background-color: var(--scroll-bar-bg);
}
.show_scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.3);
-webkit-border-radius: 100px;
border: 2px solid var(--scroll-bar-bg);
}
.show_scrollbar::-webkit-scrollbar-thumb:vertical {
min-height: 4rem;
}
.show_scrollbar::-webkit-scrollbar-thumb:horizontal {
min-width: 4rem;
}
.show_scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(0,0,0,0.4); /* Some darker color when you click it */
-webkit-border-radius: 100px;
}
.show_scrollbar::-webkit-scrollbar-thumb:active {
background-color: rgba(0,0,0,0.5); /* Some darker color when you click it */
-webkit-border-radius: 100px;
}
div.dev_warning {
position: absolute;
z-index: 10;
width: 100%;
opacity: 0.5;
pointer-events: none;
font-size: 200%;
color: white;
background: red;
text-align: center;
}
#browser-check-problem {
display: none;
width: 100%;
height: 100%;
position: absolute;
z-index: 5000;
top: 0;
left: 0;
background: rgba(255, 255, 255, 0.9);
padding-top: 3em;
}
#browser-check-problem div.browser-check-wrapper {
position: absolute;
top: 30%;
width: 100%;
}
#browser-check-problem div.browser-check-message, #browser-check-problem div.browser-check-options {
margin: 0 auto;
max-width: 400px;
padding: 1em;
background: white;
}
#browser-check-problem div.browser-check-options {
text-align: center;
}
#browser-check-problem a {
display: inline-block;
background: white;
padding: 10px;
margin: 10px;
color: #16B378;
border: 1px solid #16B378;
border-radius: 4px;
}
#browser-check-problem a:hover {
text-decoration: none;
color: #009058;
border: 1px solid #009058;
}

33
app/client/app.js Normal file
View File

@ -0,0 +1,33 @@
/* global $, window, document */
const {App} = require('./ui/App');
// Disable longStackTraces, which seem to be enabled in the browser by default.
var bluebird = require('bluebird');
bluebird.config({ longStackTraces: false });
// Set up integration between grainjs and knockout disposal.
const {setupKoDisposal} = require('grainjs');
const ko = require('knockout');
setupKoDisposal(ko);
$(function() {
window.gristApp = App.create(null, document.getElementById('grist-app'));
// Set from the login tests to stub and un-stub functions during execution.
window.loginTestSandbox = null;
// These modules are exposed for the sake of browser tests.
window.exposeModulesForTests = function() {
return (import('./exposeModulesForTests' /* webpackChunkName: "modulesForTests" */));
};
window.exposedModules = {
// Several existing tests use window.exposedModules.loadScript has loaded
// a file for them. We now load exposedModules asynchronously, so that it
// doesn't slow down application startup. To avoid changing tests
// unnecessarily, we implement a loadScript wrapper.
loadScript(name) {
return window.exposeModulesForTests()
.then(() => window.exposedModules._loadScript(name));
}
};
});

View File

@ -0,0 +1,18 @@
.ace_grist_link_hidden {
display: none;
}
.ace_grist_link {
color: var(--grist-color-light-green);
text-decoration: underline;
cursor: pointer;
}
.ace_editor.ace_autocomplete .ace_completion-highlight.ace_grist_link {
color: var(--grist-color-dark-green);
}
.ace_editor.ace_autocomplete .ace_text-layer {
z-index: 7;
pointer-events: auto;
}

View File

@ -0,0 +1,402 @@
var ace = require('brace');
var ko = require('knockout');
var _ = require('underscore');
// Used to load python language settings and 'chrome' ace style
require('brace/mode/python');
require('brace/theme/chrome');
require('brace/ext/language_tools');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var modelUtil = require('../models/modelUtil');
/**
* A class to help set up the ace editor with standard formatting and convenience functions
* @param {Observable} options.observable: If given, creates a 2-way binding between the observable
* and the value of the editor.
* @param {Boolean} options.saveValueOnBlurEvent: Flag to indicate whether ace editor
* should save the value on `blur` event.
* @param {Function} options.calcSize: Optional function used to resize the editor. It is called
* with (elem, desiredSize) as arguments, and should return the actual size to use for the
* element. Both desiredSize and the return value are objects with 'width' and 'height' members.
*/
function AceEditor(options) {
// Observable subscription is not created until the dom is built
this.observable = (options && options.observable) || null;
this.saveValueOnBlurEvent = !(options && (options.saveValueOnBlurEvent === false));
this.calcSize = (options && options.calcSize) || ((elem, size) => size);
this.gristDoc = (options && options.gristDoc) || null;
this.editor = null;
this.editorDom = null;
this.session = null;
this._setupCallback = null;
this._setupTimer = null;
this.textPadding = 10; // Space after cursor when not using wrap mode
}
dispose.makeDisposable(AceEditor);
// Builds editor dom with additional setup possible in function `optSetupCallback`.
// May be called multiple times by an instance of AceEditor.
AceEditor.prototype.buildDom = function(optSetupCallback) {
this._fullDom = dom('div.code_editor_container',
this.editorDom = dom('div')
);
this._setupCallback = optSetupCallback;
this._setupTimer = setTimeout(() => this._setup(), 0);
return this._fullDom;
};
/**
* You may optionally call this once the DOM returned from buildDom is attached to the document to
* make setup and sizing more immediate.
*/
AceEditor.prototype.onAttach = function() {
if (this._setupTimer) {
clearTimeout(this._setupTimer);
this._setupTimer = null;
this._setup();
}
};
AceEditor.prototype.writeObservable = function() {
if (this.observable) {
modelUtil.setSaveValue(this.observable, this.getValue());
}
};
AceEditor.prototype.getEditor = function() {
return this.editor;
};
AceEditor.prototype.getValue = function() {
return this.editor && this.editor.getValue();
};
/**
* @param {String} val: The new value to set the editor to.
* @param {Number} optCursorPos: Position where to place the cursor: at the end if omitted.
*/
AceEditor.prototype.setValue = function(val, optCursorPos) {
// Note that underlying setValue() has a special meaning for second parameter:
// undefined or 0 is selectAll, -1 is at the document start, and 1 is at the end.
this.editor.setValue(val, optCursorPos === 0 ? -1 : 1);
if (optCursorPos > 0 && optCursorPos < val.length) {
var pos = this.session.getDocument().indexToPosition(optCursorPos);
this.editor.moveCursorTo(pos.row, pos.column);
}
};
AceEditor.prototype.isBuilt = function() {
return this.editor !== null;
};
// Enables or disables the AceEditor
AceEditor.prototype.enable = function(bool) {
var editor = this.editor;
editor.setReadOnly(!bool);
editor.renderer.$cursorLayer.element.style.opacity = bool ? 100 : 0;
editor.gotoLine(Infinity, Infinity); // Prevents text selection on disable
};
/**
* Commands must be added specially to the ace editor.
* Attaching commands to the textarea using commandGroup.attach() only
* works for certain keys.
*
* Note: Commands to the aceEditor are always enabled.
* Note: Ace defers to standard behavior when false is returned.
*/
AceEditor.prototype.attachCommandGroup = function(commandGroup) {
_.each(commandGroup.knownKeys, (command, key) => {
this.editor.commands.addCommand({
name: command,
bindKey: {
win: key,
mac: key,
sender: 'editor|cli'
},
// AceEditor wants a command to return true if it got handled, whereas our command returns
// true to avoid stopPropagation/preventDefault, i.e. if it hasn't been handled.
exec: () => !commandGroup.commands[command]()
});
});
};
/**
* Attaches a command to the editor which saves the current editor
* contents to the attached observable on 'Shift+Enter'.
* Throws error if there is no attached observable.
* TODO: Use instead of custom save command for more implementations of AceEditor
*/
AceEditor.prototype.attachSaveCommand = function() {
if (!this.observable) {
throw new Error("Cannot attach save command to editor with no bound observable");
}
var key = 'Shift+Enter';
this.editor.commands.addCommand({
name: 'saveFormula',
bindKey: {
win: key,
mac: key,
sender: 'editor|cli'
},
// AceEditor wants a command to return true if it got handled
exec: () => {
this.writeObservable();
return true;
}
});
};
// Wraps words to the current width of the editor
AceEditor.prototype.adjustContentToWidth = function() {
var characterWidth = this.editor.renderer.characterWidth;
var contentWidth = this.editor.renderer.scroller.clientWidth;
if(contentWidth > 0) {
this.editor.getSession().setWrapLimit(parseInt(contentWidth/characterWidth, 10) - 1);
}
};
AceEditor.prototype.setFontSize = function(pxVal) {
this.editor.setFontSize(pxVal);
this.resize();
};
AceEditor.prototype._setup = function() {
// Standard editor setup
this.editor = this.autoDisposeWith('destroy', ace.edit(this.editorDom));
if (this.gristDoc) {
// Add some autocompletion with partial access to document
const aceLanguageTools = ace.acequire('ace/ext/language_tools');
const gristDoc = this.gristDoc;
aceLanguageTools.setCompleters([]);
aceLanguageTools.addCompleter({
// Default regexp stops at periods, which doesn't let autocomplete
// work on members. So we expand it to include periods.
// We also include $, which grist uses for column names.
identifierRegexps: [/[a-zA-Z_0-9.$\u00A2-\uFFFF]/],
// For autocompletion we ship text to the sandbox and run standard completion there.
getCompletions: function(editor, session, pos, prefix, callback) {
if (prefix.length === 0) { callback(null, []); return; }
const tableId = gristDoc.viewModel.activeSection().table().tableId();
gristDoc.docComm.autocomplete(prefix, tableId)
.then(suggestions => {
// ACE autocompletions are very poorly documented. This is somewhat helpful:
// https://prog.world/implementing-code-completion-in-ace-editor/
callback(null, suggestions.map(suggestion => {
if (Array.isArray(suggestion)) {
const [funcname, argSpec, isGrist] = suggestion;
const meta = isGrist ? 'grist' : 'python';
return {value: funcname + '(', caption: funcname + argSpec, score: 1, meta, funcname};
} else {
return {value: suggestion, score: 1, meta: "python"};
}
}));
});
},
});
// Create Autocomplete object at this point so we can turn autoSelect off.
// There doesn't seem to be any way to get ace to respect autoSelect otherwise.
// It is important for autoSelect to be off so that hitting enter doesn't automatically
// use a suggestion, a change of behavior that doesn't seem particularly desirable and
// which also breaks several existing tests.
const {Autocomplete} = ace.acequire('ace/autocomplete'); // lives in brace/ext/language_tools
const completer = new Autocomplete();
this.editor.completer = completer;
this.editor.completer.autoSelect = false;
aceCompleterAddHelpLinks(completer);
// Explicitly destroy the auto-completer on disposal, since it doesn't not remove the element
// it adds to body even when it detaches itself. Ace's AutoCompleter doesn't expose any
// interface for this, so this takes some hacking. (One reason for this is that Ace seems to
// expect that a single AutoCompleter would be used for all editor instances.)
this.autoDisposeCallback(() => {
if (completer.editor) {
completer.detach();
}
if (completer.popup) {
completer.popup.destroy(); // This is not enough, but seems relevant to call.
ko.removeNode(completer.popup.container); // Removes the element and cleans up JQuery state if any.
}
});
}
this.editor.setOptions({
enableLiveAutocompletion: true, // use autocompletion without needing special activation.
});
this.session = this.editor.getSession();
this.session.setMode('ace/mode/python');
this.editor.setTheme('ace/theme/chrome');
// Default line numbers to hidden
this.editor.renderer.setShowGutter(false);
this.session.setTabSize(2);
this.session.setUseWrapMode(true);
this.editor.on('change', this.resize.bind(this));
this.editor.$blockScrolling = Infinity;
this.editor.setFontSize(11);
this.resize();
// Set up the bound observable if supplied
if (this.observable) {
var subscription = this.observable.subscribeInit(val => {if (val !== undefined) {this.setValue(val);}});
// Dispose with dom since subscription is created when dom is created
dom(this.editorDom,
dom.autoDispose(subscription)
);
if (this.saveValueOnBlurEvent) {
this.editor.on('blur', () => {
this.writeObservable();
});
}
}
if (this._setupCallback) {
this._setupCallback.call(null, this.editor);
this._setupCallback = null;
}
};
AceEditor.prototype.resize = function() {
var wrap = this.session.getUseWrapMode();
var contentWidth = wrap ? 0 : this._getContentWidth();
var desiredSize = {
width: wrap ? 0 : contentWidth + this.textPadding,
height: this._getContentHeight()
};
var size = this.calcSize(this._fullDom, desiredSize);
if (size.width < contentWidth) {
// Editor will show a horizontal scrollbar, so recalculate to make space for it.
desiredSize.height += 20;
size = this.calcSize(this._fullDom, desiredSize);
}
this.editorDom.style.width = size.width ? size.width + 'px' : 'auto';
this.editorDom.style.height = size.height + 'px';
this.editor.resize();
};
AceEditor.prototype._getContentWidth = function() {
return this.session.getScreenWidth() * this.editor.renderer.characterWidth;
};
AceEditor.prototype._getContentHeight = function() {
return Math.max(1, this.session.getScreenLength()) * this.editor.renderer.lineHeight;
};
let _RangeConstructor = null; //singleton, load it lazily
AceEditor.makeRange = function(a,b,c,d) {
_RangeConstructor = _RangeConstructor || ace.acequire('ace/range').Range;
return new _RangeConstructor(a,b,c,d);
};
/**
* When autocompleting a known function (with funcname received from the server call), turn the
* function name into a link to Grist documentation.
*
* ACE autocomplete is poorly documented, and poorly customizable, so this is accomplished by
* monkey-patching it. Further, the only text styling is done via styled tokens, but we can style
* them to look like links, and handle clicks to open the destination URL.
*
* This implementation relies a lot on the details of the implementation in
* node_modules/brace/ext/language_tools.js. Updates to brace module may easily break it.
*/
function aceCompleterAddHelpLinks(completer) {
// Replace the $init function in order to intercept the creation of the autocomplete popup.
const init = completer.$init;
completer.$init = function() {
const popup = init.apply(this, arguments);
customizeAceCompleterPopup(this, popup);
return popup;
};
}
function customizeAceCompleterPopup(completer, popup) {
// Replace the $tokenizeRow function to produce customized tokens to style the link part.
const origTokenize = popup.session.bgTokenizer.$tokenizeRow;
popup.session.bgTokenizer.$tokenizeRow = function(row) {
const tokens = origTokenize(row);
return retokenizeAceCompleterRow(popup.data[row], tokens);
};
// Replace the click handler with one that handles link clicks.
popup.removeAllListeners("click");
popup.on("click", function(e) {
if (!maybeAceCompleterLinkClick(e)) {
completer.insertMatch();
}
e.stop();
});
}
function retokenizeAceCompleterRow(rowData, tokens) {
if (!rowData.funcname) {
// Not a special completion, pass through the result of ACE's original tokenizing.
return tokens;
}
// ACE's original tokenizer splits rowData.caption into tokens to highlight matching portions.
// We jump in, and further divide the tokens so that those that form the link get an extra CSS
// class. ACE's will turn token.type into CSS classes by splitting the type on "." and prefixing
// the resulting substrings with "ace_".
// Funcname may be the recognized name itself (e.g. "UPPER"), or a method (like
// "Table1.lookupOne"), in which case only the portion after the dot is the recognized name.
// Figure out the portion that should be linkified.
const dot = rowData.funcname.lastIndexOf(".");
const linkStart = dot < 0 ? 0 : dot + 1;
const linkEnd = rowData.funcname.length;
const newTokens = [];
// Include into new tokens a special token that will be hidden, but include the link URL. On
// click, we find it to know what URL to open.
const href = 'https://support.getgrist.com/functions/#' +
rowData.funcname.slice(linkStart, linkEnd).toLowerCase();
newTokens.push({value: href, type: 'grist_link_hidden'});
// Go through tokens, splitting them if needed, and modifying those that form the link part.
let position = 0;
for (const t of tokens) {
// lStart/lEnd are indices of the link within the token, possibly negative.
const lStart = linkStart - position, lEnd = linkEnd - position;
if (lStart > 0) {
const beforeLink = t.value.slice(0, lStart);
newTokens.push({value: beforeLink, type: t.type});
}
if (lEnd > 0) {
const inLink = t.value.slice(Math.max(0, lStart), lEnd);
const newType = t.type + (t.type ? '.' : '') + 'grist_link';
newTokens.push({value: inLink, type: newType});
}
if (lEnd < t.value.length) {
const afterLink = t.value.slice(lEnd);
newTokens.push({value: afterLink, type: t.type});
}
position += t.value.length;
}
return newTokens;
}
// On any click on AceCompleter popup, we check if we happened to click .ace_grist_link class. If
// so, we should be able to find the URL and open another window to it.
function maybeAceCompleterLinkClick(event) {
const tgt = event.domEvent.target;
if (tgt && tgt.matches('.ace_grist_link')) {
const dest = tgt.parentElement.querySelector('.ace_grist_link_hidden');
if (dest) {
window.open(dest.textContent, "_blank");
return true;
}
}
return false;
}
module.exports = AceEditor;

View File

@ -0,0 +1,121 @@
.action_log {
padding: 1rem;
margin: 0;
}
.action_log_item {
list-style: none;
padding: 0;
margin: 0;
font-size: 1.1rem;
}
.action_info {
line-height: 1;
font-size: 0.9rem;
color: grey;
margin-bottom: 4px;
margin-top: 8px;
}
.action_info > span {
margin: 0 2px;
}
.action_info_user {
font-weight: 600;
}
.action_info_from_self {
color: #333333;
}
.action_log_item.undone > .action_info,
.action_log_item.undone > .action_desc {
text-decoration: line-through;
color: #aaa;
}
.action_log_item.buried {
background-color: #ddd;
}
.action_log_item.buried > .action_desc {
text-decoration: line-through;
color: #aaa;
}
.action_log_rename_pre {
background: #faa;
}
.action_log_rename_post {
background: #afa;
}
.action_log_table {
border-collapse: collapse;
}
.action_log_table caption {
caption-side: bottom;
text-align: center;
margin-top: 0;
padding-top: 0;
color: #000;
}
.action_log_table td {
border-left: 1px solid #888;
border-right: 1px solid #888;
border-bottom: 1px solid #888;
border-top: 1px solid #888;
cursor: pointer;
}
.action_log_table th {
border-top: 1px solid #ccc;
border-left: 1px solid #ccc;
border-right: 1px solid #ccc;
color: #888;
}
.action_log_table th:first-child {
border: none;
border-bottom: 1px solid #ccc;
}
.action_log_table td:first-child {
border: none;
border-left: 1px solid #ccc;
border-bottom: 1px solid #ccc;
color: #888;
cursor: inherit;
}
.action_log_table td, .action_log_table th {
padding-left: 3px;
padding-right: 3px;
font-weight: normal;
}
.action_log_cell_remove {
background: #faa;
text-decoration: line-through;
padding-left: 2px;
padding-right: 2px;
}
.action_log_cell_pre {
margin-right: 3px;
}
.action_log_cell_add {
background: #afa;
padding-left: 2px;
padding-right: 2px;
}
.action_comment {
display: none;
}

View File

@ -0,0 +1,445 @@
/**
* ActionLog manages the list of actions from server and displays them in the side bar.
*/
import * as dispose from 'app/client/lib/dispose';
import * as dom from 'app/client/lib/dom';
import {timeFormat} from 'app/common/timeFormat';
import * as ko from 'knockout';
import map = require('lodash/map');
import koArray from 'app/client/lib/koArray';
import {KoArray} from 'app/client/lib/koArray';
import * as koDom from 'app/client/lib/koDom';
import * as koForm from 'app/client/lib/koForm';
import {GristDoc} from 'app/client/components/GristDoc';
import {ActionGroup} from 'app/common/ActionGroup';
import {ActionSummary, asTabularDiffs, defunctTableName, getAffectedTables,
LabelDelta} from 'app/common/ActionSummary';
import {CellDelta} from 'app/common/TabularDiff';
import {IDomComponent} from 'grainjs';
/**
*
* Actions that are displayed in the log get a state observable
* to track if they are undone/buried.
*
* Also for each table shown in the log, we create an observable
* to track its name. References to these observables are stored
* with each action, by the name of the table at that time (the
* name of a table can change).
*
*/
export interface ActionGroupWithState extends ActionGroup {
state?: ko.Observable<string>; // is action undone/buried
tableFilters?: {[tableId: string]: ko.Observable<string>}; // current names of tables
affectedTableIds?: Array<ko.Observable<string>>; // names of tables affecting this ActionGroup
}
const gristNotify = (window as any).gristNotify;
// Action display state enum.
const state = {
UNDONE: 'undone',
BURIED: 'buried',
DEFAULT: 'default'
};
export class ActionLog extends dispose.Disposable implements IDomComponent {
private _displayStack: KoArray<ActionGroupWithState>;
private _gristDoc: GristDoc|null;
private _selectedTableId: ko.Computed<string>;
private _showAllTables: ko.Observable<boolean>; // should all tables be visible?
private _pending: ActionGroupWithState[] = []; // cache for actions that arrive while loading log
private _loaded: boolean = false; // flag set once log is loaded
private _loading: ko.Observable<boolean>; // flag set while log is loading
/**
* Create an ActionLog.
* @param options - supplies the GristDoc holding the log, if we have one, so that we
* can cross-reference with it. We may not have a document, if used from the
* command line renderActions utility, in which case we don't set up cross-references.
*/
public create(options: {gristDoc: GristDoc|null}) {
// By default, just show actions for the currently viewed table.
this._showAllTables = ko.observable(false);
// We load the ActionLog lazily now, when it is first viewed.
this._loading = ko.observable(false);
this._gristDoc = options.gristDoc;
// TODO: _displayStack grows without bound within a single session.
// Stack of actions as they should be displayed to the user.
this._displayStack = koArray<ActionGroupWithState>();
// Computed for the tableId of the table currently being viewed.
if (!this._gristDoc) {
this._selectedTableId = this.autoDispose(ko.computed(() => ""));
} else {
this._selectedTableId = this.autoDispose(ko.computed(
() => this._gristDoc!.viewModel.activeSection().table().tableId()));
}
}
public buildDom() {
return this._buildLogDom();
}
/**
* Pushes actions as they are received from the server to the display stack.
* @param {Object} actionGroup - ActionGroup instance from the server.
*/
public pushAction(ag: ActionGroupWithState): void {
if (this._loading()) {
this._pending.push(ag);
return;
}
this._setupFilters(ag, this._displayStack.at(0) || undefined);
const otherAg = ag.otherId ? this._displayStack.all().find(a => a.actionNum === ag.otherId) : null;
if (otherAg) {
// Undo/redo action.
if (otherAg.state) {
otherAg.state(ag.isUndo ? state.UNDONE : state.DEFAULT);
}
} else {
// Any (non-link) action.
if (ag.fromSelf) {
// Bury all undos immediately preceding this action since they can no longer
// be redone. This is triggered by normal actions and undo/redo actions whose
// targets are not recent (not in the stack).
for (let i = 0; i < this._displayStack.peekLength; i++) {
const prevAction = this._displayStack.at(i)!;
if (!prevAction.state) { continue; }
const prevState = prevAction.state();
if (prevAction.fromSelf && prevState === state.DEFAULT) {
// When a normal action is found, stop looking to bury previous actions.
break;
} else if (prevAction.fromSelf && prevState === state.UNDONE) {
// The previous action was undone, so now it has become buried.
prevAction.state(state.BURIED);
}
}
}
if (!ag.otherId) {
ag.state = ko.observable(state.DEFAULT);
this._displayStack.unshift(ag);
}
}
}
/**
* Render a description of an action prepared on the server.
* @param {TabularDiffs} act - a collection of table changes
* @param {string} txt - a textual description of the action
* @param {ActionGroupWithState} ag - the full action information we have
*/
public renderTabularDiffs(sum: ActionSummary, txt: string, ag?: ActionGroupWithState) {
const act = asTabularDiffs(sum);
const editDom = dom('div',
this._renderTableSchemaChanges(sum, ag),
this._renderColumnSchemaChanges(sum, ag),
map(act, (tdiff, table) => {
if (tdiff.cells.length === 0) { return dom('div'); }
return dom('table.action_log_table',
koDom.show(() => this._showForTable(table, ag)),
dom('caption',
this._renderTableName(table)),
dom('tr',
dom('th'),
tdiff.header.map(diff => {
return dom('th', this._renderCell(diff));
})),
tdiff.cells.map(row => {
return dom('tr',
dom('td', this._renderCell(row[0])),
row[2].map((diff, idx: number) => {
return dom('td',
this._renderCell(diff),
dom.on('click', () => {
return this._selectCell(row[1], act[table].header[idx], table,
ag ? ag.actionNum : 0);
}));
}));
}));
}),
dom('span.action_comment', txt));
return editDom;
}
/**
* Decorate an ActionGroup with observables for controlling visibility of any
* table information rendered from it. Observables are shared with the previous
* ActionGroup, and simply stored under a new name as needed.
*/
private _setupFilters(ag: ActionGroupWithState, prev?: ActionGroupWithState): void {
const filt: {[name: string]: ko.Observable<string>} = ag.tableFilters = {};
// First, bring along observables for tables from previous actions.
if (prev) {
// Tables are renamed from time to time - prepare dictionary of updates.
const renames = new Map(ag.actionSummary.tableRenames);
for (const name of Object.keys(prev.tableFilters!)) {
if (name.startsWith('-')) {
// skip
} else if (renames.has(name)) {
const newName = renames.get(name) || defunctTableName(name);
filt[newName] = prev.tableFilters![name];
filt[newName](newName); // Update the observable with the new name.
} else {
filt[name] = prev.tableFilters![name];
}
}
}
// Add any more observables that we need for this action.
const names = getAffectedTables(ag.actionSummary);
for (const name of names) {
if (!filt[name]) { filt[name] = ko.observable(name); }
}
// Record the observables that affect this ActionGroup specifically
ag.affectedTableIds = names.map(name => ag.tableFilters![name]).filter(obs => obs);
}
/**
* Helper function that returns true if any table touched by the ActionGroup
* is set to be visible.
*/
private _hasSelectedTable(ag: ActionGroupWithState): boolean {
if (!this._gristDoc) { return true; }
return ag.affectedTableIds!.some(tableId => tableId() === this._selectedTableId());
}
/**
* Return a koDom.show clause that activates when the named table is not
* filtered out.
*/
private _showForTable(tableName: string, ag?: ActionGroupWithState): boolean {
if (!ag) { return true; }
const obs = ag.tableFilters![tableName];
return this._showAllTables() || !obs || obs() === this._selectedTableId();
}
private _buildLogDom() {
this._loadActionSummaries().catch((error) => gristNotify(`Action Log failed to load`));
return dom('div.action_log',
dom('div.preference_item',
koForm.checkbox(this._showAllTables,
dom.testId('ActionLog_allTables'),
dom('span.preference_desc', 'All tables'))),
dom('div.action_log_load',
koDom.show(() => this._loading()),
'Loading...'),
koDom.foreach(this._displayStack, (ag: ActionGroupWithState) => {
const timestamp = ag.time ? timeFormat("D T", new Date(ag.time)) : "";
let desc = ag.desc || "";
if (ag.actionSummary) {
desc = this.renderTabularDiffs(ag.actionSummary, desc, ag);
}
return dom('div.action_log_item',
koDom.cssClass(ag.state),
koDom.show(() => this._showAllTables() || this._hasSelectedTable(ag)),
dom('div.action_info',
dom('span.action_info_action_num', `#${ag.actionNum}`),
ag.user ? dom('span.action_info_user',
ag.user,
koDom.toggleClass('action_info_from_self', ag.fromSelf)
) : '',
dom('span.action_info_timestamp', timestamp)),
dom('span.action_desc', desc)
);
})
);
}
/**
* Fetch summaries of recent actions (with summaries) from the server.
*/
private async _loadActionSummaries() {
if (this._loaded || !this._gristDoc) { return; }
this._loading(true);
const result = await this._gristDoc!.docComm.getActionSummaries();
this._loading(false);
this._loaded = true;
// Add the actions to our action log.
result.forEach(item => this.pushAction(item));
// Add any actions that came in while we were fetching. Unlikely, but
// perhaps possible?
const top = result[0] ? result[0].actionNum : 0;
for (const item of this._pending) {
if (item.actionNum > top) { this.pushAction(item); }
}
this._pending.length = 0;
}
/**
* Prepare dom element(s) for a cell that has been created, destroyed,
* or modified.
*
* @param {CellDelta|string|null} cell - a structure with before and after values,
* or a plain string, or null
*
*/
private _renderCell(cell: CellDelta|string|null) {
// we'll show completely empty cells as "..."
if (cell === null) {
return "...";
}
// strings are shown as themselves
if (typeof(cell) === 'string') {
return cell;
}
// by elimination, we have a TabularDiff.CellDelta with before and after values.
const [pre, post] = cell;
if (!pre && !post) {
// very boring before + after values :-)
return "";
} else if (pre && !post) {
// this is a cell that was removed
return dom('span.action_log_cell_remove', pre[0]);
} else if (post && (pre === null || (pre[0] === null || pre[0] === ''))) {
// this is a cell that was added, or modified from a previously empty value
return dom('span.action_log_cell_add', post[0]);
} else if (pre && post) {
// a modified cell
return dom('div',
dom('span.action_log_cell_remove.action_log_cell_pre', pre[0]),
dom('span.action_log_cell_add', post[0]));
}
return JSON.stringify(cell);
}
/**
* Choose a table name to show. For now, we show diffs of metadata tables also.
* For those tables, we show "_grist_Foo_bar" as "[Foo.bar]".
* @param {string} name - tableId of table
* @returns {string} a friendlier name for the table
*/
private _renderTableName(name: string): string {
if (name.indexOf('_grist_') !== 0) {
// Ordinary data table. Ideally, we would look up
// a friendly name from a raw data view - TODO.
return name;
}
const metaName = name.split('_grist_')[1].replace(/_/g, '.');
return `[${metaName}]`;
}
/**
* Show an ActionLog item when a column or table is renamed, added, or removed.
* Make sure the item is only shown when the affected table is not filtered out.
*
* @param scope: blank for tables, otherwise "<tablename>."
* @param pair: the rename/addition/removal in LabelDelta format: [null, name1]
* for addition of name1, [name2, null] for removal of name2, [name1, name2]
* for a rename of name1 to name2.
* @return a filtered dom element.
*/
private _renderSchemaChange(scope: string, pair: LabelDelta, ag?: ActionGroupWithState) {
const [pre, post] = pair;
// ignore addition/removal of manualSort column
if ((pre || post) === 'manualSort') { return dom('div'); }
return dom('div.action_log_rename',
koDom.show(() => this._showForTable(post || defunctTableName(pre!), ag)),
(!post ? ["Remove ", scope, dom("span.action_log_rename_pre", pre)] :
(!pre ? ["Add ", scope, dom("span.action_log_rename_post", post)] :
["Rename ", scope, dom("span.action_log_rename_pre", pre),
" to ", dom("span.action_log_rename_post", post)])));
}
/**
* Show any table additions/removals/renames.
*/
private _renderTableSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) {
return dom('div',
sum.tableRenames.map(pair => this._renderSchemaChange("", pair, ag)));
}
/**
* Show any column additions/removals/renames.
*/
private _renderColumnSchemaChanges(sum: ActionSummary, ag?: ActionGroupWithState) {
return dom('div',
Object.keys(sum.tableDeltas).filter(key => !key.startsWith('-')).map(key =>
dom('div',
koDom.show(() => this._showForTable(key, ag)),
sum.tableDeltas[key].columnRenames.map(pair =>
this._renderSchemaChange(key + ".", pair)))));
}
/**
* Move cursor to show a given cell of a given table. Uses primary view of table.
*/
private async _selectCell(rowId: number, colId: string, tableId: string, actionNum: number) {
if (!this._gristDoc) { return; }
// Find action in the stack.
const index = this._displayStack.peek().findIndex(a => a.actionNum === actionNum);
if (index < 0) { throw new Error(`Cannot find action ${actionNum} in the action log.`); }
// Found the action. Now trace forward to find current tableId, colId, rowId.
for (let i = index; i >= 0; i--) {
const action = this._displayStack.at(i)!;
const sum = action.actionSummary;
// Check if this table was renamed / removed.
const tableRename: LabelDelta|undefined = sum.tableRenames.find(r => r[0] === tableId);
if (tableRename) {
const newName = tableRename[1];
if (!newName) {
// TODO - find a better way to send informative notifications.
gristNotify(`Table ${tableId} was subsequently removed in action #${action.actionNum}`);
return;
}
tableId = newName;
}
const td = sum.tableDeltas[tableId];
if (!td) { continue; }
// Check is this row was removed - if so there's no reason to go on.
if (td.removeRows.indexOf(rowId) >= 0) {
// TODO - find a better way to send informative notifications.
gristNotify(`This row was subsequently removed in action #${action.actionNum}`);
return;
}
// Check if this column was renamed / added.
const columnRename: LabelDelta|undefined = td.columnRenames.find(r => r[0] === colId);
if (columnRename) {
const newName = columnRename[1];
if (!newName) {
// TODO - find a better way to send informative notifications.
gristNotify(`Column ${colId} was subsequently removed in action #${action.actionNum}`);
return;
}
colId = newName;
}
}
// Find the table model of interest.
const tableModel = this._gristDoc.getTableModel(tableId);
if (!tableModel) { return; }
// Get its "primary" view.
const viewRow = tableModel.tableMetaRow.primaryView();
const viewId = viewRow.getRowId();
// Switch to that view.
await this._gristDoc.openDocPage(viewId);
// Now let's pick a reasonable section in that view.
const viewSection = viewRow.viewSections().peek().find((s: any) => s.table().tableId() === tableId);
if (!viewSection) { return; }
const sectionId = viewSection.getRowId();
// Within that section, find the column of interest if possible.
const fieldIndex = viewSection.viewFields().peek().findIndex((f: any) => f.colId.peek() === colId);
// Finally, move cursor position to the section, column (if we found it), and row.
this._gristDoc.moveToCursorPos({rowId, sectionId, fieldIndex});
}
}

View File

@ -0,0 +1,94 @@
/**
* This is the base class for components. The purpose is to abstract away several
* common idioms to make derived components simpler.
*
* Usage:
* function Component(gristDoc) {
* Base.call(this, gristDoc);
* ...
* }
* Base.setBaseFor(Component);
*
* To create an object:
* var obj = Component.create(constructor_args...);
*/
/* global $ */
var dispose = require('../lib/dispose');
/**
* gristDoc may be null when there is no active document.
*/
function Base(gristDoc) {
this.gristDoc = gristDoc;
this._debugName = this.constructor.name + '[' + Base._nextObjectId + ']';
// TODO: devise a logging system that allows turning on/off different debug tags and levels.
//console.log(this._debugName, "Base constructor");
this._eventNamespace = '.Events_' + (Base._nextObjectId++);
this._eventSources = [];
this.autoDisposeCallback(this.clearEvents);
}
Base._nextObjectId = 1;
/**
* Sets ctor to inherit prototype methods from Base.
* @param {function} ctor Constructor function which needs to inherit Base's prototype.
*/
Base.setBaseFor = function(ctor) {
ctor.prototype = Object.create(Base.prototype, {
constructor: {
value: ctor,
enumerable: false,
writable: true,
configurable: true
}
});
dispose.makeDisposable(ctor);
};
/**
* Subscribe to eventType on source, similarly to $(source).on(eventType, optSelector, method).
* In fact, this uses JQuery internally. The convenience is that it allows unsubscribing in bulk.
* Also, method is called with the context of `this`.
*/
Base.prototype.onEvent = function(source, eventType, optSelector, method) {
if (typeof optSelector != 'string') {
method = optSelector;
optSelector = null;
}
if (this._eventSources.indexOf(source) === -1)
this._eventSources.push(source);
var self = this;
$(source).on(eventType + this._eventNamespace, optSelector, function(event_args) {
Array.prototype.unshift.call(arguments, this); // Unshift is generic enough for 'arguments'.
if (self._eventSources)
return method.apply(self, arguments);
});
};
/**
* Unsubscribes this object from eventType on source, similarly to $(source).off(eventType).
*/
Base.prototype.clearEvent = function(source, eventType) {
$(source).off(eventType + this._eventNamespace);
};
/**
* Unsubscribes this object from all events that it subscribed to via onEvent().
*/
Base.prototype.clearEvents = function() {
var sources = this._eventSources;
for (var i = 0; i < sources.length; i++) {
$(sources[i]).off(this._eventNamespace);
}
this._eventSources.length = 0;
};
module.exports = Base;

View File

@ -0,0 +1,639 @@
/* global window */
var _ = require('underscore');
var ko = require('knockout');
var moment = require('moment-timezone');
var {getSelectionDesc} = require('app/common/DocActions');
var {nativeCompare, roundDownToMultiple, waitObs} = require('app/common/gutil');
var gristTypes = require('app/common/gristTypes');
var koUtil = require('../lib/koUtil');
var tableUtil = require('../lib/tableUtil');
var {DataRowModel} = require('../models/DataRowModel');
var {DynamicQuerySet} = require('../models/QuerySet');
var {SortFunc} = require('app/common/SortFunc');
var rowset = require('../models/rowset');
var Base = require('./Base');
var {Cursor} = require('./Cursor');
var FieldBuilder = require('../widgets/FieldBuilder');
var commands = require('./commands');
var LinkingState = require('./LinkingState');
var BackboneEvents = require('backbone').Events;
const {ClientColumnGetters} = require('app/client/models/ClientColumnGetters');
const {reportError, UserError} = require('app/client/models/errors');
const {urlState} = require('app/client/models/gristUrlState');
const {SectionFilter} = require('app/client/models/SectionFilter');
const {copyToClipboard} = require('app/client/lib/copyToClipboard');
const {setTestState} = require('app/client/lib/testState');
const {createFilterMenu} = require('app/client/ui/ColumnFilterMenu');
/**
* BaseView forms the basis for ViewSection classes.
* @param {Object} viewSectionModel - The model for the viewSection represented.
* @param {Boolean} options.addNewRow - Whether to include an add row in the model.
*/
function BaseView(gristDoc, viewSectionModel, options) {
Base.call(this, gristDoc);
this.options = options || {};
this.viewSection = viewSectionModel;
this._name = this.viewSection.titleDef.peek();
//--------------------------------------------------
// Observable models mapped to the document
// Instantiate the models for the view metadata and for the data itself.
// The table should never change for a given view, so no need to watch the table() observable.
this.schemaModel = this.viewSection.table();
// Check if we are making a comparison with another document.
this.comparison = this.gristDoc.comparison;
if (this.comparison) {
const tableId = this.schemaModel.tableId();
// TODO: make robust to name changes.
this.leftTableDelta = this.comparison.details.leftChanges.tableDeltas[tableId];
this.rightTableDelta = this.comparison.details.rightChanges.tableDeltas[tableId];
} else {
this.rightTableDelta = null;
this.leftTableDelta = null;
}
// TODO: but accessing by tableId identifier may be problematic when the table is renamed.
this.tableModel = this.gristDoc.getTableModelMaybeWithDiff(this.schemaModel.tableId());
// We use a DynamicQuerySet as the underlying RowSource, with ColumnFilters applies on top of
// it. It filters based on section linking, re-querying as needed in case of onDemand tables.
this._queryRowSource = DynamicQuerySet.create(this, gristDoc.querySetManager, this.tableModel);
// When we have a summary table, filter out rows corresponding to empty groups.
// (TODO this may be better implemented by deleting empty groups in the data engine.)
if (this.viewSection.table().summarySourceTable()) {
const groupGetter = this.tableModel.tableData.getRowPropFunc('group');
this._mainRowSource = rowset.BaseFilteredRowSource.create(this,
rowId => !gristTypes.isEmptyList(groupGetter(rowId)));
this._mainRowSource.subscribeTo(this._queryRowSource);
} else {
this._mainRowSource = this._queryRowSource;
}
if (this.comparison) {
// Assign extra row ids for any rows added in the remote (right) table.
// We flip their sign to make them as belonging to the remote table only.
// TODO: if we wanted to show rows removed in the local (left) table, we'd need to
// add those too, and come up with ids to give them. Without this, there's no
// way to render an update that was made remotely to a row that was removed locally.
const extraRowIds = (this.rightTableDelta && this.rightTableDelta.addRows || [])
.map(rowId => -rowId);
this._mainRowSource = rowset.ExtendedRowSource.create(this, this._mainRowSource, extraRowIds);
}
// Create a section filter and a filtered row source that subscribes to its changes.
// `sectionFilter` also provides an `addTemporaryRow()` to allow views to display newly inserted rows,
// and `setFilterOverride()` to allow controlling a filter from a column menu.
this._sectionFilter = SectionFilter.create(this, this.viewSection.viewFields, this.tableModel.tableData);
this._filteredRowSource = rowset.FilteredRowSource.create(this, this._sectionFilter.sectionFilterFunc.get());
this._filteredRowSource.subscribeTo(this._mainRowSource);
this.autoDispose(this._sectionFilter.sectionFilterFunc.addListener(filterFunc => {
this._filteredRowSource.updateFilter(filterFunc);
}));
// Sorted collection of all rows to show in this view.
this.sortedRows = rowset.SortedRowSet.create(this, null);
// Re-sort when sortSpec changes.
this.sortFunc = new SortFunc(new ClientColumnGetters(this.tableModel));
this.autoDispose(this.viewSection.activeDisplaySortSpec.subscribeInit(function(spec) {
this.sortFunc.updateSpec(spec);
this.sortedRows.updateSort((rowId1, rowId2) => {
var value = nativeCompare(rowId1 === "new", rowId2 === "new");
return value || this.sortFunc.compare(rowId1, rowId2);
});
}, this));
// Here we are subscribed to the bulk of the data (main table, possibly filtered).
this.sortedRows.subscribeTo(this._filteredRowSource);
// We create a special one-row RowSource for the "Add new" row, in case we need it.
this.newRowSource = rowset.RowSource.create(this);
this.newRowSource.getAllRows = function() { return ['new']; };
// This is the LazyArrayModel containing DataRowModels, for rendering, e.g. with scrolly.
this.viewData = this.autoDispose(this.tableModel.createLazyRowsModel(this.sortedRows));
// Floating row model that is not destroyed when the row is scrolled out of view. It must be
// assigned manually to a rowId. Additionally, we override the saving of field values with a
// custom method that handles better positioning of cursor on adding a new row.
this.editRowModel = this.autoDispose(this.tableModel.createFloatingRowModel());
this.editRowModel._saveField =
(colName, value) => this._saveEditRowField(this.editRowModel, colName, value);
// Reset heights of rows when there is an action that affects them.
this.listenTo(this.viewData, 'rowModelNotify', rowModels => this.onRowResize(rowModels));
this.listenTo(this.viewSection.events, 'rowHeightChange', this.onResize );
// Create a command group for keyboard shortcuts common to all views.
this.autoDispose(commands.createGroup(BaseView.commonCommands, this, this.viewSection.hasFocus));
//--------------------------------------------------
// Prepare logic for linking with other sections.
// Linking state maintains .filterFunc and .cursorPos observables which we use for
// auto-scrolling and filtering.
this._linkingState = this.autoDispose(koUtil.computedBuilder(() => {
let v = this.viewSection;
let src = v.linkSrcSection();
const filterByAllShown = v.optionsObj.prop('filterByAllShown');
return src.getRowId() ?
LinkingState.create.bind(LinkingState, this.gristDoc,
src, v.linkSrcCol().colId(), v, v.linkTargetCol().colId(), filterByAllShown()) :
null;
}));
this._linkingFilter = this.autoDispose(ko.computed(() => {
const linking = this._linkingState();
return linking && linking.filterColValues ? linking.filterColValues() : {};
}));
// A computed for the rowId of the row selected by section linking.
this.linkedRowId = this.autoDispose(ko.computed(() => {
let linking = this._linkingState();
return linking && linking.cursorPos ? linking.cursorPos() : null;
}).extend({deferred: true}));
// Update the cursor whenever linkedRowId() changes.
this.autoDispose(this.linkedRowId.subscribe(rowId => this.setCursorPos({rowId})));
// Indicated whether editing the section should be disabled given the current linking state.
this.disableEditing = this.autoDispose(ko.computed(() => {
const linking = this._linkingState();
return linking && linking.disableEditing();
}));
this.enableAddRow = this.autoDispose(ko.computed(() => this.options.addNewRow &&
!this.viewSection.disableAddRemoveRows() && !this.disableEditing()));
// Hide the add row if editing is disabled via filter linking.
this.autoDispose(this.enableAddRow.subscribeInit(_enableAddRow => {
if (_enableAddRow) {
this.sortedRows.subscribeTo(this.newRowSource);
} else {
this.sortedRows.unsubscribeFrom(this.newRowSource);
}
}));
//--------------------------------------------------
// Observables local to this view
this._isLoading = ko.observable(true);
this._pendingCursorPos = this.viewSection.lastCursorPos;
// Initialize the cursor with the previous cursor position indicies, if they exist.
console.log("%s BaseView viewSection %s (%s) lastCursorPos %s", this._debugName, this.viewSection.getRowId(),
this.viewSection.table().tableId(), JSON.stringify(this.viewSection.lastCursorPos));
this.cursor = this.autoDispose(Cursor.create(null, this, this.viewSection.lastCursorPos));
this.currentColumn = this.autoDispose(ko.pureComputed(() =>
this.viewSection.viewFields().at(this.cursor.fieldIndex()).column()
).extend({rateLimit: 0})); // TODO Test this without the rateLimit
this.currentEditingColumnIndex = ko.observable(-1);
// A koArray of FieldBuilder objects, one for each view-section field.
this.fieldBuilders = this.autoDispose(
FieldBuilder.createAllFieldWidgets(this.gristDoc, this.viewSection.viewFields, this.cursor)
);
// An observable evaluating to the FieldBuilder for the field where the cursor is.
this.activeFieldBuilder = this.autoDispose(ko.pureComputed(() =>
this.fieldBuilders.at(this.cursor.fieldIndex())
));
// Observable for whether the data in this view is truncated, i.e. not all rows are included
// (this can only be true for on-demand tables).
this.isTruncated = ko.observable(false);
// This computed's purpose is the side-effect of calling makeQuery() initially and when any
// dependency changes.
this.autoDispose(ko.computed(() => {
this._isLoading(true);
this._queryRowSource.makeQuery(this._linkingFilter(), (err) => {
if (this.isDisposed()) { return; }
if (err) { window.gristNotify(`Query error: ${err.message}`); }
this.onTableLoaded();
});
}));
// Reset cursor to the first row when filtering changes.
this.autoDispose(this._linkingFilter.subscribe((x) => this.setCursorPos({rowIndex: 0})));
// When sorting changes, reset the cursor to the first row. (The alternative of moving the
// cursor to stay at the same record is sometimes better, but sometimes more annoying.)
this.autoDispose(this.viewSection.activeSortSpec.subscribe(() => this.setCursorPos({rowIndex: 0})));
this.copySelection = ko.observable(null);
}
Base.setBaseFor(BaseView);
_.extend(Base.prototype, BackboneEvents);
/**
* These commands are common to GridView and DetailView.
*/
BaseView.commonCommands = {
input: function(input) { this.activateEditorAtCursor(input); },
editField: function() { this.activateEditorAtCursor(); },
insertRecordBefore: function() { this.insertRow(this.cursor.rowIndex()); },
insertRecordAfter: function() { this.insertRow(this.cursor.rowIndex() + 1); },
insertCurrentDate: function() { this.insertCurrentDate(false); },
insertCurrentDateTime: function() { this.insertCurrentDate(true); },
copyLink: function() { this.copyLink().catch(reportError); },
};
/**
* Sets the cursor to the given position, deferring if necessary until the current query finishes
* loading.
*/
BaseView.prototype.setCursorPos = function(cursorPos) {
if (!this._isLoading.peek()) {
this.cursor.setCursorPos(cursorPos);
} else {
// This is the first step; the second happens in onTableLoaded.
this._pendingCursorPos = cursorPos;
this.cursor.setLive(false);
}
};
/**
* Returns a promise that's resolved when the query being loaded finishes loading.
* If no query is being loaded, it will resolve immediately.
*/
BaseView.prototype.getLoadingDonePromise = function() {
return waitObs(this._isLoading, (value) => !value);
};
/**
* Start editing the selected cell.
* @param {String} input: If given, initialize the editor with the given input (rather than the
* original content of the cell).
*/
BaseView.prototype.activateEditorAtCursor = function(input) {
var builder = this.activeFieldBuilder();
if (builder.isEditorActive()) {
return;
}
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
// LazyArrayModel row model which is also used to build the cell dom. Needed since
// it may be used as a key to retrieve the cell dom, which is useful for editor placement.
var lazyRow = this.getRenderedRowModel(rowId);
if (builder.field.disableEditData() || this.gristDoc.isReadonly.get()) {
builder.flashCursorReadOnly(lazyRow);
} else {
if (!lazyRow) {
// TODO scroll into view. For now, just don't activate the editor.
return;
}
this.editRowModel.assign(rowId);
builder.buildEditorDom(this.editRowModel, lazyRow, { 'init': input });
}
};
// Copy an anchor link for the current row to the clipboard.
BaseView.prototype.copyLink = async function() {
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
const colRef = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].colRef();
const sectionId = this.viewSection.getRowId();
try {
const link = urlState().makeUrl({ hash: { sectionId, rowId, colRef } });
await copyToClipboard(link);
setTestState({clipboard: link});
reportError(new UserError('Link copied to clipboard', {key: 'clipboard'}));
} catch (e) {
throw new Error('cannot copy to clipboard');
}
};
/**
* Insert a new row immediately before the row at the given index if given an Integer. Otherwise
* insert a new row at the end.
*/
BaseView.prototype.insertRow = function(index) {
if (this.viewSection.disableAddRemoveRows() || this.disableEditing()) {
return;
}
var rowId = this.viewData.getRowId(index);
var insertPos = Number.isInteger(rowId) ?
this.tableModel.tableData.getValue(rowId, 'manualSort') : null;
return this.sendTableAction(['AddRecord', null, { 'manualSort': insertPos }])
.then(rowId => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
this.setCursorPos({rowId});
}
return rowId;
});
};
/**
* Given a 2-d paste column-oriented paste data and target cols, transform the data to omit
* fields that shouldn't be pasted over and extract rich paste data if available.
* @param {Array<Array<(RichPasteObject|string)>>} data - Column-oriented 2-d array of either
* plain strings or rich paste data returned by `tableUtil.parsePasteHtml` with `displayValue`
* and, optionally, `colType` and `rawValue` attributes.
* @param {Array<MetaRowModel>} cols - Array of target column objects
* @returns {Object} - Object mapping colId to array of column values, suitable for use in Bulk
* actions.
*/
BaseView.prototype._parsePasteForView = function(data, cols) {
let updateCols = cols.map(col => {
if (col && !col.isRealFormula() && !col.disableEditData()) {
return col;
} else {
return null; // Don't include formulas and missing columns
}
});
let updateColIds = updateCols.map(c => c && c.colId());
let updateColTypes = updateCols.map(c => c && c.type());
let richData = data;
if (data.length > 0 && data[0].length > 0 &&
_.isObject(data[0][0]) && data[0][0].hasOwnProperty('displayValue')) {
richData = data.map((col, idx) => {
if (col[0].colType === updateColTypes[idx]) {
return col.map(v => v.hasOwnProperty('rawValue') ? v.rawValue : v.displayValue);
} else {
return col.map(v => v.displayValue);
}
});
}
return _.omit(_.object(updateColIds, richData), null);
};
BaseView.prototype._getDefaultColValues = function() {
const filterValues = this._linkingFilter.peek();
return _.mapObject(_.pick(filterValues, v => (v.length > 0)), v => v[0]);
};
/**
* Enhances [Bulk]AddRecord actions to include the default values determined by the current
* section-linking filter.
*/
BaseView.prototype._enhanceAction = function(action) {
if (action[0] === 'AddRecord' || action[0] === 'BulkAddRecord') {
let colValues = this._getDefaultColValues();
let rowIds = action[1];
if (action[0] === 'BulkAddRecord') {
colValues = _.mapObject(colValues, v => rowIds.map(() => v));
}
Object.assign(colValues, action[2]);
return [action[0], rowIds, colValues];
} else {
return action;
}
};
/**
* Enhances a list of table actions and turns them from implicit-table actions into
* proper actions.
*/
BaseView.prototype.prepTableActions = function(actions) {
actions = actions.map(a => this._enhanceAction(a));
actions.forEach(action_ => {
action_.splice(1, 0, this.tableModel.tableData.tableId);
});
return actions;
};
/**
* Shortcut for `.tableModel.tableData.sendTableActions`, which also sets default values
* determined by the current section-linking filter, if any.
*/
BaseView.prototype.sendTableActions = function(actions, optDesc) {
return this.tableModel.sendTableActions(actions.map(a => this._enhanceAction(a)), optDesc);
};
/**
* Shortcut for `.tableModel.tableData.sendTableAction`, which also sets default values
* determined by the current section-linking filter, if any.
*/
BaseView.prototype.sendTableAction = function(action, optDesc) {
return action ? this.tableModel.sendTableAction(this._enhanceAction(action), optDesc) : null;
};
/**
* Inserts the current date/time into the selected cell if the cell is of a compatible type
* (Text/Date/DateTime/Any).
* @param {Boolean} withTime: Whether to include the time in addition to the date. This is ignored
* for Date columns (assumed false) and for DateTime (assumed true).
*/
BaseView.prototype.insertCurrentDate = function(withTime) {
let column = this.currentColumn();
if (column.isRealFormula()) {
// Ignore the shortcut when in a formula column.
return;
}
let type = column.pureType();
let value, now = Date.now();
const docTimezone = this.gristDoc.docInfo.timezone.peek();
if (type === 'Text' || type === 'Any') {
// Use document timezone. Don't forget to use uppercase HH for 24-hour time.
value = moment.tz(now, docTimezone).format('YYYY-MM-DD' + (withTime ? ' HH:mm:ss' : ''));
} else if (type === 'Date') {
// Get UTC midnight for the current date (as seen in docTimezone). This is a bit confusing. If
// it's "2019-11-14 23:30 -05:00", then it's "2019-11-15 04:30" in UTC. Since we measure time
// from Epoch UTC, we want the UTC time to have the correct date, so need to add the offset
// (-05:00) to get "2019-11-14 23:30" in UTC, and then round down to midnight.
const offsetMinutes = moment.tz(now, docTimezone).utcOffset();
value = roundDownToMultiple(now / 1000 + offsetMinutes * 60, 24*3600);
} else if (type === 'DateTime') {
value = now / 1000;
} else {
// Ignore the shortcut when in a column of an inappropriate type.
return;
}
var rowId = this.viewData.getRowId(this.cursor.rowIndex());
this.editRowModel.assign(rowId);
this.editRowModel[column.colId()].setAndSave(value);
};
/**
* Override the saving of field values to add some extra processing:
* - If a new row is saved, then we may need to adjust the row where the cursor is.
* - We add the edited or added row to ensure it's displayed regardless of current columnFilters.
* - We change the main view's row observables to see the new value immediately.
* TODO: When saving a formula in the addRow, the cursor moves down instead of staying in place.
* To fix that behavior, propose to factor out the `isAddRow` overrides from here
* into a `setNewRowColValues` on the editRowModel and have `FieldBuilder._saveEdit` call
* that instead of `updateColValues`.
*/
BaseView.prototype._saveEditRowField = function(editRowModel, colName, value) {
if (editRowModel._isAddRow.peek()) {
this.cursor.setLive(false);
const colValues = this._getDefaultColValues();
colValues[colName] = value;
return editRowModel.updateColValues(colValues)
// Once we know the new row's rowId, add it to column filters to make sure it's displayed.
.then(rowId => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
this.setCursorPos({rowId});
}
return rowId;
})
.finally(() => !this.isDisposed() && this.cursor.setLive(true));
} else {
var rowId = editRowModel.getRowId();
// We are editing the floating "edit" rowModel, but to ensure that we see data in the main view
// (when the editor closes), we immediately update the main view's rowModel, if such exists.
var mainRowModel = this.getRenderedRowModel(rowId);
if (mainRowModel) {
mainRowModel[colName](value);
}
const ret = DataRowModel.prototype._saveField.call(editRowModel, colName, value)
// Display this rowId, even if it doesn't match the filter
.then((result) => {
if (!this.isDisposed()) {
this._sectionFilter.addTemporaryRow(rowId);
}
return result;
})
.finally(() => !this.isDisposed() && mainRowModel && mainRowModel._assignColumn(colName));
return this.viewSection.isSorted() ? ret : null;
// Do not return the saveField call in the case that the column is unsorted: in this case,
// we assumes optimistically that the action is successful and browser events can
// continue being processed immediately without waiting.
// When sorted, we wait on the saveField call so we may determine where the row ends
// up for cursor movement purposes.
}
};
/**
* Uses the current cursor selection to return a rich paste object with a reference to the data,
* and the selection ranges. See CopySelection.js
*
* @returns {pasteObj} - Paste object
*/
BaseView.prototype.copy = function(selection) {
this.copySelection(selection);
return {
data: this.tableModel.tableData,
selection: selection
};
};
/**
* Uses the current cursor selection to return a rich paste object with a reference to the data,
* the selection ranges and a callback that when called performs all of the actions needed for a cut.
*
* @returns {pasteObj} - Paste object
*/
BaseView.prototype.cut = function(selection) {
this.copySelection(selection);
return {
data: this.tableModel.tableData,
selection: selection,
cutCallback: () => tableUtil.makeDeleteAction(selection)
};
};
/**
* Helper to send paste actions from the cutCallback and a list of paste actions.
*/
BaseView.prototype.sendPasteActions = function(cutCallback, actions) {
let cutAction = null;
// If this is a cut -> paste, add the cut action and a description.
if (cutCallback) {
cutAction = cutCallback();
// If the cut occurs on an edit restricted cell, there may be no cut action.
if (cutAction) { actions.unshift(cutAction); }
}
return this.gristDoc.docData.sendActions(actions,
this._getPasteDesc(actions[actions.length - 1], cutAction));
};
/**
* Returns a string which describes a cut/copy action.
*/
BaseView.prototype._getPasteDesc = function(pasteAction, optCutAction) {
if (optCutAction) {
return `Moved ${getSelectionDesc(optCutAction, true)} to ` +
`${getSelectionDesc(pasteAction, true)}.`;
} else {
return `Pasted data to ${getSelectionDesc(pasteAction, true)}.`;
}
};
BaseView.prototype.buildDom = function() {
throw new Error("Not Implemented");
};
/**
* Called by ViewLayout to return view-specific controls to add into its ViewSection's title bar.
* By default builds nothing. Derived views may override.
*/
BaseView.prototype.buildTitleControls = function() {
return null;
};
/**
* Called when table data gets loaded (if already loaded, then called immediately after the
* constructor). Derived views may override.
*/
BaseView.prototype.onTableLoaded = function() {
// Complete the setting of a pending cursor position (see setCursorPos() for the first half).
if (this._pendingCursorPos) {
this.cursor.setCursorPos(this._pendingCursorPos);
this._pendingCursorPos = null;
}
this._isLoading(false);
this.isTruncated(this._queryRowSource.isTruncated);
this.cursor.setLive(true);
};
/**
* Called when view gets resized. Derived views may override.
*/
BaseView.prototype.onResize = function() {
};
/**
* Called when rows have changed and may potentially need resizing. Derived views may override.
* @param {Array<DataRowModel>} rowModels: Array of row models whose size may have changed.
*/
BaseView.prototype.onRowResize = function(rowModels) {
};
/**
* Called to obtain the rowModel for the given rowId. Returns a rowModel if it belongs to the
* section and is rendered, otherwise returns null.
* Useful to tie a rendered row to the row being edited. Derived views may override.
*/
BaseView.prototype.getRenderedRowModel = function(rowId) {
return this.viewData.getRowModel(rowId);
};
/**
* Returns the index of the last non-AddNew row in the grid.
*/
BaseView.prototype.getLastDataRowIndex = function() {
let last = this.viewData.peekLength - 1;
return (last >= 0 && this.viewData.getRowId(last) === 'new') ? last - 1 : last;
};
/**
* Creates and opens ColumnFilterMenu for a given field, and returns its PopupControl.
*/
BaseView.prototype.createFilterMenu = function(openCtl, field) {
return createFilterMenu(openCtl, this._sectionFilter, field, this._filteredRowSource, this.tableModel.tableData);
};
module.exports = BaseView;

View File

@ -0,0 +1,6 @@
.chart_container {
overflow: hidden;
position: absolute;
height: 100%;
width: 100%;
}

View File

@ -0,0 +1,508 @@
import * as BaseView from 'app/client/components/BaseView';
import {GristDoc} from 'app/client/components/GristDoc';
import {sortByXValues} from 'app/client/lib/chartUtil';
import {Delay} from 'app/client/lib/Delay';
import {Disposable} from 'app/client/lib/dispose';
import {fromKoSave} from 'app/client/lib/fromKoSave';
import {loadPlotly, PlotlyType} from 'app/client/lib/imports';
import * as DataTableModel from 'app/client/models/DataTableModel';
import {ViewFieldRec, ViewSectionRec} from 'app/client/models/DocModel';
import {KoSaveableObservable, ObjObservable} from 'app/client/models/modelUtil';
import {SortedRowSet} from 'app/client/models/rowset';
import {cssRow} from 'app/client/ui/RightPanel';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors, vars} from 'app/client/ui2018/cssVars';
import {linkSelect, select} from 'app/client/ui2018/menus';
import {nativeCompare} from 'app/common/gutil';
import {Events as BackboneEvents} from 'backbone';
import {dom, DomElementArg, makeTestId, styled} from 'grainjs';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
import defaults = require('lodash/defaults');
import defaultsDeep = require('lodash/defaultsDeep');
import {Config, Data, Datum, ErrorBar, Layout, LayoutAxis, Margin} from 'plotly.js';
let Plotly: PlotlyType;
// When charting multiple series based on user data, limit the number of series given to plotly.
const MAX_SERIES_IN_CHART = 100;
const testId = makeTestId('test-chart-');
interface ChartOptions {
multiseries?: boolean;
lineConnectGaps?: boolean;
lineMarkers?: boolean;
invertYAxis?: boolean;
logYAxis?: boolean;
// If "symmetric", one series after each Y series gives the length of the error bars around it. If
// "separate", two series after each Y series give the length of the error bars above and below it.
errorBars?: 'symmetric' | 'separate';
}
// tslint:disable:no-console
// We use plotly's Datum to describe the type of values in cells. Cells may not match this
// perfectly, but it's helpful for type-checking anyway.
type RowPropGetter = (rowId: number) => Datum;
// We convert Grist data to a list of Series first, from which we then construct Plotly traces.
interface Series {
label: string; // Corresponds to the column name.
group?: Datum; // The group value, when grouped.
values: Datum[];
}
function getSeriesName(series: Series, haveMultiple: boolean) {
if (!series.group) {
return series.label;
} else if (haveMultiple) {
return `${series.group} \u2022 ${series.label}`; // the unicode character is "black circle"
} else {
return String(series.group);
}
}
// The output of a ChartFunc. Normally it just returns one or more Data[] series, but sometimes it
// includes layout information: e.g. a "Scatter Plot" returns a Layout with axis labels.
interface PlotData {
data: Data[];
layout?: Partial<Layout>;
config?: Partial<Config>;
}
// Convert a list of Series into a set of Plotly traces.
type ChartFunc = (series: Series[], options: ChartOptions) => PlotData;
// Helper for converting numeric Date/DateTime values (seconds since Epoch) to JS Date objects for
// use with plotly.
function dateGetter(getter: RowPropGetter): RowPropGetter {
return (r: number) => {
// 0's will turn into nulls, and non-numbers will turn into NaNs and then nulls. This prevents
// Plotly from including 1970-01-01 onto X axis, which usually makes the plot useless.
const val = (getter(r) as number) * 1000;
// Plotly recommends using strings for dates rather than Date objects or timestamps. They are
// interpreted more consistently. See https://github.com/plotly/plotly.js/issues/1532#issuecomment-290420534.
return val ? new Date(val).toISOString() : null;
};
}
/**
* ChartView component displays created charts.
*/
export class ChartView extends Disposable {
public viewPane: Element;
// These elements are defined in BaseView, from which we inherit with some hackery.
protected viewSection: ViewSectionRec;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
private _chartType: ko.Observable<string>;
private _options: ObjObservable<any>;
private _chartDom: HTMLElement;
private _update: () => void;
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel);
this._chartDom = this.autoDispose(this.buildDom());
// Note that .viewPane is used by ViewLayout to insert the actual DOM into the document.
this.viewPane = this._chartDom;
// Resize if the window resizes since that can change the layout leaf size.
// TODO: Belongs into ViewLayout which already does BaseView.onResize for side-pane open/close.
const resizeChart = this.autoDispose(Delay.untilAnimationFrame(this._resizeChart, this));
window.addEventListener('resize', resizeChart);
this.autoDisposeCallback(() => window.removeEventListener('resize', resizeChart));
this._chartType = this.viewSection.chartTypeDef;
this._options = this.viewSection.optionsObj;
this._update = debounce(() => this._updateView(), 0);
this.autoDispose(this._chartType.subscribe(this._update));
this.autoDispose(this._options.subscribe(this._update));
this.autoDispose(this.viewSection.viewFields().subscribe(this._update));
this.listenTo(this.sortedRows, 'rowNotify', this._update);
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
}
protected onTableLoaded() {
(BaseView.prototype as any).onTableLoaded.call(this);
this._update();
}
protected onResize() {
this._resizeChart();
}
protected buildDom() {
return dom('div.chart_container', testId('container'));
}
private listenTo(...args: any[]): void { /* replaced by Backbone */ }
private async _updateView() {
if (this.isDisposed()) { return; }
const chartFunc = chartTypes[this._chartType()];
if (typeof chartFunc !== 'function') {
console.warn("Unknown trace type %s", this._chartType());
return;
}
const fields: ViewFieldRec[] = this.viewSection.viewFields().all();
const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[];
const series: Series[] = fields.map((field) => {
// Use the colId of the displayCol, which may be different in case of Reference columns.
const colId: string = field.displayColModel.peek().colId.peek();
const getter = this.tableModel.tableData.getRowPropFunc(colId) as RowPropGetter;
const pureType = field.displayColModel().pureType();
const fullGetter = (pureType === 'Date' || pureType === 'DateTime') ? dateGetter(getter) : getter;
return {
label: field.label(),
values: rowIds.map(fullGetter),
};
});
const options: ChartOptions = this._options.peek() || {};
let plotData: PlotData = {data: []};
if (!options.multiseries) {
plotData = chartFunc(series, options);
} else if (series.length > 1) {
// We need to group all series by the first column.
const nseries = groupSeries(series[0].values, series.slice(1));
// This will be in the order in which nseries Map was created; concat() flattens the arrays.
for (const gSeries of nseries.values()) {
const part = chartFunc(gSeries, options);
part.data = plotData.data.concat(part.data);
plotData = part;
}
}
Plotly = Plotly || await loadPlotly();
// Loading plotly is asynchronous and it may happen that the chart view had been disposed in the
// meantime and cause error later. So let's check again.
if (this.isDisposed()) { return; }
const layout: Partial<Layout> = defaultsDeep(plotData.layout, getPlotlyLayout(options));
const config: Partial<Config> = {...plotData.config, displayModeBar: false};
// react() can be used in place of newPlot(), and is faster when updating an existing plot.
await Plotly.react(this._chartDom, plotData.data, layout, config);
this._resizeChart();
}
private _resizeChart() {
if (this.isDisposed() || !Plotly) { return; }
Plotly.Plots.resize(this._chartDom);
}
}
/**
* Group the given array of series by a column of group values. The groupColumn and each of the
* series should be arrays of the same length.
*
* For example, if groupColumn has CompanyID, and valueSeries contains [Date, Employees, Revenues]
* (each an array of values), then returns a map mapping each CompanyID to the array [Date,
* Employees, Revenue], each value of which is itself an array of values for that CompanyID.
*/
function groupSeries<T extends Datum>(groupColumn: T[], valueSeries: Series[]): Map<T, Series[]> {
const nseries = new Map<T, Series[]>();
// Limit the number if group values so as to limit the total number of series we pass into
// Plotly. Too many series are impossible to make sense of anyway, and can hang the browser.
// TODO: When not all data is shown, we should probably show some indicator, similar to when
// OnDemand data is truncated.
const maxGroups = Math.floor(MAX_SERIES_IN_CHART / valueSeries.length);
const groupValues: T[] = [...new Set(groupColumn)].sort().slice(0, maxGroups);
// Set up empty lists for each group.
for (const group of groupValues) {
nseries.set(group, valueSeries.map((s: Series) => ({
label: s.label,
group,
values: []
})));
}
// Now fill up the lists.
for (let row = 0; row < groupColumn.length; row++) {
const group = groupColumn[row];
const series: Series[]|undefined = nseries.get(group);
if (series) {
for (let i = 0; i < valueSeries.length; i++) {
series[i].values.push(valueSeries[i].values[row]);
}
}
}
return nseries;
}
// If errorBars are requested, removes error bar series from the 'series' list, adding instead a
// mapping from each main Y series to the corresponding plotly ErrorBar object.
function extractErrorBars(series: Series[], options: ChartOptions): Map<Series, ErrorBar> {
const result = new Map<Series, ErrorBar>();
if (options.errorBars) {
// We assume that series is of the form [X, Y1, Y1-bar, Y2, Y2-bar, ...] (if "symmetric") or
// [X, Y1, Y1-below, Y1-above, Y2, Y2-below, Y2-above, ...] (if "separate").
for (let i = 1; i < series.length; i++) {
result.set(series[i], {
type: 'data',
symmetric: (options.errorBars === 'symmetric'),
array: series[i + 1] && series[i + 1].values,
arrayminus: (options.errorBars === 'separate' ? series[i + 2] && series[i + 2].values : undefined),
thickness: 1,
width: 3,
});
series.splice(i + 1, (options.errorBars === 'symmetric' ? 1 : 2));
}
}
return result;
}
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking.
defaults(ChartView.prototype, BaseView.prototype);
Object.assign(ChartView.prototype, BackboneEvents);
function getPlotlyLayout(options: ChartOptions): Partial<Layout> {
// Note that each call to getPlotlyLayout() creates a new layout object. We are intentionally
// avoiding reuse because Plotly caches too many layout calculations when the object is reused.
const yaxis: Partial<LayoutAxis> = {};
if (options.logYAxis) { yaxis.type = 'log'; }
if (options.invertYAxis) { yaxis.autorange = 'reversed'; }
return {
// Margins include labels, titles, legend, and may get auto-expanded beyond this.
margin: {
l: 50,
r: 50,
b: 40, // Space below chart which includes x-axis labels
t: 30, // Space above the chart (doesn't include any text)
pad: 4
} as Margin,
legend: {
// Translucent background, so chart data is still visible if legend overlaps it.
bgcolor: "#FFFFFF80",
},
yaxis,
};
}
/**
* Build the DOM for side-pane configuration options for a Chart section.
*/
export function buildChartConfigDom(section: ViewSectionRec) {
if (section.parentKey() !== 'chart') { return null; }
const optionsObj = section.optionsObj;
return [
cssRow(
select(fromKoSave(section.chartTypeDef), [
{value: 'bar', label: 'Bar Chart', icon: 'ChartBar' },
{value: 'pie', label: 'Pie Chart', icon: 'ChartPie' },
{value: 'area', label: 'Area Chart', icon: 'ChartArea' },
{value: 'line', label: 'Line Chart', icon: 'ChartLine' },
{value: 'scatter', label: 'Scatter Plot', icon: 'ChartLine' },
{value: 'kaplan_meier', label: 'Kaplan-Meier Plot', icon: 'ChartKaplan'},
]),
testId("type"),
),
dom.maybe((use) => use(section.chartTypeDef) !== 'pie', () => [
// These options don't make much sense for a pie chart.
cssCheckboxRow('Group by first column', optionsObj.prop('multiseries'), testId('multiseries')),
cssCheckboxRow('Invert Y-axis', optionsObj.prop('invertYAxis')),
cssCheckboxRow('Log scale Y-axis', optionsObj.prop('logYAxis')),
]),
dom.maybe((use) => use(section.chartTypeDef) === 'line', () => [
cssCheckboxRow('Connect gaps', optionsObj.prop('lineConnectGaps')),
cssCheckboxRow('Show markers', optionsObj.prop('lineMarkers')),
]),
dom.maybe((use) => ['line', 'bar'].includes(use(section.chartTypeDef)), () => [
cssRow(cssLabel('Error bars'),
dom('div', linkSelect(fromKoSave(optionsObj.prop('errorBars')), [
{value: '', label: 'None'},
{value: 'symmetric', label: 'Symmetric'},
{value: 'separate', label: 'Above+Below'},
], {defaultLabel: 'None'})),
testId('error-bars'),
),
dom.domComputed(optionsObj.prop('errorBars'), (value: ChartOptions["errorBars"]) =>
value === 'symmetric' ? cssRowHelp('Each Y series is followed by a series for the length of error bars.') :
value === 'separate' ? cssRowHelp('Each Y series is followed by two series, for top and bottom error bars.') :
null
),
]),
];
}
function cssCheckboxRow(label: string, value: KoSaveableObservable<unknown>, ...args: DomElementArg[]) {
return dom('label', cssRow.cls(''),
cssLabel(label),
squareCheckbox(fromKoSave(value), ...args),
);
}
function basicPlot(series: Series[], options: ChartOptions, dataOptions: Partial<Data>): PlotData {
trimNonNumericData(series);
const errorBars = extractErrorBars(series, options);
return {
data: series.slice(1).map((line: Series): Data => ({
name: getSeriesName(line, series.length > 2),
x: series[0].values,
y: line.values,
error_y: errorBars.get(line),
...dataOptions,
})),
layout: {
xaxis: series.length > 0 ? {title: series[0].label} : {},
// Include yaxis title for a single y-value series only (2 series total);
// If there are fewer than 2 total series, there is no y-series to display.
// If there are multiple y-series, a legend will be included instead, and the yaxis title
// is less meaningful, so omit it.
yaxis: series.length === 2 ? {title: series[1].label} : {},
},
};
}
// Most chart types take a list of series and then use the first series for the X-axis, and each
// subsequent series for their Y-axis values, allowing for multiple lines on the same plot.
// Each series should have the form {label, values}.
export const chartTypes: {[name: string]: ChartFunc} = {
// TODO There is a lot of code duplication across chart types. Some refactoring is in order.
bar(series: Series[], options: ChartOptions): PlotData {
return basicPlot(series, options, {type: 'bar'});
},
line(series: Series[], options: ChartOptions): PlotData {
sortByXValues(series);
return basicPlot(series, options, {
type: 'scatter',
connectgaps: options.lineConnectGaps,
mode: options.lineMarkers ? 'lines+markers' : 'lines',
});
},
area(series: Series[], options: ChartOptions): PlotData {
sortByXValues(series);
return basicPlot(series, options, {
type: 'scatter',
fill: 'tozeroy',
line: {shape: 'spline'},
});
},
scatter(series: Series[], options: ChartOptions): PlotData {
return basicPlot(series.slice(1), options, {
type: 'scatter',
mode: 'text+markers',
text: series[0].values as string[],
textposition: "bottom center",
});
},
pie(series: Series[]): PlotData {
let line: Series;
if (series.length === 0) {
return {data: []};
}
if (series.length > 1) {
trimNonNumericData(series);
line = series[1];
} else {
// When there is only one series of labels, simply count their occurrences.
line = {label: 'Count', values: series[0].values.map(() => 1)};
}
return {
data: [{
type: 'pie',
name: getSeriesName(line, false),
// nulls cause JS errors when pie charts resize, so replace with blanks.
// (a falsy value would cause plotly to show its index, like "2" which is more confusing).
labels: series[0].values.map(v => (v == null || v === "") ? "-" : v),
values: line.values,
}]
};
},
kaplan_meier(series: Series[]): PlotData {
// For this plot, the first series names the category of each point, and the second the
// survival time for that point. We turn that into as many series as there are categories.
if (series.length < 2) { return {data: []}; }
const newSeries = groupIntoSeries(series[0].values, series[1].values);
return {
data: newSeries.map((line: Series): Data => {
const points = kaplanMeierPlot(line.values as number[]);
return {
type: 'scatter',
mode: 'lines',
line: {shape: 'hv'},
name: getSeriesName(line, false),
x: points.map(p => p.x),
y: points.map(p => p.y),
} as Data;
})
};
},
};
/**
* Assumes a list of series of the form [xValues, yValues1, yValues2, ...]. Remove from all series
* those points for which all of the y-values are non-numeric (e.g. null or a string).
*/
function trimNonNumericData(series: Series[]): void {
const values = series.slice(1).map((s) => s.values);
for (const s of series) {
s.values = s.values.filter((_, i) => values.some(v => typeof v[i] === 'number'));
}
}
// Given two parallel arrays, returns an array of series of the form
// {label: category, values: array-of-values}
function groupIntoSeries(categoryList: Datum[], valueList: Datum[]): Series[] {
const groups = new Map();
for (const [i, cat] of categoryList.entries()) {
if (!groups.has(cat)) { groups.set(cat, []); }
groups.get(cat).push(valueList[i]);
}
return Array.from(groups, ([label, values]) => ({label, values}));
}
// Given a list of survivalValues, returns a list of {x, y} pairs for the kaplanMeier plot.
function kaplanMeierPlot(survivalValues: number[]): Array<{x: number, y: number}> {
// First get a distribution of survivalValue -> count.
const dist = new Map<number, number>();
for (const v of survivalValues) {
dist.set(v, (dist.get(v) || 0) + 1);
}
// Sort the distinct values.
const distinctValues = Array.from(dist.keys());
distinctValues.sort(nativeCompare);
// Now generate plot values, with 'x' for survivalValue and 'y' the number of surviving points.
let y = survivalValues.length;
const points = [{x: 0, y}];
for (const x of distinctValues) {
y -= dist.get(x)!;
points.push({x, y});
}
return points;
}
const cssLabel = styled('div', `
flex: 1 0 0px;
margin-right: 8px;
font-weight: initial; /* negate bootstrap */
color: ${colors.dark};
overflow: hidden;
text-overflow: ellipsis;
`);
const cssRowHelp = styled(cssRow, `
font-size: ${vars.smallFontSize};
color: ${colors.slate};
`);

View File

@ -0,0 +1,55 @@
import * as dispose from 'app/client/lib/dispose';
import {Storage} from 'app/plugin/StorageAPI';
import {checkers} from 'app/plugin/TypeCheckers';
import {Rpc} from 'grain-rpc';
/**
* Implementation of interfaces whose lifetime is that of the client.
*/
export class ClientScope extends dispose.Disposable {
private _pluginStorage = new Map<string, Storage>();
public create() {
// nothing to do
}
/**
* Make interfaces available for a plugin with a given name. Implementations
* are attached directly to the supplied rpc object.
*/
public servePlugin(pluginId: string, rpc: Rpc) {
// We have just one interface right now, storage. We want to keep ownership
// of storage, so it doesn't go away when the plugin is closed. So we cache
// it.
let storage = this._pluginStorage.get(pluginId);
if (!storage) {
storage = this._implementStorage();
this._pluginStorage.set(pluginId, storage);
}
rpc.registerImpl<Storage>("storage", storage, checkers.Storage);
}
/**
* Create an implementation of the Storage interface.
*/
private _implementStorage(): Storage {
const data = new Map<string, any>();
return {
getItem(key: string): any {
return data.get(key);
},
hasItem(key: string): boolean {
return data.has(key);
},
setItem(key: string, value: any) {
data.set(key, value);
},
removeItem(key: string) {
data.delete(key);
},
clear() {
data.clear();
},
};
}
}

View File

@ -0,0 +1,13 @@
/**
* With some guidance from Lucidchart:
* https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
*/
textarea.copypaste {
position: absolute;
top: -100px;
left: 0;
width: 10px;
height: 10px;
font-size: 1;
z-index: -1;
}

View File

@ -0,0 +1,248 @@
/**
* Clipboard component manages the copy/cut/paste events by capturing these events from the browser,
* managing their state, and exposing an API to other components to get/set the data.
*
* Because of a lack of standardization of ClipboardEvents between browsers, the way Clipboard
* captures the events is by creating a hidden textarea element that's always focused with some text
* selected. Here is a good write-up of this:
* https://www.lucidchart.com/techblog/2014/12/02/definitive-guide-copying-pasting-javascript/
*
* When ClipboardEvent is detected, Clipboard captures the event and calls the corresponding
* copy/cut/paste/input command actions, which will get called on the appropriate component.
*
* Usage:
* Components need to register copy/cut/paste actions with command.js:
* .copy() should return @pasteObj (defined below).
* .paste(plainText, [cutSelection]) should take a plainText value and an optional cutSelection
* parameter which will specify the selection that should be cleared as part of paste.
* .input(char) should take a single input character and will be called when the user types a
* visible character (useful if component wants to interpret typing into a cell, for example).
*/
/**
* Paste object that should be returned by implementation of `copy`.
*
* @typedef pasteObj {{
* docName: string,
* tableId: string,
* data: object,
* selection: object
* }}
*/
/* global window, document, $ */
var ko = require('knockout');
var {tsvDecode} = require('app/common/tsvFormat');
var commands = require('./commands');
var dom = require('../lib/dom');
var Base = require('./Base');
var tableUtil = require('../lib/tableUtil');
function Clipboard(app) {
Base.call(this, null);
this._app = app;
this.copypasteField = this.autoDispose(dom('textarea.copypaste.mousetrap', ''));
this.timeoutId = null;
this.onEvent(window, 'focus', this.grabFocus);
this.onEvent(this.copypasteField, 'blur', this.grabFocus);
this.onEvent(this.copypasteField, 'input', function(elem, event) {
var value = elem.value;
elem.value = '';
commands.allCommands.input.run(value);
return false;
});
this.onEvent(this.copypasteField, 'copy', this._onCopy);
this.onEvent(this.copypasteField, 'cut', this._onCut);
this.onEvent(this.copypasteField, 'paste', this._onPaste);
document.body.appendChild(this.copypasteField);
this.grabFocus();
// The following block of code deals with what happens when the window is in the background.
// When it is, focus and blur events are unreliable, and we'll watch explicitly for events which
// may cause a change in focus. These wouldn't happen normally for a background window, but do
// happen in Selenium Webdriver testing.
var grabber = this.grabFocus.bind(this);
function setBackgroundCapture(onOff) {
var addRemove = onOff ? window.addEventListener : window.removeEventListener;
// Note the third argument useCapture=true, which lets us notice these events before other
// code that might call .stopPropagation on them.
addRemove.call(window, 'click', grabber, true);
addRemove.call(window, 'mousedown', grabber, true);
addRemove.call(window, 'keydown', grabber, true);
}
this.onEvent(window, 'blur', setBackgroundCapture.bind(null, true));
this.onEvent(window, 'focus', setBackgroundCapture.bind(null, false));
setBackgroundCapture(!document.hasFocus());
// Expose the grabber as a global to allow upload from tests to explicitly restore focus
window.gristClipboardGrabFocus = grabber;
// Some bugs may prevent Clipboard from re-grabbing focus. To limit the impact of such bugs on
// the user, recover from a bad state in mousedown events. (At the moment of this comment, all
// such known bugs are fixed.)
this.onEvent(window, 'mousedown', (ev) => {
if (!document.activeElement || document.activeElement === document.body) {
this.grabFocus();
}
});
// In the event of a cut a callback is provided by the viewsection that is the target of the cut.
// When called it returns the additional removal action needed for a cut.
this._cutCallback = null;
// The plaintext content of the cut callback. Used to verify that we are pasting the results
// of the cut, rather than new data from outside.
this._cutData = null;
}
Base.setBaseFor(Clipboard);
/**
* Internal helper fired on `copy` events. If a callback was registered from a component, calls the
* callback to get selection data and puts it on the clipboard.
*/
Clipboard.prototype._onCopy = function(elem, event) {
event.preventDefault();
let pasteObj = commands.allCommands.copy.run();
this._setCBdata(pasteObj, event.originalEvent.clipboardData);
};
Clipboard.prototype._onCut = function(elem, event) {
event.preventDefault();
let pasteObj = commands.allCommands.cut.run();
this._setCBdata(pasteObj, event.originalEvent.clipboardData);
};
Clipboard.prototype._setCBdata = function(pasteObj, clipboardData) {
if (!pasteObj) {
return;
}
let plainText = tableUtil.makePasteText(pasteObj.data, pasteObj.selection);
clipboardData.setData('text/plain', plainText);
let htmlText = tableUtil.makePasteHtml(pasteObj.data, pasteObj.selection);
clipboardData.setData('text/html', htmlText);
if (pasteObj.cutCallback) {
this._cutCallback = pasteObj.cutCallback;
this._cutData = plainText;
} else {
this._cutCallback = null;
this._cutData = null;
}
};
/**
* Internal helper fired on `paste` events. If a callback was registered from a component, calls the
* callback with data from the clipboard.
*/
Clipboard.prototype._onPaste = function(elem, event) {
event.preventDefault();
let cb = event.originalEvent.clipboardData;
let plainText = cb.getData('text/plain');
let htmlText = cb.getData('text/html');
let data;
// Grist stores both text/html and text/plain when copying data. When pasting back, we first
// check if text/html exists (should exist for Grist and other spreadsheet software), and fall
// back to text/plain otherwise.
try {
data = tableUtil.parsePasteHtml(htmlText);
} catch (e) {
if (plainText === '' || plainText.charCodeAt(0) === 0xFEFF) {
data = [['']];
} else {
data = tsvDecode(plainText.replace(/\r\n?/g, "\n"));
}
}
if (this._cutData === plainText) {
if (this._cutCallback) {
// Cuts should only be possible on the first paste after a cut and only if the data being
// pasted matches the data that was cut.
commands.allCommands.paste.run(data, this._cutCallback);
}
} else {
this._cutData = null;
commands.allCommands.paste.run(data, null);
}
// The cut callback should only be usable once so it needs to be cleared after every paste.
this._cutCallback = null;
};
/**
* Helper to watch a focused element to lose focus, in which point the Clipboard will grab it.
* Because elements getting removed from the DOM don't always trigger 'blur' event, this also
* watches for the element getting disposed (using ko.removeNode or ko.cleanNode).
*/
Clipboard.prototype._watchElementForBlur = function(elem) {
var self = this;
function done() {
$(elem).off('blur.clipboard');
ko.utils.domNodeDisposal.removeDisposeCallback(elem, done);
self.grabFocus();
}
$(elem).one('blur.clipboard', done);
// TODO We need to add proper integration of grainjs and knockout dom-disposal. Otherwise a
// focused node that's disposed by grainjs will not trigger this knockout disposal callback.
ko.utils.domNodeDisposal.addDisposeCallback(elem, done);
};
var FOCUS_TARGET_TAGS = {
'INPUT': true,
'TEXTAREA': true,
'SELECT': true,
'IFRAME': true,
};
/**
* Helper to determine if the currently active element deserves to keep its own focus, and capture
* copy-paste events. Besides inputs and textareas, any element can be marked to be a valid
* copy-paste target by adding 'clipboard_focus' class to it.
*/
function isCopyPasteTarget(elem) {
return elem && (FOCUS_TARGET_TAGS.hasOwnProperty(elem.tagName) ||
elem.hasAttribute("tabindex") ||
elem.classList.contains('clipboard_focus'));
}
/**
* Select the special copypaste field to capture clipboard events.
*/
Clipboard.prototype.grabFocus = function() {
if (!this.timeoutId) {
var self = this;
this.timeoutId = setTimeout(() => {
if (self.isDisposed()) { return; }
self.timeoutId = null;
if (document.activeElement === self.copypasteField) {
return;
}
// If the window doesn't have focus, don't rush to grab it, or we can interfere with focus
// outside the frame when embedded. We'll grab focus when setBackgroundCapture tells us to.
if (!document.hasFocus()) {
return;
}
if (isCopyPasteTarget(document.activeElement)) {
self._watchElementForBlur(document.activeElement);
self._app.trigger('clipboard_blur');
} else {
self.copypasteField.value = ' ';
self.copypasteField.select();
self._app.trigger('clipboard_focus');
}
}, 0);
}
};
module.exports = Clipboard;

View File

@ -0,0 +1,19 @@
.g-code-panel {
position:absolute;
width: 100%;
height: 100%;
margin: 10px;
overflow: auto;
}
.g-code-viewer {
padding: 2rem 1rem;
font-family: monospace;
white-space: pre-wrap;
word-break: break-all;
word-wrap: break-word;
}
.g-code-viewer.hljs {
background-color: inherit;
}

View File

@ -0,0 +1,56 @@
var _ = require('underscore');
var ko = require('knockout');
var BackboneEvents = require('backbone').Events;
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
// Rather than require the whole of highlight.js, require just the core with the one language we
// need, to keep our bundle smaller and the build faster.
var hljs = require('highlight.js/lib/highlight');
hljs.registerLanguage('python', require('highlight.js/lib/languages/python'));
function CodeEditorPanel(gristDoc) {
this._gristDoc = gristDoc;
this._schema = ko.observable('');
this.listenTo(this._gristDoc, 'schemaUpdateAction', this.onSchemaAction);
this.onSchemaAction(); // Fetch the schema to initialize
}
dispose.makeDisposable(CodeEditorPanel);
_.extend(CodeEditorPanel.prototype, BackboneEvents);
CodeEditorPanel.prototype.buildDom = function() {
// The tabIndex enables the element to gain focus, and the .clipboard class prevents the
// Clipboard module from re-grabbing it. This is a quick fix for the issue where clipboard
// interferes with text selection. TODO it should be possible for the Clipboard to never
// interfere with text selection even for un-focusable elements.
return dom('div.g-code-panel.clipboard',
{tabIndex: "-1"},
kd.scope(this._schema, function(schema) {
// The reason to scope and rebuild instead of using `kd.text(schema)` is because
// hljs.highlightBlock(elem) replaces `elem` with a whole new dom tree.
return dom(
'code.g-code-viewer.python',
schema,
dom.hide,
dom.defer(function(elem) {
hljs.highlightBlock(elem);
dom.show(elem);
})
);
})
);
};
CodeEditorPanel.prototype.onSchemaAction = function(actions) {
return this._gristDoc.docComm.fetchTableSchema()
.then(schema => {
if (!this.isDisposed()) {
this._schema(schema);
}
});
};
module.exports = CodeEditorPanel;

View File

@ -0,0 +1,141 @@
/* Hide column menus by default */
.column_name .g-column-menu-btn {
visibility: hidden;
}
/* Make visible if open or in column header hover */
.g-column-menu-btn.open,
.g-column-menu-btn.active,
.column_name:hover .g-column-menu-btn {
visibility: visible;
}
.g-column-menu-btn.hide-on-inactive:not(.active) {
visibility: hidden;
}
.g-column-menu-btn > span.glyphicon {
padding: 1px;
margin-left: 2px;
margin-right: 2px;
background-color: #fff;
color: #999;
border: 1px solid #999;
border-radius: 3px;
font-size: 1rem;
}
.g-column-menu-btn.left-btn > span.glyphicon {
margin: 0 0 0 2px;
}
.g-column-menu-btn.right-btn > span.glyphicon {
margin: 0 2px 0 0;
}
.g-column-menu-btn:hover > span.glyphicon {
color: #333;
border: 1px solid #333;
}
.g-column-menu-btn.active > span.glyphicon {
color: #33f;
border-color: #33f;
}
.g-column-menu {
position: absolute;
min-width: 180px;
z-index: 10;
padding: 4px;
background-color: #fff;
border: 1px solid #9D8BB5;
box-shadow: 0px 0px 12px #666;
text-align: left;
}
.g-column-filter-remove {
float: right;
margin: 6px 0;
}
.g-column-filter-keyword {
width: 100px;
}
.g-column-filter-menu {
margin: 6px;
min-width: 250px;
}
.grist-filter-menu__link {
cursor: pointer;
}
.g-colfilter-values-scrolly {
position: relative;
height: 200px;
overflow: auto;
}
.g-colfilter-menu-item {
padding: 1px 8px;
line-height: 1.6rem;
cursor: default;
}
.g-colfilter-menu-label {
margin-left: 4px;
margin-right: 4px;
}
.g-glyphicon-tristate {
position: absolute;
top: 4px;
left: 3px;
width: 5px;
height: 5px;
background: #606060;
}
.badge-inv {
background-color: #ddd;
color: #666;
}
.arrow_box {
position: absolute;
background: #fff;
border: 1px solid transparent;
border-top-color: #9D8BB5;
top: -1px;
left: 12px;
}
.arrow_box:after, .arrow_box:before {
bottom: 100%;
left: 50%;
border: solid transparent;
content: " ";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
.arrow_box:after {
border-color: rgba(255, 255, 255, 0);
border-bottom-color: #fff;
border-width: 8px;
margin-left: -8px;
}
.arrow_box:before {
border-color: rgba(43, 57, 255, 0);
border-bottom-color: #9D8BB5;
border-width: 9px;
margin-left: -9px;
}

View File

@ -0,0 +1,183 @@
/**
* ColumnTransform is used as a abstract base class for any classes which must build a dom for the
* purpose of allowing the user to transform a column. It is currently extended by FormulaTransform
* and TypeTransform.
*/
import * as commands from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import {ViewFieldRec} from 'app/client/models/entities/ViewFieldRec';
import {TableData} from 'app/client/models/TableData';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {Disposable, Observable} from 'grainjs';
import * as ko from 'knockout';
import noop = require('lodash/noop');
// To simplify diff (avoid rearranging methods to satisfy private/public order).
// tslint:disable:member-ordering
type AceEditor = any;
/**
* Abstract class for FormulaTransform and TypeTransform to extend. Initializes properties needed
* for both types of transform. optPureType is useful for initializing type transforms.
*/
export class ColumnTransform extends Disposable {
protected field: ViewFieldRec;
protected origColumn: ColumnRec;
protected origDisplayCol: ColumnRec;
protected transformColumn: ColumnRec; // Set in prepare()
protected origWidgetOptions: unknown;
protected isCallPending: ko.Observable<boolean>;
protected editor: AceEditor|null = null; // Created when the dom is built by extending classes
protected formulaUpToDate = Observable.create(this, true);
protected _tableData: TableData;
// This is set to true in the interval between execute() and dispose().
private _isExecuting: boolean = false;
constructor(protected gristDoc: GristDoc, private _fieldBuilder: FieldBuilder) {
super();
this.field = _fieldBuilder.field;
this.origColumn = this.field.column();
this.origDisplayCol = this.field.displayColModel();
this.origWidgetOptions = this.field.widgetOptionsJson();
this.isCallPending = _fieldBuilder.isCallPending;
this._tableData = gristDoc.docData.getTable(this.origColumn.table().tableId())!;
this.autoDispose(commands.createGroup({
undo: this.cancel,
redo: noop
}, this, true));
this.onDispose(() => {
this._setTransforming(false);
this._fieldBuilder.columnTransform = null;
this.isCallPending(false);
});
}
/**
* Build dom function should be implemented by extending classes.
*/
public buildDom() {
throw new Error("Not Implemented");
}
public finalize() {
// Implemented in FormulaTransform.
}
/**
* Build general transform editor dom.
* @param {String} optInit - Optional initial value for the editor.
*/
protected buildEditorDom(optInit?: string) {
return this.editor.buildDom((aceObj: any) => {
this.editor.adjustContentToWidth();
this.editor.attachSaveCommand();
aceObj.on('change', () => {
if (this.editor) {
this.formulaUpToDate.set(this.editor.getValue() === this.transformColumn.formula());
}
});
aceObj.focus();
});
}
/**
* Helper called by contructor to prepare the column transform.
* @param {String} colType: A pure or complete type for the transformed column.
*/
public async prepare(colType?: string) {
colType = colType || this.origColumn.type.peek();
// Start bundling all actions during the transform, but include a verification callback to ensure
// no errant actions are added to the bundle.
this._tableData.docData.startBundlingActions(`Transformed column ${this.origColumn.colId()}.`,
action => (action[2] === "gristHelper_Transform" || action[1] === "_grist_Tables_column" ||
action[0] === "SetDisplayFormula" || action[1] === "_grist_Views_section_field"));
this.isCallPending(true);
try {
const newColRef = await this.addTransformColumn(colType);
// Set DocModel references
this.field.colRef(newColRef);
this.transformColumn = this.field.column();
this.transformColumn.origColRef(this.origColumn.getRowId());
this._setTransforming(true);
return await this.postAddTransformColumn();
} finally {
this.isCallPending(false);
}
}
/**
* Adds the tranform column and returns its colRef. May be overridden by derived classes to create
* differently-prepared transform columns.
* @param {String} colType: A pure or complete type for the transformed column.
*/
protected async addTransformColumn(colType: string): Promise<number> {
// Retrieve widget options on prepare (useful for type transforms)
const newColInfo = await this._tableData.sendTableAction(['AddColumn', "gristHelper_Transform", {
type: colType, isFormula: true, formula: this.getIdentityFormula(),
}]);
return newColInfo.colRef;
}
/**
* A derived class can override to do some processing after this.transformColumn has been set.
*/
protected postAddTransformColumn() {
// Nothing in base class.
}
public cancel() {
this.field.colRef(this.origColumn.getRowId());
this._tableData.sendTableAction(['RemoveColumn', this.transformColumn.colId()]);
// TODO: Cancelling a column transform should cancel all involved useractions.
this._tableData.docData.stopBundlingActions();
this.dispose();
}
// TODO: Values flicker during executing since transform column remains a formula as values are copied
// back to the original column. The CopyFromColumn useraction really ought to be "CopyAndRemove" since
// that seems the best way to avoid calculating the formula on wrong values.
protected async execute() {
if (this._isExecuting) {
return;
}
this._isExecuting = true;
// Define variables used in '.then' since this may be disposed
const transformColId = this.transformColumn.colId();
const field = this.field;
const fieldBuilder = this._fieldBuilder;
const origRef = this.origColumn.getRowId();
const tableData = this._tableData;
this.isCallPending(true);
try {
return await tableData.sendTableAction(['CopyFromColumn', transformColId, this.origColumn.colId(),
JSON.stringify(fieldBuilder.options())]);
} finally {
// Wait until the change completed to set column back, to avoid value flickering.
field.colRef(origRef);
tableData.sendTableAction(['RemoveColumn', transformColId]);
tableData.docData.stopBundlingActions();
this.dispose();
}
}
protected getIdentityFormula() {
return 'return $' + this.origColumn.colId();
}
protected _setTransforming(bool: boolean) {
this.origColumn.isTransforming(bool);
this.transformColumn.isTransforming(bool);
}
protected isExecuting(): boolean {
return this._isExecuting;
}
}

View File

@ -0,0 +1,524 @@
/**
* The Comm object in this module implements communication with the server. We
* communicate via request-response calls, and also receive async messages from
* the server.
*
* In this implementation, a single WebSocket is used for both purposes.
*
* Calls to the server:
* Call a method of the Comm object. The return value is a promise which will
* be fulfilled with the data object of the response, or rejected with
* an error object.
*
* Async messages from the server:
* Listen to Comm for events documented below.
*
*
* Implementation
* --------------
* Messages are serialized as follows. Note that this is a matter between the client's and the
* server's communication libraries, and code outside of them should not rely on these details.
* Requests: {
* reqId: Number,
* method: String,
* args: Array
* }
* Responses: {
* reqId: Number, // distinguishes responses from async messages
* error: String // if the request failed
* data: Object // if the request succeeded, may be undefined if nothing to return
* }
* Async messages from server: {
* type: String, // 'docListAction' or 'docUserAction' or 'clientConnect'
* docFD: Number, // For 'docUserAction', the file descriptor of the open document.
* data: Object // The message data.
* // other keys may exist depending on message type.
* }
*/
import {GristWSConnection} from 'app/client/components/GristWSConnection';
import * as dispose from 'app/client/lib/dispose';
import {UserAction} from 'app/common/DocActions';
import {DocListAPI, OpenLocalDocResult} from 'app/common/DocListAPI';
import {GristServerAPI} from 'app/common/GristServerAPI';
import {StringUnion} from 'app/common/StringUnion';
import {getInitialDocAssignment} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
// tslint:disable:no-console
/**
* Event for a change to the document list.
* These are sent to all connected clients, regardless of which documents they have open.
* TODO: implement and document.
* @event docListAction
*/
/**
* Event for a user action on a document, or part of one. Sent to all clients that have this
* document open.
* @event docUserAction
* @property {Number} docFD - The file descriptor of the open document, specific to each client.
* @property {Array} data.actionGroup - ActionGroup object containing user action, and doc actions.
* @property {Boolean} fromSelf - Flag to indicate whether the action originated from this client.
*/
/**
* Event for when a document is forcibly shutdown, and requires the client to re-open it.
* @event docShutdown
* @property {Number} docFD - The file descriptor of the open document, specific to each client.
*/
/**
* Event sent by server received when a client first connects.
* @event clientConnect
* @property {Number} clientId - The ID for the client, which may be reused if a client reconnects
* to reattach to its state on the server.
* @property {Number} missedMessages - Array of messages missed from the server.
* @property {Object} settings - Object containing server settings and features which
* should be used to initialize the client.
* @property {Object} profile - Object containing session profile information if the user
* is signed in, or null otherwise. See "clientLogin" message below for fields.
*/
/**
* Event sent by server to all clients in the session when the updated profile is retrieved.
* Does not necessarily contain all properties, may only include updated properties.
* Gets sent on login with all properties.
* @event profileFetch
* @property {String} email User email.
* @property {String} name User name,
* @property {String} imageUrl The url of the user's profile image.
*/
/**
* Event sent by server to all clients in the session when the user settings are updated.
* @event userSettings
* @property {Object} features - Object containing feature flags such as login, indicating
* which features are activated.
*/
/**
* Event sent by server to all clients in the session when a client logs out.
* @event clientLogout
*/
/**
* Event sent by server to all clients when an invite is received or for all invites received
* while away when a user logs in.
* @event receiveInvites
* @property {Number} data - An array of unread invites (see app/common/sharing).
*/
const ValidEvent = StringUnion('docListAction', 'docUserAction', 'docShutdown',
'clientConnect', 'clientLogout',
'profileFetch', 'userSettings', 'receiveInvites');
type ValidEvent = typeof ValidEvent.type;
/**
* A request that is currently being processed.
*/
export interface CommRequestInFlight {
resolve: (result: any) => void;
reject: (err: Error) => void;
// clientId is non-null for those requests which should not be re-sent on reconnect if
// the clientId has changed; it is null when it's safe to re-send.
clientId: string|null;
docId: string|null;
methodName: string;
requestMsg: string;
sent: boolean;
}
/**
* A request in the appropriate form for sending to the server.
*/
export interface CommRequest {
reqId: number;
method: string;
args: any[];
}
/**
* A regular, successful response from the server.
*/
export interface CommResponse {
reqId: number;
data: any;
error?: null; // TODO: keep until sure server never sets this on regular responses.
}
/**
* An exceptional response from the server when there is an error.
*/
export interface CommResponseError {
reqId: number;
error: string;
errorCode: string;
shouldFork?: boolean; // if set, the server suggests forking the document.
}
function isCommResponseError(msg: CommResponse | CommResponseError): msg is CommResponseError {
return Boolean(msg.error);
}
/**
* A message pushed from the server, not in response to a request.
*/
export interface CommMessage {
type: ValidEvent;
docFD: number;
data: any;
}
/**
* Comm object provides the interfaces to communicate with the server.
* Each method that calls to the server returns a promise for the response.
*/
export class Comm extends dispose.Disposable implements GristServerAPI, DocListAPI {
// methods defined by GristServerAPI
public logout = this._wrapMethod('logout');
public updateProfile = this._wrapMethod('updateProfile');
public getDocList = this._wrapMethod('getDocList');
public createNewDoc = this._wrapMethod('createNewDoc');
public importSampleDoc = this._wrapMethod('importSampleDoc');
public importDoc = this._wrapMethod('importDoc');
public deleteDoc = this._wrapMethod('deleteDoc');
// openDoc has special definition below
public renameDoc = this._wrapMethod('renameDoc');
public getConfig = this._wrapMethod('getConfig');
public updateConfig = this._wrapMethod('updateConfig');
public lookupEmail = this._wrapMethod('lookupEmail');
public getNewInvites = this._wrapMethod('getNewInvites');
public getLocalInvites = this._wrapMethod('getLocalInvites');
public ignoreLocalInvite = this._wrapMethod('ignoreLocalInvite');
public downloadSharedDoc = this._wrapMethod('downloadSharedDoc');
public showItemInFolder = this._wrapMethod('showItemInFolder');
public getBasketTables = this._wrapMethod('getBasketTables');
public embedTable = this._wrapMethod('embedTable');
public reloadPlugins = this._wrapMethod('reloadPlugins');
public pendingRequests: Map<number, CommRequestInFlight>;
public nextRequestNumber: number = 0;
// This is a map from docId to the connection for the server that manages
// that docId. In classic Grist, which doesn't have fixed docIds or multiple
// servers, the key is always "null".
private _connections: Map<string|null, GristWSConnection> = new Map();
private _collectedUserActions: UserAction[] | null;
private _singleWorkerMode: boolean = getInitialDocAssignment() === null; // is this classic Grist?
private listenTo: BackboneEvents["listenTo"]; // set by Backbone
private trigger: BackboneEvents["trigger"]; // set by Backbone
private stopListening: BackboneEvents["stopListening"]; // set by Backbone
public create() {
this.autoDisposeCallback(() => {
for (const connection of this._connections.values()) { connection.dispose(); }
this._connections.clear();
});
this.pendingRequests = new Map();
this.nextRequestNumber = 0;
// If collecting is turned on (by tests), this will be a list of UserActions sent to the server.
this._collectedUserActions = null;
}
/**
* Initialize a connection. For classic Grist, with a single server
* and mutable document identifiers, we will only ever have one
* connection, shared for all uses. For hosted Grist, with
* permanent docIds which map to potentially distinct servers, we
* have one connection per document.
*
* For classic grist, the docId passed here has no effect, and can
* be null. For hosted Grist, if the docId is null, the id will be
* read from the configuration object sent by the server. This
* allows the Comm object to be initialized at the same stage as
* it has been classically, eliminating a source of changes in timing
* that could effect old tests.
*/
public initialize(docId: string|null): GristWSConnection {
docId = docId || getInitialDocAssignment();
let connection = this._connections.get(docId);
if (connection) { return connection; }
connection = GristWSConnection.create(null);
this._connections.set(docId, connection);
this.listenTo(connection, 'serverMessage', this._onServerMessage.bind(this, docId));
this.listenTo(connection, 'connectionStatus', (message: any, status: any) => {
this.trigger('connectionStatus', message, status);
});
this.listenTo(connection, 'connectState', () => {
const isConnected = [...this._connections.values()].some(c => c.established);
this.trigger('connectState', isConnected);
});
connection.initialize(docId);
return connection;
}
// Returns a map of docId -> docWorkerUrl for existing connections, for testing.
public listConnections(): Map<string|null, string|null> {
return new Map(Array.from(this._connections, ([docId, conn]) => [docId, conn.getDocWorkerUrlOrNull()]));
}
/**
* The openDoc method is special, in that it is the first point at which
* we commit to a particular document. It is also the only method not
* committed to a document that is called in hosted Grist - all other methods
* are called via DocComm.
*/
public async openDoc(docName: string, mode?: string): Promise<OpenLocalDocResult> {
return this._makeRequest(null, docName, 'openDoc', docName, mode);
}
/**
* Ensure we have a connection to a docWorker serving docId, and mark it as in use by
* incrementing its useCount. This connection will not be disposed until a corresponding
* releaseDocConnection() is called.
*/
public useDocConnection(docId: string): GristWSConnection {
const connection = this._connection(docId);
connection.useCount += 1;
console.log(`Comm.useDocConnection(${docId}): useCount now ${connection.useCount}`);
return connection;
}
/**
* Remove a connection associated with a particular document. In classic grist, we skip removal,
* since all docs use the same server.
* This should be called in pair with a preceding useDocConnection() call. It decrements the
* connection's useCount, and disposes it when it's no longer in use.
*/
public releaseDocConnection(docId: string): void {
const connection = this._connections.get(docId);
if (connection) {
connection.useCount -= 1;
console.log(`Comm.releaseDocConnection(${docId}): useCount now ${connection.useCount}`);
// Dispose the connection if it is no longer in use (except in "classic grist").
if (!this._singleWorkerMode && connection.useCount <= 0) {
this.stopListening(connection);
connection.dispose();
this._connections.delete(docId);
this._rejectRequests(docId);
}
}
}
/**
* Starts or stops the collection of UserActions.
*/
public userActionsCollect(optYesNo?: boolean): void {
this._collectedUserActions = optYesNo === false ? null : [];
}
/**
* Returns all UserActions collected since collection started or since previous call.
*/
public userActionsFetchAndReset(): UserAction[] {
return this._collectedUserActions ? this._collectedUserActions.splice(0) : [];
}
/**
* Add UserActions to a list, for use in tests. Called by DocComm.
*/
public addUserActions(actions: UserAction[]) {
// Note: collecting user-actions for testing is in Comm mainly for historical reasons.
if (this._collectedUserActions) {
this._collectedUserActions.push(...actions);
}
}
/**
* Returns a url to the worker serving the specified document.
*/
public getDocWorkerUrl(docId: string|null): string {
return this._connection(docId).docWorkerUrl;
}
/**
* Returns true if there is one or more request that has not been fully processed.
*/
public hasActiveRequests(): boolean {
return this.pendingRequests.size !== 0;
}
/**
* Wait for all active requests to complete.
*/
public async waitForActiveRequests(): Promise<void> {
await Promise.all(this.pendingRequests.values());
}
/**
* Internal implementation of all the server methods. They differ only in the name of the server
* method to call, and the arguments that it expects.
*
* This is made public for DocComm's use. Regular code should not call _makeRequest directly.
*
* @param {String} clientId - If non-null, we ensure that it matches the current clientId,
* rejecting the call otherwise. It should be bound to the session's clientId for
* session-specific calls, so that we can't send requests to the wrong session. See openDoc().
* @param {String} methodName - The name of the server method to call.
* @param {...} varArgs - Other method-specific arguments to send to the server.
* @returns {Promise} Promise for the response. The server may fulfill or reject it, or it may be
* rejected in case of a disconnect.
*/
public async _makeRequest(clientId: string|null, docId: string|null,
methodName: string, ...args: any[]): Promise<any> {
const connection = this._connection(docId);
if (clientId !== null && clientId !== connection.clientId) {
console.log("Comm: Rejecting " + methodName + " for outdated clientId %s (current %s)",
clientId, connection.clientId);
return Promise.reject(new Error('Comm: outdated session'));
}
const request: CommRequest = {
reqId: this.nextRequestNumber++,
method: methodName,
args
};
console.log("Comm request #" + request.reqId + " " + methodName, request.args);
return new Promise((resolve, reject) => {
const requestMsg = JSON.stringify(request);
const sent = connection.send(requestMsg);
this.pendingRequests.set(request.reqId, {
resolve,
reject,
clientId,
docId,
methodName,
requestMsg,
sent
});
});
}
/**
* Create a connection to the specified document, or return an already open connection
* that that document. For a docId of null, any open connection will be returned, and
* an error is thrown if no connection is already open.
*/
private _connection(docId: string|null): GristWSConnection {
// for classic Grist, "docIds" are untrustworthy doc names, but on the plus side
// we only need one connections - so just replace docId with a constant.
if (this._singleWorkerMode) { docId = null; }
if (docId === null) {
if (this._connections.size > 0) {
return this._connections.values().next().value;
}
throw new Error('no connection available');
}
const connection = this._connections.get(docId);
if (!connection) {
return this.initialize(docId);
}
return connection;
}
/**
* If GristWSConnection for a docId is disposed, requests that were sent to that doc will never
* resolve. Reject them instead here.
*/
private _rejectRequests(docId: string|null) {
const error = "GristWSConnection disposed";
for (const [reqId, req] of this.pendingRequests) {
if (reqMatchesConnection(req.docId, docId)) {
console.log(`Comm: Rejecting req #${reqId} ${req.methodName}: ${error}`);
this.pendingRequests.delete(reqId);
req.reject(new Error('Comm: ' + error));
}
}
}
/**
*
* This module automatically logs any errors to the console, so callers an provide an empty
* error-handling function if logging is all they need on error.
*
* We should watch timeouts, and log something when there is no response for a while.
* There is probably no need for callers to deal with timeouts.
*/
private _onServerMessage(docId: string|null,
message: CommResponse | CommResponseError | CommMessage) {
if ('reqId' in message) {
const reqId = message.reqId;
const r = this.pendingRequests.get(reqId);
if (r) {
this.pendingRequests.delete(reqId);
if ('errorCode' in message && message.errorCode === 'AUTH_NO_VIEW') {
// We should only arrive here if the user had view access, and then lost it.
// We should not let the user see the document any more. Let's reload the
// page, reducing this to the problem of arriving at a document the user
// doesn't have access to, which is already handled.
console.log(`Comm response #${reqId} ${r.methodName} issued AUTH_NO_VIEW - closing`);
window.location.reload();
}
if (isCommResponseError(message)) {
const err: any = new Error(message.error);
let code = '';
if (message.errorCode) {
code = ` [${message.errorCode}]`;
err.code = message.errorCode;
}
err.shouldFork = message.shouldFork;
console.log(`Comm response #${reqId} ${r.methodName} ERROR:${code} ${message.error}`
+ (message.shouldFork ? ` (should fork)` : ''));
r.reject(err);
} else {
console.log(`Comm response #${reqId} ${r.methodName} OK`);
r.resolve(message.data);
}
} else {
console.log("Comm: Response to unknown reqId " + reqId);
}
} else {
if (message.type === 'clientConnect') {
// Reject or re-send any pending requests as appropriate in the order in which they were
// added to the pendingRequests map.
for (const [id, req] of this.pendingRequests) {
if (reqMatchesConnection(req.docId, docId)) {
this._resendPendingRequest(id, req);
}
}
}
// Another asynchronous message that's not a response. Broadcast it as an event.
if (ValidEvent.guard(message.type)) {
console.log("Comm: Triggering event " + message.type);
this.trigger(message.type, message);
} else {
console.log("Comm: Server message of unknown type " + message.type);
}
}
}
private _resendPendingRequest(reqId: number, r: CommRequestInFlight) {
let error = null;
const connection = this._connection(r.docId);
if (r.sent) {
// If we sent a request, and reconnected before getting a response, we don't know what
// happened. The safer choice is to reject the request.
error = "interrupted by reconnect";
} else if (r.clientId !== null && r.clientId !== connection.clientId) {
// If we are waiting to send this request for a particular clientId, but clientId changed.
error = "pending with outdated clientId";
} else {
// Waiting to send the request, and clientId is fine: go ahead and send it.
r.sent = connection.send(r.requestMsg);
}
if (error) {
console.log("Comm: Rejecting req #" + reqId + " " + r.methodName + ": " + error);
this.pendingRequests.delete(reqId);
r.reject(new Error('Comm: ' + error));
}
}
private _wrapMethod<Name extends keyof GristServerAPI>(name: Name): GristServerAPI[Name] {
return this._makeRequest.bind(this, null, null, name);
}
}
Object.assign(Comm.prototype, BackboneEvents);
function reqMatchesConnection(reqDocId: string|null, connDocId: string|null) {
return reqDocId === connDocId || !reqDocId || !connDocId;
}

View File

@ -0,0 +1,30 @@
// Grist client libs
import * as ModalDialog from 'app/client/components/ModalDialog';
import * as dom from 'app/client/lib/dom';
import * as kd from 'app/client/lib/koDom';
import * as kf from 'app/client/lib/koForm';
export function showConfirmDialog(title: string, btnText: string, onConfirm: () => Promise<void>,
explanation?: Element|string): void {
const body = dom('div.confirm',
explanation ? kf.row(explanation, kd.style('margin-bottom', '2rem')) : null,
kf.row(
1, kf.buttonGroup(
kf.button(() => dialog.hide(), 'Cancel')
),
1, kf.buttonGroup(
kf.accentButton(async () => {
await onConfirm();
dialog.hide();
}, btnText)
)
)
);
const dialog = ModalDialog.create({
title,
body,
width: '300px',
show: true
});
dialog.once('close', () => dialog.dispose());
}

View File

@ -0,0 +1,42 @@
var ValueFormatter = require('app/common/ValueFormatter');
/**
* The CopySelection class is an abstraction for a subset of currently selected cells.
* @param {Array} rowIds - row ids of the rows selected
* @param {Array} fields - MetaRowModels of the selected view fields
* @param {Object} options.rowStyle - an object that maps rowId to an object containing
* style options. i.e. { 1: { height: 20px } }
* @param {Object} options.colStyle - an object that maps colId to an object containing
* style options.
*/
function CopySelection(tableData, rowIds, fields, options) {
this.fields = fields;
this.rowIds = rowIds || [];
this.colIds = fields.map(f => f.colId());
this.displayColIds = fields.map(f => f.displayColModel().colId());
this.rowStyle = options.rowStyle;
this.colStyle = options.colStyle;
this.columns = fields.map((f, i) => {
let formatter = ValueFormatter.createFormatter(
f.displayColModel().type(), f.widgetOptionsJson());
let _fmtGetter = tableData.getRowPropFunc(this.displayColIds[i]);
let _rawGetter = tableData.getRowPropFunc(this.colIds[i]);
return {
colId: this.colIds[i],
fmtGetter: rowId => formatter.formatAny(_fmtGetter(rowId)),
rawGetter: rowId => _rawGetter(rowId)
};
});
}
CopySelection.prototype.isCellSelected = function(rowId, colId) {
return this.rowIds.includes(rowId) && this.colIds.includes(colId);
};
CopySelection.prototype.onlyAddRowSelected = function() {
return this.rowIds.length === 1 && this.rowIds[0] === "new";
};
module.exports = CopySelection;

View File

@ -0,0 +1,133 @@
/**
* The Cursor module contains functionality related to the cell with the cursor, i.e. a single
* currently selected cell.
*/
import * as BaseView from 'app/client/components/BaseView';
import * as commands from 'app/client/components/commands';
import * as BaseRowModel from 'app/client/models/BaseRowModel';
import {LazyArrayModel} from 'app/client/models/DataTableModel';
import {Disposable} from 'grainjs';
import * as ko from 'knockout';
export interface CursorPos {
rowId?: number;
rowIndex?: number;
fieldIndex?: number;
sectionId?: number;
}
function nullAsUndefined(value: number|null|undefined): number|undefined {
return value == null ? undefined : value;
}
/**
* Cursor represents the location of the cursor in the viewsection. It is maintained by BaseView,
* and implements the shared functionality related to the cursor cell.
* @param {BaseView} baseView: The BaseView object to which this Cursor belongs.
* @param {Object} optCursorPos: Optional object containing rowId and fieldIndex properties
* to which the cursor should be initialized.
*/
export class Cursor extends Disposable {
/**
* The commands closely tied to the cursor. They are active when the BaseView containing this
* Cursor has focus. Some may need to be overridden by particular views.
*/
public static editorCommands = {
// The cursor up/down commands may need to be a bit different in non-grid views.
cursorUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 1); },
cursorDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 1); },
cursorLeft(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },
cursorRight(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },
skipUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 5); },
skipDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 5); },
pageUp(this: Cursor) { this.rowIndex(this.rowIndex()! - 20); }, // TODO Not really pageUp
pageDown(this: Cursor) { this.rowIndex(this.rowIndex()! + 20); }, // TODO Not really pageDown
prevField(this: Cursor) { this.fieldIndex(this.fieldIndex() - 1); },
nextField(this: Cursor) { this.fieldIndex(this.fieldIndex() + 1); },
moveToFirstRecord(this: Cursor) { this.rowIndex(0); },
moveToLastRecord(this: Cursor) { this.rowIndex(Infinity); },
moveToFirstField(this: Cursor) { this.fieldIndex(0); },
moveToLastField(this: Cursor) { this.fieldIndex(Infinity); },
// Command to be manually triggered on cell selection. Moves the cursor to the selected cell.
// This is overridden by the formula editor to insert "$col" variables when clicking cells.
setCursor(this: Cursor, rowModel: BaseRowModel, colModel: BaseRowModel) {
this.rowIndex(rowModel ? rowModel._index() : 0);
this.fieldIndex(colModel ? colModel._index()! : 0);
},
};
public viewData: LazyArrayModel<BaseRowModel>;
public rowIndex: ko.Computed<number|null>; // May be null when there are no rows.
public fieldIndex: ko.Observable<number>;
private _rowId: ko.Observable<number|null>; // May be null when there are no rows.
// The cursor's _rowId property is always fixed across data changes. When isLive is true,
// the rowIndex of the cursor is recalculated to match _rowId. When false, they will
// be out of sync.
private _isLive: ko.Observable<boolean> = ko.observable(true);
constructor(baseView: BaseView, optCursorPos?: CursorPos) {
super();
optCursorPos = optCursorPos || {};
this.viewData = baseView.viewData;
this._rowId = ko.observable(optCursorPos.rowId || 0);
this.rowIndex = this.autoDispose(ko.computed({
read: () => {
if (!this._isLive()) { return this.rowIndex.peek(); }
const rowId = this._rowId();
return rowId == null ? null : this.viewData.clampIndex(this.viewData.getRowIndexWithSub(rowId));
},
write: (index) => {
const rowIndex = this.viewData.clampIndex(index!);
this._rowId(rowIndex == null ? null : this.viewData.getRowId(rowIndex));
},
}));
this.fieldIndex = baseView.viewSection.viewFields().makeLiveIndex(optCursorPos.fieldIndex || 0);
this.autoDispose(commands.createGroup(Cursor.editorCommands, this, baseView.viewSection.hasFocus));
// Update the section's activeRowId when the cursor's rowId changes.
this.autoDispose(this._rowId.subscribe((rowId) => baseView.viewSection.activeRowId(rowId)));
// On dispose, save the current cursor position to the section model.
this.onDispose(() => { baseView.viewSection.lastCursorPos = this.getCursorPos(); });
}
// Returns the cursor position with rowId, rowIndex, and fieldIndex.
public getCursorPos(): CursorPos {
return {
rowId: nullAsUndefined(this._rowId()),
rowIndex: nullAsUndefined(this.rowIndex()),
fieldIndex: this.fieldIndex()
};
}
/**
* Moves the cursor to the given position. Only moves the row if rowId or rowIndex is valid,
* preferring rowId.
* @param cursorPos: Position as { rowId?, rowIndex?, fieldIndex? }, as from getCursorPos().
*/
public setCursorPos(cursorPos: CursorPos): void {
if (cursorPos.rowId !== undefined && this.viewData.getRowIndex(cursorPos.rowId) >= 0) {
this._rowId(cursorPos.rowId);
} else if (cursorPos.rowIndex !== undefined && cursorPos.rowIndex >= 0) {
this.rowIndex(cursorPos.rowIndex);
} else {
// Write rowIndex to itself to force an update of rowId if needed.
this.rowIndex(this.rowIndex.peek());
}
if (cursorPos.fieldIndex !== undefined) {
this.fieldIndex(cursorPos.fieldIndex);
}
}
public setLive(isLive: boolean): void {
this._isLive(isLive);
}
}

View File

@ -0,0 +1,9 @@
iframe.custom_view {
border: none;
height: 100%;
}
.custom_view_notification {
padding: 15px;
margin: 15px;
}

View File

@ -0,0 +1,300 @@
import * as BaseView from 'app/client/components/BaseView';
import {Cursor} from 'app/client/components/Cursor';
import { GristDoc } from 'app/client/components/GristDoc';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import { CustomSectionElement, ViewProcess } from 'app/client/lib/CustomSectionElement';
import { Disposable } from 'app/client/lib/dispose';
import * as dom from 'app/client/lib/dom';
import * as kd from 'app/client/lib/koDom';
import * as DataTableModel from 'app/client/models/DataTableModel';
import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel';
import { CustomViewSectionDef } from 'app/client/models/entities/ViewSectionRec';
import {SortedRowSet} from 'app/client/models/rowset';
import { BulkColValues, RowRecord } from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import { PluginInstance } from 'app/common/PluginInstance';
import {GristView} from 'app/plugin/GristAPI';
import {Events as BackboneEvents} from 'backbone';
import {MsgType, Rpc} from 'grain-rpc';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
import defaults = require('lodash/defaults');
import noop = require('lodash/noop');
const G = getBrowserGlobals('window');
/**
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
* the content is hosted by a third-party (for instance a github page), as opposed to the "plugin"
* mode where the contents is provided by a plugin. In both cases the content is rendered safely
* within an iframe (or webview if running electron). Configuration of the component is done within
* the view config tab in the side pane. In "plugin" mode, shows notification if either the plugin
* of the section could not be found.
*/
export class CustomView extends Disposable {
/**
* The HTMLElement embedding the content.
*/
public viewPane: HTMLElement;
// viewSection, sortedRows, tableModel, gristDoc, and cursor are inherited from BaseView
protected viewSection: ViewSectionRec;
protected sortedRows: SortedRowSet;
protected tableModel: DataTableModel;
protected gristDoc: GristDoc;
protected cursor: Cursor;
private _customDef: CustomViewSectionDef;
// state of the component
private _foundPlugin: ko.Observable<boolean>;
private _foundSection: ko.Observable<boolean>;
// Note the invariant: this._customSection != undefined if this._foundSection() == true
private _customSection: ViewProcess|undefined;
private _pluginInstance: PluginInstance|undefined;
private _updateData: () => void; // debounced call to let the view know linked data changed.
private _updateCursor: () => void; // debounced call to let the view know linked cursor changed.
private _rpc: Rpc; // rpc connection to view.
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
BaseView.call(this as any, gristDoc, viewSectionModel);
this._customDef = this.viewSection.customDef;
this.autoDisposeCallback(() => {
if (this._customSection) {
this._customSection.dispose();
}
});
this._foundPlugin = ko.observable(false);
this._foundSection = ko.observable(false);
// Ensure that selecting another section in same plugin update the view.
this._foundSection.extend({notify: 'always'});
this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this));
this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this));
this.viewPane = this.autoDispose(this._buildDom());
this._updatePluginInstance();
this._updateData = debounce(() => this._updateView(true), 0);
this._updateCursor = debounce(() => this._updateView(false), 0);
this.autoDispose(this.viewSection.viewFields().subscribe(this._updateData));
this.listenTo(this.sortedRows, 'rowNotify', this._updateData);
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._updateData));
this.autoDispose(this.cursor.rowIndex.subscribe(this._updateCursor));
}
private _updateView(dataChange: boolean) {
if (this.isDisposed()) { return; }
if (this._rpc) {
const state = {
tableId: this.viewSection.table().tableId(),
rowId: this.cursor.getCursorPos().rowId || undefined,
dataChange
};
// tslint:disable-next-line:no-console
this._rpc.postMessage(state).catch(e => console.error('Failed to send view state', e));
// This post message won't get through if doc access has not been granted to the view.
}
}
/**
* Find a plugin instance that matchs the plugin id, update the `found` observables, then tries to
* find a matching section.
*/
private _updatePluginInstance() {
const pluginId = this._customDef.pluginId();
this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId);
if (this._pluginInstance) {
this._foundPlugin(true);
} else {
this._foundPlugin(false);
this._foundSection(false);
}
this._updateCustomSection();
}
/**
* If a plugin was found, find a custom section matching the section id and update the `found`
* observables.
*/
private _updateCustomSection() {
if (!this._pluginInstance) { return; }
const sectionId = this._customDef.sectionId();
this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId);
if (this._customSection) {
const el = this._customSection.element;
el.classList.add("flexitem");
this._foundSection(true);
} else {
this._foundSection(false);
}
}
/**
* Access data backing the section as a table. This code is borrowed
* with variations from ChartView.ts.
*/
private _getSelectedTable(): BulkColValues {
const fields: ViewFieldRec[] = this.viewSection.viewFields().all();
const rowIds: number[] = this.sortedRows.getKoArray().peek() as number[];
const data: BulkColValues = {};
for (const field of fields) {
// Use the colId of the displayCol, which may be different in case of Reference columns.
const colId: string = field.displayColModel.peek().colId.peek();
const getter = this.tableModel.tableData.getRowPropFunc(colId)!;
const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
data[field.column().colId()] = rowIds.map((r) => reencodeAsAny(getter(r)!, typeInfo));
}
data.id = rowIds;
return data;
}
private _getSelectedRecord(rowId: number): RowRecord {
// Prepare an object containing the fields available to the view
// for the specified row. A RECORD()-generated rendering would be
// more useful. but the data engine needs to know what information
// the custom view depends on, so we shouldn't volunteer any untracked
// information here.
const fields: ViewFieldRec[] = this.viewSection.viewFields().all();
const data: RowRecord = {id: rowId};
for (const field of fields) {
const colId: string = field.displayColModel.peek().colId.peek();
const typeInfo = extractInfoFromColType(field.column.peek().type.peek());
data[field.column().colId()] = reencodeAsAny(this.tableModel.tableData.getValue(rowId, colId)!, typeInfo);
}
return data;
}
private _buildDom() {
const {mode, url, access} = this._customDef;
const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin");
// When both plugin and section are not found, let's show only plugin notification.
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
const showSectionNotification = ko.pureComputed(() => showPlugin() && this._foundPlugin() && !this._foundSection());
const showPluginContent = ko.pureComputed(() => showPlugin() && this._foundSection())
// For the view to update when switching from one section to another one, the computed
// observable must always notify.
.extend({notify: 'always'});
return dom('div.flexauto.flexvbox.custom_view_container',
dom.autoDispose(showPlugin),
dom.autoDispose(showPluginNotification),
dom.autoDispose(showSectionNotification),
dom.autoDispose(showPluginContent),
// todo: should display content in webview when running electron
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
_mode === "url" ? this._buildIFrame(_url, _access) : null),
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
dom('strong', kd.text(this._customDef.pluginId)), ' was not found',
dom.testId('customView_notification_plugin')
)),
kd.maybe(showSectionNotification, () => buildNotification('Section ',
dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ',
dom('strong', kd.text(this._customDef.pluginId)),
dom.testId('customView_notification_section')
)),
// When showPluginContent() is true then _foundSection() is also and _customSection is not
// undefined (invariant).
kd.maybe(showPluginContent, () => this._customSection!.element)
);
}
private _buildIFrame(baseUrl: string, access: string) {
// This is a url-flavored custom view.
// Here we create an iframe, and add hooks for sending
// messages to it and receiving messages from it.
// Compute a url for the view. We add in a parameter called "access"
// so the page can determine what access level has been granted to it
// in a simple and unambiguous way.
let fullUrl: string;
if (!baseUrl) {
fullUrl = baseUrl;
} else {
const url = new URL(baseUrl);
url.searchParams.append('access', access);
fullUrl = url.href;
}
if (!access) { access = 'none'; }
const someAccess = (access !== 'none');
const fullAccess = (access === 'full');
// Create an Rpc object to manage messaging. If full access is granted,
// allow forwarding to the back-end; otherwise restrict to APIs explicitly
// made available here.
const rpc = fullAccess ? this.gristDoc.docPluginManager.makeAnonForwarder() :
new Rpc({});
// Now, we create a listener for message events (if access was granted), making sure
// to respond only to messages from our iframe.
const listener = someAccess ? (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
rpc.receiveMessage(event.data);
if (event.data.mtype === MsgType.Ready) {
// After, the "ready" message, send a notification with cursor
// (if available).
this._updateView(true);
}
}
} : null;
// Add the listener only if some access has been granted.
if (listener) { G.window.addEventListener('message', listener); }
// Here is the actual iframe.
const iframe = dom('iframe.custom_view.clipboard_focus',
{src: fullUrl},
dom.onDispose(() => {
if (listener) { G.window.removeEventListener('message', listener); }
}));
if (someAccess) {
// When replies come back, forward them to the iframe if access
// is granted.
rpc.setSendMessage(msg => {
iframe.contentWindow!.postMessage(msg, '*');
});
// Register a way for the view to access the data backing the view.
rpc.registerImpl<GristView>('GristView', {
fetchSelectedTable: () => this._getSelectedTable(),
fetchSelectedRecord: (rowId: number) => this._getSelectedRecord(rowId),
});
} else {
// Direct messages to /dev/null otherwise. Important to setSendMessage
// or they will be queued indefinitely.
rpc.setSendMessage(noop);
}
// We send events via the rpc object when the data backing the view changes
// or the cursor changes.
if (this._rpc) {
// There's an existing RPC object we are replacing.
// Unregister anything that may have been registered previously.
// TODO: add a way to clean up more systematically to grain-rpc.
this._rpc.unregisterForwarder('*');
this._rpc.unregisterImpl('GristView');
}
this._rpc = rpc;
return iframe;
}
private listenTo(...args: any[]): void { /* replaced by Backbone */ }
}
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
defaults(CustomView.prototype, BaseView.prototype);
Object.assign(CustomView.prototype, BackboneEvents);
// helper to build the notification's frame.
function buildNotification(...args: any[]) {
return dom('div.custom_view_notification.bg-warning', dom('p', ...args));
}

View File

@ -0,0 +1,270 @@
.detail_menu_bottom {
border-top: 1px solid lightgrey;
}
/* applies to the record detail container */
.record-layout-editor {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: white;
z-index: 1;
margin-top: -3px;
}
.g_record_detail_inner > .layout_root {
height: auto;
}
/* applies to all record details */
.g_record_detail_el {
position: relative;
margin: 0.5rem;
padding: .5rem;
}
.g_record_detail_label {
min-height: 1rem;
color: #666;
font-size: 1rem;
font-weight: bold;
}
.g_record_detail_value {
position: relative;
min-height: 16px;
white-space: pre;
word-wrap: break-word;
}
.g_record_detail_value.record-add {
background-color: #f6f6ff;
}
.g_record_detail_value.scissors {
outline: 2px dashed var(--grist-color-cursor);
}
.detail_row_num {
text-align: right;
font-size: var(--grist-x-small-font-size);
font-weight: normal;
color: var(--grist-color-slate);
padding: 8px;
}
.detail_row_num::before {
content: "ROW ";
}
.detail-left.disabled, .detail-right.disabled, .detail-add-btn.disabled {
cursor: default !important;
}
.detail-add-grp {
margin-left: 0.5rem;
}
/*** card view (multiple records) ***/
.detailview_scroll_pane {
position: relative;
overflow-y: auto;
overflow-x: hidden;
/* allow 3px to the left to be visible, for highlighting active record */
padding-left: 3px;
margin-left: -3px;
}
.detailview_record_detail.active {
/* highlight active record in Card List by overlaying the active-section highlight */
margin-left: -3px;
border-left: 3px solid var(--grist-color-light-green);
}
/*** single record ***/
.detailview_single {
overflow: auto;
}
.grist-single-record__menu {
padding: .2rem .5rem .2rem 0;
align-items: center;
flex-shrink: 0;
}
.grist-single-record__menu.newui {
padding: 0;
margin-top: -4px;
}
.grist-single-record__menu__count {
white-space: nowrap;
text-align: right;
padding-right: 1rem;
}
.detailview_record_single > .detail_row_num {
display: none;
}
/*** detailed record "themes" ***/
/*** label-under theme ***/
/* TODO Deprecated. Probably best to keep styles for the sake of older docs that might specify
* this theme, but in practice it's unlikely any docs use it.
*/
.detail_theme_field_under {
display: flex;
display: -webkit-flex;
flex-direction: column-reverse;
-webkit-flex-direction: column-reverse;
}
.detail_theme_field_under > .g_record_detail_label {
border-top: 1px solid #333;
}
.detail_theme_record_under {
border-top: 1px solid #ccc;
padding: 0 1rem 1rem 0;
border-left: 2px solid white;
}
.detail_theme_record_under:first-child {
border-top: none;
}
/*** compact theme ***/
.detail_theme_record_compact {
/* 12px is enough margin on the right to include most of the floating scrollbar on MacOS */
padding: 4px 16px 0px 16px;
background-color: var(--grist-color-medium-grey);
}
.detail_theme_record_compact.detailview_record_single {
padding: 8px;
}
.detail_theme_record_compact > .detail_row_num {
padding: 0px;
}
.detail_theme_record_compact > .g_record_detail_inner {
background-color: white;
position: relative;
}
.detail_theme_record_compact > .g_record_detail_inner > .layout_root {
border: 1px solid var(--grist-color-dark-grey);
border-right: none;
border-bottom: none;
}
.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner {
height: 100%;
}
.detail_theme_record_compact.detailview_record_single > .g_record_detail_inner > .layout_root {
height: 100%;
}
.detail_theme_field_compact {
border-top: none;
border-left: none;
border-right: 1px solid var(--grist-color-dark-grey);
border-bottom: 1px solid var(--grist-color-dark-grey);
padding: 1px 1px 1px 5px;
margin: 0;
line-height: 1.2;
}
.detail_theme_field_compact > .g_record_detail_label {
font-weight: normal;
font-size: var(--grist-small-font-size);
color: var(--grist-color-slate);
min-height: 0px;
white-space: nowrap;
overflow: hidden;
margin-left: 3px; /* to align with the .field_clip content */
margin-right: -1px; /* allow labels to overflow into the padding */
}
/*** form theme ***/
.detail_theme_field_form {
padding: 1px 1px 1px 5px;
}
.detail_theme_field_form > .g_record_detail_label {
font-size: var(--grist-small-font-size);
color: var(--grist-color-slate);
font-weight: bold;
min-height: 0px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-right: -8px;
}
/* TODO want to style better the values themselves (e.g. more padding, rounded corners, move label
* inside value box for compact view for better cursor looks, etc), but first the cell editor
* needs to learn to match the value box's style. Right now, the cell editor style is hard-coded.
*/
.detail_theme_field_form > .g_record_detail_value {
border: 1px solid lightgrey;
}
.detail_theme_record_form {
padding: 0px 12px 0px 8px;
}
.detail_theme_record_form.detailview_record_single {
padding-top: 8px;
}
.detail_theme_record_form.detailview_record_detail {
border-bottom: 1px solid var(--grist-color-dark-grey);
padding-bottom: 12px;
}
/*** blocks theme ***/
.detail_theme_record_blocks {
padding: 0px 12px 0px 8px;
}
.detail_theme_record_blocks > .detail_row_num {
padding-bottom: 0px;
}
.detail_theme_record_blocks.detailview_record_single {
padding: 8px;
}
.detail_theme_record_blocks.detailview_record_detail {
border-bottom: 1px solid var(--grist-color-dark-grey);
padding-bottom: 8px;
}
.detail_theme_field_blocks {
padding: 6px;
margin: 8px;
background-color: var(--grist-color-medium-grey);
border-radius: 2px;
}
.detail_theme_field_blocks > .g_record_detail_label {
font-size: var(--grist-small-font-size);
color: var(--grist-color-slate);
font-weight: normal;
white-space: nowrap;
overflow: hidden;
margin-left: 3px; /* to align with the .field_clip content */
margin-right: -6px; /* allow labels to overflow into the padding */
margin-bottom: 4px;
}

View File

@ -0,0 +1,394 @@
var _ = require('underscore');
var ko = require('knockout');
var dom = require('app/client/lib/dom');
var kd = require('app/client/lib/koDom');
var koDomScrolly = require('app/client/lib/koDomScrolly');
require('app/client/lib/koUtil'); // Needed for subscribeInit.
var Base = require('./Base');
var BaseView = require('./BaseView');
var CopySelection = require('./CopySelection');
var RecordLayout = require('./RecordLayout');
var commands = require('./commands');
/**
* DetailView component implements a list of record layouts.
*/
function DetailView(gristDoc, viewSectionModel) {
BaseView.call(this, gristDoc, viewSectionModel, { 'addNewRow': true });
this.viewFields = gristDoc.docModel.viewFields;
this._isSingle = (this.viewSection.parentKey.peek() === 'single');
//--------------------------------------------------
// Create and attach the DOM for the view.
this.recordLayout = this.autoDispose(RecordLayout.create({
viewSection: this.viewSection,
buildFieldDom: this.buildFieldDom.bind(this),
resizeCallback: () => {
if (!this._isSingle) {
this.scrolly().updateSize();
// Keep the cursor in view if the scrolly height resets.
// TODO: Ideally the original position should be kept in scroll view.
this.scrolly().scrollRowIntoView(this.cursor.rowIndex.peek());
}
}
}));
this.scrolly = this.autoDispose(ko.computed(() => {
if (!this.recordLayout.isEditingLayout() && !this._isSingle) {
return koDomScrolly.getInstance(this.viewData);
}
}));
// Reset scrolly heights when record theme changes, since it affects heights.
this.autoDispose(this.viewSection.themeDef.subscribe(() => {
var scrolly = this.scrolly();
if (scrolly) {
setTimeout(function() { scrolly.resetHeights(); }, 0);
}
}));
this.layoutBoxIdx = ko.observable(0);
//--------------------------------------------------
if (this._isSingle) {
this.detailRecord = this.autoDispose(this.tableModel.createFloatingRowModel());
this._updateFloatingRow();
this.autoDispose(this.cursor.rowIndex.subscribe(this._updateFloatingRow, this));
this.autoDispose(this.viewData.subscribe(this._updateFloatingRow, this));
} else {
this.detailRecord = null;
}
//--------------------------------------------------
// Construct DOM
this.viewPane = this.autoDispose(this.buildDom());
//--------------------------------------------------
// Set up DOM event handling.
// Clicking on a detail field selects that field.
this.onEvent(this.viewPane, 'mousedown', '.g_record_detail_el', function(elem, event) {
this.viewSection.hasFocus(true);
var rowModel = this.recordLayout.getContainingRow(elem, this.viewPane);
var field = this.recordLayout.getContainingField(elem, this.viewPane);
commands.allCommands.setCursor.run(rowModel, field);
});
// Double-clicking on a field also starts editing the field.
this.onEvent(this.viewPane, 'dblclick', '.g_record_detail_el', function(elem, event) {
this.activateEditorAtCursor();
});
//--------------------------------------------------
// Instantiate CommandGroups for the different modes.
this.autoDispose(commands.createGroup(DetailView.generalCommands, this, this.viewSection.hasFocus));
this.newFieldCommandGroup = this.autoDispose(
commands.createGroup(DetailView.newFieldCommands, this, this.isNewFieldActive));
}
Base.setBaseFor(DetailView);
_.extend(DetailView.prototype, BaseView.prototype);
DetailView.prototype.onTableLoaded = function() {
BaseView.prototype.onTableLoaded.call(this);
this._updateFloatingRow();
const scrolly = this.scrolly();
if (scrolly) {
scrolly.scrollToSavedPos(this.viewSection.lastScrollPos);
}
};
DetailView.prototype._updateFloatingRow = function() {
if (this.detailRecord) {
this.viewData.setFloatingRowModel(this.detailRecord, this.cursor.rowIndex.peek());
}
};
/**
* DetailView commands.
*/
DetailView.generalCommands = {
cursorUp: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() - 1); },
cursorDown: function() { this.cursor.fieldIndex(this.cursor.fieldIndex() + 1); },
pageUp: function() { this.cursor.rowIndex(this.cursor.rowIndex() - 1); },
pageDown: function() { this.cursor.rowIndex(this.cursor.rowIndex() + 1); },
deleteRecords: function() {
// Do not allow deleting the add record row.
if (!this._isAddRow()) {
this.deleteRow(this.cursor.rowIndex());
}
},
copy: function() { return this.copy(this.getSelection()); },
cut: function() { return this.cut(this.getSelection()); },
paste: function(pasteObj, cutCallback) { return this.paste(pasteObj, cutCallback); },
editLayout: function() {
if (this.scrolly()) {
this.scrolly().scrollRowIntoView(this.cursor.rowIndex());
}
this.recordLayout.editLayout(this.cursor.rowIndex());
}
};
//----------------------------------------------------------------------
// TODO: Factor code duplicated with GridView for deleteRow, deleteColumn,
// insertDetailField out of the view modules
DetailView.prototype.deleteRow = function(index) {
if (this.viewSection.disableAddRemoveRows()) {
return;
}
var action = ['RemoveRecord', this.viewData.getRowId(index)];
return this.tableModel.sendTableAction(action)
.bind(this).then(function() {
this.cursor.rowIndex(index);
});
};
/**
* Pastes the provided data at the current cursor.
*
* @param {Array} data - Array of arrays of data to be pasted. Each array represents a row.
* i.e. [["1-1", "1-2", "1-3"],
* ["2-1", "2-2", "2-3"]]
* @param {Function} cutCallback - If provided returns the record removal action needed
* for a cut.
*/
DetailView.prototype.paste = function(data, cutCallback) {
let pasteData = data[0][0];
let col = this.currentColumn();
let isCompletePaste = (data.length === 1 && data[0].length === 1);
let richData = this._parsePasteForView([[pasteData]], [col]);
if (_.isEmpty(richData)) {
return;
}
// Array containing the paste action to which the cut action will be added if it exists.
const rowId = this.viewData.getRowId(this.cursor.rowIndex());
const action = (rowId === 'new') ? ['BulkAddRecord', [null], richData] :
['BulkUpdateRecord', [rowId], richData];
const cursorPos = this.cursor.getCursorPos();
return this.sendPasteActions(isCompletePaste ? cutCallback : null,
this.prepTableActions([action]))
.then(results => {
// If a row was added, get its rowId from the action results.
const addRowId = (action[0] === 'BulkAddRecord' ? results[0][0] : null);
// Restore the cursor to the right rowId, even if it jumped.
this.cursor.setCursorPos({rowId: cursorPos.rowId === 'new' ? addRowId : cursorPos.rowId});
this.copySelection(null);
});
};
/**
* Returns a selection of the selected rows and cols. In the case of DetailView this will just
* be one row and one column as multiple cell selection is not supported.
*
* @returns {Object} CopySelection
*/
DetailView.prototype.getSelection = function() {
return new CopySelection(
this.tableModel.tableData,
[this.viewData.getRowId(this.cursor.rowIndex())],
[this.viewSection.viewFields().at(this.cursor.fieldIndex())],
{}
);
};
/**
* Builds the DOM for the given field of the given row.
* @param {MetaRowModel|String} field: Model for the field to render. For a new field being added,
* this may instead be an object with {isNewField:true, colRef, label, value}.
* @param {DataRowModel} row: The record of data from which to render the given field.
*/
DetailView.prototype.buildFieldDom = function(field, row) {
var self = this;
if (field.isNewField) {
return dom('div.g_record_detail_el.flexitem',
kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }),
dom('div.g_record_detail_label', field.label),
dom('div.g_record_detail_value', field.value)
);
}
var isCellSelected = ko.pureComputed(function() {
return this.cursor.fieldIndex() === (field && field._index()) &&
this.cursor.rowIndex() === (row && row._index());
}, this);
var isCellActive = ko.pureComputed(function() {
return this.viewSection.hasFocus() && isCellSelected();
}, this);
// Whether the cell is part of an active copy-paste operation.
var isCopyActive = ko.computed(function() {
return self.copySelection() &&
self.copySelection().isCellSelected(row.getRowId(), field.colId());
});
this.autoDispose(isCellSelected.subscribe(yesNo => {
if (yesNo) {
var layoutBox = dom.findAncestor(fieldDom, '.layout_hbox');
this.layoutBoxIdx(_.indexOf(layoutBox.parentElement.childNodes, layoutBox));
}
}));
var fieldBuilder = this.fieldBuilders.at(field._index());
var fieldDom = dom('div.g_record_detail_el.flexitem',
dom.autoDispose(isCellSelected),
dom.autoDispose(isCellActive),
kd.cssClass(function() { return 'detail_theme_field_' + self.viewSection.themeDef(); }),
dom('div.g_record_detail_label', kd.text(field.displayLabel)),
dom('div.g_record_detail_value',
kd.toggleClass('scissors', isCopyActive),
kd.toggleClass('record-add', row._isAddRow),
dom.autoDispose(isCopyActive),
fieldBuilder.buildDomWithCursor(row, isCellActive, isCellSelected)
)
);
return fieldDom;
};
DetailView.prototype.buildDom = function() {
return dom('div.flexvbox.flexitem',
// Add .detailview_single when showing a single card or while editing layout.
kd.toggleClass('detailview_single',
() => this._isSingle || this.recordLayout.isEditingLayout()),
kd.maybe(this.recordLayout.isEditingLayout, () => {
const rowId = this.viewData.getRowId(this.recordLayout.editIndex.peek());
const record = this.getRenderedRowModel(rowId);
return dom(
this.recordLayout.buildLayoutDom(record, true),
kd.cssClass(() => 'detail_theme_record_' + this.viewSection.themeDef()),
kd.cssClass('detailview_record_' + this.viewSection.parentKey.peek()),
);
}),
kd.maybe(() => !this.recordLayout.isEditingLayout(), () => {
if (!this._isSingle) {
return dom('div.detailview_scroll_pane.flexitem',
kd.scrollChildIntoView(this.cursor.rowIndex),
dom.onDispose(() => {
// Save the previous scroll values to the section.
if (this.scrolly()) {
this.viewSection.lastScrollPos = this.scrolly().getScrollPos();
}
}),
koDomScrolly.scrolly(this.viewData, {fitToWidth: true},
row => this.makeRecord(row)),
);
} else {
return dom(
this.makeRecord(this.detailRecord),
kd.domData('itemModel', this.detailRecord),
kd.hide(() => this.cursor.rowIndex() === null)
);
}
}),
);
};
/** @inheritdoc */
DetailView.prototype.buildTitleControls = function() {
// Hide controls if this is a card list section, or if the section has a scroll cursor link, since
// the controls can be confusing in this case.
// Note that the controls should still be visible with a filter link.
const showControls = ko.computed(() =>
this._isSingle &&
(!this.viewSection.activeLinkSrcSectionRef() || this.viewSection.activeLinkTargetColRef()) &&
!this.recordLayout.layoutEditor()
);
return dom('div',
dom.autoDispose(showControls),
kd.toggleClass('record-layout-editor', this.recordLayout.layoutEditor),
kd.maybe(this.recordLayout.layoutEditor, (editor) => editor.buildEditorDom()),
kd.maybe(showControls, () => dom('div.grist-single-record__menu.flexhbox.flexnone',
this.gristDoc.app.addNewUIClass(),
dom('div.grist-single-record__menu__count.flexitem',
// Total should not include the add record row
kd.text(() => this._isAddRow() ? 'Add record' :
`${this.cursor.rowIndex() + 1} of ${this.getLastDataRowIndex() + 1}`)
),
dom('div.btn-group.btn-group-xs',
dom('div.btn.btn-default.detail-left',
dom('span.glyphicon.glyphicon-chevron-left'),
dom.on('click', () => { this.cursor.rowIndex(this.cursor.rowIndex() - 1); }),
kd.toggleClass('disabled', () => this.cursor.rowIndex() === 0)
),
dom('div.btn.btn-default.detail-right',
dom('span.glyphicon.glyphicon-chevron-right'),
dom.on('click', () => { this.cursor.rowIndex(this.cursor.rowIndex() + 1); }),
kd.toggleClass('disabled', () => this.cursor.rowIndex() >= this.viewData.all().length - 1)
)
),
dom('div.btn-group.btn-group-xs.detail-add-grp',
dom('div.btn.btn-default.detail-add-btn',
dom('span.glyphicon.glyphicon-plus'),
dom.on('click', () => {
let addRowIndex = this.viewData.getRowIndex('new');
this.cursor.rowIndex(addRowIndex);
}),
kd.toggleClass('disabled', () => this.viewData.getRowId(this.cursor.rowIndex()) === 'new')
)
)
))
);
};
/** @inheritdoc */
DetailView.prototype.onResize = function() {
var scrolly = this.scrolly();
if (scrolly) {
scrolly.scheduleUpdateSize();
}
};
/** @inheritdoc */
DetailView.prototype.onRowResize = function(rowModels) {
var scrolly = this.scrolly();
if (scrolly) {
scrolly.resetItemHeights(rowModels);
}
};
DetailView.prototype.makeRecord = function(record) {
return dom(
this.recordLayout.buildLayoutDom(record),
kd.cssClass(() => 'detail_theme_record_' + this.viewSection.themeDef()),
kd.toggleClass('active', () => (this.cursor.rowIndex() === record._index() && this.viewSection.hasFocus())),
// 'detailview_record_single' or 'detailview_record_detail' doesn't need to be an observable,
// since a change to parentKey would cause a separate call to makeRecord.
kd.cssClass('detailview_record_' + this.viewSection.parentKey.peek())
);
};
/**
* Extends BaseView getRenderedRowModel. Called to obtain the rowModel for the given rowId.
* Returns the rowModel if it is rendered in the current view type, otherwise returns null.
*/
DetailView.prototype.getRenderedRowModel = function(rowId) {
if (this.detailRecord) {
return this.detailRecord.getRowId() === rowId ? this.detailRecord : null;
} else {
return this.viewData.getRowModel(rowId);
}
};
/**
* Returns a boolean indicating whether the given index is the index of the add row.
* Index defaults to the current index of the cursor.
*/
DetailView.prototype._isAddRow = function(index = this.cursor.rowIndex()) {
return this.viewData.getRowId(index) === 'new';
};
module.exports = DetailView;

View File

@ -0,0 +1,204 @@
import {Comm, CommMessage} from 'app/client/components/Comm';
import {reportError, UserError} from 'app/client/models/errors';
import {Notifier} from 'app/client/models/NotifyModel';
import {ActionGroup} from 'app/common/ActionGroup';
import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI';
import {DocAction, UserAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {docUrl} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {Disposable, Emitter} from 'grainjs';
// tslint:disable:no-console
export interface DocUserAction extends CommMessage {
docFD: number;
fromSelf?: boolean;
data: {
docActions: DocAction[];
actionGroup: ActionGroup;
};
}
const SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only
/**
* The type of data.methods object created by openDoc() in app/client/components/Comm.js.
* This is used in much of client-side code, and exposed firstly as GristDoc.docComm.
*/
export class DocComm extends Disposable implements ActiveDocAPI {
// These are all the methods of ActiveDocAPI. Listing them explicitly lets typescript verify
// that we haven't missed any.
// closeDoc has a special implementation below.
public fetchTable = this._wrapMethod("fetchTable");
public fetchTableSchema = this._wrapMethod("fetchTableSchema");
public useQuerySet = this._wrapMethod("useQuerySet");
public disposeQuerySet = this._wrapMethod("disposeQuerySet");
// applyUserActions has a special implementation below.
public applyUserActionsById = this._wrapMethod("applyUserActionsById");
public importFiles = this._wrapMethod("importFiles");
public finishImportFiles = this._wrapMethod("finishImportFiles");
public cancelImportFiles = this._wrapMethod("cancelImportFiles");
public addAttachments = this._wrapMethod("addAttachments");
public findColFromValues = this._wrapMethod("findColFromValues");
public getFormulaError = this._wrapMethod("getFormulaError");
public fetchURL = this._wrapMethod("fetchURL");
public autocomplete = this._wrapMethod("autocomplete");
public shareDoc = this._wrapMethod("shareDoc");
public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc");
public getActionSummaries = this._wrapMethod("getActionSummaries");
public startBundleUserActions = this._wrapMethod("startBundleUserActions");
public stopBundleUserActions = this._wrapMethod("stopBundleUserActions");
public forwardPluginRpc = this._wrapMethod("forwardPluginRpc");
public reloadPlugins = this._wrapMethod("reloadPlugins");
public reloadDoc = this._wrapMethod("reloadDoc");
public fork = this._wrapMethod("fork");
public changeUrlIdEmitter = this.autoDispose(new Emitter());
// We save the clientId that was used when opening the doc. If it changes (e.g. reconnecting to
// another server), it would be incorrect to use the new clientId without re-opening the doc
// (which is handled by App.ts). This way, Comm can protect against mismatched clientIds.
private _clientId: string;
private _docFD: number;
private _forkPromise: Promise<void>|null = null;
private _isClosed: boolean = false;
private listenTo: BackboneEvents['listenTo']; // set by Backbone
constructor(private _comm: Comm, openResponse: OpenLocalDocResult, private _docId: string,
private _notifier: Notifier) {
super();
this._setOpenResponse(openResponse);
// If *this* doc is shutdown forcibly (e.g. via reloadDoc call), mark it as closed, so we
// don't attempt to close it again.
this.listenTo(_comm, 'docShutdown', (m: CommMessage) => {
if (this.isActionFromThisDoc(m)) { this._isClosed = true; }
});
this.onDispose(() => this._shutdown());
}
// Returns the URL params that identifying this open document to the DocWorker
// (used e.g. in attachment and download URLs).
public getUrlParams(): {clientId: string, docFD: number} {
return { clientId: this._clientId, docFD: this._docFD };
}
// Completes a path by adding the correct worker host and prefix for this document.
// E.g. "/uploads" becomes "https://host.name/v/ver/o/org/uploads"
public docUrl(path: string) {
return docUrl(this.docWorkerUrl, path);
}
// Returns a base url to the worker serving the current document, e.g.
// "https://host.name/v/ver/"
public get docWorkerUrl() {
return this._comm.getDocWorkerUrl(this._docId);
}
// Returns whether a message received by this Comm object is for the current doc.
public isActionFromThisDoc(message: CommMessage): boolean {
return message.docFD === this._docFD;
}
/**
* Overrides applyUserActions() method to also add the UserActions to a list, for use in tests.
*/
public applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult> {
this._comm.addUserActions(actions);
return this._callMethod('applyUserActions', actions, options);
}
/**
* Overrides closeDoc() method to call to Comm directly, without triggering forking logic.
* This is important in particular since it may be called while forking.
*/
public closeDoc(): Promise<void> {
return this._callDocMethod('closeDoc');
}
/**
* Forks the document, making sure the url gets updated, and holding any actions
* until the fork is complete. If a fork has already been started/completed, this
* does nothing.
*/
public async forkAndUpdateUrl(): Promise<void> {
await (this._forkPromise || (this._forkPromise = this._doForkDoc()));
}
// Clean up connection after closing doc.
private async _shutdown() {
console.log(`DocComm: shutdown clientId ${this._clientId} docFD ${this._docFD}`);
try {
// Close the document to unsubscribe from further updates on it.
if (!this._isClosed) {
await this.closeDoc();
}
} catch (err) {
console.warn(`DocComm: closeDoc failed: ${err}`);
} finally {
if (!this._comm.isDisposed()) {
this._comm.releaseDocConnection(this._docId);
}
}
}
/**
* Store important information from the response to openDoc, and
* ensure we have a connection to a docWorker for the document
* identified by the current docId. the caller of _setOpenResponse
* should call _releaseDocConnection for any previous docId.
*/
private _setOpenResponse(openResponse: OpenLocalDocResult) {
this._docFD = openResponse.docFD;
this._clientId = openResponse.clientId;
this._comm.useDocConnection(this._docId);
}
private _wrapMethod<Name extends keyof ActiveDocAPI>(name: Name): ActiveDocAPI[Name] {
return this._callMethod.bind(this, name);
}
private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS);
}
private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
if (this._forkPromise) {
// If a fork is pending or has finished, call the method after waiting for it.
// (If we've gone through a fork, we will not consider forking again.)
await this._forkPromise;
return this._callDocMethod(name, ...args);
}
try {
return await this._callDocMethod(name, ...args);
} catch (err) {
// TODO should be the suggested fork id and fork user.
if (err.shouldFork) {
// If the server suggests to fork, do it now, or wait for the fork already pending.
await this.forkAndUpdateUrl();
return this._callDocMethod(name, ...args);
}
throw err;
}
}
private _callDocMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args);
}
private async _doForkDoc(): Promise<void> {
reportError(new UserError('Preparing your copy...', {key: 'forking'}));
const {urlId, docId} = await this.fork();
const openResponse = await this._comm.openDoc(docId);
// Close the old doc and release the old connection. Note that the closeDoc call is expected
// to fail, since we close the websocket immediately after it. So let it fail silently.
this.closeDoc().catch(() => null);
this._comm.releaseDocConnection(this._docId);
this._docId = docId;
this._setOpenResponse(openResponse);
this.changeUrlIdEmitter.emit(urlId);
reportError(new UserError('You are now editing your own copy', {key: 'forking'}));
}
}
Object.assign(DocComm.prototype, BackboneEvents);

View File

@ -0,0 +1,34 @@
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var ValidationPanel = require('./ValidationPanel');
/**
* Document level configuration settings.
* @param {Object} options.gristDoc A reference to the GristDoc object
* @param {Function} docName A knockout observable containing a String
*/
function DocConfigTab(options, docName) {
this.gristDoc = options.gristDoc;
// Panel to configure validation rules.
this.validationPanel = this.autoDispose(ValidationPanel.create({gristDoc: this.gristDoc}));
this.autoDispose(
this.gristDoc.addOptionsTab(
'Validate Data',
dom('span.glyphicon.glyphicon-check'),
this.buildValidationsConfigDomObj(),
{ 'shortLabel': 'Valid' }
)
);
}
dispose.makeDisposable(DocConfigTab);
DocConfigTab.prototype.buildValidationsConfigDomObj = function() {
return [{
'buildDom': this.validationPanel.buildDom.bind(this.validationPanel),
'keywords': ['document', 'validations', 'rules', 'validate']
}];
};
module.exports = DocConfigTab;

View File

@ -0,0 +1,302 @@
#grist-app {
background: url('img/gplaypattern.png');
}
.start-doclist {
position: relative;
margin: 0 auto;
padding: 0;
top: var(--layout-top-spacer);
height: calc(100% - 2*var(--layout-top-spacer));
width: 90vw;
min-width: 600px;
max-width: 900px;
background-color: var(--color-doclist-bg);
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.5);
overflow: hidden;
}
.header-actions {
background-color: var(--color-logo-bg);
text-align: center;
}
.header-actions-logo {
height: var(--layout-doclist-header-height);
padding: .5rem;
background: url('img/logo-grist-transp.png');
background-repeat: no-repeat;
background-size: contain;
background-position: center;
cursor: pointer;
}
.footer-actions {
margin: 1rem auto;
text-align: center;
}
.header-doclist {
height: var(--layout-doclist-header-height);
min-height: var(--layout-doclist-header-height);
background: linear-gradient(to bottom, #f9f9f9, #f0f0f0);
align-items: center;
}
.doclist-link {
margin: .5rem;
font-weight: 600;
color: var(--color-header-doclist);
}
.doclist-section {
height: 100%;
}
.section-sidepane-wrapper {
width: 180px;
min-width: 180px;
border-right: 1px solid var(--color-border-light);
}
.section-sidepane {
margin: .5rem auto;
padding: 0;
text-align: center;
align-items: center;
}
.section-sidepane.b-actions {
margin: .5rem 2rem;
align-items: stretch;
}
.section-doclist {
height: var(--layout-doclist-height);
}
.doclist-body-wrapper {
margin-top: 2px;
height: calc(100% - 2px - var(--layout-doclist-header-height));
width: 100%;
overflow: auto;
}
.g-btn.mod-doclist__sidepane {
border-style: solid;
border-width: 1px 1px 1px 4px;
font-weight: 400;
color: #000;
height: 30px;
line-height: 18px;
}
.g-btn.mod-doclist__sidepane:hover {
font-weight: 600;
color: #fff;
}
.g-btn.mod-doclist__sidepane.fit-text {
font-size: 1.0rem;
}
.g-btn.mod-doclist__sidepane.el-login {
border-color: var(--color-btn-login);
background-color: var(--color-btn-login-background);
}
.g-btn.mod-doclist__sidepane.el-login:hover {
background-color: var(--color-btn-login);
}
.g-btn.mod-doclist__sidepane.el-createdoc {
border-color: var(--color-btn-createdoc);
}
.g-btn.mod-doclist__sidepane.el-createdoc:hover {
background-color: var(--color-btn-createdoc);
}
.g-btn.mod-doclist__sidepane.el-editteam {
margin-top: 1rem;
border-color: #7e25ff;
}
.g-btn.mod-doclist__sidepane.el-editteam:hover {
background-color: #7e25ff;
}
.g-btn.mod-doclist__sidepane.el-uploaddoc {
position: relative;
margin-top: 1rem;
border-color: var(--color-btn-uploaddoc);
}
.g-btn.mod-doclist__sidepane.el-uploaddoc:hover {
background-color: var(--color-btn-uploaddoc);
}
.g-btn.mod-doclist__invite {
width: 20px;
height: 20px;
padding: 4px;
}
.doclist-invite {
display: flex;
flex-direction: column;
}
.doclist-invite-bottom-row {
display: inline-block;
font-size: 1.0rem;
color: var(--color-hint-text);
}
.mod-doclist__sidepane.el-divider {
border-bottom: 1px solid var(--color-border-light);
height: 1px;
width: 90%;
margin: 15px auto;
}
.el-uploaddoc__bar {
position: absolute;
height: 100%;
width: 0%;
left: 0px;
top: 0px;
padding-top: 1px;
color: #fff;
background-color: var(--color-btn-uploaddoc);
font-size: 2rem;
transition: width .2s ease-out, opacity .2s;
}
.g-btn {
margin: 0;
padding: .5rem .8rem;
cursor: pointer;
}
.g-list {
list-style-type: none;
margin: 0;
padding: 0;
}
.doclist-logo-img {
width: 90px;
}
.doclist-header {
top: 0;
height: var(--layout-doclist-header-height);
min-height: var(--layout-doclist-header-height);
padding: .2rem 1rem;
align-items: center;
background: linear-gradient(to bottom, #f0f0f0, #e6e6e6);
box-shadow: 0 2px 2px -1px rgba(0,0,0,0.5);
text-decoration: underline;
}
.doclist-row {
padding: 1rem;
border-bottom: 1px solid var(--color-border-light);
transition: background-color .2s;
}
.doclist-row:hover {
background-color: var(--color-list-row-hover);
}
.doclist-sample {
color: var(--color-header-doclist);
}
.doclist-column {
min-width: 120px;
cursor: pointer;
}
.doclist-column.doclist-column__name {
min-width: 160px;
}
.doclist-column.doclist-column__download, .doclist-column.doclist-column__delete {
min-width: 15px;
color: #777;
font-size: 1.0rem;
text-align: right;
margin-left: 10px;
padding: 3px;
}
.doclist-column.mod-header {
padding-left: 1.6rem;
}
.doclist-column__size {
text-align: right;
padding-right: 1rem;
}
.doclist-download_link {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.glyphicon.mod-doclist-column {
padding-right: .5rem;
}
div.uploadAlert {
position: relative;
margin-bottom: 1rem;
}
.gupload {
margin-bottom: 1rem;
}
.gupload_item {
position: relative;
background-color: transparent;
}
.gupload_bar {
position: absolute;
z-index: -1;
left: 0;
top: 0;
height: 100%;
background-color: lightgreen;
}
.gupload_failed {
background-color: #FFA0A0;
}
.gupload_info {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -0,0 +1,331 @@
/* global window, $ */
const bluebird = require('bluebird');
const _ = require('underscore');
const ko = require('knockout');
const BackboneEvents = require('backbone').Events;
const gutil = require('app/common/gutil');
const version = require('app/common/version');
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const koSession = require('../lib/koSession');
const {IMPORTABLE_EXTENSIONS, selectFiles} = require('../lib/uploads');
const commands = require('./commands');
const {urlState} = require('app/client/models/gristUrlState');
const {showConfirmDialog} = require('./Confirm');
const sortByFuncs = {
'name': (a, b) => a.localeCompare(b),
'mtime': (a, b) => gutil.nativeCompare(a, b),
'size': (a, b) => gutil.nativeCompare(a, b)
};
function DocList(app) {
this.app = app;
this.docListModel = app.docListModel;
this.docs = app.docListModel.docs;
this._docInvites = app.docListModel.docInvites;
// Default sort is by modified time in descending order
// TODO: This would be better yet as a user preference, since this still resets to default for
// each browser window.
this.sortBy = koSession.sessionValue('docListSortBy', 'mtime');
this.sortAsc = koSession.sessionValue('docListSortDir', -1); // 1 for asc, -1 for desc
let compareFunc = this.autoDispose(ko.computed(() => {
let attrib = this.sortBy();
let asc = this.sortAsc();
let compareFunc = sortByFuncs[attrib];
return (a, b) => gutil.nativeCompare(a.tag, b.tag) || compareFunc(a[attrib], b[attrib]) * asc;
}));
this.sortedDocs = this.autoDispose(ko.computed(() =>
this.docs.all().sort(compareFunc())).extend({rateLimit: 0}));
this.docListModel.refreshDocList();
this.autoDispose(this.app.login.isLoggedIn.subscribe(loggedIn => {
// Refresh the doc list to show/hide docs and invites belonging to the logged in user.
this.docListModel.refreshDocList();
}));
}
dispose.makeDisposable(DocList);
_.extend(DocList.prototype, BackboneEvents);
// TODO: Factor out common code from DocList and NavBar (e.g. status indicator)
DocList.prototype.buildDom = function() {
let currentStatus = ko.observable('OK');
this.listenTo(this.app.comm, 'connectionStatus', (message, status) => {
console.warn('connectionStatus', message, status);
currentStatus(status);
});
return this.autoDispose(
dom('div.start-doclist.flexhbox',
dom('section.doclist-section.section-sidepane-wrapper.flexvbox',
parts.sidepane.header(() => this.docListModel.refreshDocList()),
parts.sidepane.connection(currentStatus),
dom('div.section-sidepane.b-actions.flexnone.flexvbox',
kd.maybe(() => this.app.features().signin, () =>
dom('div',
parts.sidepane.loginButton(this.app.login),
dom('div.mod-doclist__sidepane.el-divider')
)
),
parts.sidepane.createDocButton(this.createNewDoc.bind(this)),
window.electronOpenDialog ? parts.sidepane.openDocButton() : null,
parts.sidepane.uploadDocButton(this.uploadNewDoc.bind(this))
),
dom('div.flexitem'),
parts.sidepane.footer(commands.allCommands.help.run)),
dom('section.doclist-section.section-doclist-wrapper.flexitem', this._buildDoclistBody())));
};
DocList.prototype._buildDoclistBody = function() {
return dom('div.section-doclist',
dom('div.doclist-header.flexhbox',
dom('div.doclist-column.doclist-column__name.mod-header.flexitem', 'Name',
parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'name'),
dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'name'))),
dom('div.doclist-column.doclist-column__size', 'Size',
parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'size'),
dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'size'))),
dom('div.doclist-column.doclist-column__modified', 'Modified',
parts.doclist.ascDescArrow(this.sortBy, this.sortAsc, 'mtime'),
dom.on('click', () => updateSort(this.sortBy, this.sortAsc, 'mtime'))),
dom('div.doclist-column.doclist-column__delete')
),
dom('div.doclist-body-wrapper',
dom('div.doclist-body',
kd.scope(this.sortedDocs, docs =>
docs.map(docObj => parts.doclist.listDoc(docObj, this._onClickItem.bind(this),
this._confirmRemoveDoc.bind(this)))),
kd.foreach(this._docInvites, inviteObj =>
parts.doclist.listInvite(inviteObj, this._downloadSharedDoc.bind(this),
this._confirmDeclineInvite.bind(this))))));
};
const parts = {
sidepane: {
connection: (statusObs) =>
dom('div.section-sidepane.b-status.flexhbox',
dom('div.gnotifier', kd.cssClass(() => 'g-status-' + statusObs())),
dom('span', 'Connection: '),
kd.text(statusObs)),
header: (headerCommand) =>
dom('div.header-actions',
dom('div.header-actions-logo', // Logo set in css
{title: `Ver ${version.version} (${version.gitcommit})`},
dom.on('click', headerCommand))),
footer: (helpCommand) =>
dom('div.footer-actions.flexvbox',
dom('a.doclist-link', { href: 'help', target: '_blank' }, 'Help'),
dom('a.doclist-link', { href: 'help/contact-us', target: '_blank'}, 'Contact'),
dom('a.doclist-link', { href: 'https://www.getgrist.com', target: '_blank'}, 'About')),
loginButton: (login) =>
dom('div.g-btn.mod-doclist__sidepane.el-login',
dom.testId('DocList_login'),
kd.toggleClass('fit-text', () => login.isLoggedIn()),
kd.text(login.buttonText),
dom.on('click', e => { login.onClick(e); })),
createDocButton: createNewDoc =>
dom('div.g-btn.mod-doclist__sidepane.el-createdoc',
dom.testId('DocList_createDoc'),
'Create document',
dom.on('click', createNewDoc)),
uploadDocButton: uploadNewDoc => {
let uploadStatusIcons = {
'uploading': 'glyphicon-upload',
'success': 'glyphicon-ok-sign',
'error': 'glyphicon-exclamation-sign'
};
let uploadStatus = ko.observable(null);
let uploadBar;
let uploadProgress = percentage => uploadBar.style.width = percentage + '%';
return dom('div.g-btn.mod-doclist__sidepane.el-uploaddoc',
dom.testId('DocList_uploadBtn'),
uploadBar = dom('div.el-uploaddoc__bar',
kd.show(uploadStatus),
dom('span.glyphicon.glyphicon-upload',
kd.cssClass(() => uploadStatusIcons[uploadStatus()])
)
),
dom('span',
kd.style('visibility', () => uploadStatus() === null ? 'visible' : 'hidden'),
'Import document'
),
dom.on('click', () => uploadNewDoc(uploadStatus, uploadProgress))
);
},
openDocButton: () => dom('div.g-btn.mod-doclist__sidepane.el-uploaddoc',
dom('span', 'Open document'),
dom.on('click', window.electronOpenDialog)
),
},
doclist: {
ascDescArrow: (sortBy, sortAsc, attrib) =>
dom('span', kd.text(() => sortBy() === attrib ? (sortAsc() > 0 ? '▲' : '▼') : '')),
listDoc: (docObj, onClick, onDelete) => {
let mtime = new Date(docObj.mtime);
return dom('div.doclist-row.flexhbox',
kd.toggleClass('doclist-sample', docObj.tag === 'sample'),
dom('div.doclist-column.doclist-column__name.flexitem',
dom('span.glyphicon.glyphicon-file.mod-doclist-column'),
dom('a', {
href: urlState().makeUrl({doc: docObj.name})
},
kd.toggleClass('doclist-sample', docObj.tag === 'sample'),
docObj.tag ? `[${gutil.capitalize(docObj.tag)}] ` : null,
docObj.name,
dom.on('click', (ev) => onClick(ev, docObj))
)
),
dom('div.doclist-column.doclist-column__size', gutil.byteString(docObj.size)),
dom('div.doclist-column.doclist-column__modified',
mtime.toLocaleDateString() + ' ' + mtime.toLocaleTimeString()),
// Show a downlink link for each file in the hosted version, but not in the electron version.
window.isRunningUnderElectron ? null : dom('div.doclist-column.doclist-column__download.glyphicon.glyphicon-download-alt',
dom('a.doclist-download_link', {
href: 'download?' + $.param({doc: docObj.name}),
download: `${docObj.name}.grist`
})
),
dom('div.doclist-column.doclist-column__delete.glyphicon.glyphicon-trash',
dom.on('click', () => onDelete(docObj.name))));
},
listInvite: (docObj, onDownload, onDecline) => {
const name = docObj.senderName;
const email = docObj.senderEmail;
return dom('div.doclist-row.doclist-invite.flexhbox',
dom('div.flexhbox.doclist-invite-top-row',
dom('div.doclist-column.doclist-column__name.flexitem',
dom('span.glyphicon.glyphicon-download-alt.mod-doclist-column'),
dom.on('click', () => onDownload(docObj)),
'[Invite] ',
docObj.name
),
dom('div.doclist-column.doclist-column__size'),
dom('div.doclist-column.doclist-column__modified'),
dom('div.doclist-column.doclist-column__delete.glyphicon.glyphicon-remove',
dom.on('click', () => onDecline(docObj)))
),
dom('div.flexhbox.doclist-invite-bottom-row', 'Sent by: ' + (name ? `${name} (${email})` : email))
);
}
}
};
DocList.prototype._downloadSharedDoc = function(docObj) {
return this.app.comm.downloadSharedDoc(docObj.docId, docObj.name)
.then(() => this.docListModel.refreshDocList());
};
DocList.prototype._confirmDeclineInvite = function(docObj) {
showConfirmDialog(`Ignore invite to ${docObj.name}?`, 'Ignore', () => this._ignoreInvite(docObj));
};
DocList.prototype._confirmRemoveDoc = function(docName) {
if (window.isRunningUnderElectron) {
showConfirmDialog(`Move ${docName} to trash?`, 'Move to trash', () => this.app.comm.deleteDoc(docName, false));
} else {
showConfirmDialog(`Delete ${docName} permanently?`, 'Delete', () => this.app.comm.deleteDoc(docName, true));
}
};
DocList.prototype._ignoreInvite = function(docObj) {
return this.app.comm.ignoreLocalInvite(docObj.docId)
.then(() => this.docListModel.refreshDocList());
};
// commands useable while title is active
DocList.fileEditorCommands = {
accept: function() { this.finishEditingFileName(true); },
cancel: function() { this.finishEditingFileName(false); },
};
DocList.prototype.createNewDoc = function(source, event) {
return this.app.comm.createNewDoc()
.then(docName => urlState().pushUrl({doc: docName}, {avoidReload: true}))
.catch(err => console.error(err));
};
DocList.prototype._onClickItem = function(ev, docObj) {
ev.preventDefault();
if (docObj.tag === 'sample') {
// Note that we don't expect this to fail, so an error will show up in the NotifyBox.
this.app.comm.importSampleDoc(docObj.name)
.then(docName => urlState().pushUrl({doc: docName}, {avoidReload: true}));
return false;
}
// To avoid a page reload, we handle the change of url. Previously we used fragments,
// so the browser knew there was no page change going on. Now, we are using real urls
// and we have to hold the browser's hand.
urlState().pushUrl({doc: docObj.name}, {avoidReload: true});
return false;
};
DocList.prototype.uploadNewDoc = function(uploadStatus, uploadProgress) {
function progress(percent) {
// We use one progress bar to combine the time to upload and the time to import in 40%-60%
// split. TODO The second part needs to be done; currently it stops at 40%, but at least the
// user can tell that there is still something to wait for.
const p = percent * 0.4;
uploadProgress(p);
if (p > 0) { uploadStatus('uploading'); }
}
return bluebird.try(() => {
return selectFiles({multiple: true, extensions: IMPORTABLE_EXTENSIONS},
progress);
})
.then(uploadResult => {
// Put together a text summary of what got uploaded, currently only for debugging.
const summaryList = uploadResult.files.map(f => `${f.origName} (${f.size})`);
console.log('Upload summary:', summaryList.join(", "));
// TODO: This step should include its own progress callback, and the progress indicator should
// combine the two steps, e.g. in 40%-60% split.
return this.app.comm.importDoc(uploadResult.uploadId);
})
.then(docName => {
uploadStatus('success');
return bluebird.delay(500) // Let the user see the OK icon briefly.
.then(() => urlState().pushUrl({doc: docName}, {avoidReload: true}));
})
.catch(err => {
console.error("Upload failed: %s", err);
uploadStatus('error');
return bluebird.delay(2000); // Let the user see the error icon.
})
.finally(() => {
uploadStatus(null);
});
};
function updateSort(sortObs, sortAsc, sortValue) {
let currSort = sortObs();
if (currSort === sortValue) {
sortAsc(-1 * sortAsc());
} else {
sortObs(sortValue);
sortAsc(1);
}
}
module.exports = DocList;

View File

@ -0,0 +1,39 @@
.embed-form-desc {
margin: 10px 0;
}
.embed-form-basket-id {
font-weight: bold;
margin-right: 5px;
}
.embed-form-tables {
text-align: center;
}
.embed-form-tables .kf_row > .kf_elem {
margin: 0;
width: 90%;
}
.embed-form-table-id {
text-align: left;
}
.embed-form-published {
background-color: #f0f9f9;
border: 1px dashed #35afae;
padding: 0 5px 5px 5px;
}
.embed-form-unpublished {
padding: 5px;
}
.embed-form-link {
text-align: center;
}
.embed-form-connect {
text-align: center;
}

View File

@ -0,0 +1,205 @@
// External dependencies
const _ = require('underscore');
const ko = require('knockout');
const BackboneEvents = require('backbone').Events;
// Grist client libs
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const kf = require('../lib/koForm');
const ModalDialog = require('./ModalDialog');
const gutil = require('app/common/gutil');
const BASE_URL = 'https://syvvdfor2a.execute-api.us-east-1.amazonaws.com/test';
/**
* EmbedForm - Handles logic and dom for the modal embedding instruction box.
*/
function EmbedForm(gristDoc) {
this._docComm = gristDoc.docComm;
this._login = gristDoc.app.login;
this._basketId = gristDoc.docInfo.basketId;
this._tableIds = gristDoc.docModel.allTableIds.peek().sort();
// Arrays of published and unpublished tables, initialized in this._refreshTables()
this._published = ko.observable([]);
this._unpublished = ko.observable([]);
// Notify strings which are displayed to the user when set
this._errorNotify = ko.observable();
this._updateNotify = ko.observable();
// The state of initialization, either 'connecting', 'failed', or 'done'.
this._initState = ko.observable('connecting');
this._embedDialog = this.autoDispose(ModalDialog.create({
title: 'Upload for External Embedding',
body: this._buildEmbedDom(),
width: '420px'
}));
this._embedDialog.show();
this.listenTo(this._embedDialog, 'close', () => this.dispose());
// Perform the initial fetch to see which tables are published.
this._initFetch();
}
_.extend(EmbedForm.prototype, BackboneEvents);
dispose.makeDisposable(EmbedForm);
/**
* Performs the initial fetch to see which tables are published.
* Times out after 4 seconds, giving the user the option to retry.
*/
EmbedForm.prototype._initFetch = function() {
this._initState('connecting');
return this._refreshTables()
.timeout(4000)
.then(() => {
this._initState('done');
})
.catch(err => {
console.error("EmbedForm._initFetch failed", err);
this._initState('failed');
});
};
/**
* Calls on basket to see which tables are published, then updates the published
* and unpublished local observables.
*/
EmbedForm.prototype._refreshTables = function() {
// Fetch the tables from the basket
return this._login.getBasketTables(this._docComm)
.then(basketTableIds => {
let published = [];
let unpublished = [];
gutil.sortedScan(this._tableIds, basketTableIds.sort(), (local, cloud) => {
let item = {
tableId: local || cloud,
local: Boolean(local),
cloud: Boolean(cloud)
};
if (cloud) {
published.push(item);
} else {
unpublished.push(item);
}
});
this._published(published);
this._unpublished(unpublished);
});
};
/**
* Builds the part of the form showing the table names and their status, and
* the buttons to change their status.
*/
EmbedForm.prototype._buildTablesDom = function() {
return dom('div.embed-form-tables',
kd.scope(this._published, published => {
return published.length > 0 ? dom('div.embed-form-published',
dom('div.embed-form-desc', `Published to Basket (basketId: ${this._basketId()})`),
published.map(t => {
return kf.row(
16, dom('a.embed-form-table-id', { href: this._getUrl(t.tableId), target: "_blank" },
t.tableId),
8, t.local ? this._makeButton('Update', t.tableId, 'update') : 'Only in Basket',
1, dom('div'),
2, this._makeButton('x', t.tableId, 'delete')
);
})
) : null;
}),
dom('div.embed-form-unpublished',
kd.scope(this._unpublished, unpublished => {
return unpublished.map(t => {
return kf.row(
16, dom('span.embed-form-table-id', t.tableId),
8, this._makeButton('Publish', t.tableId, 'add'),
3, dom('div')
);
});
})
)
);
};
/**
* Builds the body of the table publishing modal form.
*/
EmbedForm.prototype._buildEmbedDom = function() {
// TODO: Include links to the npm page and to download basket-api.js.
return dom('div.embed-form',
kd.scope(this._initState, state => {
switch (state) {
case 'connecting':
return dom('div.embed-form-connect', 'Connecting...');
case 'failed':
return dom('div',
dom('div.embed-form-connect', 'Connection to Basket failed'),
kf.buttonGroup(
kf.button(() => {
this._initFetch();
}, 'Retry')
)
);
case 'done':
return dom('div',
dom('div.embed-form-desc', 'Manage tables published to the cloud via Grist Basket.'),
dom('div.embed-form-desc', 'Note that by default, published tables are public.'),
this._buildTablesDom(),
dom('div.embed-form-desc', 'Basket is used to provide easy access to cloud-synced data:'),
dom('div.embed-form-link',
dom('a', { href: 'https://github.com/gristlabs/basket-api', target: "_blank" },
'Basket API on GitHub')
)
);
}
}),
kd.maybe(this._updateNotify, update => {
return dom('div.login-success-notify',
dom('div.login-success-text', update)
);
}),
kd.maybe(this._errorNotify, err => {
return dom('div.login-error-notify',
dom('div.login-error-text', err)
);
})
);
};
// Helper to perform embedAction ('add' | 'update' | 'delete') on tableId.
EmbedForm.prototype._embedTable = function(tableId, embedAction) {
this._errorNotify('');
this._updateNotify('');
return this._docComm.embedTable(tableId, embedAction)
.then(() => {
return this._refreshTables();
})
.then(() => {
if (embedAction === 'update') {
this._updateNotify(`Updated table ${tableId}`);
}
})
.catch(err => {
this._errorNotify(err.message);
});
};
// Helper to make a button with text, that when pressed performs embedAction
// ('add' | 'update' | 'delete') on tableId.
EmbedForm.prototype._makeButton = function(text, tableId, embedAction) {
return kf.buttonGroup(
kf.button(() => this._embedTable(tableId, embedAction), text)
);
};
// Returns the URL to see the hosted data for tableId.
EmbedForm.prototype._getUrl = function(tableId) {
return `${BASE_URL}/${this._basketId()}/tables/${tableId}`;
};
module.exports = EmbedForm;

View File

@ -0,0 +1,9 @@
.formula_button_f {
font-size: 1.2rem;
}
.formula_button_x {
font-style: bold;
font-size: 0.9rem;
line-height: 0.9rem;
}

View File

@ -0,0 +1,169 @@
var ko = require('knockout');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var modelUtil = require('../models/modelUtil');
var gutil = require('app/common/gutil');
var AceEditor = require('./AceEditor');
var RefSelect = require('./RefSelect');
const {dom: grainjsDom, makeTestId} = require('grainjs');
const testId = makeTestId('test-fconfigtab-');
function FieldConfigTab(options) {
this.gristDoc = options.gristDoc;
this.fieldBuilder = options.fieldBuilder;
this.origColRef = this.autoDispose(ko.computed(() =>
this.fieldBuilder() ? this.fieldBuilder().origColumn.origColRef() : null));
this.isColumnValid = this.autoDispose(ko.computed(() => Boolean(this.origColRef())));
this.origColumn = this.autoDispose(
this.gristDoc.docModel.columns.createFloatingRowModel(this.origColRef));
this.disableModify = this.autoDispose(ko.computed(() =>
this.origColumn.disableModify() || this.origColumn.isTransforming()));
this.colId = modelUtil.customComputed({
read: () => this.origColumn.colId(),
save: val => this.origColumn.colId.saveOnly(val)
});
this.showColId = this.autoDispose(ko.pureComputed({
read: () => {
let label = this.origColumn.label();
let derivedColId = label ? gutil.sanitizeIdent(label) : null;
return derivedColId === this.colId() && !this.origColumn.untieColIdFromLabel();
}
}));
this.isDerivedFromLabel = this.autoDispose(ko.pureComputed({
read: () => !this.origColumn.untieColIdFromLabel(),
write: newValue => this.origColumn.untieColIdFromLabel.saveOnly(!newValue)
}));
// Indicates whether this is a ref col that references a different table.
this.isForeignRefCol = this.autoDispose(ko.pureComputed(() => {
let type = this.origColumn.type();
return type && gutil.startsWith(type, 'Ref:') &&
this.origColumn.table().tableId() !== gutil.removePrefix(type, 'Ref:');
}));
// Create an instance of AceEditor that can be built for each column
this.formulaEditor = this.autoDispose(AceEditor.create({observable: this.origColumn.formula}));
// Builder for the reference display column multiselect.
this.refSelect = this.autoDispose(RefSelect.create(this));
if (options.contentCallback) {
options.contentCallback(this.buildConfigDomObj());
} else {
this.autoDispose(this.gristDoc.addOptionsTab(
'Field', dom('span.glyphicon.glyphicon-sort-by-attributes'),
this.buildConfigDomObj(),
{ 'category': 'options', 'show': this.fieldBuilder }
));
}
}
dispose.makeDisposable(FieldConfigTab);
// Builds object with FieldConfigTab dom builder and settings for the sidepane.
// TODO: Field still cannot be filtered/filter settings cannot be opened from FieldConfigTab.
// This should be considered.
FieldConfigTab.prototype.buildConfigDomObj = function() {
return [{
'buildDom': this._buildNameDom.bind(this),
'keywords': ['field', 'column', 'name', 'title']
}, {
'header': true,
'items': [{
'buildDom': this._buildFormulaDom.bind(this),
'keywords': ['field', 'column', 'formula']
}]
}, {
'header': true,
'label': 'Format Cells',
'items': [{
'buildDom': this._buildFormatDom.bind(this),
'keywords': ['field', 'type', 'widget', 'options', 'alignment', 'justify', 'justification']
}]
}, {
'header': true,
'label': 'Additional Columns',
'showObs': this.isForeignRefCol,
'items': [{
'buildDom': () => this.refSelect.buildDom(),
'keywords': ['additional', 'columns', 'reference', 'formula']
}]
}, {
'header': true,
'label': 'Transform',
'items': [{
'buildDom': this._buildTransformDom.bind(this),
'keywords': ['field', 'type']
}]
}];
};
FieldConfigTab.prototype._buildNameDom = function() {
return grainjsDom.maybe(this.isColumnValid, () => dom('div',
kf.row(
1, dom('div.glyphicon.glyphicon-sort-by-attributes.config_icon'),
4, kf.label('Field'),
13, kf.text(this.origColumn.label, { disabled: this.disableModify },
dom.testId("FieldConfigTab_fieldLabel"),
testId('field-label'))
),
kf.row(
kd.hide(this.showColId),
1, dom('div.glyphicon.glyphicon-tag.config_icon'),
4, kf.label('ID'),
13, kf.text(this.colId, { disabled: this.disableModify },
dom.testId("FieldConfigTab_colId"),
testId('field-col-id'))
),
kf.row(
8, kf.lightLabel("Use Name as ID?"),
1, kf.checkbox(this.isDerivedFromLabel,
dom.testId("FieldConfigTab_deriveId"),
testId('field-derive-id'))
)
));
};
FieldConfigTab.prototype._buildFormulaDom = function() {
return grainjsDom.maybe(this.isColumnValid, () => dom('div',
kf.row(
3, kf.buttonGroup(
kf.checkButton(this.origColumn.isFormula,
dom('span.formula_button_f', '\u0192'),
dom('span.formula_button_x', 'x'),
kd.toggleClass('disabled', this.disableModify),
{ title: 'Change to formula column' }
)
),
15, dom('div.transform_editor', this.formulaEditor.buildDom())
),
kf.helpRow(
3, dom('span'),
15, kf.lightLabel(kd.text(
() => this.origColumn.isFormula() ? 'Formula' : 'Default Formula'))
)
));
};
FieldConfigTab.prototype._buildTransformDom = function() {
return grainjsDom.maybe(this.fieldBuilder, builder => builder.buildTransformDom());
};
FieldConfigTab.prototype._buildFormatDom = function() {
return grainjsDom.maybe(this.fieldBuilder, builder => [
builder.buildSelectTypeDom(),
builder.buildSelectWidgetDom(),
builder.buildConfigDom()
]);
};
module.exports = FieldConfigTab;

View File

@ -0,0 +1,54 @@
/**
* FormulaTransform extends ColumnTransform, creating the transform dom in the field config tab
* used to transform a column of data using a formula. Allows the user to easily and quickly clean
* data or change data to a more useful form.
*/
// Client libraries
import * as AceEditor from 'app/client/components/AceEditor';
import {ColumnTransform} from 'app/client/components/ColumnTransform';
import {GristDoc} from 'app/client/components/GristDoc';
import {cssButtonRow} from 'app/client/ui/RightPanel';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {dom} from 'grainjs';
/**
* Creates an instance of FormulaTransform for a single field. Extends ColumnTransform.
*/
export class FormulaTransform extends ColumnTransform {
constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {
super(gristDoc, fieldBuilder);
}
/**
* Build the transform menu for a formula transform
*/
public buildDom() {
this.editor = this.autoDispose(AceEditor.create({ observable: this.transformColumn.formula }));
return [
dom('div.transform_menu',
dom('div.transform_editor',
this.buildEditorDom(this.getIdentityFormula()),
testId("formula-transform-top")
)
),
cssButtonRow(
basicButton(dom.on('click', () => this.cancel()),
'Cancel', testId("formula-transform-cancel")),
basicButton(dom.on('click', () => this.editor.writeObservable()),
'Preview',
dom.cls('disabled', this.formulaUpToDate),
{ title: 'Update formula (Shift+Enter)' },
testId("formula-transform-update")),
primaryButton(dom.on('click', () => this.execute()),
'Apply', testId("formula-transform-apply"))
),
];
}
public finalize() {
this.cancel();
}
}

View File

@ -0,0 +1,237 @@
.gridview_data_pane {
position: relative;
width: 100%;
overflow: hidden;
flex-grow: 1;
/* make sure that this element is at the back of the stack */
z-index: 0;
/* prevent browser selection of cells */
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
--gridview-header-height: 2.2rem;
}
.gridview_data_pane.newui {
--gridview-header-height: 24px;
}
.gridview_data_scroll {
/* Make it position properly */
position: absolute;
height: 100%;
width: 100%;
overflow: auto;
z-index: 2; /* scrollbar should be over the overlay background */
border-top: 1px solid lightgrey;
}
.gridview_data_pane.newui > .gridview_data_scroll {
border-top: none;
}
/* ====== Col header stuff */
.gridview_stick-top{
position: -webkit-sticky;
position: sticky;
top: 0px;
z-index: 2; /* z-index must be here, doesnt work on children*/
}
.gridview_data_header {
border-bottom: 1px solid lightgray;
position:relative;
}
.gridview_corner_spacer { /* spacer in .gridview_data_header */
width: 4rem; /* matches row_num width */
flex: none;
}
.field.column_name {
line-height: var(--gridview-header-height);
height: var(--gridview-header-height); /* Also should match height for overlay elements */
}
/* also .field.column_name, style set in viewCommon */
/* ====== Row stuff */
/* (more styles in viewCommon.css for .field, .record, etc) */
.gridview_row {
display:flex;
}
.gridview_data_row_num { /* Row nums, stick to the left side */
position: -webkit-sticky;
position: sticky;
left: 0px;
overflow: hidden;
width: 4rem; /* Also should match width for .gridview_header_corner, and the overlay elements */
border-bottom: 1px solid var(--grist-color-dark-grey);
background-color: var(--grist-color-light-grey);
z-index: 2; /* goes over data cells */
padding-top: 2px;
text-align: center;
font-size: 1rem;
cursor: pointer;
}
@media print {
/* For printing, !important tag is needed for background colors to be respected; but normally,
* do not want !important, as it interferes with row selection.
*/
.gridview_data_row_num {
background-color: var(--grist-color-light-grey) !important;
}
}
/* ========= Overlay styles ========== */
/* Positioned outside scrollpane, purely visual */
.gridview_data_corner_overlay,
.gridview_header_backdrop_top,
.gridview_header_backdrop_left,
.scroll_shadow_top,
.scroll_shadow_left {
position:absolute;
background-color: var(--grist-color-light-grey) !important;
}
.gridview_data_corner_overlay {
width: 4rem;
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
top: 1px; /* go under 1px border on scrollpane */
border-bottom: 1px solid lightgray;
z-index: 3;
}
.scroll_shadow_left {
height: 100%; /* Just needs to be tall enough to flow off the bottom*/
width: 0px;
left: 4rem;
box-shadow: -6px 0 6px 6px #444;
/* shadow should only show to the right of it (10px should be enough) */
-webkit-clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
clip-path: polygon(0 0, 10px 0, 10px 100%, 0 100%);
z-index: 3;
}
.scroll_shadow_top {
left: 0;
height: 0;
width: 100%; /* needs to be wide enough to flow off the side*/
top: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
box-shadow: 0 -6px 6px 6px #444;
/* should only show below it (10px should be enough) */
-webkit-clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0);
clip-path: polygon(0 0, 0 10px, 100% 10px, 100% 0);
z-index: 3;
}
.gridview_header_backdrop_left {
width: calc(4rem + 1px); /* Matches rowid width (+border) */
height:100%;
top: 1px; /* go under 1px border on scrollpane */
z-index: 1;
border-right: 1px solid lightgray;
}
.gridview_header_backdrop_top {
width: 100%;
height: calc(var(--gridview-header-height) + 1px); /* matches gridview_data_header height (+border) */
top: 1px; /* go under 1px border on scrollpane */
border-bottom: 1px solid lightgray;
z-index: 1;
}
.gridview_data_pane.newui > .scroll_shadow_top {
top: var(--gridview-header-height);
}
.gridview_data_pane.newui > .gridview_data_corner_overlay,
.gridview_data_pane.newui > .gridview_header_backdrop_top {
top: 0px;
}
/* End overlay styles */
/* ================ Row/col drag styles*/
.col_indicator_line{
width: 0px;
height: 100%;
position: absolute;
border: 2px solid gray;
z-index: 20;
top: 0px;
}
.column_shadow{
width: 0px;
height: 100%;
position: absolute;
border: 1px solid gray;
z-index: 15;
top: 0px;
background-color: #F0F0F0;
opacity: 0.5;
}
.row_indicator_line{
width: 100%;
height: 0px;
position: absolute;
border: 2px solid gray;
z-index: 20;
left: 0px;
}
.row_shadow{
width: 100%;
height: 0px;
position: absolute;
border: 1px solid gray;
z-index: 15;
left: 0px;
background-color: #F0F0F0;
opacity: 0.5;
pointer-events: none; /* prevents row drag shadow from stealing row headers clicks */
}
/* Etc */
.g-column-main-menu {
position: absolute;
top: 0;
right: 0;
}
.validation_error_number {
position: absolute;
top: -12px;
right: -12px;
width: 24px;
height: 24px;
padding-top: 10px;
padding-right: 10px;
border-radius: 12px;
text-align: center;
font-size: 10px;
font-weight: bold;
background: red;
color: white;
}
.column_name.mod-add-column {
border-right-width: 1px;
min-width: 40px;
padding-right: 12px;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,69 @@
/* container for main buttons */
.g-doc-menu-main {
flex: 1;
-webkit-flex: 1;
}
.btn.g_toolbar_symbol {
font-family: "Apple Symbols", "Arial Unicode MS";
font-size: 2rem;
line-height: 15px;
padding-top: 4px;
}
.big_symbol {
font-size: 2em;
line-height: 0.5;
vertical-align: middle;
}
.grist-doc-menu__view-title {
margin: auto; /* center */
text-align: center;
}
.view_main_pane {
width: 100%;
position: relative;
}
.view_main_pane.open_side_pane {
width: 75%;
min-width: 50%;
}
.add_section_btn {
width: 9.5rem;
text-align: left;
}
.add_section_icon {
position: relative;
background-color: white;
width: 1.6rem;
height: 1.2rem;
margin-left: 4px;
}
.section_icon {
position: absolute;
font-size: 1.05rem;
top: .2rem;
left: 0;
transform: scale(.9, 1);
}
.plus_icon {
position: absolute;
top: .35rem;
left: 1.1rem;
font-size: .5rem;
}
.download_btn {
font-size: 1.0rem;
color: black;
}
.relative {
position: relative;
}

View File

@ -0,0 +1,693 @@
/**
* GristDoc manages an open Grist document on the client side.
*/
// tslint:disable:no-console
import {ActionLog} from 'app/client/components/ActionLog';
import * as CodeEditorPanel from 'app/client/components/CodeEditorPanel';
import * as commands from 'app/client/components/commands';
import {CursorPos} from 'app/client/components/Cursor';
import {DocComm, DocUserAction} from 'app/client/components/DocComm';
import * as DocConfigTab from 'app/client/components/DocConfigTab';
import * as GridView from 'app/client/components/GridView';
import {Importer} from 'app/client/components/Importer';
import * as REPLTab from 'app/client/components/REPLTab';
import {ActionGroupWithCursorPos, UndoStack} from 'app/client/components/UndoStack';
import {ViewLayout} from 'app/client/components/ViewLayout';
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {DocPluginManager} from 'app/client/lib/DocPluginManager';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {createSessionObs} from 'app/client/lib/sessionObs';
import {setTestState} from 'app/client/lib/testState';
import {selectFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import * as DataTableModel from 'app/client/models/DataTableModel';
import {DataTableModelWithDiff} from 'app/client/models/DataTableModelWithDiff';
import {DocData} from 'app/client/models/DocData';
import {DocInfoRec, DocModel, ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {DocPageModel} from 'app/client/models/DocPageModel';
import {UserError} from 'app/client/models/errors';
import {IDocPage, urlState} from 'app/client/models/gristUrlState';
import {QuerySetManager} from 'app/client/models/QuerySet';
import {App} from 'app/client/ui/App';
import {DocHistory} from 'app/client/ui/DocHistory';
import {IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker';
import {IPageWidgetLink, linkFromId, selectBy} from 'app/client/ui/selectBy';
import {testId} from 'app/client/ui2018/cssVars';
import {IconName} from 'app/client/ui2018/IconList';
import {ActionGroup} from 'app/common/ActionGroup';
import {delay} from 'app/common/delay';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {isSchemaAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {HashLink} from 'app/common/gristUrls';
import {encodeQueryParams, waitObs} from 'app/common/gutil';
import {StringUnion} from 'app/common/StringUnion';
import {TableData} from 'app/common/TableData';
import {DocStateComparison} from 'app/common/UserAPI';
import {Computed, dom, Emitter, Holder, IDomComponent, subscribe, toKo} from 'grainjs';
import {IDisposable, Observable, styled} from 'grainjs';
import * as ko from 'knockout';
import cloneDeepWith = require('lodash/cloneDeepWith');
import isEqual = require('lodash/isEqual');
const G = getBrowserGlobals('document', 'window');
// Re-export DocComm to move it from main webpack bundle to the one with GristDoc.
export {DocComm};
export interface TabContent {
showObs?: any;
header?: boolean;
label?: any;
items?: any;
buildDom?: any;
keywords?: any;
}
export interface TabOptions {
shortLabel?: string;
hideSearchContent?: boolean;
showObs?: any;
category?: any;
}
const RightPanelTool = StringUnion("none", "docHistory", "validations", "repl");
export interface IExtraTool {
icon: IconName;
label: string;
content: TabContent[]|IDomComponent;
}
export class GristDoc extends DisposableWithEvents {
public docModel: DocModel;
public viewModel: ViewRec;
public activeViewId: Computed<IDocPage>;
public currentPageName: Observable<string>;
public docData: DocData;
public docInfo: DocInfoRec;
public docPluginManager: DocPluginManager;
public querySetManager: QuerySetManager;
public rightPanelTool: Observable<IExtraTool|null>;
public isReadonly = this.docPageModel.isReadonly;
public isReadonlyKo = toKo(ko, this.isReadonly);
public comparison: DocStateComparison|null;
// Emitter triggered when the main doc area is resized.
public readonly resizeEmitter = this.autoDispose(new Emitter());
// This holds a single FieldEditor. When a new FieldEditor is created (on edit), it replaces the
// previous one if any. The holder is maintained by GristDoc, so that we are guaranteed at
// most one instance of FieldEditor at any time.
public readonly fieldEditorHolder = Holder.create(this);
private _actionLog: ActionLog;
private _undoStack: UndoStack;
private _lastOwnActionGroup: ActionGroupWithCursorPos|null = null;
private _rightPanelTabs = new Map<string, TabContent[]>();
private _docHistory: DocHistory;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _viewLayout: ViewLayout|null = null;
constructor(
public readonly app: App,
public readonly docComm: DocComm,
public readonly docPageModel: DocPageModel,
openDocResponse: OpenLocalDocResult,
options: {
comparison?: DocStateComparison // initial comparison with another document
} = {}
) {
super();
console.log("RECEIVED DOC RESPONSE", openDocResponse.doc);
this.docData = new DocData(this.docComm, openDocResponse.doc);
this.docModel = new DocModel(this.docData);
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
this.docPluginManager = new DocPluginManager(openDocResponse.plugins, app.getUntrustedContentOrigin(),
this.docComm, app.clientScope);
// Maintain the MetaRowModel for the global document info, including docId and peers.
this.docInfo = this.docModel.docInfo.getRowModel(1);
const defaultViewId = this.docInfo.newDefaultViewId;
// Grainjs observable for current view id, which may be a string such as 'code'.
this.activeViewId = Computed.create(this, urlState().state, (use, s) => s.docPage || defaultViewId.peek());
// This viewModel reflects the currently active view, relying on the fact that
// createFloatingRowModel() supports an observable rowId for its argument.
// Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values,
// which yield an empty row, which is why we can cast activeViewId.
this.viewModel = this.autoDispose(
this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed<number>));
// Grainjs observable reflecting the name of the current document page.
this.currentPageName = Computed.create(this, this.activeViewId,
(use, docPage) => typeof docPage === 'number' ? use(this.viewModel.name) : docPage);
// Whenever the active viewModel is deleted, switch to the default view.
this.autoDispose(this.viewModel._isDeleted.subscribe((isDeleted) => {
if (isDeleted) {
// This should not be done synchronously, as that affects the same viewModel that triggered
// this callback, and causes some obscure effects on knockout subscriptions.
Promise.resolve().then(() => urlState().pushUrl({docPage: undefined})).catch(() => null);
}
}));
// Navigate to an anchor if one is present in the url hash.
this.autoDispose(subscribe(urlState().state, async (use, state) => {
if (state.hash) {
try {
const cursorPos = getCursorPosFromHash(state.hash);
await this._recursiveMoveToCursorPos(cursorPos, true, state.hash && state.hash.colRef);
} catch (e) {
reportError(e);
} finally {
setTimeout(finalizeAnchor, 0);
}
}
}));
// Importer takes a function for creating previews.
const createPreview = (vs: ViewSectionRec) => GridView.create(this, vs, true);
const importSourceElems = ImportSourceElement.fromArray(this.docPluginManager.pluginsList);
const importMenuItems = [
{
label: 'Import from file',
action: () => Importer.selectAndImport(this, null, createPreview),
},
...importSourceElems.map(importSourceElem => ({
label: importSourceElem.importSource.label,
action: () => Importer.selectAndImport(this, importSourceElem, createPreview)
}))
];
// Set the available import sources in the DocPageModel.
this.docPageModel.importSources = importMenuItems;
this._actionLog = this.autoDispose(ActionLog.create({ gristDoc: this }));
this._undoStack = this.autoDispose(UndoStack.create(openDocResponse.log, { gristDoc: this }));
this._docHistory = DocHistory.create(this, this.docPageModel, this._actionLog);
// Tap into docData's sendActions method to save the cursor position with every action, so that
// undo/redo can jump to the right place.
this.autoDispose(this.docData.sendActionsEmitter.addListener(this._onSendActionsStart, this));
this.autoDispose(this.docData.sendActionsDoneEmitter.addListener(this._onSendActionsEnd, this));
/* Command binding */
this.autoDispose(commands.createGroup({
undo() { this._undoStack.sendUndoAction(); },
redo() { this._undoStack.sendRedoAction(); },
reloadPlugins() { this.docComm.reloadPlugins().then(() => G.window.location.reload(false)); },
}, this, true));
this.listenTo(app.comm, 'docUserAction', this.onDocUserAction);
this.autoDispose(DocConfigTab.create({gristDoc: this}));
const replTab = this.autoDispose(REPLTab.create(this));
this.autoDispose(this.addOptionsTab(
'REPL', dom('span.glyphicon.glyphicon-console'),
replTab.buildConfigDomObj(),
{ hideSearchContent: true }
));
this.rightPanelTool = Computed.create(this, (use) => this._getToolContent(use(this._rightPanelTool)));
this.comparison = options.comparison || null;
// We need prevent default here to allow drop events to fire.
this.autoDispose(dom.onElem(window, 'dragover', (ev) => ev.preventDefault()));
// The default action is to open dragged files as a link, navigating out of the app.
this.autoDispose(dom.onElem(window, 'drop', (ev) => ev.preventDefault()));
}
public addOptionsTab(label: string, iconElem: any, contentObj: TabContent[], options: TabOptions): IDisposable {
this._rightPanelTabs.set(label, contentObj);
// Return a do-nothing disposable, to satisfy the previous interface.
return {dispose: () => null};
}
/**
* Builds the DOM for this GristDoc.
*/
public buildDom() {
return cssViewContentPane(testId('gristdoc'),
dom.domComputed<IDocPage>(this.activeViewId, (viewId) => (
viewId === 'code' ? dom.create((owner) => owner.autoDispose(CodeEditorPanel.create(this))) :
viewId === 'new' ? null :
dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, viewId)))
)),
);
}
// Open the given page. Note that links to pages should use <a> elements together with setLinkUrl().
public openDocPage(viewId: IDocPage) {
return urlState().pushUrl({docPage: viewId});
}
public showTool(tool: typeof RightPanelTool.type): void {
this._rightPanelTool.set(tool);
}
/**
* Returns an object representing the position of the cursor, including the section. It will have
* fields { sectionId, rowId, fieldIndex }. Fields may be missing if no section is active.
*/
public getCursorPos(): CursorPos {
const pos = { sectionId: this.viewModel.activeSectionId() };
const viewInstance = this.viewModel.activeSection.peek().viewInstance.peek();
return Object.assign(pos, viewInstance ? viewInstance.cursor.getCursorPos() : {});
}
/**
* Switch to the view/section and scroll to the record indicated by cursorPos. If cursorPos is
* null, then moves to a position best suited for optActionGroup (not yet implemented).
*/
public moveToCursorPos(cursorPos?: CursorPos, optActionGroup?: ActionGroup): void {
if (!cursorPos || cursorPos.sectionId == null) {
// TODO We could come up with a suitable cursorPos here based on the action itself.
// This should only come up if trying to undo/redo after reloading a page (since the cursorPos
// associated with the action is only stored in memory of the current JS process).
// A function like `getCursorPosForActionGroup(ag)` would also be useful to jump to the best
// place from any action in the action log.
return;
}
this._switchToSectionId(cursorPos.sectionId)
.then(viewInstance => (viewInstance && viewInstance.setCursorPos(cursorPos)))
.catch(reportError);
}
/**
* Process actions received from the server by forwarding them to `docData.receiveAction()` and
* pushing them to actionLog.
*/
public onDocUserAction(message: DocUserAction) {
console.log("GristDoc.onDocUserAction", message);
let schemaUpdated = false;
if (this.docComm.isActionFromThisDoc(message)) {
const docActions = message.data.docActions;
for (let i = 0, len = docActions.length; i < len; i++) {
console.log("GristDoc applying #%d", i, docActions[i]);
this.docData.receiveAction(docActions[i]);
this.docPluginManager.receiveAction(docActions[i]);
if (!schemaUpdated && isSchemaAction(docActions[i])) {
schemaUpdated = true;
}
}
// Add fromSelf property to actionGroup indicating if it's from the current session.
const actionGroup = message.data.actionGroup;
actionGroup.fromSelf = message.fromSelf || false;
// Push to the actionLog and the undoStack.
if (!actionGroup.internal) {
this._actionLog.pushAction(actionGroup);
this._undoStack.pushAction(actionGroup);
if (actionGroup.fromSelf) {
this._lastOwnActionGroup = actionGroup;
}
}
if (schemaUpdated) {
this.trigger('schemaUpdateAction', docActions);
}
}
}
public getTableModel(tableId: string): DataTableModel {
return this.docModel.dataTables[tableId];
}
// Get a DataTableModel, possibly wrapped to include diff data if a comparison is
// in effect.
public getTableModelMaybeWithDiff(tableId: string): DataTableModel {
const tableModel = this.getTableModel(tableId);
if (!this.comparison?.details) { return tableModel; }
// TODO: cache wrapped models and share between views.
return new DataTableModelWithDiff(tableModel, this.comparison.details);
}
/**
* Sends an action to create a new empty table and switches to that table's primary view.
*/
public async addEmptyTable(): Promise<void> {
const tableInfo = await this.docData.sendAction(['AddEmptyTable']);
await this.openDocPage(this.docModel.tables.getRowModel(tableInfo.id).primaryViewId());
}
/**
* Adds a view section described by val to the current page.
*/
public async addWidgetToPage(val: IPageWidget) {
const docData = this.docModel.docData;
const viewName = this.viewModel.name.peek();
const res = await docData.bundleActions(
`Added new linked section to view ${viewName}`,
() => this.addWidgetToPageImpl(val)
);
// The newly-added section should be given focus.
this.viewModel.activeSectionId(res.sectionRef);
}
/**
* The actual implementation of addWidgetToPage
*/
public async addWidgetToPageImpl(val: IPageWidget) {
const viewRef = this.activeViewId.get();
const tableRef = val.table === 'New Table' ? 0 : val.table;
const link = linkFromId(val.link);
const result = await this.docData.sendAction(
['CreateViewSection', tableRef, viewRef, val.type, val.summarize ? val.columns : null]
);
await this.docData.sendAction(
['UpdateRecord', '_grist_Views_section', result.sectionRef, {
linkSrcSectionRef: link.srcSectionRef,
linkSrcColRef: link.srcColRef,
linkTargetColRef: link.targetColRef
}]
);
return result;
}
/**
* Adds a new page (aka: view) with a single view section (aka: page widget) described by `val`.
*/
public async addNewPage(val: IPageWidget) {
if (val.table === 'New Table') {
const result = await this.docData.sendAction(['AddEmptyTable']);
await this.openDocPage(result.views[0].id);
} else {
const result = await this.docData.sendAction(
['CreateViewSection', val.table, 0, val.type, val.summarize ? val.columns : null]
);
await this.openDocPage(result.viewRef);
// The newly-added section should be given focus.
this.viewModel.activeSectionId(result.sectionRef);
}
}
/**
* Opens a dialog to upload one or multiple files as tables and then switches to the first table's
* primary view.
*/
public async uploadNewTable(): Promise<void> {
const uploadResult = await selectFiles({docWorkerUrl: this.docComm.docWorkerUrl,
multiple: true});
if (uploadResult) {
const dataSource = {uploadId: uploadResult.uploadId, transforms: []};
const importResult = await this.docComm.finishImportFiles(dataSource, {}, []);
const tableId = importResult.tables[0].hiddenTableId;
const tableRowModel = this.docModel.dataTables[tableId].tableMetaRow;
await this.openDocPage(tableRowModel.primaryViewId());
}
}
public async saveViewSection(section: ViewSectionRec, newVal: IPageWidget) {
const docData = this.docModel.docData;
const oldVal: IPageWidget = toPageWidget(section);
const viewModel = section.view();
if (isEqual(oldVal, newVal)) {
// nothing to be done
return;
}
await this._viewLayout!.freezeUntil(docData.bundleActions(
`Saved linked section ${section.title()} in view ${viewModel.name()}`,
async () => {
// if table changes or a table is made a summary table, let's replace the view section by a
// new one, and return.
if (oldVal.table !== newVal.table || oldVal.summarize !== newVal.summarize) {
await this._replaceViewSection(section, newVal);
return;
}
// if type changes, let's save it.
if (oldVal.type !== newVal.type) {
await section.parentKey.saveOnly(newVal.type);
}
// if grouped by column changes, let's use the specific user action.
if (!isEqual(oldVal.columns, newVal.columns)) {
await docData.sendAction(
['UpdateSummaryViewSection', section.getRowId(), newVal.columns]
);
}
// update link
if (oldVal.link !== newVal.link) {
await this.saveLink(linkFromId(newVal.link));
}
}
));
}
// Save link for the active section.
public async saveLink(link: IPageWidgetLink) {
const viewModel = this.viewModel;
return this.docData.sendAction(
['UpdateRecord', '_grist_Views_section', viewModel.activeSection.peek().getRowId(), {
linkSrcSectionRef: link.srcSectionRef,
linkSrcColRef: link.srcColRef,
linkTargetColRef: link.targetColRef
}]
);
}
// Returns the list of all the valid links to link from one of the sections in the active view to
// the page widget 'widget'.
public selectBy(widget: IPageWidget) {
const viewSections = this.viewModel.viewSections.peek().peek();
return selectBy(this.docModel, viewSections, widget);
}
// Fork the document if it is in prefork mode.
public async forkIfNeeded() {
if (this.docPageModel.isPrefork.get()) {
await this.docComm.forkAndUpdateUrl();
}
}
public getDownloadLink() {
return this.docComm.docUrl('download') + '?' + encodeQueryParams({
doc: this.docPageModel.currentDocId.get(),
title: this.docPageModel.currentDocTitle.get(),
...this.docComm.getUrlParams(),
});
}
public getCsvLink() {
return this.docComm.docUrl('gen_csv') + '?' + encodeQueryParams({
...this.docComm.getUrlParams(),
title: this.docPageModel.currentDocTitle.get(),
viewSection: this.viewModel.activeSectionId(),
tableId: this.viewModel.activeSection().table().tableId(),
activeSortSpec: JSON.stringify(this.viewModel.activeSection().activeSortSpec())
});
}
private _getToolContent(tool: typeof RightPanelTool.type): IExtraTool|null {
switch (tool) {
case 'docHistory': {
return {icon: 'Log', label: 'Document History', content: this._docHistory};
}
case 'validations': {
const content = this._rightPanelTabs.get("Validate Data");
return content ? {icon: 'Validation', label: 'Validation Rules', content} : null;
}
case 'repl': {
const content = this._rightPanelTabs.get("REPL");
return content ? {icon: 'Repl', label: 'REPL', content} : null;
}
case 'none':
default: {
return null;
}
}
}
private async _replaceViewSection(section: ViewSectionRec, newVal: IPageWidget) {
const docModel = this.docModel;
const viewModel = section.view();
const docData = this.docModel.docData;
// we must read the current layout from the view layout because it can override the one in
// `section.layoutSpec` (in particular it provides a default layout when missing from the
// latter).
const layoutSpec = this._viewLayout!.layoutSpec();
const sectionTitle = section.title();
const sectionId = section.id();
// create a new section
const sectionCreationResult = await this.addWidgetToPageImpl(newVal);
// update section name
const newSection: ViewSectionRec = docModel.viewSections.getRowModel(sectionCreationResult.sectionRef);
await newSection.title.saveOnly(sectionTitle);
// replace old section id with new section id in the layout spec and save
const newLayoutSpec = cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object' && val.leaf === sectionId) {
return {...val, leaf: newSection.id()};
}
});
await viewModel.layoutSpec.saveOnly(JSON.stringify(newLayoutSpec));
// The newly-added section should be given focus.
this.viewModel.activeSectionId(newSection.getRowId());
// remove old section
await docData.sendAction(['RemoveViewSection', sectionId]);
}
/**
* Helper called before an action is sent to the server. It saves cursor position to come back to
* in case of Undo.
*/
private _onSendActionsStart(ev: {cursorPos: CursorPos}) {
this._lastOwnActionGroup = null;
ev.cursorPos = this.getCursorPos();
}
/**
* Helper called when server responds to an action. It attaches the saved cursor position to the
* received action (if any), and stores also the resulting position.
*/
private _onSendActionsEnd(ev: {cursorPos: CursorPos}) {
const a = this._lastOwnActionGroup;
if (a) {
a.cursorPos = ev.cursorPos;
if (a.rowIdHint) {
a.cursorPos.rowId = a.rowIdHint;
}
}
}
/**
* Switch to a given sectionId, wait for it to load, and return a Promise for the instantiated
* viewInstance (such as an instance of GridView or DetailView).
*/
private async _switchToSectionId(sectionId: number) {
const section: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionId);
const view: ViewRec = section.view.peek();
await this.openDocPage(view.getRowId());
view.activeSectionId(sectionId); // this.viewModel will reflect this with a delay.
// Returns the value of section.viewInstance() as soon as it is truthy.
return waitObs(section.viewInstance);
}
/**
* Move to the desired cursor position. If colRef is supplied, the cursor will be
* moved to a field with that colRef. Any linked sections that need their cursors
* moved in order to achieve the desired outcome are handled recursively.
* If setAsActiveSection is true, the section in cursorPos is set as the current
* active section.
*/
private async _recursiveMoveToCursorPos(cursorPos: CursorPos, setAsActiveSection: boolean,
colRef?: number): Promise<void> {
try {
if (!cursorPos.sectionId) { throw new Error('sectionId required'); }
if (!cursorPos.rowId) { throw new Error('rowId required'); }
const section = this.docModel.viewSections.getRowModel(cursorPos.sectionId);
const srcSection = section.linkSrcSection.peek();
if (srcSection.id.peek()) {
// We're in a linked section, so we need to recurse to make sure the row we want
// will be visible.
const linkTargetCol = section.linkTargetCol.peek();
let controller: any;
if (linkTargetCol.colId.peek()) {
const destTable = await this._getTableData(section);
controller = destTable.getValue(cursorPos.rowId, linkTargetCol.colId.peek());
} else {
controller = cursorPos.rowId;
}
const colId = section.linkSrcCol.peek().colId.peek();
let srcRowId: any;
const isSrcSummary = srcSection.table.peek().summarySource.peek().id.peek();
if (!colId && !isSrcSummary) {
// Simple case - source linked by rowId, not a summary.
srcRowId = controller;
} else {
const srcTable = await this._getTableData(srcSection);
if (!colId) {
// must be a summary -- otherwise dealt with earlier.
const destTable = await this._getTableData(section);
const filter: {[key: string]: any} = {};
for (const c of srcSection.table.peek().columns.peek().peek()) {
if (c.summarySourceCol.peek()) {
const filterColId = c.summarySource.peek().colId.peek();
const destValue = destTable.getValue(cursorPos.rowId, filterColId);
filter[filterColId] = destValue;
}
}
const result = srcTable.filterRecords(filter); // Should just have one record, or 0.
srcRowId = result[0] && result[0].id;
} else {
srcRowId = srcTable.findRow(colId, controller);
}
}
if (!srcRowId || typeof srcRowId !== 'number') { throw new Error('cannot trace rowId'); }
await this._recursiveMoveToCursorPos({
rowId: srcRowId,
sectionId: srcSection.id.peek()
}, false);
}
const view: ViewRec = section.view.peek();
await this.openDocPage(view.getRowId());
if (setAsActiveSection) { view.activeSectionId(cursorPos.sectionId); }
const fieldIndex = colRef ? section.viewFields().peek().findIndex(f => f.colRef.peek() === colRef) : undefined;
const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); }
// Give any synchronous initial cursor setting a chance to happen.
await delay(0);
viewInstance.setCursorPos({...cursorPos, fieldIndex});
// TODO: column selection not working on card/detail view, or getting overridden -
// look into it (not a high priority for now since feature not easily discoverable
// in this view).
} catch (e) {
console.debug(`_recursiveMoveToCursorPos(${JSON.stringify(cursorPos)}): ${e}`);
throw new UserError('There was a problem finding the desired cell.');
}
}
private async _getTableData(section: ViewSectionRec): Promise<TableData> {
const viewInstance = await waitObs(section.viewInstance);
if (!viewInstance) { throw new Error('view not found'); }
await viewInstance.getLoadingDonePromise();
const table = this.docData.getTable(section.table.peek().tableId.peek());
if (!table) { throw new Error('no section table'); }
return table;
}
}
/**
* Convert a url hash to a cursor position.
*/
function getCursorPosFromHash(hash: HashLink): CursorPos {
return { rowId: hash.rowId, sectionId: hash.sectionId };
}
async function finalizeAnchor() {
await urlState().pushUrl({ hash: {} }, { replace: true });
setTestState({anchorApplied: true});
}
const cssViewContentPane = styled('div', `
flex: auto;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
min-width: 240px;
margin: 12px;
`);

View File

@ -0,0 +1,379 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
import {guessTimezone} from 'app/client/lib/guessTimezone';
import {getWorker} from 'app/client/models/gristConfigCache';
import * as gutil from 'app/common/gutil';
import {addOrgToPath, docUrl, getGristConfig} from 'app/common/urlUtils';
import {UserAPI, UserAPIImpl} from 'app/common/UserAPI';
import {Events as BackboneEvents} from 'backbone';
import {Disposable} from 'grainjs';
const G = getBrowserGlobals('window');
const reconnectInterval = [1000, 1000, 2000, 5000, 10000];
// Time that may elapse prior to triggering a heartbeat message. This is a message
// sent in order to keep the websocket from being closed by an intermediate load
// balancer.
const HEARTBEAT_PERIOD_IN_SECONDS = 45;
// Find the correct worker to connect to for the currently viewed doc,
// returning a base url for endpoints served by that worker. The url
// may need to change again in future.
async function getDocWorkerUrl(assignmentId: string|null): Promise<string|null> {
// Currently, a null assignmentId happens only in classic Grist, where the server
// never changes.
if (assignmentId === null) { return docUrl(null); }
const api: UserAPI = new UserAPIImpl(getGristConfig().homeUrl!);
return getWorker(api, assignmentId);
}
/**
* Settings for the Grist websocket connection. Includes timezone, urls, and client id,
* and various services needed for the connection.
*/
export interface GristWSSettings {
// A factory function for creating the WebSocket so that we can use from node
// or browser.
makeWebSocket(url: string): WebSocket;
// A function for getting the timezone, so the code can be used outside webpack -
// currently a timezone library is lazy loaded in a way that doesn't quite work
// with ts-node.
getTimezone(): Promise<string>;
// Get the page url - this is how the organization is currently determined.
getPageUrl(): string;
// Get the URL for the worker serving the given assignmentId (which is usually a docId).
getDocWorkerUrl(assignmentId: string|null): Promise<string|null>;
// Get an id associated with the client, null for "no id set yet".
getClientId(assignmentId: string|null): string|null;
// Update the id associated with the client. Future calls to getClientId should return this.
updateClientId(assignmentId: string|null, clentId: string): void;
// Returns the next identifier for a new GristWSConnection object, and advance the counter.
advanceCounter(): string;
// Called with messages to log.
log(...args: any[]): void;
warn(...args: any[]): void;
}
/**
* An implementation of Grist websocket connection settings for the browser.
*/
export class GristWSSettingsBrowser implements GristWSSettings {
public makeWebSocket(url: string) { return new WebSocket(url); }
public getTimezone() { return guessTimezone(); }
public getPageUrl() { return G.window.location.href; }
public async getDocWorkerUrl(assignmentId: string|null) {
return getDocWorkerUrl(assignmentId);
}
public getClientId(assignmentId: string|null) {
return window.sessionStorage.getItem(`clientId_${assignmentId}`) || null;
}
public updateClientId(assignmentId: string|null, id: string) {
window.sessionStorage.setItem(`clientId_${assignmentId}`, id);
}
public advanceCounter(): string {
const value = parseInt(window.sessionStorage.getItem('clientCounter')!, 10) || 0;
window.sessionStorage.setItem('clientCounter', String(value + 1));
return String(value);
}
public log(...args: any[]): void {
console.log(...args); // tslint:disable-line:no-console
}
public warn(...args: any[]): void {
console.warn(...args); // tslint:disable-line:no-console
}
}
/**
* GristWSConnection establishes a connection to the server and keep reconnecting
* in the event that it loses the connection.
*/
export class GristWSConnection extends Disposable {
public useCount: number = 0;
public on: BackboneEvents['on']; // set by Backbone
private _clientId: string|null;
private _clientCounter: string; // Identifier of this GristWSConnection object in this browser tab session
private _assignmentId: string|null;
private _docWorkerUrl: string|null = null;
private _initialConnection: Promise<void>;
private _established: boolean = false; // This is set once the server sends us a 'clientConnect' message.
private _firstConnect: boolean = true;
private _heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
private _reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private _reconnectAttempts: number = 0;
private _wantReconnect: boolean = true;
private _ws: WebSocket|null = null;
private trigger: BackboneEvents['trigger']; // set by Backbone
constructor(private _settings: GristWSSettings = new GristWSSettingsBrowser()) {
super();
this._clientCounter = _settings.advanceCounter();
this.onDispose(() => this.disconnect());
}
public initialize(assignmentId: string|null) {
// For reconnections, squirrel away the id of the resource we are committed to (if any).
this._assignmentId = assignmentId;
// clientId is associated with a session. We try to persist it within a tab across navigation
// and reloads, but the server may reset it if it doesn't recognize it.
this._clientId = this._settings.getClientId(assignmentId);
// For the DocMenu, identified as a page served with a homeUrl but no getWorker cache, we will
// simply not hook up the websocket. The client is not ready to use it, and the server is not
// ready to serve it. And the errors in the logs of both are distracting. However, it
// doesn't really make sense to rip out the websocket code entirely, since the plan is
// to eventually bring it back for smoother serving. Hence this compromise of simply
// not trying to make the connection.
// TODO: serve and use websockets for the DocMenu.
if (getGristConfig().getWorker) {
this.trigger('connectState', false);
this._initialConnection = this.connect();
} else {
this._log("GristWSConnection not activating for hosted grist page with no document present");
}
}
/**
* Method that opens a websocket connection and continuously tries to reconnect if the connection
* is closed.
* @param isReconnecting - Flag set when attempting to reconnect
*/
public async connect(isReconnecting: boolean = false): Promise<void> {
await this._updateDocWorkerUrl();
this._wantReconnect = true;
this._connectImpl(isReconnecting, await this._settings.getTimezone());
}
// Disconnect websocket if currently connected, and reset to initial state.
public disconnect() {
this._log('GristWSConnection: disconnect');
this._wantReconnect = false;
this._established = false;
if (this._ws) {
this._ws.close();
this._ws = null;
this._clientId = null;
}
this._clearHeartbeat();
if (this._reconnectTimeout) {
clearTimeout(this._reconnectTimeout);
}
this._firstConnect = true;
this._reconnectAttempts = 0;
}
public get established(): boolean {
return this._established;
}
public get clientId(): string|null {
return this._clientId;
}
/**
* Returns the URL of the doc worker, or throws if we don't have one.
*/
public get docWorkerUrl(): string {
if (!this._docWorkerUrl) { throw new Error('server for document not known'); }
return this._docWorkerUrl;
}
/**
* Returns the URL of the doc worker, or null if we don't have one.
*/
public getDocWorkerUrlOrNull(): string | null {
return this._docWorkerUrl;
}
/**
* @event serverMessage Triggered when a message arrives from the server. Callbacks receive
* the raw message data as an additional argument.
*/
public onmessage(ev: any) {
this._log('GristWSConnection: onmessage (%d bytes)', ev.data.length);
this._scheduleHeartbeat();
const message = JSON.parse(ev.data);
// clientConnect is the first message from the server that sets the clientId. We only consider
// the connection established once we receive it.
if (message.type === 'clientConnect') {
if (this._established) {
this._log("GristWSConnection skipping duplicate 'clientConnect' message");
return;
}
this._established = true;
// Add a flag to the message to indicate if the active session changed, and warrants a reload.
message.resetClientId = (message.clientId !== this._clientId && !this._firstConnect);
this._log(`GristWSConnection established: clientId ${message.clientId} counter ${this._clientCounter}` +
` resetClientId ${message.resetClientId}`);
if (message.dup) {
this._warn("GristWSConnection missed initial 'clientConnect', processing its duplicate");
}
if (message.clientId !== this._clientId) {
this._clientId = message.clientId;
if (this._settings) {
this._settings.updateClientId(this._assignmentId, message.clientId);
}
}
this._firstConnect = false;
this.trigger('connectState', true);
// Process any missed messages. (Should only have any if resetClientId is false.)
for (const msg of message.missedMessages) {
this.trigger('serverMessage', JSON.parse(msg));
}
}
if (!this._established) {
this._log("GristWSConnection not yet established; ignoring message", message);
return;
}
this.trigger('serverMessage', message);
}
public send(message: any) {
this._log(`GristWSConnection.send[${this.established}]`, message);
if (!this._established) {
return false;
}
this._ws!.send(message);
this._scheduleHeartbeat();
return true;
}
// unschedule any pending heartbeat message
private _clearHeartbeat() {
if (this._heartbeatTimeout) {
clearTimeout(this._heartbeatTimeout);
this._heartbeatTimeout = null;
}
}
// schedule a heartbeat message for HEARTBEAT_PERIOD_IN_SECONDS seconds from now
private _scheduleHeartbeat() {
this._clearHeartbeat();
this._heartbeatTimeout = setTimeout(this._sendHeartbeat.bind(this),
Math.round(HEARTBEAT_PERIOD_IN_SECONDS * 1000));
}
// send a heartbeat message, including the document url for server-side logs
private _sendHeartbeat() {
this.send(JSON.stringify({
beat: 'alive',
url: G.window.location.href,
}));
}
private _connectImpl(isReconnecting: boolean, timezone: any) {
if (!this._wantReconnect) { return; }
if (isReconnecting) {
this._reconnectAttempts++;
}
// Note that if a WebSocket can't establish a connection it will trigger onclose()
// As per http://dev.w3.org/html5/websockets/
// "If the establish a WebSocket connection algorithm fails,
// it triggers the fail the WebSocket connection algorithm,
// which then invokes the close the WebSocket connection algorithm,
// which then establishes that the WebSocket connection is closed,
// which fires the close event."
const url = this._buildWebsocketUrl(isReconnecting, timezone);
this._log("GristWSConnection connecting to: " + url);
this._ws = this._settings.makeWebSocket(url);
this._ws.onopen = () => {
const connectMessage = isReconnecting ? 'Reconnected' : 'Connected';
this._log('GristWSConnection: onopen: ' + connectMessage);
this.trigger('connectionStatus', connectMessage, 'OK');
this._reconnectAttempts = 0; // reset reconnection information
this._scheduleHeartbeat();
};
this._ws.onmessage = this.onmessage.bind(this);
this._ws.onerror = (ev: Event) => {
this._log('GristWSConnection: onerror', ev);
};
this._ws.onclose = () => {
if (this._settings) {
this._log('GristWSConnection: onclose');
}
if (this.isDisposed()) {
return;
}
this._established = false;
this._ws = null;
this.trigger('connectState', false);
if (!this._wantReconnect) { return; }
const reconnectTimeout = gutil.getReconnectTimeout(this._reconnectAttempts, reconnectInterval);
this._log("Trying to reconnect in", reconnectTimeout, "ms");
this.trigger('connectionStatus', 'Trying to reconnect...', 'WARNING');
this._reconnectTimeout = setTimeout(async () => {
this._reconnectTimeout = null;
// Make sure we've gotten through all lazy-loading.
await this._initialConnection;
await this.connect(true);
}, reconnectTimeout);
};
}
private _buildWebsocketUrl(isReconnecting: boolean, timezone: any): string {
const url = new URL(this.docWorkerUrl);
url.protocol = (url.protocol === 'https:') ? 'wss:' : 'ws:';
url.searchParams.append('clientId', this._clientId || '0');
url.searchParams.append('counter', this._clientCounter);
url.searchParams.append('newClient', String(isReconnecting ? 0 : 1));
url.searchParams.append('browserSettings', JSON.stringify({timezone}));
return url.href;
}
private async _updateDocWorkerUrl() {
try {
const url: string|null = await this._settings.getDocWorkerUrl(this._assignmentId);
// Doc worker urls in general will need to have org information in them, since
// the doc worker will check for that. The home service doesn't currently do
// that for us, although it could. TODO: update home server to produce
// standalone doc worker urls.
this._docWorkerUrl = url ? addOrgToPath(url, this._settings.getPageUrl()) : url;
} catch (e) {
this._warn('Failed to connect to server for document');
}
}
// Log a message using the configured logger, or send it to console if no
// logger available.
private _log(...args: any[]): void {
if (!this._settings) {
// tslint:disable-next-line:no-console
console.warn('log called without settings in GristWSConnection');
console.log(...args); // tslint:disable-line:no-console
} else {
this._settings.log(...args);
}
}
// Log a warning using the configured logger, or send it to console if no
// logger available.
private _warn(...args: any[]): void {
if (!this._settings) {
// tslint:disable-next-line:no-console
console.warn('warn called without settings in GristWSConnection');
console.warn(...args); // tslint:disable-line:no-console
} else {
this._settings.warn(...args);
}
}
}
Object.assign(GristWSConnection.prototype, BackboneEvents);

View File

@ -0,0 +1,509 @@
/**
* Importer manages an import files to Grist tables
* TODO: hidden tables should be also deleted on page refresh, error...
*/
// tslint:disable:no-console
import {GristDoc} from "app/client/components/GristDoc";
import {buildParseOptionsForm, ParseOptionValues} from 'app/client/components/ParseOptions';
import {ImportSourceElement} from 'app/client/lib/ImportSourceElement';
import {fetchURL, selectFiles, uploadFiles} from 'app/client/lib/uploads';
import {reportError} from 'app/client/models/AppModel';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {openFilePicker} from "app/client/ui/FileDialog";
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {colors, testId, vars} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {IOptionFull, linkSelect} from 'app/client/ui2018/menus';
import {cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals';
import {DataSourceTransformed, ImportResult, ImportTableResult} from "app/common/ActiveDocAPI";
import {TransformColumn, TransformRule, TransformRuleMap} from "app/common/ActiveDocAPI";
import {byteString} from "app/common/gutil";
import {UploadResult} from 'app/common/uploads';
import {ParseOptions, ParseOptionSchema} from 'app/plugin/FileParserAPI';
import {RenderTarget} from 'app/plugin/RenderOptions';
import {Computed, Disposable, dom, DomContents, IDisposable, Observable, styled} from 'grainjs';
// Special values for import destinations; null means "new table".
// TODO We should also support "skip table" (needs server support), so that one can open, say,
// an Excel file with many tabs, and import only some of them.
type DestId = string | null;
// We expect a function for creating the preview GridView, to avoid the need to require the
// GridView module here. That brings many dependencies, making a simple test fixture difficult.
type CreatePreviewFunc = (vs: ViewSectionRec) => GridView;
type GridView = IDisposable & {viewPane: HTMLElement};
// SourceInfo conteains information about source table and corresponding destination table id,
// transform sectionRef (can be used to show previous transform section with users changes)
// and also originalFilename and path.
export interface SourceInfo {
hiddenTableId: string;
uploadFileIndex: number;
origTableName: string;
sourceSection: ViewSectionRec;
transformSection: Observable<ViewSectionRec>;
destTableId: Observable<DestId>;
}
/**
* Importer manages an import files to Grist tables and shows Preview
*/
export class Importer extends Disposable {
/**
* Imports using the given plugin importer, or the built-in file-picker when null is passed in.
*/
public static async selectAndImport(
gristDoc: GristDoc, importSourceElem: ImportSourceElement|null, createPreview: CreatePreviewFunc
) {
// In case of using built-in file picker we want to get upload result before instantiating Importer
// because if the user dismisses the dialog without picking a file,
// there is no good way to detect this and dispose Importer.
let uploadResult: UploadResult|null = null;
if (!importSourceElem) {
// Use the built-in file picker. On electron, it uses the native file selector (without
// actually uploading anything), which is why this requires a slightly different flow.
const files: File[] = await openFilePicker({multiple: true});
// Important to fork first before trying to import, so we end up uploading to a
// consistent doc worker.
await gristDoc.forkIfNeeded();
const label = files.map(f => f.name).join(', ');
const size = files.reduce((acc, f) => acc + f.size, 0);
const app = gristDoc.app.topAppModel.appObs.get();
const progress = app ? app.notifier.createProgressIndicator(label, byteString(size)) : null;
const onProgress = (percent: number) => progress && progress.setProgress(percent);
try {
onProgress(0);
uploadResult = await uploadFiles(files, {docWorkerUrl: gristDoc.docComm.docWorkerUrl,
sizeLimit: 'import'}, onProgress);
onProgress(100);
} finally {
if (progress) {
progress.dispose();
}
}
}
// Importer disposes itself when its dialog is closed, so we do not take ownership of it.
Importer.create(null, gristDoc, importSourceElem, createPreview).pickAndUploadSource(uploadResult)
.catch((err) => reportError(err));
}
private _docComm = this._gristDoc.docComm;
private _uploadResult?: UploadResult;
private _openModalCtl: IModalControl|null = null;
private _importerContent = Observable.create<DomContents>(this, null);
private _parseOptions = Observable.create<ParseOptions>(this, {});
private _sourceInfoArray = Observable.create<SourceInfo[]>(this, []);
private _sourceInfoSelected = Observable.create<SourceInfo|null>(this, null);
private _previewViewSection: Observable<ViewSectionRec|null> =
Computed.create(this, this._sourceInfoSelected, (use, info) => {
if (!info) { return null; }
const viewSection = use(info.transformSection);
return viewSection && !use(viewSection._isDeleted) ? viewSection : null;
});
// destTables is a list of options for import destinations, and includes all tables in the
// document, plus two values: to import as a new table, and to skip an import table entirely.
private _destTables = Computed.create<Array<IOptionFull<DestId>>>(this, (use) => [
{value: null, label: 'New Table'},
...use(this._gristDoc.docModel.allTableIds.getObservable()).map((t) => ({value: t, label: t})),
]);
// null tells to use the built-in file picker.
constructor(private _gristDoc: GristDoc, private _importSourceElem: ImportSourceElement|null,
private _createPreview: CreatePreviewFunc) {
super();
}
/*
* Get new import sources and update the current one.
*/
public async pickAndUploadSource(uploadResult: UploadResult|null) {
try {
if (!this._importSourceElem) {
// Use upload result if it was passed in or the built-in file picker.
// On electron, it uses the native file selector (without actually uploading anything),
// which is why this requires a slightly different flow.
uploadResult = uploadResult || await selectFiles({docWorkerUrl: this._docComm.docWorkerUrl,
multiple: true, sizeLimit: 'import'});
} else {
const plugin = this._importSourceElem.plugin;
// registers a render target for plugin to render inline.
const handle: RenderTarget = plugin.addRenderTarget((el, opt = {}) => {
el.style.width = "100%";
el.style.height = opt.height || "200px";
this._showImportDialog();
this._renderPlugin(el);
});
const importSource = await this._importSourceElem.importSourceStub.getImportSource(handle);
plugin.removeRenderTarget(handle);
if (importSource) {
// If data has been picked, upload it.
const item = importSource.item;
if (item.kind === "fileList") {
const files = item.files.map(({content, name}) => new File([content], name));
uploadResult = await uploadFiles(files, {docWorkerUrl: this._docComm.docWorkerUrl,
sizeLimit: 'import'});
} else if (item.kind === "url") {
uploadResult = await fetchURL(this._docComm, item.url);
} else {
throw new Error(`Import source of kind ${item!.kind} are not yet supported!`);
}
}
}
} catch (err) {
this._renderError(err.message);
return;
}
if (uploadResult) {
this._uploadResult = uploadResult;
await this._reImport(uploadResult);
} else {
await this._cancelImport();
}
}
private _getPrimaryViewSection(tableId: string): ViewSectionRec {
const tableModel = this._gristDoc.getTableModel(tableId);
const viewRow = tableModel.tableMetaRow.primaryView.peek();
return viewRow.viewSections.peek().peek()[0];
}
private _getSectionByRef(sectionRef: number): ViewSectionRec {
return this._gristDoc.docModel.viewSections.getRowModel(sectionRef);
}
private async _updateTransformSection(sourceInfo: SourceInfo, destTableId: string|null) {
const transformSectionRef = await this._gristDoc.docData.sendAction(
['GenImporterView', sourceInfo.hiddenTableId, destTableId, null]);
sourceInfo.transformSection.set(this._gristDoc.docModel.viewSections.getRowModel(transformSectionRef));
sourceInfo.destTableId.set(destTableId);
}
private _getTransformedDataSource(upload: UploadResult): DataSourceTransformed {
const transforms: TransformRuleMap[] = upload.files.map((file, i) => this._createTransformRuleMap(i));
return {uploadId: upload.uploadId, transforms};
}
private _createTransformRuleMap(uploadFileIndex: number): TransformRuleMap {
const result: TransformRuleMap = {};
for (const sourceInfo of this._sourceInfoArray.get()) {
if (sourceInfo.uploadFileIndex === uploadFileIndex) {
result[sourceInfo.origTableName] = this._createTransformRule(sourceInfo);
}
}
return result;
}
private _createTransformRule(sourceInfo: SourceInfo): TransformRule {
const transformFields = sourceInfo.transformSection.get().viewFields().peek();
const sourceFields = sourceInfo.sourceSection.viewFields().peek();
const destTableId: DestId = sourceInfo.destTableId.get();
return {
destTableId,
destCols: transformFields.map<TransformColumn>((field) => ({
label: field.label(),
colId: destTableId ? field.colId() : null, // if inserting into new table, colId isnt defined
type: field.column().type(),
formula: field.column().formula()
})),
sourceCols: sourceFields.map((field) => field.colId())
};
}
private _getHiddenTableIds(): string[] {
return this._sourceInfoArray.get().map((t: SourceInfo) => t.hiddenTableId);
}
private async _reImport(upload: UploadResult) {
this._renderSpinner();
this._showImportDialog();
try {
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 100};
const importResult: ImportResult = await this._docComm.importFiles(
this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());
this._parseOptions.set(importResult.options);
this._sourceInfoArray.set(importResult.tables.map((info: ImportTableResult) => ({
hiddenTableId: info.hiddenTableId,
uploadFileIndex: info.uploadFileIndex,
origTableName: info.origTableName,
sourceSection: this._getPrimaryViewSection(info.hiddenTableId)!,
transformSection: Observable.create(null, this._getSectionByRef(info.transformSectionRef)),
destTableId: Observable.create<DestId>(null, info.destTableId)
})));
if (this._sourceInfoArray.get().length === 0) {
throw new Error("No data was imported");
}
// Select the first sourceInfo to show in preview.
this._sourceInfoSelected.set(this._sourceInfoArray.get()[0] || null);
this._renderMain(upload);
} catch (e) {
console.warn("Import failed", e);
this._renderError(e.message);
}
}
private async _finishImport(upload: UploadResult) {
this._renderSpinner();
const parseOptions = {...this._parseOptions.get(), NUM_ROWS: 0};
const importResult: ImportResult = await this._docComm.finishImportFiles(
this._getTransformedDataSource(upload), parseOptions, this._getHiddenTableIds());
if (importResult.tables[0].hiddenTableId) {
const tableRowModel = this._gristDoc.docModel.dataTables[importResult.tables[0].hiddenTableId].tableMetaRow;
await this._gristDoc.openDocPage(tableRowModel.primaryViewId());
}
this._openModalCtl!.close();
this.dispose();
}
private async _cancelImport() {
if (this._uploadResult) {
await this._docComm.cancelImportFiles(
this._getTransformedDataSource(this._uploadResult), this._getHiddenTableIds());
}
this._openModalCtl!.close();
this.dispose();
}
private _showImportDialog() {
if (this._openModalCtl) { return; }
modal((ctl, owner) => {
this._openModalCtl = ctl;
return [
cssModalOverrides.cls(''),
dom.domComputed(this._importerContent),
testId('importer-dialog'),
];
}, {
noClickAway: true,
noEscapeKey: true,
});
}
private _buildModalTitle(rightElement?: DomContents) {
const title = this._importSourceElem ? this._importSourceElem.importSource.label : 'Import from file';
return cssModalHeader(cssModalTitle(title), rightElement);
}
// The importer state showing just a spinner, when the user has to wait. We don't even let the
// user cancel it, because the cleanup can only happen properly once the wait completes.
private _renderSpinner() {
this._importerContent.set([this._buildModalTitle(), cssSpinner(loadingSpinner())]);
}
// The importer state showing the inline element from the plugin (e.g. to enter URL in case of
// import-from-url).
private _renderPlugin(inlineElement: HTMLElement) {
this._importerContent.set([this._buildModalTitle(), inlineElement]);
}
// The importer state showing just an error.
private _renderError(message: string) {
this._importerContent.set([
this._buildModalTitle(),
cssModalBody('Import failed: ', message, testId('importer-error')),
cssModalButtons(
bigBasicButton('Close',
dom.on('click', () => this._cancelImport()),
testId('modal-cancel'),
),
),
]);
}
// The importer state showing import in progress, with a list of tables, and a preview.
private _renderMain(upload: UploadResult) {
const schema = this._parseOptions.get().SCHEMA;
this._importerContent.set([
this._buildModalTitle(
schema ? cssActionLink(cssLinkIcon('Settings'), 'Import options',
testId('importer-options-link'),
dom.on('click', () => this._renderParseOptions(schema, upload))
) : null,
),
cssPreviewWrapper(
cssTableList(
dom.forEach(this._sourceInfoArray, (info) => {
const destTableId = Computed.create(null, (use) => use(info.destTableId))
.onWrite((destId) => this._updateTransformSection(info, destId));
return cssTableInfo(
dom.autoDispose(destTableId),
cssTableLine(cssToFrom('From'),
cssTableSource(getSourceDescription(info, upload), testId('importer-from'))),
cssTableLine(cssToFrom('To'), linkSelect<DestId>(destTableId, this._destTables)),
cssTableInfo.cls('-selected', (use) => use(this._sourceInfoSelected) === info),
dom.on('click', () => this._sourceInfoSelected.set(info)),
testId('importer-source'),
);
}),
),
dom.maybe(this._previewViewSection, () => cssSectionHeader('Preview')),
dom.maybe(this._previewViewSection, (viewSection) => {
const gridView = this._createPreview(viewSection);
return cssPreviewGrid(
dom.autoDispose(gridView),
gridView.viewPane,
testId('importer-preview'),
);
}),
),
cssModalButtons(
bigPrimaryButton('Import',
dom.on('click', () => this._finishImport(upload)),
testId('modal-confirm'),
),
bigBasicButton('Cancel',
dom.on('click', () => this._cancelImport()),
testId('modal-cancel'),
),
),
]);
}
// The importer state showing parse options that may be changed.
private _renderParseOptions(schema: ParseOptionSchema[], upload: UploadResult) {
this._importerContent.set([
this._buildModalTitle(),
dom.create(buildParseOptionsForm, schema, this._parseOptions.get() as ParseOptionValues,
(p: ParseOptions) => {
this._parseOptions.set(p);
this._reImport(upload).catch((err) => reportError(err));
},
() => { this._renderMain(upload); },
)
]);
}
}
function getSourceDescription(sourceInfo: SourceInfo, upload: UploadResult) {
const origName = upload!.files[sourceInfo.uploadFileIndex].origName;
return sourceInfo.origTableName ? origName + ' - ' + sourceInfo.origTableName : origName;
}
const cssActionLink = styled('div', `
display: inline-flex;
align-items: center;
cursor: pointer;
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
&:hover {
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
}
`);
const cssLinkIcon = styled(icon, `
flex: none;
margin-right: 4px;
`);
const cssModalOverrides = styled('div', `
max-height: calc(100% - 32px);
display: flex;
flex-direction: column;
& > .${cssModalButtons.className} {
margin-top: 16px;
}
`);
const cssModalBody = styled('div', `
padding: 16px 0;
overflow-y: auto;
max-width: 470px;
`);
const cssSpinner = styled('div', `
display: flex;
align-items: center;
height: 80px;
margin: auto;
`);
const cssModalHeader = styled('div', `
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
& > .${cssModalTitle.className} {
margin-bottom: 0px;
}
`);
const cssPreviewWrapper = styled('div', `
width: 600px;
padding: 8px 12px 8px 0;
overflow-y: auto;
`);
// This partly duplicates cssSectionHeader from HomeLeftPane.ts
const cssSectionHeader = styled('div', `
margin-bottom: 8px;
color: ${colors.slate};
text-transform: uppercase;
font-weight: 500;
font-size: ${vars.xsmallFontSize};
letter-spacing: 1px;
`);
const cssTableList = styled('div', `
display: flex;
flex-flow: row wrap;
justify-content: space-between;
margin-bottom: 16px;
align-items: flex-start;
`);
const cssTableInfo = styled('div', `
padding: 4px 8px;
margin: 4px 0px;
width: calc(50% - 16px);
border-radius: 3px;
border: 1px solid ${colors.darkGrey};
&:hover, &-selected {
background-color: ${colors.mediumGrey};
}
`);
const cssTableLine = styled('div', `
display: flex;
align-items: center;
margin: 4px 0;
`);
const cssToFrom = styled('span', `
flex: none;
margin-right: 8px;
color: ${colors.slate};
text-transform: uppercase;
font-weight: 500;
font-size: ${vars.xsmallFontSize};
letter-spacing: 1px;
width: 40px;
text-align: right;
`);
const cssTableSource = styled('div', `
overflow-wrap: anywhere;
`);
const cssPreviewGrid = styled('div', `
display: flex;
height: 300px;
border: 1px solid ${colors.darkGrey};
`);

View File

@ -0,0 +1,89 @@
.layout_root {
position: relative;
width: 100%;
height: 100%;
}
.layout_root.layout_fill_window {
position: absolute;
}
.layout_root > .layout_box {
height: 100%;
}
.layout_box {
position: relative;
display: -webkit-flex;
display: flex;
min-width: 0px;
}
.layout_hbox.layout_fill_window {
-webkit-flex: 1 1 0px;
flex: 1 1 0px;
}
/* We can't use ':last-child' because of resize-handle elements tacked on beyond it. */
.layout_hbox.layout_last_child {
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
}
.layout_vbox {
-webkit-flex-direction: column;
flex-direction: column;
-webkit-flex: 1 1 0px;
flex: 1 1 0px;
}
/* not so much to specify the look, as to simplify filtering events */
.layout_leaf {
-webkit-flex-direction: column;
flex-direction: column;
}
.layout_new, .layout_trash {
min-height: 2rem;
line-height: 2rem;
padding: 0.5rem 1rem;
margin: 0.5rem 0;
cursor: default;
}
.layout_trash:hover, .layout_new:hover {
background-color: #F8F8F8;
}
.layout_new {
border-left: 1px solid lightgrey;
border-top: 1px solid lightgrey;
border-right: 1px solid grey;
border-bottom: 1px solid grey;
color: grey;
}
.layout_trash {
border: 1px solid lightgrey;
border-radius: 3px;
color: red;
}
.layout_leaf_test {
border-left: 1px solid lightgrey;
border-top: 1px solid lightgrey;
border-right: 1px solid grey;
border-bottom: 1px solid grey;
color: grey;
width: 100%;
-webkit-flex: 1 1 0px;
flex: 1 1 0px;
min-height: 5rem;
line-height: 5rem;
justify-content: center;
text-align: center;
}
.layout_leaf_test_big {
min-height: 7rem;
}

View File

@ -0,0 +1,472 @@
/**
* This module provides the ability to render and edit hierarchical layouts of boxes. Each box may
* contain a list of other boxes, and horizontally- and vertically-arranged lists alternating with
* the depth in the hierarchy.
*
* Layout
* Layout is a tree of LayoutBoxes (HBoxes and VBoxes). It consists of HBoxes and VBoxes in
* alternating levels. The leaves of the tree are LeafBoxes, and those are the only items that
* may be moved around, with the structure of Boxes above them changing to accommodate.
*
* LayoutBox
* A LayoutBox is a node in the Layout tree. LayoutBoxes should typically have nothing visual
* about them (e.g. no borders) except their dimensions: they serve purely for layout purposes.
*
* A LayoutBox may be an HBox or a VBox. An HBox may contain multiple VBoxes arranged in a row.
* A VBox may contain multiple HBoxes one under the other. Either kind of LayoutBox may contain
* a single LeafBox instead of child LayoutBoxes. No LayoutBox may be empty, and no LayoutBox
* may contain a single LayoutBox as a child: it must contain either multiple LayoutBox
* children, or a single LeafBox.
*
* LeafBox
* A LeafBox is the container for user content, i.e. what needs to be laid out, for example
* form elements. LeafBoxes are what the user can drag around to other location in the layout.
* All the LeafBoxes in a Layout together fill the entire Layout rectangle. If some parts of
* the layout are to be empty, they should still contain an empty LeafBox.
*
* There is no separate JS class for LeafBoxes, they are simply LayoutBoxes with .layout_leaf
* class and set leafId and leafContent member observables.
*
* Floater
* A Floater is a rectangle that floats over the layout with the mouse pointer while the user is
* dragging a LeafBox. It contains the content of the LeafBox being dragged, so that the user
* can see what is being repositioned.
*
* DropOverlay
* An DropOverlay is a visual aid to the user to indicate area over the current LeafBox where a
* drop may be attempted. It also computes the "affinity": which border of the current LeafBox
* the user is trying to target as the insertion point.
*
* DropTargeter
* DropTargeter displays a set of rectangles, each of which represents a particular allowed
* insertion point for the element being dragged. E.g. dragging an element to the right side of
* a LeafBox would display a drop target for each LayoutBox up the tree that allows a sibling
* to be inserted on the right.
*
* Saving Changes
* --------------
* We don't attempt to save granular changes to the layout, for each drag operation, because
* for the user, it's better to finish editing the layout, and only save the end result. Also,
* it's not so easy (the structure changes many times while dragging, and a single drag
* operation results in a non-trivial diff of the 'before' and 'after' layouts). So instead, we
* just have a way to serialize the layout to and from a JSON blob.
*/
var ko = require('knockout');
var assert = require('assert');
var _ = require('underscore');
var BackboneEvents = require('backbone').Events;
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var koArray = require('../lib/koArray');
/**
* A LayoutBox is the node in the hierarchy of boxes comprising the layout. This class is used for
* rendering as well as for the code editor. Since it may be rendered many times on a page, it's
* important for it to be efficient.
* @param {Layout} layout: The Layout object that manages this LayoutBox.
*/
function LayoutBox(layout) {
this.layout = layout;
this.parentBox = ko.observable(null);
this.childBoxes = koArray();
this.leafId = ko.observable(null);
this.leafContent = ko.observable(null);
this.uniqueId = _.uniqueId("lb"); // For logging and debugging.
this.isVBox = this.autoDispose(ko.computed(function() {
return this.parentBox() ? !this.parentBox().isVBox() : true;
}, this));
this.isHBox = this.autoDispose(ko.computed(function() { return !this.isVBox(); }, this));
this.isLeaf = this.autoDispose(ko.computed(function() { return this.leafId() !== null; },
this));
// flexSize represents flexWidth for VBoxes and flexHeight for HBoxes.
// Undesirable transition effects are likely when <1, so we set average value
// to 100 so that reduction below 1 is rare.
this.flexSize = ko.observable(100);
this.dom = null;
this._parentBeingDisposed = false;
// This is an optimization to avoid the wasted cost of removeFromParent during disposal.
this._parentBeingDisposed = false;
this.autoDisposeCallback(function() {
if (!this._parentBeingDisposed) {
this.removeFromParent();
}
this.childBoxes.peek().forEach(function(child) {
child._parentBeingDisposed = true;
child.dispose();
});
});
}
exports.LayoutBox = LayoutBox;
dispose.makeDisposable(LayoutBox);
LayoutBox.prototype.getDom = function() {
return this.dom || (this.dom = this.autoDispose(this.buildDom()));
};
/**
* This helper turns a value, observable, or function (as accepted by koDom functions) into a
* plain value. It's used to build a static piece of DOM without subscribing to any of the
* observables, to avoid the performance cost of subscribing/unsubscribing.
*/
function makeStatic(valueOrFunc) {
if (ko.isObservable(valueOrFunc) || koArray.isKoArray(valueOrFunc)) {
return valueOrFunc.peek();
} else if (typeof valueOrFunc === 'function') {
return valueOrFunc();
} else {
return valueOrFunc;
}
}
LayoutBox.prototype.buildDom = function() {
var self = this;
var wrap = this.layout.needDynamic ? _.identity : makeStatic;
return dom('div.layout_box',
kd.toggleClass('layout_leaf', wrap(this.isLeaf)),
kd.toggleClass(this.layout.leafId, wrap(this.isLeaf)),
kd.cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })),
kd.cssClass(wrap(function() { return (self.layout.fillWindow ? 'layout_fill_window' :
(self.isLastChild() ? 'layout_last_child' : null));
})),
kd.style('flexGrow', wrap(function() {
return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : '';
})),
kd.domData('layoutBox', this),
kd.foreach(wrap(this.childBoxes), function(layoutBox) {
return layoutBox.getDom();
}),
kd.scope(wrap(this.leafContent), function(leafContent) {
return leafContent;
})
);
};
/**
* Moves the leaf id and content from another layoutBox, unsetting them in the source one.
*/
LayoutBox.prototype.takeLeafFrom = function(sourceLayoutBox) {
this.leafId(sourceLayoutBox.leafId.peek());
// Note that we detach the node, so that the old box doesn't destroy its DOM.
this.leafContent(dom.detachNode(sourceLayoutBox.leafContent.peek()));
sourceLayoutBox.leafId(null);
sourceLayoutBox.leafContent(null);
};
LayoutBox.prototype.setChildren = function(children) {
children.forEach(function(child) {
child.parentBox(this);
}, this);
this.childBoxes.assign(children);
};
LayoutBox.prototype.isFirstChild = function() {
return this.parentBox() ? this.parentBox().childBoxes.peek()[0] === this : true;
};
LayoutBox.prototype.isLastChild = function() {
// Use .all() rather than .peek() because it's used in kd.toggleClass('layout_last_child'), and
// we want it to automatically stay correct when childBoxes array changes.
return this.parentBox() ? _.last(this.parentBox().childBoxes.all()) === this : true;
};
LayoutBox.prototype.isDomDetached = function() {
return !(this.dom && this.dom.parentNode);
};
LayoutBox.prototype.getSiblingBox = function(isAfter) {
if (!this.parentBox()) {
return null;
}
var siblings = this.parentBox().childBoxes.peek();
var index = siblings.indexOf(this);
if (index < 0) {
return null;
}
index += (isAfter ? 1 : -1);
return (index < 0 || index >= siblings.length ? null : siblings[index]);
};
LayoutBox.prototype._addChild = function(childBox, isAfter, optNextSibling) {
assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set");
var index;
if (optNextSibling) {
index = this.childBoxes.peek().indexOf(optNextSibling) + (isAfter ? 1 : 0);
} else {
index = isAfter ? this.childBoxes.peekLength : 0;
}
childBox.parentBox(this);
this.childBoxes.splice(index, 0, childBox);
};
LayoutBox.prototype.addSibling = function(childBox, isAfter) {
childBox.removeFromParent();
var parentBox = this.parentBox();
if (parentBox) {
// Normally, we just add a sibling as requested.
parentBox._addChild(childBox, isAfter, this);
} else {
// If adding a sibling to the root node (another VBox), we need to create a new root and push
// things down two levels (HBox and VBox), and add the sibling to the lower VBox.
if (this.childBoxes.peekLength === 1) {
// Except when the root has a single child, in which case there is already a good place to
// add the new node two levels lower. And we should not create another level because the
// root is the only place that can have a single child.
var lowerBox = this.childBoxes.peek()[0];
assert(!lowerBox.isLeaf(), 'LayoutBox.addSibling: should not have leaf as a single child');
lowerBox._addChild(childBox, isAfter);
} else {
// Create a new root, and add the sibling two levels lower.
var vbox = LayoutBox.create(this.layout);
var hbox = LayoutBox.create(this.layout);
// We don't need removeFromParent here because this only runs when there is no parent.
vbox._addChild(hbox, false);
hbox._addChild(this, false);
hbox._addChild(childBox, isAfter);
this.layout.setRoot(vbox);
}
}
this.layout.trigger('layoutChanged');
};
LayoutBox.prototype.addChild = function(childBox, isAfter) {
childBox.removeFromParent();
if (this.isLeaf()) {
// Move the leaf data into a new child, then add the requested childBox.
var newBox = LayoutBox.create(this.layout);
newBox.takeLeafFrom(this);
this._addChild(newBox, 0);
}
this._addChild(childBox, isAfter);
this.layout.trigger('layoutChanged');
};
LayoutBox.prototype.toString = function() {
return this.isDisposed() ? this.uniqueId + "[disposed]" : (this.uniqueId +
(this.isHBox() ? "H" : "V") +
(this.isLeaf() ? "(" + this.leafId() + ")" :
"[" + this.childBoxes.peek().map(function(b) { return b.toString(); }).join(",") + "]")
);
};
LayoutBox.prototype._removeChildBox = function(childBox) {
//console.log("_removeChildBox %s from %s", childBox.toString(), this.toString());
var index = this.childBoxes.peek().indexOf(childBox);
childBox.parentBox(null);
if (index >= 0) {
this.childBoxes.splice(index, 1);
this.rescaleFlexSizes();
}
if (this.childBoxes.peekLength === 1) {
// If we now have a single child, then something needs to collapse.
var lowerBox = this.childBoxes.peek()[0];
var parentBox = this.parentBox();
if (lowerBox.isLeaf()) {
// Move the leaf data into ourselves, and remove the lower box.
this.takeLeafFrom(lowerBox);
lowerBox.dispose();
} else if (parentBox) {
// Move grandchildren into our place within our parent, and collapse two levels.
// (Unless we are the root, in which case it's OK for us to have a single non-leaf child.)
index = parentBox.childBoxes.peek().indexOf(this);
assert(index >= 0, 'LayoutBox._removeChildBox: box not found in parent');
var grandchildBoxes = lowerBox.childBoxes.peek();
grandchildBoxes.forEach(function(box) { box.parentBox(parentBox); });
parentBox.childBoxes.arraySplice(index, 0, grandchildBoxes);
lowerBox.childBoxes.splice(0, lowerBox.childBoxes.peekLength);
this.removeFromParent();
lowerBox.dispose();
this.dispose();
}
}
};
/**
* Helper to detach a box from its parent without disposing it. If you no longer plan to reattach
* the box, you should probably call box.dispose().
*/
LayoutBox.prototype.removeFromParent = function() {
if (this.parentBox()) {
this.parentBox()._removeChildBox(this);
this.layout.trigger('layoutChanged');
}
};
/**
* Adjust flexSize values of the children so that they add up to at least 1.
* Otherwise, Firefox will not stretch them to the full size of the container.
*/
LayoutBox.prototype.rescaleFlexSizes = function() {
// Just scale so that the smallest value is 1.
var children = this.childBoxes.peek();
var minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); }));
if (minSize < 1) {
children.forEach(function(b) {
b.flexSize(b.flexSize() / minSize);
});
}
};
//----------------------------------------------------------------------
/**
* @event layoutChanged: Triggered on changes to the structure of the layout.
* @event layoutResized: Triggered on non-structural changes that may affect the size of rootElem.
*/
function Layout(boxSpec, createLeafFunc, optFillWindow) {
this.rootBox = ko.observable(null);
this.createLeafFunc = createLeafFunc;
this._leafIdMap = null;
this.fillWindow = optFillWindow || false;
this.needDynamic = false;
this.rootElem = this.autoDispose(this.buildDom());
// Generates a unique id class so boxes can only be placed next to other boxes in this layout.
this.leafId = _.uniqueId('layout_leaf_');
this.buildLayout(boxSpec || {});
// Invalidate the _leafIdMap when the layout is adjusted.
this.listenTo(this, 'layoutChanged', function() { this._leafIdMap = null; });
this.autoDisposeCallback(function() {
if (this.rootBox()) {
this.rootBox().dispose();
}
});
}
exports.Layout = Layout;
dispose.makeDisposable(Layout);
_.extend(Layout.prototype, BackboneEvents);
/**
* Returns a LayoutBox object containing the given DOM element, or null if not found.
*/
Layout.prototype.getContainingBox = function(elem) {
return Layout.getContainingBox(elem, this.rootElem);
};
/**
* You can also find the nearest containing LayoutBox without having the Layout object itself by
* using Layout.Layout.getContainingBox. The Layout object is then accessible as box.layout.
*/
Layout.getContainingBox = function(elem, optContainer) {
var boxElem = dom.findAncestor(elem, optContainer, '.layout_box');
return boxElem ? ko.utils.domData.get(boxElem, 'layoutBox') : null;
};
/**
* Finds and returns the leaf layout box containing the content for the given leafId.
*/
Layout.prototype.getLeafBox = function(leafId) {
return this.getLeafIdMap().get(leafId);
};
/**
* Returns the list of all leafIds present in this layout.
*/
Layout.prototype.getAllLeafIds = function() {
return Array.from(this.getLeafIdMap().keys());
};
Layout.prototype.setRoot = function(layoutBox) {
this.rootBox(layoutBox);
};
Layout.prototype.buildDom = function() {
return dom('div.layout_root',
kd.domData('layoutModel', this),
kd.toggleClass('layout_fill_window', this.fillWindow),
kd.scope(this.rootBox, function(rootBox) {
return rootBox ? rootBox.getDom() : null;
})
);
};
/**
* Calls cb on each box in the layout recursively.
*/
Layout.prototype.forEachBox = function(cb, optContext) {
function iter(box) {
cb.call(optContext, box);
box.childBoxes.peek().forEach(iter);
}
iter(this.rootBox.peek());
};
Layout.prototype.buildLayoutBox = function(boxSpec) {
// Note that this is hot code: it runs when rendering a layout for each record, not only for the
// layout editor.
var box = LayoutBox.create(this);
if (boxSpec.size) {
box.flexSize(boxSpec.size);
}
if (boxSpec.leaf) {
box.leafId(boxSpec.leaf);
box.leafContent(this.createLeafFunc(box.leafId()));
} else if (boxSpec.children) {
box.setChildren(boxSpec.children.map(this.buildLayoutBox, this));
}
return box;
};
Layout.prototype.buildLayout = function(boxSpec, needDynamic) {
this.needDynamic = needDynamic;
var oldRootBox = this.rootBox();
this.rootBox(this.buildLayoutBox(boxSpec));
this.trigger('layoutChanged');
if (oldRootBox) {
oldRootBox.dispose();
}
};
Layout.prototype._getBoxSpec = function(layoutBox) {
var spec = {};
if (layoutBox.flexSize() && layoutBox.flexSize() !== 100) {
spec.size = layoutBox.flexSize();
}
if (layoutBox.isLeaf()) {
spec.leaf = layoutBox.leafId();
} else {
spec.children = layoutBox.childBoxes.peek().map(this._getBoxSpec, this);
}
return spec;
};
Layout.prototype.getLayoutSpec = function() {
return this._getBoxSpec(this.rootBox());
};
/**
* Returns a Map object mapping leafId to its LayoutBox. This gets invalidated on layoutAdjust
* events, and rebuilt on next request.
*/
Layout.prototype.getLeafIdMap = function() {
if (!this._leafIdMap) {
this._leafIdMap = new Map();
this.forEachBox(function(box) {
var leafId = box.leafId.peek();
if (leafId !== null) {
this._leafIdMap.set(leafId, box);
}
}, this);
}
return this._leafIdMap;
};

View File

@ -0,0 +1,97 @@
.layout_editor_floater {
position: absolute;
overflow: hidden;
pointer-events: none;
z-index: 10;
-webkit-transform: rotate(5deg) scale(0.8);
transform: rotate(5deg) scale(0.8);
display: -webkit-flex;
display: flex;
}
/* Invisible div, into which we can place content that needs to be measured. */
.layout_editor_measuring_box {
position: absolute;
left: 0px;
top: 0px;
border: none;
visibility: hidden;
}
.layout_editor_drop_overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.1;
border-top: 0px solid #66F;
border-bottom: 0px solid #66F;
border-left: 0px solid #6F6;
border-right: 0px solid #6F6;
pointer-events: none;
}
.layout_editor_drop_targeter {
position: absolute;
top: 0px;
left: 0px;
}
.layout_editor_drop_target {
position: absolute;
border: 2px dashed black;
z-index: 10;
}
.layout_editor_drop_target.layout_hover {
border: 2px dashed #798AF1;
}
.layout_editor_empty_space {
background-color: rgba(0,0,0,0.1);
border-radius: 2px;
-webkit-flex: 1 1 0px;
flex: 1 1 0px;
}
.layout_editor_resize_transition {
-webkit-transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s;
transition: height .4s cubic-bezier(0.4, 0, 0.2, 1), width .4s cubic-bezier(0.4, 0, 0.2, 1), opacity .8s;
min-height: 0px !important;
/* Important tags necessary for .layout_hbox.layout_fill_window flex boxes */
-webkit-flex-basis: auto !important;
flex-basis: auto !important;
}
.layout_box > .ui-resizable-handle {
opacity: 0.0;
-webkit-transition: opacity .2s;
transition: opacity .2s;
}
.layout_box > .ui-resizable-w,
.layout_box > .ui-resizable-e {
cursor: ew-resize;
border-left: 1px dashed #a9a9a9;
margin-right: -1px;
}
.layout_box > .ui-resizable-s {
cursor: ns-resize;
border-top: 1px dashed #a9a9a9;
margin-bottom: -1px;
}
.layout_box > .ui-resizable-handle:hover {
opacity: 1.0;
}
.layout_grabbable:hover {
cursor: -webkit-grab;
cursor: grab;
}
/* TODO: Grabbing cursor does not show in Firefox */
.layout_grabbable:active {
cursor: -webkit-grabbing;
}

View File

@ -0,0 +1,738 @@
/**
* The LayoutEditor can be attached to a Layout object to allow changing it.
*
* Issues:
* TODO: Hitting ESC while dragging should revert smoothly. We can collapse the original leaf, but
* not remove it. On Cancel, we would uncollapse it, and remove the newly-inserted targetBox.
* TODO: UNDO should work. It's OK to just rebuild the old layout without any transition. In other
* words, this may be fine to do fully outside of LayoutEditor.
* TODO: if mouseup over an active hint of the DropTargeter, it might be a better experience to
* reposition to that spot.
*
* TEST CASES THAT SHOULD BE VERIFIED AFTER ANY CHANGE.
* These refer to test/client/components/sampleLayout.js, testable at
* http://localhost:8080/testKoForm.html#topTab=4.
* 1. Drag #1 down and up its container element, pausing at borders. Elements around that border
* should smoothly float to open space for it. Dropping it should cause no jumps.
* 2. Drag #1 down to top of #6. A grey "drop target" rectangle should appear. Hovering over it
* should open space over #6. After that, dragging to bottom of #6 and back to top of #6 should
* open the space automatically without the "drop target".
* 3. Drag #3 right and left in its container, pausing at borders. Elements should again smoothly
* float to open space for it. Dropping it should cause no jumps.
* 4. Drag #4 down into #5, positioning above #5, below, to the left (splitting #5 horizontally)
* or to the right.
* 5. Drop #4 onto the leftmost "drop target" on the left side of #5. It should end up as 1/3 of
* the width of the entire layout, spanning the full height above #6. Drop it back to its place
* between #3 and #9.
* 6. Resizing: every vertical line should allow dragging it left or right to resize. The "resize"
* mouse pointer should appear over a few pixels to the left and right of the border, it should
* not be a difficult area to target. (This gets messed up if overflow:hidden is set on the box
* elements.)
* 7. Drag box 3 to trash; hovering should make it disappear from Layout, mousing back should
* bring it back. Mouse-up over the trash icon should leave it out of the layout.
* 8. Drag boxes 3, 9, 10, 2, 7, 1 (8 should stretch vertically), 5 to trash. They should
* disappear with other elements shrinking or expanding to close the gap.
* 9. Adding a new element: Drag "+ Add New" box to between 1 and 2. A "drop target" should
* appear, allowing you to insert it. Same for adding between 3 and 4. Should be no jumps.
* 10. Drag new element to above #3: three possible drop targets should appear. Hover over each in
* turn, starting from the bottommost part, and make sure it gets inserted in the right level.
*/
var _ = require('underscore');
var ko = require('knockout');
var assert = require('assert');
var Promise = require('bluebird');
var BackboneEvents = require('backbone').Events;
var dispose = require('app/client/lib/dispose');
var {Delay} = require('app/client/lib/Delay');
var dom = require('app/client/lib/dom');
var kd = require('app/client/lib/koDom');
var Layout = require('./Layout');
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('../lib/browserGlobals').get('window', 'document', '$');
//----------------------------------------------------------------------
/**
* The Floater class represents a floating version of the element being dragged around. Its size
* corresponds to the box being dragged. It lets the user see what's being repositioned.
*/
function Floater(fillWindow) {
this.leafId = ko.observable(null);
this.leafContent = ko.observable(null);
this.fillWindow = fillWindow || false;
this.floaterElem = this.autoDispose(dom('div.layout_editor_floater',
kd.show(this.leafContent),
kd.scope(this.leafContent, function(leafContent) {
return leafContent;
})
));
G.document.body.appendChild(this.floaterElem);
this.mouseOffsetX = 0;
this.mouseOffsetY = 0;
this.lastMouseEvent = null;
}
dispose.makeDisposable(Floater);
Floater.prototype.onInitialMouseMove = function(mouseEvent, sourceBox) {
var rect = sourceBox.dom.getBoundingClientRect();
this.floaterElem.style.width = rect.width + 'px';
this.floaterElem.style.height = rect.height + 'px';
this.mouseOffsetX = 0.2 * rect.width;
this.mouseOffsetY = 0.1 * rect.height;
this.onMouseMove(mouseEvent);
this.leafId(sourceBox.leafId());
this.leafContent(sourceBox.leafContent());
// We use a dummy non-null leafId here, to ensure that sourceBox remains considered a leaf.
sourceBox.leafId('empty');
sourceBox.leafContent(dom('div.layout_editor_empty_space',
kd.style('margin', (rect.height * 0.02) + 'px'),
kd.style('min-height', (rect.height * 0.96) + 'px')
));
};
Floater.prototype.onMouseUp = function() {
this.lastMouseEvent = null;
};
Floater.prototype.onMouseMove = function(mouseEvent) {
this.lastMouseEvent = mouseEvent;
this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px';
this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px';
};
//----------------------------------------------------------------------
/**
* When the user hovers near the edge of a box, we call the direction the "affinity", and it
* indicates where an insertion is to happen. Affinities are represented by numbers 0 - 3. The
* functions below distinguish top-down vs left-right, and top/left vs down/right.
*/
//var AFFINITY_NAMES = { 0: 'TOP', 1: 'DOWN', 2: 'LEFT', 3: 'RIGHT' };
function isAffinityUpDown(affinity) { return (affinity >> 1) === 0; }
function isAffinityAfter(affinity) { return (affinity & 1) === 1; }
//----------------------------------------------------------------------
/**
* DropOverlay is a rectangular indicator that's displayed over a leaf box under the mouse
* pointer, and shows regions of affinity towards one of the borders. It also computes which
* region the user is targeting, and returns an affinity value.
*/
function DropOverlay() {
this.overlayElem = this.autoDispose(dom('div.layout_editor_drop_overlay'));
this.overlayRect = null;
this.hBorder = null;
this.vBorder = null;
}
dispose.makeDisposable(DropOverlay);
/**
* Hides the overlay box by detaching it from the current element, if any.
*/
DropOverlay.prototype.detach = function() {
if (this.overlayElem.parentNode) {
this.overlayElem.parentNode.removeChild(this.overlayElem);
}
};
function getFrac(distance, max) {
return distance < max ? distance / max : Infinity;
}
/**
* Shows the overlay box over the given element.
*/
DropOverlay.prototype.attach = function(targetElem) {
var rect = this.overlayRect = targetElem.getBoundingClientRect();
/*
// If uncommented, this will show areas of affinity when hovering over a box. This is helpful in
// debugging, and may be helpful to users too, but makes the interface feel more cluttered.
if (this.overlayElem.parentNode !== targetElem) {
// This also automatically removes it from the old parent, if any.
targetElem.appendChild(this.overlayElem);
}
*/
// Areas of affinity are essentially fat borders, proportional to width and height. In addition,
// to avoid overly disproportionate regions, we use twice the smaller dimension to limit the
// larger dimension.
this.hBorder = Math.floor(Math.min(rect.height, rect.width * 2) / 3);
this.vBorder = Math.floor(Math.min(rect.width, rect.height * 2) / 3);
var s = this.overlayElem.style;
s.borderTopWidth = s.borderBottomWidth = this.hBorder + 'px';
s.borderLeftWidth = s.borderRightWidth = this.vBorder + 'px';
};
/**
* If the mouse is over a region of affinity, returns the affinity as an 0-3 integer (see
* AFFINITY_NAMES above). Otherwise, returns -1.
*/
DropOverlay.prototype.getAffinity = function(mouseEvent) {
var rect = this.overlayRect;
var x = mouseEvent.clientX - rect.left,
y = mouseEvent.clientY - rect.top,
top = getFrac(y, this.hBorder),
down = getFrac(rect.height - y, this.hBorder),
left = getFrac(x, this.vBorder),
right = getFrac(rect.width - x, this.vBorder),
minValue = Math.min(top, down, left, right);
return (minValue === Infinity ? -1 : [top, down, left, right].indexOf(minValue));
};
//----------------------------------------------------------------------
/**
* DropTargeter displays a set of rectangles, each of which represents a particular allowed
* insertion point for the element being dragged. It only shows the insertion points at the edge
* of a particular layoutBox as indicated by DropOverlay.
*/
function DropTargeter(rootElem) {
this.rootElem = rootElem;
this.targetsDom = null;
this.currentBox = null;
this.currentAffinity = null;
this.delayedInsertion = Delay.create();
this.activeTarget = null;
this.autoDisposeCallback(this.removeTargetHints);
}
dispose.makeDisposable(DropTargeter);
_.extend(DropTargeter.prototype, BackboneEvents);
DropTargeter.prototype.removeTargetHints = function() {
this.activeTarget = null;
this.delayedInsertion.cancel();
if (this.targetsDom) {
ko.removeNode(this.targetsDom);
this.targetsDom = null;
}
this.currentBox = null;
this.currentAffinity = null;
};
DropTargeter.prototype.updateTargetHints = function(layoutBox, affinity, overlay, prevTargetBox) {
// Nothing to update.
if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) {
return;
}
this.removeTargetHints();
if (affinity === -1) {
return;
}
this.currentBox = layoutBox;
this.currentAffinity = affinity;
var upDown = isAffinityUpDown(affinity);
var isAfter = isAffinityAfter(affinity);
var targetParts = [];
// Allow dragging a leaf into another leaf as a child, splitting the latter into two.
// But don't allow dragging a leaf box into itself, that makes no sense.
if (upDown === layoutBox.isVBox() && layoutBox !== prevTargetBox) {
targetParts.push({ box: layoutBox, isChild: true, isAfter: isAfter });
}
while (layoutBox) {
if (upDown === layoutBox.isHBox()) {
var children = layoutBox.childBoxes.peek();
// If one of two children is prevTargetBox, replace the last target hint since it
// will be redundant once prevTargetBox is removed.
if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) {
targetParts.splice(targetParts.length - 1, 1,
{ box: layoutBox, isChild: false, isAfter: isAfter });
}
// If there is only one child (which may happen for the root box), the target hint
// is redundant.
else if (prevTargetBox !== layoutBox && prevTargetBox !== layoutBox.getSiblingBox(isAfter) &&
children.length !== 1) {
targetParts.push({ box: layoutBox, isChild: false, isAfter: isAfter });
}
if (isAfter && !layoutBox.isLastChild()) { break; }
if (!isAfter && !layoutBox.isFirstChild()) { break; }
}
layoutBox = layoutBox.parentBox();
}
if (targetParts.length === 0) {
return;
}
// Render the hint parts.
if (!isAfter) {
targetParts.reverse();
}
// The same code works for both horizontal and vertical situation. For ease of thinking about
// it, we pretend below that we are dealing with an up-down situation (drop hints are horizontal
// wide boxes stacked vertically), and use properties that are named using the up-down
// situation, but whose values might reflect a left-right situation.
var pTop = upDown ? 'top' : 'left',
pHeight = upDown ? 'height' : 'width',
pLeft = upDown ? 'left' : 'top',
pWidth = upDown ? 'width' : 'height',
totalHeight = upDown ? overlay.hBorder : overlay.vBorder,
singleHeight = Math.floor(totalHeight / targetParts.length);
// Adjust to account for the rounding-down above.
totalHeight = singleHeight * targetParts.length;
var outerRect = this.rootElem.getBoundingClientRect();
var innerRect = this.currentBox.dom.getBoundingClientRect();
var self = this;
this.targetsDom = dom('div.layout_editor_drop_targeter',
kd.style(pTop,
(innerRect[pTop] - outerRect[pTop] +
(isAfter ? innerRect[pHeight] - totalHeight : 0)) + 'px'
),
targetParts.map(function(part, index) {
var rect = part.box.dom.getBoundingClientRect();
return dom('div.layout_editor_drop_target', function(elem) {
elem.style[pHeight] = (singleHeight + 1) + 'px'; // 1px of overlap for better looks
elem.style[pWidth] = rect[pWidth] + 'px';
elem.style[pLeft] = (rect[pLeft] - outerRect[pLeft]) + 'px';
elem.style[pTop] = (singleHeight * index) + 'px';
},
dom.on('mouseenter', function() {
this.classList.add("layout_hover");
self.activeTarget = part;
var padDir = upDown ? (isAfter ? 'Bottom' : 'Top') : (isAfter ? 'Right' : 'Left');
var padding = 'padding' + padDir;
part.box.dom.style.transition = 'padding .3s';
part.box.dom.style[padding] = '20px';
}),
dom.on('mouseleave', function() {
this.classList.remove("layout_hover");
self.activeTarget = null;
part.box.dom.style.padding = '0';
}),
dom.on('transitionend', this.triggerInsertion.bind(this, part))
);
}, this)
);
this.rootElem.appendChild(this.targetsDom);
};
DropTargeter.prototype.triggerInsertion = function(part) {
this.removeTargetHints();
this.trigger('insertBox', function(box) {
if (part.isChild) {
part.box.addChild(box, part.isAfter);
} else {
part.box.addSibling(box, part.isAfter);
}
});
};
DropTargeter.prototype.accelerateInsertion = function() {
if (this.activeTarget) {
this.activeTarget.box.dom.style.transition = '';
this.activeTarget.box.dom.style.padding = '0';
this.triggerInsertion(this.activeTarget);
}
};
//----------------------------------------------------------------------
/**
* When a LayoutEditor is created for a given Layout object, it makes it possible to drag
* LayoutBoxes to change the layout.
*
* When a user drags a box, its content migrates temporarily to the Floater element, which moves
* with the mouse cursor. As the user drags, the space for the element will open up here or there,
* by adding an appropriate empty targetBox. DropOverlay and DropTargeter together decide the
* insertion point for the drag operations.
*
* NOTES:
* There is some awkwardness in sizing: in a vertically laid out box, the last box takes up all
* available space, so moving it away does not show a transition (the box transitions to empty in
* theory, but it still takes all the same available space).
*/
function LayoutEditor(layout) {
this.layout = layout;
this.rootElem = layout.rootElem;
this.layout.buildLayout(this.layout.getLayoutSpec(), true);
this.floater = this.autoDispose(Floater.create(this.layout.fillWindow));
this.dropOverlay = this.autoDispose(DropOverlay.create());
this.dropTargeter = this.autoDispose(DropTargeter.create(this.rootElem));
this.listenTo(this.dropTargeter, 'insertBox', this.onInsertBox);
// This is a place to put LayoutBoxes that should NOT be shown, but SHOULD be possible to
// measure. It's used when a new box is being moved into the editor.
this.measuringBox = this.autoDispose(dom('div.layout_editor_measuring_box'));
this.rootElem.appendChild(this.measuringBox);
// For better experience, we prevent new repositions while a transition is active, and we
// require some work (leaving and re-entering affinity area) after a previous transition ends.
this.transitionPromise = Promise.resolve();
this.trashDelay = Delay.create();
// TODO: We don't use originalBox at the moment, but may want to, specifically to collapse it
// without removing, and restore if the user hits "Escape".
// This is the box the user clicked, to move its content elsewhere.
this.originalBox = null;
// The new box into which the content is to be inserted. During a move operation, it starts out
// with this.originalBox.
this.targetBox = null;
// Make all LayoutBoxes resizable. Update whenever the layout changes.
this.layout.forEachBox(this.makeResizable, this);
this.listenTo(this.layout, 'layoutChanged', function() {
this.layout.forEachBox(this.makeResizable, this);
});
var self = this;
this.boundMouseDown = function(ev) { return self.handleMouseDown(ev, this); };
this.boundMouseMove = this.handleMouseMove.bind(this);
this.boundMouseUp = this.handleMouseUp.bind(this);
G.$(this.rootElem).on('mousedown', '.layout_leaf', this.boundMouseDown);
this.initialMouseDown = false;
this.lastTriggered = 'stop';
this.autoDisposeCallback(function() {
G.$(G.window).off('mouseup', this.boundMouseUp);
G.$(G.window).off('mousemove', this.boundMouseMove);
G.$(this.rootElem).off('mousedown', this.boundMouseDown);
if (!this.layout.isDisposed()) {
this.layout.buildLayout(this.layout.getLayoutSpec(), false);
this.layout.forEachBox(this.unmakeResizable, this);
}
});
}
exports.LayoutEditor = LayoutEditor;
dispose.makeDisposable(LayoutEditor);
_.extend(LayoutEditor.prototype, BackboneEvents);
LayoutEditor.prototype.triggerUserEditStart = function() {
assert(this.lastTriggered === 'stop', "UserEditStart triggered twice in succession");
this.lastTriggered = 'start';
// This attribute allows browser tests to tell when an edit is in progress.
this.rootElem.setAttribute('data-useredit', 'start');
this.layout.trigger('layoutUserEditStart');
};
LayoutEditor.prototype.triggerUserEditStop = function() {
assert(this.lastTriggered === 'start', "UserEditStop triggered twice in succession");
this.lastTriggered = 'stop';
this.layout.trigger('layoutUserEditStop');
// This attribute allows browser tests to tell when an edit is finished.
this.rootElem.setAttribute('data-useredit', 'stop');
};
LayoutEditor.prototype.makeResizable = function(box) {
// Do not add resizable if:
// Box already resizable, box is not vertically resizable, box is last in it`s group.
if (G.$(box.dom).resizable('instance') || (box.isHBox() && !this.layout.fillWindow) ||
box.isLastChild()) {
return;
}
var helperObj = { box: box };
var isWidth = box.isVBox();
G.$(box.dom).resizable({
handles: isWidth ? 'e' : 's',
start: this.onResizeStart.bind(this, helperObj, isWidth),
resize: this.onResizeMove.bind(this, helperObj, isWidth),
stop: this.triggerUserEditStop.bind(this)
});
};
LayoutEditor.prototype.unmakeResizable = function(box) {
if (G.$(box.dom).resizable("instance")) {
// Resizable widget is set for this box.
G.$(box.dom).resizable('destroy');
}
};
LayoutEditor.prototype.onResizeStart = function(helperObj, isWidth, event, ui) {
this.triggerUserEditStart();
var size = isWidth ? ui.originalSize.width : ui.originalSize.height;
helperObj.scalePerFlexUnit = size / (helperObj.box.flexSize() || 1);
var allSiblings = helperObj.box.parentBox().childBoxes.peek();
var index = allSiblings.indexOf(helperObj.box);
helperObj.nextSiblings = allSiblings.slice(index + 1);
helperObj.origNextSizes = helperObj.nextSiblings.map(function(b) { return b.flexSize(); });
helperObj.origSize = helperObj.box.flexSize();
function adder(sum, box) { return sum + box.flexSize.peek(); }
helperObj.sumPrev = allSiblings.slice(0, index).reduce(adder, 0);
helperObj.sumAll = allSiblings.reduce(adder, 0);
helperObj.sumNext = helperObj.sumAll - helperObj.sumPrev;
};
// We'll snap to 1/NumSteps of total size. The choice of 60 allows many evenly-sized layouts.
var NumSteps = 60;
function round(value, multipleOf) {
return Math.round(value / multipleOf) * multipleOf;
}
function snap(flexSize, sumPrev, sumAll) {
var endEdge = round(sumPrev + flexSize, sumAll / NumSteps);
return Math.min(endEdge, sumAll) - sumPrev;
}
LayoutEditor.prototype.onResizeMove = function(helperObj, isWidth, event, ui) {
var sizePx = isWidth ? ui.size.width : ui.size.height;
var newSize = sizePx / helperObj.scalePerFlexUnit;
// We need some amount of snapping to make it easier to align boxes. The way we'll do it is to
// adjust flexSize of the box being resized and all following boxes so that boundaries end up at
// multiples of fullSize / NumSteps.
newSize = snap(newSize, helperObj.sumPrev, helperObj.sumAll);
var siblingsFactor = (helperObj.sumNext - newSize) / (helperObj.sumNext - helperObj.origSize);
var sumPrev = helperObj.sumPrev + newSize;
var newSizes = [];
helperObj.origNextSizes.forEach(function(size) {
var s = snap(size * siblingsFactor, sumPrev, helperObj.sumAll);
sumPrev += s;
newSizes.push(s);
});
if (newSize <= 0 || newSizes.some(function(size) { return size <= 0; })) {
return; // This isn't an acceptable position.
}
if (newSize !== helperObj.box.flexSize.peek()) {
helperObj.box.flexSize(newSize);
helperObj.nextSiblings.forEach(function(b, i) {
b.flexSize(newSizes[i]);
});
this.layout.trigger('layoutResized');
}
};
LayoutEditor.prototype.handleMouseDown = function(event, elem) {
if (event.button !== 0 || event.target.classList.contains('ui-resizable-handle')) {
return;
}
if (event.target.classList.contains('layout_grabbable')) {
this.initialMouseDown = true;
this.originalBox = ko.utils.domData.get(elem, 'layoutBox');
assert(this.originalBox, "MouseDown on element without an associated layoutBox");
G.$(G.window).on('mousemove', this.boundMouseMove);
G.$(G.window).on('mouseup', this.boundMouseUp);
return false;
}
};
LayoutEditor.prototype.dragInNewBox = function(event, leafId) {
var box = this.layout.buildLayoutBox({leaf: leafId});
// Place this box into a measuring div.
this.measuringBox.appendChild(box.getDom());
this.handleMouseDown(event, box.dom);
};
LayoutEditor.prototype.startDragBox = function(event, box) {
this.triggerUserEditStart();
this.targetBox = box;
this.floater.onInitialMouseMove(event, box);
};
LayoutEditor.prototype.handleMouseUp = function(event) {
G.$(G.window).off('mousemove', this.boundMouseMove);
G.$(G.window).off('mouseup', this.boundMouseUp);
if (this.initialMouseDown) {
this.initialMouseDown = false;
return;
}
this.targetBox.takeLeafFrom(this.floater);
if (this.dropTargeter.activeTarget) {
this.dropTargeter.accelerateInsertion();
} else {
resizeLayoutBox(this.targetBox, 'reset');
}
this.dropTargeter.removeTargetHints();
this.dropOverlay.detach();
this.transitionPromise.bind(this).finally(function() {
this.floater.onMouseUp();
resizeLayoutBox(this.targetBox, 'reset');
this.targetBox = this.originalBox = null;
dispose.emptyNode(this.measuringBox);
this.triggerUserEditStop();
});
};
LayoutEditor.prototype.removeContainingBox = function(elem) {
var box = this.layout.getContainingBox(elem);
if (box && !box.isDomDetached()) {
this.triggerUserEditStart();
this.targetBox = box;
var rect = box.dom.getBoundingClientRect();
box.leafId('empty');
box.leafContent(dom('div.layout_editor_empty_space',
kd.style('min-height', rect.height + 'px')
));
this.onInsertBox(_.noop, rect);
this.triggerUserEditStop();
}
};
LayoutEditor.prototype.handleMouseMove = function(event) {
// Make sure the grabbed box still exists
if (this.originalBox.isDisposed()) {
return;
}
if (this.initialMouseDown) {
this.initialMouseDown = false;
this.startDragBox(event, this.originalBox);
}
this.floater.onMouseMove(event);
if (this.transitionPromise.isPending()) {
// Don't attempt to do any repositioning while another reposition is happening.
return;
}
// Handle dragging to trash.
if (dom.findAncestor(event.target, null, '.layout_trash')) {
var isTrashed = this.targetBox && this.targetBox.isDomDetached();
if (!this.trashDelay.isPending() && !isTrashed) {
// To "trash" a box, we call onInsertBox with noop for the inserter function. The new box
// will still be created, just not attached to anything.
this.trashDelay.schedule(100, this.onInsertBox, this, _.noop);
}
return;
}
this.trashDelay.cancel();
// See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged
// element. If so, we are dealing with repositioning.
var elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId);
if (elem) {
var hoverBox = ko.utils.domData.get(elem, 'layoutBox');
this.dropOverlay.attach(elem);
var affinity = this.dropOverlay.getAffinity(event);
this.dropTargeter.updateTargetHints(hoverBox, affinity, this.dropOverlay, this.targetBox);
} else if (!dom.findAncestor(event.target, this.rootElem, '.layout_editor_drop_target')) {
this.dropTargeter.removeTargetHints();
}
};
/**
* Resizes the given LayoutBox to transition it when it's supposed to expand or collapse. It only
* affects the height for HBoxes, and only the width for VBoxes. For rows, we use an explicit
* height. For columns we rely on 'flex-grow' property.
* A rectangle object: set the relevant style according to the values there.
* 'reset': unset the relevant style, to revert to the values associated with CSS classes.
* 'collapse': collapse to empty size.
* 'current': set and explicit value for the relevant style, which is needed for transitions.
*/
function resizeLayoutBox(layoutBox, sizeRect) {
var reset = (sizeRect === 'reset');
var collapse = (sizeRect === 'collapse');
if (sizeRect === 'current') {
sizeRect = layoutBox.dom.getBoundingClientRect();
}
if (layoutBox.isHBox()) {
layoutBox.dom.style.height = (reset ? '' : (collapse ? '0px' : sizeRect.height + 'px'));
} else {
layoutBox.dom.style.width = (reset ? '' : (collapse ? '0px' : sizeRect.width + 'px'));
}
layoutBox.dom.style.opacity = collapse ? '0.0' : '1.0';
}
function rectDesc(rect) {
return (typeof rect === 'string') ? rect :
Math.floor(rect.width) + "x" + Math.floor(rect.height);
}
/**
* Resizes the given LayoutBox smoothly from starting to ending position, where startRect and
* endRect are one of the values documented in 'resizeLayoutBox'.
*/
function resizeLayoutBoxSmoothly(layoutBox, startRect, endRect) {
if (layoutBox.isDomDetached()) {
return Promise.resolve();
}
var prevFlexGrow = layoutBox.dom.style.flexGrow;
layoutBox.dom.style.flexGrow = 0;
resizeLayoutBox(layoutBox, startRect);
// Force the layout engine to compute the current state of the layoutBox.dom element before
// applying the transition. This follows the recommendation here, and seems to work:
// https://timtaubert.de/blog/2012/09/css-transitions-for-dynamically-created-dom-elements/
_.pick(G.window.getComputedStyle(layoutBox.dom), 'height', 'width');
// Start the transition.
layoutBox.dom.classList.add('layout_editor_resize_transition');
return new Promise(function(resolve, reject) {
dom.once(layoutBox.dom, 'transitionend', function() { resolve(); });
resizeLayoutBox(layoutBox, endRect);
})
.timeout(600) // Transitions are only 400ms long, so complain if nothing happened for longer.
.catch(Promise.TimeoutError, function() {
console.error("LayoutEditor.resizeLayoutBoxSmoothly %s %s->%s: transition didn't run",
layoutBox, rectDesc(startRect), rectDesc(endRect));
// We keep going. It should look like something's wrong and jumpy, but it should still be
// usable and not cause errors elsewhere.
})
.finally(function() {
layoutBox.dom.classList.remove('layout_editor_resize_transition');
layoutBox.dom.style.flexGrow = prevFlexGrow;
});
}
LayoutEditor.prototype.onInsertBox = function(inserterFunc, optRect) {
// Create a new LayoutBox, and insert it using inserterFunc.
// Shrink prevTargetBox to 0. Create a new target box, initially shrunk, and grow it.
var prevTargetBox = this.targetBox;
this.targetBox = Layout.LayoutBox.create(this.layout);
this.targetBox.takeLeafFrom(prevTargetBox);
this.targetBox.flexSize(prevTargetBox.flexSize());
// Sizing boxes vertically requires extra care that the sum of values doesn't change.
this.targetBox.getDom(); // Make sure its dom is created.
//console.log("onInsertBox %s -> %s", prevTargetBox, this.targetBox);
var transitionPromiseResolve;
this.transitionPromise = new Promise(function(resolve, reject) {
transitionPromiseResolve = resolve;
});
inserterFunc(this.targetBox);
var prevRect = prevTargetBox.dom.getBoundingClientRect();
// Set previous box size to 0 for accurate measurement of new target box
var prevFlexGrow = prevTargetBox.dom.style.flexGrow;
prevTargetBox.dom.style.flexGrow = 0;
var targetRect = this.targetBox.dom.getBoundingClientRect();
prevTargetBox.dom.style.flexGrow = prevFlexGrow;
return Promise.all([
resizeLayoutBoxSmoothly(prevTargetBox, prevRect, 'collapse'),
resizeLayoutBoxSmoothly(this.targetBox, 'collapse', targetRect),
])
.bind(this).then(function() {
prevTargetBox.dispose();
if (this.targetBox) {
resizeLayoutBox(this.targetBox, 'reset');
this.dropOverlay.attach(this.targetBox.dom);
}
transitionPromiseResolve();
this.layout.trigger('layoutResized');
});
};

View File

@ -0,0 +1,7 @@
.layout_preview_leaf {
position: relative;
flex: 1 1 0px;
background-color: black;
margin: 1px 0 0 1px;
border-radius: 1px;
}

View File

@ -0,0 +1,40 @@
var ko = require('knockout');
var dom = require('../lib/dom');
var dispose = require('../lib/dispose');
var Layout = require('./Layout');
/**
* LayoutPreview - Represents a preview for a single layout. Builds an icon that takes
* the size of its container showing a version of the layout made from solid blocks.
* An optional map between leafId and hex color strings may be used to color the blocks.
* The map may be an observable, but it is only consulted on changes to layoutSpecObj.
*/
function LayoutPreview(layoutSpecObj, optColorMap) {
var self = this;
this.layoutSpecObj = layoutSpecObj;
this.colorMap = optColorMap || {};
this.layout = this.autoDispose(
Layout.Layout.create(this.layoutSpecObj(),
function(leafId) {
var content = dom('div.layout_preview_leaf');
var colorMap = ko.unwrap(self.colorMap);
content.style.backgroundColor = colorMap[leafId] || "#000";
return content;
}, true)
);
// When the layoutSpec changes, rebuild.
this.autoDispose(this.layoutSpecObj.subscribe(function(spec) {
this.layout.buildLayout(this.layoutSpecObj(), true);
}, this));
}
dispose.makeDisposable(LayoutPreview);
LayoutPreview.prototype.buildDom = function() {
return this.layout.rootElem;
};
module.exports = LayoutPreview;

View File

@ -0,0 +1,122 @@
const _ = require('underscore');
const ko = require('knockout');
const dispose = require('../lib/dispose');
const gutil = require('app/common/gutil');
/**
* Returns if the first table is a summary of the second. If both are summary tables, returns true
* if the second table is a more detailed summary, i.e. has additional group-by columns.
* @param {MetaRowModel} summary: RowModel for the table to check for being the summary table.
* @param {MetaRowModel} detail: RowModel for the table to check for being the detailed version.
* @returns {Boolean} Whether the first argument is a summarized version of the second.
*/
function isSummaryOf(summary, detail) {
let summarySource = summary.summarySourceTable();
if (summarySource === detail.getRowId()) { return true; }
let detailSource = detail.summarySourceTable();
return (summarySource &&
detailSource === summarySource &&
summary.getRowId() !== detail.getRowId() &&
gutil.isSubset(summary.summarySourceColRefs(), detail.summarySourceColRefs()));
}
/**
* Maintains state useful for linking sections, i.e. auto-filtering and auto-scrolling.
* Exposes .filterColValues, which is either null or a computed evaluating to a filtering object;
* and .cursorPos, which is either null or a computed that evaluates to a cursor position.
* LinkingState must be created with a valid srcSection and tgtSection.
*
* There are several modes of linking:
* (1) If tgtColId is set, tgtSection will be filtered to show rows whose values of target column
* are equal to the value of source column in srcSection at the cursor. With byAllShown set, all
* values in srcSection are used (rather than only the value in the cursor).
* (2) If srcSection is a summary of tgtSection, then tgtSection is filtered to show only those
* rows that match the row at the cursor of srcSection.
* (3) If tgtColId is null, tgtSection is scrolled to the rowId determined by the value of the
* source column at the cursor in srcSection.
*
* @param gristDoc: GristDoc instance, for getting the relevant TableData objects.
* @param srcSection: RowModel for the section that drives the target section.
* @param srcColId: Name of the column that drives the target section, or null to use rowId.
* @param tgtSection: RowModel for the section that's being driven.
* @param tgtColId: Name of the reference column to auto-filter by, or null to auto-scroll.
* @param byAllShown: For auto-filter, filter by all values in srcSection rather than only the
* value at the cursor. The user can use column filters on srcSection to control what's shown
* in the linked tgtSection.
*/
function LinkingState(gristDoc, srcSection, srcColId, tgtSection, tgtColId, byAllShown) {
this._srcSection = srcSection;
let srcTableData = gristDoc.getTableModel(srcSection.table().tableId()).tableData;
// Function from srcRowId (i.e. srcSection.activeRowId()) to the source value. It is used for
// filtering or for cursor positioning, depending on the setting of tgtCol.
let srcValueFunc = srcColId ? srcTableData.getRowPropFunc(srcColId) : _.identity;
// If linking affects target section's cursor, this will be a computed for the cursor rowId.
this.cursorPos = null;
// If linking affects filtering, this is a computed for the current filtering state, as a
// {[colId]: colValues} mapping, with a dependency on srcSection.activeRowId(). Otherwise, null.
this.filterColValues = null;
// A computed that evaluates to a filter function to use, or null if not filtering. If
// filtering, depends on srcSection.activeRowId().
if (tgtColId) {
if (byAllShown) {
// Include all values present in srcSection.
this.filterColValues = this.autoDispose(ko.computed(() => {
const srcValues = new Set();
const viewInstance = srcSection.viewInstance();
if (viewInstance) {
for (const srcRowId of viewInstance.sortedRows.getKoArray().all()) {
if (srcRowId !== 'new') {
srcValues.add(srcValueFunc(srcRowId));
}
}
}
return {[tgtColId]: Array.from(srcValues)};
}));
} else {
this.filterColValues = this.autoDispose(ko.computed(() => {
const srcRowId = srcSection.activeRowId();
const srcValue = srcValueFunc(srcRowId);
return {[tgtColId]: [srcValue]};
}));
}
} else if (isSummaryOf(srcSection.table(), tgtSection.table())) {
// We filter summary tables when a summary section is linked to a more detailed one without
// specifying src or target column. The filtering is on the shared group-by column (i.e. all
// those in the srcSection).
// TODO: This approach doesn't help cursor-linking (the other direction). If we have the
// inverse of summary-table's 'group' column, we could implement both, and more efficiently.
this.filterColValues = this.autoDispose(ko.computed(() => {
const srcRowId = srcSection.activeRowId();
const filter = {};
for (const c of srcSection.table().columns().all()) {
if (c.summarySourceCol()) {
const colId = c.summarySource().colId();
const srcValue = srcTableData.getValue(srcRowId, colId);
filter[colId] = [srcValue];
}
}
return filter;
}));
} else if (isSummaryOf(tgtSection.table(), srcSection.table())) {
// TODO: We should move the cursor, but don't currently it for summaries. For that, we need a
// column or map representing the inverse of summary table's "group" column.
} else {
this.cursorPos = this.autoDispose(ko.computed(() => srcValueFunc(srcSection.activeRowId())));
}
}
dispose.makeDisposable(LinkingState);
/**
* Returns a boolean indicating whether editing should be disabled in the destination section.
*/
LinkingState.prototype.disableEditing = function() {
return this.filterColValues && this._srcSection.activeRowId() === 'new';
};
module.exports = LinkingState;

View File

@ -0,0 +1,81 @@
.login-services {
margin: 0 15%;
}
.login-btns > .kf_elem {
flex: 1 1 100%;
}
.login-spacer {
height: 10px;
}
.login-divider {
height: 0;
width: 100%;
margin-top: 20px;
border-bottom: 1px solid #ccc;
margin-bottom: 20px;
}
.login-divider-text {
text-align: center;
display: inline-block;
position: absolute;
font-size: 1.2rem;
margin: -0.7rem auto 0 auto;
left: 0;
right: 0;
background-color: white;
width: 50px;
color: #aaa;
}
.login-error-notify {
background-color: #fdd;
padding: 10px;
margin: 10px 20px;
border: 1px solid #daa;
}
.login-success-notify {
background-color: #dfd;
padding: 10px;
margin: 10px 20px;
border: 1px solid #ada;
}
.login-send-code-box {
text-align: center;
margin-top: 10px;
}
.login-send-code {
display: inline-block;
color: #337ab7;
cursor: pointer;
}
.profile-row {
font-size: 1.2rem;
}
.edit-profile.btn {
background: none;
box-shadow: none;
}
.edit-profile.btn:hover {
color: #aaa;
}
.edit-profile.btn:active {
outline: none;
}
.edit-profile-form {
margin: 10px;
padding: 10px;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}

View File

@ -0,0 +1,129 @@
/* global window */
// External dependencies
const Promise = require('bluebird');
const ko = require('knockout');
// Grist client libs
const dispose = require('../lib/dispose');
const ProfileForm = require('./ProfileForm');
/**
* Login - Handles dom and settings for the login box.
* @param {app} app - The app instance.
*/
function Login(app) {
this.app = app;
this.comm = this.app.comm;
// When logged in, an object containing user profile properties.
this._profile = ko.observable();
this.isLoggedIn = ko.observable(false);
this.emailObs = this.autoDispose(ko.computed(() => ((this._profile() && this._profile().email) || '')));
this.nameObs = this.autoDispose(ko.computed(() => ((this._profile() && this._profile().name) || '')));
this.buttonText = this.autoDispose(ko.computed(() =>
this.isLoggedIn() ? this.emailObs() : 'Log in'));
// Instantialized with createLoginForm() and createProfileForm()
this.profileForm = null;
}
dispose.makeDisposable(Login);
/**
* Returns the current profile object.
*/
Login.prototype.getProfile = function() {
return this._profile();
};
/**
* Opens the Cognito login form in a new browser window to allow the user to log in.
* The login tokens are sent back to the server to which this client belongs.
*/
Login.prototype.login = function() {
if (window.isRunningUnderElectron) {
// Under electron, we open the login URL (it opens in a user's default browser).
// With null for redirectUrl, it will close automatically when login completes.
return this.comm.getLoginUrl(null)
.then((loginUrl) => window.open(loginUrl));
} else {
// In hosted / dev version, we redirect to the login URL, and it will redirect back to the
// starting URL when login completes.
return this.comm.getLoginUrl(window.location.href)
.then((loginUrl) => { window.location.href = loginUrl; });
}
};
/**
* Tells the server to log out, and also opens a new window to the logout URL to get Cognito to
* clear its cookies. The new window will hit our page which will close the window automatically.
*/
Login.prototype.logout = function() {
// We both log out the server, and for hosted version, visit a logout URL to clear AWS cookies.
if (window.isRunningUnderElectron) {
// Under electron, only clear the server state. Don't open the user's default browser
// to clear cookies there because it serves dubious purpose and is annoying to the user.
return this.comm.logout(null);
} else {
// In hosted / dev version, we redirect to the logout URL, which will clear cookies and
// redirect back to the starting URL when logout completes.
return this.comm.logout(window.location.href)
.then((logoutUrl) => { window.location.href = logoutUrl; });
}
};
/**
* Retrieves the updated user profile from DynamoDB and creates the profile form.
* Also sends the fetched user profile to the server to keep it up to date.
*/
Login.prototype.createProfileForm = function() {
// ProfileForm disposes itself, no need to handle disposal.
this.profileForm = ProfileForm.create(this);
};
// Called when the user logs out in this or another tab.
Login.prototype.onLogout = function() {
this._profile(null);
this.isLoggedIn(false);
};
/**
* Update the internally-stored profile given a profile object from the server.
*/
Login.prototype.updateProfileFromServer = function(profileObj) {
this._profile(profileObj);
this.isLoggedIn(Boolean(this._profile.peek()));
};
Login.prototype.setProfileItem = function(key, val) {
return this.comm.updateProfile({[key]: val});
};
/**
* Returns an array of tableIds in the basket of the current document. If the current
* document has no basket, an empty array is returned.
*/
Login.prototype.getBasketTables = function(docComm) {
return docComm.getBasketTables();
};
// Execute func if the user is logged in. Otherwise, prompt the user to log in
// and then execute the function. Attempts refresh if the token is expired.
Login.prototype.tryWithLogin = function(func) {
return Promise.try(() => {
if (!this.isLoggedIn()) {
return this.login();
}
})
.then(() => func())
.catch(err => {
if (err.code === 'LoginClosedError') {
console.log("Login#tryWithLogin", err);
} else {
throw err;
}
});
};
module.exports = Login;

View File

@ -0,0 +1,125 @@
/* global $, document, window */
var _ = require('underscore');
var ko = require('knockout');
var BackboneEvents = require('backbone').Events;
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var Base = require('./Base');
/**
* ModalDialog constructor creates a new ModalDialog element. The dialog accepts a bunch of
* options, and the content can be overridde when calling .show(), so that a single ModalDialog
* component can be used for different purposes. It triggers a 'close' event (using
* Backbone.Events) when hidden.
*
* The DOM of the dialog is always attached to document.body.
*
* @param {Boolean} [options.fade] Include `fade` css class to fade the modal in/out.
* @param {Boolean} [options.close] Include a close icon in the corner (default true).
* @param {Boolean} [options.backdrop] Include a modal-backdrop element (default true).
* @param {Boolean} [options.keyboard] Close the modal on Escape key (default true).
* @param {Boolean} [options.show] Shows the modal when initialized (default true).
* @param {CSS String} [options.width] Optional css width to override default.
* @param {DOM|String} [options.title] The content to place in the title.
* @param {DOM|String} [options.body] The content to place in the body.
* @param {DOM|String} [options.footer] The content to place in the footer.
*/
function ModalDialog(options) {
Base.call(this, options);
options = options || {};
this.options = _.defaults(options, {
fade: false, // Controls whether the model pops or fades into view
close: true, // Determines whether the "x" dismiss icon appears in the modal
backdrop: true,
keyboard: true,
show: false,
});
// If the width option is set, the margins must be set to auto to keep the dialog centered.
this.style = options.width ?
`width: ${this.options.width}; margin-left: auto; margin-right: auto;` : '';
this.title = ko.observable(options.title || null);
this.body = ko.observable(options.body || null);
this.footer = ko.observable(options.footer || null);
this.modal = this.autoDispose(this._buildDom());
document.body.appendChild(this.modal);
$(this.modal).modal(_.pick(this.options, 'backdrop', 'keyboard', 'show'));
// On applyState event, close the modal.
this.onEvent(window, 'applyState', () => this.hide());
// If disposed, let the underlying JQuery Modal run its hiding logic, and trigger 'close' event.
this.autoDisposeCallback(this.hide);
}
Base.setBaseFor(ModalDialog);
_.extend(ModalDialog.prototype, BackboneEvents);
/**
* Shows the ModalDialog. It accepts the same `title`, `body`, and `footer` options as the
* constructor, which will replace previous content of those sections.
*/
ModalDialog.prototype.show = function(options) {
options = options || {};
// Allow options to specify new title, body, and footer content.
['title', 'body', 'footer'].forEach(function(prop) {
if (options.hasOwnProperty(prop)) {
this[prop](options[prop]);
}
}, this);
$(this.modal).modal('show');
};
/**
* Hides the ModalDialog. This triggers the `close` to be triggered using Backbone.Events.
*/
ModalDialog.prototype.hide = function() {
$(this.modal).modal('hide');
};
/**
* Internal helper to build the DOM of the dialog.
*/
ModalDialog.prototype._buildDom = function() {
var self = this;
// The .clipboard_focus class tells Clipboard.js to let this component have focus. Otherwise
// it's impossible to select text.
return dom('div.modal.clipboard_focus',
{ "role": "dialog", "tabIndex": -1 },
// Emit a 'close' Backbone.Event whenever the dialog is hidden.
dom.on('hidden.bs.modal', function() {
self.trigger('close');
}),
dom('div.modal-dialog', { style: this.style },
dom('div.modal-content',
kd.toggleClass('fade', self.options.fade),
kd.maybe(this.title, function(title) {
return dom('div.modal-header',
kd.maybe(self.options.close, function () {
return dom('button.close',
{"data-dismiss": "modal", "aria-label": "Close"},
dom('span', {"aria-hidden": true}, '×')
);
}),
dom('h4.modal-title', title)
);
}),
kd.maybe(this.body, function(body) {
return dom('div.modal-body', body);
}),
kd.maybe(this.footer, function(footer) {
return dom('div.modal-footer', footer);
})
)
)
);
};
module.exports = ModalDialog;

View File

@ -0,0 +1,118 @@
import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons';
import {squareCheckbox} from 'app/client/ui2018/checkbox';
import {colors, testId} from 'app/client/ui2018/cssVars';
import {cssModalButtons} from 'app/client/ui2018/modals';
import {ParseOptionSchema} from 'app/plugin/FileParserAPI';
import {Computed, dom, DomContents, IDisposableOwner, input, Observable, styled} from 'grainjs';
import fromPairs = require('lodash/fromPairs');
import invert = require('lodash/invert');
export type ParseOptionValueType = boolean|string|number;
export interface ParseOptionValues {
[name: string]: ParseOptionValueType;
}
/**
* EscapeChars contains mapping for some escape characters that we need to convert
* for displaying in input fields
*/
interface EscapeChars {
[char: string]: string;
}
const escapeCharDict: EscapeChars = {
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
};
const invertedEscapeCharDict: EscapeChars = invert(escapeCharDict);
// Helpers to escape and unescape certain non-printable characters that are useful in parsing
// options, e.g. as separators.
function escapeChars(value: string) {
return value.replace(/[\n\r\t]/g, (match) => escapeCharDict[match]);
}
function unescapeChars(value: string) {
return value.replace(/\\[nrt]/g, (match) => invertedEscapeCharDict[match]);
}
/**
* Builds a DOM form consisting of inputs built according to schema, with the passed-in values.
* The included "Update" button is enabled if any value has changed, and calls doUpdate() with the
* current values.
*/
export function buildParseOptionsForm(
owner: IDisposableOwner,
schema: ParseOptionSchema[],
values: ParseOptionValues,
doUpdate: (v: ParseOptionValues) => void,
doCancel: () => void,
): DomContents {
const items = schema.filter(item => item.visible);
const optionsMap = new Map<string, Observable<ParseOptionValueType>>(
items.map((item) => [item.name, Observable.create(owner, values[item.name])]));
function collectParseOptions(): ParseOptionValues {
return fromPairs(items.map((item) => [item.name, optionsMap.get(item.name)!.get()]));
}
return [
cssParseOptionForm(
items.map((item) => cssParseOption(
cssParseOptionName(item.label),
optionToInput(owner, item.type, optionsMap.get(item.name)!),
testId('parseopts-opt'),
)),
),
cssModalButtons(
dom.domComputed((use) => items.every((item) => use(optionsMap.get(item.name)!) === values[item.name]),
(unchanged) => (unchanged ?
bigBasicButton('Back to preview', dom.on('click', doCancel), testId('parseopts-back')) :
bigPrimaryButton('Update preview', dom.on('click', () => doUpdate(collectParseOptions())),
testId('parseopts-update'))
)
)
),
];
}
function optionToInput(owner: IDisposableOwner, type: string, value: Observable<ParseOptionValueType>): HTMLElement {
switch (type) {
case 'boolean': return squareCheckbox(value as Observable<boolean>);
default: {
const obs = Computed.create(owner, (use) => escapeChars(String(use(value) || "")))
.onWrite((val) => value.set(unescapeChars(val)));
return cssInputText(obs, {onInput: true},
dom.on('focus', (ev, elem) => elem.select()));
}
}
}
const cssParseOptionForm = styled('div', `
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 16px 0;
width: 400px;
overflow-y: auto;
`);
const cssParseOption = styled('div', `
flex: none;
margin: 8px 0;
width: calc(50% - 16px);
font-weight: initial; /* negate bootstrap */
`);
const cssParseOptionName = styled('div', `
margin-bottom: 8px;
`);
const cssInputText = styled(input, `
position: relative;
display: inline-block;
outline: none;
height: 28px;
border: 1px solid ${colors.darkGrey};
border-radius: 3px;
padding: 0 6px;
width: 100%;
`);

View File

@ -0,0 +1,43 @@
.preference_mask {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 10;
background-color: rgba(1, 1, 1, 0.3);
}
.preference_window {
position: absolute;
background-color: white;
display: inline-block;
padding: 10px 20px;
width: 60%;
margin: 20%;
box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.8);
}
.preference_title {
color: #546e7a;
text-align: left;
border-bottom: 1px solid rgba(0,0,0,.12);
font-family: 'Roboto', Helvetica, Arial, sans-serif;
width: 100%;
height: 40px;
}
.preference_content {
width: 100%;
font-size: 1.4rem;
}
.preference_desc {
margin-left: 5px;
cursor: pointer;
font-weight: normal;
}
.preference_footer > .kf_elem {
flex: 1 1 100%;
}

View File

@ -0,0 +1,160 @@
/* global document */
// External dependencies
const _ = require('underscore');
const ko = require('knockout');
const BackboneEvents = require('backbone').Events;
// Grist client libs
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kf = require('../lib/koForm');
const kd = require('../lib/koDom');
const ModalDialog = require('./ModalDialog');
/**
* ProfileForm - Handles dom and settings for the profile box.
* @param {Login} login - The login instance.
*/
function ProfileForm(login) {
this._login = login;
this._comm = this._login.comm;
this._gristLogin = this._login.gristLogin;
this._errorNotify = ko.observable();
this._successNotify = ko.observable();
// Form data which may be filled in when modifying profile information.
this._newName = ko.observable('');
// Counter used to provide each edit profile sub-form with an id which indicates
// when it is visible.
this._formId = 1;
this._editingId = ko.observable(null);
this._profileDialog = this.autoDispose(ModalDialog.create({
title: 'User profile',
body: this._buildProfileDom(),
width: '420px'
}));
this._profileDialog.show();
// TODO: Some indication is necessary that verification is occurring between
// submitting the form and waiting for the box to close.
this.listenTo(this._comm, 'clientLogout', () => this.dispose());
this.listenTo(this._profileDialog, 'close', () => this.dispose());
}
_.extend(ProfileForm.prototype, BackboneEvents);
dispose.makeDisposable(ProfileForm);
/**
* Builds the body of the profile modal form.
*/
ProfileForm.prototype._buildProfileDom = function() {
return dom('div.profile-form',
// Email
// TODO: Allow changing email
this._buildProfileRow('Email', {
buildDisplayFunc: () => dom('div',
kd.text(this._login.emailObs),
dom.testId('ProfileForm_viewEmail')
)
}),
// Name
this._buildProfileRow('Name', {
buildDisplayFunc: () => dom('div',
kd.text(this._login.nameObs),
dom.testId('ProfileForm_viewName')
),
buildEditFunc: () => dom('div',
kf.label('New name'),
kf.text(this._newName, {}, dom.testId('ProfileForm_newName'))
),
submitFunc: () => this._submitNameChange()
}),
// TODO: Allow editing profile image.
kd.maybe(this._successNotify, success => {
return dom('div.login-success-notify',
dom('div.login-success-text', success)
);
}),
kd.maybe(this._errorNotify, err => {
return dom('div.login-error-notify',
dom('div.login-error-text', err)
);
})
);
};
/**
* Builds a row of the profile form.
* @param {String} label - Indicates the profile item displayed by the row.
* @param {Function} options.buildDisplayFunc - A function which returns dom representing
* the value of the profile item to be displayed. If omitted, no value is visible.
* @param {Function} options.buildEditFunc - A function which returns dom to change the
* value of the profile item. If omitted, the profile item may not be edited.
* @param {Function} options.submitFunc - A function to call to save changes to the
* profile item. MUST be included if buildEditFunc is included.
*/
ProfileForm.prototype._buildProfileRow = function(label, options) {
options = options || {};
let formId = this._formId++;
return dom('div.profile-row',
kf.row(
2, kf.label(label),
5, options.buildDisplayFunc ? options.buildDisplayFunc() : '',
1, dom('div.btn.edit-profile.glyphicon.glyphicon-pencil',
{ style: `visibility: ${options.buildEditFunc ? 'visible' : 'hidden'}` },
dom.testId(`ProfileForm_edit${label}`),
dom.on('click', () => {
this._editingId(this._editingId() === formId ? null : formId);
})
)
),
kd.maybe(() => this._editingId() === formId, () => {
return dom('div',
dom.on('keydown', e => {
if (e.keyCode === 13) {
// Current element is likely a knockout text field with changes that haven't yet been
// saved to the observable. Blur the current element to ensure its value is saved.
document.activeElement.blur();
options.submitFunc();
}
}),
dom('div.edit-profile-form',
options.buildEditFunc(),
dom('div.login-btns.flexhbox',
kf.buttonGroup(
kf.button(() => this._editingId(null), 'Cancel',
dom.testId('ProfileForm_cancel'))
),
kf.buttonGroup(
kf.accentButton(() => options.submitFunc(), 'Submit',
dom.testId('ProfileForm_submit'))
)
)
)
);
})
);
};
// Submits the profile name change form.
ProfileForm.prototype._submitNameChange = function() {
if (!this._newName()) {
throw new Error('Name may not be blank.');
}
return this._login.setProfileItem('name', this._newName())
// TODO: attemptRefreshToken() should be handled in a general way for all methods
// which require using tokens after sign in.
.then(() => {
this._editingId(null);
this._successNotify('Successfully changed name.');
})
.catch(err => {
console.error('Error changing name', err);
this._errorNotify(err.message);
});
};
module.exports = ProfileForm;

View File

@ -0,0 +1,106 @@
.repl-container {
padding: 0 10px;
}
.repl-text {
cursor: pointer;
display: inline-block;
font-family: monospace;
white-space: pre;
tab-size: 4;
-moz-tab-size: 4;
-o-tab-size: 4;
word-wrap: break-word;
}
.repl-field {
display: inline-block;
}
.repl-error {
color: #D00;
}
.repl-text_line:hover {
background-color: #E5E5E5;
}
.re-eval_line_button {
cursor: pointer;
float: right;
text-align: center;
width: 15px;
color: #808080;
}
.erase_line_button {
cursor: pointer;
float: right;
text-align: center;
width: 15px;
color: #808080;
}
.re-eval_line_button:hover {
color: #000000;
}
.erase_line_button:hover {
color: #000000;
}
.unselectable {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.pointer_group {
cursor: pointer;
display: inline-block;
width: 25px;
}
.pointer {
font-family: monospace;
font-weight: bold;
}
.repl-newline {
font-family: monospace;
font-weight: bold;
padding: 2px 10px 15px 10px;
}
.repl-cursor_editor {
display: inline;
}
.repl-text_editor {
position: absolute;
padding: 2px 0 0 0;
min-width: 10px;
min-height: 100%;
width: 100%;
height: 100%;
border: none;
resize: none;
box-shadow: none;
outline: none;
background: transparent;
z-index: 10;
overflow: hidden;
}
.repl-content_measure {
position: absolute;
left: 0;
top: 0;
padding-top: 2px;
padding-right: 1em;
border: none;
visibility: hidden;
overflow: visible;
}
.formula-text {
font-family: monospace;
tab-size: 4;
-moz-tab-size: 4;
-o-tab-size: 4;
}

View File

@ -0,0 +1,318 @@
/**
* A Tab that contains a REPL.
* The REPL allows the user to write snippets of python code and see the results of evaluating
* them. In particular, the REPL has access to the usercode module, so they can see the results
* of quick operations on their data.
* The REPL supports evaluation of code, removal of lines from history, and re-computation
* and editing of older lines.
*/
var kd = require('../lib/koDom');
var ko = require('knockout');
var dom = require('../lib/dom');
var Base = require('./Base');
var commands = require('./commands');
var NEW_LINE = -1;
/**
* Hard tab used instead of soft tabs, as soft-tabs would require a lot of additional
* editor logic (partial-width tabs, backspacing a tab, ...) for
* which we may want to eventually use a 3rd-party library for in addition to syntax highlighting, etc
*/
var INDENT_STR = "\t";
function REPLTab(gristDoc) {
Base.call(this, gristDoc);
this.replHist = gristDoc.docModel.replHist.createAllRowsModel("id");
this.docData = gristDoc.docData;
this.editingIndex = ko.observable(null);
this.histIndex = ko.observable(this.replHist.peekLength);
this.editorActive = ko.observable(false);
this.numLines = ko.observable(0);
this.row = null;
this._contentSizer = ko.observable('');
this._originalValue = '';
this._textInput = null;
this.commandGroup = this.autoDispose(commands.createGroup(
REPLTab.replCommands, this, this.editorActive));
}
Base.setBaseFor(REPLTab);
/**
* Editor commands for the cellEditor in the REPL Tab
* TODO: Using the command group, distinguish between "on enter" saves and "on blur" saves
* So that we can give up focus on blur
*/
REPLTab.replCommands = {
// TODO: GridView commands are activated more recently after startup.
fieldEditSave: function() {
if (!this._textInput || !this.editorActive() ||
!this._textInput.value.trim() && this.editingIndex() === NEW_LINE) { return; }
// TODO: Scroll pane does not automatically scroll down on save.
var self = this;
this.save()
.then(function(success) {
if (success) {
self.editingIndex(NEW_LINE);
self.clear();
// Refresh the history index.
self.histIndex(self.replHist.peekLength);
} else {
self.write("\n");
// Since focus is staying in the current input, increment lines.
self.numLines(self.numLines.peek()+1);
}
});
},
fieldEditCancel: function() {
this.clear();
this.editingIndex(NEW_LINE);
},
nextField: function() {
// In this case, 'nextField' (Tab) inserts a tab.
this.write(INDENT_STR);
},
historyPrevious: function() {
// Fills the editor with the code previously entered.
if (this.editingIndex() === NEW_LINE) { this.writePrev(); }
},
historyNext: function() {
// Fills the editor with the code entered after the current code.
if (this.editingIndex() === NEW_LINE) { this.writeNext(); }
}
};
/**
* Sends the entered code as an EvalCode Useraction.
* @param {Function} callback - Is called with a single argument 'success' indicating
* whether the save was successful.
*/
REPLTab.prototype.save = function(callback) {
if (!this._textInput.value.trim()) {
// If its text is cleared, remove history item.
var currentEditIndex = this.editingIndex();
this.histIndex(this.replHist.peekLength - 1);
this.editorActive(false);
return this.docData.sendAction(["RemoveRecord", "_grist_REPL_Hist", currentEditIndex]);
}
else {
// If something is entered, save value.
var rowId = this.row ? this.row.id() : null;
return this.docData.sendAction(["EvalCode", this._textInput.value, rowId]);
}
};
// Builds object with REPLTab dom builder and settings for the sidepane.
REPLTab.prototype.buildConfigDomObj = function() {
return [{
'buildDom': this.buildDom.bind(this),
'keywords': ['repl', 'console', 'python', 'code', 'terminal']
}];
};
REPLTab.prototype.buildDom = function() {
var self = this;
return dom('div',
kd.foreach(this.replHist, function(replLine) {
return dom('div.repl-container',
dom('div.repl-text_line',
kd.scope(function() { return self.editingIndex() === replLine.id(); },
function(isEditing) {
if (isEditing) {
return dom('div.field.repl-field',
kd.scope(self.numLines, function(numLines) {
return self.buildPointerGroup(numLines);
}),
self.attachEditorDom(replLine));
} else {
var numLines = replLine.code().trim().split('\n').length;
return dom('div.repl-field',
dom.on('click', function() {
// TODO: Flickering occurs on click for multiline code segments.
self.editingIndex(replLine.id());
self.focus();
}),
self.buildPointerGroup(numLines),
dom('div.repl-text',
kd.text(replLine.code)
)
);
}
}
),
dom('div.erase_line_button.unselectable', dom.on('click', function() {
self.histIndex(self.replHist.peekLength - 1);
return self.docData.sendAction(
["RemoveRecord", "_grist_REPL_Hist", replLine.id()]
);
}), '\u2A09'),
dom('div.re-eval_line_button.unselectable', dom.on('click', function() {
return self.docData.sendAction(
["EvalCode", replLine.code(), replLine.id()]
);
}), '\u27f3') // 'refresh' symbol
),
kd.maybe(replLine.outputText, function() {
return dom('div.repl-text.repl-output', kd.text(replLine.outputText));
}),
kd.maybe(replLine.errorText, function() {
return dom('div.repl-text.repl-error', kd.text(replLine.errorText));
})
);
}),
// Special bottom editor which sends actions to add new records to the REPL hist.
dom('div.repl-newline',
dom.on('click', function() {
self.editingIndex(NEW_LINE);
self.focus();
}),
dom('div.field.repl-field',
kd.scope(self.numLines, function(numLines) {
return self.buildPointerGroup(self.editingIndex() === NEW_LINE ? numLines : 1);
}),
kd.maybe(ko.pureComputed(function() { return self.editingIndex() === NEW_LINE; }),
function() { return self.attachEditorDom(null); }
)
)
)
);
};
/**
* Builds the set of pointers to the left of the code
* @param {String} code - The code for which the pointer group is to be built.
*/
REPLTab.prototype.buildPointerGroup = function(numLines) {
var pointers = [];
for (var i = 0; i < numLines; i++) {
pointers.push(dom('div.pointer', i ? '...' : '>>>'));
}
return dom('div.pointer_group.unselectable', pointers);
};
REPLTab.prototype.buildEditorDom = function() {
var self = this;
return dom('div.repl-cursor_editor',
dom('div.repl-content_measure.formula-text', kd.text(this._contentSizer)),
function() {
self._textInput = dom('textarea.repl-text_editor.formula-text',
kd.value(self.row ? self.row.code() : ""),
dom.on('focus', function() {
self.numLines(this.value.split('\n').length);
}),
dom.on('blur', function() {
if (!this._textInput || !this.editorActive()) { return; }
self.save()
.then(function(success) {
if (success) {
// If editing a new line, clear it to start fresh.
if (self.editingIndex() === NEW_LINE) { self.clear(); }
// Refresh the history index.
self.histIndex(self.replHist.peekLength);
} else {
self.write("\n");
}
self.editorActive(false);
});
}),
//Resizes the textbox whenever user writes in it.
dom.on('input', function() {
self.numLines(this.value.split('\n').length);
self.resizeElem();
}),
dom.defer(function(elem) {
self.resizeElem();
elem.focus();
// Set the cursor at the end.
var elemLen = elem.value.length;
elem.selectionStart = elemLen;
elem.selectionEnd = elemLen;
}),
dom.on('mouseup mousedown click', function(event) { event.stopPropagation(); }),
self.commandGroup.attach()
);
return self._textInput;
}
);
};
/**
* This function measures a hidden div with the same value as the textarea being edited and then resizes the textarea to match.
*/
REPLTab.prototype.resizeElem = function() {
// \u200B is a zero-width space; it is used so the textbox will expand vertically
// on newlines, but it does not add any width the string
this._contentSizer(this._textInput.value + '\u200B');
var rect = this._textInput.parentNode.childNodes[0].getBoundingClientRect();
//Allows form to expand passed its container div.
this._textInput.style.width = Math.ceil(rect.width) + 'px';
this._textInput.style.height = Math.ceil(rect.height) + 'px';
};
/**
* Appends text to the contents being edited
*/
REPLTab.prototype.write = function(text) {
this._textInput.value += text;
this.resizeElem();
};
/**
* Clears both the current text and any memory of text in the currently edited cell.
*/
REPLTab.prototype.clear = function() {
this._textInput.value = "";
this._orignalValue = "";
this.numLines(1);
this.resizeElem();
};
/**
* Restores focus to the most recent input.
*/
REPLTab.prototype.focus = function() {
if (this._textInput) {
this._textInput.focus();
this.editorActive(true);
}
};
/**
* Writes the code entered before the current code to the input.
*/
REPLTab.prototype.writePrev = function() {
this.histIndex(Math.max(this.histIndex.peek() - 1, 0));
this.clear();
if (this.replHist.at(this.histIndex.peek())) {
this.write(this.replHist.at(this.histIndex.peek()).code());
}
};
/**
* Writes the code entered after the current code to the input.
*/
REPLTab.prototype.writeNext = function() {
this.histIndex(Math.min(this.histIndex() + 1, this.replHist.peekLength));
this.clear();
if (this.histIndex.peek() < this.replHist.peekLength) {
this.write(this.replHist.at(this.histIndex.peek()).code());
}
};
/**
* This function is called in the DOM element where an editor is desired.
* It attaches to as a child of that element with that elements value as default or whatever is set as an override value.
*/
REPLTab.prototype.attachEditorDom = function(row) {
var self = this;
self.row = row;
self._originalValue = self.row ? self.row.code() : "";
return self.buildEditorDom();
};
module.exports = REPLTab;

View File

@ -0,0 +1,46 @@
.g_record_layout_leaf {
width: 100%;
}
.g_record_layout_editing {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
cursor: move;
z-index: 5;
background-color: rgba(192, 192, 192, 0.2);
border-left: 1px solid white;
border-top: 1px solid white;
border-right: 1px solid var(--grist-color-dark-grey);
border-bottom: 1px solid var(--grist-color-dark-grey);
}
.dropdown-menu .g_record_layout_newfield {
margin: 2px 1rem;
padding: 0px 0.5rem;
border: 2px outset rgba(160, 160, 255, 0.5);
background-color: rgba(233, 233, 233, 0.5);
cursor: move;
color: #666;
font-size: 1.2rem;
}
.g_record_delete_field {
position: absolute;
top: 0;
right: 0;
background-color: #404040;
border: 1px solid #404040;
border-radius: 1rem;
color: white;
cursor: pointer;
display: none;
}
.g_record_layout_editing:hover > .g_record_delete_field {
display: block;
}

View File

@ -0,0 +1,353 @@
/**
* Module for displaying a record of user data in a two-dimentional editable layout.
*/
// TODO:
// 1. Consider a way to upgrade a file to add layoutSpec column to the ViewSections meta table.
// Plan: add docInfo schemaVersion field.
// when opening a file, let the sandbox check the version and check if loaded metadata matches the schema.
// sandbox should return doc-version, current-version, and match status.
// if current-version != doc_version [AND mismatch] (this is optional, let's think if we
// want that), then
// Sandbox creates new temp document
// Replays action log into it.
// Renames it over the old document. [Would be nice to ask the user first]
// Reopen document
// 1. [LATER] Create RecordLayout file with APIs to support more efficient big list of laid-out
// records (so that a single RecordLayout can maintain many Layout instances).
// 2. [LATER] Allow dragging in boxes from the view config.
// 3. [LATER] Allow creating new field and inserting at the bottom.
// 4. [LATER] Allow selecting existing field from context menu and inserting.
// 5. [LATER] Add interface to Layout to tab forward and back, left, right, up, down, and use that in
// detail view.
// 6. [LATER] Implement saving and loading of widths in the layout spec.
var _ = require('underscore');
var ko = require('knockout');
var Promise = require('bluebird');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var {Delay} = require('../lib/Delay');
var kd = require('../lib/koDom');
var Layout = require('./Layout');
var RecordLayoutEditor = require('./RecordLayoutEditor');
/**
* Construct a RecordLayout.
* @param {MetaRowModel} options.viewSection: The model for the viewSection represented.
* @param {Function} options.buildFieldDom: Function called with (viewField) that should
* return the DOM for that field.
* @param {Function} options.resizeCallback: Optional function called with no arguments when
* the RecordLayout is modified in a way that may require resizing.
*/
function RecordLayout(options) {
this.viewSection = options.viewSection;
this.buildFieldDom = options.buildFieldDom;
this.isEditingLayout = ko.observable(false);
this.editIndex = ko.observable(0);
this.layoutEditor = ko.observable(null); // RecordLayoutEditor when one is active.
if (options.resizeCallback) {
this._resizeCallback = options.resizeCallback;
this._delayedResize = this.autoDispose(Delay.create());
}
// Observable object that will be rebuilt whenever the list of viewFields changes.
this.fieldsById = this.autoDispose(ko.computed(function() {
return _.indexBy(this.viewSection.viewFields().all(),
function(field) { return field.getRowId(); });
}, this));
// Update the stored layoutSpecObj with any missing fields that are present in viewFields.
this.layoutSpec = this.autoDispose(ko.computed(function() {
return RecordLayout.updateLayoutSpecWithFields(
this.viewSection.layoutSpecObj(), this.viewSection.viewFields().all());
}, this).extend({rateLimit: 0})); // layoutSpecObj and viewFields should be updated together.
this.autoDispose(this.layoutSpec.subscribe(() => this.resizeCallback()));
// TODO: We may want a context menu for each record, but the previous implementation wasn't
// working, and was creating a separate context menu for each row, which is very expensive. A
// better approach is to create a single context menu for the view section, as GridView does.
}
dispose.makeDisposable(RecordLayout);
RecordLayout.prototype.resizeCallback = function() {
// Note that while editing layout, scrolly is hidden, and resizeCallback is unhelpful. We rely
// on explicit resizing when isEditLayout is reset.
if (!this.isDisposed() && this._delayedResize && !this.isEditingLayout.peek()) {
this._delayedResize.schedule(0, this._resizeCallback);
}
};
RecordLayout.prototype.getField = function(fieldRowId) {
// If fieldRowId is a string, then it's actually "colRef:label:value" placeholder that we use
// when adding a new field. If so, return a special object with the fields available.
if (typeof fieldRowId === 'string') {
var parts = gutil.maxsplit(fieldRowId, ":", 2);
return {
isNewField: true, // To make it easy to distinguish from a ViewField MetaRowModel
colRef: parseInt(parts[0], 10),
label: parts[1],
value: parts[2]
};
}
return this.fieldsById()[fieldRowId];
};
/**
* Sets the layout to being edited.
*/
RecordLayout.prototype.editLayout = function(rowIndex) {
this.editIndex(rowIndex);
this.isEditingLayout(true);
};
/**
* Ends layout editing, without updating the layout on the server.
*/
RecordLayout.prototype.onEditLayoutCancel = function(layoutSpec) {
this.isEditingLayout(false);
// Call resizeCallback here, since it's possible that theme was also changed (and auto-saved)
// even though the layout itself was reverted.
this.resizeCallback();
};
/**
* Ends layout editing, and saves the given layoutSpec to the server.
*/
RecordLayout.prototype.onEditLayoutSave = async function(layoutSpec) {
try {
await this.saveLayoutSpec(layoutSpec);
} finally {
this.isEditingLayout(false);
this.resizeCallback();
}
};
/**
* If there is no layout saved, we can create a default layout just from the list of fields for
* this view section. By default we just arrange them into a list of rows, two fields per row.
*/
RecordLayout.updateLayoutSpecWithFields = function(spec, viewFields) {
// We use tmpLayout as a way to manipulate the layout before we get a final spec from it.
var tmpLayout = Layout.Layout.create(spec, function(leafId) { return dom('div'); });
var specFieldIds = tmpLayout.getAllLeafIds();
var viewFieldIds = viewFields.map(function(f) { return f.getRowId(); });
// For any stale fields (no longer among viewFields), remove them from tmpLayout.
_.difference(specFieldIds, viewFieldIds).forEach(function(leafId) {
tmpLayout.getLeafBox(leafId).dispose();
});
// For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a
// two-column layout, so add a new row, or a second box to the last row if it's a leaf.
_.difference(viewFieldIds, specFieldIds).forEach(function(leafId) {
var newBox = tmpLayout.buildLayoutBox({ leaf: leafId });
var rows = tmpLayout.rootBox().childBoxes.peek();
if (rows.length >= 1 && _.last(rows).isLeaf()) {
// Add a new child to the last row.
_.last(rows).addChild(newBox, true);
} else {
// Add a new row.
tmpLayout.rootBox().addChild(newBox, true);
}
});
spec = tmpLayout.getLayoutSpec();
tmpLayout.dispose();
return spec;
};
/**
* Saves the layout spec as build by the user. This is quite involved, because it may need to
* remove fields as well as create fields and possibly new columns. And it needs the results of
* these operations to update the spec before saving it.
*/
RecordLayout.prototype.saveLayoutSpec = function(layoutSpec) {
// The layout hasn't actually changed. Skip the rest to avoid creating no-op actions (the
// resulting no-op undo would be particularly confusing).
if (JSON.stringify(layoutSpec) === this.viewSection.layoutSpec.peek()) {
return;
}
const docModel = this.viewSection._table.docModel;
const docData = docModel.docData;
const tableId = this.viewSection.table().tableId();
const getField = fieldRef => this.getField(fieldRef);
const addColAction = ["AddColumn", null, {}];
// Build a set of fieldRefs (i.e. rowIds) that are currently stored. Also build a map of colRef
// to fieldRef, so that we can restore a field that got removed and re-added (as a colRef).
var origRefs = [];
var colRefToFieldRef = new Map();
this.viewSection.viewFields().all().forEach(f => {
origRefs.push(f.getRowId());
colRefToFieldRef.set(f.colRef(), f.getRowId());
});
// Initialize leaf index counter and num cols to be added counter.
var nextPos = 0;
var addColNum = 0;
// Initialize arrays to keep track of existing field refs and their updated positions.
var existingRefs = [];
var existingPositions = [];
// Initialize arrays to keep track of added fields for existing but hidden columns.
var hiddenColRefs = [];
var hiddenCallbacks = [];
var hiddenPositions = [];
// Initialize arrays to keep track of newly added columns.
var addedCallbacks = [];
var addedPositions = [];
// Recursively process all layoutBoxes in the spec. Sets up bookkeeping arrays for
// exisiting fields and added fields for new/hidden cols from which the action bundle will
// be created.
function processBox(spec) {
// "empty" is a temporary placeholder used by LayoutEditor, and not a valid leaf.
if (spec.leaf && spec.leaf !== "empty") {
let pos = nextPos++;
let field = getField(spec.leaf);
let updateLeaf = ref => { spec.leaf = ref; };
if (!field.isNewField) {
// Existing field.
existingRefs.push(field.getRowId());
existingPositions.push(pos);
} else if (colRefToFieldRef.has(field.colRef)) {
// Existing field that got removed and re-added.
let fieldRef = colRefToFieldRef.get(field.colRef);
existingRefs.push(fieldRef);
existingPositions.push(pos);
updateLeaf(fieldRef);
} else if (Number.isNaN(field.colRef)) {
// We need to add a new column AND field.
addColNum++;
addedCallbacks.push(updateLeaf);
addedPositions.push(pos);
} else {
// We need to add a field for an existing column.
hiddenColRefs.push(field.colRef);
hiddenCallbacks.push(updateLeaf);
hiddenPositions.push(pos);
}
}
if (spec.children) {
spec.children.map(processBox);
}
}
processBox(layoutSpec);
// Combine data for item which require both new columns and new fields and only new fields,
// with items which require new columns first.
let callbacks = addedCallbacks.concat(hiddenCallbacks);
let positions = addedPositions.concat(hiddenPositions);
let addActions = gutil.arrayRepeat(addColNum, addColAction);
docData.startBundlingActions('Updating record layout.', action => {
return [tableId, '_grist_Views_section', '_grist_Views_section_field'].includes(action[1]);
});
return Promise.try(() => {
return addColNum > 0 ? docModel.dataTables[tableId].sendTableActions(addActions) : [];
})
.then(results => {
let colRefs = results.map(r => r.colRef).concat(hiddenColRefs);
const addFieldNum = colRefs.length;
// Add fields for newly added columns and previously hidden columns.
return addFieldNum > 0 ?
docModel.viewFields.sendTableAction(["BulkAddRecord", gutil.arrayRepeat(addFieldNum, null), {
parentId: gutil.arrayRepeat(addFieldNum, this.viewSection.getRowId()),
colRef: colRefs,
parentPos: positions
}]) : [];
})
.each((fieldRef, i) => {
// Call the stored callback for each fieldRef, which each set the correct layoutSpec leaf
// to the newly obtained fieldRef.
callbacks[i](fieldRef);
})
.then(addedRefs => {
let actions = [];
// Records present before that were not present after editing must be removed.
let finishedRefs = new Set(existingRefs.concat(addedRefs));
let removed = origRefs.filter(fieldRef => !finishedRefs.has(fieldRef));
if (removed.length > 0) {
actions.push(["BulkRemoveRecord", "_grist_Views_section_field", removed]);
}
// Positions must be updated for fields which were not added/removed.
if (existingRefs.length > 0) {
actions.push(["BulkUpdateRecord", "_grist_Views_section_field", existingRefs, {
"parentPos": existingPositions
}]);
}
// And update the layoutSpecObj itself.
actions.push(["UpdateRecord", "_grist_Views_section", this.viewSection.getRowId(), {
"layoutSpec": JSON.stringify(layoutSpec)
}]);
return docData.sendActions(actions);
})
.finally(() => docData.stopBundlingActions());
};
/**
* Builds the Layout dom for a single record.
*/
RecordLayout.prototype.buildLayoutDom = function(row, optCreateEditor) {
const createEditor = Boolean(optCreateEditor && !this.layoutEditor.peek());
const layout = Layout.Layout.create(this.layoutSpec(), (fieldRowId) =>
dom('div.g_record_layout_leaf.flexhbox.flexauto',
this.buildFieldDom(this.getField(fieldRowId), row),
(createEditor ?
kd.maybe(this.layoutEditor, editor => editor.buildLeafDom()) :
null
)
)
);
const sub = this.layoutSpec.subscribe((spec) => { layout.buildLayout(spec); });
if (createEditor) {
this.layoutEditor(RecordLayoutEditor.create(this, layout));
}
return dom('div.g_record_detail.flexauto',
dom.autoDispose(layout),
dom.autoDispose(sub),
createEditor ? dom.onDispose(() => {
this.layoutEditor.peek().dispose();
this.layoutEditor(null);
}) : null,
dom('div.detail_row_num', kd.text(() => (row._index() + 1))),
dom('div.g_record_detail_inner', layout.rootElem)
);
};
/**
* Returns the viewField row model for the field that the given DOM element belongs to.
*/
RecordLayout.prototype.getContainingField = function(elem, optContainer) {
return this.getField(Layout.Layout.getContainingBox(elem, optContainer).leafId());
};
/**
* Returns the RowModel for the record that the given DOM element belongs to.
*/
RecordLayout.prototype.getContainingRow = function(elem, optContainer) {
var itemElem = dom.findAncestor(elem, optContainer, '.g_record_detail');
return ko.utils.domData.get(itemElem, 'itemModel');
};
module.exports = RecordLayout;

View File

@ -0,0 +1,150 @@
var _ = require('underscore');
var BackboneEvents = require('backbone').Events;
var dispose = require('app/client/lib/dispose');
var commands = require('./commands');
var LayoutEditor = require('./LayoutEditor');
const {basicButton, cssButton, primaryButton} = require('app/client/ui2018/buttons');
const {icon} = require('app/client/ui2018/icons');
const {menu, menuDivider, menuItem} = require('app/client/ui2018/menus');
const {testId} = require('app/client/ui2018/cssVars');
const {dom, Observable, styled} = require('grainjs');
//----------------------------------------------------------------------
/**
* An extension of LayoutEditor which includes commands and the option for a callback function.
*
* Used by RecordLayout.js
*
* @param {layoutSpec} observable - An observable evaluating to the original layoutSpec of the layout.
* @param {optResizeCallback} Function - An optional function to be called after every resize during
* layout editing.
*/
function RecordLayoutEditor(recordLayout, layout, optResizeCallback) {
this.recordLayout = recordLayout;
this.layout = layout;
this.layoutEditor = this.autoDispose(LayoutEditor.LayoutEditor.create(layout));
this._hiddenColumns = this.autoDispose(Observable.create(null, this.getHiddenColumns()));
this.listenTo(layout, 'layoutChanged', function() {
this._hiddenColumns.set(this.getHiddenColumns());
});
if (optResizeCallback) {
this.listenTo(layout, 'layoutChanged', optResizeCallback);
this.listenTo(layout, 'layoutResized', optResizeCallback);
}
// Command group implementing the commands available while editing the layout.
this.autoDispose(commands.createGroup(RecordLayoutEditor.editLayoutCommands, this, true));
}
dispose.makeDisposable(RecordLayoutEditor);
_.extend(RecordLayoutEditor.prototype, BackboneEvents);
/**
* Commands active while editing the record layout.
*/
RecordLayoutEditor.editLayoutCommands = {
accept: function() {
this.recordLayout.onEditLayoutSave(this.layout.getLayoutSpec());
},
cancel: function() {
this.layout.buildLayout(this.recordLayout.layoutSpec());
this.recordLayout.onEditLayoutCancel();
},
};
/**
* Returns the list of columns that are not included in the current layout.
*/
RecordLayoutEditor.prototype.getHiddenColumns = function() {
var included = new Set(this.layout.getAllLeafIds().map(function(leafId) {
var f = this.recordLayout.getField(leafId);
return f.isNewField ? f.colRef : f.colRef.peek();
}, this));
return this.recordLayout.viewSection.table().columns().all().filter(function(col) {
return !included.has(col.getRowId()) && !col.isHiddenCol();
});
};
RecordLayoutEditor.prototype._addField = function(leafId) {
var newBox = this.layout.buildLayoutBox({ leaf: leafId });
var rows = this.layout.rootBox().childBoxes.peek();
if (rows.length >= 1 && _.last(rows).isLeaf()) {
// Add a new child to the last row.
_.last(rows).addChild(newBox, true);
} else {
// Add a new row.
this.layout.rootBox().addChild(newBox, true);
}
};
RecordLayoutEditor.prototype.buildEditorDom = function() {
const addNewField = () => { this._addField(':New_Field:'); };
const showField = (col) => {
// Use setTimeout, since showing a field synchronously removes it from the list, which would
// prevent the menu from closing if we don't let the event to run its course.
setTimeout(() => this._addField(col.getRowId() + ':' + col.label()), 0);
};
return cssControls(
basicButton('Add Field', cssCollapseIcon('Collapse'),
menu((ctl) => [
menuItem(() => addNewField(), 'Create New Field'),
dom.maybe((use) => use(this._hiddenColumns).length > 0,
() => menuDivider()),
dom.forEach(this._hiddenColumns, (col) =>
menuItem(() => showField(col), `Show field ${col.label()}`)
),
testId('edit-layout-add-menu'),
]),
),
dom('div.flexauto', {style: 'margin-left: 8px'}),
this.buildFinishButtons(),
testId('edit-layout-controls'),
);
};
RecordLayoutEditor.prototype.buildFinishButtons = function() {
return [
primaryButton('Save Layout',
dom.on('click', () => commands.allCommands.accept.run()),
),
basicButton('Cancel',
dom.on('click', () => commands.allCommands.cancel.run()),
{style: 'margin-left: 8px'},
),
];
}
RecordLayoutEditor.prototype.buildLeafDom = function() {
return dom('div.layout_grabbable.g_record_layout_editing',
dom('div.g_record_delete_field.glyphicon.glyphicon-remove',
dom.on('mousedown', (ev) => ev.stopPropagation()),
dom.on('click', (ev, elem) => {
ev.preventDefault();
ev.stopPropagation();
this.layoutEditor.removeContainingBox(elem);
})
)
);
};
const cssControls = styled('div', `
display: flex;
align-items: flex-start;
& > .${cssButton.className} {
white-space: nowrap;
overflow: hidden;
}
`);
const cssCollapseIcon = styled(icon, `
margin: -3px -2px -2px 2px;
`);
module.exports = RecordLayoutEditor;

View File

@ -0,0 +1,226 @@
var _ = require('underscore');
var ko = require('knockout');
var Promise = require('bluebird');
var koArray = require('../lib/koArray');
var dispose = require('../lib/dispose');
var tableUtil = require('../lib/tableUtil');
var gutil = require('app/common/gutil');
const {colors, testId} = require('app/client/ui2018/cssVars');
const {cssFieldEntry, cssFieldLabel} = require('app/client/ui/VisibleFieldsConfig');
const {dom, fromKo, styled} = require('grainjs');
const {icon} = require('app/client/ui2018/icons');
const {menu, menuItem, menuText} = require('app/client/ui2018/menus');
/**
* Builder for the reference display multiselect.
*/
function RefSelect(fieldConfigTab) {
this.docModel = fieldConfigTab.gristDoc.docModel;
this.origColumn = fieldConfigTab.origColumn;
this.colId = fieldConfigTab.colId;
this.isForeignRefCol = fieldConfigTab.isForeignRefCol;
// Computed for the current fieldBuilder's field, if it exists.
this.fieldObs = this.autoDispose(ko.computed(() => {
let builder = fieldConfigTab.fieldBuilder();
return builder ? builder.field : null;
}));
// List of valid cols in the currently referenced table.
this._validCols = this.autoDispose(ko.computed(() => {
var refTable = this.origColumn.refTable();
if (refTable) {
return refTable.columns().all().filter(col => !col.isHiddenCol() &&
!gutil.startsWith(col.type(), 'Ref:'));
}
return [];
}));
// Returns the array of columns added to the multiselect. Used as a helper to create a synced KoArray.
var _addedObs = this.autoDispose(ko.computed(() => {
return this.isForeignRefCol() && this.fieldObs() ?
this._getReferencedCols().map(c => ({ label: c.label(), value: c.colId() })) : [];
}));
// KoArray of columns displaying data from the referenced table in the current section.
this._added = this.autoDispose(koArray.syncedKoArray(_addedObs));
// Set of added colIds.
this._addedSet = this.autoDispose(ko.computed(() => new Set(this._added.all().map(item => item.value))));
}
dispose.makeDisposable(RefSelect);
/**
* Builds the multiselect dom to select columns to added to the table to show data from the
* referenced table.
*/
RefSelect.prototype.buildDom = function() {
return cssFieldList(
testId('ref-select'),
dom.forEach(fromKo(this._added), (col) =>
cssFieldEntry(
cssFieldLabel(dom.text(col.label)),
cssRemoveIcon('Remove',
dom.on('click', () => this._removeFormulaField(col)),
testId('ref-select-remove'),
),
testId('ref-select-item'),
)
),
cssAddLink(cssAddIcon('Plus'), 'Add Column',
menu(() => [
...this._validCols.peek()
.filter((col) => !this._addedSet.peek().has(col.colId.peek()))
.map((col) =>
menuItem(() => this._addFormulaField({label: col.label(), value: col.colId()}),
col.label.peek())
),
cssEmptyMenuText("No columns to add"),
testId('ref-select-menu'),
]),
testId('ref-select-add'),
),
);
};
const cssFieldList = styled('div', `
display: flex;
flex-direction: column;
width: 100%;
& > .${cssFieldEntry.className} {
margin: 2px 0;
}
`);
const cssEmptyMenuText = styled(menuText, `
font-size: inherit;
&:not(:first-child) {
display: none;
}
`);
const cssAddLink = styled('div', `
display: flex;
cursor: pointer;
color: ${colors.lightGreen};
--icon-color: ${colors.lightGreen};
&:not(:first-child) {
margin-top: 8px;
}
&:hover, &:focus, &:active {
color: ${colors.darkGreen};
--icon-color: ${colors.darkGreen};
}
`);
const cssAddIcon = styled(icon, `
margin-right: 4px;
`);
const cssRemoveIcon = styled(icon, `
display: none;
cursor: pointer;
flex: none;
margin-left: 8px;
.${cssFieldEntry.className}:hover & {
display: block;
}
`);
/**
* Adds the column item to the multiselect. If the visibleCol is 'id', sets the visibleCol.
* Otherwise, adds a field which refers to the column to the table. If a column with the
* necessary formula exists, only adds a field to this section, otherwise adds the necessary
* column and field.
*/
RefSelect.prototype._addFormulaField = function(item) {
var field = this.fieldObs();
var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData;
// Check if column already exists in the table
var cols = this.origColumn.table().columns().all();
var colMatch = cols.find(c => c.formula() === `$${this.colId()}.${item.value}` && !c.isHiddenCol());
// Get field position, so that the new field is inserted just after the current field.
var fields = field.viewSection().viewFields();
var index = fields.all()
.sort((a, b) => a.parentPos() > b.parentPos() ? a : b)
.findIndex(f => f.getRowId() === field.getRowId());
var pos = tableUtil.fieldInsertPositions(fields, index + 1)[0];
var colAction;
if (colMatch) {
// If column exists, use it.
colAction = Promise.resolve({ colRef: colMatch.getRowId(), colId: colMatch.colId() });
} else {
// If column doesn't exist, add it (without fields).
colAction = tableData.sendTableAction(['AddHiddenColumn', `${this.colId()}_${item.value}`, {
type: 'Any',
isFormula: true,
formula: `$${this.colId()}.${item.value}`,
_position: pos
}]);
}
return colAction.then(colInfo => {
// Add field to the current section.
var fieldInfo = {
colRef: colInfo.colRef,
parentId: field.viewSection().getRowId(),
parentPos: pos
};
return this.docModel.viewFields.sendTableAction(['AddRecord', null, fieldInfo]);
});
};
/**
* Removes the column item from the multiselect. If the item is the visibleCol, clears to show
* row id. Otherwise, removes all fields which refer to the column from the table.
*/
RefSelect.prototype._removeFormulaField = function(item) {
var tableData = this.docModel.dataTables[this.origColumn.table().tableId()].tableData;
// Iterate through all display fields in the current section.
this._getReferrerFields(item.value).forEach(refField => {
var sectionId = this.fieldObs().viewSection().getRowId();
if (_.any(refField.column().viewFields().all(), field => field.parentId() !== sectionId)) {
// The col has fields in other sections, remove only the fields in this section.
this.docModel.viewFields.sendTableAction(['RemoveRecord', refField.getRowId()]);
} else {
// The col is only displayed in this section, remove the column.
tableData.sendTableAction(['RemoveColumn', refField.column().colId()]);
}
});
};
/**
* Returns a list of fields in the current section whose formulas refer to 'colId' in the table this
* reference column refers to.
*/
RefSelect.prototype._getReferrerFields = function(colId) {
var re = new RegExp("^\\$" + this.colId() + "\\." + colId + "$");
return this.fieldObs().viewSection().viewFields().all()
.filter(field => re.exec(field.column().formula()));
};
/**
* Returns a non-repeating list of columns in the referenced table referred to by fields in
* the current section.
*/
RefSelect.prototype._getReferencedCols = function() {
var matchesSet = this._getFormulaMatchSet();
return this._validCols().filter(c => matchesSet.has(c.colId()));
};
/**
* Helper function for getReferencedCols. Iterates through fields in
* the current section, returning a set of colIds which those fields' formulas refer to.
*/
RefSelect.prototype._getFormulaMatchSet = function() {
var fields = this.fieldObs().viewSection().viewFields().all();
var re = new RegExp("^\\$" + this.colId() + "\\.(\\w+)$");
return new Set(fields.map(field => {
var found = re.exec(field.column().formula());
return found ? found[1] : null;
}));
};
module.exports = RefSelect;

View File

@ -0,0 +1,73 @@
.searchbar-box.grist-navbar-pfx.part-toolbar-group__item {
display: flex;
width: 15rem;
padding: 0;
color: grey;
}
.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:focus-within {
box-shadow: 0 0 3px 2px var(--grist-color-cursor);
color: black;
}
.searchbar-box.grist-navbar-pfx.part-toolbar-group__item:hover {
/* undo the effect of hover in .part-toolbar-group__item */
background-color: var(--color-navbar-btn-bg);
}
.searchbar-button.grist-navbar-pfx.part-toolbar-group__item {
padding: 0 3px;
}
.searchbar-icon {
flex: none;
font-size: 1.2rem;
color: grey;
margin: 0 2px 0 4px;
top: 2px;
line-height: inherit;
}
.searchbar-icon-indicator {
animation: searchbar_flip 1s ease-in-out infinite;
}
.searchbar-input {
display: block;
border: none;
outline: none;
background-color: transparent;
height: 22px;
min-width: 0;
}
.searchbar-buttons {
flex: none;
align-self: center;
margin: 0 2px 0 0 !important;
}
.searchbar-buttons > .kf_button {
height: 1.6rem;
padding: 0.3rem 0.6rem;
}
.searchbar-buttons > .disabled {
opacity: 0.5;
}
@keyframes searchbar_flip {
0% { transform: scaleX(1); }
50% { transform: scaleX(-1); }
100% { transform: scaleX(1); }
}
/* applies to the cursor element, added and quickly removed to trigger a highlight animation */
.selected_cursor {
transition: background-color 500ms linear;
}
.search-match {
transition: none;
background-color: rgba(0, 255, 0, 0.4);
}

View File

@ -0,0 +1,375 @@
// tslint:disable:no-console
// TODO: This file should be removed once the old search UI is phased out.
import {createGroup} from 'app/client/components/commands';
import {GristDoc} from 'app/client/components/GristDoc';
import * as dom from 'app/client/lib/dom';
import * as kd from 'app/client/lib/koDom';
import * as kf from 'app/client/lib/koForm';
import {delay} from 'app/common/delay';
import {waitObs} from 'app/common/gutil';
import {TableData} from 'app/common/TableData';
import {BaseFormatter, createFormatter} from 'app/common/ValueFormatter';
import * as ko from 'knockout';
import debounce = require('lodash/debounce');
/**
* Creates a search box for the toolbar. Returns a value suitable for NavBar.makeToolbarGroup().
*/
export function makeSearchToolbarGroup(gristDoc: GristDoc) {
const searcher = new Searcher(gristDoc);
let input: HTMLInputElement;
// Active normally.
const commandGroup = createGroup({
find: () => { input.focus(); },
findNext: () => { searcher.findNext(); }, // tslint:disable-line:no-floating-promises TODO
findPrev: () => { searcher.findPrev(); }, // tslint:disable-line:no-floating-promises TODO
}, null, true);
// Return an array of one item (for a toolbar group of a single item). The item is an array of
// arguments that populate the div for this single toolbar group item.
return [[
kd.toggleClass('searchbar-box', true),
dom('span.searchbar-icon.glyphicon.glyphicon-search',
kd.toggleClass('searchbar-icon-indicator', searcher.isRunning)
),
input = dom('input.searchbar-input',
{placeholder: 'Search'},
(elem: HTMLInputElement) => bindChangeOrDelay(elem, value => searcher.findFirst(value), 100),
dom.testId('SearchBar_input'),
dom.autoDispose(commandGroup),
commandGroup.attach(),
dom.on('focus', () => { input.select(); }),
// Using a keyboard handler directly because command groups are hard to get to work (because
// the searchbox is created so early that the actions like accept/cancel get overridden).
dom.on('keydown', (e: KeyboardEvent) => {
switch (e.keyCode) {
case 13: searcher.findNext(); break; // tslint:disable-line:no-floating-promises TODO
case 27: input.blur(); break;
}
})
),
kf.buttonGroup(
kd.toggleClass('searchbar-buttons', true),
kf.button(() => searcher.findPrev(), '\u2329', kd.toggleClass('disabled', searcher.noMatch)),
kf.button(() => searcher.findNext(), '\u232A', kd.toggleClass('disabled', searcher.noMatch)),
),
]];
}
// Calls the given callback on 'change' event and within delayMs of any 'input' event.
// TODO: This duplicates part of functionality of koForm.textInput(), so the two could be unified.
function bindChangeOrDelay(input: HTMLInputElement, cb: (value: string) => void, delayMs: number) {
const debounced = debounce((e: Event) => cb(input.value), delayMs);
dom.on(input, 'input', debounced);
dom.on(input, 'change', (e: Event) => { debounced(e); debounced.flush(); });
}
interface SearchPosition {
tabIndex: number;
sectionIndex: number;
rowIndex: number;
fieldIndex: number;
}
class Stepper<T> {
public array: ReadonlyArray<T> = [];
public index: number = 0;
public inRange() {
return this.index >= 0 && this.index < this.array.length;
}
// Doing await at every step adds a ton of overhead; we can optimize by returning and waiting on
// Promises only when needed.
public next(step: number, nextArrayFunc: () => Promise<void>|void): Promise<void>|void {
this.index += step;
if (!this.inRange()) {
// If index reached the end of the array, take a step at a higher level to get a new array.
// For efficiency, only wait asynchronously if the callback returned a promise.
const p = nextArrayFunc();
if (p) {
return p.then(() => this.setStart(step));
} else {
this.setStart(step);
}
}
}
public setStart(step: number) {
this.index = step > 0 ? 0 : this.array.length - 1;
}
public get value(): T { return this.array[this.index]; }
}
class Searcher {
public isRunning = ko.observable(false);
public noMatch = ko.observable(true);
private _searchRegexp: RegExp;
private _tabStepper = new Stepper<any>();
private _sectionStepper = new Stepper<any>();
private _sectionTableData: TableData;
private _rowStepper = new Stepper<number>();
private _fieldStepper = new Stepper<any>();
private _fieldFormatters: BaseFormatter[];
private _startPosition: SearchPosition;
private _tabsSwitched: number = 0;
constructor(private _gristDoc: GristDoc) {}
public findFirst(value: string) {
if (!value) { this.noMatch(true); return; }
this._searchRegexp = makeRegexp(value);
const tabs: any[] = this._gristDoc.docModel.allTabs.peek();
this._tabStepper.array = tabs;
this._tabStepper.index = tabs.findIndex(t => t.viewRef() === this._gristDoc.activeViewId.get());
if (this._tabStepper.index < 0) { this.noMatch(true); return; }
const view = this._tabStepper.value.view.peek();
const sections: any[] = view.viewSections().peek();
this._sectionStepper.array = sections;
this._sectionStepper.index = sections.findIndex(s => s.getRowId() === view.activeSectionId());
if (this._sectionStepper.index < 0) { this.noMatch(true); return; }
this._initNewSectionShown();
// Find the current cursor position in the current section.
const viewInstance = this._sectionStepper.value.viewInstance.peek();
const pos = viewInstance.cursor.getCursorPos();
this._rowStepper.index = pos.rowIndex;
this._fieldStepper.index = pos.fieldIndex;
this._startPosition = this._getCurrentPosition();
return this._matchNext(1);
}
public async findNext() {
this._startPosition = this._getCurrentPosition();
await this._nextField(1);
return this._matchNext(1);
}
public async findPrev() {
this._startPosition = this._getCurrentPosition();
await this._nextField(-1);
return this._matchNext(-1);
}
private async _matchNext(step: number): Promise<void> {
const indicatorTimer = setTimeout(() => this.isRunning(true), 300);
try {
const searchRegexp = this._searchRegexp;
let count = 0;
let lastBreak = Date.now();
this._tabsSwitched = 0;
while (!this._matches() || ((await this._loadSection(step)) && !this._matches())) {
// To avoid hogging the CPU for too long, check time periodically, and if we've been running
// for long enough, take a brief break. We choose a 5ms break every 20ms; and only check
// time every 100 iterations, to avoid excessive overhead purely due to time checks.
if ((++count) % 100 === 0 && Date.now() >= lastBreak + 20) {
await delay(5);
lastBreak = Date.now();
// After other code had a chance to run, it's possible that we are now searching for
// something else, in which case abort this task.
if (this._searchRegexp !== searchRegexp) {
console.log("SearchBar: aborting search since a new one was started");
return;
}
}
const p = this._nextField(step);
if (p) { await p; }
// Detect when we get back to the start position; this is where we break on no match.
if (this._isCurrentPosition(this._startPosition)) {
console.log("SearchBar: reached start position without finding anything");
this.noMatch(true);
return;
}
// A fail-safe to prevent certain bugs from causing infinite loops; break also if we stan
// through tabs too many times.
// TODO: test it by disabling the check above.
if (this._tabsSwitched > this._tabStepper.array.length) {
console.log("SearchBar: aborting search due to too many tab switches");
this.noMatch(true);
return;
}
}
console.log("SearchBar: found a match at %s", JSON.stringify(this._getCurrentPosition()));
this.noMatch(false);
await this._highlight();
} finally {
clearTimeout(indicatorTimer);
this.isRunning(false);
}
}
private _getCurrentPosition(): SearchPosition {
// It's important to call _getCurrentPosition() in the visible tab, since other tabs will not
// use the currently visible version of the data (with the same sort and filter).
return {
tabIndex: this._tabStepper.index,
sectionIndex: this._sectionStepper.index,
rowIndex: this._rowStepper.index,
fieldIndex: this._fieldStepper.index,
};
}
private _isCurrentPosition(pos: SearchPosition): boolean {
return (
this._tabStepper.index === pos.tabIndex &&
this._sectionStepper.index === pos.sectionIndex &&
this._rowStepper.index === pos.rowIndex &&
this._fieldStepper.index === pos.fieldIndex
);
}
private _nextField(step: number): Promise<void>|void {
return this._fieldStepper.next(step, () => this._nextRow(step));
// console.log("nextField", this._fieldStepper.index);
}
private _nextRow(step: number) {
return this._rowStepper.next(step, () => this._nextSection(step));
// console.log("nextRow", this._rowStepper.index);
}
private async _nextSection(step: number) {
// Switching sections is rare enough that we don't worry about optimizing away `await` calls.
await this._sectionStepper.next(step, () => this._nextTab(step));
// console.log("nextSection", this._sectionStepper.index);
await this._initNewSectionAny();
}
// TODO There are issues with filtering. A section may have filters applied, and it may be
// auto-filtered (linked sections). If a tab is shown, we have the filtered list of rowIds; if
// the tab is not shown, it takes work to apply explicit filters. For linked sections, the
// sensible behavior seems to scan through ALL values, then once a match is found, set the
// cursor that determines the linking to include the matched row. And even that may not always
// be possible. So this is an open question.
private _initNewSectionCommon() {
const section = this._sectionStepper.value;
const tableModel = this._gristDoc.getTableModel(section.table.peek().tableId.peek());
this._sectionTableData = tableModel.tableData;
this._fieldStepper.array = section.viewFields().peek();
this._fieldFormatters = this._fieldStepper.array.map(
f => createFormatter(f.displayColModel().type(), f.widgetOptionsJson()));
return tableModel;
}
private _initNewSectionShown() {
this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek();
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek();
}
private async _initNewSectionAny() {
const tableModel = this._initNewSectionCommon();
const viewInstance = this._sectionStepper.value.viewInstance.peek();
if (viewInstance) {
this._rowStepper.array = viewInstance.sortedRows.getKoArray().peek();
} else {
// If we are searching through another tab (not currently loaded), we will NOT have a
// viewInstance, but we use the unsorted unfiltered row list, and if we find a match, the
// _loadSection() method will load the tab and we'll repeat the search with a viewInstance.
await tableModel.fetch();
this._rowStepper.array = this._sectionTableData.getRowIds();
}
}
private async _nextTab(step: number) {
await this._tabStepper.next(step, () => undefined);
this._tabsSwitched++;
// console.log("nextTab", this._tabStepper.index);
const view = this._tabStepper.value.view.peek();
this._sectionStepper.array = view.viewSections().peek();
}
private _matches(): boolean {
if (this._tabStepper.index < 0 || this._sectionStepper.index < 0 ||
this._rowStepper.index < 0 || this._fieldStepper.index < 0) {
console.warn("match outside");
return false;
}
const field = this._fieldStepper.value;
const formatter = this._fieldFormatters[this._fieldStepper.index];
const rowId = this._rowStepper.value;
const displayCol = field.displayColModel.peek();
const value = this._sectionTableData.getValue(rowId, displayCol.colId.peek());
// TODO: Note that formatting dates is now the bulk of the performance cost.
const text = formatter.format(value);
return this._searchRegexp.test(text);
}
private async _loadSection(step: number): Promise<boolean> {
// If we found a match in a section for which we don't have a valid BaseView instance, we need
// to load the BaseView and start searching the section again, since the match we found does
// not take into account sort or filters. So we switch to the right tab, wait for the
// viewInstance to be created, reset the section info, and return true to continue searching.
const section = this._sectionStepper.value;
if (!section.viewInstance.peek()) {
const view = this._tabStepper.value.view.peek();
await this._gristDoc.openDocPage(view.getRowId());
console.log("SearchBar: loading view %s section %s", view.getRowId(), section.getRowId());
const viewInstance: any = await waitObs(section.viewInstance);
await viewInstance.getLoadingDonePromise();
this._initNewSectionShown();
this._rowStepper.setStart(step);
this._fieldStepper.setStart(step);
console.log("SearchBar: loaded view %s section %s", view.getRowId(), section.getRowId());
return true;
}
return false;
}
// Highlights the cell at the current position.
private async _highlight() {
const view = this._tabStepper.value.view.peek();
await this._gristDoc.openDocPage(view.getRowId());
const section = this._sectionStepper.value;
view.activeSectionId(section.getRowId());
// We may need to wait for the BaseView instance to load.
const viewInstance = await waitObs<any>(section.viewInstance);
await viewInstance.getLoadingDonePromise();
viewInstance.setCursorPos({
rowIndex: this._rowStepper.index,
fieldIndex: this._fieldStepper.index,
});
// Highlight the selected cursor, after giving it a chance to update. We find the cursor in
// this ad-hoc way rather than use observables, to avoid the overhead of *every* cell
// depending on an additional observable.
await delay(0);
const cursor = viewInstance.viewPane.querySelector('.selected_cursor');
if (cursor) {
cursor.classList.add('search-match');
setTimeout(() => cursor.classList.remove('search-match'), 20);
}
}
}
function makeRegexp(value: string) {
// From https://stackoverflow.com/a/3561711/328565
const escaped = value.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
return new RegExp(escaped, 'i');
}

View File

@ -0,0 +1,278 @@
/**
* Selector takes care of attaching callbacks to the relevant mouse events on the given view.
* Selection and dragging/dropping consists of 3 phases: mouse down -> mouse move -> mouse up
* The Selector class is purposefully lightweight because different views might have
* different select/drag/drop behavior. Most of the work is done in the callbacks
* provided to the Selector class.
*
* Usage:
Selectors are instantiated with a view.
@param{view}: The view containing the selectable/draggable elements
* Views must also supply the Selector class with mousedown/mousemove/mouseup callbacks and
* the associated element's that listen for the mouse events.
* through registerMouseHandlers.
*/
/* globals document */
var ko = require('knockout');
var _ = require('underscore');
var dispose = require('../lib/dispose');
var gutil = require('app/common/gutil');
var ROW = 'row';
var COL = 'col';
var CELL = 'cell';
var NONE = '';
var SELECT = 'select';
var DRAG = 'drag';
exports.ROW = ROW;
exports.COL = COL;
exports.CELL = CELL;
exports.NONE = NONE;
/**
* @param {Object} view
* @param {Object} opt
* @param {function} opt.isDisabled - Is this selector disabled? Allows caller to specify
* conditions for temporarily disabling capturing of mouse events.
*/
function Selector(view, opt) {
this.view = view;
// TODO: There should be a better way to ensure that select/drag doesnt happen when clicking
// on these things. Also, these classes should not be in the generic Selector class.
// TODO: get rid of the Selector class entirely and make this a Cell/GridSelector class specifically
// for GridView(and its derived views).
this.exemptClasses = [
'glyphicon-pencil',
'ui-resizable-handle',
'dropdown-toggle',
];
opt = opt || {};
this.isDisabled = opt.isDisabled || _.constant(false);
}
/**
* Register mouse callbacks to various sources.
* @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions
* for selecting and dragging, along with with the source string name and target element
* string name to which the mouse events must listen on.
* @param {string} handlerName - string name of the kind of element that the mouse callbacks
* are acting on.
* handlerName is used to deduce what kind of element is triggering the mouse callbacks
* The alternative is to look at triggering DOM element's css classes which is more hacky.
*/
Selector.prototype.registerMouseHandlers = function(callbacks, handlerName) {
this.setCallbackDefaults(callbacks);
var self = this;
this.view.onEvent(callbacks.mousedown.source, 'mousedown', callbacks.mousedown.elemName,
function(elem, event) {
if (self.isExemptMouseTarget(event) || event.button !== 0 || self.isDisabled()) {
return true; // Do nothing if the mouse event if exempt or not a left click
}
if (!self.isSelected(elem, handlerName) && !callbacks.disableSelect()) {
self.applyCallbacks(SELECT, callbacks, elem, event);
} else if (!callbacks.disableDrag()) {
self.applyCallbacks(DRAG, callbacks, elem, event);
}
});
};
Selector.prototype.isExemptMouseTarget = function(event) {
var cl = event.target.classList;
return _.some(this.exemptClasses, cl.contains.bind(cl));
};
Selector.prototype.setCallbackDefaults = function(callbacks) {
_.defaults(callbacks, {'mousedown': {}, 'mousemove': {}, 'mouseup': {},
'disableDrag': _.constant(false), 'disableSelect': _.constant(false)}
);
_.defaults(callbacks.mousedown, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': null});
_.defaults(callbacks.mousemove, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': document});
_.defaults(callbacks.mouseup, {'select': _.noop, 'drag': _.noop, 'elemName': null,
'source': document});
};
/**
* Applies the drag or select callback for mousedown and then registers
* the appropriate mousemove and mouseup callbacks. We only register mousemove/mouseup
* after seeing a mousedown event so that we don't have to constantly listen for
* mousemove/mouseup.
* @param {String} dragOrSelect - string that is either 'drag' or 'select' which denotes
* which mouse methods to apply on mouse events.
* @param {Object} callbacks - an object containing mousedown/mouseup/mouseup functions
* for selecting and dragging, along with with the source string name and target element
* string name to which the mouse events must listen on.
*/
Selector.prototype.applyCallbacks = function(dragOrSelect, callbacks, mouseDownElem, mouseDownEvent) {
console.assert(dragOrSelect === DRAG || dragOrSelect === SELECT);
var self = this;
callbacks.mousedown[dragOrSelect].call(this.view, mouseDownElem, mouseDownEvent);
this.view.onEvent(callbacks.mousemove.source, 'mousemove', function(elem, event) {
callbacks.mousemove[dragOrSelect].call(self.view, elem, event);
});
this.view.onEvent(callbacks.mouseup.source, 'mouseup', function(elem, event) {
callbacks.mouseup[dragOrSelect].call(self.view, elem, event);
self.view.clearEvent(callbacks.mousemove.source, 'mousemove');
self.view.clearEvent(callbacks.mouseup.source, 'mouseup');
if (dragOrSelect === DRAG) self.currentDragType(NONE);
});
};
// ===========================================================================
// CELL SELECTOR
function CellSelector(view, opt) {
Selector.call(this, view, opt);
// row or col.start denotes the anchor/initial index of the select range.
// start is not necessarily smaller than end.
// IE: clicking on col 10 and dragging until the mouse is on col 5 will yield: start = 10, end = 5
this.row = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'),
dropIndex: ko.observable(-1),
};
this.col = {
start: ko.observable(0),
end: ko.observable(0),
linePos: ko.observable('0px'),
dropIndex: ko.observable(-1),
};
this.currentSelectType = ko.observable(NONE);
this.currentDragType = ko.observable(NONE);
this.autoDispose(this.view.cursor.rowIndex.subscribeInit(function(rowIndex) {
this.setToCursor();
}, this));
this.autoDispose(this.view.cursor.fieldIndex.subscribeInit(function(colIndex) {
this.setToCursor();
}, this));
}
dispose.makeDisposable(CellSelector);
_.extend(CellSelector.prototype, Selector.prototype);
CellSelector.prototype.setToCursor = function(elemType) {
// Must check that the view contains cursor.rowIndex/cursor.fieldIndex
// in case it has changed.
if (this.view.cursor.rowIndex) {
this.row.start(this.view.cursor.rowIndex());
this.row.end(this.view.cursor.rowIndex());
}
if (this.view.cursor.fieldIndex) {
this.col.start(this.view.cursor.fieldIndex());
this.col.end(this.view.cursor.fieldIndex());
}
this.currentSelectType(elemType || NONE);
};
CellSelector.prototype.containsCell = function(rowIndex, colIndex) {
return this.containsCol(colIndex) && this.containsRow(rowIndex);
};
CellSelector.prototype.containsRow = function(rowIndex) {
return gutil.between(rowIndex, this.row.start(), this.row.end());
};
CellSelector.prototype.containsCol = function(colIndex) {
return gutil.between(colIndex, this.col.start(), this.col.end());
};
CellSelector.prototype.isSelected = function(elem, handlerName) {
if (handlerName !== this.currentSelectType()) return false;
// TODO: this only works with view: GridView.
// But it seems like we only ever use selectors with gridview anyway
let row = this.view.domToRowModel(elem, handlerName);
let col = this.view.domToColModel(elem, handlerName);
switch (handlerName) {
case ROW:
return this.containsRow(row._index());
case COL:
return this.containsCol(col._index());
case CELL:
return this.containsCell(row._index(), col._index());
default:
console.error('Given element is not a row, cell or column');
return false;
}
};
CellSelector.prototype.isRowSelected = function(rowIndex) {
return this.isCurrentSelectType(COL) || this.containsRow(rowIndex);
};
CellSelector.prototype.isColSelected = function(colIndex) {
return this.isCurrentSelectType(ROW) || this.containsCol(colIndex);
};
CellSelector.prototype.isCellSelected = function(rowIndex, colIndex) {
return this.isColSelected(colIndex) && this.isRowSelected(rowIndex);
};
CellSelector.prototype.onlyCellSelected = function(rowIndex, colIndex) {
return (this.row.start() === rowIndex && this.row.end() === rowIndex) &&
(this.col.start() === colIndex && this.col.end() === colIndex);
};
CellSelector.prototype.isCurrentSelectType = function(elemType) {
return this._isCurrentType(this.currentSelectType(), elemType);
};
CellSelector.prototype.isCurrentDragType = function(elemType) {
return this._isCurrentType(this.currentDragType(), elemType);
};
CellSelector.prototype._isCurrentType = function(currentType, elemType) {
console.assert([ROW, COL, CELL, NONE].indexOf(elemType) !== -1);
return currentType === elemType;
};
CellSelector.prototype.colLower = function() {
return Math.min(this.col.start(), this.col.end());
};
CellSelector.prototype.colUpper = function() {
return Math.max(this.col.start(), this.col.end());
};
CellSelector.prototype.rowLower = function() {
return Math.min(this.row.start(), this.row.end());
};
CellSelector.prototype.rowUpper = function() {
return Math.max(this.row.start(), this.row.end());
};
CellSelector.prototype.colCount = function() {
return this.colUpper() - this.colLower() + 1;
};
CellSelector.prototype.rowCount = function() {
return this.rowUpper() - this.rowLower() + 1;
};
CellSelector.prototype.selectArea = function(rowStartIdx,colStartIdx,rowEndIdx,colEndIdx) {
this.row.start(rowStartIdx);
this.col.start(colStartIdx);
this.row.end(rowEndIdx);
this.col.end(colEndIdx);
// Only select the area if it's not a single cell
if (this.colCount() > 1 || this.rowCount() > 1) {
this.currentSelectType(CELL);
}
};
exports.CellSelector = CellSelector;

View File

@ -0,0 +1,129 @@
const ko = require('knockout');
const dispose = require('../lib/dispose');
const dom = require('../lib/dom');
const kd = require('../lib/koDom');
const kf = require('../lib/koForm');
const koArray = require('../lib/koArray');
const multiselect = require('../lib/multiselect');
const modelUtil = require('../models/modelUtil');
const gutil = require('app/common/gutil');
/**
* Maintains the part of side-pane configuration responsible for summary tables. In particular, it
* allows the user to see and change group-by columns.
* @param {GristDoc} options.gristDoc: the GristDoc instance.
* @param {observable} options.section: the observable for the ViewSection RowModel being configured.
*/
function SummaryConfig(options) {
this.gristDoc = options.gristDoc;
this.section = options.section;
// Whether or not this is a summary section at all.
this.isSummarySection = this.autoDispose(ko.computed(() =>
Boolean(this.section().table().summarySourceTable())));
// Observable for the RowModel for the source table for this summary table.
this._summarySourceTable = this.autoDispose(ko.computed(() =>
this.section().table().summarySource()
));
// Observable for the array of colRefs for the source group-by columns. It may be saved to sync
// to the server, or reverted.
this._groupByCols = this.autoDispose(modelUtil.customComputed({
read: () => (
this.section().viewFields().all().map(f => f.column().summarySourceCol())
.concat(
// If there are hidden group-by columns, list those as well.
this.section().hiddenColumns().map(col => col.summarySourceCol())
)
.filter(scol => scol)
),
save: colRefs => this.gristDoc.docData.sendAction(
["UpdateSummaryViewSection", this.section().getRowId(), colRefs]
)
}));
// Observable for the same set of colRefs as in this._groupByCols, for faster lookups.
this._groupBySourceColSet = this.autoDispose(ko.computed(() => new Set(this._groupByCols())));
// KoArray for the RowModels for the source group-by columns.
this._groupByItems = this.autoDispose(koArray.syncedKoArray(this._groupByCols,
colRef => this.gristDoc.docModel.columns.getRowModel(colRef)));
}
dispose.makeDisposable(SummaryConfig);
/**
* Helper that implements the auto-complete search of columns available for group-by.
* Calls response() with a list of {label, value} objects, where 'label' is the colId, and 'value'
* is the rowId.
*/
SummaryConfig.prototype._groupBySearch = function(request, response) {
response(
this._summarySourceTable().columns().peek().filter(c => {
return gutil.startsWith(c.label().toLowerCase(), request.term.toLowerCase()) &&
!this._groupBySourceColSet().has(c.getRowId()) && !c.isHiddenCol();
})
.map(c => ({label: c.label(), value: c.getRowId()}))
);
};
/**
* Saves this summary table as an independent table.
*/
SummaryConfig.prototype._saveAsTable = function() {
return this.gristDoc.docData.sendAction(
["DetachSummaryViewSection", this.section().getRowId()]);
};
/**
* Build the DOM for summary table config.
*/
SummaryConfig.prototype.buildSummaryConfigDom = function() {
return dom('div',
dom.testId('SummaryConfig'),
dom('div.multiselect-hint', 'Select columns to group by.'),
multiselect(this._groupBySearch.bind(this), this._groupByItems, col => {
return dom('div.multiselect-label', kd.text(col.label));
}, {
// Shows up when no group-by columns are selected
hint: "Showing totals.",
add: item => this._groupByCols.modifyAssign(colRefs =>
colRefs.push(item.value)),
remove: col => this._groupByCols.modifyAssign(colRefs =>
gutil.arrayRemove(colRefs, col.getRowId())),
reorder: (col, nextCol) => this._groupByCols.modifyAssign(colRefs => {
gutil.arrayRemove(colRefs, col.getRowId());
gutil.arrayInsertBefore(colRefs, col.getRowId(), nextCol ? nextCol.getRowId() : null);
}),
}),
kf.row(
2, kf.buttonGroup(
kf.button(() => this._groupByCols.revert(),
kd.toggleClass('disabled', this._groupByCols.isSaved),
'Cancel'
),
kf.button(() => this._groupByCols.save(),
kd.toggleClass('disabled', this._groupByCols.isSaved),
'Apply'
)
),
1, kf.buttonGroup(
kf.button(() => this._saveAsTable(),
{ title: 'Save summary as a separate table' },
'Detach'
)
)
)
);
};
module.exports = SummaryConfig;

View File

@ -0,0 +1,196 @@
/**
* This module contains various logic for converting columns between types. It is used from
* TypeTransform.js.
*/
// tslint:disable:no-console
import {DocModel} from 'app/client/models/DocModel';
import {ColumnRec} from 'app/client/models/entities/ColumnRec';
import * as UserType from 'app/client/widgets/UserType';
import * as gristTypes from 'app/common/gristTypes';
import * as gutil from 'app/common/gutil';
import {TableData} from 'app/common/TableData';
export interface ColInfo {
type: string;
isFormula: boolean;
formula: string;
visibleCol: number;
widgetOptions?: string;
}
/**
* Returns the suggested full type for `column` given a desired pure type to convert it to.
* Specifically, a pure type of "DateTime" returns a full type of "DateTime:{timezone}", and "Ref"
* returns a full type of "Ref:{TableId}". A `type` that's already complete is returned unchanged.
*/
export function addColTypeSuffix(type: string, column: ColumnRec, docModel: DocModel) {
switch (type) {
case "Ref": {
const refTableId = getRefTableIdFromData(docModel, column) || column.table().primaryTableId();
return 'Ref:' + refTableId;
}
case "DateTime":
return 'DateTime:' + docModel.docInfo.getRowModel(1).timezone();
default:
return type;
}
}
/**
* Looks through the data of the given column to find the first value of the form
* [R, <tableId>, <rowId>] (a Reference value returned from a formula), and returns the tableId
* from that.
*/
function getRefTableIdFromData(docModel: DocModel, column: ColumnRec): string|null {
const tableData = docModel.docData.getTable(column.table().tableId());
const columnData = tableData && tableData.getColValues(column.colId());
if (columnData) {
for (const value of columnData) {
if (gristTypes.isObject(value) && value[0] === 'R') {
return value[1];
} else if (typeof value === 'string') {
// If it looks like a formatted Ref value (e.g. "Table1[123]"), and the tableId is valid,
// use it. (This helps if a Ref-returning formula column got converted to Text first.)
const match = value.match(/^(\w+)\[\d+\]/);
if (match && docModel.docData.getTable(match[1])) {
return match[1];
}
}
}
}
return null;
}
// Given info about the original column, and the type of the new one, returns a promise for the
// ColInfo to use for the transform column. Note that isFormula will be set to true, and formula
// will be set to the expression to compute the new values from the old ones.
// @param toTypeMaybeFull: Type to convert the column to, either full ('Ref:Foo') or pure ('Ref').
export async function prepTransformColInfo(docModel: DocModel, origCol: ColumnRec, origDisplayCol: ColumnRec,
toTypeMaybeFull: string): Promise<ColInfo> {
const toType = gristTypes.extractTypeFromColType(toTypeMaybeFull);
const tableData: TableData = docModel.docData.getTable(origCol.table().tableId())!;
let widgetOptions: any = null;
const colInfo: ColInfo = {
type: addColTypeSuffix(toTypeMaybeFull, origCol, docModel),
isFormula: true,
visibleCol: 0,
formula: "", // Will be filled in at the end.
};
switch (toType) {
case 'Choice': {
// Set suggested choices. Limit to 100, since too many choices is more likely to cause
// trouble than desired behavior. For many choices, recommend using a Ref to helper table.
const columnData = tableData.getDistinctValues(origCol.colId(), 100);
if (columnData) {
columnData.delete("");
widgetOptions = {choices: Array.from(columnData, String)};
}
break;
}
case 'Ref': {
// Set suggested destination table and visible column.
// Null if toTypeMaybeFull is a pure type (e.g. converting to Ref before a table is chosen).
const optTableId = gutil.removePrefix(toTypeMaybeFull, "Ref:")!;
// Finds a reference suggestion column and sets it as the current reference value.
const columnData = tableData.getDistinctValues(origDisplayCol.colId(), 100);
if (!columnData) { break; }
columnData.delete(gristTypes.getDefaultForType(origCol.type()));
// 'findColFromValues' function requires an array since it sends the values to the sandbox.
const matches: number[] = await docModel.docData.findColFromValues(Array.from(columnData), 2, optTableId);
const suggestedColRef = matches.find(match => match !== origCol.getRowId());
if (!suggestedColRef) { break; }
const suggestedCol = docModel.columns.getRowModel(suggestedColRef);
const suggestedTableId = suggestedCol.table().tableId();
if (optTableId && suggestedTableId !== optTableId) {
console.warn("Inappropriate column received from findColFromValues");
break;
}
colInfo.type = `Ref:${suggestedTableId}`;
colInfo.visibleCol = suggestedColRef;
break;
}
}
const newOptions = UserType.mergeOptions(widgetOptions || {}, colInfo.type);
if (widgetOptions) {
colInfo.widgetOptions = JSON.stringify(widgetOptions);
}
colInfo.formula = getDefaultFormula(docModel, origCol, colInfo.type, colInfo.visibleCol, newOptions);
return colInfo;
}
// Given the transformCol, calls (if needed) a user action to update its displayCol.
export async function setDisplayFormula(
docModel: DocModel, transformCol: ColumnRec, visibleCol?: number
): Promise<void> {
const vcolRef = (visibleCol == null) ? transformCol.visibleCol() : visibleCol;
if (isReferenceCol(transformCol)) {
const vcol = getVisibleColName(docModel, vcolRef);
const tcol = transformCol.colId();
const displayFormula = (vcolRef === 0 ? '' : `$${tcol}.${vcol}`);
return transformCol.saveDisplayFormula(displayFormula);
}
}
// Given the original column and info about the new column, returns the formula to use for the
// transform column to do the transformation.
export function getDefaultFormula(
docModel: DocModel, origCol: ColumnRec, newType: string,
newVisibleCol: number, newWidgetOptions: any): string {
const colId = origCol.colId();
const oldVisibleColName = isReferenceCol(origCol) ?
getVisibleColName(docModel, origCol.visibleCol()) : undefined;
const origValFormula = oldVisibleColName ?
// The `str()` below converts AltText to plain text.
`$${colId}.${oldVisibleColName} if ISREF($${colId}) else str($${colId})` :
`$${colId}`;
const toTypePure: string = gristTypes.extractTypeFromColType(newType);
// The args are used to construct the call to grist.TYPE.typeConvert(value, [params]).
// Optional parameters depend on the type; see sandbox/grist/usertypes.py
const args: string[] = [origValFormula];
switch (toTypePure) {
case 'Ref': {
const table = gutil.removePrefix(newType, "Ref:");
args.push(table || 'None');
const visibleColName = getVisibleColName(docModel, newVisibleCol);
if (visibleColName) {
args.push(q(visibleColName));
}
break;
}
case 'Date': {
args.push(q(newWidgetOptions.dateFormat));
break;
}
case 'DateTime': {
const timezone = gutil.removePrefix(newType, "DateTime:") || '';
const format = newWidgetOptions.dateFormat + ' ' + newWidgetOptions.timeFormat;
args.push(q(format), q(timezone));
break;
}
}
return `grist.${gristTypes.getGristType(toTypePure)}.typeConvert(${args.join(', ')})`;
}
function q(value: string): string {
return "'" + value.replace(/'/g, "\\'") + "'";
}
// Returns the name of the visibleCol given its rowId.
function getVisibleColName(docModel: DocModel, visibleColRef: number): string|undefined {
return visibleColRef ? docModel.columns.getRowModel(visibleColRef).colId() : undefined;
}
// Returns whether the given column model is of type Ref.
function isReferenceCol(colModel: ColumnRec) {
return gristTypes.extractTypeFromColType(colModel.type()) === 'Ref';
}

View File

@ -0,0 +1,140 @@
/**
* TypeTransform extends ColumnTransform, creating the transform dom prompt that is shown when the
* user changes the type of a data column. The purpose is to aid the user in converting data to the new
* type by allowing a formula to be applied prior to conversion. It also allows for program-generated formulas
* to be pre-entered for certain transforms (to Reference / Date) which the user can modify via dropdown menus.
*/
import * as AceEditor from 'app/client/components/AceEditor';
import {ColumnTransform} from 'app/client/components/ColumnTransform';
import {GristDoc} from 'app/client/components/GristDoc';
import * as TypeConversion from 'app/client/components/TypeConversion';
import {reportError} from 'app/client/models/errors';
import * as modelUtil from 'app/client/models/modelUtil';
import {cssButtonRow} from 'app/client/ui/RightPanel';
import {basicButton, primaryButton} from 'app/client/ui2018/buttons';
import {testId} from 'app/client/ui2018/cssVars';
import {FieldBuilder} from 'app/client/widgets/FieldBuilder';
import {NewAbstractWidget} from 'app/client/widgets/NewAbstractWidget';
import {ColValues} from 'app/common/DocActions';
import {Computed, dom, fromKo, Observable} from 'grainjs';
import isEmpty = require('lodash/isEmpty');
import pickBy = require('lodash/pickBy');
// To simplify diff (avoid rearranging methods to satisfy private/public order).
// tslint:disable:member-ordering
/**
* Creates an instance of TypeTransform for a single field. Extends ColumnTransform.
*/
export class TypeTransform extends ColumnTransform {
private reviseTypeChange = Observable.create(this, false);
private transformWidget: Computed<NewAbstractWidget|null>;
constructor(gristDoc: GristDoc, fieldBuilder: FieldBuilder) {
super(gristDoc, fieldBuilder);
// The display widget of the new transform column. Used to build the transform config menu.
// Only set while transforming.
this.transformWidget = Computed.create(this, fromKo(fieldBuilder.widgetImpl), (use, widget) => {
return use(this.origColumn.isTransforming) ? widget : null;
});
}
/**
* Build the transform menu for a type transform
*/
public buildDom() {
// An observable to disable all buttons before the dom get removed.
const disableButtons = Observable.create(null, false);
this.reviseTypeChange.set(false);
this.editor = this.autoDispose(AceEditor.create({ observable: this.transformColumn.formula }));
return dom('div',
testId('type-transform-top'),
dom.maybe(this.transformWidget, transformWidget => transformWidget.buildTransformConfigDom()),
dom.maybe(this.reviseTypeChange, () =>
dom('div.transform_editor', this.buildEditorDom(),
testId("type-transform-formula")
)
),
cssButtonRow(
basicButton(dom.on('click', () => { this.cancel(); disableButtons.set(true); }),
'Cancel', testId("type-transform-cancel"),
dom.cls('disabled', disableButtons)
),
dom.domComputed(this.reviseTypeChange, revising => {
if (revising) {
return basicButton(dom.on('click', () => this.editor.writeObservable()),
'Preview', testId("type-transform-update"),
dom.cls('disabled', (use) => use(disableButtons) || use(this.formulaUpToDate)),
{ title: 'Update formula (Shift+Enter)' }
);
} else {
return basicButton(dom.on('click', () => { this.reviseTypeChange.set(true); }),
'Revise', testId("type-transform-revise"),
dom.cls('disabled', disableButtons)
);
}
}),
primaryButton(dom.on('click', () => { this.execute().catch(reportError); disableButtons.set(true); }),
'Apply', testId("type-transform-apply"),
dom.cls('disabled', disableButtons)
)
)
);
}
protected async resetToDefaultFormula() {
if (!this.isExecuting()) {
const toType = this.transformColumn.type.peek();
const formula = TypeConversion.getDefaultFormula(this.gristDoc.docModel, this.origColumn,
toType, this.field.visibleColRef(), this.field.widgetOptionsJson());
await modelUtil.setSaveValue(this.transformColumn.formula, formula);
}
}
/**
* Overrides parent method to initialize the transform column with guesses as to the particular
* type and column options.
* @param {String} toType: A pure or complete type for the transformed column.
*/
protected async addTransformColumn(toType: string) {
const docModel = this.gristDoc.docModel;
const colInfo = await TypeConversion.prepTransformColInfo(docModel, this.origColumn, this.origDisplayCol, toType);
const newColInfo = await this._tableData.sendTableAction(['AddColumn', 'gristHelper_Transform', colInfo]);
const tcol = docModel.columns.getRowModel(newColInfo.colRef);
await TypeConversion.setDisplayFormula(docModel, tcol);
return newColInfo.colRef;
}
/**
* Overrides parent method to subscribe to changes to the transform column.
*/
protected postAddTransformColumn() {
// When a user-initiated change is saved to type or widgetOptions, update the formula.
this.autoDispose(this.transformColumn.type.subscribe(this.resetToDefaultFormula, this, "save"));
this.autoDispose(this.transformColumn.visibleCol.subscribe(this.resetToDefaultFormula, this, "save"));
this.autoDispose(this.field.widgetOptionsJson.subscribe(this.resetToDefaultFormula, this, "save"));
}
/**
* When a type is changed, again guess appropriate column options.
*/
public async setType(toType: string) {
const docModel = this.gristDoc.docModel;
const colInfo = await TypeConversion.prepTransformColInfo(docModel, this.origColumn, this.origDisplayCol, toType);
// Only update those values which changed, and only if needed.
const tcol = this.transformColumn;
const changedInfo = pickBy(colInfo, (val, key) =>
(val !== tcol[key as keyof TypeConversion.ColInfo].peek()));
return Promise.all([
isEmpty(changedInfo) ? undefined : tcol.updateColValues(changedInfo as ColValues),
TypeConversion.setDisplayFormula(docModel, tcol, changedInfo.visibleCol)
]);
}
public finalize() {
return this.execute();
}
}

View File

@ -0,0 +1,148 @@
import {CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import * as dispose from 'app/client/lib/dispose';
import {ActionGroup} from 'app/common/ActionGroup';
import {PromiseChain} from 'app/common/gutil';
import {fromKo, Observable} from 'grainjs';
import * as ko from 'knockout';
export interface ActionGroupWithCursorPos extends ActionGroup {
cursorPos?: CursorPos;
}
// Provides observables indicating disabled state for undo/redo.
export interface IUndoState {
isUndoDisabled: Observable<boolean>;
isRedoDisabled: Observable<boolean>;
}
/**
* Maintains the stack of actions which can be undone and redone, and maintains the
* position in this stack. Undo and redo actions are generated and sent to the server here.
*/
export class UndoStack extends dispose.Disposable {
public undoDisabledObs: ko.Observable<boolean>;
public redoDisabledObs: ko.Observable<boolean>;
private _gristDoc: GristDoc;
private _stack: ActionGroupWithCursorPos[];
private _pointer: number;
private _linkMap: {[actionNum: number]: ActionGroup};
// Chain of promises which send undo actions to the server. This delays the execution of the
// next action until the current one has been received and moved the pointer index.
private _undoChain = new PromiseChain<void>();
public create(log: ActionGroup[], options: {gristDoc: GristDoc}) {
this._gristDoc = options.gristDoc;
// TODO: _stack and _linkMap grow without bound within a single session.
// The top of the stack is stack.length - 1. The pointer points above the most
// recently applied (not undone) action.
this._stack = [];
this._pointer = 0;
// Map leading from actionNums to the action groups which link to them.
this._linkMap = {};
// Observables for when there is nothing to undo/redo.
this.undoDisabledObs = ko.observable(true);
this.redoDisabledObs = ko.observable(true);
// Set the history nav interface in the DocPageModel to properly enable/disabled undo/redo.
if (this._gristDoc.docPageModel) {
this._gristDoc.docPageModel.undoState.set({
isUndoDisabled: fromKo(this.undoDisabledObs),
isRedoDisabled: fromKo(this.redoDisabledObs)
});
}
// Initialize the stack from the log of recent actions from the server.
log.forEach(ag => { this.pushAction(ag); });
}
/**
* Should only be given own actions. Pays attention to actionNum, otherId, linkId, and
* uses those to adjust undo index.
*/
public pushAction(ag: ActionGroup): void {
if (!ag.fromSelf) {
return;
}
const otherIndex = ag.otherId ?
this._stack.findIndex(a => a.actionNum === ag.otherId) : -1;
if (ag.linkId) {
// Link action. Add the action to the linkMap, but not to any stacks.
this._linkMap[ag.linkId] = ag;
} else if (otherIndex > -1) {
// Undo/redo action from the current session.
this._pointer = ag.isUndo ? otherIndex : otherIndex + 1;
} else {
// Either a normal action from the current session, or an undo/redo which
// applies to a non-recent action. Bury all undone actions.
if (!this.redoDisabledObs()) {
this._stack.splice(this._pointer);
}
// Reset pointer and add to the stack (if not an undo action).
if (!ag.otherId) {
this._stack.push(ag);
}
this._pointer = this._stack.length;
}
this.undoDisabledObs(this._pointer <= 0);
this.redoDisabledObs(this._pointer >= this._stack.length);
}
// Send an undo action. This should be called when the user presses 'undo'.
public sendUndoAction(): Promise<void> {
return this._undoChain.add(() => this._sendAction(true));
}
// Send a redo action. This should be called when the user presses 'redo'.
public sendRedoAction(): Promise<void> {
return this._undoChain.add(() => this._sendAction(false));
}
private async _sendAction(isUndo: boolean): Promise<void> {
// Pick the action group to undo or redo.
const ag = this._stack[isUndo ? this._pointer - 1 : this._pointer];
if (!ag) { return; }
try {
// Get all actions in the bundle that starts at the current index. Typically, an array with a
// single action group is returned.
const actionGroups = this._findActionBundle(ag);
// When we undo/redo, jump to the place where this action occurred, to bring the user to the
// context where the change was originally made. We jump first immediately to feel more
// responsive, then again when the action is done. The second jump matters more for most
// changes, but the first is the important one when Undoing an AddRecord.
this._gristDoc.moveToCursorPos(ag.cursorPos, ag);
await this._gristDoc.docComm.applyUserActionsById(
actionGroups.map(a => a.actionNum),
actionGroups.map(a => a.actionHash),
isUndo,
{ otherId: ag.actionNum });
this._gristDoc.moveToCursorPos(ag.cursorPos, ag);
} catch (err) {
err.message = `Failed to apply ${isUndo ? 'undo' : 'redo'} action: ${err.message}`;
throw err;
}
}
/**
* Find all actionGroups in the bundle that starts with the given action group.
*/
private _findActionBundle(ag: ActionGroup) {
const prevNums = new Set();
const actionGroups = [];
// Follow references through the linkMap adding items to the array bundle.
while (ag && !prevNums.has(ag.actionNum)) {
// Checking that actions are only accessed once prevents an infinite circular loop.
actionGroups.push(ag);
prevNums.add(ag.actionNum);
ag = this._linkMap[ag.actionNum];
}
return actionGroups;
}
}

View File

@ -0,0 +1,29 @@
.validation {
background-color: rgba(255, 255, 255, .5);
margin: 4px 8px 4px 1px;
padding: 3px 0;
}
.validation_title {
position: relative;
width: 100%;
padding: 4px 8px;
margin-bottom: 10px;
border-bottom: 1px solid #E6E6E6;
}
.validation_trash {
cursor: pointer;
color: #AAA;
font-size: 1.1rem;
}
.validation_trash:hover {
color: black;
}
.validation_formula {
width: 90%;
margin: 5px auto;
border: 1px solid #DDD;
}

View File

@ -0,0 +1,97 @@
/* global $ */
var ko = require('knockout');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var AceEditor = require('./AceEditor');
/**
* Document level configuration settings.
* @param {Object} options.gristDoc A reference to the GristDoc object
* @param {Function} docName A knockout observable containing a String
*/
function ValidationPanel(options) {
this.gristDoc = options.gristDoc;
this.validationsTable = this.gristDoc.docModel.validations;
this.validations = this.autoDispose(this.validationsTable.createAllRowsModel('id'));
this.docTables = this.autoDispose(
this.gristDoc.docModel.tables.createAllRowsModel('tableId'));
this.tableChoices = this.autoDispose(this.docTables.map(function(table) {
return { label: table.tableId, value: table.id() };
}));
}
dispose.makeDisposable(ValidationPanel);
ValidationPanel.prototype.onAddRule = function() {
this.validationsTable.sendTableAction(["AddRecord", null, {
tableRef: this.docTables.at(0).id(),
name: "Rule " + (this.validations.peekLength + 1),
formula: ""
}])
.then(function() {
$('.validation_formula').last().find("input").focus();
});
};
ValidationPanel.prototype.onDeleteRule = function(rowId) {
this.validationsTable.sendTableAction(["RemoveRecord", rowId]);
};
ValidationPanel.prototype.buildDom = function() {
return [
kf.row(
1, kf.label('Validations'),
1, kf.buttonGroup(
kf.button(this.onAddRule.bind(this), 'Add Rule', dom.testId("Validation_addRule"))
)
),
dom('div',
dom.testId("Validation_rules"),
kd.foreach(this.validations, validation => {
var editor = AceEditor.create({ observable: validation.formula });
var editorUpToDate = ko.observable(true);
return dom('div.validation',
dom.autoDispose(editor),
dom('div.validation_title.flexhbox',
dom('div.validation_name', kf.editableLabel(validation.name)),
dom('div.flexitem'),
dom('div.validation_trash.glyphicon.glyphicon-remove',
dom.on('click', this.onDeleteRule.bind(this, validation.id()))
)
),
kf.row(
1, dom('div.glyphicon.glyphicon-tag.config_icon'),
8, kf.label('Table'),
9, kf.select(validation.tableRef, this.tableChoices)
),
dom('div.kf_elem.validation_formula', editor.buildDom(aceObj => {
editor.attachSaveCommand();
aceObj.on('change', () => {
// Monitor whether the value mismatch is reflected by editorDiff
if ((editor.getValue() === validation.formula()) !== editorUpToDate()) {
editorUpToDate(!editorUpToDate());
}
});
aceObj.removeAllListeners('blur');
})),
kf.row(
2, '',
1, kf.buttonGroup(
kf.button(() => editor.writeObservable(),
'Apply', { title: 'Update formula (Shift+Enter)' },
kd.toggleClass('disabled', editorUpToDate)
)
)
)
);
})
)
];
};
module.exports = ValidationPanel;

View File

@ -0,0 +1,38 @@
.view_config_draggable_field {
position: relative;
margin: .2rem .5rem;
padding: .2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.view_config_draggable_field:hover {
background-color: var(--color-list-item-hover);
}
.view_config_draggable_field > .drag_delete {
float: none;
position: absolute;
top: 0.3rem;
right: 0.2rem;
background-color: white;
padding: 0.2rem;
border-radius: 1rem;
margin: 0;
}
.view_config_draggable_field > .drag_delete:hover {
color: black;
}
.view_config_field_group.kf_collapser {
font-size: inherit;
font-weight: bold;
margin: 1rem .5rem;
}
.view_config_draggable_field > .kf_draggable_content {
display: inline;
}

View File

@ -0,0 +1,759 @@
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var kf = require('../lib/koForm');
var koArray = require('../lib/koArray');
var {showConfirmDialog} = require('./Confirm');
var SummaryConfig = require('./SummaryConfig');
var commands = require('./commands');
var {CustomSectionElement} = require('../lib/CustomSectionElement');
const {buildChartConfigDom} = require('./ChartView');
const {Computed, dom: grainjsDom, makeTestId, Observable, styled} = require('grainjs');
const {VisibleFieldsConfig} = require('app/client/ui/VisibleFieldsConfig');
const {addToSort, flipColDirection, parseSortColRefs} = require('app/client/lib/sortUtil');
const {reorderSortRefs, updatePositions} = require('app/client/lib/sortUtil');
const {cssIcon, cssRow} = require('app/client/ui/RightPanel');
const {basicButton, primaryButton} = require('app/client/ui2018/buttons');
const {colors} = require('app/client/ui2018/cssVars');
const {cssDragger} = require('app/client/ui2018/draggableList');
const {menu, menuItem, select} = require('app/client/ui2018/menus');
const isEqual = require('lodash/isEqual');
const {cssMenuItem} = require('popweasel');
const testId = makeTestId('test-vconfigtab-');
/**
* Helper class that combines one ViewSection's data for building dom.
*/
function ViewSectionData(section) {
this.section = section;
// A koArray reflecting the columns (RowModels) that are not present in the current view.
this.hiddenFields = this.autoDispose(koArray.syncedKoArray(section.hiddenColumns));
}
dispose.makeDisposable(ViewSectionData);
function ViewConfigTab(options) {
var self = this;
this.gristDoc = options.gristDoc;
this.viewModel = options.viewModel;
// viewModel may point to different views, but viewSectionData is a single koArray reflecting
// the sections of the current view.
this.viewSectionData = this.autoDispose(
koArray.syncedKoArray(this.viewModel.viewSections, function(section) {
return ViewSectionData.create(section);
})
.setAutoDisposeValues()
);
this.activeSectionData = this.autoDispose(ko.computed(function() {
return _.find(self.viewSectionData.all(), function(sectionData) {
return sectionData.section &&
sectionData.section.getRowId() === self.viewModel.activeSectionId();
}) || self.viewSectionData.at(0);
}));
this.isDetail = this.autoDispose(ko.computed(function() {
return ['detail','single'].includes(this.viewModel.activeSection().parentKey());
}, this));
this.isChart = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'chart';}, this));
this.isGrid = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'record';}, this));
this.isCustom = this.autoDispose(ko.computed(function() {
return this.viewModel.activeSection().parentKey() === 'custom';}, this));
this._summaryConfig = this.autoDispose(SummaryConfig.create({
gristDoc: this.gristDoc,
section: this.viewModel.activeSection
}));
if (!options.skipDomBuild) {
this.gristDoc.addOptionsTab(
'View', dom('span.glyphicon.glyphicon-credit-card'),
this.buildConfigDomObj(),
{ 'category': 'options', 'show': this.activeSectionData }
);
}
}
dispose.makeDisposable(ViewConfigTab);
function getLabelFunc(field) { return field ? field.label() : null; }
ViewConfigTab.prototype._buildSectionFieldsConfig = function() {
var self = this;
return kd.maybe(this.activeSectionData, function(sectionData) {
const visibleFieldsConfig = VisibleFieldsConfig.create(null, self.gristDoc, sectionData.section, false);
const [fieldsDraggable, hiddenFieldsDraggable] = visibleFieldsConfig.buildSectionFieldsConfigHelper({
visibleFields: { itemCreateFunc: getLabelFunc },
hiddenFields: {itemCreateFunc: getLabelFunc }
});
return dom('div',
dom.autoDispose(visibleFieldsConfig),
kf.collapsible(function(isCollapsed) {
return [
kf.collapserLabel(isCollapsed, 'Visible Fields', kd.toggleClass('view_config_field_group', true)),
dom.testId('ViewConfigTab_visibleFields'),
fieldsDraggable,
];
}, false),
kf.collapsible(function(isCollapsed) {
return [
kf.collapserLabel(isCollapsed, 'Hidden Fields', kd.toggleClass('view_config_field_group', true)),
dom.testId('ViewConfigTab_hiddenFields'),
hiddenFieldsDraggable,
];
}, false),
);
});
};
// Builds object with ViewConfigTab dom builder and settings for the sidepane.
ViewConfigTab.prototype.buildConfigDomObj = function() {
return [{
'buildDom': this._buildNameDom.bind(this),
'keywords': ['view', 'name', 'title']
}, {
'buildDom': this._buildSectionNameDom.bind(this),
'keywords': ['section', 'viewsection', 'name', 'title']
}, {
'buildDom': this._buildAdvancedSettingsDom.bind(this),
'keywords': ['table', 'demand', 'ondemand', 'big']
}, {
'header': true,
'label': 'Summarize',
'showObs': this._summaryConfig.isSummarySection,
'items': [{
'buildDom': () => this._summaryConfig.buildSummaryConfigDom(),
'keywords': ['section', 'summary', 'summarize', 'group', 'breakdown']
}]
}, {
'header': true,
'label': 'Sort',
'items': [{
'buildDom': this.buildSortDom.bind(this),
'keywords': ['section', 'sort', 'order']
}]
}, {
'header': true,
'label': 'Filter',
'items': [{
'buildDom': this._buildFilterDom.bind(this),
'keywords': ['section', 'filters']
}]
}, {
'header': true,
'label': 'Link Sections',
'items': [{
'buildDom': this._buildLinkDom.bind(this),
'keywords': ['section', 'view', 'linking', 'edit', 'autoscroll', 'autofilter']
}]
}, {
'header': true,
'label': 'Customize Detail View',
'showObs': this.isDetail,
'items': [{
'buildDom': this._buildDetailTypeDom.bind(this),
'keywords': ['section', 'detail']
}, {
'buildDom': this._buildThemeDom.bind(this),
'keywords': ['section', 'theme', 'appearance', 'detail']
}, {
'buildDom': this._buildLayoutDom.bind(this),
'keywords': ['section', 'layout', 'arrangement', 'rearrange']
}]
}, {
'header': true,
'label': 'Customize Grid View',
'showObs': this.isGrid,
'items': [{
'buildDom': this._buildGridStyleDom.bind(this),
'keywords': ['section', 'zebra', 'stripe', 'appearance', 'grid', 'gridlines', 'style', 'border']
}]
}, {
'header': true,
'label': 'Chart',
'showObs': this.isChart,
'items': [{
'buildDom': () => this._buildChartConfigDom()
}]
}, {
'header': true,
'label': 'Custom View',
'showObs': this.isCustom,
'items': this._buildCustomTypeItems(),
'keywords': ['section', 'custom']
}, {
'header': true,
'label': 'Column Display',
'items': [{
'buildDom': this._buildSectionFieldsConfig.bind(this),
'keywords': ['section', 'fields', 'hidden', 'hide', 'show', 'visible']
}]
}];
};
ViewConfigTab.prototype.buildSortDom = function() {
return grainjsDom.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
// Computed to indicate if sort has changed from saved.
const hasChanged = Computed.create(null, (use) =>
!isEqual(use(section.activeSortSpec), parseSortColRefs(use(section.sortColRefs))));
// Computed array of sortable columns.
const columns = Computed.create(null, (use) => {
// Columns is an observable holding an observable array - must call 'use' on it 2x.
const cols = use(use(use(section.table).columns));
return cols.filter(col => !use(col.isHiddenCol))
.map(col => ({
label: use(col.colId),
value: col.getRowId(),
icon: 'FieldColumn'
}));
});
// KoArray of sortRows used to create the draggableList.
const sortRows = koArray.syncedKoArray(section.activeSortSpec);
// Sort row create function for each sort row in the draggableList.
const rowCreateFn = sortRef =>
this._buildSortRow(sortRef, section.activeSortSpec.peek(), columns);
// Reorder function called when sort rows are reordered via dragging.
const reorder = (...args) => {
const spec = reorderSortRefs(section.activeSortSpec.peek(), ...args);
this._saveSort(spec);
};
return grainjsDom('div',
grainjsDom.autoDispose(hasChanged),
grainjsDom.autoDispose(columns),
grainjsDom.autoDispose(sortRows),
// Sort rows.
kf.draggableList(sortRows, rowCreateFn, {
reorder,
removeButton: false,
drag_indicator: cssDragger,
itemClass: cssDragRow.className
}),
// Add to sort btn & menu & fake sort row.
this._buildAddToSortBtn(columns),
// Update/save/reset buttons visible when the sort has changed.
cssRow(
grainjsDom.maybe(hasChanged, () => [
primaryButton('Save', {style: 'margin-right: 8px;'},
grainjsDom.on('click', () => { section.activeSortJson.save(); }),
testId('sort-save'),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
),
// Let's use same label (revert) as the similar button which appear in the view section.
// menu.
basicButton('Revert',
grainjsDom.on('click', () => { section.activeSortJson.revert(); }),
testId('sort-reset')
)
]),
cssFlex(),
grainjsDom.maybe(section.isSorted, () =>
basicButton('Update Data', {style: 'margin-left: 8px; white-space: nowrap;'},
grainjsDom.on('click', () => { updatePositions(this.gristDoc, section); }),
testId('sort-update'),
grainjsDom.show((use) => use(use(section.table).supportsManualSort)),
grainjsDom.boolAttr('disabled', this.gristDoc.isReadonly),
)
),
grainjsDom.show((use) => use(hasChanged) || use(section.isSorted))
),
testId('sort-menu')
);
});
};
// Builds a single row of the sort dom
// Takes the sortRef (signed colRef), current sortSpec and array of column select options to show
// in the column select dropdown.
ViewConfigTab.prototype._buildSortRow = function(sortRef, sortSpec, columns) {
// sortRef is a rowId of a column or its negative value (indicating descending order).
const colRef = Math.abs(sortRef);
// Computed to show the selected column at the sortSpec index and to update the
// sortSpec on write.
const col = Computed.create(null, () => colRef);
col.onWrite((newRef) => {
const idx = sortSpec.findIndex(_sortRef => _sortRef === sortRef);
const swapIdx = sortSpec.findIndex(_sortRef => Math.abs(_sortRef) === newRef);
// If the selected ref is already present, swap it with the old ref.
// Maintain sort order in each case for simplicity.
if (swapIdx > -1) { sortSpec.splice(swapIdx, 1, sortSpec[swapIdx] > 0 ? colRef : -colRef); }
if (colRef !== newRef) { sortSpec.splice(idx, 1, sortRef > 0 ? newRef : -newRef); }
this._saveSort(sortSpec);
});
return cssSortRow(
grainjsDom.autoDispose(col),
cssSortSelect(
select(col, columns)
),
cssSortIconPrimaryBtn('Sort',
grainjsDom.style('transform', sortRef < 0 ? 'none' : 'scaleY(-1)'),
grainjsDom.on('click', () => {
this._saveSort(flipColDirection(sortSpec, sortRef));
}),
testId('sort-order'),
testId(sortRef < 0 ? 'sort-order-desc' : 'sort-order-asc')
),
cssSortIconBtn('Remove',
grainjsDom.on('click', () => {
const _idx = sortSpec.findIndex(c => c === sortRef);
if (_idx !== -1) {
sortSpec.splice(_idx, 1);
this._saveSort(sortSpec);
}
}),
testId('sort-remove')
),
testId('sort-row')
);
};
// Build the button to open the menu to add a sort item to the sort dom.
// Takes the full array of sortable column select options.
ViewConfigTab.prototype._buildAddToSortBtn = function(columns) {
// Observable indicating whether the add new column row is visible.
const showAddNew = Observable.create(null, false);
return [
// Add column button.
cssRow(
grainjsDom.autoDispose(showAddNew),
cssTextBtn(
cssPlusIcon('Plus'), 'Add Column',
testId('sort-add')
),
grainjsDom.hide(showAddNew),
grainjsDom.on('click', () => { showAddNew.set(true); }),
),
// Fake add column row that appears only when the menu is open to select a new column
// to add to the sort. Immediately destroyed when menu is closed.
grainjsDom.maybe((use) => use(showAddNew) && use(columns), _columns => {
const col = Observable.create(null, 0);
const currentSection = this.activeSectionData().section;
const currentSortSpec = currentSection.activeSortSpec();
const specRowIds = new Set(currentSortSpec.map(_sortRef => Math.abs(_sortRef)));
// Function called when a column select value is clicked.
const onClick = (_col) => {
showAddNew.set(false); // Remove add row ASAP to prevent flickering
addToSort(currentSection.activeSortSpec, _col.value);
};
const menuCols = _columns
.filter(_col => !specRowIds.has(_col.value))
.map(_col =>
menuItem(() => onClick(_col),
cssMenuIcon(_col.icon),
_col.label,
testId('sort-add-menu-row')
)
);
return cssRow(cssSortRow(
dom.autoDispose(col),
cssSortSelect(
select(col, [], {defaultLabel: 'Add Column'}),
menu(() => [
menuCols,
grainjsDom.onDispose(() => { showAddNew.set(false); })
], {
// Trigger to make menu open immediately
trigger: [(elem, ctl) => {
ctl.open();
grainjsDom.onElem(elem, 'click', () => { ctl.close(); });
}],
stretchToSelector: `.${cssSortSelect.className}`
})
),
cssSortIconPrimaryBtn('Sort',
grainjsDom.style('transform', 'scaleY(-1)')
),
cssSortIconBtn('Remove')
));
})
];
};
ViewConfigTab.prototype._saveSort = function(sortSpec) {
this.activeSectionData().section.activeSortSpec(sortSpec);
};
ViewConfigTab.prototype._buildNameDom = function() {
return kf.row(
1, dom('div.glyphicon.glyphicon-tasks.config_icon'),
4, kf.label('View'),
13, kf.text(this.viewModel.name, {}, dom.testId('ViewManager_viewNameInput'))
);
};
ViewConfigTab.prototype._buildSectionNameDom = function() {
return kd.maybe(this.activeSectionData, function(sectionData) {
return kf.row(
1, dom('div.glyphicon.glyphicon-credit-card.config_icon'),
4, kf.label('Section'),
13, kf.text(sectionData.section.titleDef, {}, dom.testId('ViewConfigTab_sectionNameInput'))
);
});
};
ViewConfigTab.prototype._makeOnDemand = function(table) {
// After saving the changed setting, force the reload of the document.
const onConfirm = () => {
return table.onDemand.saveOnly(!table.onDemand.peek())
.then(() => {
return this.gristDoc.docComm.reloadDoc()
.catch((err) => {
// Ignore the expected error from the socket shutdown that we asked for.
if (!err.message.includes('GristWSConnection disposed')) {
throw err;
}
})
});
}
if (table.onDemand()) {
showConfirmDialog('Unmark table On-Demand?', 'Unmark On-Demand', onConfirm,
dom('div', 'If you unmark table ', dom('b', table), ' as On-Demand, ' +
'its data will be loaded into the calculation engine and will be available ' +
'for use in formulas. For a big table, this may greatly increase load times.',
dom('br'), 'Changing this setting will reload the document for all users.')
);
} else {
showConfirmDialog('Make table On-Demand?', 'Make On-Demand', onConfirm,
dom('div', 'If you make table ', dom('b', table), ' On-Demand, ' +
'its data will no longer be loaded into the calculation engine and will not be available ' +
'for use in formulas. It will remain available for viewing and editing.',
dom('br'), 'Changing this setting will reload the document for all users.')
);
}
};
ViewConfigTab.prototype._buildAdvancedSettingsDom = function() {
return kd.maybe(() => {
const s = this.activeSectionData();
return s && !s.section.table().summarySourceTable() ? s : null;
}, (sectionData) => {
const table = sectionData.section.table();
const isCollapsed = ko.observable(true);
return [
kf.collapserLabel(isCollapsed, 'Advanced settings', dom.testId('ViewConfig_advanced')),
kf.helpRow(kd.hide(isCollapsed),
'Big tables may be marked as "on-demand" to avoid loading them into the data engine.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')
),
kf.row(kd.hide(isCollapsed),
kf.label('Table ', dom('b', kd.text(table.tableId)), ':')
),
kf.row(kd.hide(isCollapsed),
kf.buttonGroup(kf.button(() => this._makeOnDemand(table),
kd.text(() => table.onDemand() ? 'Unmark On-Demand' : 'Make On-Demand'),
dom.testId('ViewConfig_onDemandBtn')
))
),
];
});
};
ViewConfigTab.prototype._buildDetailTypeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
if (this.isDetail()) {
return kf.row(
1, kf.label('Type'),
1, kf.buttonSelect(section.parentKey,
kf.optionButton('detail', 'List', dom.testId('ViewConfigTab_card')),
kf.optionButton('single', 'Single', dom.testId('ViewConfigTab_detail'))
)
);
}
});
};
ViewConfigTab.prototype._buildFilterDom = function() {
return kd.maybe(this.activeSectionData, sectionData => {
let section = sectionData.section;
return dom('div',
kf.row(
1, dom('div.glyphicon.glyphicon-filter.config_icon'),
4, kf.label('Filters'),
13, dom('div.kf_elem', kd.foreach(section.viewFields(), field => {
return dom('div.filter_list', kd.maybe(field.activeFilter, () => {
return dom('div.token',
dom('span.token-label', field.label()),
dom('span.close.glyphicon.glyphicon-remove',
dom.on('click', () => { field.activeFilter(''); })
)
);
}));
}))
),
grainjsDom.maybe(section.filterSpecChanged, () => {
return kf.prompt(
kf.liteButtonGroup(
kf.liteButton(() => section.saveFilters(),
dom('span.config_icon.left_icon.glyphicon.glyphicon-save'), 'Save',
dom.testId('ViewConfigTab_saveFilter'),
kd.toggleClass('disabled', () => this.gristDoc.isReadonlyKo()),
),
kf.liteButton(() => section.revertFilters(),
dom('span.config_icon.left_icon.glyphicon.glyphicon-refresh'), 'Reset',
dom.testId('ViewConfigTab_resetFilter')
)
)
);
})
);
});
};
ViewConfigTab.prototype._buildThemeDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
if (this.isDetail()) {
const theme = Computed.create(null, (use) => use(section.themeDef));
theme.onWrite(val => section.themeDef.setAndSave(val));
return cssRow(
dom.autoDispose(theme),
select(theme, [
{label: 'Form', value: 'form' },
{label: 'Compact', value: 'compact'},
{label: 'Blocks', value: 'blocks' },
]),
testId('detail-theme')
);
}
});
};
ViewConfigTab.prototype._buildGridStyleDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
var section = sectionData.section;
return dom('div',
kf.row(
15, kf.label('Horizontal Gridlines'),
2, kf.checkbox(section.optionsObj.prop('horizontalGridlines'),
dom.testId('ViewConfigTab_hGridButton'))
),
kf.row(
15, kf.label('Vertical Gridlines'),
2, kf.checkbox(section.optionsObj.prop('verticalGridlines'),
dom.testId('ViewConfigTab_vGridButton'))
),
kf.row(
15, kf.label('Zebra Stripes'),
2, kf.checkbox(section.optionsObj.prop('zebraStripes'),
dom.testId('ViewConfigTab_zebraStripeButton'))
),
dom.testId('ViewConfigTab_gridOptions')
);
});
};
ViewConfigTab.prototype._buildChartConfigDom = function() {
return grainjsDom.maybe(this.viewModel.activeSection, buildChartConfigDom);
};
ViewConfigTab.prototype._buildLayoutDom = function() {
return kd.maybe(this.activeSectionData, (sectionData) => {
if (this.isDetail()) {
const view = sectionData.section.viewInstance.peek();
const layoutEditorObs = ko.computed(() => view && view.recordLayout && view.recordLayout.layoutEditor());
return cssRow({style: 'margin-top: 16px;'},
kd.maybe(layoutEditorObs, (editor) => editor.buildFinishButtons()),
primaryButton('Edit Card Layout',
dom.autoDispose(layoutEditorObs),
dom.on('click', () => commands.allCommands.editLayout.run()),
grainjsDom.hide(layoutEditorObs),
testId('detail-edit-layout')
)
);
}
});
};
ViewConfigTab.prototype._buildLinkDom = function() {
var linkSpecChanged = ko.computed(() =>
!this.viewModel.viewSections().all().every(vs => vs.isActiveLinkSaved()));
return dom('div',
dom.autoDispose(linkSpecChanged),
kf.buttonGroup(kf.checkButton(this.viewModel.isLinking,
dom('span', 'Edit Links', dom.testId('viewConfigTab_link')))),
kd.maybe(this.activeSectionData, (sectionData) => {
const section = sectionData.section;
// This section option affects section linking: it tells a link-target section to show rows
// matching any of the rows in link-source section, not only the current cursor row.
const filterByAllShown = section.optionsObj.prop('filterByAllShown');
return kf.row(
15, kf.label('Filter by all shown'),
2, kf.checkbox(filterByAllShown, dom.testId('ViewConfigTab_filterByAll'))
);
}),
kd.maybe(linkSpecChanged, () =>
kf.prompt(
kf.liteButtonGroup(
kf.liteButton(() => {
commands.allCommands.saveLinks.run();
this.viewModel.isLinking(false);
}, dom('span.config_icon.left_icon.glyphicon.glyphicon-save'), 'Save'),
kf.liteButton(() => commands.allCommands.revertLinks.run(),
dom('span.config_icon.left_icon.glyphicon.glyphicon-refresh'), 'Reset'
)
)
)
)
);
};
/**
* Builds the three items for configuring a `Custom View`:
* 1) Mode picker: let user choose between 'url' and 'plugin' mode
* 2) Show if 'url' mode: let user enter the url
* 3) Show if 'plugin' mode: let user pick a plugin and a section from the list of available plugin.
*/
ViewConfigTab.prototype._buildCustomTypeItems = function() {
const docPluginManager = this.gristDoc.docPluginManager;
const activeSection = this.viewModel.activeSection;
// all available custom sections grouped by their plugin id
const customSections = _.groupBy(CustomSectionElement.getSections(docPluginManager.pluginsList), s => s.pluginId);
// all plugin ids which have custom sections
const allPlugins = Object.keys(customSections);
// the list of customSections of the selected plugin (computed)
const customSectionIds = ko.pureComputed(() => {
const sections = customSections[this.viewModel.activeSection().customDef.pluginId()] || [];
return sections.map(({sectionId}) => sectionId);
});
return [{
// 1)
buildDom: () => kd.scope(activeSection, ({customDef}) => kf.buttonSelect(customDef.mode,
kf.optionButton('url', 'URL', dom.testId('ViewConfigTab_customView_url')),
kf.optionButton('plugin', 'Plugin', dom.testId('ViewConfigTab_customView_plugin'))))
}, {
// 2)
showObs: () => activeSection().customDef.mode() === "url",
buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div',
kf.row(18, kf.text(customDef.url, {placeholder: "Full URL of webpage to show"}, dom.testId('ViewConfigTab_url'))),
kf.row(5, "Access", 13, dom(kf.select(customDef.access, ['none', 'read table', 'full']), dom.testId('ViewConfigTab_customView_access'))),
kf.helpRow('none: widget has no access to document.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')),
kf.helpRow('read table: widget can read the selected table.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem')),
kf.helpRow('full: widget can read, modify, and copy the document.',
kd.style('text-align', 'left'),
kd.style('margin-top', '1.5rem'))
)),
}, {
// 3)
showObs: () => activeSection().customDef.mode() === "plugin",
buildDom: () => kd.scope(activeSection, ({customDef}) => dom('div',
kf.row(5, "Plugin: ", 13, kf.text(customDef.pluginId, {}, {list: "list_plugin"}, dom.testId('ViewConfigTab_customView_pluginId'))),
kf.row(5, "Section: ", 13, kf.text(customDef.sectionId, {}, {list: "list_section"}, dom.testId('ViewConfigTab_customView_sectionId'))),
// For both `customPlugin` and `selectedSection` it is possible for the value not to be in the
// list of options. Combining <datalist> and <input> allows both to freely edit the value with
// keyboard and to select it from a list. Although the content of the list seems to be
// filtered by the current value, which could confuse user into thinking that there are no
// available options. I think it would be better to have the full list always, but it seems
// harder to accomplish and is left as a TODO.
dom('datalist#list_plugin', kd.foreach(koArray(allPlugins), value => dom('option', {value}))),
dom('datalist#list_section', kd.scope(customSectionIds, sections => kd.foreach(koArray(sections), (value) => dom('option', {value}))))
))
}];
};
const cssMenuIcon = styled(cssIcon, `
margin: 0 8px 0 0;
.${cssMenuItem.className}-sel > & {
background-color: ${colors.light};
}
`);
// Note that the width is set to 0 so that flex-shrink works properly with long text values.
const cssSortSelect = styled('div', `
flex: 1 1 0px;
margin: 0 6px 0 0;
min-width: 0;
`);
const cssSortIconBtn = styled(cssIcon, `
flex: none;
margin: 0 6px;
cursor: pointer;
background-color: ${colors.slate};
&:hover {
background-color: ${colors.dark};
}
`);
const cssSortIconPrimaryBtn = styled(cssSortIconBtn, `
background-color: ${colors.lightGreen};
&:hover {
background-color: ${colors.darkGreen};
}
`);
const cssTextBtn = styled('div', `
color: ${colors.lightGreen};
cursor: pointer;
height: 29px;
&:hover {
color: ${colors.darkGreen};
}
`);
const cssPlusIcon = styled(cssIcon, `
background-color: ${colors.lightGreen};
cursor: pointer;
margin: 0px 4px 3px 0;
.${cssTextBtn.className}:hover > & {
background-color: ${colors.darkGreen};
}
`);
const cssDragRow = styled('div', `
display: flex !important;
align-items: center;
margin: 0 16px 0px 0px;
& > .kf_draggable_content {
margin: 6px 0;
flex: 1 1 0px;
min-width: 0px;
}
`);
const cssSortRow = styled('div', `
display: flex;
align-items: center;
width: 100%;
`);
const cssFlex = styled('div', `
flex: 1 1 0;
`);
module.exports = ViewConfigTab;

View File

@ -0,0 +1,234 @@
.view_leaf {
position: relative;
flex: 1 1 0px;
}
.viewsection_title {
background-color: #e5e5e5;
color: black;
flex-shrink: 0;
align-items: baseline;
line-height: 2;
cursor: default;
}
.active_section > .viewsection_title {
background-color: #3e568e;
color: white;
}
.viewsection_content.newui > .viewsection_title {
height: 24px;
margin-left: -16px; /* to include drag handle that shows up on hover */
color: var(--grist-color-slate);
background-color: unset;
font-size: var(--grist-small-font-size);
font-weight: 500;
text-transform: uppercase;
line-height: inherit;
}
.active_section.newui > .viewsection_title {
background-color: unset;
color: var(--grist-color-slate);
}
.viewsection_titletext {
cursor: text;
overflow: hidden;
}
.viewsection_content {
background-color: #ffffff;
margin: 4px;
overflow: visible;
box-shadow: 0px 1px 3px 0px rgba(0,0,0,0.8);
}
.viewsection_content.newui {
margin: 12px;
box-shadow: none;
}
.viewsection_title_colorbox {
width: 16px;
height: 16px;
border-radius: 8px;
margin: auto .5rem auto 0;
box-shadow: inset 0px 0px 5px rgba(0,0,0,0.5);
}
.viewsection_drag_indicator {
visibility: hidden;
margin: auto;
padding: 0px 2px;
top: 0px;
}
/* TODO should be switched to use new icon */
.viewsection_drag_indicator.newui {
width: 16px;
height: 16px;
margin: 0;
padding: 0px;
}
.viewsection_title:hover .viewsection_drag_indicator.layout_grabbable {
visibility: visible;
z-index: 100; /* ensure it's above the resizer line, since it's hard to grab otherwise */
}
.viewsection_btn {
display: inline-block;
padding: 0px 4px;
}
.viewsection_settings {
display: inline-block;
vertical-align: middle;
}
.viewsection_status_icons {
color: #999999;
}
.viewsection_status_icons > .status_icon:hover {
color: black;
}
.viewsection_status_icons.active_section {
color: #AEC6FE;
}
.viewsection_status_icons.active_section > .status_icon:hover {
color: white;
}
.viewsection_truncated {
position: absolute;
right: 8px;
bottom: 8px;
background-color: red;
color: white;
z-index: 1;
}
.link_direction_icon {
display: inline-block;
position: relative;
vertical-align: top;
width: 1.9rem;
height: 1.2rem;
margin: .25rem -.1rem 0 -.2rem;
}
.viewsection_status_icons.active_section > .status_icon.unsaved_changes {
text-shadow: 0px 0px 5px #fff;
color: #FFFFFF;
}
.view_data_pane_container {
position: relative;
flex: auto;
}
.viewsection_content.newui > .view_data_pane_container {
border: 1px solid var(--grist-color-dark-grey);
}
@media not print {
.active_section.newui > .view_data_pane_container {
box-shadow: -2px 0 0 0px var(--grist-color-light-green);
border-left: 1px solid var(--grist-color-light-green);
}
.active_section.newui > .view_data_pane_container.viewsection_type_detail {
/* this color is a translucent version of grist-color-light-green */
box-shadow: -2px 0 0 0px var(--grist-color-inactive-cursor);
border-left: 1px solid var(--grist-color-inactive-cursor);
}
}
.disable_viewpane {
justify-content: center;
text-align: center;
position: absolute;
z-index: 1;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.1);
font-size: 12pt;
}
.status_icon.unsaved_changes {
text-shadow: 0px 0px 5px #8A8A8A;
color: #FFFFFF
}
.link_direction_icon.has_in_arrow {
margin-left: .3rem;
}
.link_direction_icon.has_out_arrow {
margin-right: .2rem;
}
.link_icon {
position: absolute;
font-size: 1.05rem;
left: .45rem;
}
.link_out_arrow {
position: absolute;
top: .5rem;
left: 1.3rem;
font-size: .65rem;
transform: scale(.8, 1);
}
.link_in_arrow {
position: absolute;
top: .05rem;
left: 0;
font-size: .65rem;
transform: scale(.8, 1);
}
.sort_icon {
display: inline-block;
position: relative;
vertical-align: top;
width: 1.2rem;
height: 1.2rem;
font-size: 1.0rem;
margin: .25rem .1rem 0 .3rem;
}
.filter_icon {
display: inline-block;
position: relative;
vertical-align: top;
width: 1.2rem;
height: 1.2rem;
font-size: 1.0rem;
margin: .25rem .1rem 0 .3rem;
}
.shaking {
animation: shake 0.4s ease;
transform: translate(0, 0);
}
@keyframes shake {
10%, 90% {
transform: translate(2px, 0);
}
30%, 70% {
transform: translate(-3px, 0);
}
50% {
transform: translate(3px, 0);
}
}

View File

@ -0,0 +1,270 @@
import * as BaseView from 'app/client/components/BaseView';
import {ChartView} from 'app/client/components/ChartView';
import * as commands from 'app/client/components/commands';
import {CustomView} from 'app/client/components/CustomView';
import * as DetailView from 'app/client/components/DetailView';
import * as GridView from 'app/client/components/GridView';
import {GristDoc} from 'app/client/components/GristDoc';
import {Layout} from 'app/client/components/Layout';
import {LayoutEditor} from 'app/client/components/LayoutEditor';
import {Delay} from 'app/client/lib/Delay';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {testId} from 'app/client/ui2018/cssVars';
import {editableLabel} from 'app/client/ui2018/editableLabel';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {mod} from 'app/common/gutil';
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, subscribe} from 'grainjs';
import * as ko from 'knockout';
import * as _ from 'underscore';
// tslint:disable:no-console
const viewSectionTypes: {[key: string]: any} = {
record: GridView,
detail: DetailView,
chart: ChartView,
single: DetailView,
custom: CustomView,
};
function getInstanceConstructor(parentKey: string) {
const Cons = viewSectionTypes[parentKey];
if (!Cons) {
console.error('ViewLayout error: requested an unsupported section type:', parentKey);
}
// Default to GridView if no valid constructor
return Cons || viewSectionTypes.record;
}
class ViewSectionHelper extends Disposable {
private _instance = Holder.create<BaseView>(this);
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
super();
this.onDispose(() => vs.viewInstance(null));
this.autoDispose(subscribe((use) => {
// Rebuild the section when its type changes or its underlying table.
const table = use(vs.table);
const Cons = getInstanceConstructor(use(vs.parentKey));
this._instance.clear();
if (table.getRowId()) {
this._instance.autoDispose(Cons.create(gristDoc, vs));
}
vs.viewInstance(this._instance.get());
}));
}
}
/**
* ViewLayout - Handles layout for a single page.
*/
export class ViewLayout extends DisposableWithEvents implements IDomComponent {
public docModel = this.gristDoc.docModel;
public viewModel: ViewRec;
public layoutSpec: ko.Computed<object>;
private _freeze = false;
private _layout: any;
private _sectionIds: number[];
constructor(public readonly gristDoc: GristDoc, viewId: number) {
super();
this.viewModel = this.docModel.views.getRowModel(viewId);
// A Map from viewSection RowModels to corresponding View class instances.
// TODO add a test that creating / deleting a section creates/destroys one instance, and
// switching pages destroys all instances.
const viewSectionObs = createObsArray(this, this.viewModel.viewSections());
this.autoDispose(computedArray(viewSectionObs, (vs, i, compArr) =>
ViewSectionHelper.create(compArr, gristDoc, vs)));
// Update the stored layoutSpecObj with any missing fields that are present in viewFields.
this.layoutSpec = this.autoDispose(ko.computed(
() => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj()))
.extend({rateLimit: 0}));
this._layout = this.autoDispose(Layout.create(this.layoutSpec(),
this._buildLeafContent.bind(this), true));
this._sectionIds = this._layout.getAllLeafIds();
// When the layoutSpec changes by some means other than the layout editor, rebuild.
// This includes adding/removing sections and undo/redo.
this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this._rebuildLayout(spec)));
const layoutSaveDelay = this.autoDispose(new Delay());
this.listenTo(this._layout, 'layoutUserEditStop', () => {
layoutSaveDelay.schedule(1000, () => {
if (!this._layout) { return; }
(this.viewModel.layoutSpecObj as any).setAndSave(this._layout.getLayoutSpec());
this._onResize();
});
});
// Do not save if the user has started editing again.
this.listenTo(this._layout, 'layoutUserEditStart', () => layoutSaveDelay.cancel());
this.autoDispose(LayoutEditor.create(this._layout));
// Add disposal of this._layout after layoutEditor, so that it gets disposed first, and
// layoutEditor doesn't attempt to update it in its own disposal logic.
this.onDispose(() => this._layout.dispose());
this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this));
// It's hard to detect a click or mousedown on a third-party iframe
// (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript).
this.listenTo(this.gristDoc.app, 'clipboard_blur', this._maybeFocusInSection);
const commandGroup = {
deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); },
nextSection: () => { this._otherSection(+1); },
prevSection: () => { this._otherSection(-1); },
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
public buildDom() {
return this._layout.rootElem;
}
// Freezes the layout until the passed in promise resolves. This is useful to achieve a single
// layout rebuild when multiple user actions needs to apply, simply pass in a promise that resolves
// when all user actions have resolved.
public async freezeUntil(promise: Promise<unknown>): Promise<void> {
this._freeze = true;
try {
await promise;
} finally {
this._freeze = false;
this._rebuildLayout(this.layoutSpec.peek());
}
}
// Removes a view section from the current view. Should only be called if there is
// more than one viewsection in the view.
private _removeViewSection(viewSectionRowId: number) {
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
}
private _buildLeafContent(sectionRowId: number) {
// Creating normal section dom
const vs: ViewSectionRec = this.docModel.viewSections.getRowModel(sectionRowId);
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
testId(`viewlayout-section-${sectionRowId}`),
this.gristDoc.app.addNewUIClass(),
dom.cls('active_section', vs.hasFocus),
dom.maybe((use) => use(vs.viewInstance) !== null, () => dom('div.viewsection_title.flexhbox',
dom('span.viewsection_drag_indicator.glyphicon.glyphicon-option-vertical',
this.gristDoc.app.addNewUIClass(),
// Makes element grabbable only if grist is not readonly.
dom.cls('layout_grabbable', (use) => !use(this.gristDoc.isReadonlyKo))),
dom('div.flexitem.flexhbox',
dom('span.viewsection_titletext', editableLabel(
fromKo(vs.titleDef),
(val) => vs.titleDef.saveOnly(val),
testId('viewsection-title'),
)),
),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance: BaseView) => viewInstance.buildTitleControls()),
dom('span.viewsection_buttons',
viewSectionMenu(this.docModel, vs, this.viewModel, this.gristDoc.isReadonly, this.gristDoc.app.useNewUI)
)
)),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) =>
dom('div.view_data_pane_container.flexvbox',
dom.maybe(viewInstance.disableEditing, () =>
dom('div.disable_viewpane.flexvbox', 'No data')
),
dom.maybe(viewInstance.isTruncated, () =>
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane
)
),
dom.on('mousedown', () => { this.viewModel.activeSectionId(sectionRowId); }),
);
}
/**
* If there is no layout saved, we can create a default layout just from the list of fields for
* this view section. By default we just arrange them into a list of rows, two fields per row.
*/
private _updateLayoutSpecWithSections(spec: object) {
// We use tmpLayout as a way to manipulate the layout before we get a final spec from it.
const tmpLayout = Layout.create(spec, (leafId: number) => dom('div'), true);
const specFieldIds = tmpLayout.getAllLeafIds();
const viewSectionIds = this.viewModel.viewSections().all().map(function(f) { return f.getRowId(); });
function addToSpec(leafId: number) {
const newBox = tmpLayout.buildLayoutBox({ leaf: leafId });
const rows = tmpLayout.rootBox().childBoxes.peek();
const lastRow = rows[rows.length - 1];
if (rows.length >= 1 && lastRow.isLeaf()) {
// Add a new child to the last row.
lastRow.addChild(newBox, true);
} else {
// Add a new row.
tmpLayout.rootBox().addChild(newBox, true);
}
return newBox;
}
// For any stale fields (no longer among viewFields), remove them from tmpLayout.
_.difference(specFieldIds, viewSectionIds).forEach(function(leafId) {
tmpLayout.getLeafBox(leafId).dispose();
});
// For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a
// two-column layout, so add a new row, or a second box to the last row if it's a leaf.
_.difference(viewSectionIds, specFieldIds).forEach(function(leafId) {
// Only add the builder box if it hasn`t already been created
addToSpec(leafId);
});
spec = tmpLayout.getLayoutSpec();
tmpLayout.dispose();
return spec;
}
private _rebuildLayout(layoutSpec: object) {
this._layout.buildLayout(layoutSpec, true);
this._onResize();
this._sectionIds = this._layout.getAllLeafIds();
}
// Resizes the scrolly windows of all viewSection classes with a 'scrolly' property.
private _onResize() {
this.viewModel.viewSections().all().forEach(vs => {
const inst = vs.viewInstance.peek();
if (inst) {
inst.onResize();
}
});
}
// Select another section in cyclic ordering of sections. Order is counter-clockwise if given a
// positive `delta`, clockwise otherwise.
private _otherSection(delta: number) {
const sectionId = this.viewModel.activeSectionId.peek();
const currentIndex = this._sectionIds.indexOf(sectionId);
const index = mod(currentIndex + delta, this._sectionIds.length);
// update the active section id
this.viewModel.activeSectionId(this._sectionIds[index]);
}
private _maybeFocusInSection() {
// If the focused element is inside a view section, make that section active.
const layoutBox = this._layout.getContainingBox(document.activeElement);
if (layoutBox && layoutBox.leafId) {
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
}
}
}

View File

@ -0,0 +1,114 @@
.g_record_layout_linking {
position: absolute;
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
justify-content: center;
-webkit-justify-content: center;
align-items: center;
-webkit-align-items: center;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 5;
}
.linker_canvas {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
z-index: 10;
}
.linker_save_btns {
position: absolute;
right: 10px;
top: 10px;
z-index: 100;
display: flex;
}
.linker_btn {
width: 90px;
margin: 4px;
background-color: black;
padding: 5px;
border-radius: 2px;
text-align: center;
color: white;
cursor: pointer;
border: 1px solid #cdcbcb;
}
.linker_btn.disabled {
color: #555;
cursor: default;
}
.linker_btn_cancel {
color: #aaa;
}
.linker_box {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
font-size: 1.5rem;
}
.section_link {
margin: 4px 0;
background-color: black;
padding: 5px 10px;
border-radius: 2px;
cursor: default;
width: 100%;
}
.view_link {
position: absolute;
}
.view_link_icon {
color: #aaa;
cursor: pointer;
}
.linker_box_header {
visibility: hidden;
font-size: 1.2rem;
font-weight: bold;
margin: 2px 0 -4px 0;
color: white;
}
.linker_box_header.visible {
visibility: visible;
}
.link_text {
margin-left: 25px;
color: #666;
}
.selected_text,
.available_text {
color: white;
}
.remove_link_icon:hover,
.view_link_icon:hover,
.selected_link {
color: white;
}
.remove_link_icon {
color: #aaa;
margin: 0 0 0 8px;
font-size: 1.2rem;
cursor: pointer;
}

View File

@ -0,0 +1,334 @@
var _ = require('underscore');
var ko = require('knockout');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var dom = require('../lib/dom');
var kd = require('../lib/koDom');
var commands = require('./commands');
/**
* Use the browser globals in a way that allows replacing them with mocks in tests.
*/
var G = require('../lib/browserGlobals').get('window', 'document', '$');
//----------------------------------------------------------------------
function ViewLinkerNode(section, column, tableId, primaryTableId) {
this.section = section;
this.sectionRef = section ? section.getRowId() : 0;
this.col = column;
this.colRef = column ? column.getRowId() : 0;
this.tableId = tableId;
this.primaryTableId = primaryTableId;
this.nodeId = this.sectionRef + ':' + this.colRef; // Uniquely identifies the node.
this.linkIconDom = null;
}
ViewLinkerNode.prototype.isLinked = function() {
return this.section && this.section.activeLinkSrcSectionRef() &&
this.section.activeLinkTargetColRef() === this.colRef;
};
ViewLinkerNode.prototype.isValidLinkTo = function(targetNode) {
let tablesMatch = (this.tableId === targetNode.tableId ||
(this.primaryTableId === targetNode.primaryTableId && !this.col && !targetNode.col));
// There are table-to-table links (cursor sync), table-to-column (filter), column-to-table
// (cursor), and column-to-column (filter).
// The table must match.
if (!tablesMatch) { return false; }
// The target must not be already linked.
if (targetNode.section && targetNode.section.activeLinkSrcSectionRef()) {
return false;
}
if (this.section) {
// The link must not create a cycle.
for (let sec = this.section; sec.getRowId(); sec = sec.linkSrcSection()) {
if (targetNode.sectionRef === sec.getRowId()) {
return false;
}
}
}
return true;
};
ViewLinkerNode.prototype.linkCoord = function() {
var rect = this.linkIconDom.getBoundingClientRect();
return {
left: rect.left + rect.width / 2,
top: rect.top + rect.height / 2
};
};
//----------------------------------------------------------------------
/**
* ViewLinker - Builds GUI for linking viewSections in viewModel
*/
function ViewLinker(viewModel) {
this.viewModel = viewModel;
this.viewSections = this.viewModel.viewSections().all();
this.clicked = ko.observable(null);
// For each section, the pair (section, colRef) represents a linkable node for every reference
// column, and for colRef 0 (which corresponds to the special "id" column, or the table itself).
this.allNodes = [];
for (let section of this.viewSections) {
this.allNodes.push(...ViewLinker.createNodes(section, section.table()));
}
this.nodesBySection = _.groupBy(this.allNodes, 'sectionRef');
this.nodesById = _.indexBy(this.allNodes, 'nodeId');
// A list of coordinate data objects for coordinates in no particular order.
this.coordinates = null;
this.boundMouseMove = this.handleMouseMove.bind(this);
this.boundWindowResize = this.handleWindowResize.bind(this);
this.autoDisposeCallback(function() {
G.$(G.window).off('mousemove', this.boundMouseMove);
G.$(G.window).off('resize', this.boundWindowResize);
});
this.canvas = this.autoDispose(dom('canvas.linker_canvas'));
this.buttons = this.autoDispose(
dom('div.linker_save_btns',
dom('div.linker_btn', 'Apply',
dom.on('click', () => {
this.viewModel.isLinking(false);
})
),
dom('div.linker_btn', 'Apply & Save',
dom.on('click', () => {
commands.allCommands.saveLinks.run();
this.viewModel.isLinking(false);
})
),
dom('div.linker_btn', 'Cancel',
dom.on('click', () => {
commands.allCommands.revertLinks.run();
this.viewModel.isLinking(false);
})
)
)
);
dom.id('grist-app').appendChild(this.canvas);
dom.id('view_content').appendChild(this.buttons);
this.ctx = this.canvas.getContext("2d");
// Must monitor link values and redraw arrows on changes since undo-ing/redo-ing may
// affect them.
this.autoDispose(ko.computed(() => {
for (let section of this.viewSections) {
section.linkSrcSectionRef();
section.linkSrcColRef();
section.linkTargetColRef();
}
setTimeout(this.resetArrows.bind(this), 0);
}));
G.$(G.window).on('resize', this.boundWindowResize);
this.handleWindowResize();
}
dispose.makeDisposable(ViewLinker);
ViewLinker.createNodes = function(section, table) {
const nodes = [];
nodes.push(new ViewLinkerNode(section, null, table.tableId(),
table.primaryTableId()));
for (let column of table.columns().peek()) {
let tableId = gutil.removePrefix(column.type(), "Ref:");
if (tableId) {
nodes.push(new ViewLinkerNode(section, column, tableId));
}
}
return nodes;
};
// Fills coordinates object with values and re-renders canvas.
ViewLinker.prototype.resetArrows = function() {
this.resetCoordinates();
this.redrawArrows();
};
ViewLinker.prototype.onClickNode = function(event, node) {
if (!this.clicked()) {
this.clicked(node);
G.$(G.window).on('mousemove', this.boundMouseMove);
} else {
this.connectLink(node);
this.clicked(null);
G.$(G.window).off('mousemove', this.boundMouseMove);
}
};
ViewLinker.prototype.onClickBackground = function(event) {
if (this.clicked()) {
this.clicked(null);
G.$(G.window).off('mousemove', this.boundMouseMove);
this.redrawArrows();
} else {
this.viewModel.isLinking(false);
}
};
ViewLinker.prototype.handleMouseMove = function(event) {
this.redrawArrows();
let coord = this.clicked().linkCoord();
drawArrow(this.ctx, coord.left, coord.top, event.clientX, event.clientY);
this.ctx.stroke();
};
ViewLinker.prototype.handleWindowResize = function(event) {
// Canvas must be scaled up in a particular way for improved resolution.
var w = G.$(G.window);
var windowWidth = w.innerWidth();
var windowHeight = w.innerHeight();
this.ctx.canvas.style.width = windowWidth + 'px';
this.ctx.canvas.style.height = windowHeight + 'px';
this.ctx.canvas.width = windowWidth * 2;
this.ctx.canvas.height = windowHeight * 2;
this.ctx.scale(2, 2);
setTimeout(this.resetArrows.bind(this), 0);
};
// To be called by each viewSection when the ViewLinker is active.
ViewLinker.prototype.buildLeafDom = function(viewSection) {
var sectionRef = viewSection.getRowId();
var mainNode = this.nodesById[sectionRef + ':0'];
var sectionNodes = this.nodesBySection[sectionRef];
var getOtherSections = () => this.clicked() && this.clicked().sectionRef !== sectionRef;
return dom('div.g_record_layout_linking',
dom.on('click', event => { this.onClickBackground(event); }),
dom('div.linker_box',
dom('div.linker_box_section',
dom('div.linker_box_header', 'Scroll',
kd.toggleClass('visible', getOtherSections)
),
this.buildLinkRowDom(viewSection, mainNode)
),
dom('div.linker_box_columns',
dom('div.linker_box_header', 'Filter',
kd.toggleClass('visible', () => getOtherSections() && sectionNodes.length > 1)
),
this.nodesBySection[sectionRef].map(node => {
if (!node.col) { return null; }
return this.buildLinkRowDom(viewSection, node);
})
)
)
);
};
ViewLinker.prototype.buildLinkRowDom = function(viewSection, node) {
var sectionRef = viewSection.getRowId();
var tableId = viewSection.table().tableTitle();
var isAvailable = ko.computed(() => (this.clicked() ?
this.clicked().isValidLinkTo(node) :
// See if there are any nodes this could possibly link to.
this.allNodes.some(tgt => node.isValidLinkTo(tgt))
));
return dom('div.section_link.section_link_' + sectionRef,
dom.autoDispose(isAvailable),
// Prevents closing on link mode when clicking in link boxes.
dom.on('click', event => { event.stopPropagation(); }),
dom('span.view_link.link_' + sectionRef + (node.col ? '_' + node.col.getRowId() : ''),
dom.on('click', event => {
if (isAvailable()) { this.onClickNode(event, node); }
}),
node.linkIconDom =
dom('span.glyphicon.glyphicon-link.view_link_icon',
// Use 'visibility' rather than 'display', to keep size and position available.
kd.style('visibility', () => isAvailable() ? 'visible' : 'hidden'),
kd.toggleClass('selected_link', () => (node.isLinked() || this.clicked() === node))
)
),
dom('span.link_text', tableId + (node.col ? '.' + node.col.colId() : ''),
kd.toggleClass('available_text', isAvailable),
kd.toggleClass('selected_text', () => node.isLinked()),
dom('span.glyphicon.glyphicon-remove.remove_link_icon',
kd.style('visibility', () =>
(node.isLinked() && !this.clicked() ? 'visible' : 'hidden')),
dom.on('click', () => this.removeNodeLinks(node))
)
)
);
};
// Initializes the coordinates array with linked node coordinates.
ViewLinker.prototype.resetCoordinates = function() {
this.coordinates = [];
for (let vs of this.viewSections) {
if (vs.activeLinkSrcSectionRef.peek()) {
let sourceNodeId = vs.activeLinkSrcSectionRef.peek() + ':' + vs.activeLinkSrcColRef.peek();
let targetNodeId = vs.getRowId() + ':' + vs.activeLinkTargetColRef.peek();
this.coordinates.push({
'from': this.nodesById[sourceNodeId].linkCoord(),
'to': this.nodesById[targetNodeId].linkCoord()
});
}
}
};
// Draws all pre-existing arrows on the canvas.
ViewLinker.prototype.redrawArrows = function() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.beginPath();
this.coordinates.forEach(conn =>
drawArrow(this.ctx, conn.from.left, conn.from.top, conn.to.left, conn.to.top));
};
// Saves a new link from the clicked node to 'node' to the local list of links.
ViewLinker.prototype.connectLink = function(node) {
var sourceNode = this.clicked();
var section = node.section;
section.activeLinkSrcSectionRef(sourceNode.sectionRef);
section.activeLinkSrcColRef(sourceNode.colRef);
section.activeLinkTargetColRef(node.colRef);
this.resetArrows();
};
// Removes links from the given node.
ViewLinker.prototype.removeNodeLinks = function(node) {
if (!node.isLinked()) { return; }
var section = node.section;
section.activeLinkSrcSectionRef(0);
section.activeLinkSrcColRef(0);
section.activeLinkTargetColRef(0);
this.resetArrows();
};
// Draws an orange arrow with a black outline in context ctx from (origX, origY) to (x, y).
function drawArrow(ctx, origX, origY, x, y){
ctx.strokeStyle = 'black';
ctx.lineWidth = 4;
ctx.lineCap = 'round';
_drawBasicArrow(ctx, origX, origY, x, y);
ctx.stroke();
ctx.strokeStyle = '#F5A542';
ctx.lineWidth = 3;
_drawBasicArrow(ctx, origX, origY, x, y);
ctx.stroke();
}
// Draws a single arrow with no outline.
function _drawBasicArrow(ctx, origX, origY, x, y) {
var l = 10; // length of arrow head
var angle = Math.atan2(y - origY, x - origX);
ctx.moveTo(x, y);
ctx.lineTo(x - l*Math.cos(angle - Math.PI/5), y - l*Math.sin(angle - Math.PI/5));
ctx.moveTo(x, y);
ctx.lineTo(x - l*Math.cos(angle + Math.PI/5), y - l*Math.sin(angle + Math.PI/5));
ctx.moveTo(origX, origY);
ctx.lineTo(x, y);
}
module.exports = ViewLinker;

View File

@ -0,0 +1,6 @@
// This module is unused except to group some modules for a webpack bundle.
// TODO It is a vestige of the old ViewPane.js, and can go away with some bundling improvements.
import * as FieldConfigTab from 'app/client/components/FieldConfigTab';
import * as ViewConfigTab from 'app/client/components/ViewConfigTab';
export {FieldConfigTab, ViewConfigTab};

View File

@ -0,0 +1,404 @@
// The top-level groups, and the ordering within them are for user-facing documentation.
exports.groups = [{
group: 'General',
commands: [
{
name: 'shortcuts',
keys: ['F1', 'Mod+/'],
desc: 'Display shortcuts pane'
}, {
name: 'help',
keys: [],
desc: 'Display Grist documentation'
}, {
name: 'undo',
keys: ['Mod+z'],
desc: 'Undo last action'
}, {
name: 'redo',
keys: ['Mod+Shift+Z', 'Ctrl+y' ],
desc: 'Redo last action'
}, {
name: 'accept',
keys: ['Enter'],
desc: null, // Accept the action of the dialog box
}, {
name: 'cancel',
keys: ['Escape'],
desc: null, // Cancel the action of the dialog box
}, {
name: 'find',
keys: ['Mod+f'],
desc: 'Find',
}, {
name: 'findNext',
keys: ['Mod+g'],
desc: 'Find next occurrence',
}, {
name: 'findPrev',
keys: ['Mod+Shift+G'],
desc: 'Find previous occurrence',
}, {
// Without this, when focus in on Clipboard, this shortcut would only move the cursor.
name: 'historyBack',
keys: ['Mod+Left'],
desc: null, // Move back in history, same as clicking the Back button
}, {
// Without this, when focus in on Clipboard, this shortcut would only move the cursor.
name: 'historyForward',
keys: ['Mod+Right'],
desc: null, // Move forward in history, same as clicking the Forward button
}, {
name: 'reloadPlugins',
keys: ['Mod+Alt+P'],
desc: null, // reload plugins
}
],
}, {
group: 'Menu shortcuts',
commands: [
{
name: 'closeActiveMenu',
keys: ['Esc'],
desc: null, // Shortcut to close active menu
},
{
name: 'filterMenuOpen',
keys: [],
desc: 'Shortcut to open filter menu'
},
{
name: 'docTabOpen',
keys: [],
desc: 'Shortcut to open document tab'
},
{
name: 'viewTabOpen',
keys: [],
desc: 'Shortcut to open view tab'
},
{
name: 'fieldTabOpen',
keys: [],
desc: 'Shortcut to open field tab'
},
{
name: 'sortFilterTabOpen',
keys: [],
desc: 'Shortcut to sort & filter tab'
},
{
name: 'dataSelectionTabOpen',
keys: [],
desc: 'Shortcut to data selection tab'
}
]
}, {
group: 'Navigation',
commands: [
{
name: 'cursorDown',
keys: ['Down'],
desc: 'Move downward to next record or field'
}, {
name: 'cursorUp',
keys: ['Up'],
desc: 'Move upward to previous record or field'
}, {
name: 'cursorRight',
keys: ['Right'],
desc: 'Move right to the next field'
}, {
name: 'cursorLeft',
keys: ['Left'],
desc: 'Move left to the previous field'
}, {
name: 'nextField',
keys: ['Tab'],
desc: 'Move to the next field, saving changes if editing a value'
}, {
name: 'prevField',
keys: ['Shift+Tab'],
desc: 'Move to the previous field, saving changes if editing a value'
}, {
name: 'pageDown',
keys: ['PageDown'],
desc: 'Move down one page of records, or to next record in a card list'
}, {
name: 'pageUp',
keys: ['PageUp'],
desc: 'Move up one page of records, or to previous record in a card list'
}, {
name: 'moveToFirstRecord',
keys: ['Mod+Up'],
desc: 'Move up to the first record',
}, {
name: 'moveToLastRecord',
keys: ['Mod+Down'],
desc: 'Move down to the last record',
}, {
name: 'moveToFirstField',
keys: ['Home'],
desc: 'Move to the first field or the beginning of a row'
}, {
name: 'moveToLastField',
keys: ['End'],
desc: 'Move to the last field or the end of a row'
}, {
// no longer used
name: 'skipDown',
keys: [],
desc: 'Move downward five records'
}, {
// no longer used
name: 'skipUp',
keys: [],
desc: 'Move upward five records'
}, {
name: 'setCursor',
keys: [],
desc: 'Moves the cursor to the correct location'
}, {
name: 'openDocumentList',
keys: [],
desc: 'Opens document list'
}, {
name: 'nextPage',
keys: ['Alt+Down'],
desc: 'Open next page'
}, {
name: 'prevPage',
keys: ['Alt+Up'],
desc: 'Open previous page'
}, {
name: 'nextSection',
keys: ['Mod+o'],
desc: 'Activate next page widget',
}, {
name: 'prevSection',
keys: ['Mod+Shift+O'],
desc: 'Activate previous page widget',
}
],
}, {
group: 'Selection',
commands: [
{
name: 'shiftDown',
keys: ['Shift+Down'],
desc: 'Adds the element below the cursor to the selected range'
}, {
name: 'shiftUp',
keys: ['Shift+Up'],
desc: 'Adds the element above the cursor to the selected range'
}, {
name: 'shiftRight',
keys: ['Shift+Right'],
desc: 'Adds the element to the right of the cursor to the selected range'
}, {
name: 'shiftLeft',
keys: ['Shift+Left'],
desc: 'Adds the element to the left of the cursor to the selected range'
}, {
name: 'selectAll',
keys: ['Mod+A'],
desc: 'Selects all currently displayed cells'
}, {
name: 'copyLink',
keys: ['Mod+Shift+A'],
desc: 'Copy anchor link'
}
],
}, {
group: 'Editing',
commands: [
{
name: 'editField',
keys: ['Enter', 'F2'],
desc: 'Start editing the currently-selected cell'
}, {
name: 'fieldEditSave',
keys: ['Enter'],
desc: 'Finish editing a cell, saving the value'
}, {
name: 'fieldEditSaveHere',
keys: [],
desc: 'Finish editing a cell and save without moving to next record',
}, {
name: 'fieldEditCancel',
keys: ['Escape'],
desc: 'Discard changes to a cell value'
}, {
name: 'copy',
keys: [],
desc: 'Copy current selection to clipboard'
}, {
name: 'cut',
keys: [],
desc: 'Cut current selection to clipboard'
}, {
name: 'paste',
keys: [],
desc: 'Paste clipboard contents at cursor'
}, {
name: 'fillSelectionDown',
keys: ['Mod+D'],
desc: 'Fills current selection with the contents of the top row in the selection'
}, {
name: 'clearValues',
keys: ['Backspace', 'Del'],
desc: 'Clears the currently selected cells'
}, {
name: 'input',
keys: [],
desc: 'Enter text into currently-selected cell and start editing'
}, {
name: 'editLabel',
keys: [],
desc: 'Edit label of the currently-selected field'
}, {
name: 'editLayout',
keys: [],
desc: 'Edit record layout'
}, {
name: 'toggleCheckbox',
keys: ['Enter', 'Space'],
desc: 'Toggles the value of checkbox cells'
}, {
name: 'historyPrevious',
keys: ['Up'],
desc: null, // Fetches the previous command from the history list, moving back in the list
}, {
name: 'historyNext',
keys: ['Down'],
desc: null, // Fetches the next command from the history list, moving forward in the list
}, {
name: 'makeFormula',
keys: ["="],
desc: 'When typed at the start of a cell, make this a formula column',
}, {
name: 'unmakeFormula',
keys: ['Backspace'],
desc: null, // Undoes turning of column into a formula column, when pressed at start of a cell
}, {
name: 'insertCurrentDate',
keys: ['Mod+;'],
desc: 'Insert the current date',
}, {
name: 'insertCurrentDateTime',
keys: ['Mod+Shift+;'],
desc: 'Insert the current date and time',
}, {
name: 'datepickerFocus',
keys: ['Up', 'Down'],
desc: null, // While editing a date cell, switch keyboard focus to the datepicker
}
],
}, {
group: 'Data manipulation',
commands: [
{
name: 'insertRecordBefore',
keys: ['Mod+Shift+='],
desc: 'Insert a new record, before the currently selected one in an unsorted table'
}, {
name: 'insertRecordAfter',
keys: ['Mod+='],
desc: 'Insert a new record, after the currently selected one in an unsorted table',
}, {
name: 'deleteRecords',
keys: ['Mod+-'],
desc: 'Delete the currently selected record'
}, {
name: 'insertFieldBefore',
keys: ['Alt+Shift+='],
desc: 'Insert a new column, before the currently selected one'
}, {
name: 'insertFieldAfter',
keys: ['Alt+='],
desc: 'Insert a new column, after the currently selected one'
}, {
name: 'renameField',
keys: ['Ctrl+m'],
desc: 'Rename the currently selected column'
}, {
name: 'hideField',
keys: ['Alt+Shift+-'],
desc: 'Hide the currently selected column'
}, {
name: 'deleteFields',
keys: ['Alt+-'],
desc: 'Delete the currently selected columns'
}, {
name: 'addSection',
keys: [],
desc: 'Add a new viewsection to the currently active view'
}, {
name: 'deleteSection',
keys: [],
desc: 'Delete the currently active viewsection'
}
],
}, {
group: 'Sorting',
commands: [
{
name: 'sortAsc',
keys: [],
desc: 'Sort the view data by the currently selected field in ascending order'
}, {
name: 'sortDesc',
keys: [],
desc: 'Sort the view data by the currently selected field in descending order'
}, {
name: 'addSortAsc',
keys: [],
desc: 'Adds the currently selected column(ascending) to the current view\'s sort spec'
}, {
name: 'addSortDesc',
keys: [],
desc: 'Adds the currently selected column(descending) to the current view\'s sort spec'
},
],
}, {
group: 'Linking',
commands: [
{
name: 'enterLinkMode',
keys: [],
desc: 'Enters section linking mode in the current view'
}, {
name: 'exitLinkMode',
keys: [],
desc: 'Exits section linking mode in the current view'
}, {
name: 'saveLinks',
keys: [],
desc: 'Saves the sections links in the current view'
}, {
name: 'revertLinks',
keys: [],
desc: 'Reverts the sections links to the saved links the current view'
}, {
name: 'clearLinks',
keys: [],
desc: 'Clears the section links in the current view'
}, {
name: 'clearSectionLinks',
keys: [],
desc: 'Clears the section links in the current viewsection'
}
],
}, {
group: 'Transforming',
commands: [
{
// TODO: Use AceEditor internal save command instead of custom transform save command
name: 'transformUpdate',
keys: ['Shift+Enter'],
desc: null // Updates the transform formula
}
],
}];

View File

@ -0,0 +1,53 @@
.shortcut_keys {
display: inline-block;
}
.context-menu-item .shortcut_keys {
font-size: 1.2rem;
}
.shortcut_key_image {
display: inline-block;
border-left: 2px solid #eee;
border-top: 2px solid #eee;
border-right: 2px solid #aaa;
border-bottom: 2px solid #aaa;
box-shadow: inset 0px 0px 0px 1px #fff, inset 3px 1px 0.5rem 2px #eee;
border-radius: 3px;
margin: 1px 0.2rem;
padding: 1px 6px;
font-size: 0.9em;
color: #666;
background-color: white;
}
.shortcut_key_image.pressed {
border-left: 2px solid #aba;
border-top: 2px solid #aba;
border-right: 2px solid #efe;
border-bottom: 2px solid #efe;
box-shadow: inset 0px 0px 0px 1px #efe, inset 3px 1px 0.5rem 2px #efe;
}
.shortcut_key_image.highlight {
border-left: 2px solid #cfc;
border-top: 2px solid #cfc;
border-right: 2px solid #8b8;
border-bottom: 2px solid #8b8;
box-shadow: inset 0px 0px 0px 1px #bfb, inset 3px 1px 0.5rem 2px #bfb;
}
.g-help .shortcut_key_image {
display: inline-block;
border-left: 2px solid #777;
border-top: 2px solid #777;
border-right: 2px solid #444;
border-bottom: 2px solid #444;
box-shadow: inset 0px 0px 0px 1px #555, inset -3px -1px 0.5rem 2px #777;
border-radius: 3px;
margin: 1px 0.2rem;
padding: 1px 6px;
font-size: 0.9em;
color: #cf0;
background-color: #555;
}

View File

@ -0,0 +1,330 @@
/**
* Commands are invoked by the user via keyboard shortcuts or mouse clicks, for example, to move
* the cursor or to delete the selected records.
*
* This module provides APIs for other components to implement groups of commands. Any given
* command may be implemented by different components, but at most one implementation of any
* command is active at any time.
*/
/* global navigator */
var _ = require('underscore');
var ko = require('knockout');
var Mousetrap = require('../lib/Mousetrap');
var dom = require('../lib/dom');
var gutil = require('app/common/gutil');
var dispose = require('../lib/dispose');
var commandList = require('./commandList');
require('../lib/koUtil'); // for subscribeInit
var G = require('../lib/browserGlobals').get('window');
// Same logic as used by mousetrap to map 'Mod' key to platform-specific key.
var isMac = (typeof navigator !== 'undefined' && navigator &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform));
/**
* Globally-exposed map of command names to Command objects. E.g. typing "cmd.cursorDown.run()" in
* the browser console should move the cursor down as long as it makes sense in the currently
* shown view. If the command is inactive, its run() function is a no-op.
*
* See also Command object below.
*/
var allCommands = {};
exports.allCommands = allCommands;
/**
* This is an internal variable, mapping key combinations to the stack of CommandGroups which
* include them (see also CommandGroup.knownKeys). It's used for deciding which CommandGroup to
* use when different Commands use the same key.
*/
var _allKeys = {};
/**
* Populate allCommands from those provided, or listed in commandList.js. Also populates the
* globally exposed `cmd` object whose properties invoke commands: e.g. typing `cmd.cursorDown` in
* the browser console will run allCommands.cursorDown.run().
*/
function init(optCommandGroups) {
var commandGroups = optCommandGroups || commandList.groups;
// Clear out the objects holding the global state.
Object.keys(allCommands).forEach(function(c) {
delete allCommands[c];
});
Object.keys(_allKeys).forEach(function(k) {
delete _allKeys[k];
});
commandGroups.forEach(function(commandGroup) {
commandGroup.commands.forEach(function(c) {
if (allCommands[c.name]) {
console.error("Ignoring duplicate command %s in commandList", c.name);
} else {
allCommands[c.name] = new Command(c.name, c.desc, c.keys);
}
});
});
// Define the browser console interface.
G.window.cmd = {};
_.each(allCommands, function(cmd, name) {
Object.defineProperty(G.window.cmd, name, {get: cmd.run});
});
}
exports.init = init;
//----------------------------------------------------------------------
const KEY_MAP_MAC = {
Mod: '⌘',
Alt: '⌥',
Shift: '⇧',
Ctrl: '⌃',
Left: '←',
Right: '→',
Up: '↑',
Down: '↓',
};
const KEY_MAP_WIN = {
Mod: 'Ctrl',
Left: '←',
Right: '→',
Up: '↑',
Down: '↓',
};
function getHumanKey(key, isMac) {
const keyMap = isMac ? KEY_MAP_MAC : KEY_MAP_WIN;
let keys = key.split('+').map(s => s.trim());
keys = keys.map(k => {
if (k in keyMap) { return keyMap[k]; }
if (k.length === 1) { return k.toUpperCase(); }
return k;
});
return keys.join( isMac ? '' : ' + ');
}
/**
* Command represents a single command. It is exposed via the `allCommands` map.
* @property {String} name: The name of the command, same as the key into the `allCommands` map.
* @property {String} desc: The description of the command.
* @property {Array} keys: The array of keyboard shortcuts for the command.
* @property {Function} run: A bound function that will run the currently active implementation.
* @property {Observable} isActive: Knockout observable for whether this command is active.
*/
function Command(name, desc, keys) {
this.name = name;
this.desc = desc;
this.humanKeys = keys.map(key => getHumanKey(key, isMac));
this.keys = keys.map(function(k) { return k.trim().toLowerCase().replace(/ *\+ */g, '+'); });
this.isActive = ko.observable(false);
this._implGroupStack = [];
this._activeFunc = _.noop; // The function to run when this command is invoked.
// Let .run bind the Command object, so that it can be used as a stand-alone callback.
this.run = this._run.bind(this);
}
exports.Command = Command;
Command.prototype._run = function() {
return this._activeFunc.apply(null, arguments);
};
/**
* Returns the text description for the command, including the keyboard shortcuts.
*/
Command.prototype.getDesc = function() {
var desc = this.desc;
if (this.humanKeys.length) {
desc += " (" + this.humanKeys.join(", ") + ")";
}
return desc;
};
/**
* Returns DOM for the keyboard shortcuts, wrapped in cute boxes that look like keyboard keys.
*/
Command.prototype.getKeysDom = function() {
return dom('span.shortcut_keys',
this.humanKeys.map(key => dom('span.shortcut_key_image', key))
);
};
/**
* Adds a CommandGroup that implements this Command to the top of the stack of groups.
*/
Command.prototype._addGroup = function(cmdGroup) {
this._implGroupStack.push(cmdGroup);
this._updateActive();
};
/**
* Removes a CommandGroup from the stack of groups implementing this Command.
*/
Command.prototype._removeGroup = function(cmdGroup) {
gutil.arrayRemove(this._implGroupStack, cmdGroup);
this._updateActive();
};
/**
* Updates the command's state to reflect the currently active group, if any.
*/
Command.prototype._updateActive = function() {
if (this._implGroupStack.length > 0) {
this.isActive(true);
this._activeFunc = _.last(this._implGroupStack).commands[this.name];
} else {
this.isActive(false);
this._activeFunc = _.noop;
}
// Now bind or unbind the affected key combinations.
this.keys.forEach(function(key) {
var keyGroups = _allKeys[key];
if (keyGroups && keyGroups.length > 0) {
var commandGroup = _.last(keyGroups);
// Command name might be different from this.name in case we are deactivating a command, and
// the previous meaning of the key points to a different command.
var commandName = commandGroup.knownKeys[key];
Mousetrap.bind(key, wrapKeyCallback(commandGroup.commands[commandName]));
} else {
Mousetrap.unbind(key);
}
});
};
/**
* Helper for mousetrap callbacks, which returns a version of the callback that by default stops
* the propagation of the keyboard event (unless the callback returns a true value).
*/
function wrapKeyCallback(callback) {
return function() {
return callback.apply(null, arguments) || false;
};
}
//----------------------------------------------------------------------
/**
* CommandGroup is the way for other components to provide implementations for a group of
* commands. Note that CommandGroups are stacked, with groups activated later having priority over
* groups activated earlier.
* @param {String->Function} commands: The map of command names to implementations.
* @param {Object} context: "this" context with which to invoke implementation functions.
* @param {Boolean|Observable<boolean>} activate: Whether to activate this group immediately, false if
* omitted. This may be an Observable.
*/
function CommandGroup(commands, context, activate) {
// Keep only valid commands, so that we don't have to check for validity elsewhere, and bind
// each to the passed-in context object.
this.commands = {};
this.isActive = false;
var name;
for (name in commands) {
if (allCommands[name]) {
this.commands[name] = commands[name].bind(context);
} else {
console.warn("Ignoring unknown command %s", name);
}
}
// Map recognized key combinations to the corresponding command names.
this.knownKeys = {};
for (name in this.commands) {
var keys = allCommands[name].keys;
for (var i = 0; i < keys.length; i++) {
this.knownKeys[keys[i]] = name;
}
}
// On disposal, remove the CommandGroup from all the commands and keys.
this.autoDisposeCallback(this._removeGroup);
// Finally, set the activatation status of the command group, subscribing if an observable.
if (ko.isObservable(activate)) {
this.autoDispose(activate.subscribeInit(this.activate, this));
} else {
this.activate(activate);
}
}
exports.CommandGroup = CommandGroup;
dispose.makeDisposable(CommandGroup);
/**
* Just a shorthand for CommandGroup.create constructor.
*/
function createGroup(commands, context, activate) {
return CommandGroup.create(commands, context, activate);
}
exports.createGroup = createGroup;
/**
* Activate or deactivate this implementation group.
*/
CommandGroup.prototype.activate = function(yesNo) {
if (yesNo) {
this._addGroup();
} else {
this._removeGroup();
}
};
CommandGroup.prototype._addGroup = function() {
if (!this.isActive) {
this.isActive = true;
// Add this CommandGroup to each key combination that it recognizes.
for (var key in this.knownKeys) {
(_allKeys[key] || (_allKeys[key] = [])).push(this);
}
// Add this CommandGroup to each command that it implements.
for (var name in this.commands) {
allCommands[name]._addGroup(this);
}
}
};
CommandGroup.prototype._removeGroup = function() {
if (this.isActive) {
// On disposal, remove the CommandGroup from all the commands and keys.
for (var key in this.knownKeys) {
gutil.arrayRemove(_allKeys[key], this);
}
for (var name in this.commands) {
allCommands[name]._removeGroup(this);
}
this.isActive = false;
}
};
/**
* Attach this CommandGroup to a DOM element, to allow it to accept key events, limiting them to
* this group only. This is useful for inputs and textareas, where only a limited set of keyboard
* shortcuts should be applicable and where by default mousetrap ignores shortcuts completely.
*
* See also stopCallback in app/client/lib/Mousetrap.js.
*/
CommandGroup.prototype.attach = dom.inlinable(function(elem) {
ko.utils.domData.set(elem, 'mousetrapCommandGroup', this);
});
//----------------------------------------------------------------------
/**
* Tie the button to an command listed in commandList.js, triggering the callback from the
* currently active CommandLayer (if any), and showing a description and keyboard shortcuts in its
* tooltip.
*
* You may use this inline while building dom, as in
* dom('button', commands.setButtomCommand(dom, 'command'))
*/
exports.setButtonCommand = dom.inlinable(function(elem, commandName) {
var cmd = allCommands[commandName];
elem.setAttribute('title', cmd.getDesc());
dom.on(elem, 'click', cmd.run);
});

View File

@ -0,0 +1,179 @@
import { GristDoc } from 'app/client/components/GristDoc';
import { ViewFieldRec, ViewSectionRec } from 'app/client/models/DocModel';
import { cssField, cssInput, cssLabel} from 'app/client/ui/MakeCopyMenu';
import { IPageWidget, toPageWidget } from 'app/client/ui/PageWidgetPicker';
import { confirmModal } from 'app/client/ui2018/modals';
import { BulkColValues, RowRecord, UserAction } from 'app/common/DocActions';
import { arrayRepeat } from 'app/common/gutil';
import { schema } from 'app/common/schema';
import { dom } from 'grainjs';
import cloneDeepWith = require('lodash/cloneDeepWith');
import flatten = require('lodash/flatten');
import forEach = require('lodash/forEach');
import zip = require('lodash/zip');
import zipObject = require('lodash/zipObject');
// Duplicate page with pageId. Starts by prompting user for a new name.
export async function duplicatePage(gristDoc: GristDoc, pageId: number) {
const pagesTable = gristDoc.docModel.pages;
const pageName = pagesTable.rowModels[pageId].view.peek().name.peek();
let inputEl: HTMLInputElement;
setTimeout(() => {inputEl.focus(); inputEl.select(); }, 100);
confirmModal('Duplicate page', 'Save', () => makeDuplicate(gristDoc, pageId, inputEl.value), (
dom('div', [
"Enter name for the new page. ",
"Note that this does not copy data, ",
"but creates another view of the same data. ",
cssField(
cssLabel("Name"),
inputEl = cssInput({value: pageName + ' (copy)'}),
)
])
));
}
async function makeDuplicate(gristDoc: GristDoc, pageId: number, pageName: string = '') {
const sourceView = gristDoc.docModel.pages.rowModels[pageId].view.peek();
pageName = pageName || `${sourceView.name.peek()} (copy)`;
const viewSections = sourceView.viewSections.peek().peek();
let viewRef = 0;
await gristDoc.docData.bundleActions(
`Duplicate page ${pageName}`,
async () => {
// create new view and new sections
const results = await createNewViewSections(gristDoc.docData, viewSections);
viewRef = results[0].viewRef;
// give it a better name
await gristDoc.docModel.views.rowModels[viewRef].name.saveOnly(pageName);
// create a map from source to target section ids
const viewSectionIdMap = zipObject(
viewSections.map(vs => vs.getRowId()),
results.map(res => res.sectionRef)
) as {[id: number]: number};
// update layout spec
const viewLayoutSpec = patchLayoutSpec(sourceView.layoutSpecObj.peek(), viewSectionIdMap);
await gristDoc.docData.sendAction(
['UpdateRecord', '_grist_Views', viewRef, { layoutSpec: JSON.stringify(viewLayoutSpec)}]
);
// update the view fields
const destViewSections = viewSections.map((vs) => (
gristDoc.docModel.viewSections.rowModels[viewSectionIdMap[vs.getRowId()]]
));
const newViewFieldIds = await updateViewFields(gristDoc, destViewSections, viewSections);
// create map for mapping from a src field's id to its corresponding dest field's id
const viewFieldsIdMap = zipObject(
flatten(viewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId()))),
flatten(newViewFieldIds)) as {[id: number]: number};
// update the view sections
await updateViewSections(gristDoc, destViewSections, viewSections, viewFieldsIdMap, viewSectionIdMap);
});
// Give copy focus
await gristDoc.openDocPage(viewRef);
}
/**
* Update all of destViewSections with srcViewSections, use fieldsMap to patch the section layout
* (for detail/cardlist sections), use viewSectionMap to patch the sections ids for linking.
*/
async function updateViewSections(gristDoc: GristDoc, destViewSections: ViewSectionRec[],
srcViewSections: ViewSectionRec[], fieldsMap: {[id: number]: number},
viewSectionMap: {[id: number]: number}) {
// collect all the records for the src view sections
const records: RowRecord[] = [];
for (const srcViewSection of srcViewSections) {
const viewSectionLayoutSpec = patchLayoutSpec(srcViewSection.layoutSpecObj.peek(), fieldsMap);
const record = gristDoc.docData.getTable('_grist_Views_section')!.getRecord(srcViewSection.getRowId())!;
records.push({
...record,
layoutSpec: JSON.stringify(viewSectionLayoutSpec),
linkSrcSectionRef: viewSectionMap[srcViewSection.linkSrcSectionRef.peek()],
});
}
// transpose data
const sectionsInfo = {} as BulkColValues;
forEach(records[0], (val, key) => sectionsInfo[key] = records.map(rec => rec[key]));
// ditch column ids and parentId
delete sectionsInfo.id;
delete sectionsInfo.parentId;
// send action
const rowIds = destViewSections.map((vs) => vs.getRowId());
await gristDoc.docData.sendAction(['BulkUpdateRecord', '_grist_Views_section', rowIds, sectionsInfo]);
}
async function updateViewFields(gristDoc: GristDoc, destViewSections: ViewSectionRec[],
srcViewSections: ViewSectionRec[]) {
const actions: UserAction[] = [];
const docData = gristDoc.docData;
// First, remove all existing fields. Needed because `CreateViewSections` adds some by default.
const toRemove = flatten(destViewSections.map((vs) => vs.viewFields.peek().peek().map((field) => field.getRowId())));
actions.push(['BulkRemoveRecord', '_grist_Views_section_field', toRemove]);
// collect all the fields to add
const fieldsToAdd: RowRecord[] = [];
for (const [destViewSection, srcViewSection] of zip(destViewSections, srcViewSections)) {
const srcViewFields: ViewFieldRec[] = srcViewSection!.viewFields.peek().peek();
const parentId = destViewSection!.getRowId();
for (const field of srcViewFields) {
const record = docData.getTable('_grist_Views_section_field')!.getRecord(field.getRowId())!;
fieldsToAdd.push({...record, parentId});
}
}
// transpose data
const fieldsInfo = {} as BulkColValues;
forEach(schema._grist_Views_section_field, (val, key) => fieldsInfo[key] = fieldsToAdd.map(rec => rec[key]));
const rowIds = arrayRepeat(fieldsInfo.parentId.length, null);
actions.push(['BulkAddRecord', '_grist_Views_section_field', rowIds, fieldsInfo]);
const results = await gristDoc.docData.sendActions(actions);
return results[1];
}
/**
* Create a new view containing all of the viewSections. Note that it doesn't copy view fields, for
* which you can use `updateViewFields`.
*/
async function createNewViewSections(docData: GristDoc['docData'], viewSections: ViewSectionRec[]) {
const [first, ...rest] = viewSections.map(toPageWidget);
// Passing a viewId of 0 will create a new view.
const firstResult = await docData.sendAction(newViewSectionAction(first, 0));
const otherResult = await docData.sendActions(
// other view section are added to the newly created view
rest.map((widget) => newViewSectionAction(widget, firstResult.viewRef))
);
return [firstResult, ...otherResult];
}
// Helper to create an action that add widget to the view with viewId.
function newViewSectionAction(widget: IPageWidget, viewId: number) {
return ['CreateViewSection', widget.table, viewId, widget.type, widget.summarize ? widget.columns : null];
}
/**
* Replaces each `leaf` id in layoutSpec by its corresponding id in mapIds. Leave unchanged if id is
* missing from mapIds.
*/
export function patchLayoutSpec(layoutSpec: any, mapIds: {[id: number]: number}) {
return cloneDeepWith(layoutSpec, (val) => {
if (typeof val === 'object') {
if (mapIds[val.leaf]) {
return {...val, leaf: mapIds[val.leaf]};
}
}
});
}

View File

@ -0,0 +1,180 @@
.record {
display: -webkit-flex;
display: flex;
position: relative;
box-sizing: border-box;
-moz-box-sizing: border-box;
border-width: 0px;
border-style: none;
border-color: var(--grist-color-dark-grey);
border-left-style: solid; /* left border, against rownumbers div, always on */
border-bottom-width: 1px; /* style: none, set by record-hlines*/
}
.record.record-hlines { /* Overwrites style, width set on element */
border-bottom-style: solid;
}
.record.record-zebra.record-even {
background-color: #f8f8f8;
}
.record.record-add {
background-color: #f6f6ff !important; /* important to win over zebra stripes */
}
.field {
position: relative;
height: 100%;
-webkit-flex: none;
flex: none;
min-height: 22px;
white-space: pre;
/* make border exist always so content doesn't shift on v-gridline toggle */
border: 0px solid transparent; /* width set by js, border exists but is transparent */
}
.record-vlines > .field {
border-right-color: var(--grist-color-dark-grey); /* set border visibility */
}
.field.scissors {
outline: 2px dashed var(--grist-color-cursor);
}
.field.selected {
background-color: var(--grist-color-selection);
}
.field_clip {
padding: 3px 3px 0px 3px;
font-family: var(--grist-font-family-data);
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
height: 100%;
}
.field_clip.invalid {
background-color: #ffb6c1;
}
.field_clip.invalid:empty {
background-color: unset;
}
.field_clip.field-error-P {
color: #B0B0B0;
background-color: unset;
}
.field_clip.field-error-U {
color: #6363a2;
background-color: unset;
}
/* Insert a zero-width space into each cell, to size cells to at least one line of text. */
.field_clip:empty::before { content: '\200B'; }
@media not print {
.selected_cursor {
position: absolute;
left: 0px;
top: 0px;
width: 100%;
height: 100%;
/* one pixel outline around the cell, and one inside the cell */
outline: 1px solid var(--grist-color-inactive-cursor);
box-shadow: inset 0 0 0 1px var(--grist-color-inactive-cursor);
pointer-events: none;
}
.active_cursor {
outline: 1px solid var(--grist-color-cursor);
box-shadow: inset 0 0 0 1px var(--grist-color-cursor);
}
}
/* These classes are used to flash the cursor to indicate that editing in a cell is disabled. */
.cursor_read_only {
outline: 1px solid #ff9a00;
box-shadow: inset 0 0 0 1px #ff9a00;
}
.cursor_read_only_fade {
outline-color: var(--grist-color-cursor);
box-shadow: inset 0 0 0 1px var(--grist-color-cursor);
transition: outline-color 0.5s ease-in, box-shadow 0.5s ease-in;
}
.cursor_read_only_lock {
top: 0px;
height: 100%;
padding: 0 4px;
line-height: inherit;
background-color: #ff9a00;
color: white;
opacity: 1;
}
.cursor_read_only_fade > .cursor_read_only_lock {
opacity: 0;
transition: opacity 0.5s ease-in;
}
.column_name {
background-color: var(--grist-color-light-grey);
text-align: center;
cursor: pointer;
/* Column headers always show vertical gridlines, to make it clear how to resize them */
border-right-color: var(--grist-color-dark-grey);
}
.column_name.selected {
background-color: var(--grist-color-medium-grey-opaque);
}
.gridview_data_row_num.selected {
background-color: var(--grist-color-medium-grey-opaque);
}
.gridview_data_row_info.linked_dst::before {
position: absolute;
content: '\25B8';
text-align: left;
left: 7px;
}
.text_wrapping {
word-break: break-word;
white-space: pre-wrap;
}
.diff-change .diff-parent, .diff-change .diff-remote {
display: inline-block;
width: 50%;
}
.diff-conflict .diff-parent, .diff-conflict .diff-local, .diff-conflict .diff-remote {
display: inline-block;
width: 33.33%;
}
.diff-local {
background-color: #dfdfff;
}
.diff-parent {
background-color: #ffdfdf;
text-decoration: line-through;
}
.diff-remote {
background-color: #afffaf;
}
.diff-common {
color: #555;
}

View File

@ -0,0 +1,69 @@
/* global $ */
var koDom = require('../lib/koDom');
/**
* This adds `.isFlex` option to JQuery's $.ui.resizable to make it work better with flexbox.
* Specifically, when resizing to the left, JQuery adjusts both `width` and `left` properties. If
* the element is part of a flexbox, it's wrong to adjust `left`. This widget adds `.isFlex`
* option: when set to true, the `left` (also `top`) adjustments get ignored.
*/
var _respectSize = $.ui.resizable.prototype._respectSize;
$.ui.resizable.prototype._respectSize = function() {
var data = _respectSize.apply(this, arguments);
if (this.options.isFlex) {
console.log("Ignoring left, top");
data.left = data.top = undefined;
}
return data;
};
/**
* When used as an argument to dom() function, makes the containing element resizable, with the
* size written into the given observable. If the observable has a .save() method, it's called
* when the resize is complete (to save the new size to the server).
* @param {Object} options.enabled: An observable, a constant, or a function for a computed
* observable. The value is treated as a boolean, and determined whether resizable
* functionality is enabled.
* @param {String} options.handles: Same as for jqueryui's `resizable`, e.g. 'e' to resize right
* edge (east), 'w' to resize left edge (west).
* @param {Function} options.stop: Additional callback to call when resizing stops.
* @param {Boolean} options.isFlex: If true, will avoid changing 'left' when resizing the left edge.
* @param {Number} options.minWidth: The minimum width the element can be resized to.
* Defaults to 10 (JQuery default).
*/
function makeResizable(widthObservable, options) {
options = options || {};
function onEvent(e, ui) {
widthObservable(ui.size.width);
if (e.type === 'resizestop') {
if (options.stop) {
options.stop(e, ui);
}
if (widthObservable.save) {
widthObservable.save();
}
}
}
return function(elem) {
$(elem).resizable({
handles: options.handles || 'e',
resize: onEvent,
stop: onEvent,
isFlex: options.isFlex,
minWidth: options.minWidth || 10
});
if (options.hasOwnProperty('enabled')) {
koDom.setBinding(elem, options.enabled, function(elem, value) {
if (value) {
$(elem).resizable('enable');
} else {
$(elem).resizable('disable').removeClass('ui-state-disabled');
}
});
}
};
}
exports.makeResizable = makeResizable;

352
app/client/declarations.d.ts vendored Normal file
View File

@ -0,0 +1,352 @@
declare module "app/client/components/AceEditor";
declare module "app/client/components/Clipboard";
declare module "app/client/components/CodeEditorPanel";
declare module "app/client/components/DetailView";
declare module "app/client/components/DocConfigTab";
declare module "app/client/components/EmbedForm";
declare module "app/client/components/FieldConfigTab";
declare module "app/client/components/GridView";
declare module "app/client/components/Layout";
declare module "app/client/components/LayoutEditor";
declare module "app/client/components/Login";
declare module "app/client/components/ModalDialog";
declare module "app/client/components/REPLTab";
declare module "app/client/components/commandList";
declare module "app/client/lib/Mousetrap";
declare module "app/client/lib/browserGlobals";
declare module "app/client/lib/dom";
declare module "app/client/lib/koDom";
declare module "app/client/lib/koForm";
declare module "app/client/lib/koSession";
declare module "app/client/models/DocListModel";
declare module "app/client/widgets/UserType";
declare module "app/client/widgets/UserTypeImpl";
// tslint:disable:max-classes-per-file
declare module "app/client/components/Base" {
import {GristDoc} from 'app/client/components/GristDoc';
namespace Base { }
class Base {
public static setBaseFor(ctor: any): void;
constructor(gristDoc: GristDoc);
}
export = Base;
}
declare module "app/client/components/BaseView" {
import {Cursor, CursorPos} from 'app/client/components/Cursor';
import {GristDoc} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {KoArray} from "app/client/lib/koArray";
import * as BaseRowModel from "app/client/models/BaseRowModel";
import {LazyArrayModel} from "app/client/models/DataTableModel";
import * as DataTableModel from "app/client/models/DataTableModel";
import {ViewFieldRec, ViewSectionRec} from "app/client/models/DocModel";
import {SortedRowSet} from 'app/client/models/rowset';
import {DomArg} from 'grainjs';
import {IOpenController} from 'popweasel';
namespace BaseView {}
class BaseView extends Disposable {
public viewSection: ViewSectionRec;
public viewPane: any;
public viewData: LazyArrayModel<BaseRowModel>;
public gristDoc: GristDoc;
public cursor: Cursor;
public sortedRows: SortedRowSet;
public activeFieldBuilder: ko.Computed<unknown>;
public disableEditing: ko.Computed<boolean>;
public isTruncated: ko.Observable<boolean>;
protected tableModel: DataTableModel;
constructor(gristDoc: GristDoc, viewSectionModel: any);
public setCursorPos(cursorPos: CursorPos): void;
public createFilterMenu(ctl: IOpenController, field: ViewFieldRec): HTMLElement;
public buildTitleControls(): DomArg;
public getLoadingDonePromise(): Promise<void>;
public onResize(): void;
}
export = BaseView;
}
declare module "app/client/components/FieldConfigTab" {
import {GristDoc, TabContent} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {DomArg} from 'grainjs';
namespace FieldConfigTab {}
class FieldConfigTab extends Disposable {
public isForeignRefCol: ko.Computed<boolean>;
public refSelect: any;
constructor(options: {gristDoc: GristDoc, fieldBuilder: unknown, contentCallback: unknown});
public buildConfigDomObj(): TabContent[];
// TODO: these should be made private or renamed.
public _buildNameDom(): DomArg;
public _buildFormulaDom(): DomArg;
public _buildTransformDom(): DomArg;
public _buildFormatDom(): DomArg;
}
export = FieldConfigTab;
}
declare module "app/client/components/ViewConfigTab" {
import {GristDoc, TabContent} from 'app/client/components/GristDoc';
import {Disposable} from 'app/client/lib/dispose';
import {KoArray} from "app/client/lib/koArray";
import {ColumnRec, ViewRec, ViewSectionRec} from "app/client/models/DocModel";
import {DomArg} from 'grainjs';
namespace ViewConfigTab {
interface ViewSectionData {
section: ViewSectionRec;
hiddenFields: KoArray<ColumnRec>;
}
}
class ViewConfigTab extends Disposable {
constructor(options: {gristDoc: GristDoc, viewModel: ViewRec, skipDomBuild?: boolean});
public buildConfigDomObj(): TabContent[];
public buildSortDom(): DomArg;
// TODO: these should be made private or renamed.
public _buildSectionFieldsConfig(): DomArg;
public _buildNameDom(): DomArg;
public _buildSectionNameDom(): DomArg;
public _buildAdvancedSettingsDom(): DomArg;
public _buildDetailTypeDom(): DomArg;
public _buildFilterDom(): DomArg;
public _buildThemeDom(): DomArg;
public _buildGridStyleDom(): DomArg;
public _buildChartConfigDom(): DomArg;
public _buildLayoutDom(): DomArg;
public _buildLinkDom(): DomArg;
public _buildCustomTypeItems(): DomArg;
}
export = ViewConfigTab;
}
declare module "app/client/components/ViewLinker" {
import {ViewRec} from "app/client/models/DocModel";
namespace ViewLinker {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
class ViewLinkerNode {
public section: any;
public sectionRef: number;
public col: any;
public colRef: number;
public isValidLinkTo(node: ViewLinkerNode): boolean;
}
}
class ViewLinker {
public static create(viewRec: ViewRec): ViewLinker;
public static createNodes(section: any|null, table: any): ViewLinker.ViewLinkerNode[];
}
export = ViewLinker;
}
declare module "app/client/components/commands" {
export class Command {
public name: string;
public desc: string;
public humanKeys: string[];
public keys: string[];
public run: () => any;
}
export type CommandsGroup = any;
export const init: any;
export const allCommands: any;
export const createGroup: any;
}
declare module "app/client/lib/tableUtil" {
import {KoArray} from 'app/client/lib/koArray';
import {ViewFieldRec} from 'app/client/models/DocModel';
function insertPositions(lowerPos: number|null, upperPos: number|null, numInserts: number): number[];
function fieldInsertPositions(viewFields: KoArray<ViewFieldRec>, index: number, numInserts: number): number[];
}
declare module "app/client/models/BaseRowModel" {
import {Disposable} from 'app/client/lib/dispose';
import * as TableModel from 'app/client/models/TableModel';
import {ColValues} from 'app/common/DocActions';
namespace BaseRowModel {}
class BaseRowModel extends Disposable {
public id: ko.Computed<number>;
public _index: ko.Observable<number|null>;
public getRowId(): number;
public updateColValues(colValues: ColValues): Promise<void>;
public _table: TableModel;
protected _rowId: number | 'new' | null;
protected _fields: string[];
}
export = BaseRowModel;
}
declare module "app/client/models/MetaRowModel" {
import * as BaseRowModel from "app/client/models/BaseRowModel";
namespace MetaRowModel {}
class MetaRowModel extends BaseRowModel {
public _isDeleted: ko.Observable<boolean>;
public events: { trigger: (key: string) => void };
}
export = MetaRowModel;
}
declare module "app/client/models/modelUtil" {
interface SaveInterface<T> {
saveOnly(value: T): Promise<void>;
save(): Promise<void>;
setAndSave(value: T): Promise<void>;
}
type KoSaveableObservable<T> = ko.Observable<T> & SaveInterface<T>;
type KoSaveableComputed<T> = ko.Computed<T> & SaveInterface<T>;
interface CustomComputed<T> extends KoSaveableComputed<T> {
isSaved: ko.Computed<boolean>;
revert(): void;
}
function addSaveInterface<T>(
obs: ko.Observable<T>|ko.Computed<T>,
saveFunc: (value: T) => Promise<void>): KoSaveableObservable<T>;
interface ObjObservable<T> extends ko.Observable<T> {
update(obj: T): void;
prop(propName: string): ko.Observable<any>;
}
interface SaveableObjObservable<T> extends ko.Observable<T>, SaveInterface<T> {
update(obj: T): void;
prop(propName: string): KoSaveableObservable<any>;
}
function objObservable<T>(obs: ko.Observable<T>): ObjObservable<T>;
function jsonObservable(obs: KoSaveableObservable<string>,
modifierFunc?: any, optContext?: any): SaveableObjObservable<any>;
function jsonObservable(obs: ko.Observable<string>|ko.Computed<string>,
modifierFunc?: any, optContext?: any): ObjObservable<any>;
function fieldWithDefault<T>(fieldObs: KoSaveableObservable<T>, defaultOrFunc: T | (() => T)):
KoSaveableObservable<T>;
function customValue<T>(obs: KoSaveableObservable<T>): CustomComputed<T>;
function savingComputed<T>(options: {
read: () => T,
write: (setter: (obs: ko.Observable<T>, val: T) => void, val: T) => void;
}): KoSaveableObservable<T>;
function customComputed<T>(options: {
read: () => T,
save?: (val: T) => Promise<void>;
}): CustomComputed<T>;
function setSaveValue<T>(obs: KoSaveableObservable<T>, val: T): Promise<void>;
}
declare module "app/client/models/TableModel" {
import {DocModel} from "app/client/models/DocModel";
import {RowGrouping, RowSource} from "app/client/models/rowset";
import {TableData} from "app/client/models/TableData";
import {CellValue, UserAction} from "app/common/DocActions";
namespace TableModel {}
class TableModel extends RowSource {
public docModel: DocModel;
public tableData: TableData;
public isLoaded: ko.Observable<boolean>;
constructor(docModel: DocModel, tableData: TableData);
public fetch(force?: boolean): Promise<void>;
public getAllRows(): ReadonlyArray<number>;
public getRowGrouping(groupByCol: string): RowGrouping<CellValue>;
public sendTableActions(actions: UserAction[], optDesc?: string): Promise<any[]>;
public sendTableAction(action: UserAction, optDesc?: string): Promise<any> | undefined;
}
export = TableModel;
}
declare module "app/client/models/MetaTableModel" {
import {KoArray} from "app/client/lib/koArray";
import {DocModel} from "app/client/models/DocModel";
import * as MetaRowModel from "app/client/models/MetaRowModel";
import {RowSource} from "app/client/models/rowset";
import {TableData} from "app/client/models/TableData";
import * as TableModel from "app/client/models/TableModel";
import {CellValue} from "app/common/DocActions";
namespace MetaTableModel {}
class MetaTableModel<RowModel extends MetaRowModel> extends TableModel {
public rowModels: RowModel[];
constructor(docModel: DocModel, tableData: TableData, fields: string[], rowConstructor: (dm: DocModel) => void);
public loadData(): void;
public getRowModel(rowId: number, dependOnVersion?: boolean): RowModel;
public getEmptyRowModel(): RowModel;
public createFloatingRowModel(rowIdObs: ko.Observable<number>|ko.Computed<number>): RowModel;
public createRowGroupModel(groupValue: CellValue, options: {groupBy: string, sortBy: string}): KoArray<RowModel>;
public createAllRowsModel(sortColId: string): KoArray<RowModel>;
public _createRowSetModel(rowSource: RowSource, sortColId: string): KoArray<RowModel>;
}
export = MetaTableModel;
}
declare module "app/client/models/DataTableModel" {
import {KoArray} from "app/client/lib/koArray";
import * as BaseRowModel from "app/client/models/BaseRowModel";
import {DocModel, TableRec} from "app/client/models/DocModel";
import {TableQuerySets} from 'app/client/models/QuerySet';
import {RowSource, SortedRowSet} from "app/client/models/rowset";
import {TableData} from "app/client/models/TableData";
import * as TableModel from "app/client/models/TableModel";
import {CellValue} from "app/common/DocActions";
namespace DataTableModel {
interface LazyArrayModel<T> extends KoArray<T | null> {
getRowId(index: number): number;
getRowIndex(index: number): number;
getRowIndexWithSub(rowId: number): number;
getRowModel(rowId: number): T|undefined;
}
}
class DataTableModel extends TableModel {
public tableMetaRow: TableRec;
public tableQuerySets: TableQuerySets;
constructor(docModel: DocModel, tableData: TableData, tableMetaRow: TableRec);
public createLazyRowsModel(sortedRowSet: SortedRowSet, optRowModelClass: any):
DataTableModel.LazyArrayModel<BaseRowModel>;
public createFloatingRowModel(optRowModelClass: any): BaseRowModel;
}
export = DataTableModel;
}
declare module "app/client/lib/koUtil" {
export interface ComputedWithKoUtils<T> extends ko.Computed<T> {
onlyNotifyUnequal(): this;
}
export interface ObservableWithKoUtils<T> extends ko.Observable<T> {
assign(value: unknown): this;
}
export function withKoUtils<T>(computed: ko.Computed<T>): ComputedWithKoUtils<T>;
export function withKoUtils<T>(computed: ko.Observable<T>): ObservableWithKoUtils<T>;
export function computedBuilder(callback: any, optContext: any): any;
export function observableWithDefault(obs: any, defaultOrFunc: any, optContext?: any): any;
export function computedAutoDispose(optionsOrReadFunc: any, target: any, options: any): any;
}
// Used in browser check. Bowser does in fact have types, but not the bundled version
// with polyfills for old browsers.
declare module "bowser/bundled";
declare module "randomcolor";

View File

@ -0,0 +1,13 @@
/* global window */
// These modules are exposed for the sake of browser tests.
Object.assign(window.exposedModules, {
dom: require('./lib/dom'),
grainjs: require('grainjs'),
ko: require('knockout'),
moment: require('moment-timezone'),
Comm: require('./components/Comm'),
ProfileForm: require('./components/ProfileForm'),
_loadScript: require('./lib/loadScript'),
ConnectState: require('./models/ConnectState'),
});

View File

@ -1,34 +0,0 @@
import {PageContents, pagePanels} from 'app/client/ui/PagePanels';
import {cssRootVars} from 'app/client/ui2018/cssVars';
import {dom, observable, styled} from "grainjs";
function renderPage(): Element {
const leftPanelOpen = observable(true);
const page: PageContents = {
leftPanel: {
panelWidth: observable<number>(240),
panelOpen: leftPanelOpen,
hideOpener: false,
header: testContent('LEFT HEADER'),
content: testContent('LEFT PANEL'),
},
rightPanel: {
panelWidth: observable<number>(240),
panelOpen: observable(true),
header: testContent('RIGHT HEADER'),
content: testContent('RIGHT PANEL'),
},
headerMain: testContent('Header'),
contentMain: testContent('Welcome to a tiny bit of Grist'),
};
return pagePanels(page);
}
const testContent = styled('div', `
padding: 5px;
text-align: center;
flex: 1 1 0px;
`);
// Load icons.css, wait for it to load, then build the page.
dom.update(document.body, dom.cls(cssRootVars), renderPage());

250
app/client/lib/ACIndex.ts Normal file
View File

@ -0,0 +1,250 @@
/**
* A search index for auto-complete suggestions.
*
* This implementation indexes words, and suggests items based on a best-match score, including
* amount of overlap and position of words. It searches case-insensitively and only at the start
* of words. E.g. searching for "Blue" would match "Blu" in "Lavender Blush", but searching for
* "lush" would only match the "L" in "Lavender".
*/
import {nativeCompare, sortedIndex} from 'app/common/gutil';
import {DomContents} from 'grainjs';
export interface ACItem {
// This should be a trimmed lowercase version of the item's text. It may be an accessor.
// Note that items with empty cleanText are never suggested.
cleanText: string;
}
// Regexp used to split text into words; includes nearly all punctuation. This means that
// "foo-bar" may be searched by "bar", but it's impossible to search for punctuation itself (e.g.
// "a-b" and "a+b" are not distinguished). (It's easy to exclude unicode punctuation too if the
// need arises, see https://stackoverflow.com/a/25575009/328565).
const wordSepRegexp = /[\s!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]+/;
/**
* An auto-complete index, which simply allows searching for a string.
*/
export interface ACIndex<Item extends ACItem> {
search(searchText: string): ACResults<Item>;
}
// Splits text into an array of pieces, with odd-indexed pieces being the ones to highlight.
export type HighlightFunc = (text: string) => string[];
export const highlightNone: HighlightFunc = (text) => [text];
/**
* AutoComplete results include the suggested items, which one to highlight, and a function for
* highlighting the matched portion of each item.
*/
export interface ACResults<Item extends ACItem> {
// Matching items in order from best match to worst.
items: Item[];
// May be used to highlight matches using buildHighlightedDom().
highlightFunc: HighlightFunc;
// index of a good match (normally 0), or -1 if no great match
selectIndex: number;
}
interface Word {
word: string; // The indexed word
index: number; // Index into _allItems for the item containing this word.
pos: number; // Position of the word within the item where it occurred.
}
/**
* Implements a search index. It doesn't currently support updates; when any values change, the
* index needs to be rebuilt from scratch.
*/
export class ACIndexImpl<Item extends ACItem> implements ACIndex<Item> {
private _allItems: Item[];
// All words from _allItems, sorted.
private _words: Word[];
// Creates an index for the given list of items.
// The max number of items to suggest may be set using _maxResults (default is 50).
constructor(items: Item[], private _maxResults: number = 50) {
this._allItems = items.slice(0);
// Collects [word, occurrence, position] tuples for all words in _allItems.
const allWords: Word[] = [];
for (let index = 0; index < this._allItems.length; index++) {
const item = this._allItems[index];
const words = item.cleanText.split(wordSepRegexp).filter(w => w);
for (let pos = 0; pos < words.length; pos++) {
allWords.push({word: words[pos], index, pos});
}
}
allWords.sort((a, b) => nativeCompare(a.word, b.word));
this._words = allWords;
}
// The main search function. SearchText will be cleaned (trimmed and lowercased) at the start.
// Empty search text returns the first N items in the search universe.
public search(searchText: string): ACResults<Item> {
const cleanedSearchText = searchText.trim().toLowerCase();
const searchWords = cleanedSearchText.split(wordSepRegexp).filter(w => w);
// Maps item index in _allItems to its score.
const myMatches = new Map<number, number>();
if (searchWords.length > 0) {
// For each of searchWords, go through items with an overlap, and update their scores.
for (let k = 0; k < searchWords.length; k++) {
const searchWord = searchWords[k];
for (const [itemIndex, score] of this._findOverlaps(searchWord, k)) {
myMatches.set(itemIndex, (myMatches.get(itemIndex) || 0) + score);
}
}
// Give an extra point to items that start with the searchText.
for (const [itemIndex, score] of myMatches) {
if (this._allItems[itemIndex].cleanText.startsWith(cleanedSearchText)) {
myMatches.set(itemIndex, score + 1);
}
}
}
// Array of pairs [itemIndex, score], sorted by score (desc) and itemIndex.
const sortedMatches = Array.from(myMatches)
.sort((a, b) => nativeCompare(b[1], a[1]) || nativeCompare(a[0], b[0]))
.slice(0, this._maxResults);
const items: Item[] = sortedMatches.map(([index, score]) => this._allItems[index]);
// Append enough non-matching items to reach maxResults.
for (let i = 0; i < this._allItems.length && items.length < this._maxResults; i++) {
if (this._allItems[i].cleanText && !myMatches.has(i)) {
items.push(this._allItems[i]);
}
}
if (!cleanedSearchText) {
// In this case we are just returning the first few items.
return {items, highlightFunc: highlightNone, selectIndex: -1};
}
const highlightFunc = highlightMatches.bind(null, searchWords);
// The best match is the first item. If it actually starts with the search text, AND has a
// strictly better score than other items, highlight it as a default selection. Otherwise, no
// item will be auto-selected.
let selectIndex = -1;
if (items.length > 0 && items[0].cleanText.startsWith(cleanedSearchText) &&
(sortedMatches.length <= 1 || sortedMatches[1][1] < sortedMatches[0][1])) {
selectIndex = 0;
}
return {items, highlightFunc, selectIndex};
}
/**
* Given one of the search words, looks it up in the indexed list of words and searches up and
* down the list for all words that share a prefix with it. Each such word contributes something
* to the score of the index entry it is a part of.
*
* Returns a Map from the index entry (index into _allItems) to the score which this searchWord
* contributes to it.
*
* The searchWordPos argument is the position of searchWord in the overall search text (e.g. 0
* if it's the first word). It is used for the position bonus, to give higher scores to entries
* whose words occur in the same order as in the search text.
*/
private _findOverlaps(searchWord: string, searchWordPos: number): Map<number, number> {
const insertIndex = sortedIndex<{word: string}>(this._words, {word: searchWord},
(a, b) => nativeCompare(a.word, b.word));
// Maps index of item to its score.
const scored = new Map<number, number>();
// Search up and down the list, accepting smaller and smaller overlap.
for (const step of [1, -1]) {
let prefix = searchWord;
let index = insertIndex + (step > 0 ? 0 : -1);
while (prefix && index >= 0 && index < this._words.length) {
for ( ; index >= 0 && index < this._words.length; index += step) {
const wordEntry = this._words[index];
// Once we reach a word that doesn't start with our prefix, break this loop, so we can
// reduce the length of the prefix and keep scanning.
if (!wordEntry.word.startsWith(prefix)) { break; }
// The contribution of this word's to the score consists primarily of the length of
// overlap (i.e. length for the current prefix).
const baseScore = prefix.length;
// To this we add 1 if the word matches exactly.
const fullWordBonus = (wordEntry.word === searchWord ? 1 : 0);
// To prefer matches where words occur in the same order as searched (e.g. searching for
// "Foo B" should prefer "Foo Bar" over "Bar Foo"), we give a bonus based on the
// position of the word in the search text and the entry text. (If positions match as
// 0:0 and 1:1, the total position bonus is 2^0+2^(-2)=1.25; while the bonus from 0:1
// and 1:0 would be 2^(-1) + 2^(-1)=1.0.)
const positionBonus = Math.pow(2, -(searchWordPos + wordEntry.pos));
const itemScore = baseScore + fullWordBonus + positionBonus;
// Each search word contributes only one score (e.g. a search for "Foo" will partially
// match both words in "forty five", but only the higher of the matches will count).
if (itemScore >= (scored.get(wordEntry.index) || 0)) {
scored.set(wordEntry.index, itemScore);
}
}
prefix = prefix.slice(0, -1);
}
}
return scored;
}
}
export type BuildHighlightFunc = (match: string) => DomContents;
/**
* Converts text to DOM with matching bits of text rendered using highlight(match) function.
*/
export function buildHighlightedDom(
text: string, highlightFunc: HighlightFunc, highlight: BuildHighlightFunc
): DomContents {
if (!text) { return text; }
const parts = highlightFunc(text);
return parts.map((part, k) => k % 2 ? highlight(part) : part);
}
// Same as wordSepRegexp, but with capturing parentheses.
const wordSepRegexpParen = new RegExp(`(${wordSepRegexp.source})`);
/**
* Splits text into pieces, with odd-numbered pieces the ones matching a prefix of some
* searchWord, i.e. the ones to highlight.
*/
function highlightMatches(searchWords: string[], text: string): string[] {
const textParts = text.split(wordSepRegexpParen);
const outputs = [''];
for (let i = 0; i < textParts.length; i += 2) {
const word = textParts[i];
const separator = textParts[i + 1] || '';
const prefixLen = findLongestPrefixLen(word.toLowerCase(), searchWords);
if (prefixLen === 0) {
outputs[outputs.length - 1] += word + separator;
} else {
outputs.push(word.slice(0, prefixLen), word.slice(prefixLen) + separator);
}
}
return outputs;
}
function findLongestPrefixLen(text: string, choices: string[]): number {
return choices.reduce((max, choice) => Math.max(max, findCommonPrefixLength(text, choice)), 0);
}
function findCommonPrefixLength(text1: string, text2: string): number {
let i = 0;
while (i < text1.length && text1[i] === text2[i]) { ++i; }
return i;
}

View File

@ -0,0 +1,47 @@
import { SafeBrowser, ViewProcess } from 'app/client/lib/SafeBrowser';
import { PluginInstance } from 'app/common/PluginInstance';
export { ViewProcess } from 'app/client/lib/SafeBrowser';
/**
* A PluginCustomSection identifies one custom section in a plugin.
*/
export interface PluginCustomSection {
pluginId: string;
sectionId: string;
}
export class CustomSectionElement {
/**
* Get the list of all available custom sections in all plugins' contributions.
*/
public static getSections(plugins: PluginInstance[]): PluginCustomSection[] {
return plugins.reduce<PluginCustomSection[]>((acc, plugin) => {
const customSections = plugin.definition.manifest.contributions.customSections;
const pluginId = plugin.definition.id;
if (customSections) {
// collect identifiers
const sectionIds = customSections.map(section => ({sectionId: section.name, pluginId}));
// concat to the accumulator
return acc.concat(sectionIds);
}
return acc;
}, []);
}
/**
* Find a section matching sectionName in the plugin instances' constributions and returns
* it. Returns `undefined` if not found.
*/
public static find(plugin: PluginInstance, sectionName: string): ViewProcess|undefined {
const customSections = plugin.definition.manifest.contributions.customSections;
if (customSections) {
const section = customSections.find(({ name }) => name === sectionName);
if (section) {
const safeBrowser = plugin.safeBrowser as SafeBrowser;
return safeBrowser.createViewProcess(section.path);
}
}
}
}

91
app/client/lib/Delay.ts Normal file
View File

@ -0,0 +1,91 @@
/**
* A little class to make it easier to work with setTimeout/clearTimeout when it may need to get
* cancelled or rescheduled.
*/
import {Disposable} from 'app/client/lib/dispose';
export class Delay extends Disposable {
/**
* Returns a function which will schedule a call to cb(), forwarding the arguments.
* This is a static method that may be used without a Delay object.
* E.g. wrapWithDelay(10, cb)(1,2,3) will call cb(1,2,3) in 10ms.
*/
public static wrapWithDelay(ms: number, cb: (this: void, ...args: any[]) => any,
optContext?: any): (...args: any[]) => void;
public static wrapWithDelay<T>(ms: number, cb: (this: T, ...args: any[]) => any,
optContext: T): (...args: any[]) => void {
return function(this: any, ...args: any[]) {
const ctx = optContext || this;
setTimeout(() => cb.apply(ctx, args), ms);
};
}
/**
* Returns a wrapped callback whose execution is delayed until the next animation frame. The
* returned callback may be disposed to cancel the delayed execution.
*/
public static untilAnimationFrame(cb: (this: void, ...args: any[]) => void,
optContext?: any): DisposableCB;
public static untilAnimationFrame<T>(cb: (this: T, ...args: any[]) => void,
optContext: T): DisposableCB {
let reqId: number|null = null;
const f = function(...args: any[]) {
if (reqId === null) {
reqId = window.requestAnimationFrame(() => {
reqId = null;
cb.apply(optContext, args);
});
}
};
f.dispose = function() {
if (reqId !== null) {
window.cancelAnimationFrame(reqId);
}
};
return f;
}
private _timeoutId: ReturnType<typeof setTimeout> | null = null;
public create() {
this.autoDisposeCallback(this.cancel);
}
/**
* If there is a scheduled callback, clear it.
*/
public cancel() {
if (this._timeoutId !== null) {
clearTimeout(this._timeoutId);
this._timeoutId = null;
}
}
/**
* Returns whether there is a scheduled callback.
*/
public isPending() {
return this._timeoutId !== null;
}
/**
* Schedule a new callback, to be called in ms milliseconds, optionally bound to the passed-in
* arguments. If another callback was scheduled, it is cleared first.
*/
public schedule(ms: number, cb: (this: void, ...args: any[]) => any, optContext?: any, ...optArgs: any[]): void;
public schedule<T>(ms: number, cb: (this: T, ...args: any[]) => any, optContext: T, ...optArgs: any[]): void {
this.cancel();
this._timeoutId = setTimeout(() => {
this._timeoutId = null;
cb.apply(optContext, optArgs);
}, ms);
}
}
export interface DisposableCB {
(...args: any[]): void;
dispose(): void;
}

View File

@ -0,0 +1,66 @@
import {ClientScope} from 'app/client/components/ClientScope';
import {SafeBrowser} from 'app/client/lib/SafeBrowser';
import {ActiveDocAPI} from 'app/common/ActiveDocAPI';
import {LocalPlugin} from 'app/common/plugin';
import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance';
import {Rpc} from 'grain-rpc';
/**
* DocPluginManager's Client side implementation.
*/
export class DocPluginManager {
public pluginsList: PluginInstance[];
constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI,
private _clientScope: ClientScope) {
this.pluginsList = [];
for (const plugin of localPlugins) {
try {
const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`));
const components = plugin.manifest.components || {};
const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance,
this._clientScope, this._untrustedContentOrigin, components.safeBrowser);
if (components.safeBrowser) {
pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser);
}
// Forward calls to the server, if no matching forwarder.
pluginInstance.rpc.registerForwarder('*', {
forwardCall: (call) => this._docComm.forwardPluginRpc(plugin.id, call),
forwardMessage: (msg) => this._docComm.forwardPluginRpc(plugin.id, msg),
});
this.pluginsList.push(pluginInstance);
} catch (err) {
console.error( // tslint:disable-line:no-console
`DocPluginManager: failed to instantiate ${plugin.id}: ${err.message}`);
}
}
}
/**
* `receiveAction` handles an action received from the server by forwarding it to all safe browser component.
*/
public receiveAction(action: any[]) {
for (const plugin of this.pluginsList) {
const safeBrowser = plugin.safeBrowser as SafeBrowser;
if (safeBrowser) {
safeBrowser.receiveAction(action);
}
}
}
/**
* Make an Rpc object to call server methods from a url-flavored custom view.
*/
public makeAnonForwarder() {
const rpc = new Rpc({});
rpc.queueOutgoingUntilReadyMessage();
rpc.registerForwarder('*', {
forwardCall: (call) => this._docComm.forwardPluginRpc("builtIn/core", call),
forwardMessage: (msg) => this._docComm.forwardPluginRpc("builtIn/core", msg),
});
return rpc;
}
}

View File

@ -0,0 +1,35 @@
import {PluginInstance} from 'app/common/PluginInstance';
import {InternalImportSourceAPI} from 'app/plugin/InternalImportSourceAPI';
import {ImportSource} from 'app/plugin/PluginManifest';
import {checkers} from 'app/plugin/TypeCheckers';
/**
* Encapsulate together an import source contribution with its plugin instance and a callable stub
* for the ImportSourceAPI. Exposes as well a `fromArray` static method to get all the import
* sources from an array of plugins instances.
*/
export class ImportSourceElement {
/**
* Get all import sources from an array of plugin instances.
*/
public static fromArray(pluginInstances: PluginInstance[]): ImportSourceElement[] {
const importSources: ImportSourceElement[] = [];
for (const plugin of pluginInstances) {
const definitions = plugin.definition.manifest.contributions.importSources;
if (definitions) {
for (const importSource of definitions) {
importSources.push(new ImportSourceElement(plugin, importSource));
}
}
}
return importSources;
}
public importSourceStub: InternalImportSourceAPI;
private constructor(public plugin: PluginInstance, public importSource: ImportSource) {
this.importSourceStub = plugin.getStub<InternalImportSourceAPI>(importSource.importSource,
checkers.InternalImportSourceAPI);
}
}

View File

@ -0,0 +1,67 @@
/**
* This file adds some includes tweaks to the behavior of Mousetrap.js, the keyboard bindings
* library. It exports the mousetrap library itself, so you may use it in mousetrap's place.
*/
/* global document */
if (typeof window === 'undefined') {
// We can't require('mousetrap') in a browserless environment (specifically for unittests)
// because it uses global variables right on require, which are not available with jsdom.
// So to use mousetrap in unittests, we need to stub it out.
module.exports = {
bind: function() {},
unbind: function() {},
};
} else {
var Mousetrap = require('mousetrap');
var ko = require('knockout');
// Minus is different on Gecko:
// see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode
// and https://github.com/ccampbell/mousetrap/pull/215
Mousetrap.addKeycodes({173: '-'});
var MousetrapProtype = Mousetrap.prototype;
var origStopCallback = MousetrapProtype.stopCallback;
/**
* Enhances Mousetrap's stopCallback filter. Normally, mousetrap ignores key events in input
* fields and textareas. This replacement allows individual CommandGroups to be activated in such
* elements. See also 'attach' method of commands.CommandGroup.
*/
MousetrapProtype.stopCallback = function(e, element, combo, sequence) {
if (mousetrapBindingsPaused) {
return true;
}
var cmdGroup = ko.utils.domData.get(element, 'mousetrapCommandGroup');
if (cmdGroup) {
return !cmdGroup.knownKeys.hasOwnProperty(combo);
}
try {
return origStopCallback.call(this, e, element, combo, sequence);
} catch (err) {
if (!document.body.contains(element)) {
// Mousetrap throws a pointless error in this case, which we ignore. It happens when
// element gets removed by a non-mousetrap keyboard handler.
return;
}
throw err;
}
};
var mousetrapBindingsPaused = false;
/**
* Globally pause or unpause mousetrap bindings. This is useful e.g. while a context menu is being
* shown, which has its own keyboard handling.
*/
Mousetrap.setPaused = function(yesNo) {
mousetrapBindingsPaused = yesNo;
};
module.exports = Mousetrap;
}

View File

@ -0,0 +1,155 @@
var ko = require('knockout');
var dispose = require('./dispose');
/**
* ObservableMap provides a structure to keep track of values that need to recalculate in
* response to a key change or a mapping function change.
*
* @example
* let factor = ko.observable(2);
* let myFunc = ko.computed(() => {
* let f = factor();
* return (keyId) => key * f;
* });
*
* let myMap = ObservableMap.create(myFunc);
* let inObs1 = ko.observable(2);
* let inObs2 = ko.observable(3);
*
* let outObs1 = myMap.add(inObs1);
* let outObs2 = myMap.add(inObs2);
* outObs1(); // 4
* outObs2(); // 6
*
* inObs1(5);
* outObs1(); // 10
*
* factor(3);
* outObs1(); // 15
* outObs2(); // 9
*
*
* @param {Function} mapFunc - Computed that returns a mapping function that takes in a key and
* returns a value. Whenever `mapFunc` is updated, all the current values in the map will be
* recalculated using the new function.
*/
function ObservableMap(mapFunc) {
this.store = new Map();
this.mapFunc = mapFunc;
// Recalculate all values on changes to mapFunc
let mapFuncSub = mapFunc.subscribe(() => {
this.updateAll();
});
// Disposes all stored observable and clears the map.
this.autoDisposeCallback(() => {
// Unsbuscribe from mapping function
mapFuncSub.dispose();
// Clear the store
this.store.forEach((val, key) => val.forEach(obj => obj.dispose()));
this.store.clear();
});
}
dispose.makeDisposable(ObservableMap);
/**
* Takes an observable for the key value and returns an observable for the output.
* Subscribes to the given observable so that whenever it changes the output observable is
* updated to the value returned by `mapFunc` when provided the new key as input.
* If user disposes of the returned observable, it will be removed from the map.
*
* @param {ko.observable} obsKey
* @return {ko.observble} Observable value equal to `mapFunc(obsKey())` that will be updated on
* updates to `obsKey` and `mapFunc`.
*/
ObservableMap.prototype.add = function (obsKey) {
let currKey = obsKey();
let ret = ko.observable(this.mapFunc()(currKey));
// Add to map
this._addKeyValue(currKey, ret);
// Subscribe to changes to key
let subs = obsKey.subscribe(newKey => {
ret(this.mapFunc()(newKey));
if (currKey !== newKey) {
// If the key changed, add it to the new bucket and delete from the old one
this._addKeyValue(newKey, ret);
this._delete(currKey, ret);
// And update the key
currKey = newKey;
}
});
ret.dispose = () => {
// On dispose, delete from map unless the whole map is being disposed
if (!this.isDisposed()) {
this._delete(currKey, ret);
}
subs.dispose();
};
return ret;
};
/**
* Returns the Set of observable values for the given key.
*/
ObservableMap.prototype.get = function (key) {
return this.store.get(key);
};
ObservableMap.prototype._addKeyValue = function (key, value) {
if (!this.store.has(key)) {
this.store.set(key, new Set([value]));
} else {
this.store.get(key).add(value);
}
};
/**
* Triggers an update for all keys.
*/
ObservableMap.prototype.updateAll = function () {
this.store.forEach((val, key) => this.updateKey(key));
};
/**
* Triggers an update for all observables for given keys in the map.
* @param {Array} keys
*/
ObservableMap.prototype.updateKeys = function (keys) {
keys.forEach(key => this.updateKey(key));
};
/**
* Triggers an update for all observables for the given key in the map.
* @param {Any} key
*/
ObservableMap.prototype.updateKey = function (key) {
if (this.store.has(key) && this.store.get(key).size > 0) {
this.store.get(key).forEach(obj => {
obj(this.mapFunc()(key));
});
}
};
/**
* Given a key and an observable, deletes the observable from that key's bucket.
*
* @param {Any} key - Current value of the key.
* @param {Any} obsValue - An observable previously returned by `add`.
*/
ObservableMap.prototype._delete = function (key, obsValue) {
if (this.store.has(key) && this.store.get(key).size > 0) {
this.store.get(key).delete(obsValue);
// Clean up empty buckets
if (this.store.get(key).size === 0) {
this.store.delete(key);
}
}
};
module.exports = ObservableMap;

View File

@ -0,0 +1,74 @@
var _ = require('underscore');
var ko = require('knockout');
var dispose = require('./dispose');
/**
* An ObservableSet keeps track of a set of values whose membership is controlled by a boolean
* observable.
* @property {ko.observable<Number>} count: Count of items that are currently included.
*/
function ObservableSet() {
this._items = {};
this.count = ko.observable(0);
}
dispose.makeDisposable(ObservableSet);
/**
* Adds an item to keep track of. The value is added to the set whenever isIncluded observable is
* true. To stop keeping track of this item, call dispose() on the returned object.
*
* @param {ko.observable<Boolean>} isIncluded: observable for whether to include the value.
* @param {Object} value: Arbitrary value. May be omitted if you only care about the count.
* @return {Object} Object with dispose() method, which can be called to unsubscribe from
* isIncluded, and remove the value from the set.
*/
ObservableSet.prototype.add = function(isIncluded, value) {
var uniqueKey = _.uniqueId();
var sub = this.autoDispose(isIncluded.subscribe(function(include) {
if (include) {
this._add(uniqueKey, value);
} else {
this._remove(uniqueKey);
}
}, this));
if (isIncluded.peek()) {
this._add(uniqueKey, value);
}
return {
dispose: function() {
this._remove(uniqueKey);
this.disposeDiscard(sub);
}.bind(this)
};
};
/**
* Returns an array of all the values that are currently included in the set.
*/
ObservableSet.prototype.all = function() {
return _.values(this._items);
};
/**
* Internal helper to add a value to the set.
*/
ObservableSet.prototype._add = function(key, value) {
if (!this._items.hasOwnProperty(key)) {
this._items[key] = value;
this.count(this.count() + 1);
}
};
/**
* Internal helper to remove a value from the set.
*/
ObservableSet.prototype._remove = function(key) {
if (this._items.hasOwnProperty(key)) {
delete this._items[key];
this.count(this.count() - 1);
}
};
module.exports = ObservableSet;

View File

@ -0,0 +1,331 @@
/**
* The SafeBrowser component implementation is responsible for executing the safeBrowser component
* of a plugin.
*
* A plugin's safeBrowser component is made of one main entry point (the javascript files declares
* in the manifest), html files and any ressources included by the html files (css, scripts, images
* ...). The main script is the main entry point which uses the Grist API to render the views,
* communicate with them en dispose them.
*
* The main script is executed within a WebWorker, and the html files are rendered within webviews
* if run within electron, or iframe in case of the browser.
*
* Communication between the main process and the views are handle with rpc.
*
* If the plugins includes as well an unsafeNode component or a safePython component and if one of
* them registers a function using the Grist Api, this function can then be called from within the
* safeBrowser main script using the Grist API, as described in `app/plugin/Grist.ts`.
*
* The grist API available to safeBrowser components is implemented in `app/plugin/PluginImpl.ts`.
*
* All the safeBrowser's component ressources, including the main script, the html files and any
* other ressources needed by the views, should be placed within one plugins' subfolder, and Grist
* should serve only this folder. However, this is not yet implemented and is left as a TODO, as of
* now the whole plugin's folder is served.
*
*/
// Todo: plugin ressources should not be made available on the server by default, but only after
// activation.
// tslint:disable:max-classes-per-file
import { ClientScope } from 'app/client/components/ClientScope';
import { get as getBrowserGlobals } from 'app/client/lib/browserGlobals';
import * as dom from 'app/client/lib/dom';
import * as Mousetrap from 'app/client/lib/Mousetrap';
import { ActionRouter } from 'app/common/ActionRouter';
import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance';
import { tbind } from 'app/common/tbind';
import { getOriginUrl } from 'app/common/urlUtils';
import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI';
import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions';
import { checkers } from 'app/plugin/TypeCheckers';
import { IpcMessageEvent, WebviewTag } from 'electron';
import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc';
import { Disposable } from './dispose';
const G = getBrowserGlobals('document', 'window');
/**
* The SafeBrowser component implementation. Responsible for running the script, rendering the
* views, settings up communication channel.
*/
// todo: it is unfortunate that SafeBrowser had to expose both `renderImpl` and `disposeImpl` which
// really have no business outside of this module. What could be done, is to have an internal class
// ProcessManager which will be created by SafeBrowser as a private field. It will manage the
// client processes and among other thing will expose both renderImpl and
// disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser.
export class SafeBrowser extends BaseComponent {
/**
* Create a webview ClientProcess to render safe browser process in electron.
*/
public static createWorker(safeBrowser: SafeBrowser, rpc: Rpc, src: string): WorkerProcess {
return new WorkerProcess(safeBrowser, rpc, src);
}
/**
* Create either an iframe or a webview ClientProcess depending on wether running electron or not.
*/
public static createView(safeBrowser: SafeBrowser, rpc: Rpc, src: string): ViewProcess {
return G.window.isRunningUnderElectron ?
new WebviewProcess(safeBrowser, rpc, src) :
new IframeProcess(safeBrowser, rpc, src);
}
// All view processes. This is not used anymore to dispose all processes on deactivation (this is
// now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch
// events to all processes (such as doc actions which will need soon).
private _viewProcesses: Map<number, ClientProcess> = new Map();
private _pluginId: string;
private _pluginRpc: Rpc;
private _mainProcess: WorkerProcess|undefined;
private _viewCount: number = 0;
constructor(
private _plugin: PluginInstance,
private _clientScope: ClientScope,
private _untrustedContentOrigin: string,
private _mainPath: string = "",
private _baseLogger: BaseLogger = console,
rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`),
) {
super(_plugin.definition.manifest, rpcLogger);
this._pluginId = _plugin.definition.id;
this._pluginRpc = _plugin.rpc;
}
/**
* Render the file at path in an iframe or webview and returns its ViewProcess.
*/
public createViewProcess(path: string): ViewProcess {
return this._createViewProcess(path)[0];
}
/**
* `receiveAction` handles an action received from the server by forwarding it to the view processes.
*/
public receiveAction(action: any[]) {
for (const view of this._viewProcesses.values()) {
view.receiveAction(action);
}
}
/**
* Renders the file at path and returns its proc id. This is the SafeBrowser implementation for
* the GristAPI's render(...) method, more details can be found at app/plugin/GristAPI.ts.
*/
public async renderImpl(path: string, target: RenderTarget, options: RenderOptions): Promise<number> {
const [proc, viewId] = this._createViewProcess(path);
const renderFunc = this._plugin.getRenderTarget(target, options);
renderFunc(proc.element);
if (this._mainProcess) {
// Disposing the web worker should dispose all view processes that created using the
// gristAPI. There is a flaw here: please read [1].
this._mainProcess.autoDispose(proc);
}
return viewId;
// [1]: When a process, which is not owned by the mainProcess (ie: a process which was created
// using `public createViewProcess(...)'), creates a view process using the gristAPI, the
// rendered view will be owned by the main process. This is not correct and could cause views to
// suddently disappear from the screen. This is pretty nasty. But for following reason I think
// it's ok to leave it for now: (1) fixing this would require (yet) another refactoring of
// SafeBrowser and (2) at this point it is not sure wether we want to keep `render()` in the
// future (we could as well directly register contribution using files directly in the
// manifest), and (3) plugins are only developped by us, we only have to remember that using
// `render()` is only supported from within the main process (which cover all our use cases so
// far).
}
/**
* Dispose the process using it's proc id. This is the SafeBrowser implementation for the
* GristAPI's dispose(...) method, more details can be found at app/plugin/GristAPI.ts.
*/
public async disposeImpl(procId: number): Promise<void> {
const proc = this._viewProcesses.get(procId);
if (proc) {
this._viewProcesses.delete(procId);
proc.dispose();
}
}
protected doForwardCall(c: IMsgRpcCall): Promise<any> {
if (this._mainProcess) {
return this._mainProcess.rpc.forwardCall(c);
}
// should not happen.
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
}
protected doForwardMessage(c: IMsgCustom): Promise<any> {
if (this._mainProcess) {
return this._mainProcess.rpc.forwardMessage(c);
}
// should not happen.
throw new Error("Using SafeBrowser as an IForwarder requires a main script");
}
protected async activateImplementation(): Promise<void> {
if (this._mainPath) {
const rpc = this._createRpc(this._mainPath);
const src = `plugins/${this._pluginId}/${this._mainPath}`;
// This SafeBrowser object is registered with _pluginRpc as _mainPath forwarder, and
// forwards calls to _mainProcess in doForward* methods (called from BaseComponent.forward*
// methods). Note that those calls are what triggers component activation.
this._mainProcess = SafeBrowser.createWorker(this, rpc, src);
}
}
protected async deactivateImplementation(): Promise<void> {
if (this._mainProcess) {
this._mainProcess.dispose();
}
}
/**
* Creates an iframe or a webview embedding the file at path. And adds it to `this._viewProcesses`
* using `viewId` as key, and registers it as forwarder to the `pluginRpc` using name
* `path`. Unregister both on disposal.
*/
private _createViewProcess(path: string): [ViewProcess, number] {
const rpc = this._createRpc(path);
const url = `${this._untrustedContentOrigin}/plugins/${this._plugin.definition.id}/${path}`
+ `?host=${G.window.location.origin}`;
const viewId = this._viewCount++;
const process = SafeBrowser.createView(this, rpc, url);
this._viewProcesses.set(viewId, process);
this._pluginRpc.registerForwarder(path, rpc);
process.autoDisposeCallback(() => {
this._pluginRpc.unregisterForwarder(path);
this._viewProcesses.delete(viewId);
});
return [process, viewId];
}
/**
* Create an rpc instance and set it up for communicating with a ClientProcess:
* - won't send any message before receiving a ready message
* - has the '*' forwarder set to the plugin's instance rpc
* - has registered an implementation of the gristAPI.
* Returns the rpc instance.
*/
private _createRpc(path: string): Rpc {
const rpc = new Rpc({logger: createRpcLogger(this._baseLogger, `PLUGIN ${this._pluginId}/${path} SafeBrowser:`) });
rpc.queueOutgoingUntilReadyMessage();
warnIfNotReady(rpc, 3000, "Plugin isn't ready; be sure to call grist.ready() from plugin");
rpc.registerForwarder('*', this._pluginRpc);
// TODO: we should be able to stop serving plugins, it looks like there are some resources
// required that should be disposed on component deactivation.
this._clientScope.servePlugin(this._pluginId, rpc);
return rpc;
}
}
/**
* Base class for any client process. `onDispose` allows to register a callback that will be
* triggered when dispose() is called. This is for internally use.
*/
export class ClientProcess extends Disposable {
public rpc: Rpc;
private _safeBrowser: SafeBrowser;
private _src: string;
private _actionRouter: ActionRouter;
public create(...args: any[]): void;
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
this.rpc = rpc;
this._safeBrowser = safeBrowser;
this._src = src;
this._actionRouter = new ActionRouter(this.rpc);
const gristAPI: GristAPI = {
subscribe: tbind(this._actionRouter.subscribeTable, this._actionRouter),
unsubscribe: tbind(this._actionRouter.unsubscribeTable, this._actionRouter),
render: tbind(this._safeBrowser.renderImpl, this._safeBrowser),
dispose: tbind(this._safeBrowser.disposeImpl, this._safeBrowser),
};
rpc.registerImpl<GristAPI>(RPC_GRISTAPI_INTERFACE, gristAPI, checkers.GristAPI);
this.autoDisposeCallback(() => {
this.rpc.unregisterImpl(RPC_GRISTAPI_INTERFACE);
});
}
public receiveAction(action: any[]) {
this._actionRouter.process(action)
// tslint:disable:no-console
.catch((err: any) => console.warn("ClientProcess[%s] receiveAction: failed with %s", this._src, err));
}
}
/**
* The web worker client process, used to execute safe browser main script.
*/
class WorkerProcess extends ClientProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
// Serve web worker script from same host as current page
const worker = new Worker(getOriginUrl(`/${src}`));
worker.addEventListener("message", (e: MessageEvent) => this.rpc.receiveMessage(e.data));
this.rpc.setSendMessage(worker.postMessage.bind(worker));
this.autoDisposeCallback(() => worker.terminate());
}
}
export class ViewProcess extends ClientProcess {
public element: HTMLElement;
}
/**
* The Iframe ClientProcess used to render safe browser content in the browser.
*/
class IframeProcess extends ViewProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`,
{ src }));
const listener = (event: MessageEvent) => {
if (event.source === iframe.contentWindow) {
this.rpc.receiveMessage(event.data);
}
};
G.window.addEventListener('message', listener);
this.autoDisposeCallback(() => {
G.window.removeEventListener('message', listener);
});
this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*'));
}
}
/**
* The webview ClientProcess to render safe browser process in electron.
*/
class WebviewProcess extends ViewProcess {
public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) {
super.create(safeBrowser, rpc, src);
const webview: WebviewTag = this.element = this.autoDispose(dom('webview.safe_browser_process.clipboard_focus', {
src,
allowpopups: '',
// Requests with this partition get an extra header (see main.js) to get access to plugin content.
partition: 'plugins',
}));
// Temporaily disable "mousetrap" keyboard stealing for the duration of this webview.
// This is acceptable since webviews are currently full-screen modals.
// TODO: find a way for keyboard events to play nice when webviews are non-modal.
Mousetrap.setPaused(true);
this.autoDisposeCallback(() => Mousetrap.setPaused(false));
webview.addEventListener('ipc-message', (event: IpcMessageEvent) => {
// The event object passed to the listener is missing proper documentation. In the examples
// listed in https://electronjs.org/docs/api/ipc-main the arguments should be passed to the
// listener after the event object, but this is not happening here. Only we know it is a
// DOMEvent with some extra porperties including a `channel` property of type `string` and an
// `args` property of type `any[]`.
if (event.channel === 'grist') {
rpc.receiveMessage(event.args[0]);
}
});
this.rpc.setSendMessage(msg => webview.send('grist', msg));
}
}

View File

@ -0,0 +1,12 @@
.plugin_instance_fullscreen {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index:9999;
}
.safe_browser_process{
border: none;
}

View File

@ -0,0 +1,239 @@
/**
* Implements an autocomplete dropdown.
*/
import {createPopper, Instance as Popper, Modifier, Options as PopperOptions} from '@popperjs/core';
import {ACItem, ACResults, HighlightFunc} from 'app/client/lib/ACIndex';
import {reportError} from 'app/client/models/errors';
import {Disposable, dom, EventCB, IDisposable} from 'grainjs';
import {obsArray, onKeyElem, styled} from 'grainjs';
import merge = require('lodash/merge');
import maxSize from 'popper-max-size-modifier';
import {cssMenu} from 'popweasel';
export interface IAutocompleteOptions<Item extends ACItem> {
// If provided, applies the css class to the menu container. Could be multiple, space-separated.
menuCssClass?: string;
// A single class name to add for the selected item, or 'selected' by default.
selectedCssClass?: string;
// Popper options for positioning the popup.
popperOptions?: Partial<PopperOptions>;
// Given a search term, return the list of Items to render.
search(searchText: string): Promise<ACResults<Item>>;
// Function to render a single item.
renderItem(item: Item, highlightFunc: HighlightFunc): HTMLElement;
// Get text for the text input for a selected item, i.e. the text to present to the user.
getItemText(item: Item): string;
// A callback triggered when user clicks one of the choices.
onClick?(): void;
}
/**
* An instance of an open Autocomplete dropdown.
*/
export class Autocomplete<Item extends ACItem> extends Disposable {
// The UL element containing the actual menu items.
protected _menuContent: HTMLElement;
// Index into _items as well as into _menuContent, -1 if nothing selected.
protected _selectedIndex: number = -1;
// Currently selected element.
protected _selected: HTMLElement|null = null;
private _popper: Popper;
private _mouseOver: {reset(): void};
private _lastAsTyped: string;
private _items = this.autoDispose(obsArray<Item>([]));
private _highlightFunc: HighlightFunc;
constructor(
private _triggerElem: HTMLInputElement | HTMLTextAreaElement,
private readonly options: IAutocompleteOptions<Item>,
) {
super();
const content = cssMenuWrap(
this._menuContent = cssMenu({class: options.menuCssClass || ''},
dom.forEach(this._items, (item) => options.renderItem(item, this._highlightFunc)),
dom.style('min-width', _triggerElem.getBoundingClientRect().width + 'px'),
dom.on('mouseleave', (ev) => this._setSelected(-1, true)),
dom.on('click', (ev) => {
this._setSelected(this._findTargetItem(ev.target), true);
if (options.onClick) { options.onClick(); }
})
),
// Prevent trigger element from being blurred on click.
dom.on('mousedown', (ev) => ev.preventDefault()),
);
this._mouseOver = attachMouseOverOnMove(this._menuContent,
(ev) => this._setSelected(this._findTargetItem(ev.target), true));
// Add key handlers to the trigger element as well as the menu if it is an input.
this.autoDispose(onKeyElem(_triggerElem, 'keydown', {
ArrowDown: () => this._setSelected(this._getNext(1), true),
ArrowUp: () => this._setSelected(this._getNext(-1), true),
}));
// Keeps track of the last value as typed by the user.
this.search();
this.autoDispose(dom.onElem(_triggerElem, 'input', () => this.search()));
// Attach the content to the page.
document.body.appendChild(content);
this.onDispose(() => { dom.domDispose(content); content.remove(); });
// Prepare and create the Popper instance, which places the content according to the options.
const popperOptions = merge({}, defaultPopperOptions, options.popperOptions);
this._popper = createPopper(_triggerElem, content, popperOptions);
this.onDispose(() => this._popper.destroy());
}
public getSelectedItem(): Item|undefined {
return this._items.get()[this._selectedIndex];
}
public search(findMatch?: (items: Item[]) => number) {
this._updateChoices(this._triggerElem.value, findMatch).catch(reportError);
}
// When the selected element changes, update the classes of the formerly and newly-selected
// elements and optionally update the text input.
private _setSelected(index: number, updateValue: boolean) {
const elem = (this._menuContent.children[index] as HTMLElement) || null;
const prev = this._selected;
if (elem !== prev) {
const clsName = this.options.selectedCssClass || 'selected';
if (prev) { prev.classList.remove(clsName); }
if (elem) {
elem.classList.add(clsName);
elem.scrollIntoView({block: 'nearest'});
}
}
this._selected = elem;
this._selectedIndex = elem ? index : -1;
if (updateValue) {
// Update trigger's value with the selected choice, or else with the last typed value.
if (elem) {
this._triggerElem.value = this.options.getItemText(this.getSelectedItem()!);
} else {
this._triggerElem.value = this._lastAsTyped;
}
}
}
private _findTargetItem(target: EventTarget|null): number {
// Find immediate child of this._menuContent which is an ancestor of ev.target.
const elem = findAncestorChild(this._menuContent, target as Element|null);
return Array.prototype.indexOf.call(this._menuContent.children, elem);
}
private _getNext(step: 1 | -1): number {
// Pretend there is an extra element at the end to mean "nothing selected".
const xsize = this._items.get().length + 1;
const next = (this._selectedIndex + step + xsize) % xsize;
return (next === xsize - 1) ? -1 : next;
}
private async _updateChoices(inputVal: string, findMatch?: (items: Item[]) => number): Promise<void> {
this._lastAsTyped = inputVal;
// TODO We should perhaps debounce the search() call in some clever way, to avoid unnecessary
// searches while typing. Today, search() is synchronous in practice, so it doesn't matter.
const acResults = await this.options.search(inputVal);
this._highlightFunc = acResults.highlightFunc;
this._items.set(acResults.items);
// Plain update() (which is deferred) may be better, but if _setSelected() causes scrolling
// before the positions are updated, it causes the entire page to scroll horizontally.
this._popper.forceUpdate();
this._mouseOver.reset();
let index: number;
if (findMatch) {
index = findMatch(this._items.get());
} else {
index = inputVal ? acResults.selectIndex : -1;
}
this._setSelected(index, false);
}
}
// The maxSize modifiers follow recommendations at https://www.npmjs.com/package/popper-max-size-modifier
const calcMaxSize = {
...maxSize,
options: {padding: 4},
};
const applyMaxSize: Modifier<any, any> = {
name: 'applyMaxSize',
enabled: true,
phase: 'beforeWrite',
requires: ['maxSize'],
fn({state}: any) {
// The `maxSize` modifier provides this data
const {height} = state.modifiersData.maxSize;
Object.assign(state.styles.popper, {
maxHeight: `${Math.max(160, height)}px`
});
}
};
export const defaultPopperOptions: Partial<PopperOptions> = {
placement: 'bottom-start',
modifiers: [
calcMaxSize,
applyMaxSize,
{name: "computeStyles", options: {gpuAcceleration: false}},
],
};
/**
* Helper function which returns the direct child of ancestor which is an ancestor of elem, or
* null if elem is not a descendant of ancestor.
*/
function findAncestorChild(ancestor: Element, elem: Element|null): Element|null {
while (elem && elem.parentElement !== ancestor) {
elem = elem.parentElement;
}
return elem;
}
/**
* A version of dom.onElem('mouseover') that doesn't start firing until there is first a 'mousemove'.
* This way if an element is created under the mouse cursor (triggered by the keyboard, for
* instance) it's not immediately highlighted, but only when a user moves the mouse.
* Returns an object with a reset() method, which restarts the wait for mousemove.
*/
function attachMouseOverOnMove<T extends EventTarget>(elem: T, callback: EventCB<MouseEvent, T>) {
let lis: IDisposable|undefined;
function setListener(eventType: 'mouseover'|'mousemove', cb: EventCB<MouseEvent, T>) {
if (lis) { lis.dispose(); }
lis = dom.onElem(elem, eventType, cb);
}
function reset() {
setListener('mousemove', (ev, _elem) => {
setListener('mouseover', callback);
callback(ev, _elem);
});
}
reset();
return {reset};
}
const cssMenuWrap = styled('div', `
position: absolute;
display: flex;
flex-direction: column;
outline: none;
`);

View File

@ -0,0 +1,54 @@
/**
* Module that allows client-side code to use browser globals (such as `document` or `Node`) in a
* way that allows those globals to be replaced by mocks in browser-less tests.
*
* E.g. test/client/clientUtil.js can replace globals with those provided by jsdom.
*/
var allGlobals = [];
/* global window */
var globalVars = (typeof window !== 'undefined' ? window : {});
/**
* Usage: to get access to global variables `foo` and `bar`, call:
* var G = require('browserGlobals').get('foo', 'bar');
* and use G.foo and G.bar.
*
* This modules stores a reference to G, so that setGlobals() call can replace the values to which
* G.foo and G.bar refer.
*/
function get(varArgNames) {
var obj = {
neededNames: Array.prototype.slice.call(arguments),
globals: {}
};
updateGlobals(obj);
allGlobals.push(obj);
return obj.globals;
}
exports.get = get;
/**
* Internal helper which updates properties of all globals objects created with get().
*/
function updateGlobals(obj) {
obj.neededNames.forEach(function(key) {
obj.globals[key] = globalVars[key];
});
}
/**
* Replace globals with those from the given object. The previous mapping of global values is
* returned, so that it can be restored later.
*/
function setGlobals(globals) {
var oldVars = globalVars;
globalVars = globals;
allGlobals.forEach(function(obj) {
updateGlobals(obj);
});
return oldVars;
}
exports.setGlobals = setGlobals;

View File

@ -0,0 +1,13 @@
import * as Bowser from "bowser"; // TypeScript
let parser: Bowser.Parser.Parser|undefined;
function getParser() {
return parser || (parser = Bowser.getParser(window.navigator.userAgent));
}
// Returns whether the browser we are in is a desktop browser.
export function isDesktop() {
const platformType = getParser().getPlatformType();
return (!platformType || platformType === 'desktop');
}

View File

@ -0,0 +1,20 @@
import {typedCompare} from 'app/common/SortFunc';
import {Datum} from 'plotly.js';
/**
* Sort all values in a list of series according to the values in the first one.
*/
export function sortByXValues(series: Array<{values: Datum[]}>): void {
// The order of points matters for graph types that connect points with lines: the lines are
// drawn in order in which the points appear in the data. For the chart types we support, it
// only makes sense to keep the points sorted. (The only downside is that Grist line charts can
// no longer produce arbitrary line drawings.)
if (!series[0]) { return; }
const xValues = series[0].values;
const indices = xValues.map((val, i) => i);
indices.sort((a, b) => typedCompare(xValues[a], xValues[b]));
for (const s of series) {
const values = s.values;
s.values = indices.map((i) => values[i]);
}
}

View File

@ -0,0 +1,38 @@
import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals';
const G = getBrowserGlobals('document', 'window');
/**
* Copy some text to the clipboard, by hook or by crook.
*/
export async function copyToClipboard(txt: string) {
// If present and we have permission to use it, the navigator.clipboard interface
// is convenient. This method works in non-headless tests, and regular chrome
// and firefox.
if (G.window.navigator && G.window.navigator.clipboard && G.window.navigator.clipboard.writeText) {
try {
await G.window.navigator.clipboard.writeText(txt);
return;
} catch (e) {
// no joy, try another way.
}
}
// Otherwise fall back on document.execCommand('copy'), which requires text in
// the dom to be selected. Implementation here based on:
// https://hackernoon.com/copying-text-to-clipboard-with-javascript-df4d4988697f
// This fallback takes effect at least in headless tests, and in Safari.
const stash = G.document.createElement('textarea');
stash.value = txt;
stash.setAttribute('readonly', '');
stash.style.position = 'absolute';
stash.style.left = '-10000px';
G.document.body.appendChild(stash);
const selection = G.document.getSelection().rangeCount > 0 && G.document.getSelection().getRangeAt(0);
stash.select();
G.document.execCommand('copy');
G.document.body.removeChild(stash);
if (selection) {
G.document.getSelection().removeAllRanges();
G.document.getSelection().addRange(selection);
}
}

16
app/client/lib/dispose.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
// TODO: add remaining Disposable methode
export abstract class Disposable {
public static create<T extends new (...args: any[]) => any>(
this: T, ...args: ConstructorParameters<T>): InstanceType<T>;
constructor(...args: any[]);
public dispose(): void;
public isDisposed(): boolean;
public autoDispose<T>(obj: T): T;
public autoDisposeCallback(callback: () => void): void;
public disposeRelease<T>(obj: T): T;
public disposeDiscard(obj: any): void;
public makeDisposable(obj: any): void;
}
export function emptyNode(node: Node): void;

Some files were not shown because too many files have changed in this diff Show More