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/D3779pull/452/head
parent
e9efac05f7
commit
59cf654190
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||
`);
|
@ -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;
|
@ -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));
|
||||
});
|
||||
});
|
Loading…
Reference in new issue