mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
e9efac05f7
commit
59cf654190
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
1080
app/client/components/LayoutTray.ts
Normal file
1080
app/client/components/LayoutTray.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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',
|
||||
|
@ -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);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
`);
|
||||
|
208
app/client/components/buildViewSectionDom.ts
Normal file
208
app/client/components/buildViewSectionDom.ts
Normal 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;
|
||||
`);
|
@ -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
193
app/client/lib/Signal.ts
Normal 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;
|
@ -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 => {
|
||||
|
@ -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());
|
||||
}));
|
||||
}
|
||||
|
@ -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')),
|
||||
];
|
||||
}
|
||||
|
@ -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} & {
|
||||
|
@ -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
125
test/client/lib/Signal.ts
Normal 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));
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user