AAE-12273: Implemented column resizing directive (#8272)

* AAE-12273: Implemented column resizing directive

* AAE-12273: Updated datatable component docs with column resizing feature

* AAE-12273: Fixed lint errors

* AAE-12273: Fixed spell check and lint errors

* AAE-12273: Fixing lint issues

* AAE-12273: Excluded failing e2e

* AAE-12273: Excluded more failing E2Es

* AAE-12273: Excluded more failing e2e

* AAE-12273: Code Improvement

* AAE-12273: Fixed datatable column flex item shrink

* AAE-12273: Fixed unit tests of column header resizing flex item shrink
This commit is contained in:
Ehsan Rezaei
2023-02-20 10:45:03 +01:00
committed by GitHub
parent 058cd9e01c
commit 4aa2e0eb87
17 changed files with 959 additions and 73 deletions

View File

@@ -80,6 +80,7 @@
"jsons",
"Inplace",
"MLTEXT",
"mousedrag",
"mouseenter",
"multiselect",
"mysites",
@@ -122,6 +123,7 @@
"uncheck",
"Unclaim",
"unfavorite",
"unlisten",
"unshare",
"UPDATEPERMISSIONS",
"uploader",

View File

@@ -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

View File

@@ -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
<adf-datatable
[data]="data"
[isResizingEnabled]="true">
</adf-datatable>
```
Once set up, the column resizing behaves as shown in the image below:
![](../../docassets/images/datatable-column-resizing.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@@ -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"
}

View File

@@ -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">
<div
adf-resizable
#resizableElement="adf-resizable"
(resizing)="onResizing($event, columnIndex)"
(resizeStart)="isResizing = true"
(resizeEnd)="isResizing = false"
[attr.data-automation-id]="'auto_header_content_id_' + col.key"
class="adf-datatable-cell-header-content"
[class.adf-datatable-cell-header-content--hovered]="hoveredHeaderColumnIndex === columnIndex && !isDraggingHeaderColumn"
[ngClass]="{ 'adf-datatable-cell-header-content--hovered':
hoveredHeaderColumnIndex === columnIndex &&
!isDraggingHeaderColumn &&
!isResizing }"
>
<span
*ngIf="hoveredHeaderColumnIndex === columnIndex && col.draggable"
*ngIf="hoveredHeaderColumnIndex === columnIndex && col.draggable && !isResizing"
class="adf-datatable-cell-header-drag-icon-placeholder"
[attr.data-automation-id]="'adf-datatable-cell-header-drag-icon-placeholder-'+col.key"
></span>
@@ -82,14 +93,19 @@
[class.adf-datatable__header--sorted-desc]="isColumnSorted(col, 'desc')">
</span>
</div>
<div
*ngIf="isResizingEnabled"
adf-resize-handle
class="adf-datatable__resize-handle"
[resizableContainer]="resizableElement">
</div>
<div
*ngIf="col.draggable"
cdkDragHandle
class="adf-datatable-cell-header-drag-icon"
[ngClass]="{ 'adf-datatable-cell-header-drag-icon': !isResizing }"
>
<adf-icon
*ngIf="hoveredHeaderColumnIndex === columnIndex"
*ngIf="hoveredHeaderColumnIndex === columnIndex && !isResizing"
value="adf:drag_indicator"
[attr.data-automation-id]="'adf-datatable-cell-header-drag-icon-'+col.key">
</adf-icon>
@@ -131,8 +147,8 @@
<mat-option *ngFor="let col of getSortableColumns()"
[value]="col.key"
[attr.data-automation-id]="'grid-view-sorting-'+col.title"
(click)="onColumnHeaderClick(col)"
(keyup.enter)="onColumnHeaderClick(col)">
(click)="onColumnHeaderClick(col, $event)"
(keyup.enter)="onColumnHeaderClick(col, $event)">
{{ col.title | translate}}
</mat-option>
</mat-select>
@@ -141,7 +157,7 @@
<div
class="adf-datatable-body"
[class.adf-blur-datatable-body]="isDraggingHeaderColumn"
[ngClass]="{ 'adf-blur-datatable-body': isDraggingHeaderColumn || isResizing }"
role="rowgroup">
<ng-container *ngIf="!loading && !noPermission">
<adf-datatable-row *ngFor="let row of data.getRows(); let idx = index"
@@ -196,7 +212,8 @@
(keydown.enter)="onEnterKeyPressed(row, $any($event))"
[adf-context-menu]="getContextMenuActions(row, col)"
[adf-context-menu-enabled]="contextMenu"
adf-drop-zone dropTarget="cell" [dropColumn]="col" [dropRow]="row">
adf-drop-zone dropTarget="cell" [dropColumn]="col" [dropRow]="row"
[ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }">
<div *ngIf="!col.template" class="adf-datatable-cell-container">
<ng-container [ngSwitch]="data.getColumnType(row, col)">
<div *ngSwitchCase="'image'" class="adf-cell-value">

View File

@@ -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;
}

View File

@@ -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<DataTableComponent>;
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');
});
});

View File

@@ -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<any>;
noContentTemplate: TemplateRef<any>;
noPermissionTemplate: TemplateRef<any>;
@@ -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 {

View File

@@ -47,5 +47,6 @@ export interface DataColumn<T = unknown> {
header?: TemplateRef<any>;
draggable?: boolean;
isHidden?: boolean;
width?: number;
customData?: T;
}

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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<ResizeEvent>();
/**
* Emitted when the mouse is dragged after a resize event has started.
*/
@Output() resizing = new EventEmitter<ResizeEvent>();
/**
* Emitted when the mouse is released after a resize event.
*/
@Output() resizeEnd = new EventEmitter<ResizeEvent>();
mouseup = new Subject<IResizeMouseEvent>();
mousedown = new Subject<IResizeMouseEvent>();
mousemove = new Subject<IResizeMouseEvent>();
private pointerDown: Observable<IResizeMouseEvent>;
private pointerMove: Observable<IResizeMouseEvent>;
private pointerUp: Observable<IResizeMouseEvent>;
private startingRect: BoundingRectangle;
private currentRect: BoundingRectangle;
private unlistenMouseDown: () => void;
private unlistenMouseMove: () => void;
private unlistenMouseUp: () => void;
private destroy$ = new Subject<void>();
private static MINIMUM_COLUMN_SIZE = 100;
constructor(
private readonly renderer: Renderer2,
private readonly element: ElementRef<HTMLElement>,
private readonly zone: NgZone
) {
this.pointerDown = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseDown = renderer.listen(
'document',
'mousedown',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
this.pointerMove = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseMove = renderer.listen(
'document',
'mousemove',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
this.pointerUp = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseUp = renderer.listen(
'document',
'mouseup',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
}
ngOnInit(): void {
const mousedown$: Observable<IResizeMouseEvent> = merge(this.pointerDown, this.mousedown);
const mousemove$: Observable<IResizeMouseEvent> = merge(this.pointerMove, this.mousemove)
.pipe(
tap((event) => this.preventDefaultEvent(event)),
share()
);
const mouseup$: Observable<IResizeMouseEvent> = merge(this.pointerUp, this.mouseup);
const mousedrag: Observable<IResizeMouseEvent | ICoordinateX> = 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;
}
}

View File

@@ -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 {}

View File

@@ -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));
});
});

View File

@@ -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<void>();
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);
}
}

View File

@@ -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;
}