Notebook Views autodash feature (#16238)

The autodash feature in notebook views creates an initial grid layout for users when a view is created. It is intended to reduce the effort required by the user to start editing their view. Instead of displaying every cell and stacking them vertically like the default notebook layout, we use guidelines to determine which cells are worth displaying and how to arrange them.
This commit is contained in:
Daniel Grajeda
2021-07-21 21:46:58 -06:00
committed by GitHub
parent 6d4608dd8b
commit c5c7ca019d
4 changed files with 326 additions and 32 deletions

View File

@@ -4,33 +4,48 @@
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./notebookViewsGrid';
import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, ViewChildren, QueryList, Input, Inject, forwardRef, ChangeDetectorRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core';
import { NotebookViewsCardComponent } from 'sql/workbench/contrib/notebook/browser/notebookViews/notebookViewsCard.component';
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
import { NotebookModel } from 'sql/workbench/services/notebook/browser/models/notebookModel';
import { GridStack, GridStackEvent, GridStackNode } from 'gridstack';
import { localize } from 'vs/nls';
import { NotebookViewsExtension } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViewsExtension';
import { CellChangeEvent, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
import { CellChangeEvent, INotebookView, INotebookViewCell } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
import { AngularDisposable } from 'sql/base/browser/lifecycle';
import { generateLayout } from 'sql/workbench/services/notebook/browser/notebookViews/autodash';
export interface INotebookViewsGridOptions {
cellHeight?: number;
}
@Component({
selector: 'notebook-views-grid-component',
templateUrl: decodeURI(require.toUrl('./notebookViewsGrid.component.html'))
templateUrl: decodeURI(require.toUrl('./notebookViewsGrid.component.html')),
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class NotebookViewsGridComponent implements OnInit {
export class NotebookViewsGridComponent extends AngularDisposable implements OnInit {
@Input() cells: ICellModel[];
@Input() model: NotebookModel;
@Input() activeView: INotebookView;
@Input() views: NotebookViewsExtension;
@ViewChildren(NotebookViewsCardComponent) private _items: QueryList<NotebookViewsCardComponent>;
protected _grid: GridStack;
public loaded: boolean;
protected _gridEnabled: boolean;
protected _loaded: boolean;
protected _options: INotebookViewsGridOptions = {
cellHeight: 60
};;
constructor(
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
) {
this.loaded = false;
super();
this._loaded = false;
}
public get empty(): boolean {
@@ -38,7 +53,7 @@ export class NotebookViewsGridComponent implements OnInit {
}
public get hiddenItems(): NotebookViewsCardComponent[] {
return this._items.filter(item => !item.display);
return this._items?.filter(item => !item.display) ?? [];
}
public get emptyText(): String {
@@ -50,17 +65,115 @@ export class NotebookViewsGridComponent implements OnInit {
ngAfterViewInit() {
const self = this;
self._grid = GridStack.init({
alwaysShowResizeHandle: false,
styleInHead: true
});
this.createGrid();
this.loaded = true;
this._loaded = true;
this.detectChanges();
self._grid.on('added', function (e: Event, items: GridStackNode[]) { self.persist('added', items, self._grid, self._items); });
self._grid.on('removed', function (e: Event, items: GridStackNode[]) { self.persist('removed', items, self._grid, self._items); });
self._grid.on('change', function (e: Event, items: GridStackNode[]) { self.persist('change', items, self._grid, self._items); });
self._grid.on('added', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('added', items, self._grid, self._items); } });
self._grid.on('removed', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('removed', items, self._grid, self._items); } });
self._grid.on('change', function (e: Event, items: GridStackNode[]) { if (self._gridEnabled) { self.persist('change', items, self._grid, self._items); } });
}
ngAfterContentChecked() {
//If activeView has changed or not present, we will destroy the grid in order to rebuild it later.
if (!this.activeView || this.activeView.guid !== this.activeView.guid) {
if (this._grid) {
this.destroyGrid();
this._grid = undefined;
}
}
}
ngAfterViewChecked() {
// If activeView has changed, rebuild the grid
if (!this.activeView || this.activeView.guid !== this.activeView.guid) {
if (!this._grid) {
this.createGrid();
}
this._loaded = true;
this.detectChanges();
}
}
override ngOnDestroy() {
this.destroyGrid();
}
private destroyGrid() {
this._gridEnabled = false;
this._grid.destroy(false);
}
private createGrid() {
const isNew = this.activeView.isNew;
if (this._grid) {
this.destroyGrid();
}
if (isNew) {
this.runAutoLayout(this.activeView);
this.activeView.markAsViewed();
}
this._grid = GridStack.init({
alwaysShowResizeHandle: false,
styleInHead: true,
margin: 2,
staticGrid: false,
});
this._gridEnabled = true;
if (isNew) {
this.updateGrid();
}
}
/**
* Updates the grid layout based on changes to the view model
*/
private updateGrid(): void {
if (!this._grid || !this.activeView) {
return;
}
this._grid.batchUpdate();
this.activeView.cells.forEach(cell => {
const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === cell.cellGuid);
const cellData = this.activeView.getCellMetadata(cell);
this._grid.update(el, { x: cellData.x, y: cellData.y, w: cellData.width, h: cellData.height });
if (cellData?.hidden) {
this._grid.removeWidget(el, false); // Do not trigger event for batch update
}
});
this._grid.commit();
}
private resizeCells(): void {
this._items.forEach((i: NotebookViewsCardComponent) => {
if (i.elementRef) {
const cellHeight = this._options.cellHeight;
const naturalHeight = i.elementRef.nativeElement.clientHeight;
const heightInCells = Math.ceil(naturalHeight / cellHeight);
const update: INotebookViewCell = {
height: heightInCells
};
this.views.updateCell(i.cell, this.activeView, update);
}
});
}
private runAutoLayout(view: INotebookView): void {
//Resize the cells before regenerating layout so that we know the natural height of the cells
this.resizeCells();
generateLayout(view);
}
private detectChanges(): void {
@@ -70,28 +183,25 @@ export class NotebookViewsGridComponent implements OnInit {
}
async onCellChanged(e: CellChangeEvent): Promise<void> {
const currentView = this.views.getActiveView();
if (this._grid && currentView) {
if (this._grid && this.activeView) {
const cellElem: HTMLElement = this._grid.el.querySelector(`[data-cell-id='${e.cell.cellGuid}']`);
if (cellElem && e.event === 'hide') {
this._grid.removeWidget(cellElem);
currentView.hideCell(e.cell);
this.activeView.hideCell(e.cell);
}
if (e.cell && e.event === 'insert') {
const component = this._items.find(x => x.cell.cellGuid === e.cell.cellGuid);
// Prevent an awkward movement on the grid by moving this out of view first
currentView.moveCell(e.cell, 9999, 0);
currentView.insertCell(e.cell);
this.activeView.insertCell(e.cell);
this.detectChanges();
const el = this._grid.getGridItems().find(x => x.getAttribute('data-cell-id') === e.cell.cellGuid);
this._grid.makeWidget(el);
this._grid.update(el, { x: 0, y: 0 });
this._grid.resizable(el, true);
this._grid.movable(el, true);
component.detectChanges();
}
this.detectChanges();
}
}
@@ -99,15 +209,14 @@ export class NotebookViewsGridComponent implements OnInit {
/**
* Update the document model with the gridstack data as metadata
*/
persist(action: GridStackEvent, changedItems: GridStackNode[], grid: GridStack, items: QueryList<NotebookViewsCardComponent>): void {
persist(action: GridStackEvent, changedItems: GridStackNode[] = [], grid: GridStack, items: QueryList<NotebookViewsCardComponent>): void {
changedItems.forEach((changedItem) => {
const cellId = changedItem.el.getAttribute('data-cell-id');
const item = items.toArray().find(item => item.cell.cellGuid === cellId);
const activeView = this.views.getActiveView();
if (item && activeView) {
if (item && this.activeView) {
const update: INotebookViewCell = {
guid: activeView.guid,
guid: this.activeView.guid,
x: changedItem.x,
y: changedItem.y,
width: changedItem.w,
@@ -120,9 +229,12 @@ export class NotebookViewsGridComponent implements OnInit {
update.hidden = true;
}
this.views.updateCell(item.cell, activeView, update);
this.views.updateCell(item.cell, this.activeView, update);
}
});
}
public get loaded(): boolean {
return this._loaded;
}
}

View File

@@ -0,0 +1,118 @@
import { nb } from 'azdata';
import { ICellModel } from 'sql/workbench/services/notebook/browser/models/modelInterfaces';
import { INotebookView } from 'sql/workbench/services/notebook/browser/notebookViews/notebookViews';
import { CellTypes } from 'sql/workbench/services/notebook/common/contracts';
class VisInfo<T> {
public width: number;
public height: number;
public orderRank: number;
public display: boolean;
public cell: T;
}
class DisplayCell<T> {
constructor(private _item: T) { }
get item(): T {
return this._item;
}
}
abstract class DisplayGroup<T> {
public width: number;
public height: number;
public orderRank: number;
public display: boolean;
private _displayCells: DisplayCell<T>[] = [];
private _visInfos: VisInfo<T>[] = [];
constructor() { }
addCell(cell: T, initialView: INotebookView) {
const dCell = new DisplayCell<T>(cell);
this._displayCells.push(dCell);
this._visInfos.push(this.evaluateCell(cell, initialView));
}
get visInfos(): VisInfo<T>[] {
return this._visInfos;
}
get displayCells(): DisplayCell<T>[] {
return this._displayCells;
}
abstract evaluateCell(cell: T, view: INotebookView): VisInfo<T>;
}
class CellDisplayGroup extends DisplayGroup<ICellModel> {
evaluateCell(cell: ICellModel, view: INotebookView): VisInfo<ICellModel> {
let meta = view.getCellMetadata(cell);
let visInfo = new VisInfo<ICellModel>();
visInfo.cell = cell;
if (cell.cellType !== CellTypes.Code && !this.isHeader(cell)) {
visInfo.display = false;
return visInfo;
}
if (cell.cellType === CellTypes.Code && (!cell.outputs || !cell.outputs.length)) {
visInfo.display = false;
return visInfo;
}
//For headers
if (this.isHeader(cell)) {
visInfo.height = 1;
}
//For graphs
if (this.hasGraph(cell)) {
visInfo.width = 6;
visInfo.height = 4;
}
//For tables
else if (this.hasTable(cell)) {
visInfo.height = Math.min(meta?.height, 3);
} else {
visInfo.height = Math.min(meta?.height, 3);
}
visInfo.display = true;
return visInfo;
}
isHeader(cell: ICellModel): boolean {
return cell.cellType === 'markdown' && cell.source.length === 1 && cell.source[0].startsWith('#');
}
hasGraph(cell: ICellModel): boolean {
return !!cell.outputs.find((o: nb.IDisplayResult) => o?.output_type === 'display_data' && o?.data.hasOwnProperty('application/vnd.plotly.v1+json'));
}
hasTable(cell: ICellModel): boolean {
return !!cell.outputs.find((o: nb.IDisplayResult) => o?.output_type === 'display_data' && o?.data.hasOwnProperty('application/vnd.dataresource+json'));
}
}
export function generateLayout(initialView: INotebookView): void {
let displayGroup: CellDisplayGroup = new CellDisplayGroup();
const cells = initialView.cells;
cells.forEach((cell, idx) => {
displayGroup.addCell(cell, initialView);
});
displayGroup.visInfos.forEach((v) => {
if (!v.display) {
initialView.hideCell(v.cell);
}
if (v.width || v.height) {
initialView.resizeCell(v.cell, v.width, v.height);
}
});
initialView.compactCells();
}

View File

@@ -11,23 +11,31 @@ import { generateUuid } from 'vs/base/common/uuid';
export const DEFAULT_VIEW_CARD_HEIGHT = 4;
export const DEFAULT_VIEW_CARD_WIDTH = 12;
export const GRID_COLUMNS = 12;
export class ViewNameTakenError extends Error { }
function cellCollides(c1: INotebookViewCell, c2: INotebookViewCell): boolean {
return !((c1.y + c1.height <= c2.y) || (c1.x + c1.width <= c2.x) || (c1.x + c1.width <= c2.x) || (c2.x + c2.width <= c1.x));
}
export class NotebookViewModel implements INotebookView {
private _onDeleted = new Emitter<INotebookView>();
private _isNew: boolean = false;
public readonly guid: string;
public readonly onDeleted = this._onDeleted.event;
constructor(
protected _name: string,
private _notebookViews: NotebookViewsExtension
private _notebookViews: NotebookViewsExtension,
guid?: string
) {
this.guid = generateUuid();
}
public initialize(): void {
this._isNew = true;
const cells = this._notebookViews.notebook.cells;
cells.forEach((cell, idx) => { this.initializeCell(cell, idx); });
}
@@ -45,6 +53,8 @@ export class NotebookViewModel implements INotebookView {
hidden: false,
y: idx * DEFAULT_VIEW_CARD_HEIGHT,
x: 0,
width: DEFAULT_VIEW_CARD_WIDTH,
height: DEFAULT_VIEW_CARD_HEIGHT
});
}
@@ -76,6 +86,10 @@ export class NotebookViewModel implements INotebookView {
return this._notebookViews.notebook.cells;
}
public get displayedCells(): Readonly<ICellModel[]> {
return this.cells.filter(cell => !this.getCellMetadata(cell)?.hidden);
}
public getCell(guid: string): Readonly<ICellModel> {
return this._notebookViews.notebook.cells.find(cell => cell.cellGuid === guid);
}
@@ -92,8 +106,46 @@ export class NotebookViewModel implements INotebookView {
this._notebookViews.updateCell(cell, this, { x, y });
}
public resizeCell(cell: ICellModel, width: number, height: number) {
this._notebookViews.updateCell(cell, this, { width, height });
public resizeCell(cell: ICellModel, width?: number, height?: number) {
let data: INotebookViewCell = {};
if (width) {
data.width = width;
}
if (height) {
data.height = height;
}
this._notebookViews.updateCell(cell, this, data);
}
public getCellSize(cell: ICellModel): any {
const meta = this.getCellMetadata(cell);
return { width: meta.width, height: meta.height };
}
public compactCells() {
let cellsPlaced: INotebookViewCell[] = [];
this.displayedCells.forEach((cell: ICellModel) => {
const c1 = this.getCellMetadata(cell);
for (let i = 0; ; i++) {
const row = i % GRID_COLUMNS;
const column = Math.floor(i / GRID_COLUMNS);
if (row + c1.width > GRID_COLUMNS) {
continue;
}
if (!cellsPlaced.find((c2) => cellCollides(c2, { ...c1, x: row, y: column }))) {
this._notebookViews.updateCell(cell, this, { x: row, y: column });
cellsPlaced.push({ ...c1, x: row, y: column });
break;
}
}
});
}
public save() {
@@ -105,6 +157,14 @@ export class NotebookViewModel implements INotebookView {
this._onDeleted.fire(this);
}
public get isNew(): boolean {
return this._isNew;
}
public markAsViewed() {
this._isNew = false;
}
public toJSON() {
return { guid: this.guid, name: this._name } as NotebookViewModel;
}

View File

@@ -17,17 +17,21 @@ export interface INotebookView {
readonly guid: string;
readonly onDeleted: Event<INotebookView>;
isNew: boolean;
cells: Readonly<ICellModel[]>;
hiddenCells: Readonly<ICellModel[]>;
displayedCells: Readonly<ICellModel[]>;
name: string;
initialize(): void;
nameAvailable(name: string): boolean;
getCellMetadata(cell: ICellModel): INotebookViewCell;
hideCell(cell: ICellModel): void;
moveCell(cell: ICellModel, x: number, y: number): void;
compactCells();
resizeCell(cell: ICellModel, width: number, height: number): void;
getCell(guid: string): Readonly<ICellModel>;
insertCell(cell: ICellModel): void;
markAsViewed(): void;
save(): void;
delete(): void;
}