(core) Minimazing widgets

Summary:
A feature that allows minimizing widgets on the ViewLayout.
- Code in ViewLayout and Layout hasn't been changed. Only some methods or variables were made public, and some events are now triggered when a section is dragged.
- Widgets can be collapsed or expanded (added back to the main area)
- Collapsed widgets can be expanded and shown as a popup
- Collapsed widgets support drugging, reordering, and transferring between the main and collapsed areas.

Test Plan: New test

Reviewers: georgegevoian

Reviewed By: georgegevoian

Differential Revision: https://phab.getgrist.com/D3779
This commit is contained in:
Jarosław Sadziński 2023-02-24 12:12:55 +01:00
parent e9efac05f7
commit 59cf654190
19 changed files with 1949 additions and 259 deletions

View File

@ -229,8 +229,7 @@ export class ChartView extends Disposable {
this.listenTo(this.sortedRows, 'rowNotify', this._update);
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
this.autoDispose(this._formatterComp.subscribe(this._update));
this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() =>
this._update()));
this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() => this._update()));
}
public prepareToPrint(onOff: boolean) {

View File

@ -166,10 +166,17 @@ 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);
// One of the section can be expanded (as requested from the Layout), we will
// store its id in this variable. NOTE: expanded section looks exactly the same as a section
// in the popup. But they are rendered differently, as section in popup is probably an external
// section (or raw data section) that is not part of this view. Maximized section is a section
// in the view, so there is no need to render it twice, layout just hides all other sections to make
// the space.
public maximizedSectionId: Observable<number|null> = Observable.create(this, null);
// This is id of the section that is currently shown in the popup. Probably this is an external
// section, like raw data view, or a section from another view..
public externalSectionId: Computed<number|null>;
public viewLayout: ViewLayout|null = null;
private _actionLog: ActionLog;
private _undoStack: UndoStack;
@ -178,7 +185,6 @@ export class GristDoc extends DisposableWithEvents {
private _docHistory: DocHistory;
private _discussionPanel: DiscussionPanel;
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
private _viewLayout: ViewLayout|null = null;
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
@ -229,6 +235,10 @@ export class GristDoc extends DisposableWithEvents {
return viewId || use(defaultViewId);
});
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId));
this.externalSectionId = Computed.create(this, use => {
const externalContent = use(this._rawSectionOptions);
return externalContent ? use(externalContent.viewSection.id) : null;
});
// 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,
@ -238,10 +248,10 @@ export class GristDoc extends DisposableWithEvents {
// When active section is changed, clear the maximized state.
this.autoDispose(this.viewModel.activeSectionId.subscribe(() => {
this.sectionInPopup.set(null);
this.maximizedSectionId.set(null);
// If we have layout, update it.
if (!this._viewLayout?.isDisposed()) {
this._viewLayout?.maximized.set(null);
if (!this.viewLayout?.isDisposed()) {
this.viewLayout?.maximized.set(null);
}
}));
@ -444,7 +454,7 @@ export class GristDoc extends DisposableWithEvents {
* Builds the DOM for this GristDoc.
*/
public buildDom() {
const isMaximized = Computed.create(this, use => use(this.sectionInPopup) !== null);
const isMaximized = Computed.create(this, use => use(this.maximizedSectionId) !== null);
const isPopup = Computed.create(this, use => {
return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages
|| use(isMaximized) // Layout has a maximized section visible
@ -468,9 +478,10 @@ export class GristDoc extends DisposableWithEvents {
return dom.create(RawDataPopup, this, content.viewSection, content.close);
}) :
dom.create((owner) => {
this._viewLayout = ViewLayout.create(owner, this, content);
this._viewLayout.maximized.addListener(n => this.sectionInPopup.set(n));
return this._viewLayout;
this.viewLayout = ViewLayout.create(owner, this, content);
this.viewLayout.maximized.addListener(n => this.maximizedSectionId.set(n));
owner.onDispose(() => this.viewLayout = null);
return this.viewLayout;
})
);
}),
@ -700,7 +711,7 @@ export class GristDoc extends DisposableWithEvents {
return section;
}
return await this._viewLayout!.freezeUntil(docData.bundleActions(
return await this.viewLayout!.freezeUntil(docData.bundleActions(
t("Saved linked section {{title}} in view {{name}}", {title:section.title(), name: viewModel.name()}),
async () => {
@ -1021,7 +1032,7 @@ export class GristDoc extends DisposableWithEvents {
// 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)) {
if (this.viewModel.viewSections.peek().peek().some(s => s.id.peek() === hash.sectionId && !s.isCollapsed.peek())) {
this.viewModel.activeSectionId(hash.sectionId);
// If the anchor link is valid, set the cursor.
if (hash.colRef && hash.rowId) {
@ -1178,7 +1189,7 @@ export class GristDoc extends DisposableWithEvents {
// we must read the current layout from the view layout because it can override the one in
// `section.layoutSpec` (in particular it provides a default layout when missing from the
// latter).
const layoutSpec = this._viewLayout!.layoutSpec();
const layoutSpec = this.viewLayout!.layoutSpec();
const sectionTitle = section.title();
const sectionId = section.id();

View File

@ -66,6 +66,14 @@ import {identity, last, uniqueId} from 'underscore';
export interface ContentBox {
leafId: ko.Observable<any>;
leafContent: ko.Observable<Element|null>;
dom: HTMLElement|null;
}
export interface BoxSpec {
leaf?: string|number;
size?: number;
children?: BoxSpec[];
collapsed?: BoxSpec[];
}
/**
@ -372,7 +380,7 @@ export class Layout extends Disposable {
public leafId: string;
private _leafIdMap: Map<any, LayoutBox>|null;
public create(boxSpec: object, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) {
public create(boxSpec: BoxSpec, createLeafFunc: (id: string) => HTMLElement, optFillWindow: boolean) {
this.maximized = observable(null as (string|null));
this.rootBox = observable(null as any);
this.createLeafFunc = createLeafFunc;
@ -424,13 +432,16 @@ export class Layout extends Disposable {
* Calls cb on each box in the layout recursively.
*/
public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) {
if (!this.rootBox.peek()) {
return;
}
function iter(box: any) {
cb.call(optContext, box);
box.childBoxes.peek().forEach(iter);
}
iter(this.rootBox.peek());
}
public buildLayoutBox(boxSpec: any) {
public buildLayoutBox(boxSpec: BoxSpec) {
// 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);
@ -445,7 +456,7 @@ export class Layout extends Disposable {
}
return box;
}
public buildLayout(boxSpec: any, needDynamic = false) {
public buildLayout(boxSpec: BoxSpec, needDynamic = false) {
this.needDynamic = needDynamic;
const oldRootBox = this.rootBox();
this.rootBox(this.buildLayoutBox(boxSpec));
@ -455,7 +466,7 @@ export class Layout extends Disposable {
}
}
public _getBoxSpec(layoutBox: LayoutBox) {
const spec: any = {};
const spec: BoxSpec = {};
if (layoutBox.isDisposed()) {
return spec;
}

View File

@ -89,40 +89,42 @@ interface JqueryUI {
originalSize: { width: number, height: number };
}
type LeafId = string|number;
/**
* The Floater class represents a floating version of the element being dragged around. Its size
* corresponds to the box being dragged. It lets the user see what's being repositioned.
*/
class Floater extends Disposable implements ContentBox {
public leafId: ko.Observable<string | null>;
public leafId: ko.Observable<LeafId|null>;
public leafContent: ko.Observable<Element | null>;
public fillWindow: boolean;
public floaterElem: HTMLElement;
public dom: HTMLElement;
public mouseOffsetX: number;
public mouseOffsetY: number;
public lastMouseEvent: MouseEvent | null;
public create(fillWindow?: boolean) {
this.leafId = observable<string|null>(null);
this.leafId = observable<LeafId|null>(null);
this.leafContent = observable<Element | null>(null);
this.fillWindow = fillWindow || false;
this.floaterElem = this.autoDispose(dom('div.layout_editor_floater',
this.dom = this.autoDispose(dom('div.layout_editor_floater',
koDom.show(this.leafContent),
koDom.scope(this.leafContent, (leafContent: Element) => {
return leafContent;
})
));
G.document.body.appendChild(this.floaterElem);
G.document.body.appendChild(this.dom);
this.mouseOffsetX = 0;
this.mouseOffsetY = 0;
this.lastMouseEvent = null;
}
public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: LayoutBox) {
public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: ContentBox) {
const rect = sourceBox.dom!.getBoundingClientRect();
this.floaterElem.style.width = rect.width + 'px';
this.floaterElem.style.height = rect.height + 'px';
this.dom.style.width = rect.width + 'px';
this.dom.style.height = rect.height + 'px';
this.mouseOffsetX = 0.2 * rect.width;
this.mouseOffsetY = 0.1 * rect.height;
this.onMouseMove(mouseEvent);
@ -141,8 +143,8 @@ class Floater extends Disposable implements ContentBox {
}
public onMouseMove(mouseEvent: MouseEvent) {
this.lastMouseEvent = mouseEvent;
this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px';
this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px';
this.dom.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px';
this.dom.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px';
}
}
@ -240,6 +242,10 @@ class DropTargeter extends Disposable {
this.autoDisposeCallback(this.removeTargetHints);
}
public removeTargetHints() {
if (this.activeTarget?.box?.dom) {
this.activeTarget.box.dom.style.transition = '';
this.activeTarget.box.dom.style.padding = '0';
}
this.activeTarget = null;
this.delayedInsertion.cancel();
if (this.targetsDom) {
@ -253,7 +259,7 @@ class DropTargeter extends Disposable {
layoutBox: LayoutBox|null,
affinity: number,
overlay: DropOverlay,
prevTargetBox: LayoutBox
prevTargetBox?: LayoutBox
) {
// Nothing to update.
if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) {
@ -280,7 +286,7 @@ class DropTargeter extends Disposable {
const children = layoutBox.childBoxes.peek();
// If one of two children is prevTargetBox, replace the last target hint since it
// will be redundant once prevTargetBox is removed.
if (children.length === 2 && prevTargetBox.parentBox() === layoutBox) {
if (children.length === 2 && prevTargetBox?.parentBox() === layoutBox) {
targetParts.splice(targetParts.length - 1, 1,
{box: layoutBox, isChild: false, isAfter: isAfter});
}
@ -572,6 +578,7 @@ export class LayoutEditor extends Disposable {
this.triggerUserEditStart();
this.targetBox = box;
this.floater.onInitialMouseMove(event, box);
this.trigger('dragStart', this.originalBox);
}
public handleMouseUp(event: MouseEvent) {
G.$(G.window).off('mousemove', this.boundMouseMove);
@ -582,16 +589,27 @@ export class LayoutEditor extends Disposable {
return;
}
// We stopped dragging, any listener can clean its modification
// to the floater element.
this.trigger('dragStop');
this.targetBox!.takeLeafFrom(this.floater);
if (this.dropTargeter.activeTarget) {
this.dropTargeter.accelerateInsertion();
} else {
resizeLayoutBox(this.targetBox!, 'reset');
// We dropped back the box to its original position, now
// anyone can hijack the box.
this.trigger('dragDrop', this.targetBox);
// Check if the box was hijacked by a drop target.
if (this.originalBox?.leafId() !== 'empty') {
if (this.dropTargeter.activeTarget) {
this.dropTargeter.accelerateInsertion();
} else {
resizeLayoutBox(this.targetBox!, 'reset');
}
}
this.dropTargeter.removeTargetHints();
this.dropOverlay.detach();
this.trigger('dragEnd');
// Cleanup for any state.
this.transitionPromise.finally(() => {
this.floater.onMouseUp();
resizeLayoutBox(this.targetBox!, 'reset');
@ -600,20 +618,36 @@ export class LayoutEditor extends Disposable {
this.triggerUserEditStop();
});
}
public removeContainingBox(elem: HTMLElement) {
public getBoxFromElement(elem: HTMLElement) {
const box = this.layout.getContainingBox(elem);
if (box && !box.isDomDetached()) {
return box;
}
return null;
}
public getBox(leafId: number) {
return this.layout.getLeafBox(leafId);
}
public removeContainingBox(box: LayoutBox) {
if (box && !box.isDomDetached()) {
this.triggerUserEditStart();
this.targetBox = box;
const rect = box.dom.getBoundingClientRect();
box.leafId('empty');
box.leafContent(dom('div.layout_editor_empty_space',
koDom.style('min-height', rect.height + 'px')
));
this.onInsertBox(noop).catch(noop);
this.doRemoveBox(box);
this.triggerUserEditStop();
}
}
public doRemoveBox(box: ContentBox) {
const rect = box.dom!.getBoundingClientRect();
box.leafId('empty');
box.leafContent(dom('div.layout_editor_empty_space',
koDom.style('min-height', rect.height + 'px')
));
this.onInsertBox(noop).catch(noop);
}
public handleMouseMove(event: MouseEvent) {
// Make sure the grabbed box still exists
if (!this.originalBox || this.originalBox?.isDisposed()) {
@ -626,6 +660,8 @@ export class LayoutEditor extends Disposable {
}
this.floater.onMouseMove(event);
this.trigger('dragMove', event, this.originalBox);
if (this.transitionPromise.isPending()) {
// Don't attempt to do any repositioning while another reposition is happening.
return;
@ -642,7 +678,14 @@ export class LayoutEditor extends Disposable {
return;
}
this.trashDelay.cancel();
this.updateTargets(event);
}
public updateTargets(event: MouseEvent) {
if (this.transitionPromise.isPending()) {
// Don't attempt to do any repositioning while another reposition is happening.
return;
}
// See if we are over a layout_leaf, and that the leaf is in the same layout as the dragged
// element. If so, we are dealing with repositioning.
const elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId);

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,13 @@ import {DataTables} from 'app/client/components/DataTables';
import {DocumentUsage} from 'app/client/components/DocumentUsage';
import {GristDoc} from 'app/client/components/GristDoc';
import {printViewSection} from 'app/client/components/Printing';
import {buildViewSectionDom, ViewSectionHelper} from 'app/client/components/ViewLayout';
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {mediaSmall, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
import {reportError} from 'app/client/models/errors';
import {ViewSectionRec} from 'app/client/models/DocModel';
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
const testId = makeTestId('test-raw-data-');
@ -76,6 +77,13 @@ export class RawDataPopup extends Disposable {
super();
const commandGroup = {
cancel: () => { this._onClose(); },
deleteSection: () => {
// Normally this command is disabled on the menu, but for collapsed section it is active.
if (this._viewSection.isRaw.peek()) {
throw new Error("Can't delete a raw section");
}
this._gristDoc.docData.sendAction(['RemoveViewSection', this._viewSection.id.peek()]).catch(reportError);
},
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
}
@ -89,7 +97,7 @@ export class RawDataPopup extends Disposable {
sectionRowId: this._viewSection.getRowId(),
draggable: false,
focusable: false,
widgetNameHidden: true
widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections.
})
),
cssCloseButton('CrossBig',

View File

@ -130,7 +130,8 @@ RecordLayoutEditor.prototype.buildLeafDom = function() {
dom.on('click', (ev, elem) => {
ev.preventDefault();
ev.stopPropagation();
this.layoutEditor.removeContainingBox(elem);
const box = this.layoutEditor.getBoxFromElement(elem);
this.layoutEditor.removeContainingBox(box);
})
)
);

View File

@ -17,10 +17,13 @@
cursor: default;
height: 24px;
margin-left: -16px; /* to include drag handle that shows up on hover */
white-space: nowrap;
}
.viewsection_title, .viewsection_title_font {
color: var(--grist-theme-text-light, var(--grist-color-slate));
font-size: var(--grist-small-font-size);
font-weight: 500;
white-space: nowrap;
}
.viewsection_content {

View File

@ -5,24 +5,24 @@ import {CustomView} from 'app/client/components/CustomView';
import * as DetailView from 'app/client/components/DetailView';
import * as GridView from 'app/client/components/GridView';
import {GristDoc} from 'app/client/components/GristDoc';
import {Layout} from 'app/client/components/Layout';
import {BoxSpec, Layout} from 'app/client/components/Layout';
import {LayoutEditor} from 'app/client/components/LayoutEditor';
import {printViewSection} from 'app/client/components/Printing';
import {Delay} from 'app/client/lib/Delay';
import {createObsArray} from 'app/client/lib/koArrayWrap';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {reportError} from 'app/client/models/errors';
import {filterBar} from 'app/client/ui/FilterBar';
import {viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
import {colors, isNarrowScreen, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {LayoutTray} from 'app/client/components/LayoutTray';
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
import {mod} from 'app/common/gutil';
import * as ko from 'knockout';
import * as _ from 'underscore';
import debounce from 'lodash/debounce';
import {computedArray, Disposable, dom, fromKo, Holder, IDomComponent, Observable, styled, subscribe} from 'grainjs';
import {Computed, computedArray, Disposable, dom, fromKo, Holder,
IDomComponent, Observable, styled, subscribe} from 'grainjs';
// tslint:disable:no-console
@ -49,8 +49,23 @@ export class ViewSectionHelper extends Disposable {
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
super();
this.onDispose(() => vs.viewInstance(null));
// If this is a collapsed section (but not active), don't create an instance (or remove the old one).
// Collapsed section can be expanded and shown in the popup window, it will be active then.
// This is important to avoid recreating the instance when the section is collapsed, but mainly for the
// charts as they are not able to handle being detached from the dom.
const hidden = Computed.create(this, (use) => {
// Note: this is a separate computed from the one below (with subscribe method), because we don't want
// trigger it unnecessarily.
return use(vs.isCollapsed) && use(gristDoc.externalSectionId) !== use(vs.id);
});
this.autoDispose(subscribe((use) => {
// Destroy the instance if the section is hidden.
if (use(hidden)) {
this._instance.clear();
vs.viewInstance(null);
return;
}
// Rebuild the section when its type changes or its underlying table.
const table = use(vs.table);
const Cons = getInstanceConstructor(use(vs.parentKey));
@ -69,18 +84,22 @@ export class ViewSectionHelper extends Disposable {
export class ViewLayout extends DisposableWithEvents implements IDomComponent {
public docModel = this.gristDoc.docModel;
public viewModel: ViewRec;
public layoutSpec: ko.Computed<object>;
public layoutSpec: ko.Computed<BoxSpec>;
public maximized: Observable<number|null>;
public isResizing = Observable.create(this, false);
public layout: Layout;
public layoutEditor: LayoutEditor;
public layoutTray: LayoutTray;
public layoutSaveDelay = this.autoDispose(new Delay());
private _freeze = false;
private _layout: Layout;
private _sectionIds: number[];
private _isResizing = Observable.create(this, false);
// Exposed for test to indicate that save has not yet been called.
private _savePending = Observable.create(this, false);
constructor(public readonly gristDoc: GristDoc, viewId: number) {
super();
this.viewModel = this.docModel.views.getRowModel(viewId);
// A Map from viewSection RowModels to corresponding View class instances.
// TODO add a test that creating / deleting a section creates/destroys one instance, and
// switching pages destroys all instances.
@ -93,40 +112,34 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
() => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj()))
.extend({rateLimit: 0}));
this._layout = this.autoDispose(Layout.create(this.layoutSpec(),
this.layout = this.autoDispose(Layout.create(this.layoutSpec(),
this._buildLeafContent.bind(this), true));
this._sectionIds = this._layout.getAllLeafIds();
// When the layoutSpec changes by some means other than the layout editor, rebuild.
// This includes adding/removing sections and undo/redo.
this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this._rebuildLayout(spec)));
this.autoDispose(this.layoutSpec.subscribe((spec) => this._freeze || this.rebuildLayout(spec)));
const layoutSaveDelay = this.autoDispose(new Delay());
this.listenTo(this._layout, 'layoutUserEditStop', () => {
this._isResizing.set(false);
layoutSaveDelay.schedule(1000, () => {
if (!this._layout) { return; }
// Only save layout changes when the document isn't read-only.
if (!this.gristDoc.isReadonly.get()) {
(this.viewModel.layoutSpecObj as any).setAndSave(this._layout.getLayoutSpec());
}
this._onResize();
this.listenTo(this.layout, 'layoutUserEditStop', () => {
this.isResizing.set(false);
this.layoutSaveDelay.schedule(1000, () => {
this.saveLayoutSpec();
});
});
// Do not save if the user has started editing again.
this.listenTo(this._layout, 'layoutUserEditStart', () => {
layoutSaveDelay.cancel();
this._isResizing.set(true);
this.listenTo(this.layout, 'layoutUserEditStart', () => {
this.layoutSaveDelay.cancel();
this._savePending.set(true);
this.isResizing.set(true);
});
this.autoDispose(LayoutEditor.create(this._layout));
this.layoutEditor = this.autoDispose(LayoutEditor.create(this.layout));
this.layoutTray = LayoutTray.create(this, this);
// Add disposal of this._layout after layoutEditor, so that it gets disposed first, and
// layoutEditor doesn't attempt to update it in its own disposal logic.
this.onDispose(() => this._layout.dispose());
this.onDispose(() => this.layout.dispose());
this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this));
@ -148,19 +161,19 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
// Make resize.
view.onResize();
}, 0);
this._layout.rootElem.addEventListener('transitionend', handler);
this.layout.rootElem.addEventListener('transitionend', handler);
// Don't need to dispose the listener, as the rootElem is disposed with the layout.
const classActive = cssLayoutBox.className + '-active';
const classInactive = cssLayoutBox.className + '-inactive';
this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => {
const id = section.getRowId();
this._layout.forEachBox(box => {
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 || null;
let elem: Element|null = this.layout.getLeafBox(id)?.dom || null;
while (elem?.matches('.layout_box')) {
elem.classList.remove(classInactive);
elem.classList.add(classActive);
@ -172,10 +185,10 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
}));
const commandGroup = {
deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); },
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); },
nextSection: () => { this._otherSection(+1); },
prevSection: () => { this._otherSection(-1); },
printSection: () => { printViewSection(this._layout, this.viewModel.activeSection()).catch(reportError); },
printSection: () => { printViewSection(this.layout, this.viewModel.activeSection()).catch(reportError); },
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
maximizeActiveSection: () => { this._maximizeActiveSection(); },
cancel: () => {
@ -186,7 +199,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
};
this.autoDispose(commands.createGroup(commandGroup, this, true));
this.maximized = fromKo(this._layout.maximized) as any;
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.
@ -201,9 +214,12 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
return cssOverlay(
cssOverlay.cls('-active', use => !!use(this.maximized)),
testId('viewLayout-overlay'),
cssLayoutWrapper(
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
this._layout.rootElem,
cssLayoutContainer(
this.layoutTray.buildDom(),
cssLayoutWrapper(
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
this.layout.rootElem,
),
),
dom.maybe(use => !!use(this.maximized), () =>
cssCloseButton('CrossBig',
@ -212,7 +228,8 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
)
),
// Close the lightbox when user clicks exactly on the overlay.
dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null))
dom.on('click', (ev, elem) => void (ev.target === elem && this.maximized.get() ? close() : null)),
dom.cls('test-viewLayout-save-pending', this._savePending)
);
}
@ -225,20 +242,41 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
return await promise;
} finally {
this._freeze = false;
this._rebuildLayout(this.layoutSpec.peek());
this.rebuildLayout(this.layoutSpec.peek());
}
}
public saveLayoutSpec(specs?: BoxSpec) {
this._savePending.set(false);
// Cancel the automatic delay.
this.layoutSaveDelay.cancel();
if (!this.layout) { return; }
// Only save layout changes when the document isn't read-only.
if (!this.gristDoc.isReadonly.get()) {
if (!specs) {
specs = this.layout.getLayoutSpec();
specs.collapsed = this.viewModel.activeCollapsedSections.peek().map((leaf)=> ({leaf}));
}
this.viewModel.layoutSpecObj.setAndSave(specs).catch(reportError);
}
this._onResize();
}
// Removes a view section from the current view. Should only be called if there is
// more than one viewsection in the view.
private _removeViewSection(viewSectionRowId: number) {
public removeViewSection(viewSectionRowId: number) {
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
}
public rebuildLayout(layoutSpec: object) {
this.layout.buildLayout(layoutSpec, true);
this._onResize();
}
private _maximizeActiveSection() {
const activeSection = this.viewModel.activeSection();
const activeSectionId = activeSection.getRowId();
const activeSectionBox = this._layout.getLeafBox(activeSectionId);
const activeSectionBox = this.layout.getLeafBox(activeSectionId);
if (!activeSectionBox) { return; }
activeSectionBox.maximize();
}
@ -247,7 +285,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
return buildViewSectionDom({
gristDoc: this.gristDoc,
sectionRowId,
isResizing: this._isResizing,
isResizing: this.isResizing,
viewModel: this.viewModel
});
}
@ -256,7 +294,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
* If there is no layout saved, we can create a default layout just from the list of fields for
* this view section. By default we just arrange them into a list of rows, two fields per row.
*/
private _updateLayoutSpecWithSections(spec: object) {
private _updateLayoutSpecWithSections(spec: BoxSpec) {
// We use tmpLayout as a way to manipulate the layout before we get a final spec from it.
const tmpLayout = Layout.create(spec, () => dom('div'), true);
@ -278,15 +316,18 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
}
// For any stale fields (no longer among viewFields), remove them from tmpLayout.
_.difference(specFieldIds, viewSectionIds).forEach(function(leafId: any) {
_.difference(specFieldIds, viewSectionIds).forEach(function(leafId: string|number) {
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: any) {
// Only add the builder box if it hasn`t already been created
addToSpec(leafId);
const missingLeafs = _.difference(viewSectionIds, specFieldIds);
const collapsedLeafs = new Set((spec.collapsed || []).map(c => c.leaf));
missingLeafs.forEach(function(leafId: any) {
if (!collapsedLeafs.has(leafId)) {
addToSpec(leafId);
}
});
spec = tmpLayout.getLayoutSpec();
@ -294,11 +335,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
return spec;
}
private _rebuildLayout(layoutSpec: object) {
this._layout.buildLayout(layoutSpec, true);
this._onResize();
this._sectionIds = this._layout.getAllLeafIds();
}
// Resizes the scrolly windows of all viewSection classes with a 'scrolly' property.
private _onResize() {
@ -313,17 +350,17 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
// Select another section in cyclic ordering of sections. Order is counter-clockwise if given a
// positive `delta`, clockwise otherwise.
private _otherSection(delta: number) {
const sectionIds = this.layout.getAllLeafIds();
const sectionId = this.viewModel.activeSectionId.peek();
const currentIndex = this._sectionIds.indexOf(sectionId);
const index = mod(currentIndex + delta, this._sectionIds.length);
const currentIndex = sectionIds.indexOf(sectionId);
const index = mod(currentIndex + delta, sectionIds.length);
// update the active section id
this.viewModel.activeSectionId(this._sectionIds[index]);
this.viewModel.activeSectionId(sectionIds[index]);
}
private _maybeFocusInSection() {
// If the focused element is inside a view section, make that section active.
const layoutBox = this._layout.getContainingBox(document.activeElement);
const layoutBox = this.layout.getContainingBox(document.activeElement);
if (layoutBox && layoutBox.leafId) {
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
}
@ -336,7 +373,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
*/
private _openSortFilterMenu(sectionId?: number) {
const id = sectionId ?? this.viewModel.activeSectionId();
const leafBoxDom = this._layout.getLeafBox(id)?.dom;
const leafBoxDom = this.layout.getLeafBox(id)?.dom;
if (!leafBoxDom) { return; }
const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter');
@ -344,125 +381,6 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
}
}
export function buildViewSectionDom(options: {
gristDoc: GristDoc,
sectionRowId: number,
isResizing?: Observable<boolean>
viewModel?: ViewRec,
// Should show drag anchor.
draggable?: boolean, /* defaults to true */
// Should show green bar on the left (but preserves active-section class).
focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
}) {
const isResizing = options.isResizing ?? Observable.create(null, false);
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options;
// Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
testId(`viewlayout-section-${sectionRowId}`),
!options.isResizing ? dom.autoDispose(isResizing) : null,
cssViewLeaf.cls(''),
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
dom.cls('active_section', vs.hasFocus),
dom.cls('active_section--no-indicator', !focusable),
dom.maybe<BaseView|null>((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox',
cssDragIcon('DragDrop',
dom.cls("viewsection_drag_indicator"),
// Makes element grabbable only if grist is not readonly.
dom.cls('layout_grabbable', (use) => !use(gristDoc.isReadonlyKo)),
!draggable ? dom.style("visibility", "hidden") : null
),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs)
)
)),
dom.create(filterBar, gristDoc, vs),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),
dom.maybe(viewInstance.disableEditing, () =>
dom('div.disable_viewpane.flexvbox', 'No data')
),
dom.maybe(viewInstance.isTruncated, () =>
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane,
),
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
]),
dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }),
);
}
// With new widgetPopup it is hard to click on viewSection without a activating it, hence we
// add a little blank space to use in test.
const cssTestClick = styled(`div`, `
min-width: 2px;
`);
const cssSigmaIcon = styled(icon, `
bottom: 1px;
margin-right: 5px;
background-color: ${theme.lightText}
`);
const cssViewLeaf = styled('div', `
@media ${mediaSmall} {
& {
margin: 4px;
}
}
`);
const cssViewLeafInactive = styled('div', `
@media screen and ${mediaSmall} {
& {
overflow: hidden;
background: repeating-linear-gradient(
-45deg,
${theme.widgetInactiveStripesDark},
${theme.widgetInactiveStripesDark} 10px,
${theme.widgetInactiveStripesLight} 10px,
${theme.widgetInactiveStripesLight} 20px
);
border: 1px solid ${theme.widgetBorder};
border-radius: 4px;
padding: 0 2px;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
&.layout_vbox {
max-width: 32px;
}
&.layout_hbox {
max-height: 32px;
}
& > .viewsection_title.flexhbox {
position: absolute;
}
& > .view_data_pane_container,
& .viewsection_buttons,
& .grist-single-record__menu,
& > .filter_bar {
display: none;
}
}
`);
const cssLayoutBox = styled('div', `
@media screen and ${mediaSmall} {
&-active, &-inactive {
@ -491,27 +409,11 @@ const cssLayoutBox = styled('div', `
}
`);
// z-index ensure it's above the resizer line, since it's hard to grab otherwise
const cssDragIcon = styled(icon, `
visibility: hidden;
--icon-color: ${colors.slate};
top: -1px;
z-index: 100;
.viewsection_title:hover &.layout_grabbable {
visibility: visible;
}
`);
// This class is added while sections are being resized (or otherwise edited), to ensure that the
// content of the section (such as an iframe) doesn't interfere with mouse drag-related events.
// (It assumes that contained elements do not set pointer-events to another value; if that were
// important then we'd need to use an overlay element during dragging.)
const cssResizing = styled('div', `
pointer-events: none;
`);
const cssLayoutWrapper = styled('div', `
display: flex;
flex-direction: column;
position: relative;
flex-grow: 1;
@media not print {
&-active {
background: ${theme.mainPanelBg};
@ -536,6 +438,7 @@ const cssLayoutWrapper = styled('div', `
`);
const cssOverlay = styled('div', `
height: 100%;
@media screen {
&-active {
background-color: ${theme.modalBackdrop};
@ -545,6 +448,9 @@ const cssOverlay = styled('div', `
padding: 20px 56px 20px 56px;
position: absolute;
}
&-active .collapsed_layout {
display: none !important;
}
}
@media screen and ${mediaSmall} {
&-active {
@ -572,3 +478,9 @@ const cssCloseButton = styled(icon, `
}
}
`);
const cssLayoutContainer = styled('div', `
height: 100%;
display: flex;
flex-direction: column;
`);

View File

@ -0,0 +1,208 @@
import BaseView from 'app/client/components/BaseView';
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
import {filterBar} from 'app/client/ui/FilterBar';
import {cssIcon} from 'app/client/ui/RightPanelStyles';
import {makeCollapsedLayoutMenu} from 'app/client/ui/ViewLayoutMenu';
import {cssDotsIconWrapper, cssMenu, viewSectionMenu} from 'app/client/ui/ViewSectionMenu';
import {buildWidgetTitle} from 'app/client/ui/WidgetTitle';
import {getWidgetTypes} from 'app/client/ui/widgetTypes';
import {colors, isNarrowScreenObs, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
import {icon} from 'app/client/ui2018/icons';
import {menu} from 'app/client/ui2018/menus';
import {Computed, dom, DomElementArg, Observable, styled} from 'grainjs';
import {defaultMenuOptions} from 'popweasel';
export function buildCollapsedSectionDom(options: {
gristDoc: GristDoc,
sectionRowId: number|string,
}, ...domArgs: DomElementArg[]) {
const {gristDoc, sectionRowId} = options;
if (typeof sectionRowId === 'string') {
return cssMiniSection(
dom('span.viewsection_title_font',
'Empty'
)
);
}
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
const typeComputed = Computed.create(null, use => getWidgetTypes(use(vs.parentKey) as any).icon);
return cssMiniSection(
testId(`minilayout-section-${sectionRowId}`),
testId(`minilayout-section`),
cssDragHandle(
dom.domComputed(typeComputed, (type) => icon(type)),
dom('div', {style: 'margin-right: 16px;'}),
dom.maybe((use) => use(use(vs.table).summarySourceTable), () => cssSigmaIcon('Pivot', testId('sigma'))),
dom('span.viewsection_title_font', testId('viewsection-title'),
dom.text(vs.titleDef),
),
),
cssMenu(
testId('section-menu-viewLayout'),
cssDotsIconWrapper(cssIcon('Dots')),
menu(_ctl => makeCollapsedLayoutMenu(vs, gristDoc), {
...defaultMenuOptions,
placement: 'bottom-end',
})
),
...domArgs
);
}
export function buildViewSectionDom(options: {
gristDoc: GristDoc,
sectionRowId: number,
isResizing?: Observable<boolean>
viewModel?: ViewRec,
// Should show drag anchor.
draggable?: boolean, /* defaults to true */
// Should show green bar on the left (but preserves active-section class).
focusable?: boolean, /* defaults to true */
tableNameHidden?: boolean,
widgetNameHidden?: boolean,
}) {
const isResizing = options.isResizing ?? Observable.create(null, false);
const {gristDoc, sectionRowId, viewModel, draggable = true, focusable = true} = options;
// Creating normal section dom
const vs: ViewSectionRec = gristDoc.docModel.viewSections.getRowModel(sectionRowId);
return dom('div.view_leaf.viewsection_content.flexvbox.flexauto',
testId(`viewlayout-section-${sectionRowId}`),
!options.isResizing ? dom.autoDispose(isResizing) : null,
cssViewLeaf.cls(''),
cssViewLeafInactive.cls('', (use) => !vs.isDisposed() && !use(vs.hasFocus)),
dom.cls('active_section', vs.hasFocus),
dom.cls('active_section--no-indicator', !focusable),
dom.maybe<BaseView|null>((use) => use(vs.viewInstance), (viewInstance) => dom('div.viewsection_title.flexhbox',
cssDragIcon('DragDrop',
dom.cls("viewsection_drag_indicator"),
// Makes element grabbable only if grist is not readonly.
dom.cls('layout_grabbable', (use) => !use(gristDoc.isReadonlyKo)),
!draggable ? dom.style("visibility", "hidden") : null
),
dom.maybe((use) => use(use(viewInstance.viewSection.table).summarySourceTable), () =>
cssSigmaIcon('Pivot', testId('sigma'))),
buildWidgetTitle(vs, options, testId('viewsection-title'), cssTestClick(testId("viewsection-blank"))),
viewInstance.buildTitleControls(),
dom('div.viewsection_buttons',
dom.create(viewSectionMenu, gristDoc, vs)
)
)),
dom.create(filterBar, gristDoc, vs),
dom.maybe<BaseView|null>(vs.viewInstance, (viewInstance) => [
dom('div.view_data_pane_container.flexvbox',
cssResizing.cls('', isResizing),
dom.maybe(viewInstance.disableEditing, () =>
dom('div.disable_viewpane.flexvbox', 'No data')
),
dom.maybe(viewInstance.isTruncated, () =>
dom('div.viewsection_truncated', 'Not all data is shown')
),
dom.cls((use) => 'viewsection_type_' + use(vs.parentKey)),
viewInstance.viewPane,
),
dom.maybe(use => !use(isNarrowScreenObs()), () => viewInstance.selectionSummary?.buildDom()),
]),
dom.on('mousedown', () => { viewModel?.activeSectionId(sectionRowId); }),
);
}
// With new widgetPopup it is hard to click on viewSection without a activating it, hence we
// add a little blank space to use in test.
const cssTestClick = styled(`div`, `
min-width: 2px;
`);
const cssSigmaIcon = styled(icon, `
bottom: 1px;
margin-right: 5px;
background-color: ${theme.lightText}
`);
const cssViewLeaf = styled('div', `
@media ${mediaSmall} {
& {
margin: 4px;
}
}
`);
const cssViewLeafInactive = styled('div', `
@media screen and ${mediaSmall} {
& {
overflow: hidden;
background: repeating-linear-gradient(
-45deg,
${theme.widgetInactiveStripesDark},
${theme.widgetInactiveStripesDark} 10px,
${theme.widgetInactiveStripesLight} 10px,
${theme.widgetInactiveStripesLight} 20px
);
border: 1px solid ${theme.widgetBorder};
border-radius: 4px;
padding: 0 2px;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
&.layout_vbox {
max-width: 32px;
}
&.layout_hbox {
max-height: 32px;
}
& > .viewsection_title.flexhbox {
position: absolute;
}
& > .view_data_pane_container,
& .viewsection_buttons,
& .grist-single-record__menu,
& > .filter_bar {
display: none;
}
}
`);
// z-index ensure it's above the resizer line, since it's hard to grab otherwise
const cssDragIcon = styled(icon, `
visibility: hidden;
--icon-color: ${colors.slate};
top: -1px;
z-index: 100;
.viewsection_title:hover &.layout_grabbable {
visibility: visible;
}
`);
// This class is added while sections are being resized (or otherwise edited), to ensure that the
// content of the section (such as an iframe) doesn't interfere with mouse drag-related events.
// (It assumes that contained elements do not set pointer-events to another value; if that were
// important then we'd need to use an overlay element during dragging.)
const cssResizing = styled('div', `
pointer-events: none;
`);
const cssMiniSection = styled('div.mini_section_container', `
--icon-color: ${colors.lightGreen};
display: flex;
background: ${theme.mainPanelBg};
align-items: center;
padding-right: 8px;
`);
const cssDragHandle = styled('div.draggable-handle', `
display: flex;
padding: 8px;
flex: 1;
padding-right: 16px;
`);

View File

@ -392,6 +392,18 @@ exports.groups = [{
name: 'deleteSection',
keys: [],
desc: 'Delete the currently active viewsection'
}, {
name: 'collapseSection',
keys: [],
desc: 'Collapse the currently active viewsection'
}, {
name: 'expandSection',
keys: [],
desc: 'Expand collapsed viewsection'
}, {
name: 'deleteCollapsedSection',
keys: [],
desc: 'Delete collapsed viewsection'
}, {
name: 'duplicateRows',
keys: ['Mod+Shift+d'],

193
app/client/lib/Signal.ts Normal file
View File

@ -0,0 +1,193 @@
import { DisposableWithEvents } from 'app/common/DisposableWithEvents';
import { Disposable, IDisposable, IDisposableOwner, Observable } from 'grainjs';
/**
* A simple abstraction for events composition. It is an object that can emit a single value of type T,
* and holds the last value emitted. It can be used to compose events from other events.
*
* Simple observables can't be used for this purpose because they are not reentrant. We can't update
* an observable from within a listener, because it won't trigger a new event.
*
* This class is basically a wrapper around Observable, that emits events when the value changes after it is
* set.
*
* Example:
* const signal = Signal.create(null, 0);
* signal.listen(value => console.log(value));
* const onlyEven = signal.filter(value => value % 2 === 0);
* onlyEven.listen(value => console.log('even', value));
*
* const flag1 = Signal.create(null, false);
* const flag2 = Signal.create(null, false);
* const flagAnd = Signal.compute(null, on => on(flag1) && on(flag2));
* // This will still emit multiple times with the same value repeated.
* flagAnd.listen(value => console.log('Both are true', value));
*
* // This will emit only when both are true, and will ignore further changes while both are true.
* const toggle = flagAnd.distinct();
*
* // Current value can be accessed via signal.state.get()
* const emitter = Signal.from(null, 0);
* // Emit values only when the toggle is true.
* const emitterWhileAnd = emitter.filter(() => toggle.state.get());
* // Equivalent to:
* const emitterWhileAnd = Signal.compute(null, on => on(toggle) ? on(emitter) : null).distinct();
*/
export class Signal<T = any> implements IDisposable, IDisposableOwner {
/**
* Creates a new event with a default value. A convenience method for creating an event that supports
* generic attribute.
*/
public static create<T>(owner: IDisposableOwner | null, value: T) {
return new Signal(owner, value);
}
/**
* Creates an event from a set of events. Holds last value emitted by any of the events.
*/
public static fromEvents<T = any>(
owner: Disposable | null,
emitter: any,
first: string,
...rest: string[]
) {
const signal = Signal.create(owner, null);
for(const event of [first, ...rest]) {
signal._emitter.listenTo(emitter, event, (value: any) => signal.emit(value));
}
return signal as Signal<T | null>;
}
/**
* Helper methods that creates a signal that emits the result of a function that takes a function
*/
public static compute<T>(owner: Disposable | null, compute: ComputeFunction<T>) {
const signal = Signal.create(owner, null as any);
const on: any = (s: Signal) => {
if (!signal._listeners.has(s)) {
signal._listeners.add(s);
signal._emitter.listenTo(s._emitter, 'signal', () => signal.emit(compute(on)));
}
return s.state.get();
};
signal.state.set(compute(on));
return signal as Signal<T>;
}
/**
* Last value emitted if any.
*/
public state: Observable<T>;
/**
* List of signals that we are listening to. Stored in a WeakSet to avoid memory leaks.
*/
private _listeners: WeakSet<Signal> = new WeakSet();
/**
* Flag that can be changed by stateless() function. It won't hold last value (but can't be used in compute function).
*/
private _emitter: DisposableWithEvents;
private _beforeHandler: CustomEmitter<T>;
constructor(owner: IDisposableOwner|null, initialValue: T) {
this._emitter = DisposableWithEvents.create(owner);
this.state = Observable.create(this, initialValue);
}
public dispose() {
this._emitter.dispose();
}
public autoDispose(disposable: IDisposable) {
this._emitter.autoDispose(disposable);
}
/**
* Push all events from this signal to another signal.
*/
public pipe(signal: Signal<T>) {
this.autoDispose(this.listen(value => signal.emit(value)));
}
/**
* Modify all values emitted by this signal.
*/
public map<Z>(selector: (value: T) => Z): Signal<Z> {
const signal = Signal.create(this, selector(this.state.get()));
this.listen(value => {
signal.emit(selector(value));
});
return signal;
}
/**
* Creates a new signal with the same state, but it will only
* emit those values that pass the test implemented by the provided function.
*/
public filter(selector: (value: T) => boolean): Signal<T> {
const signal = Signal.create(this, this.state.get());
this.listen(value => {
if (selector(value)) {
signal.emit(value);
}
});
return signal;
}
/**
* Emit only the value that is different from the previous one.
*/
public distinct(): Signal<T> {
let last = this.state.get();
const signal = this.filter((value: any) => {
if (value !== last) {
last = value;
return true;
}
return false;
});
signal.state.set(last);
return signal;
}
/**
* Emits true or false only when the value is changed from truthy to falsy or vice versa.
*/
public flag() {
return this.map(Boolean).distinct();
}
/**
* Listen to changes of the signal.
*/
public listen(handler: (value: T) => any) {
const stateHandler = () => {
handler(this.state.get());
};
this._emitter.on('signal', stateHandler);
return {
dispose: () => this._emitter.off('signal', stateHandler),
};
}
public emit(value: T) {
if (this._beforeHandler) {
this._beforeHandler(value, (emitted: T) => {
this.state.set(emitted);
this._emitter.trigger('signal', emitted);
});
} else {
this.state.set(value);
this._emitter.trigger('signal', value);
}
}
public before(handler: CustomEmitter<T>) {
this._beforeHandler = handler;
}
}
type ComputeFunction<T> = (on: <TS>(s: Signal<TS>) => TS) => T;
type CustomEmitter<T> = (value: T, emit: (value: T) => void) => any;

View File

@ -1,3 +1,4 @@
import {BoxSpec} from 'app/client/components/Layout';
import {KoArray} from 'app/client/lib/koArray';
import * as koUtil from 'app/client/lib/koUtil';
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
@ -10,11 +11,21 @@ export interface ViewRec extends IRowModel<"_grist_Views"> {
viewSections: ko.Computed<KoArray<ViewSectionRec>>;
tabBarItem: ko.Computed<KoArray<TabBarRec>>;
layoutSpecObj: modelUtil.ObjObservable<any>;
layoutSpecObj: modelUtil.SaveableObjObservable<BoxSpec>;
// An observable for the ref of the section last selected by the user.
activeSectionId: ko.Computed<number>;
// This is active collapsed section id. Set when the widget is clicked.
activeCollapsedSectionId: ko.Observable<number>;
// Saved collapsed sections.
collapsedSections: ko.Computed<number[]>;
// Active collapsed sections, changed by the user, can be different from the
// saved collapsed sections, for a brief moment (editor is buffering changes).
activeCollapsedSections: ko.Observable<number[]>;
activeSection: ko.Computed<ViewSectionRec>;
// If the active section is removed, set the next active section to be the default.
@ -39,6 +50,15 @@ export function createViewRec(this: ViewRec, docModel: DocModel): void {
this.activeSection = refRecord(docModel.viewSections, this.activeSectionId);
this.activeCollapsedSectionId = ko.observable(0);
this.collapsedSections = this.autoDispose(ko.pureComputed(() => {
const allSections = new Set(this.viewSections().all().map(x => x.id()));
const collapsed: number[] = (this.layoutSpecObj().collapsed || []).map(x => x.leaf as number);
return collapsed.filter(x => allSections.has(x));
}));
this.activeCollapsedSections = ko.observable(this.collapsedSections.peek());
// If the active section is removed, set the next active section to be the default.
this._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));
this.autoDispose(this._isActiveSectionGone.subscribe(gone => {

View File

@ -56,6 +56,7 @@ export interface ViewSectionRec extends IRowModel<"_grist_Views_section">, RuleO
// true if this record is its table's rawViewSection, i.e. a 'raw data view'
// in which case the UI prevents various things like hiding columns or changing the widget type.
isRaw: ko.Computed<boolean>;
isCollapsed: ko.Computed<boolean>;
borderWidthPx: ko.Computed<string>;
@ -722,4 +723,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
};
this.removeRule = (index: number) => removeRule(docModel, this, index);
this.isCollapsed = this.autoDispose(ko.pureComputed(() => {
const list = this.view().activeCollapsedSections();
return list.includes(this.id());
}));
}

View File

@ -4,7 +4,8 @@ import {ViewSectionRec} from 'app/client/models/DocModel';
import {urlState} from 'app/client/models/gristUrlState';
import {testId} from 'app/client/ui2018/cssVars';
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
import {dom} from 'grainjs';
import {GristDoc} from 'app/client/components/GristDoc';
import {dom, UseCB} from 'grainjs';
const t = makeT('ViewLayoutMenu');
@ -42,10 +43,27 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
anchorUrlState.hash!.popup = true;
const rawUrl = urlState().makeUrl(anchorUrlState);
// Count number of rendered sections on the viewLayout. Note that the layout might be detached or cleaned
// when we have an external section in the popup.
const expandedSectionCount = () => gristDoc.viewLayout?.layout.getAllLeafIds().length ?? 0 > 1;
const dontRemoveSection = () =>
!viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly || expandedSectionCount() === 1;
const dontCollapseSection = () =>
dontRemoveSection() ||
(gristDoc.externalSectionId.get() === viewSection.getRowId()) ||
(gristDoc.maximizedSectionId.get() === viewSection.getRowId());
const showRawData = (use: UseCB) => {
return !use(viewSection.isRaw)// Don't show raw data if we're already in raw data.
&& !isLight // Don't show raw data in light mode.
;
};
return [
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.sectionInPopup),
dom.maybe(showRawData,
() => menuItemLink(
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
dom.on('click', (ev) => {
@ -78,8 +96,44 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
testId('section-open-configuration')),
),
menuItemCmd(allCommands.collapseSection, t("Collapse widget"),
dom.cls('disabled', dontCollapseSection()),
testId('section-collapse')),
menuItemCmd(allCommands.deleteSection, t("Delete widget"),
dom.cls('disabled', !viewRec.getRowId() || viewRec.viewSections().peekLength <= 1 || isReadonly),
dom.cls('disabled', dontRemoveSection()),
testId('section-delete')),
];
}
/**
* Returns a list of menu items for a view section.
*/
export function makeCollapsedLayoutMenu(viewSection: ViewSectionRec, gristDoc: GristDoc) {
const isReadonly = gristDoc.isReadonly.get();
const isLight = urlState().state.get().params?.style === 'light';
const sectionId = viewSection.table.peek().rawViewSectionRef.peek();
const anchorUrlState = { hash: { sectionId, popup: true } };
const rawUrl = urlState().makeUrl(anchorUrlState);
return [
dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.maximizedSectionId),
() => 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);
})
)
),
menuDivider(),
menuItemCmd(allCommands.expandSection, t("Add to page"),
dom.cls('disabled', isReadonly),
testId('section-expand')),
menuItemCmd(allCommands.deleteCollapsedSection, t("Delete widget"),
dom.cls('disabled', isReadonly),
testId('section-delete')),
];
}

View File

@ -58,7 +58,8 @@ export function viewSectionMenu(
// 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(gristDoc.maximizedSectionId) !== use(viewSection.id) // not in when we are maximized
&& use(gristDoc.externalSectionId) !== use(viewSection.id) // not in when we are external
&& !use(viewSection.isRaw); // not in raw mode
});
@ -210,7 +211,7 @@ function makeCustomOptions(section: ViewSectionRec) {
const clsOldUI = styled('div', ``);
const cssMenu = styled('div', `
export const cssMenu = styled('div', `
display: flex;
cursor: pointer;
border-radius: 3px;
@ -277,7 +278,7 @@ const cssIcon = styled(icon, `
}
`);
const cssDotsIconWrapper = styled(cssIconWrapper, `
export const cssDotsIconWrapper = styled(cssIconWrapper, `
border-radius: 0px 2px 2px 0px;
display: flex;
.${clsOldUI.className} & {

View File

@ -257,7 +257,7 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
queryParams[`${k}_`] = v;
}
const hashParts: string[] = [];
if (state.hash && state.hash.rowId) {
if (state.hash && (state.hash.rowId || state.hash.popup)) {
const hash = state.hash;
hashParts.push(state.hash?.popup ? 'a2' : `a1`);
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {

125
test/client/lib/Signal.ts Normal file
View File

@ -0,0 +1,125 @@
import { assert } from 'chai';
import { Signal } from 'app/client/lib/Signal';
describe('Signal', function() {
it('computes new signal from other events', function() {
const started = Signal.create(null, false);
const hovered = Signal.create(null, false);
const hoverAndStarted = Signal.compute(null, on => on(started) && on(hovered));
let flag: any = 'not-called';
hoverAndStarted.listen(val => flag = val);
function start(emit: boolean, expected: boolean) {
started.emit(emit);
assert.equal(flag, expected);
flag = 'not-called';
}
function hover(emit: boolean, expected: boolean) {
hovered.emit(emit);
assert.equal(flag, expected);
flag = 'not-called';
}
start(true, false);
start(false, false);
start(true, false);
hover(true, true);
hover(false, false);
hover(true, true);
start(false, false);
});
it('works as flag', function() {
const started = Signal.create(null, false);
const hovered = Signal.create(null, false);
const andEvent = Signal.compute(null, on => on(started) && on(hovered)).distinct();
const notCalled = {};
let andCalled = notCalled;
andEvent.listen(val => andCalled = val);
function start(emit: boolean, expected: any) {
started.emit(emit);
assert.equal(andCalled, expected);
andCalled = notCalled;
}
start(true, notCalled);
start(false, notCalled);
start(true, notCalled);
function hover(emit: boolean, expected: any) {
hovered.emit(emit);
assert.equal(andCalled, expected);
andCalled = notCalled;
}
hover(true, true);
hover(false, false);
hover(true, true);
start(false, false);
});
it('supports basic compositions', function() {
const numbers = Signal.create(null, 0);
const even = numbers.filter(n => n % 2 === 0);
const odd = numbers.filter(n => n % 2 === 1);
let evenCount = 0;
let oddCount = 0;
even.listen(() => evenCount++);
odd.listen(() => oddCount++);
assert.equal(evenCount, 0);
assert.equal(oddCount, 0);
numbers.emit(2);
assert.equal(evenCount, 1);
assert.equal(oddCount, 0);
numbers.emit(3);
assert.equal(evenCount, 1);
assert.equal(oddCount, 1);
const distinct = numbers.distinct();
let distinctCount = 0;
distinct.listen(() => distinctCount++);
assert.equal(distinctCount, 0);
numbers.emit(3);
assert.equal(distinctCount, 0);
numbers.emit(3);
assert.equal(distinctCount, 0);
numbers.emit(4);
assert.equal(distinctCount, 1);
numbers.emit(4);
assert.equal(distinctCount, 1);
numbers.emit(5);
assert.equal(distinctCount, 2);
const trafficLight = Signal.create(null, false);
const onRoad = numbers.filter(n => !!trafficLight.state.get());
let onRoadCount = 0;
onRoad.listen(() => onRoadCount++);
assert.equal(onRoadCount, 0);
numbers.emit(5);
assert.equal(onRoadCount, 0);
trafficLight.emit(true);
assert.equal(onRoadCount, 0);
numbers.emit(6);
assert.equal(onRoadCount, 1);
});
it('detects cycles', function() {
const first = Signal.create(null, 0);
const second = Signal.create(null, 0);
first.listen(n => second.emit(n + 1));
second.listen(n => first.emit(n + 1));
assert.throws(() => first.emit(1));
});
});

View File

@ -1060,8 +1060,12 @@ export async function addNewPage(
await driver.wait(async () => (await driver.getCurrentUrl()) !== url, 2000);
}
type SectionTypes = 'Table'|'Card'|'Card List'|'Chart'|'Custom';
// Add a new widget to the current page using the 'Add New' menu.
export async function addNewSection(typeRe: RegExp|string, tableRe: RegExp|string, options?: PageWidgetPickerOptions) {
export async function addNewSection(
typeRe: RegExp|SectionTypes, tableRe: RegExp|string, options?: PageWidgetPickerOptions
) {
// Click the 'Add widget to page' entry in the 'Add New' menu
await driver.findWait('.test-dp-add-new', 2000).doClick();
await driver.findWait('.test-dp-add-widget-to-page', 500).doClick();
@ -1515,7 +1519,6 @@ const ColumnMenuOption: { [id: string]: string; } = {
Filter: '.test-filter-menu-wrapper'
};
async function openColumnMenuHelper(col: IColHeader|string, option?: string): Promise<WebElement> {
await getColumnHeader(typeof col === 'string' ? {col} : col).mouseMove().find('.g-column-main-menu').click();
const menu = await driver.findWait('.grist-floating-menu', 100);