diff --git a/dev/dashboard.html b/dev/dashboard.html index e4ad7e8223..2747ef16cb 100644 --- a/dev/dashboard.html +++ b/dev/dashboard.html @@ -117,6 +117,10 @@ console.log('dashboard-item-resize-end'); console.log('item after resize', e.detail); }); + + dashboard.addEventListener('dashboard-item-removed', (e) => { + console.log('dashboard-item-removed', e.detail); + }); diff --git a/packages/dashboard/src/vaadin-dashboard-helpers.js b/packages/dashboard/src/vaadin-dashboard-helpers.js new file mode 100644 index 0000000000..4c94c118bf --- /dev/null +++ b/packages/dashboard/src/vaadin-dashboard-helpers.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright (c) 2016 - 2024 Vaadin Ltd. + * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ + */ + +export const WRAPPER_LOCAL_NAME = 'vaadin-dashboard-widget-wrapper'; + +/** + * Returns the array of items that contains the given item. + * Might be the dashboard items or the items of a section. + * + * @param {Object} item the item element + * @param {Object[]} items the root level items array + * @return {Object[]} the items array + */ +export function getItemsArrayOfItem(item, items) { + if (items.includes(item)) { + return items; + } + const parentItem = items.find((i) => i.items && getItemsArrayOfItem(item, i.items)); + return parentItem ? parentItem.items : null; +} + +/** + * Returns the item associated with the given element. + * + * @param {HTMLElement} element the element + */ +export function getElementItem(element) { + return element.closest(WRAPPER_LOCAL_NAME).__item; +} diff --git a/packages/dashboard/src/vaadin-dashboard-section.js b/packages/dashboard/src/vaadin-dashboard-section.js index ab11921faf..45c9272e7a 100644 --- a/packages/dashboard/src/vaadin-dashboard-section.js +++ b/packages/dashboard/src/vaadin-dashboard-section.js @@ -100,10 +100,9 @@ class DashboardSection extends ControllerMixin(ElementMixin(PolylitMixin(LitElem render() { return html`
+ -
- -
+
@@ -135,6 +134,11 @@ class DashboardSection extends ControllerMixin(ElementMixin(PolylitMixin(LitElem __onSectionTitleChanged(sectionTitle) { this.__titleController.setTitle(sectionTitle); } + + /** @private */ + __remove() { + this.dispatchEvent(new CustomEvent('item-remove', { bubbles: true, composed: true })); + } } defineCustomElement(DashboardSection); diff --git a/packages/dashboard/src/vaadin-dashboard-styles.js b/packages/dashboard/src/vaadin-dashboard-styles.js index d874cdc469..241ce9261d 100644 --- a/packages/dashboard/src/vaadin-dashboard-styles.js +++ b/packages/dashboard/src/vaadin-dashboard-styles.js @@ -25,13 +25,23 @@ export const dashboardWidgetAndSectionStyles = css` align-items: center; } - #header-actions { + #drag-handle { display: var(--_vaadin-dashboard-widget-actions-display, none); + font-size: 30px; + cursor: grab; } #drag-handle::before { - font-size: 30px; content: '☰'; - cursor: grab; + } + + #remove-button { + display: var(--_vaadin-dashboard-widget-actions-display, none); + font-size: 30px; + cursor: pointer; + } + + #remove-button::before { + content: '×'; } `; diff --git a/packages/dashboard/src/vaadin-dashboard-widget.js b/packages/dashboard/src/vaadin-dashboard-widget.js index 3e46de5cf1..6a15e52e5e 100644 --- a/packages/dashboard/src/vaadin-dashboard-widget.js +++ b/packages/dashboard/src/vaadin-dashboard-widget.js @@ -52,21 +52,21 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme #resize-handle { display: var(--_vaadin-dashboard-widget-actions-display, none); - } - - #resize-handle::before { position: absolute; bottom: 0; right: 0; font-size: 30px; - content: '\\2921'; cursor: grab; line-height: 1; } + #resize-handle::before { + content: '\\2921'; + } + :host::after { content: ''; - z-index: 100; + z-index: 2; position: absolute; inset-inline-start: 0; top: 0; @@ -96,18 +96,17 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme render() { return html`
+ -
- -
+
-
+ `; } @@ -153,6 +152,11 @@ class DashboardWidget extends ControllerMixin(ElementMixin(PolylitMixin(LitEleme __updateTitle() { this.__titleController.setTitle(this.widgetTitle); } + + /** @private */ + __remove() { + this.dispatchEvent(new CustomEvent('item-remove', { bubbles: true, composed: true })); + } } defineCustomElement(DashboardWidget); diff --git a/packages/dashboard/src/vaadin-dashboard.d.ts b/packages/dashboard/src/vaadin-dashboard.d.ts index 5b5feb2c49..8bec317279 100644 --- a/packages/dashboard/src/vaadin-dashboard.d.ts +++ b/packages/dashboard/src/vaadin-dashboard.d.ts @@ -88,6 +88,15 @@ export type DashboardItemDragResizeEvent = CustomEv rowspan: number; }>; +/** + * Fired when an item is removed + */ +export type DashboardItemRemoveEvent = CustomEvent<{ + item: TItem | DashboardSectionItem; + + items: Array>; +}>; + export interface DashboardCustomEventMap { 'dashboard-item-reorder-start': DashboardItemReorderStartEvent; @@ -100,6 +109,8 @@ export interface DashboardCustomEventMap { 'dashboard-item-resize-end': DashboardItemResizeEndEvent; 'dashboard-item-drag-resize': DashboardItemDragResizeEvent; + + 'dashboard-item-removed': DashboardItemRemoveEvent; } export type DashboardEventMap = DashboardCustomEventMap & HTMLElementEventMap; diff --git a/packages/dashboard/src/vaadin-dashboard.js b/packages/dashboard/src/vaadin-dashboard.js index 8055a13cf2..524b0641f7 100644 --- a/packages/dashboard/src/vaadin-dashboard.js +++ b/packages/dashboard/src/vaadin-dashboard.js @@ -16,6 +16,7 @@ import { defineCustomElement } from '@vaadin/component-base/src/define.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js'; import { css, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; +import { getElementItem, getItemsArrayOfItem } from './vaadin-dashboard-helpers.js'; import { DashboardLayoutMixin } from './vaadin-dashboard-layout-mixin.js'; import { hasWidgetWrappers } from './vaadin-dashboard-styles.js'; import { WidgetReorderController } from './widget-reorder-controller.js'; @@ -30,6 +31,7 @@ import { WidgetResizeController } from './widget-resize-controller.js'; * @fires {CustomEvent} dashboard-item-drag-resize - Fired when an item will be resized by dragging * @fires {CustomEvent} dashboard-item-resize-start - Fired when item resizing starts * @fires {CustomEvent} dashboard-item-resize-end - Fired when item resizing ends + * @fires {CustomEvent} dashboard-item-removed - Fired when an item is removed * * @customElement * @extends HTMLElement @@ -108,6 +110,7 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab super(); this.__widgetReorderController = new WidgetReorderController(this); this.__widgetResizeController = new WidgetResizeController(this); + this.addEventListener('item-remove', (e) => this.__itemRemove(e)); } /** @protected */ @@ -176,6 +179,18 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab }); } + /** @private */ + __itemRemove(e) { + e.stopImmediatePropagation(); + const item = getElementItem(e.target); + const items = getItemsArrayOfItem(item, this.items); + items.splice(items.indexOf(item), 1); + this.items = [...this.items]; + this.dispatchEvent( + new CustomEvent('dashboard-item-removed', { cancelable: true, detail: { item, items: this.items } }), + ); + } + /** * Fired when item reordering starts * @@ -211,6 +226,12 @@ class Dashboard extends ControllerMixin(DashboardLayoutMixin(ElementMixin(Themab * * @event dashboard-item-drag-resize */ + + /** + * Fired when an item is removed + * + * @event dashboard-item-removed + */ } defineCustomElement(Dashboard); diff --git a/packages/dashboard/src/widget-reorder-controller.js b/packages/dashboard/src/widget-reorder-controller.js index 0fe154244c..e7f1137d79 100644 --- a/packages/dashboard/src/widget-reorder-controller.js +++ b/packages/dashboard/src/widget-reorder-controller.js @@ -4,7 +4,8 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -const WRAPPER_LOCAL_NAME = 'vaadin-dashboard-widget-wrapper'; +import { getElementItem, getItemsArrayOfItem, WRAPPER_LOCAL_NAME } from './vaadin-dashboard-helpers.js'; + const REORDER_EVENT_TIMEOUT = 200; /** @@ -30,7 +31,7 @@ export class WidgetReorderController extends EventTarget { } this.__draggedElement = e.target; - this.draggedItem = this.__getElementItem(this.__draggedElement); + this.draggedItem = getElementItem(this.__draggedElement); // Set the drag image to the dragged element const { left, top } = this.__draggedElement.getBoundingClientRect(); @@ -61,7 +62,7 @@ export class WidgetReorderController extends EventTarget { // Get all elements that are candidates for reordering with the dragged element const dragContextElements = this.__getDragContextElements(this.__draggedElement); // Find the up-to-date element instance representing the dragged item - const draggedElement = dragContextElements.find((element) => this.__getElementItem(element) === this.draggedItem); + const draggedElement = dragContextElements.find((element) => getElementItem(element) === this.draggedItem); if (!draggedElement) { return; } @@ -82,8 +83,8 @@ export class WidgetReorderController extends EventTarget { this.__reordering = false; }, REORDER_EVENT_TIMEOUT); - const targetItem = this.__getElementItem(closestElement); - const targetItems = this.__getItemsArrayOfItem(targetItem); + const targetItem = getElementItem(closestElement); + const targetItems = getItemsArrayOfItem(targetItem, this.host.items); const targetIndex = targetItems.indexOf(targetItem); const reorderEvent = new CustomEvent('dashboard-item-drag-reorder', { @@ -172,11 +173,6 @@ export class WidgetReorderController extends EventTarget { } } - /** @private */ - __getElementItem(element) { - return element.closest(WRAPPER_LOCAL_NAME).__item; - } - /** * Returns the elements (widgets or sections) that are candidates for reordering with the * currently dragged item. Effectively, this is the list of child widgets or sections inside @@ -200,33 +196,13 @@ export class WidgetReorderController extends EventTarget { /** @private */ __reorderItems(draggedItem, targetIndex) { - const items = this.__getItemsArrayOfItem(draggedItem); + const items = getItemsArrayOfItem(draggedItem, this.host.items); const draggedIndex = items.indexOf(draggedItem); items.splice(draggedIndex, 1); items.splice(targetIndex, 0, draggedItem); this.host.items = [...this.host.items]; } - /** - * Returns the array of items that contains the given item. - * Might be the host items or the items of a section. - * @private - */ - __getItemsArrayOfItem(item, items = this.host.items) { - for (const i of items) { - if (i === item) { - return items; - } - if (i.items) { - const result = this.__getItemsArrayOfItem(item, i.items); - if (result) { - return result; - } - } - } - return null; - } - /** * The dragged element might be removed from the DOM during the drag operation if * the widgets get re-rendered. This method restores the dragged element if it's not diff --git a/packages/dashboard/src/widget-resize-controller.js b/packages/dashboard/src/widget-resize-controller.js index b2f59eaf38..a720a83e2a 100644 --- a/packages/dashboard/src/widget-resize-controller.js +++ b/packages/dashboard/src/widget-resize-controller.js @@ -4,8 +4,8 @@ * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ -const WRAPPER_LOCAL_NAME = 'vaadin-dashboard-widget-wrapper'; import { addListener } from '@vaadin/component-base/src/gestures.js'; +import { getElementItem, WRAPPER_LOCAL_NAME } from './vaadin-dashboard-helpers.js'; /** * A controller to widget resizing inside a dashboard. @@ -38,7 +38,7 @@ export class WidgetResizeController extends EventTarget { } this.host.$.grid.toggleAttribute('resizing', true); - this.resizedItem = this.__getElementItem(e.target); + this.resizedItem = getElementItem(e.target); this.__resizeStartWidth = e.target.offsetWidth; this.__resizeStartHeight = e.target.offsetHeight; @@ -133,11 +133,6 @@ export class WidgetResizeController extends EventTarget { this.resizedItem = null; } - /** @private */ - __getElementItem(element) { - return element.closest(WRAPPER_LOCAL_NAME).__item; - } - /** @private */ __getItemWrapper(item) { return [...this.host.querySelectorAll(WRAPPER_LOCAL_NAME)].find((el) => el.__item === item); diff --git a/packages/dashboard/test/dashboard-widget-reordering.test.ts b/packages/dashboard/test/dashboard-widget-reordering.test.ts index c9c5ade5ee..d9519cbcfc 100644 --- a/packages/dashboard/test/dashboard-widget-reordering.test.ts +++ b/packages/dashboard/test/dashboard-widget-reordering.test.ts @@ -23,7 +23,7 @@ type TestDashboardItem = DashboardItem & { id: number }; describe('dashboard - widget reordering', () => { let dashboard: Dashboard; - const columnWidth = 100; + const columnWidth = 200; beforeEach(async () => { dashboard = fixtureSync(''); diff --git a/packages/dashboard/test/dashboard-widget-resizing.test.ts b/packages/dashboard/test/dashboard-widget-resizing.test.ts index 18e630e9d3..28bb344a49 100644 --- a/packages/dashboard/test/dashboard-widget-resizing.test.ts +++ b/packages/dashboard/test/dashboard-widget-resizing.test.ts @@ -20,7 +20,7 @@ type TestDashboardItem = DashboardItem & { id: number }; describe('dashboard - widget resizing', () => { let dashboard: Dashboard; - const columnWidth = 100; + const columnWidth = 200; const rowHeight = 100; beforeEach(async () => { @@ -60,6 +60,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // Expect the widgets to be reordered // prettier-ignore expectLayout(dashboard, [ @@ -75,6 +78,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'start'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -96,6 +102,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'start'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -117,6 +126,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'end'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 0], @@ -139,6 +151,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -161,6 +176,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'top'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -183,6 +201,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'top'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -205,6 +226,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 0)!, 'bottom'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -228,6 +252,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 1, 0)!, 'bottom'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -247,6 +274,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], @@ -274,6 +304,9 @@ describe('dashboard - widget resizing', () => { fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + expect(resizeSpy).to.have.been.calledOnce; expect(resizeSpy.getCall(0).args[0].detail).to.deep.equal({ item: { id: 0 }, @@ -288,6 +321,9 @@ describe('dashboard - widget resizing', () => { await nextFrame(); fireResizeOver(getElementFromCell(dashboard, 0, 1)!, 'end'); await nextFrame(); + fireResizeEnd(dashboard); + await nextFrame(); + // prettier-ignore expectLayout(dashboard, [ [0, 1], diff --git a/packages/dashboard/test/dashboard.test.ts b/packages/dashboard/test/dashboard.test.ts index a24bdd79f0..65c1ea4752 100644 --- a/packages/dashboard/test/dashboard.test.ts +++ b/packages/dashboard/test/dashboard.test.ts @@ -3,8 +3,16 @@ import { fixtureSync, nextFrame } from '@vaadin/testing-helpers'; import sinon from 'sinon'; import '../vaadin-dashboard.js'; import type { CustomElementType } from '@vaadin/component-base/src/define.js'; +import type { DashboardWidget } from '../src/vaadin-dashboard-widget.js'; import type { Dashboard, DashboardItem } from '../vaadin-dashboard.js'; -import { getDraggable, getElementFromCell, setGap, setMaximumColumnWidth, setMinimumColumnWidth } from './helpers.js'; +import { + getDraggable, + getElementFromCell, + getRemoveButton, + setGap, + setMaximumColumnWidth, + setMinimumColumnWidth, +} from './helpers.js'; type TestDashboardItem = DashboardItem & { id: string; component?: Element | string }; @@ -79,6 +87,31 @@ describe('dashboard', () => { expect(dashboard.querySelectorAll('vaadin-dashboard-widget')).to.be.empty; }); + it('should remove a widget', () => { + const widget = getElementFromCell(dashboard, 0, 1); + getRemoveButton(widget as DashboardWidget).click(); + expect(dashboard.items).to.eql([{ id: 'Item 0' }]); + }); + + it('should dispatch an dashboard-item-removed event', () => { + const spy = sinon.spy(); + dashboard.addEventListener('dashboard-item-removed', spy); + const widget = getElementFromCell(dashboard, 0, 1); + getRemoveButton(widget as DashboardWidget).click(); + expect(spy).to.be.calledOnce; + expect(spy.firstCall.args[0].detail.item).to.eql({ id: 'Item 1' }); + expect(spy.firstCall.args[0].detail.items).to.eql([{ id: 'Item 0' }]); + }); + + it('should not dispatch an item-remove event', () => { + const spy = sinon.spy(); + // @ts-ignore unexpected event type + dashboard.addEventListener('item-remove', spy); + const widget = getElementFromCell(dashboard, 0, 1); + getRemoveButton(widget as DashboardWidget).click(); + expect(spy).to.not.be.called; + }); + describe('custom element definition', () => { let tagName: string; @@ -163,6 +196,13 @@ describe('dashboard', () => { expect(widget3?.localName).to.equal('vaadin-dashboard-widget'); expect(widget3).to.have.property('widgetTitle', 'Item 3 title'); }); + + it('should remove a section', () => { + const widget = getElementFromCell(dashboard, 1, 0); + const section = widget?.closest('vaadin-dashboard-section'); + getRemoveButton(section!).click(); + expect(dashboard.items).to.eql([{ id: 'Item 0' }, { id: 'Item 1' }]); + }); }); describe('editable', () => { diff --git a/packages/dashboard/test/helpers.ts b/packages/dashboard/test/helpers.ts index 69c44ff011..a3c59c48ba 100644 --- a/packages/dashboard/test/helpers.ts +++ b/packages/dashboard/test/helpers.ts @@ -1,5 +1,7 @@ import { expect } from '@vaadin/chai-plugins'; import sinon from 'sinon'; +import type { DashboardSection } from '../src/vaadin-dashboard-section.js'; +import type { DashboardWidget } from '../src/vaadin-dashboard-widget.js'; function getCssGrid(element: Element): Element { return (element as any).$?.grid || element; @@ -273,3 +275,7 @@ export function fireResizeStart(resizedWidget: Element): void { export function fireResizeEnd(dragOverTarget: Element): void { dragOverTarget.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, composed: true })); } + +export function getRemoveButton(section: DashboardWidget | DashboardSection): HTMLElement { + return section.shadowRoot!.querySelector('#remove-button') as HTMLElement; +} diff --git a/packages/dashboard/test/typings/dashboard.types.ts b/packages/dashboard/test/typings/dashboard.types.ts index df2601a3b2..f7aab45673 100644 --- a/packages/dashboard/test/typings/dashboard.types.ts +++ b/packages/dashboard/test/typings/dashboard.types.ts @@ -6,6 +6,7 @@ import type { DashboardItem, DashboardItemDragReorderEvent, DashboardItemDragResizeEvent, + DashboardItemRemoveEvent, DashboardItemReorderEndEvent, DashboardItemReorderStartEvent, DashboardItemResizeEndEvent, @@ -77,6 +78,11 @@ narrowedDashboard.addEventListener('dashboard-item-drag-resize', (event) => { assertType(event.detail.rowspan); }); +narrowedDashboard.addEventListener('dashboard-item-removed', (event) => { + assertType>(event); + assertType>(event.detail.item); +}); + /* DashboardLayout */ const layout = document.createElement('vaadin-dashboard-layout'); assertType(layout);