From 4d6d5cdd492bef268814dba0e1e870a085f50d56 Mon Sep 17 00:00:00 2001 From: Alain Dumesny Date: Sat, 1 Apr 2023 17:39:33 -0700 Subject: [PATCH] feature: Angular nested grid * updated the Angular demo and wrapper to support nested grids * fixed gridstack to call addRemoveCB for grids now, as well as sub-grid items * WARNING: AddRemoveFcn had to change slightly to support calling grid or gridItem from same callback. --- demo/angular/src/app/app.component.css | 4 ++ demo/angular/src/app/app.component.html | 26 ++++++++--- demo/angular/src/app/app.component.ts | 29 ++++++++++-- .../src/app/gridstack-item.component.ts | 22 +++++++-- demo/angular/src/app/gridstack.component.ts | 37 ++++++++++----- demo/angular/src/styles.css | 2 + doc/CHANGES.md | 3 +- src/gridstack.ts | 46 ++++++++++++------- src/types.ts | 14 +++--- 9 files changed, 134 insertions(+), 49 deletions(-) diff --git a/demo/angular/src/app/app.component.css b/demo/angular/src/app/app.component.css index e19bfa2bc..4308bbd73 100644 --- a/demo/angular/src/app/app.component.css +++ b/demo/angular/src/app/app.component.css @@ -1,3 +1,7 @@ .test-container { margin-top: 30px; } +button.active { + color: #fff; + background-color: #007bff; +} diff --git a/demo/angular/src/app/app.component.html b/demo/angular/src/app/app.component.html index e69e454bb..704f47f17 100644 --- a/demo/angular/src/app/app.component.html +++ b/demo/angular/src/app/app.component.html @@ -1,12 +1,13 @@ -
+

Pick a demo to load:

- - - - - - + + + + + + +
@@ -49,4 +50,15 @@
+ +
+

Nested Grid: shows nested component grids, like nested.html demo but with Ng Components

+ + + + + + +
+
diff --git a/demo/angular/src/app/app.component.ts b/demo/angular/src/app/app.component.ts index 8c4bc22a7..ae9ba80e6 100644 --- a/demo/angular/src/app/app.component.ts +++ b/demo/angular/src/app/app.component.ts @@ -29,12 +29,35 @@ export class AppComponent { children: this.items, } + // nested grid options + public sub1: GridStackWidget[] = [ {x:0, y:0}, {x:1, y:0}, {x:2, y:0}, {x:3, y:0}, {x:0, y:1}, {x:1, y:1}]; + public sub2: GridStackWidget[] = [ {x:0, y:0}, {x:0, y:1, w:2}]; + public subOptions: GridStackOptions = { + cellHeight: 50, // should be 50 - top/bottom + column: 'auto', // size to match container. make sure to include gridstack-extra.min.css + acceptWidgets: true, // will accept .grid-stack-item by default + margin: 5, + }; + public nestedGridOptions: GridStackOptions = { // main grid options + cellHeight: 50, + margin: 5, + minRow: 2, // don't collapse when empty + disableOneColumnMode: true, + acceptWidgets: true, + id: 'main', + children: [ + {x:0, y:0, content: 'regular item', id: 0}, + {x:1, y:0, w:4, h:4, subGrid: {children: this.sub1, id:'sub1_grid', class: 'sub1', ...this.subOptions}}, + {x:5, y:0, w:3, h:4, subGrid: {children: this.sub2, id:'sub2_grid', class: 'sub2', ...this.subOptions}}, + ] + }; + constructor() { // give them content and unique id to make sure we track them during changes below... - this.items.forEach(w => { + [...this.items, ...this.sub1, ...this.sub2].forEach(w => { w.content = `item ${ids}`; w.id = String(ids++); - }) + }); } /** called whenever items change size/position/etc.. */ @@ -72,7 +95,7 @@ export class AppComponent { } /** - * TEST TEMPLATE operations for ngFor case - NOT recommended unless you have no GS creating/re-parenting + * ngFor case: TEST TEMPLATE operations - NOT recommended unless you have no GS creating/re-parenting */ public addNgFor() { // new array isn't required as Angular detects changes to content with trackBy:identify() diff --git a/demo/angular/src/app/gridstack-item.component.ts b/demo/angular/src/app/gridstack-item.component.ts index 9114c3f26..cf1db497e 100644 --- a/demo/angular/src/app/gridstack-item.component.ts +++ b/demo/angular/src/app/gridstack-item.component.ts @@ -3,9 +3,14 @@ * Copyright (c) 2022 Alain Dumesny - see GridStack root license */ -import { ChangeDetectionStrategy, Component, ElementRef, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, Input, ViewChild, ViewContainerRef, OnDestroy } from '@angular/core'; import { GridItemHTMLElement, GridStackNode } from 'gridstack'; +/** store element to Ng Class pointer back */ +export interface GridItemCompHTMLElement extends GridItemHTMLElement { + _gridItemComp?: GridstackItemComponent; +} + /** * HTML Component Wrapper for gridstack items, in combination with GridstackComponent for parent grid */ @@ -13,15 +18,21 @@ import { GridItemHTMLElement, GridStackNode } from 'gridstack'; selector: 'gridstack-item', template: `
+ {{options.content}} + +
`, styles: [` :host { display: block; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GridstackItemComponent { +export class GridstackItemComponent implements OnDestroy { + + /** container to append items dynamically */ + @ViewChild('container', { read: ViewContainerRef, static: true}) public container?: ViewContainerRef; /** list of options for creating/updating this item */ @Input() public set options(val: GridStackNode) { @@ -42,7 +53,7 @@ export class GridstackItemComponent { private _options?: GridStackNode; /** return the native element that contains grid specific fields as well */ - public get el(): GridItemHTMLElement { return this.elementRef.nativeElement; } + public get el(): GridItemCompHTMLElement { return this.elementRef.nativeElement; } /** clears the initial options now that we've built */ public clearOptions() { @@ -50,5 +61,10 @@ export class GridstackItemComponent { } constructor(private readonly elementRef: ElementRef) { + this.el._gridItemComp = this; + } + + public ngOnDestroy(): void { + delete this.el._gridItemComp; } } diff --git a/demo/angular/src/app/gridstack.component.ts b/demo/angular/src/app/gridstack.component.ts index 7a18af3bf..5a8641209 100644 --- a/demo/angular/src/app/gridstack.component.ts +++ b/demo/angular/src/app/gridstack.component.ts @@ -7,9 +7,9 @@ import { AfterContentInit, ChangeDetectionStrategy, Component, ContentChildren, NgZone, OnDestroy, OnInit, Output, QueryList, ViewChild, ViewContainerRef } from '@angular/core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { AddRemoveFcn, GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode, GridStackOptions, GridStackWidget } from 'gridstack'; +import { GridHTMLElement, GridItemHTMLElement, GridStack, GridStackNode, GridStackOptions, GridStackWidget } from 'gridstack'; -import { GridstackItemComponent } from './gridstack-item.component'; +import { GridItemCompHTMLElement, GridstackItemComponent } from './gridstack-item.component'; /** events handlers emitters signature for different events */ export type eventCB = {event: Event}; @@ -17,6 +17,11 @@ export type elementCB = {event: Event, el: GridItemHTMLElement}; export type nodesCB = {event: Event, nodes: GridStackNode[]}; export type droppedCB = {event: Event, previousNode: GridStackNode, newNode: GridStackNode}; +/** store element to Ng Class pointer back */ +export interface GridCompHTMLElement extends GridHTMLElement { + _gridComp?: GridstackComponent; +} + /** * HTML Component Wrapper for gridstack, in combination with GridstackItemComponent for the items */ @@ -71,7 +76,7 @@ export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy { @Output() public resizeStopCB = new EventEmitter(); /** return the native element that contains grid specific fields as well */ - public get el(): GridHTMLElement { return this.elementRef.nativeElement; } + public get el(): GridCompHTMLElement { return this.elementRef.nativeElement; } /** return the GridStack class */ public get grid(): GridStack | undefined { return this._grid; } @@ -79,20 +84,19 @@ export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy { private _options?: GridStackOptions; private _grid?: GridStack; private loaded?: boolean; - private outsideAddRemove?: AddRemoveFcn; private ngUnsubscribe: Subject = new Subject(); constructor( private readonly zone: NgZone, - private readonly elementRef: ElementRef, + private readonly elementRef: ElementRef, ) { + this.el._gridComp = this; } public ngOnInit(): void { // inject our own addRemove so we can create GridItemComponent instead of simple divs const opts: GridStackOptions = this._options || {}; - if (opts.addRemoveCB) this.outsideAddRemove = opts.addRemoveCB; - opts.addRemoveCB = this._addRemoveCB.bind(this); + opts.addRemoveCB = GridstackComponent._addRemoveCB; // init ourself before any template children are created since we track them below anyway - no need to double create+update widgets this.loaded = !!this.options?.children?.length; @@ -118,6 +122,7 @@ export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy { this.ngUnsubscribe.complete(); this.grid?.destroy(); delete this._grid; + delete this.el._gridComp; } /** @@ -159,14 +164,22 @@ export class GridstackComponent implements OnInit, AfterContentInit, OnDestroy { } /** called by GS when a new item needs to be created, which we do as a Angular component, or deleted (skip) */ - private _addRemoveCB(g: GridStack, w: GridStackWidget, add: boolean): HTMLElement | undefined { + private static _addRemoveCB(parent: GridCompHTMLElement | HTMLElement, w: GridStackWidget | GridStackOptions, add: boolean, isGrid: boolean): HTMLElement | undefined { if (add) { - if (!this.container) return; + if (!parent) return; // create the grid item dynamically - see https://angular.io/docs/ts/latest/cookbook/dynamic-component-loader.html - const gridItem = this.container.createComponent(GridstackItemComponent)?.instance; - return gridItem?.el; + if (isGrid) { + const gridItemComp = (parent.parentElement as GridItemCompHTMLElement)._gridItemComp; + const grid = gridItemComp?.container?.createComponent(GridstackComponent)?.instance; + if (grid) grid.options = w as GridStackOptions; + return grid?.el; + } else { + // TODO: use GridStackWidget to define what type of component to create as child, or do it in GridstackItemComponent template... + const gridComp = (parent as GridCompHTMLElement)._gridComp; + const gridItem = gridComp?.container?.createComponent(GridstackItemComponent)?.instance; + return gridItem?.el; + } } - // if (this.outsideAddRemove) this.outsideAddRemove(g, w, add); // TODO: ? return; } } diff --git a/demo/angular/src/styles.css b/demo/angular/src/styles.css index 5711dd5ab..b91194390 100644 --- a/demo/angular/src/styles.css +++ b/demo/angular/src/styles.css @@ -1,2 +1,4 @@ /* re-use existing demo css file we already use for the plain demos - that include gridstack.css which is required */ @import "../../demo.css"; +/* required file for gridstack 2-11 column */ +@import "../../../dist/gridstack-extra.css"; diff --git a/doc/CHANGES.md b/doc/CHANGES.md index 7de8ba9f3..e76ab5356 100644 --- a/doc/CHANGES.md +++ b/doc/CHANGES.md @@ -83,7 +83,8 @@ Change log ## 7.2.3-dev (TBD) -* feat [#2229](https://github.com/gridstack/gridstack.js/pull/2229) support nonce for CSP +* feat [#2229](https://github.com/gridstack/gridstack.js/pull/2229) support nonce for CSP. Thank you [@jedwards1211](https://github.com/jedwards1211) +* feat: support nested grids with Angular component demo. Thank you R. Blanken for supporting this. * fix [#2206](https://github.com/gridstack/gridstack.js/issues/2206) `load()` with collision fix * fix [#2232](https://github.com/gridstack/gridstack.js/issues/2232) `autoPosition` bug loading from DOM diff --git a/src/gridstack.ts b/src/gridstack.ts index 9ff87e350..fc3e23d4c 100644 --- a/src/gridstack.ts +++ b/src/gridstack.ts @@ -137,11 +137,16 @@ export class GridStack { // create the grid element, but check if the passed 'parent' already has grid styling and should be used instead let el = parent; - if (!parent.classList.contains('grid-stack')) { - let doc = document.implementation.createHTMLDocument(''); // IE needs a param - doc.body.innerHTML = `
`; - el = doc.body.children[0] as HTMLElement; - parent.appendChild(el); + const parentIsGrid = parent.classList.contains('grid-stack'); + if (!parentIsGrid || opt.addRemoveCB) { + if (opt.addRemoveCB) { + el = opt.addRemoveCB(parent, opt, true, true); + } else { + let doc = document.implementation.createHTMLDocument(''); // IE needs a param + doc.body.innerHTML = `
`; + el = doc.body.children[0] as HTMLElement; + parent.appendChild(el); + } } // create grid class and load any children @@ -182,7 +187,6 @@ export class GridStack { /** @internal true if we got created by drag over gesture, so we can removed on drag out (temporary) */ public _isTemp?: boolean; - /** @internal create placeholder DIV as needed */ public get placeholder(): HTMLElement { if (!this._placeholder) { @@ -409,7 +413,7 @@ export class GridStack { if (node?.el) { el = node.el; // re-use element stored in the node } else if (this.opts.addRemoveCB) { - el = this.opts.addRemoveCB(this, options, true); + el = this.opts.addRemoveCB(this.el, options, true, false); } else { let content = options?.content || ''; let doc = document.implementation.createHTMLDocument(''); // IE needs a param @@ -443,7 +447,7 @@ export class GridStack { // see if there is a sub-grid to create if (node.subGrid) { - this.makeSubGrid(node.el, undefined, undefined, false); + this.makeSubGrid(node.el, undefined, undefined, false); //node.subGrid will be used as option in method, no need to pass } // if we're adding an item into 1 column (_prevColumn is set only when going to 1) make sure @@ -493,16 +497,11 @@ export class GridStack { } // if we're converting an existing full item, move over the content to be the first sub item in the new grid - // TODO: support this.opts.addRemoveCB for frameworks let content = node.el.querySelector('.grid-stack-item-content') as HTMLElement; let newItem: HTMLElement; let newItemOpt: GridStackNode; if (saveContent) { this._removeDD(node.el); // remove D&D since it's set on content div - let doc = document.implementation.createHTMLDocument(''); // IE needs a param - doc.body.innerHTML = `
`; - newItem = doc.body.children[0] as HTMLElement; - newItem.appendChild(content); newItemOpt = {...node, x:0, y:0}; Utils.removeInternalForSave(newItemOpt); delete newItemOpt.subGrid; @@ -510,9 +509,17 @@ export class GridStack { newItemOpt.content = node.content; delete node.content; } - doc.body.innerHTML = `
`; - content = doc.body.children[0] as HTMLElement; - node.el.appendChild(content); + if (this.opts.addRemoveCB) { + newItem = this.opts.addRemoveCB(this.el, newItemOpt, true, false); + } else { + let doc = document.implementation.createHTMLDocument(''); // IE needs a param + doc.body.innerHTML = `
`; + newItem = doc.body.children[0] as HTMLElement; + newItem.appendChild(content); + doc.body.innerHTML = `
`; + content = doc.body.children[0] as HTMLElement; + node.el.appendChild(content); + } this._prepareDragDropByNode(node); // ... and restore original D&D } @@ -526,6 +533,9 @@ export class GridStack { setTimeout(() => style.transition = null); // recover animation } + if (this.opts.addRemoveCB) { + ops.addRemoveCB = ops.addRemoveCB || this.opts.addRemoveCB; + } let subGrid = node.subGrid = GridStack.addGrid(content, ops); if (nodeToAdd?._moving) subGrid._isTemp = true; // prevent re-nesting as we add over if (autoColumn) subGrid._autoColumn = true; @@ -564,6 +574,7 @@ export class GridStack { pGrid.addWidget(n.el, n); }); pGrid.batchUpdate(false); + if (this.parentGridItem) delete this.parentGridItem.subGrid; delete this.parentGridItem; // create an artificial event for the original grid now that this one is gone (got a leave, but won't get enter) @@ -668,7 +679,7 @@ export class GridStack { let item = items.find(w => n.id === w.id); if (!item) { if (this.opts.addRemoveCB) - this.opts.addRemoveCB(this, n, false); + this.opts.addRemoveCB(this.el, n, false, false); removed.push(n); // batch keep track this.removeWidget(n.el, true, false); } @@ -873,6 +884,7 @@ export class GridStack { } this._removeStylesheet(); this.el.removeAttribute('gs-current-row'); + if (this.parentGridItem) delete this.parentGridItem.subGrid; delete this.parentGridItem; delete this.opts; delete this._placeholder; diff --git a/src/types.ts b/src/types.ts index 719aa2502..832c49d9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,8 +73,8 @@ export type GridStackNodesHandler = (event: Event, nodes: GridStackNode[]) => vo export type GridStackDroppedHandler = (event: Event, previousNode: GridStackNode, newNode: GridStackNode) => void; export type GridStackEventHandlerCallback = GridStackEventHandler | GridStackElementHandler | GridStackNodesHandler | GridStackDroppedHandler; -/** optional function called during load() to callback the user on new added/remove items */ -export type AddRemoveFcn = (g: GridStack, w: GridStackWidget, add: boolean) => HTMLElement | undefined; +/** optional function called during load() to callback the user on new added/remove grid items | grids */ +export type AddRemoveFcn = (parent: HTMLElement, w: GridStackWidget, add: boolean, grid: boolean) => HTMLElement | undefined; /** * Defines the options for a Grid @@ -89,10 +89,12 @@ export interface GridStackOptions { acceptWidgets?: boolean | string | ((element: Element) => boolean); /** - * callback method use when new items needs to be created or deleted, instead of the default - *
w.content
- * Create: the returned DOM element will then be converted to a GridItemHTMLElement using makeWidget(). - * Delete: the item will be removed from DOM (if not already done) + * callback method use when new items|grids needs to be created or deleted, instead of the default + * item:
w.content
+ * grid:
grid content...
+ * add = true: the returned DOM element will then be converted to a GridItemHTMLElement using makeWidget()|GridStack:init(). + * add = false: the item will be removed from DOM (if not already done) + * grid = true|false for grid vs grid-items */ addRemoveCB?: AddRemoveFcn;