(core) Expanding widgets

Summary:
New icon to expand an active section and show it as a popup (just like raw data views).

"Show raw data" popup couldn't be reused (as it is basically a different page), so now
we have two kinds of popups that look the same.

1. Raw data popup - to show an alien section on a page (a section from a different view). This is used by "Show raw data" button, it is basically a different page that shows an arbitrary section.

2. Layout popup - a popup generated by Layout.ts that basically hides every other section and adds an overlay effect to itself.

Other changes
- Layout.js was migrated to typescript
- "Show raw data" menu item was converted to link

Test Plan: new tests

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3764
pull/409/head
Jarosław Sadziński 1 year ago
parent ff901c06d2
commit 1dafe4bae0

@ -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();

@ -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<boolean>;
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<number|null> = 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<PopupOptions|null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage|PopupOptions>;
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
private _activeContent: Computed<IDocPage|RawSectionOptions>;
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<number>));
// 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.

@ -88,3 +88,7 @@
.layout_leaf_test_big {
min-height: 7rem;
}
.layout_hidden {
display: none;
}

@ -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;
};

@ -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<any>; // probably number for section id
public parentBox: ko.Observable<LayoutBox|null>;
public childBoxes: KoArray<LayoutBox>;
public leafContent: ko.Observable<Element|null>;
public uniqueId: string;
public isVBox: ko.Computed<boolean>;
public isHBox: ko.Computed<boolean>;
public isLeaf: ko.Computed<boolean>;
public isMaximized: ko.Computed<boolean>;
public isHidden: ko.Computed<boolean>;
public flexSize: ko.Observable<number>;
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<string|null>;
public rootBox: ko.Observable<LayoutBox|null>;
public createLeafFunc: (id: string) => HTMLElement;
public fillWindow: boolean;
public needDynamic: boolean;
public rootElem: HTMLElement;
public leafId: string;
private _leafIdMap: Map<any, LayoutBox>|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<number|string, LayoutBox>();
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);

@ -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;

@ -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<object>;
public maximized: Observable<number|null>;
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;
}
}
`);

@ -111,6 +111,11 @@ exports.groups = [{
keys: [],
desc: 'Open Custom widget configuration screen',
},
{
name: 'maximizeActiveSection',
keys: [],
desc: 'Maximize the active section',
},
{
name: 'leftPanelOpen',
keys: [],

@ -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<void>;
public getAnchorLinkForSection(sectionId: number): IGristUrlState;
}
export = BaseView;
}

@ -24,8 +24,10 @@ declare class KoArray<T> {
public clampIndex(index: number): number|null;
public makeLiveIndex(index?: number): ko.Observable<number> & {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<T>(initialValue?: T[]): KoArray<T>;
export function isKoArray(obj: any): obj is KoArray<any>;

@ -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: ''},

@ -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};

@ -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",

@ -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=');

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.5 1.5L6.5 6.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.5 9.5L14.5 14.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
<path d="M7.5 1.5H1.5V7.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14.5 8.5V14.5H8.5" stroke="#929299" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 579 B

@ -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

Loading…
Cancel
Save