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.listenTo(this.sortedRows, 'rowNotify', this._update);
|
||||||
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
|
this.autoDispose(this.sortedRows.getKoArray().subscribe(this._update));
|
||||||
this.autoDispose(this._formatterComp.subscribe(this._update));
|
this.autoDispose(this._formatterComp.subscribe(this._update));
|
||||||
this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() =>
|
this.autoDispose(this.gristDoc.docPageModel.appModel.currentTheme.addListener(() => this._update()));
|
||||||
this._update()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public prepareToPrint(onOff: boolean) {
|
public prepareToPrint(onOff: boolean) {
|
||||||
|
@ -166,10 +166,17 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
public readonly hasDocTour: Computed<boolean>;
|
public readonly hasDocTour: Computed<boolean>;
|
||||||
|
|
||||||
public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager;
|
public readonly behavioralPromptsManager = this.docPageModel.appModel.behavioralPromptsManager;
|
||||||
// One of the section can be shown it the popup (as requested from the Layout), we will
|
// One of the section can be expanded (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
|
// store its id in this variable. NOTE: expanded section looks exactly the same as a section
|
||||||
// hide it be informing the layout about it.
|
// in the popup. But they are rendered differently, as section in popup is probably an external
|
||||||
public sectionInPopup: Observable<number|null> = Observable.create(this, null);
|
// 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 _actionLog: ActionLog;
|
||||||
private _undoStack: UndoStack;
|
private _undoStack: UndoStack;
|
||||||
@ -178,7 +185,6 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
private _docHistory: DocHistory;
|
private _docHistory: DocHistory;
|
||||||
private _discussionPanel: DiscussionPanel;
|
private _discussionPanel: DiscussionPanel;
|
||||||
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
|
private _rightPanelTool = createSessionObs(this, "rightPanelTool", "none", RightPanelTool.guard);
|
||||||
private _viewLayout: ViewLayout|null = null;
|
|
||||||
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
|
private _showGristTour = getUserOrgPrefObs(this.userOrgPrefs, 'showGristTour');
|
||||||
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
|
private _seenDocTours = getUserOrgPrefObs(this.userOrgPrefs, 'seenDocTours');
|
||||||
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
|
private _rawSectionOptions: Observable<RawSectionOptions|null> = Observable.create(this, null);
|
||||||
@ -229,6 +235,10 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return viewId || use(defaultViewId);
|
return viewId || use(defaultViewId);
|
||||||
});
|
});
|
||||||
this._activeContent = Computed.create(this, use => use(this._rawSectionOptions) ?? use(this.activeViewId));
|
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
|
// This viewModel reflects the currently active view, relying on the fact that
|
||||||
// createFloatingRowModel() supports an observable rowId for its argument.
|
// createFloatingRowModel() supports an observable rowId for its argument.
|
||||||
// Although typings don't reflect it, createFloatingRowModel() accepts non-numeric values,
|
// 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.
|
// When active section is changed, clear the maximized state.
|
||||||
this.autoDispose(this.viewModel.activeSectionId.subscribe(() => {
|
this.autoDispose(this.viewModel.activeSectionId.subscribe(() => {
|
||||||
this.sectionInPopup.set(null);
|
this.maximizedSectionId.set(null);
|
||||||
// If we have layout, update it.
|
// If we have layout, update it.
|
||||||
if (!this._viewLayout?.isDisposed()) {
|
if (!this.viewLayout?.isDisposed()) {
|
||||||
this._viewLayout?.maximized.set(null);
|
this.viewLayout?.maximized.set(null);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -444,7 +454,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
* Builds the DOM for this GristDoc.
|
* Builds the DOM for this GristDoc.
|
||||||
*/
|
*/
|
||||||
public buildDom() {
|
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 => {
|
const isPopup = Computed.create(this, use => {
|
||||||
return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages
|
return ['data', 'settings'].includes(use(this.activeViewId) as any) // On Raw data or doc settings pages
|
||||||
|| use(isMaximized) // Layout has a maximized section visible
|
|| 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);
|
return dom.create(RawDataPopup, this, content.viewSection, content.close);
|
||||||
}) :
|
}) :
|
||||||
dom.create((owner) => {
|
dom.create((owner) => {
|
||||||
this._viewLayout = ViewLayout.create(owner, this, content);
|
this.viewLayout = ViewLayout.create(owner, this, content);
|
||||||
this._viewLayout.maximized.addListener(n => this.sectionInPopup.set(n));
|
this.viewLayout.maximized.addListener(n => this.maximizedSectionId.set(n));
|
||||||
return this._viewLayout;
|
owner.onDispose(() => this.viewLayout = null);
|
||||||
|
return this.viewLayout;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@ -700,7 +711,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
return section;
|
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()}),
|
t("Saved linked section {{title}} in view {{name}}", {title:section.title(), name: viewModel.name()}),
|
||||||
async () => {
|
async () => {
|
||||||
|
|
||||||
@ -1021,7 +1032,7 @@ export class GristDoc extends DisposableWithEvents {
|
|||||||
// We can only open a popup for a section.
|
// We can only open a popup for a section.
|
||||||
if (!hash.sectionId) { return; }
|
if (!hash.sectionId) { return; }
|
||||||
// We might open popup either for a section in this view or some other section (like Raw Data Page).
|
// 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);
|
this.viewModel.activeSectionId(hash.sectionId);
|
||||||
// If the anchor link is valid, set the cursor.
|
// If the anchor link is valid, set the cursor.
|
||||||
if (hash.colRef && hash.rowId) {
|
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
|
// 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
|
// `section.layoutSpec` (in particular it provides a default layout when missing from the
|
||||||
// latter).
|
// latter).
|
||||||
const layoutSpec = this._viewLayout!.layoutSpec();
|
const layoutSpec = this.viewLayout!.layoutSpec();
|
||||||
|
|
||||||
const sectionTitle = section.title();
|
const sectionTitle = section.title();
|
||||||
const sectionId = section.id();
|
const sectionId = section.id();
|
||||||
|
@ -66,6 +66,14 @@ import {identity, last, uniqueId} from 'underscore';
|
|||||||
export interface ContentBox {
|
export interface ContentBox {
|
||||||
leafId: ko.Observable<any>;
|
leafId: ko.Observable<any>;
|
||||||
leafContent: ko.Observable<Element|null>;
|
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;
|
public leafId: string;
|
||||||
private _leafIdMap: Map<any, LayoutBox>|null;
|
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.maximized = observable(null as (string|null));
|
||||||
this.rootBox = observable(null as any);
|
this.rootBox = observable(null as any);
|
||||||
this.createLeafFunc = createLeafFunc;
|
this.createLeafFunc = createLeafFunc;
|
||||||
@ -424,13 +432,16 @@ export class Layout extends Disposable {
|
|||||||
* Calls cb on each box in the layout recursively.
|
* Calls cb on each box in the layout recursively.
|
||||||
*/
|
*/
|
||||||
public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) {
|
public forEachBox(cb: (box: LayoutBox) => void, optContext?: any) {
|
||||||
|
if (!this.rootBox.peek()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
function iter(box: any) {
|
function iter(box: any) {
|
||||||
cb.call(optContext, box);
|
cb.call(optContext, box);
|
||||||
box.childBoxes.peek().forEach(iter);
|
box.childBoxes.peek().forEach(iter);
|
||||||
}
|
}
|
||||||
iter(this.rootBox.peek());
|
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
|
// Note that this is hot code: it runs when rendering a layout for each record, not only for the
|
||||||
// layout editor.
|
// layout editor.
|
||||||
const box = LayoutBox.create(this);
|
const box = LayoutBox.create(this);
|
||||||
@ -445,7 +456,7 @@ export class Layout extends Disposable {
|
|||||||
}
|
}
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
public buildLayout(boxSpec: any, needDynamic = false) {
|
public buildLayout(boxSpec: BoxSpec, needDynamic = false) {
|
||||||
this.needDynamic = needDynamic;
|
this.needDynamic = needDynamic;
|
||||||
const oldRootBox = this.rootBox();
|
const oldRootBox = this.rootBox();
|
||||||
this.rootBox(this.buildLayoutBox(boxSpec));
|
this.rootBox(this.buildLayoutBox(boxSpec));
|
||||||
@ -455,7 +466,7 @@ export class Layout extends Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
public _getBoxSpec(layoutBox: LayoutBox) {
|
public _getBoxSpec(layoutBox: LayoutBox) {
|
||||||
const spec: any = {};
|
const spec: BoxSpec = {};
|
||||||
if (layoutBox.isDisposed()) {
|
if (layoutBox.isDisposed()) {
|
||||||
return spec;
|
return spec;
|
||||||
}
|
}
|
||||||
|
@ -89,40 +89,42 @@ interface JqueryUI {
|
|||||||
originalSize: { width: number, height: number };
|
originalSize: { width: number, height: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LeafId = string|number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The Floater class represents a floating version of the element being dragged around. Its size
|
* 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.
|
* corresponds to the box being dragged. It lets the user see what's being repositioned.
|
||||||
*/
|
*/
|
||||||
class Floater extends Disposable implements ContentBox {
|
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 leafContent: ko.Observable<Element | null>;
|
||||||
public fillWindow: boolean;
|
public fillWindow: boolean;
|
||||||
public floaterElem: HTMLElement;
|
public dom: HTMLElement;
|
||||||
public mouseOffsetX: number;
|
public mouseOffsetX: number;
|
||||||
public mouseOffsetY: number;
|
public mouseOffsetY: number;
|
||||||
public lastMouseEvent: MouseEvent | null;
|
public lastMouseEvent: MouseEvent | null;
|
||||||
|
|
||||||
public create(fillWindow?: boolean) {
|
public create(fillWindow?: boolean) {
|
||||||
this.leafId = observable<string|null>(null);
|
this.leafId = observable<LeafId|null>(null);
|
||||||
this.leafContent = observable<Element | null>(null);
|
this.leafContent = observable<Element | null>(null);
|
||||||
this.fillWindow = fillWindow || false;
|
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.show(this.leafContent),
|
||||||
koDom.scope(this.leafContent, (leafContent: Element) => {
|
koDom.scope(this.leafContent, (leafContent: Element) => {
|
||||||
return leafContent;
|
return leafContent;
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
G.document.body.appendChild(this.floaterElem);
|
G.document.body.appendChild(this.dom);
|
||||||
|
|
||||||
this.mouseOffsetX = 0;
|
this.mouseOffsetX = 0;
|
||||||
this.mouseOffsetY = 0;
|
this.mouseOffsetY = 0;
|
||||||
this.lastMouseEvent = null;
|
this.lastMouseEvent = null;
|
||||||
}
|
}
|
||||||
public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: LayoutBox) {
|
public onInitialMouseMove(mouseEvent: MouseEvent, sourceBox: ContentBox) {
|
||||||
const rect = sourceBox.dom!.getBoundingClientRect();
|
const rect = sourceBox.dom!.getBoundingClientRect();
|
||||||
this.floaterElem.style.width = rect.width + 'px';
|
this.dom.style.width = rect.width + 'px';
|
||||||
this.floaterElem.style.height = rect.height + 'px';
|
this.dom.style.height = rect.height + 'px';
|
||||||
this.mouseOffsetX = 0.2 * rect.width;
|
this.mouseOffsetX = 0.2 * rect.width;
|
||||||
this.mouseOffsetY = 0.1 * rect.height;
|
this.mouseOffsetY = 0.1 * rect.height;
|
||||||
this.onMouseMove(mouseEvent);
|
this.onMouseMove(mouseEvent);
|
||||||
@ -141,8 +143,8 @@ class Floater extends Disposable implements ContentBox {
|
|||||||
}
|
}
|
||||||
public onMouseMove(mouseEvent: MouseEvent) {
|
public onMouseMove(mouseEvent: MouseEvent) {
|
||||||
this.lastMouseEvent = mouseEvent;
|
this.lastMouseEvent = mouseEvent;
|
||||||
this.floaterElem.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px';
|
this.dom.style.left = (mouseEvent.clientX - this.mouseOffsetX) + 'px';
|
||||||
this.floaterElem.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px';
|
this.dom.style.top = (mouseEvent.clientY - this.mouseOffsetY) + 'px';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -240,6 +242,10 @@ class DropTargeter extends Disposable {
|
|||||||
this.autoDisposeCallback(this.removeTargetHints);
|
this.autoDisposeCallback(this.removeTargetHints);
|
||||||
}
|
}
|
||||||
public 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.activeTarget = null;
|
||||||
this.delayedInsertion.cancel();
|
this.delayedInsertion.cancel();
|
||||||
if (this.targetsDom) {
|
if (this.targetsDom) {
|
||||||
@ -253,7 +259,7 @@ class DropTargeter extends Disposable {
|
|||||||
layoutBox: LayoutBox|null,
|
layoutBox: LayoutBox|null,
|
||||||
affinity: number,
|
affinity: number,
|
||||||
overlay: DropOverlay,
|
overlay: DropOverlay,
|
||||||
prevTargetBox: LayoutBox
|
prevTargetBox?: LayoutBox
|
||||||
) {
|
) {
|
||||||
// Nothing to update.
|
// Nothing to update.
|
||||||
if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) {
|
if (!layoutBox || (layoutBox === this.currentBox && affinity === this.currentAffinity)) {
|
||||||
@ -280,7 +286,7 @@ class DropTargeter extends Disposable {
|
|||||||
const children = layoutBox.childBoxes.peek();
|
const children = layoutBox.childBoxes.peek();
|
||||||
// If one of two children is prevTargetBox, replace the last target hint since it
|
// If one of two children is prevTargetBox, replace the last target hint since it
|
||||||
// will be redundant once prevTargetBox is removed.
|
// 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,
|
targetParts.splice(targetParts.length - 1, 1,
|
||||||
{box: layoutBox, isChild: false, isAfter: isAfter});
|
{box: layoutBox, isChild: false, isAfter: isAfter});
|
||||||
}
|
}
|
||||||
@ -572,6 +578,7 @@ export class LayoutEditor extends Disposable {
|
|||||||
this.triggerUserEditStart();
|
this.triggerUserEditStart();
|
||||||
this.targetBox = box;
|
this.targetBox = box;
|
||||||
this.floater.onInitialMouseMove(event, box);
|
this.floater.onInitialMouseMove(event, box);
|
||||||
|
this.trigger('dragStart', this.originalBox);
|
||||||
}
|
}
|
||||||
public handleMouseUp(event: MouseEvent) {
|
public handleMouseUp(event: MouseEvent) {
|
||||||
G.$(G.window).off('mousemove', this.boundMouseMove);
|
G.$(G.window).off('mousemove', this.boundMouseMove);
|
||||||
@ -582,16 +589,27 @@ export class LayoutEditor extends Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We stopped dragging, any listener can clean its modification
|
||||||
|
// to the floater element.
|
||||||
|
this.trigger('dragStop');
|
||||||
this.targetBox!.takeLeafFrom(this.floater);
|
this.targetBox!.takeLeafFrom(this.floater);
|
||||||
if (this.dropTargeter.activeTarget) {
|
// We dropped back the box to its original position, now
|
||||||
this.dropTargeter.accelerateInsertion();
|
// anyone can hijack the box.
|
||||||
} else {
|
this.trigger('dragDrop', this.targetBox);
|
||||||
resizeLayoutBox(this.targetBox!, 'reset');
|
|
||||||
|
// 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.dropTargeter.removeTargetHints();
|
||||||
this.dropOverlay.detach();
|
this.dropOverlay.detach();
|
||||||
|
this.trigger('dragEnd');
|
||||||
|
// Cleanup for any state.
|
||||||
this.transitionPromise.finally(() => {
|
this.transitionPromise.finally(() => {
|
||||||
this.floater.onMouseUp();
|
this.floater.onMouseUp();
|
||||||
resizeLayoutBox(this.targetBox!, 'reset');
|
resizeLayoutBox(this.targetBox!, 'reset');
|
||||||
@ -600,20 +618,36 @@ export class LayoutEditor extends Disposable {
|
|||||||
this.triggerUserEditStop();
|
this.triggerUserEditStop();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
public removeContainingBox(elem: HTMLElement) {
|
|
||||||
|
public getBoxFromElement(elem: HTMLElement) {
|
||||||
const box = this.layout.getContainingBox(elem);
|
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()) {
|
if (box && !box.isDomDetached()) {
|
||||||
this.triggerUserEditStart();
|
this.triggerUserEditStart();
|
||||||
this.targetBox = box;
|
this.targetBox = box;
|
||||||
const rect = box.dom.getBoundingClientRect();
|
this.doRemoveBox(box);
|
||||||
box.leafId('empty');
|
|
||||||
box.leafContent(dom('div.layout_editor_empty_space',
|
|
||||||
koDom.style('min-height', rect.height + 'px')
|
|
||||||
));
|
|
||||||
this.onInsertBox(noop).catch(noop);
|
|
||||||
this.triggerUserEditStop();
|
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) {
|
public handleMouseMove(event: MouseEvent) {
|
||||||
// Make sure the grabbed box still exists
|
// Make sure the grabbed box still exists
|
||||||
if (!this.originalBox || this.originalBox?.isDisposed()) {
|
if (!this.originalBox || this.originalBox?.isDisposed()) {
|
||||||
@ -626,6 +660,8 @@ export class LayoutEditor extends Disposable {
|
|||||||
}
|
}
|
||||||
this.floater.onMouseMove(event);
|
this.floater.onMouseMove(event);
|
||||||
|
|
||||||
|
this.trigger('dragMove', event, this.originalBox);
|
||||||
|
|
||||||
if (this.transitionPromise.isPending()) {
|
if (this.transitionPromise.isPending()) {
|
||||||
// Don't attempt to do any repositioning while another reposition is happening.
|
// Don't attempt to do any repositioning while another reposition is happening.
|
||||||
return;
|
return;
|
||||||
@ -642,7 +678,14 @@ export class LayoutEditor extends Disposable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.trashDelay.cancel();
|
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
|
// 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.
|
// element. If so, we are dealing with repositioning.
|
||||||
const elem = dom.findAncestor(event.target, this.rootElem, '.' + this.layout.leafId);
|
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 {DocumentUsage} from 'app/client/components/DocumentUsage';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
import {GristDoc} from 'app/client/components/GristDoc';
|
||||||
import {printViewSection} from 'app/client/components/Printing';
|
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 {mediaSmall, theme} from 'app/client/ui2018/cssVars';
|
||||||
import {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
|
import {Computed, Disposable, dom, fromKo, makeTestId, Observable, styled} from 'grainjs';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {reportError} from 'app/client/models/errors';
|
||||||
import {ViewSectionRec} from 'app/client/models/DocModel';
|
import {ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
|
import {buildViewSectionDom} from 'app/client/components/buildViewSectionDom';
|
||||||
|
|
||||||
const testId = makeTestId('test-raw-data-');
|
const testId = makeTestId('test-raw-data-');
|
||||||
|
|
||||||
@ -76,6 +77,13 @@ export class RawDataPopup extends Disposable {
|
|||||||
super();
|
super();
|
||||||
const commandGroup = {
|
const commandGroup = {
|
||||||
cancel: () => { this._onClose(); },
|
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));
|
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
||||||
}
|
}
|
||||||
@ -89,7 +97,7 @@ export class RawDataPopup extends Disposable {
|
|||||||
sectionRowId: this._viewSection.getRowId(),
|
sectionRowId: this._viewSection.getRowId(),
|
||||||
draggable: false,
|
draggable: false,
|
||||||
focusable: false,
|
focusable: false,
|
||||||
widgetNameHidden: true
|
widgetNameHidden: this._viewSection.isRaw.peek(), // We are sometimes used for non raw sections.
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
cssCloseButton('CrossBig',
|
cssCloseButton('CrossBig',
|
||||||
|
@ -130,7 +130,8 @@ RecordLayoutEditor.prototype.buildLeafDom = function() {
|
|||||||
dom.on('click', (ev, elem) => {
|
dom.on('click', (ev, elem) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
this.layoutEditor.removeContainingBox(elem);
|
const box = this.layoutEditor.getBoxFromElement(elem);
|
||||||
|
this.layoutEditor.removeContainingBox(box);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -17,10 +17,13 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin-left: -16px; /* to include drag handle that shows up on hover */
|
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));
|
color: var(--grist-theme-text-light, var(--grist-color-slate));
|
||||||
font-size: var(--grist-small-font-size);
|
font-size: var(--grist-small-font-size);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewsection_content {
|
.viewsection_content {
|
||||||
|
@ -5,24 +5,24 @@ import {CustomView} from 'app/client/components/CustomView';
|
|||||||
import * as DetailView from 'app/client/components/DetailView';
|
import * as DetailView from 'app/client/components/DetailView';
|
||||||
import * as GridView from 'app/client/components/GridView';
|
import * as GridView from 'app/client/components/GridView';
|
||||||
import {GristDoc} from 'app/client/components/GristDoc';
|
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 {LayoutEditor} from 'app/client/components/LayoutEditor';
|
||||||
import {printViewSection} from 'app/client/components/Printing';
|
import {printViewSection} from 'app/client/components/Printing';
|
||||||
import {Delay} from 'app/client/lib/Delay';
|
import {Delay} from 'app/client/lib/Delay';
|
||||||
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
import {createObsArray} from 'app/client/lib/koArrayWrap';
|
||||||
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
import {ViewRec, ViewSectionRec} from 'app/client/models/DocModel';
|
||||||
import {reportError} from 'app/client/models/errors';
|
import {reportError} from 'app/client/models/errors';
|
||||||
import {filterBar} from 'app/client/ui/FilterBar';
|
import {isNarrowScreen, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars';
|
||||||
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 {icon} from 'app/client/ui2018/icons';
|
import {icon} from 'app/client/ui2018/icons';
|
||||||
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
|
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 {mod} from 'app/common/gutil';
|
||||||
import * as ko from 'knockout';
|
import * as ko from 'knockout';
|
||||||
import * as _ from 'underscore';
|
import * as _ from 'underscore';
|
||||||
import debounce from 'lodash/debounce';
|
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
|
// tslint:disable:no-console
|
||||||
|
|
||||||
@ -49,8 +49,23 @@ export class ViewSectionHelper extends Disposable {
|
|||||||
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
|
constructor(gristDoc: GristDoc, vs: ViewSectionRec) {
|
||||||
super();
|
super();
|
||||||
this.onDispose(() => vs.viewInstance(null));
|
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) => {
|
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.
|
// Rebuild the section when its type changes or its underlying table.
|
||||||
const table = use(vs.table);
|
const table = use(vs.table);
|
||||||
const Cons = getInstanceConstructor(use(vs.parentKey));
|
const Cons = getInstanceConstructor(use(vs.parentKey));
|
||||||
@ -69,18 +84,22 @@ export class ViewSectionHelper extends Disposable {
|
|||||||
export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
||||||
public docModel = this.gristDoc.docModel;
|
public docModel = this.gristDoc.docModel;
|
||||||
public viewModel: ViewRec;
|
public viewModel: ViewRec;
|
||||||
public layoutSpec: ko.Computed<object>;
|
public layoutSpec: ko.Computed<BoxSpec>;
|
||||||
public maximized: Observable<number|null>;
|
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 _freeze = false;
|
||||||
private _layout: Layout;
|
// Exposed for test to indicate that save has not yet been called.
|
||||||
private _sectionIds: number[];
|
private _savePending = Observable.create(this, false);
|
||||||
private _isResizing = Observable.create(this, false);
|
|
||||||
|
|
||||||
constructor(public readonly gristDoc: GristDoc, viewId: number) {
|
constructor(public readonly gristDoc: GristDoc, viewId: number) {
|
||||||
super();
|
super();
|
||||||
this.viewModel = this.docModel.views.getRowModel(viewId);
|
this.viewModel = this.docModel.views.getRowModel(viewId);
|
||||||
|
|
||||||
|
|
||||||
// A Map from viewSection RowModels to corresponding View class instances.
|
// A Map from viewSection RowModels to corresponding View class instances.
|
||||||
// TODO add a test that creating / deleting a section creates/destroys one instance, and
|
// TODO add a test that creating / deleting a section creates/destroys one instance, and
|
||||||
// switching pages destroys all instances.
|
// switching pages destroys all instances.
|
||||||
@ -93,40 +112,34 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
() => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj()))
|
() => this._updateLayoutSpecWithSections(this.viewModel.layoutSpecObj()))
|
||||||
.extend({rateLimit: 0}));
|
.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._buildLeafContent.bind(this), true));
|
||||||
this._sectionIds = this._layout.getAllLeafIds();
|
|
||||||
|
|
||||||
// When the layoutSpec changes by some means other than the layout editor, rebuild.
|
// When the layoutSpec changes by some means other than the layout editor, rebuild.
|
||||||
// This includes adding/removing sections and undo/redo.
|
// 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);
|
||||||
this.listenTo(this._layout, 'layoutUserEditStop', () => {
|
this.layoutSaveDelay.schedule(1000, () => {
|
||||||
this._isResizing.set(false);
|
this.saveLayoutSpec();
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Do not save if the user has started editing again.
|
// Do not save if the user has started editing again.
|
||||||
this.listenTo(this._layout, 'layoutUserEditStart', () => {
|
this.listenTo(this.layout, 'layoutUserEditStart', () => {
|
||||||
layoutSaveDelay.cancel();
|
this.layoutSaveDelay.cancel();
|
||||||
this._isResizing.set(true);
|
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
|
// 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.
|
// 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));
|
this.autoDispose(this.gristDoc.resizeEmitter.addListener(this._onResize, this));
|
||||||
|
|
||||||
@ -148,19 +161,19 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
// Make resize.
|
// Make resize.
|
||||||
view.onResize();
|
view.onResize();
|
||||||
}, 0);
|
}, 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.
|
// Don't need to dispose the listener, as the rootElem is disposed with the layout.
|
||||||
|
|
||||||
const classActive = cssLayoutBox.className + '-active';
|
const classActive = cssLayoutBox.className + '-active';
|
||||||
const classInactive = cssLayoutBox.className + '-inactive';
|
const classInactive = cssLayoutBox.className + '-inactive';
|
||||||
this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => {
|
this.autoDispose(subscribe(fromKo(this.viewModel.activeSection), (use, section) => {
|
||||||
const id = section.getRowId();
|
const id = section.getRowId();
|
||||||
this._layout.forEachBox(box => {
|
this.layout.forEachBox(box => {
|
||||||
box.dom!.classList.add(classInactive);
|
box.dom!.classList.add(classInactive);
|
||||||
box.dom!.classList.remove(classActive);
|
box.dom!.classList.remove(classActive);
|
||||||
box.dom!.classList.remove("transition");
|
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')) {
|
while (elem?.matches('.layout_box')) {
|
||||||
elem.classList.remove(classInactive);
|
elem.classList.remove(classInactive);
|
||||||
elem.classList.add(classActive);
|
elem.classList.add(classActive);
|
||||||
@ -172,10 +185,10 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const commandGroup = {
|
const commandGroup = {
|
||||||
deleteSection: () => { this._removeViewSection(this.viewModel.activeSectionId()); },
|
deleteSection: () => { this.removeViewSection(this.viewModel.activeSectionId()); },
|
||||||
nextSection: () => { this._otherSection(+1); },
|
nextSection: () => { this._otherSection(+1); },
|
||||||
prevSection: () => { 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); },
|
sortFilterMenuOpen: (sectionId?: number) => { this._openSortFilterMenu(sectionId); },
|
||||||
maximizeActiveSection: () => { this._maximizeActiveSection(); },
|
maximizeActiveSection: () => { this._maximizeActiveSection(); },
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
@ -186,7 +199,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
};
|
};
|
||||||
this.autoDispose(commands.createGroup(commandGroup, this, true));
|
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 => {
|
this.autoDispose(this.maximized.addListener(val => {
|
||||||
const section = this.viewModel.activeSection.peek();
|
const section = this.viewModel.activeSection.peek();
|
||||||
// If section is not disposed and it is not a deleted row.
|
// 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(
|
return cssOverlay(
|
||||||
cssOverlay.cls('-active', use => !!use(this.maximized)),
|
cssOverlay.cls('-active', use => !!use(this.maximized)),
|
||||||
testId('viewLayout-overlay'),
|
testId('viewLayout-overlay'),
|
||||||
cssLayoutWrapper(
|
cssLayoutContainer(
|
||||||
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
|
this.layoutTray.buildDom(),
|
||||||
this._layout.rootElem,
|
cssLayoutWrapper(
|
||||||
|
cssLayoutWrapper.cls('-active', use => !!use(this.maximized)),
|
||||||
|
this.layout.rootElem,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
dom.maybe(use => !!use(this.maximized), () =>
|
dom.maybe(use => !!use(this.maximized), () =>
|
||||||
cssCloseButton('CrossBig',
|
cssCloseButton('CrossBig',
|
||||||
@ -212,7 +228,8 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
// Close the lightbox when user clicks exactly on the overlay.
|
// 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;
|
return await promise;
|
||||||
} finally {
|
} finally {
|
||||||
this._freeze = false;
|
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
|
// Removes a view section from the current view. Should only be called if there is
|
||||||
// more than one viewsection in the view.
|
// more than one viewsection in the view.
|
||||||
private _removeViewSection(viewSectionRowId: number) {
|
public removeViewSection(viewSectionRowId: number) {
|
||||||
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
|
this.gristDoc.docData.sendAction(['RemoveViewSection', viewSectionRowId]).catch(reportError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public rebuildLayout(layoutSpec: object) {
|
||||||
|
this.layout.buildLayout(layoutSpec, true);
|
||||||
|
this._onResize();
|
||||||
|
}
|
||||||
|
|
||||||
private _maximizeActiveSection() {
|
private _maximizeActiveSection() {
|
||||||
const activeSection = this.viewModel.activeSection();
|
const activeSection = this.viewModel.activeSection();
|
||||||
const activeSectionId = activeSection.getRowId();
|
const activeSectionId = activeSection.getRowId();
|
||||||
const activeSectionBox = this._layout.getLeafBox(activeSectionId);
|
const activeSectionBox = this.layout.getLeafBox(activeSectionId);
|
||||||
if (!activeSectionBox) { return; }
|
if (!activeSectionBox) { return; }
|
||||||
activeSectionBox.maximize();
|
activeSectionBox.maximize();
|
||||||
}
|
}
|
||||||
@ -247,7 +285,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
return buildViewSectionDom({
|
return buildViewSectionDom({
|
||||||
gristDoc: this.gristDoc,
|
gristDoc: this.gristDoc,
|
||||||
sectionRowId,
|
sectionRowId,
|
||||||
isResizing: this._isResizing,
|
isResizing: this.isResizing,
|
||||||
viewModel: this.viewModel
|
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
|
* 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.
|
* 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.
|
// 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);
|
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.
|
// 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();
|
tmpLayout.getLeafBox(leafId)?.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
// For all fields that should be in the spec but aren't, add them to tmpLayout. We maintain a
|
// 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.
|
// 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) {
|
const missingLeafs = _.difference(viewSectionIds, specFieldIds);
|
||||||
// Only add the builder box if it hasn`t already been created
|
const collapsedLeafs = new Set((spec.collapsed || []).map(c => c.leaf));
|
||||||
addToSpec(leafId);
|
missingLeafs.forEach(function(leafId: any) {
|
||||||
|
if (!collapsedLeafs.has(leafId)) {
|
||||||
|
addToSpec(leafId);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
spec = tmpLayout.getLayoutSpec();
|
spec = tmpLayout.getLayoutSpec();
|
||||||
@ -294,11 +335,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
return spec;
|
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.
|
// Resizes the scrolly windows of all viewSection classes with a 'scrolly' property.
|
||||||
private _onResize() {
|
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
|
// Select another section in cyclic ordering of sections. Order is counter-clockwise if given a
|
||||||
// positive `delta`, clockwise otherwise.
|
// positive `delta`, clockwise otherwise.
|
||||||
private _otherSection(delta: number) {
|
private _otherSection(delta: number) {
|
||||||
|
const sectionIds = this.layout.getAllLeafIds();
|
||||||
const sectionId = this.viewModel.activeSectionId.peek();
|
const sectionId = this.viewModel.activeSectionId.peek();
|
||||||
const currentIndex = this._sectionIds.indexOf(sectionId);
|
const currentIndex = sectionIds.indexOf(sectionId);
|
||||||
const index = mod(currentIndex + delta, this._sectionIds.length);
|
const index = mod(currentIndex + delta, sectionIds.length);
|
||||||
|
|
||||||
// update the active section id
|
// update the active section id
|
||||||
this.viewModel.activeSectionId(this._sectionIds[index]);
|
this.viewModel.activeSectionId(sectionIds[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _maybeFocusInSection() {
|
private _maybeFocusInSection() {
|
||||||
// If the focused element is inside a view section, make that section active.
|
// 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) {
|
if (layoutBox && layoutBox.leafId) {
|
||||||
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
|
this.gristDoc.viewModel.activeSectionId(layoutBox.leafId.peek());
|
||||||
}
|
}
|
||||||
@ -336,7 +373,7 @@ export class ViewLayout extends DisposableWithEvents implements IDomComponent {
|
|||||||
*/
|
*/
|
||||||
private _openSortFilterMenu(sectionId?: number) {
|
private _openSortFilterMenu(sectionId?: number) {
|
||||||
const id = sectionId ?? this.viewModel.activeSectionId();
|
const id = sectionId ?? this.viewModel.activeSectionId();
|
||||||
const leafBoxDom = this._layout.getLeafBox(id)?.dom;
|
const leafBoxDom = this.layout.getLeafBox(id)?.dom;
|
||||||
if (!leafBoxDom) { return; }
|
if (!leafBoxDom) { return; }
|
||||||
|
|
||||||
const menu: HTMLElement | null = leafBoxDom.querySelector('.test-section-menu-sortAndFilter');
|
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', `
|
const cssLayoutBox = styled('div', `
|
||||||
@media screen and ${mediaSmall} {
|
@media screen and ${mediaSmall} {
|
||||||
&-active, &-inactive {
|
&-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', `
|
const cssLayoutWrapper = styled('div', `
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
flex-grow: 1;
|
||||||
@media not print {
|
@media not print {
|
||||||
&-active {
|
&-active {
|
||||||
background: ${theme.mainPanelBg};
|
background: ${theme.mainPanelBg};
|
||||||
@ -536,6 +438,7 @@ const cssLayoutWrapper = styled('div', `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssOverlay = styled('div', `
|
const cssOverlay = styled('div', `
|
||||||
|
height: 100%;
|
||||||
@media screen {
|
@media screen {
|
||||||
&-active {
|
&-active {
|
||||||
background-color: ${theme.modalBackdrop};
|
background-color: ${theme.modalBackdrop};
|
||||||
@ -545,6 +448,9 @@ const cssOverlay = styled('div', `
|
|||||||
padding: 20px 56px 20px 56px;
|
padding: 20px 56px 20px 56px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
&-active .collapsed_layout {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media screen and ${mediaSmall} {
|
@media screen and ${mediaSmall} {
|
||||||
&-active {
|
&-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',
|
name: 'deleteSection',
|
||||||
keys: [],
|
keys: [],
|
||||||
desc: 'Delete the currently active viewsection'
|
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',
|
name: 'duplicateRows',
|
||||||
keys: ['Mod+Shift+d'],
|
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 {KoArray} from 'app/client/lib/koArray';
|
||||||
import * as koUtil from 'app/client/lib/koUtil';
|
import * as koUtil from 'app/client/lib/koUtil';
|
||||||
import {DocModel, IRowModel, recordSet, refRecord} from 'app/client/models/DocModel';
|
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>>;
|
viewSections: ko.Computed<KoArray<ViewSectionRec>>;
|
||||||
tabBarItem: ko.Computed<KoArray<TabBarRec>>;
|
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.
|
// An observable for the ref of the section last selected by the user.
|
||||||
activeSectionId: ko.Computed<number>;
|
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>;
|
activeSection: ko.Computed<ViewSectionRec>;
|
||||||
|
|
||||||
// If the active section is removed, set the next active section to be the default.
|
// 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.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.
|
// 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._isActiveSectionGone = this.autoDispose(ko.computed(() => this.activeSection()._isDeleted()));
|
||||||
this.autoDispose(this._isActiveSectionGone.subscribe(gone => {
|
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'
|
// 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.
|
// in which case the UI prevents various things like hiding columns or changing the widget type.
|
||||||
isRaw: ko.Computed<boolean>;
|
isRaw: ko.Computed<boolean>;
|
||||||
|
isCollapsed: ko.Computed<boolean>;
|
||||||
|
|
||||||
borderWidthPx: ko.Computed<string>;
|
borderWidthPx: ko.Computed<string>;
|
||||||
|
|
||||||
@ -722,4 +723,9 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel):
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.removeRule = (index: number) => removeRule(docModel, this, index);
|
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 {urlState} from 'app/client/models/gristUrlState';
|
||||||
import {testId} from 'app/client/ui2018/cssVars';
|
import {testId} from 'app/client/ui2018/cssVars';
|
||||||
import {menuDivider, menuItemCmd, menuItemLink} from 'app/client/ui2018/menus';
|
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');
|
const t = makeT('ViewLayoutMenu');
|
||||||
|
|
||||||
@ -42,10 +43,27 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
|||||||
anchorUrlState.hash!.popup = true;
|
anchorUrlState.hash!.popup = true;
|
||||||
const rawUrl = urlState().makeUrl(anchorUrlState);
|
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 [
|
return [
|
||||||
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
|
dom.maybe((use) => ['single'].includes(use(viewSection.parentKey)), () => contextMenu),
|
||||||
dom.maybe((use) => !use(viewSection.isRaw) && !isLight && !use(gristDoc.sectionInPopup),
|
dom.maybe(showRawData,
|
||||||
() => menuItemLink(
|
() => menuItemLink(
|
||||||
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
|
{ href: rawUrl}, t("Show raw data"), testId('show-raw-data'),
|
||||||
dom.on('click', (ev) => {
|
dom.on('click', (ev) => {
|
||||||
@ -78,8 +96,44 @@ export function makeViewLayoutMenu(viewSection: ViewSectionRec, isReadonly: bool
|
|||||||
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
|
menuItemCmd(allCommands.openWidgetConfiguration, t("Open configuration"),
|
||||||
testId('section-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"),
|
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')),
|
testId('section-delete')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,8 @@ export function viewSectionMenu(
|
|||||||
// Should we show expand icon.
|
// Should we show expand icon.
|
||||||
const showExpandIcon = Computed.create(owner, (use) => {
|
const showExpandIcon = Computed.create(owner, (use) => {
|
||||||
return !use(isNarrowScreenObs()) // not on narrow screens
|
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
|
&& !use(viewSection.isRaw); // not in raw mode
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -210,7 +211,7 @@ function makeCustomOptions(section: ViewSectionRec) {
|
|||||||
const clsOldUI = styled('div', ``);
|
const clsOldUI = styled('div', ``);
|
||||||
|
|
||||||
|
|
||||||
const cssMenu = styled('div', `
|
export const cssMenu = styled('div', `
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 3px;
|
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;
|
border-radius: 0px 2px 2px 0px;
|
||||||
display: flex;
|
display: flex;
|
||||||
.${clsOldUI.className} & {
|
.${clsOldUI.className} & {
|
||||||
|
@ -257,7 +257,7 @@ export function encodeUrl(gristConfig: Partial<GristLoadConfig>,
|
|||||||
queryParams[`${k}_`] = v;
|
queryParams[`${k}_`] = v;
|
||||||
}
|
}
|
||||||
const hashParts: string[] = [];
|
const hashParts: string[] = [];
|
||||||
if (state.hash && state.hash.rowId) {
|
if (state.hash && (state.hash.rowId || state.hash.popup)) {
|
||||||
const hash = state.hash;
|
const hash = state.hash;
|
||||||
hashParts.push(state.hash?.popup ? 'a2' : `a1`);
|
hashParts.push(state.hash?.popup ? 'a2' : `a1`);
|
||||||
for (const key of ['sectionId', 'rowId', 'colRef'] as Array<keyof HashLink>) {
|
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);
|
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.
|
// 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
|
// 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-new', 2000).doClick();
|
||||||
await driver.findWait('.test-dp-add-widget-to-page', 500).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'
|
Filter: '.test-filter-menu-wrapper'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
async function openColumnMenuHelper(col: IColHeader|string, option?: string): Promise<WebElement> {
|
async function openColumnMenuHelper(col: IColHeader|string, option?: string): Promise<WebElement> {
|
||||||
await getColumnHeader(typeof col === 'string' ? {col} : col).mouseMove().find('.g-column-main-menu').click();
|
await getColumnHeader(typeof col === 'string' ? {col} : col).mouseMove().find('.g-column-main-menu').click();
|
||||||
const menu = await driver.findWait('.grist-floating-menu', 100);
|
const menu = await driver.findWait('.grist-floating-menu', 100);
|
||||||
|
Loading…
Reference in New Issue
Block a user