diff --git a/cspell.json b/cspell.json index d15ae332c6..a34ba087dd 100644 --- a/cspell.json +++ b/cspell.json @@ -80,6 +80,7 @@ "jsons", "Inplace", "MLTEXT", + "mousedrag", "mouseenter", "multiselect", "mysites", @@ -122,6 +123,7 @@ "uncheck", "Unclaim", "unfavorite", + "unlisten", "unshare", "UPDATEPERMISSIONS", "uploader", diff --git a/docs/core/components/data-column.component.md b/docs/core/components/data-column.component.md index fa75b7e5cb..e0f5e4d16d 100644 --- a/docs/core/components/data-column.component.md +++ b/docs/core/components/data-column.component.md @@ -60,6 +60,7 @@ Defines column properties for DataTable, Tasklist, Document List and other compo | srTitle | `string` | | Title to be used for screen readers. | | title | `string` | "" | Display title of the column, typically used for column headers. You can use the i18n resource key to get it translated automatically. | | type | `string` | "text" | Value type for the column. Possible settings are 'text', 'image', 'date', 'fileSize', 'location', and 'json'. | +| width | `number` | | size of the column in pixels | ## Details diff --git a/docs/core/components/datatable.component.md b/docs/core/components/datatable.component.md index 1bd4ad01c8..fcad5c1dbe 100644 --- a/docs/core/components/datatable.component.md +++ b/docs/core/components/datatable.component.md @@ -388,6 +388,7 @@ Learm more about styling your datatable: [Customizing the component's styles](#c | showMainDatatableActions | `boolean` | false | Toggles the main datatable action. | | sorting | `any[]` | \[] | Define the sort order of the datatable. Possible values are : [`created`, `desc`], [`created`, `asc`], [`due`, `desc`], [`due`, `asc`] | | stickyHeader | `boolean` | false | Toggles the sticky header mode. | +| isResizingEnabled | `boolean` | false | Toggles column resizing feature. | ### Events @@ -873,3 +874,19 @@ You can define the tooltip format for cells of type date using a configuration i - [Pagination component](pagination.component.md) - [Data Table Adapter interface](../interfaces/datatable-adapter.interface.md) - [Document list component](../../content-services/components/document-list.component.md) + +#### Column Resizing + +It is possible to have the ability to resize columns by clicking on the right border of each column and dragging it to the left. +You can do this by setting the `isResizingEnabled` property of your datatable to `true`: + +```html + + +``` + +Once set up, the column resizing behaves as shown in the image below: + +![](../../docassets/images/datatable-column-resizing.png) diff --git a/docs/docassets/images/datatable-column-resizing.png b/docs/docassets/images/datatable-column-resizing.png new file mode 100644 index 0000000000..e8569d4648 Binary files /dev/null and b/docs/docassets/images/datatable-column-resizing.png differ diff --git a/e2e/protractor.excludes.json b/e2e/protractor.excludes.json index 86043f9a08..a70f4d2751 100644 --- a/e2e/protractor.excludes.json +++ b/e2e/protractor.excludes.json @@ -7,5 +7,10 @@ "C260377": "https://alfresco.atlassian.net/browse/ACS-4467", "C260375": "https://alfresco.atlassian.net/browse/ACS-4467", "C286290": "https://alfresco.atlassian.net/browse/ACS-4467", - "C286472": "https://alfresco.atlassian.net/browse/ACS-4467" + "C286472": "https://alfresco.atlassian.net/browse/ACS-4467", + "C260387": "https://alfresco.atlassian.net/browse/ACS-4595", + "C216430": "https://alfresco.atlassian.net/browse/ACS-4595", + "C280063": "https://alfresco.atlassian.net/browse/ACS-4595", + "C280064": "https://alfresco.atlassian.net/browse/ACS-4595", + "C280407": "https://alfresco.atlassian.net/browse/ACS-4595" } diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.html b/lib/core/src/lib/datatable/components/datatable/datatable.component.html index f058733bf7..6fe0ea69e4 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.html +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.html @@ -30,13 +30,16 @@ *ngFor=" let col of (data.getColumns() | filterOutEvery:'isHidden':true); let columnIndex = index" - [class.adf-sortable]="col.sortable" [attr.data-automation-id]="'auto_id_' + col.key" - [class.adf-datatable__header--sorted-asc]="isColumnSorted(col, 'asc')" - [class.adf-datatable__header--sorted-desc]="isColumnSorted(col, 'desc')" + [ngClass]="{ + 'adf-sortable': col.sortable, + 'adf-datatable__cursor--pointer': !isResizing, + 'adf-datatable__header--sorted-asc': isColumnSorted(col, 'asc'), + 'adf-datatable__header--sorted-desc': isColumnSorted(col, 'desc')}" + [ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }" [attr.aria-label]="col.title | translate" - (click)="onColumnHeaderClick(col)" - (keyup.enter)="onColumnHeaderClick(col)" + (click)="onColumnHeaderClick(col, $event)" + (keyup.enter)="onColumnHeaderClick(col, $event)" role="columnheader" [attr.tabindex]="isHeaderVisible() ? 0 : null" [attr.aria-sort]="col.sortable ? (getAriaSort(col) | translate) : null" @@ -51,12 +54,20 @@ adf-drop-zone dropTarget="header" [dropColumn]="col">
@@ -82,14 +93,19 @@ [class.adf-datatable__header--sorted-desc]="isColumnSorted(col, 'desc')">
- +
+
@@ -131,8 +147,8 @@ + (click)="onColumnHeaderClick(col, $event)" + (keyup.enter)="onColumnHeaderClick(col, $event)"> {{ col.title | translate}} @@ -141,7 +157,7 @@
+ adf-drop-zone dropTarget="cell" [dropColumn]="col" [dropRow]="row" + [ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }">
diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.scss b/lib/core/src/lib/datatable/components/datatable/datatable.component.scss index 8af9b45d04..bc24387970 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.scss +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.scss @@ -21,6 +21,21 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default; .adf-full-width { width: 100%; } + + &__resize-handle { + align-self: stretch; + border: 0.5px solid var(--theme-border-color); + + &:hover { + cursor: col-resize; + } + } + + &__cursor--pointer { + &:hover { + cursor: pointer; + } + } } .adf-datatable-card { @@ -564,7 +579,6 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default; .adf-datatable-cell-header { @include adf-no-select; - cursor: pointer; position: relative; display: flex; align-items: center; @@ -580,10 +594,6 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default; &.adf-sortable { @include adf-no-select; - &:hover { - cursor: pointer; - } - display: flex; align-items: center; } diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.spec.ts b/lib/core/src/lib/datatable/components/datatable/datatable.component.spec.ts index edfd17d06a..c8cafaf763 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.spec.ts +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.spec.ts @@ -311,10 +311,12 @@ describe('DataTable', () => { done(); }); - dataTable.ngOnChanges({}); fixture.detectChanges(); dataTable.ngAfterViewInit(); - dataTable.onColumnHeaderClick(column); + const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); + + hedaderColumns[0].click(); + fixture.detectChanges(); }); it('should change the rows on changing of the data', () => { @@ -867,54 +869,53 @@ describe('DataTable', () => { expect(e.preventDefault).toHaveBeenCalled(); }); - it('should not sort if column is missing', () => { - dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) }); - fixture.detectChanges(); - dataTable.ngAfterViewInit(); - const adapter = dataTable.data; - spyOn(adapter, 'setSorting').and.callThrough(); - dataTable.onColumnHeaderClick(null); - expect(adapter.setSorting).not.toHaveBeenCalled(); - }); - it('should not sort upon clicking non-sortable column header', () => { - dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) }); + dataTable.data = new ObjectDataTableAdapter( + [{ name: '1' }, { name: '2' }], + [ + new ObjectDataColumn({ key: 'name', sortable: false }), + new ObjectDataColumn({ key: 'other', sortable: true }) + ] + ); fixture.detectChanges(); dataTable.ngAfterViewInit(); const adapter = dataTable.data; spyOn(adapter, 'setSorting').and.callThrough(); - const column = new ObjectDataColumn({ - key: 'column_1' - }); + const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); + hedaderColumns[0].click(); + fixture.detectChanges(); - dataTable.onColumnHeaderClick(column); expect(adapter.setSorting).not.toHaveBeenCalled(); }); it('should set sorting upon column header clicked', () => { - dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) }); + dataTable.data = new ObjectDataTableAdapter( + [{ name: '1' }], + [ + new ObjectDataColumn({ key: 'column_1', sortable: true }) + ] + ); fixture.detectChanges(); dataTable.ngAfterViewInit(); const adapter = dataTable.data; spyOn(adapter, 'setSorting').and.callThrough(); + spyOn(dataTable.data, 'getSorting').and.returnValue(new DataSorting('column_1', 'desc')); - const column = new ObjectDataColumn({ - key: 'column_1', - sortable: true - }); + const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); + hedaderColumns[0].click(); + fixture.detectChanges(); - dataTable.onColumnHeaderClick(column); - expect(adapter.setSorting).toHaveBeenCalledWith( - jasmine.objectContaining({ - key: 'column_1', - direction: 'asc' - }) - ); + expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'asc')); }); it('should invert sorting upon column header clicked', () => { - dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) }); + dataTable.data = new ObjectDataTableAdapter( + [{ name: '1' }], + [ + new ObjectDataColumn({ key: 'column_1', sortable: true }) + ] + ); fixture.detectChanges(); dataTable.ngAfterViewInit(); @@ -922,30 +923,20 @@ describe('DataTable', () => { const sorting = new DataSorting('column_1', 'asc'); spyOn(adapter, 'setSorting').and.callThrough(); spyOn(adapter, 'getSorting').and.returnValue(sorting); + const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); - const column = new ObjectDataColumn({ - key: 'column_1', - sortable: true - }); + // // check first click on the header + hedaderColumns[0].click(); + fixture.detectChanges(); - // check first click on the header - dataTable.onColumnHeaderClick(column); - expect(adapter.setSorting).toHaveBeenCalledWith( - jasmine.objectContaining({ - key: 'column_1', - direction: 'desc' - }) - ); + expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'desc')); // check second click on the header sorting.direction = 'desc'; - dataTable.onColumnHeaderClick(column); - expect(adapter.setSorting).toHaveBeenCalledWith( - jasmine.objectContaining({ - key: 'column_1', - direction: 'asc' - }) - ); + hedaderColumns[0].click(); + fixture.detectChanges(); + + expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'asc')); }); it('should indicate column that has sorting applied', () => { @@ -960,8 +951,10 @@ describe('DataTable', () => { dataTable.ngAfterViewInit(); const [col1, col2] = dataTable.getSortableColumns(); + const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); - dataTable.onColumnHeaderClick(col2); + hedaderColumns[1].click(); + fixture.detectChanges(); expect(dataTable.isColumnSortActive(col1)).toBe(false); expect(dataTable.isColumnSortActive(col2)).toBe(true); @@ -1805,3 +1798,188 @@ describe('Show/hide columns', () => { }); }); }); + +describe('Column Resizing', () => { + let fixture: ComponentFixture; + let dataTable: DataTableComponent; + let data: { id: number; name: string }[] = []; + let dataTableSchema: DataColumn[] = []; + + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + CoreTestingModule + ], + declarations: [CustomColumnTemplateComponent], + schemas: [NO_ERRORS_SCHEMA] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DataTableComponent); + dataTable = fixture.componentInstance; + data = [ + { id: 1, name: 'name1' }, + { id: 2, name: 'name2' } + ]; + + dataTableSchema = [ + new ObjectDataColumn({ key: 'id', title: 'ID', draggable: true }), + new ObjectDataColumn({ key: 'name', title: 'Name', draggable: true }) + ]; + + dataTable.data = new ObjectDataTableAdapter( + [...data], + [...dataTableSchema] + ); + + dataTable.isResizingEnabled = false; + fixture.detectChanges(); + }); + + it('should NOT display resize handle when the feature is Disabled [isResizingEnabled=false]', () => { + const resizeHandle = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + + expect(resizeHandle).toBeNull(); + const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header'); + + headerColumns.forEach((header: HTMLElement) => { + expect(header.classList).toContain('adf-datatable__cursor--pointer'); + }); + }); + + it('should display resize handle when the feature is Enabled [isResizingEnabled=true]', () => { + dataTable.isResizingEnabled = true; + + fixture.detectChanges(); + const resizeHandle = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + + expect(resizeHandle).not.toBeNull(); + const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header'); + + headerColumns.forEach((header: HTMLElement) => { + expect(header.classList).toContain('adf-datatable__cursor--pointer'); + }); + }); + + it('should NOT have the cursor pointer class in the header upon resizing starts', () => { + dataTable.isResizingEnabled = true; + fixture.detectChanges(); + + const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + resizeHandle.dispatchEvent(new MouseEvent('mousedown')); + fixture.detectChanges(); + + const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header'); + + expect(dataTable.isResizing).toBeTrue(); + headerColumns.forEach((header: HTMLElement) => { + expect(header.classList).not.toContain('adf-datatable__cursor--pointer'); + }); + }); + + it('should NOT have the [adf-datatable-cell-header-content--hovered] class in the header upon resizing starts', () => { + dataTable.isResizingEnabled = true; + fixture.detectChanges(); + + const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + resizeHandle.dispatchEvent(new MouseEvent('mousedown')); + fixture.detectChanges(); + + const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content'); + + expect(dataTable.isResizing).toBeTrue(); + headerColumns.forEach((header: HTMLElement) => { + expect(header.classList).not.toContain('adf-datatable-cell-header-content--hovered'); + }); + }); + + it('should NOT display drag icon upon resizing starts', () => { + dataTable.isResizingEnabled = true; + fixture.detectChanges(); + + const hedaderColumn = fixture.debugElement.nativeElement.querySelector('[data-automation-id="auto_id_id"]'); + hedaderColumn.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + let dragIcon = fixture.debugElement.nativeElement.querySelector('[data-automation-id="adf-datatable-cell-header-drag-icon-id"]'); + + expect(dragIcon).not.toBeNull(); + + const resizeHandle: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable__resize-handle'); + resizeHandle[0].dispatchEvent(new MouseEvent('mousedown')); + fixture.detectChanges(); + + dragIcon = fixture.debugElement.nativeElement.querySelector('[data-automation-id="adf-datatable-cell-header-drag-icon-id"]'); + + expect(dataTable.isResizing).toBeTrue(); + expect(dragIcon).toBeNull(); + }); + + it('should blur the table body upon resizing starts', () => { + dataTable.isResizingEnabled = true; + fixture.detectChanges(); + + const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + resizeHandle.dispatchEvent(new MouseEvent('mousedown')); + fixture.detectChanges(); + + const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body'); + + expect(dataTable.isResizing).toBeTrue(); + expect(tableBody.classList).toContain('adf-blur-datatable-body'); + }); + + it('should set column width on resizing', () => { + const adapter = dataTable.data; + spyOn(adapter, 'setColumns').and.callThrough(); + + dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0); + fixture.detectChanges(); + const columns = dataTable.data.getColumns(); + + expect(columns[0].width).toBe(65); + expect(adapter.setColumns).toHaveBeenCalledWith(columns); + }); + + it('should set the column header style on resizing', () => { + dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0); + fixture.detectChanges(); + const headerColumns: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header'); + + expect(headerColumns[0].style.flex).toBe('0 1 65px'); + }); + + it('should set the style of all the table cells under the resizing header on resizing', () => { + dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0); + fixture.detectChanges(); + + const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body'); + const firstCell: HTMLElement = tableBody.querySelector('[data-automation-id="name1"]'); + const secondCell: HTMLElement = tableBody.querySelector('[data-automation-id="name2"]'); + + expect(firstCell.style.flex).toBe('0 1 65px'); + expect(secondCell.style.flex).toBe('0 1 65px'); + }); + + it('should unblur the body and set the resizing to false upon resizing ends', () => { + dataTable.isResizingEnabled = true; + fixture.detectChanges(); + + const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle'); + resizeHandle.dispatchEvent(new MouseEvent('mousedown')); + fixture.detectChanges(); + + const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body'); + + expect(dataTable.isResizing).toBeTrue(); + expect(tableBody.classList).toContain('adf-blur-datatable-body'); + + resizeHandle.dispatchEvent(new MouseEvent('mousemove')); + fixture.detectChanges(); + + resizeHandle.dispatchEvent(new MouseEvent('mouseup')); + fixture.detectChanges(); + + expect(dataTable.isResizing).toBeFalse(); + expect(tableBody.classList).not.toContain('adf-blur-datatable-body'); + }); +}); diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.ts b/lib/core/src/lib/datatable/components/datatable/datatable.component.ts index f48b1eb073..9656c4cc25 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.ts +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.ts @@ -43,6 +43,7 @@ import { share, buffer, map, filter, debounceTime } from 'rxjs/operators'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { MatIconRegistry } from '@angular/material/icon'; import { DomSanitizer } from '@angular/platform-browser'; +import { ResizeEvent } from '../../directives/resizable/types'; // eslint-disable-next-line no-shadow export enum DisplayMode { @@ -204,6 +205,12 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, @Input() allowFiltering: boolean = false; + /** + * Flag that indicates if the datatable allows column resizing. + */ + @Input() + isResizingEnabled: boolean = false; + headerFilterTemplate: TemplateRef; noContentTemplate: TemplateRef; noPermissionTemplate: TemplateRef; @@ -216,6 +223,7 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, isDraggingHeaderColumn = false; hoveredHeaderColumnIndex = -1; + isResizing = false; /** This array of fake rows fix the flex layout for the gallery view */ fakeRows = []; @@ -591,8 +599,18 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, ); } - onColumnHeaderClick(column: DataColumn) { - if (column && column.sortable) { + private isValidClickEvent(event: Event): boolean { + if (event instanceof MouseEvent) { + return event.eventPhase === event.BUBBLING_PHASE; + } else if (event instanceof KeyboardEvent) { + return event.eventPhase === event.AT_TARGET; + } + + return false; + } + + onColumnHeaderClick(column: DataColumn, event: Event) { + if (this.isValidClickEvent(event) && column && column.sortable) { const current = this.data.getSorting(); let newDirection = 'asc'; if (current && column.key === current.key) { @@ -911,6 +929,12 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, iconUrl ); } + + onResizing({ rectangle: { width } }: ResizeEvent, colIndex: number): void { + const allColumns = this.data.getColumns(); + allColumns[colIndex].width = width; + this.data.setColumns(allColumns); + } } export interface DataTableDropEvent { diff --git a/lib/core/src/lib/datatable/data/data-column.model.ts b/lib/core/src/lib/datatable/data/data-column.model.ts index 2031f5b229..a087f691a1 100644 --- a/lib/core/src/lib/datatable/data/data-column.model.ts +++ b/lib/core/src/lib/datatable/data/data-column.model.ts @@ -47,5 +47,6 @@ export interface DataColumn { header?: TemplateRef; draggable?: boolean; isHidden?: boolean; + width?: number; customData?: T; } diff --git a/lib/core/src/lib/datatable/datatable.module.ts b/lib/core/src/lib/datatable/datatable.module.ts index c6b395ecbb..34ef02b1fe 100644 --- a/lib/core/src/lib/datatable/datatable.module.ts +++ b/lib/core/src/lib/datatable/datatable.module.ts @@ -51,6 +51,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop'; import { IconModule } from '../icon/icon.module'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { DataColumnComponent, DataColumnListComponent, DateColumnHeaderComponent } from './data-column'; +import { ResizableModule } from './directives/resizable/resizable.module'; @NgModule({ imports: [ @@ -65,7 +66,8 @@ import { DataColumnComponent, DataColumnListComponent, DateColumnHeaderComponent DragDropModule, IconModule, FormsModule, - ReactiveFormsModule + ReactiveFormsModule, + ResizableModule ], declarations: [ DataTableComponent, diff --git a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts new file mode 100644 index 0000000000..5eae86cb9b --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts @@ -0,0 +1,154 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { ElementRef, NgZone, Renderer2 } from '@angular/core'; +import { ResizableDirective } from './resizable.directive'; + +describe('ResizableDirective', () => { + let ngZone: NgZone; + let renderer: Renderer2; + let element: ElementRef; + let directive: ResizableDirective; + + const scrollTop = 0; + const scrollLeft = 0; + + const boundingClientRectMock = { + top: 0, + left: 0, + right: 0, + width: 150, + height: 0, + bottom: 0, + scrollTop, + scrollLeft + }; + + const rendererMock = { + listen: jasmine.createSpy('listen'), + setStyle: jasmine.createSpy('setStyle') + }; + + const elementRefMock = { + nativeElement: { + scrollTop, + scrollLeft, + getBoundingClientRect: () => boundingClientRectMock + } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ResizableDirective], + providers: [ + { provide: Renderer2, useValue: rendererMock }, + { provide: ElementRef, useValue: elementRefMock } + ] + }); + + element = TestBed.inject(ElementRef); + renderer = TestBed.inject(Renderer2); + ngZone = TestBed.inject(NgZone); + spyOn(ngZone, 'runOutsideAngular').and.callFake((fn) => fn()); + spyOn(ngZone, 'run').and.callFake((fn) => fn()); + directive = new ResizableDirective(renderer, element, ngZone); + + directive.ngOnInit(); + }); + + it('should attach mousedown event to document', () => { + expect(renderer.listen).toHaveBeenCalledWith('document', 'mousedown', jasmine.any(Function)); + }); + + it('should attach mousemove event to document', () => { + const mouseDownEvent = new MouseEvent('mousedown'); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + + expect(renderer.listen).toHaveBeenCalledWith('document', 'mousemove', jasmine.any(Function)); + }); + + it('should attach mouseup event to document', () => { + expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function)); + }); + + it('should should set the cursor on mouse down', () => { + spyOn(directive.resizeStart, 'emit'); + const mouseDownEvent = new MouseEvent('mousedown'); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + + expect(renderer.setStyle).toHaveBeenCalledWith(document.body, 'cursor', 'col-resize'); + }); + + it('should emit resizeStart event on mouse down', () => { + spyOn(directive.resizeStart, 'emit'); + directive.resizeStart.subscribe(); + const mouseDownEvent = new MouseEvent('mousedown'); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + + expect(directive.resizeStart.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 0, width: 0 } }); + }); + + it('should unset cursor on mouseup', () => { + const mouseDownEvent = new MouseEvent('mousedown'); + const mouseUpEvent = new MouseEvent('mouseup'); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + directive.mouseup.next(mouseUpEvent); + + expect(renderer.setStyle).toHaveBeenCalledWith(document.body, 'cursor', ''); + }); + + it('should emit resizeEnd on mouseup', () => { + spyOn(directive.resizeEnd, 'emit'); + directive.resizeEnd.subscribe(); + const mouseDownEvent = new MouseEvent('mousedown'); + const mouseUpEvent = new MouseEvent('mouseup'); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + directive.mouseup.next(mouseUpEvent); + + expect(directive.resizeEnd.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, right: 0, width: 150, height: 0, bottom: 0, scrollTop: 0, scrollLeft: 0 } }); + }); + + it('should emit resizing on mousemove', () => { + spyOn(directive.resizing, 'emit'); + directive.resizing.subscribe(); + const mouseDownEvent = new MouseEvent('mousedown'); + const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 120 }); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + directive.mousemove.next(mouseMoveEvent); + + expect(directive.resizing.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 120, width: 120 } }); + }); + + it('should NOT emit resizing on mousemove when movement goes under the minimum allowed size [100]', () => { + spyOn(directive.resizing, 'emit'); + directive.resizing.subscribe(); + const mouseDownEvent = new MouseEvent('mousedown'); + const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 99 }); + + directive.mousedown.next({ ...mouseDownEvent, resize: true }); + directive.mousemove.next(mouseMoveEvent); + + expect(directive.resizing.emit).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts new file mode 100644 index 0000000000..df98f6bb1c --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts @@ -0,0 +1,257 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subject, Observable, Observer, merge } from 'rxjs'; +import { BoundingRectangle, ResizeEvent, IResizeMouseEvent, ICoordinateX } from './types'; +import { map, tap, take, share, filter, pairwise, mergeMap, takeUntil } from 'rxjs/operators'; +import { OnInit, Output, NgZone, OnDestroy, Directive, Renderer2, ElementRef, EventEmitter } from '@angular/core'; + +@Directive({ + selector: '[adf-resizable]', + exportAs: 'adf-resizable' +}) +export class ResizableDirective implements OnInit, OnDestroy { + /** + * Emitted when the mouse is pressed and a resize event is about to begin. + */ + @Output() resizeStart = new EventEmitter(); + + /** + * Emitted when the mouse is dragged after a resize event has started. + */ + @Output() resizing = new EventEmitter(); + + /** + * Emitted when the mouse is released after a resize event. + */ + @Output() resizeEnd = new EventEmitter(); + + mouseup = new Subject(); + + mousedown = new Subject(); + + mousemove = new Subject(); + + private pointerDown: Observable; + + private pointerMove: Observable; + + private pointerUp: Observable; + + private startingRect: BoundingRectangle; + + private currentRect: BoundingRectangle; + + private unlistenMouseDown: () => void; + + private unlistenMouseMove: () => void; + + private unlistenMouseUp: () => void; + + private destroy$ = new Subject(); + + private static MINIMUM_COLUMN_SIZE = 100; + + constructor( + private readonly renderer: Renderer2, + private readonly element: ElementRef, + private readonly zone: NgZone + ) { + + this.pointerDown = new Observable( + (observer: Observer) => { + zone.runOutsideAngular(() => { + this.unlistenMouseDown = renderer.listen( + 'document', + 'mousedown', + (event: MouseEvent) => { + observer.next(event); + } + ); + }); + } + ).pipe(share()); + + this.pointerMove = new Observable( + (observer: Observer) => { + zone.runOutsideAngular(() => { + this.unlistenMouseMove = renderer.listen( + 'document', + 'mousemove', + (event: MouseEvent) => { + observer.next(event); + } + ); + }); + } + ).pipe(share()); + + this.pointerUp = new Observable( + (observer: Observer) => { + zone.runOutsideAngular(() => { + this.unlistenMouseUp = renderer.listen( + 'document', + 'mouseup', + (event: MouseEvent) => { + observer.next(event); + } + ); + }); + } + ).pipe(share()); + } + + ngOnInit(): void { + const mousedown$: Observable = merge(this.pointerDown, this.mousedown); + + const mousemove$: Observable = merge(this.pointerMove, this.mousemove) + .pipe( + tap((event) => this.preventDefaultEvent(event)), + share() + ); + + const mouseup$: Observable = merge(this.pointerUp, this.mouseup); + + const mousedrag: Observable = mousedown$ + .pipe( + mergeMap(({ clientX = 0 }) => merge( + mousemove$.pipe(take(1)).pipe(map((coords) => [, coords])), + mousemove$.pipe(pairwise()) + ) + .pipe( + map(([previousCoords = {}, newCoords = {}]) => + [ + { clientX: previousCoords.clientX - clientX }, + { clientX: newCoords.clientX - clientX } + ] + ) + ) + .pipe( + filter(([previousCoords = {}, newCoords = {}]) => + Math.ceil(previousCoords.clientX) !== Math.ceil(newCoords.clientX)) + ) + .pipe( + map(([, newCoords]) => + ({ + clientX: Math.round(newCoords.clientX) + })) + ) + .pipe(takeUntil(merge(mouseup$, mousedown$))) + ) + ) + .pipe(filter(() => !!this.currentRect)); + + mousedrag + .pipe( + map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX)) + ) + .pipe( + filter(this.minimumAllowedSize) + ) + .subscribe((rectangle: BoundingRectangle) => { + if (this.resizing.observers.length > 0) { + this.zone.run(() => { + this.resizing.emit({ rectangle }); + }); + } + this.currentRect = rectangle; + }); + + mousedown$ + .pipe( + map(({ resize = false }) => resize), + filter((resize) => resize), + takeUntil(this.destroy$) + ) + .subscribe(() => { + const startingRect: BoundingRectangle = this.getElementRect(this.element); + + this.startingRect = startingRect; + this.currentRect = startingRect; + + this.renderer.setStyle(document.body, 'cursor', 'col-resize'); + if (this.resizeStart.observers.length > 0) { + this.zone.run(() => { + this.resizeStart.emit({ + rectangle: this.getNewBoundingRectangle(this.startingRect, 0) + }); + }); + } + }); + + mouseup$.pipe(takeUntil(this.destroy$)).subscribe(() => { + if (this.currentRect) { + this.renderer.setStyle(document.body, 'cursor', ''); + if (this.resizeEnd.observers.length > 0) { + this.zone.run(() => { + this.resizeEnd.emit({ rectangle: this.currentRect }); + }); + } + this.startingRect = null; + this.currentRect = null; + } + }); + } + + ngOnDestroy(): void { + this.mousedown.complete(); + this.mousemove.complete(); + this.mouseup.complete(); + this.unlistenMouseDown && this.unlistenMouseDown(); + this.unlistenMouseMove && this.unlistenMouseMove(); + this.unlistenMouseUp && this.unlistenMouseUp(); + this.destroy$.next(); + } + + private preventDefaultEvent(event: MouseEvent): void { + if ((this.currentRect || this.startingRect) && event.cancelable) { + event.preventDefault(); + } + } + + private getNewBoundingRectangle({ top, bottom, left, right }: BoundingRectangle, clientX: number): BoundingRectangle { + const updatedRight = right += clientX; + + return { + top, + left, + bottom, + right: updatedRight, + width: updatedRight - left + }; + } + + private getElementRect({ nativeElement }: ElementRef): BoundingRectangle { + + const { height = 0, width = 0, top = 0, bottom = 0, right = 0, left = 0 }: BoundingRectangle = nativeElement.getBoundingClientRect(); + + return { + top, + left, + right, + width, + height, + bottom, + scrollTop: nativeElement.scrollTop, + scrollLeft: nativeElement.scrollLeft + }; + } + + private minimumAllowedSize({ width = 0 }: BoundingRectangle): boolean { + return width > ResizableDirective.MINIMUM_COLUMN_SIZE; + } +} diff --git a/lib/core/src/lib/datatable/directives/resizable/resizable.module.ts b/lib/core/src/lib/datatable/directives/resizable/resizable.module.ts new file mode 100644 index 0000000000..bf7054eaf6 --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/resizable.module.ts @@ -0,0 +1,26 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { ResizableDirective } from './resizable.directive'; +import { ResizeHandleDirective } from './resize-handle.directive'; + +@NgModule({ + declarations: [ResizableDirective, ResizeHandleDirective], + exports: [ResizableDirective, ResizeHandleDirective] +}) +export class ResizableModule {} diff --git a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts new file mode 100644 index 0000000000..8a3eff643d --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts @@ -0,0 +1,56 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { ElementRef, NgZone, Renderer2 } from '@angular/core'; +import { ResizeHandleDirective } from './resize-handle.directive'; + +describe('ResizeHandleDirective', () => { + let ngZone: NgZone; + let renderer: Renderer2; + let element: ElementRef; + let directive: ResizeHandleDirective; + + const rendererMock = { + listen: jasmine.createSpy('listen') + }; + + const elementRefMock = { + nativeElement: { dispatchEvent: () => { } } + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ResizeHandleDirective], + providers: [ + { provide: Renderer2, useValue: rendererMock }, + { provide: ElementRef, useValue: elementRefMock } + ] + }); + + element = TestBed.inject(ElementRef); + renderer = TestBed.inject(Renderer2); + ngZone = TestBed.inject(NgZone); + spyOn(ngZone, 'runOutsideAngular').and.callFake((fn) => fn()); + directive = new ResizeHandleDirective(renderer, element, ngZone); + directive.ngOnInit(); + }); + + it('should attach mousedown event on resizable element', () => { + expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousedown', jasmine.any(Function)); + }); +}); diff --git a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts new file mode 100644 index 0000000000..97396ed913 --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts @@ -0,0 +1,96 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Subject } from 'rxjs'; +import { ResizableDirective } from './resizable.directive'; +import { Input, OnInit, Directive, Renderer2, ElementRef, OnDestroy, NgZone } from '@angular/core'; + +@Directive({ + selector: '[adf-resize-handle]' +}) +export class ResizeHandleDirective implements OnInit, OnDestroy { + /** + * Reference to ResizableDirective + */ + @Input() resizableContainer: ResizableDirective; + + private unlistenMouseDown: () => void; + + private unlistenMouseMove: () => void; + + private unlistenMouseUp: () => void; + + private destroy$ = new Subject(); + + constructor( + private readonly renderer: Renderer2, + private readonly element: ElementRef, + private readonly zone: NgZone + ) { } + + ngOnInit(): void { + this.zone.runOutsideAngular(() => { + this.unlistenMouseDown = this.renderer.listen( + this.element.nativeElement, + 'mousedown', + (mouseDownEvent: MouseEvent) => { + this.onMousedown(mouseDownEvent); + } + ); + }); + } + + ngOnDestroy(): void { + this.unlistenMouseDown && this.unlistenMouseDown(); + this.unlistenMouseMove && this.unlistenMouseMove(); + this.unlistenMouseUp && this.unlistenMouseUp(); + this.destroy$.next(); + } + + private onMousedown(event: MouseEvent): void { + if (event.cancelable) { + event.preventDefault(); + } + this.unlistenMouseMove = this.renderer.listen( + this.element.nativeElement, + 'mousemove', + (mouseMoveEvent: MouseEvent) => { + this.onMousemove(mouseMoveEvent); + } + ); + + this.unlistenMouseUp = this.renderer.listen( + this.element.nativeElement, + 'mouseup', + (mouseUpEvent: MouseEvent) => { + this.onMouseup(mouseUpEvent); + } + ); + + this.resizableContainer.mousedown.next({ ...event, resize: true }); + } + + private onMouseup(event: MouseEvent): void { + this.unlistenMouseMove && this.unlistenMouseMove(); + this.unlistenMouseUp(); + this.resizableContainer.mouseup.next(event); + } + + private onMousemove(event: MouseEvent): void { + this.resizableContainer.mousemove.next(event); + } +} diff --git a/lib/core/src/lib/datatable/directives/resizable/types.ts b/lib/core/src/lib/datatable/directives/resizable/types.ts new file mode 100644 index 0000000000..7d8ad9b85f --- /dev/null +++ b/lib/core/src/lib/datatable/directives/resizable/types.ts @@ -0,0 +1,40 @@ +/*! + * @license + * Copyright 2019 Alfresco Software, Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface BoundingRectangle { + top: number; + left: number; + right: number; + bottom: number; + width?: number; + height?: number; + scrollTop?: number; + scrollLeft?: number; + [key: string]: number | undefined; +} + +export interface ResizeEvent { + rectangle: BoundingRectangle; +} + +export interface IResizeMouseEvent extends MouseEvent{ + resize?: boolean; +} + +export interface ICoordinateX { + clientX: number; +}