diff --git a/app/client/components/BaseView.js b/app/client/components/BaseView.js index 8d63d79f..e0996b81 100644 --- a/app/client/components/BaseView.js +++ b/app/client/components/BaseView.js @@ -243,8 +243,6 @@ BaseView.commonCommands = { copyLink: function() { this.copyLink().catch(reportError); }, - showRawData: function() { this.showRawData().catch(reportError); }, - deleteRecords: function(source) { this.deleteRecords(source); }, filterByThisCellValue: function() { this.filterByThisCellValue(); }, @@ -401,13 +399,6 @@ BaseView.prototype.copyLink = async function() { } }; -BaseView.prototype.showRawData = async function() { - const sectionId = this.schemaModel.rawViewSectionRef.peek(); - const anchorUrlState = this.getAnchorLinkForSection(sectionId); - anchorUrlState.hash.popup = true; - await urlState().pushUrl(anchorUrlState, {replace: true, avoidReload: true}); -} - BaseView.prototype.filterByThisCellValue = function() { const rowId = this.viewData.getRowId(this.cursor.rowIndex()); const col = this.viewSection.viewFields().peek()[this.cursor.fieldIndex()].column(); diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index df4e092b..564610c2 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -25,6 +25,7 @@ import {get as getBrowserGlobals} from 'app/client/lib/browserGlobals'; import {DocPluginManager} from 'app/client/lib/DocPluginManager'; import {ImportSourceElement} from 'app/client/lib/ImportSourceElement'; import {makeT} from 'app/client/lib/localization'; +import {allCommands} from 'app/client/components/commands'; import {createSessionObs} from 'app/client/lib/sessionObs'; import {setTestState} from 'app/client/lib/testState'; import {selectFiles} from 'app/client/lib/uploads'; @@ -116,8 +117,7 @@ export interface IExtraTool { label: DomContents; content: TabContent[]|IDomComponent; } - -interface PopupOptions { +interface RawSectionOptions { viewSection: ViewSectionRec; hash: HashLink; close: () => void; @@ -166,6 +166,10 @@ export class GristDoc extends DisposableWithEvents { public readonly hasDocTour: Computed; public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager; + // One of the section can be shown it the popup (as requested from the Layout), we will + // store its id in this variable. When the section is removed, changed or page is changed, we will + // hide it be informing the layout about it. + public sectionInPopup: Observable = Observable.create(this, null); private _actionLog: ActionLog; private _undoStack: UndoStack; @@ -177,8 +181,8 @@ export class GristDoc extends DisposableWithEvents { private _viewLayout: ViewLayout|null = null; private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour'); private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours'); - private _popupOptions: Observable = Observable.create(this, null); - private _activeContent: Computed; + private _rawSectionOptions: Observable = Observable.create(this, null); + private _activeContent: Computed; constructor( @@ -224,9 +228,7 @@ export class GristDoc extends DisposableWithEvents { const viewId = this.docModel.views.tableData.findRow(docPage === 'GristDocTour' ? 'name' : 'id', docPage); return viewId || use(defaultViewId); }); - - this._activeContent = Computed.create(this, use => use(this._popupOptions) ?? use(this.activeViewId)); - + this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId)); // 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, @@ -234,6 +236,15 @@ export class GristDoc extends DisposableWithEvents { this.viewModel = this.autoDispose( this.docModel.views.createFloatingRowModel(toKo(ko, this.activeViewId) as ko.Computed)); + // When active section is changed, clear the maximized state. + this.autoDispose(this.viewModel.activeSectionId.subscribe(() => { + this.sectionInPopup.set(null); + // If we have layout, update it. + if (!this._viewLayout?.isDisposed()) { + this._viewLayout?.maximized.set(null); + } + })); + // 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); @@ -433,23 +444,33 @@ export class GristDoc extends DisposableWithEvents { * Builds the DOM for this GristDoc. */ public buildDom() { + const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null); + const isPopup = Computed.create(this, use => { + return use(this.activeViewId) === 'data' // On Raw data page + || use(isMaximized) // Layout has a maximized section visible + || typeof use(this._activeContent) === 'object'; // We are on show raw data popup + }); return cssViewContentPane( testId('gristdoc'), - cssViewContentPane.cls("-contents", use => use(this.activeViewId) === 'data' || use(this._popupOptions) !== null), + cssViewContentPane.cls("-contents", isPopup), dom.domComputed(this._activeContent, (content) => { return ( content === 'code' ? dom.create(CodeEditorPanel, this) : content === 'acl' ? dom.create(AccessRules, this) : content === 'data' ? dom.create(RawDataPage, this) : content === 'GristDocTour' ? null : - typeof content === 'object' ? dom.create(owner => { + (typeof content === 'object') ? dom.create(owner => { // In case user changes a page, close the popup. owner.autoDispose(this.activeViewId.addListener(content.close)); // In case the section is removed, close the popup. content.viewSection.autoDispose({dispose: content.close}); return dom.create(RawDataPopup, this, content.viewSection, content.close); }) : - dom.create((owner) => (this._viewLayout = ViewLayout.create(owner, this, content))) + dom.create((owner) => { + this._viewLayout = ViewLayout.create(owner, this, content); + this._viewLayout.maximized.addListener(n => this.sectionInPopup.set(n)); + return this._viewLayout; + }) ); }), ); @@ -998,6 +1019,21 @@ export class GristDoc extends DisposableWithEvents { public async openPopup(hash: HashLink) { // We can only open a popup for a section. if (!hash.sectionId) { return; } + // We might open popup either for a section in this view or some other section (like Raw Data Page). + if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId)) { + this.viewModel.activeSectionId(hash.sectionId); + // If the anchor link is valid, set the cursor. + if (hash.colRef && hash.rowId) { + const activeSection = this.viewModel.activeSection.peek(); + const fieldIndex = activeSection.viewFields.peek().all().findIndex(f => f.colRef.peek() === hash.colRef); + if (fieldIndex >= 0) { + const view = await this._waitForView(activeSection); + view?.setCursorPos({ sectionId: hash.sectionId, rowId: hash.rowId, fieldIndex }); + } + } + allCommands.maximizeActiveSection.run(); + return; + } // We will borrow active viewModel and will trick him into believing that // the section from the link is his viewSection and it is active. Fortunately // he doesn't care. After popup is closed, we will restore the original. @@ -1010,12 +1046,12 @@ export class GristDoc extends DisposableWithEvents { // which might be a diffrent view from what we currently have. If the section is // a raw data section it will use `EmptyRowModel` as raw sections don't have parents. popupSection.hasFocus(true); - this._popupOptions.set({ + this._rawSectionOptions.set({ hash, viewSection: popupSection, close: () => { // In case we are already close, do nothing. - if (!this._popupOptions.get()) { return; } + if (!this._rawSectionOptions.get()) { return; } if (popupSection !== prevSection) { // We need to blur raw view section. Otherwise it will automatically be opened // on raw data view. Note: raw data section doesn't have its own view, it uses @@ -1028,7 +1064,7 @@ export class GristDoc extends DisposableWithEvents { if (!prevSection.isDisposed()) { prevSection.hasFocus(true); } } // Clearing popup data will close this popup. - this._popupOptions.set(null); + this._rawSectionOptions.set(null); } }); // If the anchor link is valid, set the cursor. diff --git a/app/client/components/Layout.css b/app/client/components/Layout.css index 78c6e842..1fce41dc 100644 --- a/app/client/components/Layout.css +++ b/app/client/components/Layout.css @@ -88,3 +88,7 @@ .layout_leaf_test_big { min-height: 7rem; } + +.layout_hidden { + display: none; +} diff --git a/app/client/components/Layout.js b/app/client/components/Layout.js deleted file mode 100644 index 312da743..00000000 --- a/app/client/components/Layout.js +++ /dev/null @@ -1,475 +0,0 @@ -/** - * 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('--flex-grow', 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.isDisposed()) { - return 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; -}; diff --git a/app/client/components/Layout.ts b/app/client/components/Layout.ts new file mode 100644 index 00000000..8ced4ab0 --- /dev/null +++ b/app/client/components/Layout.ts @@ -0,0 +1,494 @@ +/** + * 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. + */ + + +import dom, {detachNode, findAncestor} from '../lib/dom'; +import koArray, {isKoArray, KoArray} from '../lib/koArray'; +import {cssClass, domData, foreach, scope, style, toggleClass} from '../lib/koDom'; +import {Disposable} from 'app/client/lib/dispose'; +import assert from 'assert'; +import {Events as BackboneEvents} from 'backbone'; +import * as ko from 'knockout'; +import {computed, isObservable, observable, utils} from 'knockout'; +import {identity, last, uniqueId} from 'underscore'; + +/** + * 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. + */ +export class LayoutBox extends Disposable { + public layout: Layout; + public dom: Element | null = null; + public leafId: ko.Observable; // probably number for section id + public parentBox: ko.Observable; + public childBoxes: KoArray; + public leafContent: ko.Observable; + public uniqueId: string; + public isVBox: ko.Computed; + public isHBox: ko.Computed; + public isLeaf: ko.Computed; + public isMaximized: ko.Computed; + public isHidden: ko.Computed; + public flexSize: ko.Observable; + private _parentBeingDisposed: boolean; + + public create(layout: Layout) { + this.layout = layout; + this.parentBox = observable(null as any); + this.childBoxes = koArray(); + this.leafId = observable(null); + this.leafContent = observable(null as any); + this.uniqueId = uniqueId("lb"); // For logging and debugging. + + this.isVBox = this.autoDispose(computed(() => { + return this.parentBox() ? !this.parentBox()!.isVBox() : true; + }, this)); + this.isHBox = this.autoDispose(computed(() => { return !this.isVBox(); })); + this.isLeaf = this.autoDispose(computed(() => { return this.leafId() !== null; }, + this)); + + this.isMaximized = this.autoDispose(ko.pureComputed(() => { + const leafId = this.layout?.maximized(); + if (!leafId) { return false; } + if (leafId === this.leafId()) { return true; } + return this.childBoxes.all().some(function(child) { return child.isMaximized(); }); + }, this)); + this.isHidden = this.autoDispose(ko.pureComputed(() => { + // If there isn't any maximized box, then no box is hidden. + const maximized = this.layout?.maximized(); + if (!maximized) { return false; } + return !this.isMaximized(); + }, 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 = observable(100); + + this.dom = null; + + // This is an optimization to avoid the wasted cost of removeFromParent during disposal. + this._parentBeingDisposed = false; + + this.autoDisposeCallback(() => { + if (!this._parentBeingDisposed) { + this.removeFromParent(); + } + this.childBoxes.peek().forEach(function(child) { + child._parentBeingDisposed = true; + child.dispose(); + }); + }); + } + public getDom() { + return this.dom || (this.dom = this.autoDispose(this.buildDom())); + } + public maximize() { + if (this.layout.maximized.peek() !== this.leafId.peek()) { + this.layout.maximized(this.leafId()); + } else { + this.layout.maximized(null); + } + } + public buildDom() { + const self = this; + const wrap = this.layout.needDynamic ? identity : makeStatic; + + return dom('div.layout_box', + toggleClass('layout_leaf', wrap(this.isLeaf)), + toggleClass('layout_hidden', this.isHidden), + toggleClass(this.layout.leafId, wrap(this.isLeaf)), + cssClass(wrap(function() { return self.isVBox() ? "layout_vbox" : "layout_hbox"; })), + cssClass(wrap(function() { + return (self.layout.fillWindow ? 'layout_fill_window' : + (self.isLastChild() ? 'layout_last_child' : null)); + })), + style('--flex-grow', wrap(function() { + return (self.isVBox() || (self.isHBox() && self.layout.fillWindow)) ? self.flexSize() : ''; + })), + domData('layoutBox', this), + foreach(wrap(this.childBoxes), function(layoutBox: LayoutBox) { + return layoutBox.getDom(); + }), + scope(wrap(this.leafContent), function(leafContent: any) { + return leafContent; + }) + ); + } + /** + * Moves the leaf id and content from another layoutBox, unsetting them in the source one. + */ + public takeLeafFrom(sourceLayoutBox: LayoutBox) { + this.leafId(sourceLayoutBox.leafId.peek()); + // Note that we detach the node, so that the old box doesn't destroy its DOM. + this.leafContent(detachNode(sourceLayoutBox.leafContent.peek())); + sourceLayoutBox.leafId(null); + sourceLayoutBox.leafContent(null); + } + public setChildren(children: LayoutBox[]) { + children.forEach((child) => child.parentBox(this)); + this.childBoxes.assign(children); + } + public isFirstChild() { + return this.parentBox() ? this.parentBox()!.childBoxes.peek()[0] === this : true; + } + public isLastChild() { + // 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; + } + public isDomDetached() { + return !(this.dom && this.dom.parentNode); + } + public getSiblingBox(isAfter: boolean) { + if (!this.parentBox()) { + return null; + } + const siblings = this.parentBox()!.childBoxes.peek(); + let index = siblings.indexOf(this); + if (index < 0) { + return null; + } + index += (isAfter ? 1 : -1); + return (index < 0 || index >= siblings.length ? null : siblings[index]); + } + public _addChild(childBox: LayoutBox, isAfter: boolean, optNextSibling?: LayoutBox) { + assert(childBox.parentBox() === null, "LayoutBox._addChild: child already has parentBox set"); + let 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); + } + public addSibling(childBox: LayoutBox, isAfter: boolean) { + childBox.removeFromParent(); + const 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. + const 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. + const vbox = LayoutBox.create(this.layout); + const 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'); + } + public addChild(childBox: LayoutBox, isAfter: boolean) { + childBox.removeFromParent(); + if (this.isLeaf()) { + // Move the leaf data into a new child, then add the requested childBox. + const newBox = LayoutBox.create(this.layout); + newBox.takeLeafFrom(this); + this._addChild(newBox, false); + } + this._addChild(childBox, isAfter); + this.layout.trigger('layoutChanged'); + } + public toString(): string { + 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(",") + "]") + ); + } + public _removeChildBox(childBox: LayoutBox) { + //console.log("_removeChildBox %s from %s", childBox.toString(), this.toString()); + let 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. + const lowerBox = this.childBoxes.peek()[0]; + const 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'); + + const 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(). + */ + public removeFromParent() { + 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. + */ + public rescaleFlexSizes() { + // Just scale so that the smallest value is 1. + const children = this.childBoxes.peek(); + const minSize = Math.min.apply(null, children.map(function(b) { return b.flexSize(); })); + if (minSize < 1) { + children.forEach(function(b) { + b.flexSize(b.flexSize() / minSize); + }); + } + } +} + +/** + * 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: any) { + if (isObservable(valueOrFunc) || isKoArray(valueOrFunc)) { + return valueOrFunc.peek(); + } else if (typeof valueOrFunc === 'function') { + return valueOrFunc(); + } else { + return valueOrFunc; + } +} + +//---------------------------------------------------------------------- + +/** + * @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. + */ +export class Layout extends Disposable { + /** + * 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. + */ + public static getContainingBox(elem: Element|null, optContainer: any) { + const boxElem = findAncestor(elem, optContainer, '.layout_box'); + return boxElem ? utils.domData.get(boxElem, 'layoutBox') : null; + } + + public listenTo: BackboneEvents["listenTo"]; // set by Backbone + public trigger: BackboneEvents["trigger"]; // set by Backbone + public stopListening: BackboneEvents["stopListening"]; // set by Backbone + + public maximized: ko.Observable; + public rootBox: ko.Observable; + public createLeafFunc: (id: string) => HTMLElement; + public fillWindow: boolean; + public needDynamic: boolean; + public rootElem: HTMLElement; + public leafId: string; + private _leafIdMap: Map|null; + + public create(boxSpec: object, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) { + this.maximized = observable(null as (string|null)); + this.rootBox = observable(null as any); + 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', () => { this._leafIdMap = null; }); + + this.autoDisposeCallback(() => { + if (this.rootBox()) { + this.rootBox()!.dispose(); + } + }); + } + /** + * Finds and returns the leaf layout box containing the content for the given leafId. + */ + public getLeafBox(leafId: string|number) { + return this.getLeafIdMap().get(leafId); + } + /** + * Returns the list of all leafIds present in this layout. + */ + public getAllLeafIds() { + return Array.from(this.getLeafIdMap().keys()); + } + public setRoot(layoutBox: LayoutBox) { + this.rootBox(layoutBox); + } + public buildDom() { + return dom('div.layout_root', + domData('layoutModel', this), + toggleClass('layout_fill_window', this.fillWindow), + toggleClass('layout_box_maximized', this.maximized), + scope(this.rootBox, (rootBox: LayoutBox) => { + return rootBox ? rootBox.getDom() : null; + }) + ); + } + /** + * Calls cb on each box in the layout recursively. + */ + public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) { + function iter(box: any) { + cb.call(optContext, box); + box.childBoxes.peek().forEach(iter); + } + iter(this.rootBox.peek()); + } + public buildLayoutBox(boxSpec: any) { + // Note that this is hot code: it runs when rendering a layout for each record, not only for the + // layout editor. + const 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; + } + public buildLayout(boxSpec: any, needDynamic = false) { + this.needDynamic = needDynamic; + const oldRootBox = this.rootBox(); + this.rootBox(this.buildLayoutBox(boxSpec)); + this.trigger('layoutChanged'); + if (oldRootBox) { + oldRootBox.dispose(); + } + } + public _getBoxSpec(layoutBox: LayoutBox) { + const spec: any = {}; + if (layoutBox.isDisposed()) { + return 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; + } + public getLayoutSpec() { + 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. + */ + public getLeafIdMap() { + if (!this._leafIdMap) { + this._leafIdMap = new Map(); + this.forEachBox((box) => { + const leafId = box.leafId.peek(); + if (leafId !== null) { + this._leafIdMap!.set(leafId, box); + } + }, this); + } + return this._leafIdMap; + } + /** + * Returns a LayoutBox object containing the given DOM element, or null if not found. + */ + public getContainingBox(elem: Element|null) { + return Layout.getContainingBox(elem, this.rootElem); + } +} + +Object.assign(Layout.prototype, BackboneEvents); diff --git a/app/client/components/ViewLayout.css b/app/client/components/ViewLayout.css index fdd381b2..7fc46773 100644 --- a/app/client/components/ViewLayout.css +++ b/app/client/components/ViewLayout.css @@ -5,6 +5,11 @@ .viewsection_buttons { margin-left: 4px; + display: flex; + align-items: center; + gap: 8px; + align-self: flex-start; + margin-top: -1px; } .viewsection_title { @@ -99,6 +104,19 @@ border-left: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey)); } +/* Used by full screen section. Removes the green box-shadow and restores normal color of the border. + It still leaves the indicator for the cardlist selection (the green box shadow in card) which looks nice. +*/ +.layout_box_maximized .active_section > .view_data_pane_container{ + box-shadow: none; + border: 1px solid var(--grist-theme-widget-border, var(--grist-color-dark-grey)); +} +/* Remove the drag indicator */ +.layout_box_maximized .active_section .viewsection_drag_indicator { + visibility: hidden !important; +} + + .disable_viewpane { justify-content: center; text-align: center; diff --git a/app/client/components/ViewLayout.ts b/app/client/components/ViewLayout.ts index 5be25fc3..e1c5be8e 100644 --- a/app/client/components/ViewLayout.ts +++ b/app/client/components/ViewLayout.ts @@ -19,11 +19,10 @@ import {colors, isNarrowScreen, isNarrowScreenObs, mediaSmall, testId, theme} fr import {icon} from 'app/client/ui2018/icons'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; import {mod} from 'app/common/gutil'; -import {Observable} from 'grainjs'; import * as ko from 'knockout'; import * as _ from 'underscore'; import debounce from 'lodash/debounce'; -import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, styled, subscribe} from 'grainjs'; +import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, Observable, styled, subscribe} from 'grainjs'; // tslint:disable:no-console @@ -71,9 +70,10 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { public docModel = this.gristDoc.docModel; public viewModel: ViewRec; public layoutSpec: ko.Computed; + public maximized: Observable; private _freeze = false; - private _layout: any; + private _layout: Layout; private _sectionIds: number[]; private _isResizing = Observable.create(this, false); @@ -155,12 +155,12 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { const classInactive = cssLayoutBox.className + '-inactive'; this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => { const id = section.getRowId(); - this._layout.forEachBox((box: {dom: Element}) => { - box.dom.classList.add(classInactive); - box.dom.classList.remove(classActive); - box.dom.classList.remove("transition"); + this._layout.forEachBox(box => { + box.dom!.classList.add(classInactive); + box.dom!.classList.remove(classActive); + box.dom!.classList.remove("transition"); }); - let elem: Element|null = this._layout.getLeafBox(id)?.dom; + let elem: Element|null = this._layout.getLeafBox(id)?.dom || null; while (elem?.matches('.layout_box')) { elem.classList.remove(classInactive); elem.classList.add(classActive); @@ -177,12 +177,43 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { prevSection: () => { this._otherSection(-1); }, printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); }, sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); }, + maximizeActiveSection: () => { this._maximizeActiveSection(); }, + cancel: () => { + if (this.maximized.get()) { + this.maximized.set(null); + } + } }; this.autoDispose(commands.createGroup(commandGroup, this, true)); + + this.maximized = fromKo(this._layout.maximized) as any; + this.autoDispose(this.maximized.addListener(val => { + const section = this.viewModel.activeSection.peek(); + // If section is not disposed and it is not a deleted row. + if (!section.isDisposed() && section.id.peek()) { + section?.viewInstance.peek()?.onResize(); + } + })); } public buildDom() { - return this._layout.rootElem; + const close = () => this.maximized.set(null); + return cssOverlay( + cssOverlay.cls('-active', use => !!use(this.maximized)), + testId('viewLayout-overlay'), + cssLayoutWrapper( + cssLayoutWrapper.cls('-active', use => !!use(this.maximized)), + this._layout.rootElem, + ), + dom.maybe(use => !!use(this.maximized), () => + cssCloseButton('CrossBig', + testId('close-button'), + dom.on('click', () => close()) + ) + ), + // Close the lightbox when user clicks exactly on the overlay. + dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null)) + ); } // Freezes the layout until the passed in promise resolves. This is useful to achieve a single @@ -204,6 +235,14 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError); } + private _maximizeActiveSection() { + const activeSection = this.viewModel.activeSection(); + const activeSectionId = activeSection.getRowId(); + const activeSectionBox = this._layout.getLeafBox(activeSectionId); + if (!activeSectionBox) { return; } + activeSectionBox.maximize(); + } + private _buildLeafContent(sectionRowId: number) { return buildViewSectionDom({ gristDoc: this.gristDoc, @@ -219,33 +258,33 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent { */ 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 tmpLayout = Layout.create(spec, () => 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 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); + 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(); + _.difference(specFieldIds, viewSectionIds).forEach(function(leafId: any) { + 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) { + _.difference(viewSectionIds, specFieldIds).forEach(function(leafId: any) { // Only add the builder box if it hasn`t already been created addToSpec(leafId); }); @@ -340,7 +379,7 @@ export function buildViewSectionDom(options: { cssSigmaIcon('Pivot', testId('sigma'))), buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))), viewInstance.buildTitleControls(), - dom('span.viewsection_buttons', + dom('div.viewsection_buttons', dom.create(viewSectionMenu, gristDoc, vs) ) )), @@ -471,3 +510,65 @@ const cssDragIcon = styled(icon, ` const cssResizing = styled('div', ` pointer-events: none; `); + +const cssLayoutWrapper = styled('div', ` + @media not print { + &-active { + background: ${theme.mainPanelBg}; + height: 100%; + width: 100%; + border-radius: 5px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + position: relative; + } + &-active .viewsection_content { + margin: 0px; + margin-top: 12px; + } + &-active .viewsection_title { + padding: 0px 12px; + } + &-active .filter_bar { + margin-left: 6px; + } + } +`); + +const cssOverlay = styled('div', ` + @media screen { + &-active { + background-color: ${theme.modalBackdrop}; + inset: 0px; + height: 100%; + width: 100%; + padding: 20px 56px 20px 56px; + position: absolute; + } + } + @media screen and ${mediaSmall} { + &-active { + padding: 22px; + padding-top: 30px; + } + } +`); + +const cssCloseButton = styled(icon, ` + position: absolute; + top: 16px; + right: 16px; + height: 24px; + width: 24px; + cursor: pointer; + --icon-color: ${theme.modalBackdropCloseButtonFg}; + &:hover { + --icon-color: ${theme.modalBackdropCloseButtonHoverFg}; + } + @media ${mediaSmall} { + & { + top: 6px; + right: 6px; + } + } +`); diff --git a/app/client/components/commandList.js b/app/client/components/commandList.js index 26aafd4b..dc612de1 100644 --- a/app/client/components/commandList.js +++ b/app/client/components/commandList.js @@ -111,6 +111,11 @@ exports.groups = [{ keys: [], desc: 'Open Custom widget configuration screen', }, + { + name: 'maximizeActiveSection', + keys: [], + desc: 'Maximize the active section', + }, { name: 'leftPanelOpen', keys: [], diff --git a/app/client/declarations.d.ts b/app/client/declarations.d.ts index f44b488f..17f2768a 100644 --- a/app/client/declarations.d.ts +++ b/app/client/declarations.d.ts @@ -4,7 +4,6 @@ declare module "app/client/components/CodeEditorPanel"; declare module "app/client/components/DetailView"; declare module "app/client/components/DocConfigTab"; declare module "app/client/components/GridView"; -declare module "app/client/components/Layout"; declare module "app/client/components/LayoutEditor"; declare module "app/client/components/commandList"; declare module "app/client/lib/Mousetrap"; @@ -30,6 +29,7 @@ declare module "app/client/components/BaseView" { import {Cursor, CursorPos} from 'app/client/components/Cursor'; import {GristDoc} from 'app/client/components/GristDoc'; + import {IGristUrlState} from 'app/common/gristUrls'; import {SelectionSummary} from 'app/client/components/SelectionSummary'; import {Disposable} from 'app/client/lib/dispose'; import BaseRowModel from "app/client/models/BaseRowModel"; @@ -77,6 +77,7 @@ declare module "app/client/components/BaseView" { public prepareToPrint(onOff: boolean): void; public moveEditRowToCursor(): DataRowModel; public scrollToCursor(sync: boolean): Promise; + public getAnchorLinkForSection(sectionId: number): IGristUrlState; } export = BaseView; } diff --git a/app/client/lib/koArray.d.ts b/app/client/lib/koArray.d.ts index 8d63564c..005f554f 100644 --- a/app/client/lib/koArray.d.ts +++ b/app/client/lib/koArray.d.ts @@ -24,8 +24,10 @@ declare class KoArray { public clampIndex(index: number): number|null; public makeLiveIndex(index?: number): ko.Observable & {setLive(live: boolean): void}; public setAutoDisposeValues(): this; + public arraySplice(start: number, deleteCount: number, items: T[]): T[]; } declare function syncedKoArray(...args: any[]): any; export default function koArray(initialValue?: T[]): KoArray; +export function isKoArray(obj: any): obj is KoArray; diff --git a/app/client/ui/ViewLayoutMenu.ts b/app/client/ui/ViewLayoutMenu.ts index 2df11bc9..614cbfce 100644 --- a/app/client/ui/ViewLayoutMenu.ts +++ b/app/client/ui/ViewLayoutMenu.ts @@ -36,10 +36,26 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool const viewRec = viewSection.view(); const isLight = urlState().state.get().params?.style === 'light'; + + const sectionId = viewSection.table.peek().rawViewSectionRef.peek(); + const anchorUrlState = viewInstance.getAnchorLinkForSection(sectionId); + anchorUrlState.hash!.popup = true; + const rawUrl = urlState().makeUrl(anchorUrlState); + + return [ dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu), - dom.maybe((use) => !use(viewSection.isRaw) && !isLight, - () => menuItemCmd(allCommands.showRawData, t("Show raw data"), testId('show-raw-data')), + dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.sectionInPopup), + () => menuItemLink( + { href: rawUrl}, t("Show raw data"), testId('show-raw-data'), + dom.on('click', (ev) => { + // Replace the current URL so that the back button works as expected (it navigates back from + // the current page). + ev.stopImmediatePropagation(); + ev.preventDefault(); + urlState().pushUrl(anchorUrlState, { replace: true }).catch(reportError); + }) + ) ), menuItemCmd(allCommands.printSection, t("Print widget"), testId('print-section')), menuItemLink({ href: gristDoc.getCsvLink(), target: '_blank', download: ''}, diff --git a/app/client/ui/ViewSectionMenu.ts b/app/client/ui/ViewSectionMenu.ts index 2e3e0ad4..1b1cadc5 100644 --- a/app/client/ui/ViewSectionMenu.ts +++ b/app/client/ui/ViewSectionMenu.ts @@ -1,4 +1,5 @@ import {GristDoc} from 'app/client/components/GristDoc'; +import {allCommands} from 'app/client/components/commands'; import {makeT} from 'app/client/lib/localization'; import {reportError} from 'app/client/models/AppModel'; import {DocModel, ViewSectionRec} from 'app/client/models/DocModel'; @@ -8,7 +9,7 @@ import {hoverTooltip} from 'app/client/ui/tooltips'; import {SortConfig} from 'app/client/ui/SortConfig'; import {makeViewLayoutMenu} from 'app/client/ui/ViewLayoutMenu'; import {basicButton, primaryButton} from 'app/client/ui2018/buttons'; -import {theme, vars} from 'app/client/ui2018/cssVars'; +import {isNarrowScreenObs, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu} from 'app/client/ui2018/menus'; import {Computed, dom, IDisposableOwner, makeTestId, styled} from 'grainjs'; @@ -54,9 +55,15 @@ export function viewSectionMenu( const save = () => { doSave(docModel, viewSection).catch(reportError); }; const revert = () => doRevert(viewSection); + // Should we show expand icon. + const showExpandIcon = Computed.create(owner, (use) => { + return !use(isNarrowScreenObs()) // not on narrow screens + && use(gristDoc.sectionInPopup) !== use(viewSection.id) // not in popup + && !use(viewSection.isRaw); // not in raw mode + }); + return [ cssFilterMenuWrapper( - cssFixHeight.cls(''), cssFilterMenuWrapper.cls('-unsaved', displaySaveObs), testId('wrapper'), cssMenu( @@ -130,12 +137,19 @@ export function viewSectionMenu( ), cssMenu( testId('viewLayout'), - cssFixHeight.cls(''), cssDotsIconWrapper(cssIcon('Dots')), menu(_ctl => makeViewLayoutMenu(viewSection, isReadonly.get()), { ...defaultMenuOptions, placement: 'bottom-end', }) + ), + dom.maybe(showExpandIcon, () => + cssExpandIconWrapper( + cssSmallIcon('Grow'), + testId('expandSection'), + dom.on('click', () => allCommands.maximizeActiveSection.run()), + hoverTooltip('Expand section', {key: 'expandSection'}), + ), ) ]; } @@ -181,7 +195,7 @@ function makeCustomOptions(section: ViewSectionRec) { dom.text(text), cssMenuText.cls(color), cssSpacer(), - dom.maybe(use => use(section.activeCustomOptions), () => + dom.maybe(use => Boolean(use(section.activeCustomOptions)), () => cssMenuIconWrapper( cssIcon('Remove', testId('btn-remove-options'), dom.on('click', () => section.activeCustomOptions(null) @@ -195,22 +209,15 @@ function makeCustomOptions(section: ViewSectionRec) { const clsOldUI = styled('div', ``); -const cssFixHeight = styled('div', ` - margin-top: -3px; /* Section header is 24px, so need to move this up a little bit */ -`); const cssMenu = styled('div', ` - - display: inline-flex; + display: flex; cursor: pointer; - border-radius: 3px; - border: 1px solid transparent; &.${clsOldUI.className} { margin-top: 0px; border-radius: 0px; } - &:hover, &.weasel-popup-open { background-color: ${theme.hover}; } @@ -241,8 +248,7 @@ const cssMenuIconWrapper = styled(cssIconWrapper, ` `); const cssFilterMenuWrapper = styled('div', ` - display: inline-flex; - margin-right: 10px; + display: flex; border-radius: 3px; align-items: center; &-unsaved { @@ -251,7 +257,6 @@ const cssFilterMenuWrapper = styled('div', ` & .${cssMenu.className} { border: none; } - `); const cssIcon = styled(icon, ` @@ -274,14 +279,31 @@ const cssIcon = styled(icon, ` const cssDotsIconWrapper = styled(cssIconWrapper, ` border-radius: 0px 2px 2px 0px; - + display: flex; .${clsOldUI.className} & { border-radius: 0px; } `); +const cssExpandIconWrapper = styled('div', ` + display: flex; + border-radius: 3px; + align-items: center; + padding: 4px; + cursor: pointer; + &:hover, &.weasel-popup-open { + background-color: ${theme.hover}; + } +`); + +const cssSmallIcon = styled(cssIcon, ` + height: 13px; + width: 13px; +`); + const cssFilterIconWrapper = styled(cssIconWrapper, ` border-radius: 2px 0px 0px 2px; + display: flex; &-any { border-radius: 2px; background-color: ${theme.controlSecondaryFg}; diff --git a/app/client/ui2018/IconList.ts b/app/client/ui2018/IconList.ts index 214b2886..5b218dab 100644 --- a/app/client/ui2018/IconList.ts +++ b/app/client/ui2018/IconList.ts @@ -67,6 +67,7 @@ export type IconName = "ChartArea" | "FontStrikethrough" | "FontUnderline" | "FunctionResult" | + "Grow" | "Help" | "Home" | "Idea" | @@ -200,6 +201,7 @@ export const IconList: IconName[] = ["ChartArea", "FontStrikethrough", "FontUnderline", "FunctionResult", + "Grow", "Help", "Home", "Idea", diff --git a/static/icons/icons.css b/static/icons/icons.css index 83be4150..0aa3370d 100644 --- a/static/icons/icons.css +++ b/static/icons/icons.css @@ -68,6 +68,7 @@ --icon-FontStrikethrough: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHBhdGggZD0iTSAxLjgwNTY1ODUsMy4zOTcxNzIgViAxLjczNTc0MzEgSCAxNC4xOTQzNDIgViAzLjM5NzE3MiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxMC4wNDI4ODcgViAxNC4xOTY0NiIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNIDcuOTk5OTk5OSwxLjczNTc0MzEgViA3LjU1MDc0NCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjOTI5Mjk5IiBzdHJva2Utd2lkdGg9IjEuMTQzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNNS4zNDUyODIyIDE0LjE5NjQ2SDEwLjY1NDcxOE0xLjgwNTY1ODUgNy41NTA3NDRIMTQuMTk0MzQyIiBzdHJva2U9IiM5MjkyOTkiIHN0cm9rZS13aWR0aD0iMS4xNDMiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPjwvc3ZnPg=='); --icon-FontUnderline: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMCAwSDEyVjEySDB6Ii8+PC9jbGlwUGF0aD48L2RlZnM+PGcgdHJhbnNmb3JtPSJtYXRyaXgoMS4xNDI5NSAwIDAgMS4xNDI5NSAxLjE0MiAuNzIxKSIgY2xpcC1wYXRoPSJ1cmwoI2EpIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNLjM3NSAxMS42MjVoMTEuMjVNOS4zNzUgMS4xMjVWNkM5LjM3NSA2Ljg5NTExIDkuMDE5NDIgNy43NTM1NSA4LjM4NjQ5IDguMzg2NDkgNy43NTM1NSA5LjAxOTQyIDYuODk1MTEgOS4zNzUgNiA5LjM3NXYwQzUuMTA0ODkgOS4zNzUgNC4yNDY0NSA5LjAxOTQyIDMuNjEzNTEgOC4zODY0OSAyLjk4MDU4IDcuNzUzNTUgMi42MjUgNi44OTUxMSAyLjYyNSA2VjEuMTI1TTEuMTI1IDEuMTI1aDNNNy44NzUgMS4xMjVoMyIvPjwvZz48L3N2Zz4='); --icon-FunctionResult: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTQsMiBMMTIsMiBDMTMuMTA0NTY5NSwyIDE0LDIuODk1NDMwNSAxNCw0IEwxNCwxMiBDMTQsMTMuMTA0NTY5NSAxMy4xMDQ1Njk1LDE0IDEyLDE0IEw0LDE0IEMyLjg5NTQzMDUsMTQgMiwxMy4xMDQ1Njk1IDIsMTIgTDIsNCBDMiwyLjg5NTQzMDUgMi44OTU0MzA1LDIgNCwyIFogTTQuNSw2IEM0LjIyMzg1NzYzLDYgNCw2LjIyMzg1NzYzIDQsNi41IEM0LDYuNzc2MTQyMzcgNC4yMjM4NTc2Myw3IDQuNSw3IEwxMS41LDcgQzExLjc3NjE0MjQsNyAxMiw2Ljc3NjE0MjM3IDEyLDYuNSBDMTIsNi4yMjM4NTc2MyAxMS43NzYxNDI0LDYgMTEuNSw2IEw0LjUsNiBaIE00LjUsOSBDNC4yMjM4NTc2Myw5IDQsOS4yMjM4NTc2MyA0LDkuNSBDNCw5Ljc3NjE0MjM3IDQuMjIzODU3NjMsMTAgNC41LDEwIEwxMS41LDEwIEMxMS43NzYxNDI0LDEwIDEyLDkuNzc2MTQyMzcgMTIsOS41IEMxMiw5LjIyMzg1NzYzIDExLjc3NjE0MjQsOSAxMS41LDkgTDQuNSw5IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); + --icon-Grow: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEuNSAxLjVMNi41IDYuNU05LjUgOS41TDE0LjUgMTQuNU03LjUgMS41SDEuNVY3LjVNMTQuNSA4LjVWMTQuNUg4LjUiIHN0cm9rZT0iIzkyOTI5OSIgc3Ryb2tlLW1pdGVybGltaXQ9IjEwIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4='); --icon-Help: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzA5MjY0NDMsMy40MTYzNzEyMSBDMS42NDQ0MDc2OSw0LjY0NDQzMjIyIDEsNi4yNDY5NjEzNyAxLDggQzEsOS43NTMwMzg2MyAxLjY0NDQwNzY5LDExLjM1NTU2NzggMi43MDkyNjQ0MywxMi41ODM2Mjg4IEw0Ljg0MDA3MTI5LDEwLjQ1MjgyMTkgQzQuMzEzNTQwOCw5Ljc3NTQ4MDA3IDQsOC45MjQzNTU3MyA0LDggQzQsNy4wNzU2NDQyNyA0LjMxMzU0MDgsNi4yMjQ1MTk5MyA0Ljg0MDA3MTI5LDUuNTQ3MTc4MDcgTDIuNzA5MjY0NDMsMy40MTYzNzEyMSBaIE0zLjQxNjM3MTIxLDIuNzA5MjY0NDMgTDUuNTQ3MTc4MDcsNC44NDAwNzEyOSBDNi4yMjQ1MTk5Myw0LjMxMzU0MDggNy4wNzU2NDQyNyw0IDgsNCBDOC45MjQzNTU3Myw0IDkuNzc1NDgwMDcsNC4zMTM1NDA4IDEwLjQ1MjgyMTksNC44NDAwNzEyOSBMMTIuNTgzNjI4OCwyLjcwOTI2NDQzIEMxMS4zNTU1Njc4LDEuNjQ0NDA3NjkgOS43NTMwMzg2MywxIDgsMSBDNi4yNDY5NjEzNywxIDQuNjQ0NDMyMjIsMS42NDQ0MDc2OSAzLjQxNjM3MTIxLDIuNzA5MjY0NDMgWiBNNS45MDE5ODczMSw1Ljg1NTYyMzc2IEM1Ljg5NDgyMDAxLDUuODYzNzgzMDcgNS44ODczNDIwNCw1Ljg3MTc2NDc0IDUuODc5NTUzMzksNS44Nzk1NTMzOSBDNS44NzE3NjQ3NCw1Ljg4NzM0MjA0IDUuODYzNzgzMDcsNS44OTQ4MjAwMSA1Ljg1NTYyMzc2LDUuOTAxOTg3MzEgQzUuMzI2Mjk1NSw2LjQ0MjkzOTMyIDUsNy4xODMzNjQ0NiA1LDggQzUsOC44MTY2MzU1NCA1LjMyNjI5NTUsOS41NTcwNjA2OCA1Ljg1NTYyMzc2LDEwLjA5ODAxMjcgQzUuODYzNzgzMDcsMTAuMTA1MTggNS44NzE3NjQ3NCwxMC4xMTI2NTggNS44Nzk1NTMzOSwxMC4xMjA0NDY2IEM1Ljg4NzM0MjA0LDEwLjEyODIzNTMgNS44OTQ4MjAwMSwxMC4xMzYyMTY5IDUuOTAxOTg3MzEsMTAuMTQ0Mzc2MiBDNi40NDI5MzkzMiwxMC42NzM3MDQ1IDcuMTgzMzY0NDYsMTEgOCwxMSBDOC44MTY2MzU1NCwxMSA5LjU1NzA2MDY4LDEwLjY3MzcwNDUgMTAuMDk4MDEyNywxMC4xNDQzNzYyIEMxMC4xMDUxOCwxMC4xMzYyMTY5IDEwLjExMjY1OCwxMC4xMjgyMzUzIDEwLjEyMDQ0NjYsMTAuMTIwNDQ2NiBDMTAuMTI4MjM1MywxMC4xMTI2NTggMTAuMTM2MjE2OSwxMC4xMDUxOCAxMC4xNDQzNzYyLDEwLjA5ODAxMjcgQzEwLjY3MzcwNDUsOS41NTcwNjA2OCAxMSw4LjgxNjYzNTU0IDExLDggQzExLDcuMTgzMzY0NDYgMTAuNjczNzA0NSw2LjQ0MjkzOTMyIDEwLjE0NDM3NjIsNS45MDE5ODczMSBDMTAuMTM2MjE2OSw1Ljg5NDgyMDAxIDEwLjEyODIzNTMsNS44ODczNDIwNCAxMC4xMjA0NDY2LDUuODc5NTUzMzkgQzEwLjExMjY1OCw1Ljg3MTc2NDc0IDEwLjEwNTE4LDUuODYzNzgzMDcgMTAuMDk4MDEyNyw1Ljg1NTYyMzc2IEM5LjU1NzA2MDY4LDUuMzI2Mjk1NSA4LjgxNjYzNTU0LDUgOCw1IEM3LjE4MzM2NDQ2LDUgNi40NDI5MzkzMiw1LjMyNjI5NTUgNS45MDE5ODczMSw1Ljg1NTYyMzc2IFogTTMuNDE2MzcxMjEsMTMuMjkwNzM1NiBDNC42NDQ0MzIyMiwxNC4zNTU1OTIzIDYuMjQ2OTYxMzcsMTUgOCwxNSBDOS43NTMwMzg2MywxNSAxMS4zNTU1Njc4LDE0LjM1NTU5MjMgMTIuNTgzNjI4OCwxMy4yOTA3MzU2IEwxMC40NTI4MjE5LDExLjE1OTkyODcgQzkuNzc1NDgwMDcsMTEuNjg2NDU5MiA4LjkyNDM1NTczLDEyIDgsMTIgQzcuMDc1NjQ0MjcsMTIgNi4yMjQ1MTk5MywxMS42ODY0NTkyIDUuNTQ3MTc4MDcsMTEuMTU5OTI4NyBMMy40MTYzNzEyMSwxMy4yOTA3MzU2IFogTTEzLjI5MDczNTYsMTIuNTgzNjI4OCBDMTQuMzU1NTkyMywxMS4zNTU1Njc4IDE1LDkuNzUzMDM4NjMgMTUsOCBDMTUsNi4yNDY5NjEzNyAxNC4zNTU1OTIzLDQuNjQ0NDMyMjIgMTMuMjkwNzM1NiwzLjQxNjM3MTIxIEwxMS4xNTk5Mjg3LDUuNTQ3MTc4MDcgQzExLjY4NjQ1OTIsNi4yMjQ1MTk5MyAxMiw3LjA3NTY0NDI3IDEyLDggQzEyLDguOTI0MzU1NzMgMTEuNjg2NDU5Miw5Ljc3NTQ4MDA3IDExLjE1OTkyODcsMTAuNDUyODIxOSBMMTMuMjkwNzM1NiwxMi41ODM2Mjg4IFogTTgsMTYgQzMuNTgxNzIyLDE2IDAsMTIuNDE4Mjc4IDAsOCBDMCwzLjU4MTcyMiAzLjU4MTcyMiwwIDgsMCBDMTIuNDE4Mjc4LDAgMTYsMy41ODE3MjIgMTYsOCBDMTYsMTIuNDE4Mjc4IDEyLjQxODI3OCwxNiA4LDE2IFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Home: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTgsMi4wODMwOTUxOSBMMS40NzE4MjUzMiw2IEw4LDkuOTE2OTA0ODEgTDE0LjUyODE3NDcsNiBMOCwyLjA4MzA5NTE5IFogTTguMjU3MjQ3ODgsMS4wNzEyNTM1NCBMMTUuNzU3MjQ3OSw1LjU3MTI1MzU0IEMxNi4wODA5MTc0LDUuNzY1NDU1MjMgMTYuMDgwOTE3NCw2LjIzNDU0NDc3IDE1Ljc1NzI0NzksNi40Mjg3NDY0NiBMOC4yNTcyNDc4OCwxMC45Mjg3NDY1IEM4LjA5ODkwNjY4LDExLjAyMzc1MTIgNy45MDEwOTMzMiwxMS4wMjM3NTEyIDcuNzQyNzUyMTIsMTAuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsNi40Mjg3NDY0NiBDLTAuMDgwOTE3Mzc0MSw2LjIzNDU0NDc3IC0wLjA4MDkxNzM3NDEsNS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw1LjU3MTI1MzU0IEw3Ljc0Mjc1MjEyLDEuMDcxMjUzNTQgQzcuOTAxMDkzMzIsMC45NzYyNDg4MjEgOC4wOTg5MDY2OCwwLjk3NjI0ODgyMSA4LjI1NzI0Nzg4LDEuMDcxMjUzNTQgWiBNMTQuNTI4MTc0NywxMCBMMTMuNzQyNzUyMSw5LjUyODc0NjQ2IEMxMy41MDU5NjIsOS4zODY2NzIzOCAxMy40MjkxNzk1LDkuMDc5NTQyMjYgMTMuNTcxMjUzNSw4Ljg0Mjc1MjEyIEMxMy43MTMzMjc2LDguNjA1OTYxOTkgMTQuMDIwNDU3Nyw4LjUyOTE3OTQ2IDE0LjI1NzI0NzksOC42NzEyNTM1NCBMMTUuNzU3MjQ3OSw5LjU3MTI1MzU0IEMxNi4wODA5MTc0LDkuNzY1NDU1MjMgMTYuMDgwOTE3NCwxMC4yMzQ1NDQ4IDE1Ljc1NzI0NzksMTAuNDI4NzQ2NSBMOC4yNTcyNDc4OCwxNC45Mjg3NDY1IEM4LjA5ODkwNjY4LDE1LjAyMzc1MTIgNy45MDEwOTMzMiwxNS4wMjM3NTEyIDcuNzQyNzUyMTIsMTQuOTI4NzQ2NSBMMC4yNDI3NTIxMjIsMTAuNDI4NzQ2NSBDLTAuMDgwOTE3Mzc0MSwxMC4yMzQ1NDQ4IC0wLjA4MDkxNzM3NDEsOS43NjU0NTUyMyAwLjI0Mjc1MjEyMiw5LjU3MTI1MzU0IEwxLjc0Mjc1MjEyLDguNjcxMjUzNTQgQzEuOTc5NTQyMjYsOC41MjkxNzk0NiAyLjI4NjY3MjM4LDguNjA1OTYxOTkgMi40Mjg3NDY0Niw4Ljg0Mjc1MjEyIEMyLjU3MDgyMDU0LDkuMDc5NTQyMjYgMi40OTQwMzgwMSw5LjM4NjY3MjM4IDIuMjU3MjQ3ODgsOS41Mjg3NDY0NiBMMS40NzE4MjUzMiwxMCBMOCwxMy45MTY5MDQ4IEwxNC41MjgxNzQ3LDEwIFoiIGZpbGw9IiMwMDAiIGZpbGwtcnVsZT0ibm9uemVybyIvPjwvc3ZnPg=='); --icon-Idea: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTcsMC41IEM3LDAuMjIzODU3NjI1IDcuMjIzODU3NjMsMCA3LjUsMCBDNy43NzYxNDIzNywwIDgsMC4yMjM4NTc2MjUgOCwwLjUgTDgsMi41IEM4LDIuNzc2MTQyMzcgNy43NzYxNDIzNywzIDcuNSwzIEM3LjIyMzg1NzYzLDMgNywyLjc3NjE0MjM3IDcsMi41IEw3LDAuNSBaIE0xLjQ4OTQ0NjYxLDMuMTk2NTUzMzkgQzEuMjk0MTg0NDYsMy4wMDEyOTEyNCAxLjI5NDE4NDQ2LDIuNjg0NzA4NzYgMS40ODk0NDY2MSwyLjQ4OTQ0NjYxIEMxLjY4NDcwODc2LDIuMjk0MTg0NDYgMi4wMDEyOTEyNCwyLjI5NDE4NDQ2IDIuMTk2NTUzMzksMi40ODk0NDY2MSBMMy42MTA1NTMzOSwzLjkwMzQ0NjYxIEMzLjgwNTgxNTU0LDQuMDk4NzA4NzYgMy44MDU4MTU1NCw0LjQxNTI5MTI0IDMuNjEwNTUzMzksNC42MTA1NTMzOSBDMy40MTUyOTEyNCw0LjgwNTgxNTU0IDMuMDk4NzA4NzYsNC44MDU4MTU1NCAyLjkwMzQ0NjYxLDQuNjEwNTUzMzkgTDEuNDg5NDQ2NjEsMy4xOTY1NTMzOSBaIE0xMi44MDM0NDY2LDIuNDg5NDQ2NjEgQzEyLjk5ODcwODgsMi4yOTQxODQ0NiAxMy4zMTUyOTEyLDIuMjk0MTg0NDYgMTMuNTEwNTUzNCwyLjQ4OTQ0NjYxIEMxMy43MDU4MTU1LDIuNjg0NzA4NzYgMTMuNzA1ODE1NSwzLjAwMTI5MTI0IDEzLjUxMDU1MzQsMy4xOTY1NTMzOSBMMTIuMDk2NTUzNCw0LjYxMDU1MzM5IEMxMS45MDEyOTEyLDQuODA1ODE1NTQgMTEuNTg0NzA4OCw0LjgwNTgxNTU0IDExLjM4OTQ0NjYsNC42MTA1NTMzOSBDMTEuMTk0MTg0NSw0LjQxNTI5MTI0IDExLjE5NDE4NDUsNC4wOTg3MDg3NiAxMS4zODk0NDY2LDMuOTAzNDQ2NjEgTDEyLjgwMzQ0NjYsMi40ODk0NDY2MSBaIE0xMCwxMi4yMjMzNjY2IEwxMCwxNC41IEMxMCwxNC43NzYxNDI0IDkuNzc2MTQyMzcsMTUgOS41LDE1IEw1LjUsMTUgQzUuMjIzODU3NjMsMTUgNSwxNC43NzYxNDI0IDUsMTQuNSBMNSwxMi4yMjI2NzMyIEMzLjI4NDM3MTQ2LDExLjA3Nzg0MDQgMi41NTY2OTc1Niw4Ljg5NTgwMTU4IDMuMjczODk0MTMsNi45MzUwNzcwNyBDNC4wMjUwMDkzMyw0Ljg4MTYyMzMxIDYuMTQzNDksMy42NjUyNjIzMiA4LjI5NTU2NjkyLDQuMDUxNzk5OTQgQzEwLjQ0NzQzMTksNC40MzgyOTk0OSAxMi4wMTAxMTM2LDYuMzE1NTI0NzggMTEuOTk5OTk3Myw4LjUwMTczODM2IEMxMS45OTY0OTg2LDEwLjAwNDU1NjkgMTEuMjQwNzkxOSwxMS4zOTg0NzI3IDEwLDEyLjIyMzM2NjYgWiBNOSwxMS45NDUgQzksMTEuNzY1OTAwOCA5LjA5NTc5MzU2LDExLjYwMDQ4MDUgOS4yNTExMzEyOCwxMS41MTEzMzYxIEMxMC4zMzA1NDU2LDEwLjg5MTg4NzYgMTAuOTk3MjgyNCw5Ljc0MzQzMzQ2IDExLjAwMDAwMTIsOC40OTg5MDc2OSBDMTEuMDA4MTE1Nyw2Ljc5NzAxMjQgOS43OTI2MjE5OCw1LjMzNjY5MDI0IDguMTE4Nzg0MzgsNS4wMzYwNDk4OCBDNi40NDQ5NDY3OCw0LjczNTQwOTUxIDQuNzk3MjM5NTksNS42ODE0NjgwNSA0LjIxMzAzODg4LDcuMjc4NTk4NzUgQzMuNjI4ODM4MTYsOC44NzU3Mjk0NSA0LjI3NzIzNDU1LDEwLjY2MTY2IDUuNzUwMDA1NjksMTEuNTExOTkwNiBDNS45MDQ3MDMwMiwxMS42MDEzMDc4IDYsMTEuNzY2MzY5NiA2LDExLjk0NSBMNiwxNCBMOSwxNCBMOSwxMS45NDUgWiBNMTMuNSw5IEMxMy4yMjM4NTc2LDkgMTMsOC43NzYxNDIzNyAxMyw4LjUgQzEzLDguMjIzODU3NjMgMTMuMjIzODU3Niw4IDEzLjUsOCBMMTQuNSw4IEMxNC43NzYxNDI0LDggMTUsOC4yMjM4NTc2MyAxNSw4LjUgQzE1LDguNzc2MTQyMzcgMTQuNzc2MTQyNCw5IDE0LjUsOSBMMTMuNSw5IFogTTAuNSw5IEMwLjIyMzg1NzYyNSw5IDAsOC43NzYxNDIzNyAwLDguNSBDMCw4LjIyMzg1NzYzIDAuMjIzODU3NjI1LDggMC41LDggTDEuNSw4IEMxLjc3NjE0MjM3LDggMiw4LjIyMzg1NzYzIDIsOC41IEMyLDguNzc2MTQyMzcgMS43NzYxNDIzNyw5IDEuNSw5IEwwLjUsOSBaIiBmaWxsPSIjMDAwIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4='); diff --git a/static/ui-icons/UI/Grow.svg b/static/ui-icons/UI/Grow.svg new file mode 100644 index 00000000..63eb12cf --- /dev/null +++ b/static/ui-icons/UI/Grow.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index d8d07f14..211d271f 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -238,6 +238,19 @@ export async function selectSectionByTitle(title: string) { } } +export async function expandSection(title?: string) { + const select = title + ? driver.findContent(`.test-viewsection-title`, exactMatch(title)).findClosest(".viewsection_title") + : driver.find(".active_section"); + await select.find(".test-section-menu-expandSection").click(); +} + +export async function getSectionId() { + const classList = await driver.find(".active_section").getAttribute("class"); + const match = classList.match(/test-viewlayout-section-(\d+)/); + if (!match) { throw new Error("Could not find section id"); } + return parseInt(match[1]); +} /** * Returns visible cells of the GridView from a single column and one or more rows. Options may be