mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-26 17:24:56 +00:00
AAE-12844: Column Resizing Improvements (#8340)
* AAE-12844: Column Resizing Improvements * AAE-12844: Added extra check on column header query selector * AAE-12844: Fixed lint issue * AAE-12844: Fixed unit tests * AAE-122844: Code improvement * AAE-12844: Added missing if condition
This commit is contained in:
parent
d1fa1a3cd7
commit
ccbf76a75e
@ -36,7 +36,7 @@
|
|||||||
'adf-datatable__cursor--pointer': !isResizing,
|
'adf-datatable__cursor--pointer': !isResizing,
|
||||||
'adf-datatable__header--sorted-asc': isColumnSorted(col, 'asc'),
|
'adf-datatable__header--sorted-asc': isColumnSorted(col, 'asc'),
|
||||||
'adf-datatable__header--sorted-desc': isColumnSorted(col, 'desc')}"
|
'adf-datatable__header--sorted-desc': isColumnSorted(col, 'desc')}"
|
||||||
[ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }"
|
[ngStyle]="(col.width) && {'flex': getFlexValue(col)}"
|
||||||
[attr.aria-label]="col.title | translate"
|
[attr.aria-label]="col.title | translate"
|
||||||
(click)="onColumnHeaderClick(col, $event)"
|
(click)="onColumnHeaderClick(col, $event)"
|
||||||
(keyup.enter)="onColumnHeaderClick(col, $event)"
|
(keyup.enter)="onColumnHeaderClick(col, $event)"
|
||||||
@ -214,7 +214,7 @@
|
|||||||
[adf-context-menu]="getContextMenuActions(row, col)"
|
[adf-context-menu]="getContextMenuActions(row, col)"
|
||||||
[adf-context-menu-enabled]="contextMenu"
|
[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' }">
|
[ngStyle]="(col.width) && {'flex': getFlexValue(col)}">
|
||||||
<div *ngIf="!col.template" class="adf-datatable-cell-container">
|
<div *ngIf="!col.template" class="adf-datatable-cell-container">
|
||||||
<ng-container [ngSwitch]="data.getColumnType(row, col)">
|
<ng-container [ngSwitch]="data.getColumnType(row, col)">
|
||||||
<div *ngSwitchCase="'image'" class="adf-cell-value">
|
<div *ngSwitchCase="'image'" class="adf-cell-value">
|
||||||
|
@ -1928,37 +1928,61 @@ describe('Column Resizing', () => {
|
|||||||
expect(tableBody.classList).toContain('adf-blur-datatable-body');
|
expect(tableBody.classList).toContain('adf-blur-datatable-body');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set column width on resizing', () => {
|
it('should set column width on resizing', fakeAsync(() => {
|
||||||
const adapter = dataTable.data;
|
const adapter = dataTable.data;
|
||||||
spyOn(adapter, 'setColumns').and.callThrough();
|
spyOn(adapter, 'setColumns').and.callThrough();
|
||||||
|
|
||||||
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
|
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
|
||||||
fixture.detectChanges();
|
tick();
|
||||||
const columns = dataTable.data.getColumns();
|
|
||||||
|
|
||||||
|
const columns = dataTable.data.getColumns();
|
||||||
expect(columns[0].width).toBe(65);
|
expect(columns[0].width).toBe(65);
|
||||||
expect(adapter.setColumns).toHaveBeenCalledWith(columns);
|
expect(adapter.setColumns).toHaveBeenCalledWith(columns);
|
||||||
});
|
}));
|
||||||
|
|
||||||
it('should set the column header style on resizing', () => {
|
it('should set the column header style on resizing', fakeAsync(() => {
|
||||||
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
|
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 125 } }, 0);
|
||||||
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const headerColumns: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
|
const headerColumns: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
|
||||||
|
expect(headerColumns[0].style.flex).toBe('0 1 125px');
|
||||||
|
}));
|
||||||
|
|
||||||
expect(headerColumns[0].style.flex).toBe('0 1 65px');
|
it('should set the column header to 100px on resizing when its width goes below 100', fakeAsync(() => {
|
||||||
});
|
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 85 } }, 0);
|
||||||
|
tick();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
it('should set the style of all the table cells under the resizing header on resizing', () => {
|
const headerColumns: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
|
||||||
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
|
expect(headerColumns[0].style.flex).toBe('0 1 100px');
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should set the style of all the table cells under the resizing header on resizing', fakeAsync(() => {
|
||||||
|
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 130 } }, 0);
|
||||||
|
tick();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body');
|
const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body');
|
||||||
const firstCell: HTMLElement = tableBody.querySelector('[data-automation-id="name1"]');
|
const firstCell: HTMLElement = tableBody.querySelector('[data-automation-id="name1"]');
|
||||||
const secondCell: HTMLElement = tableBody.querySelector('[data-automation-id="name2"]');
|
const secondCell: HTMLElement = tableBody.querySelector('[data-automation-id="name2"]');
|
||||||
|
|
||||||
expect(firstCell.style.flex).toBe('0 1 65px');
|
expect(firstCell.style.flex).toBe('0 1 130px');
|
||||||
expect(secondCell.style.flex).toBe('0 1 65px');
|
expect(secondCell.style.flex).toBe('0 1 130px');
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
it('should set the style of all the table cells under the resizing header to 100px on resizing when its width goes below 100', fakeAsync(() => {
|
||||||
|
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 85 } }, 0);
|
||||||
|
tick();
|
||||||
|
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 100px');
|
||||||
|
expect(secondCell.style.flex).toBe('0 1 100px');
|
||||||
|
}));
|
||||||
|
|
||||||
it('should unblur the body and set the resizing to false upon resizing ends', () => {
|
it('should unblur the body and set the resizing to false upon resizing ends', () => {
|
||||||
dataTable.isResizingEnabled = true;
|
dataTable.isResizingEnabled = true;
|
||||||
@ -1976,7 +2000,7 @@ describe('Column Resizing', () => {
|
|||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
document.dispatchEvent(new MouseEvent('mouseup'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(dataTable.isResizing).toBeFalse();
|
expect(dataTable.isResizing).toBeFalse();
|
||||||
@ -1998,12 +2022,11 @@ describe('Column Resizing', () => {
|
|||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
document.dispatchEvent(new MouseEvent('mouseup'));
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(dataTable.isResizing).toBeFalse();
|
expect(dataTable.isResizing).toBeFalse();
|
||||||
expect(dataTable.columnsWidthChanged.emit).toHaveBeenCalled();
|
expect(dataTable.columnsWidthChanged.emit).toHaveBeenCalled();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -66,6 +66,7 @@ export enum ShowHeaderMode {
|
|||||||
host: { class: 'adf-datatable' }
|
host: { class: 'adf-datatable' }
|
||||||
})
|
})
|
||||||
export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, DoCheck, OnDestroy, AfterViewInit {
|
export class DataTableComponent implements OnInit, AfterContentInit, OnChanges, DoCheck, OnDestroy, AfterViewInit {
|
||||||
|
private static MINIMUM_COLUMN_SIZE = 100;
|
||||||
|
|
||||||
@ViewChildren(DataTableRowComponent)
|
@ViewChildren(DataTableRowComponent)
|
||||||
rowsList: QueryList<DataTableRowComponent>;
|
rowsList: QueryList<DataTableRowComponent>;
|
||||||
@ -934,14 +935,43 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
|
|||||||
}
|
}
|
||||||
|
|
||||||
onResizing({ rectangle: { width } }: ResizeEvent, colIndex: number): void {
|
onResizing({ rectangle: { width } }: ResizeEvent, colIndex: number): void {
|
||||||
const allColumns = this.data.getColumns();
|
const timeoutId = setTimeout(() => {
|
||||||
allColumns[colIndex].width = width;
|
const allColumns = this.data.getColumns();
|
||||||
this.data.setColumns(allColumns);
|
allColumns[colIndex].width = width;
|
||||||
|
this.data.setColumns(allColumns);
|
||||||
|
|
||||||
|
if (!this.isResizing) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onResizingEnd(): void {
|
onResizingEnd(): void {
|
||||||
this.isResizing = false;
|
this.isResizing = false;
|
||||||
|
|
||||||
|
this.updateColumnsWidths();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFlexValue({ width = 0 }: DataColumn): string {
|
||||||
|
return `0 1 ${width < DataTableComponent.MINIMUM_COLUMN_SIZE ? DataTableComponent.MINIMUM_COLUMN_SIZE : width}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateColumnsWidths(): void {
|
||||||
const allColumns = this.data.getColumns();
|
const allColumns = this.data.getColumns();
|
||||||
|
|
||||||
|
const headerContainer: HTMLElement = document.querySelector('.adf-datatable-header');
|
||||||
|
|
||||||
|
if (headerContainer) {
|
||||||
|
const headerContainerColumns = headerContainer.querySelectorAll('.adf-datatable-cell-header');
|
||||||
|
|
||||||
|
headerContainerColumns.forEach((column: HTMLElement, index: number): void => {
|
||||||
|
if (allColumns[index]) {
|
||||||
|
allColumns[index].width = column.offsetWidth ?? DataTableComponent.MINIMUM_COLUMN_SIZE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.data.setColumns(allColumns);
|
||||||
|
|
||||||
this.columnsWidthChanged.emit(allColumns);
|
this.columnsWidthChanged.emit(allColumns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -139,16 +139,4 @@ describe('ResizableDirective', () => {
|
|||||||
|
|
||||||
expect(directive.resizing.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 120, width: 120 } });
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
import { Subject, Observable, Observer, merge } from 'rxjs';
|
import { Subject, Observable, Observer, merge } from 'rxjs';
|
||||||
import { BoundingRectangle, ResizeEvent, IResizeMouseEvent, ICoordinateX } from './types';
|
import { BoundingRectangle, ResizeEvent, IResizeMouseEvent, ICoordinateX } from './types';
|
||||||
import { map, tap, take, share, filter, pairwise, mergeMap, takeUntil } from 'rxjs/operators';
|
import { map, take, share, filter, pairwise, mergeMap, takeUntil } from 'rxjs/operators';
|
||||||
import { OnInit, Output, NgZone, OnDestroy, Directive, Renderer2, ElementRef, EventEmitter } from '@angular/core';
|
import { OnInit, Output, NgZone, OnDestroy, Directive, Renderer2, ElementRef, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
@Directive({
|
@Directive({
|
||||||
@ -64,8 +64,6 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
private static MINIMUM_COLUMN_SIZE = 100;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly renderer: Renderer2,
|
private readonly renderer: Renderer2,
|
||||||
private readonly element: ElementRef<HTMLElement>,
|
private readonly element: ElementRef<HTMLElement>,
|
||||||
@ -118,11 +116,7 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const mousedown$: Observable<IResizeMouseEvent> = merge(this.pointerDown, this.mousedown);
|
const mousedown$: Observable<IResizeMouseEvent> = merge(this.pointerDown, this.mousedown);
|
||||||
|
|
||||||
const mousemove$: Observable<IResizeMouseEvent> = merge(this.pointerMove, this.mousemove)
|
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 mouseup$: Observable<IResizeMouseEvent> = merge(this.pointerUp, this.mouseup);
|
||||||
|
|
||||||
@ -159,9 +153,6 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
|||||||
.pipe(
|
.pipe(
|
||||||
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX))
|
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX))
|
||||||
)
|
)
|
||||||
.pipe(
|
|
||||||
filter(this.minimumAllowedSize)
|
|
||||||
)
|
|
||||||
.subscribe((rectangle: BoundingRectangle) => {
|
.subscribe((rectangle: BoundingRectangle) => {
|
||||||
if (this.resizing.observers.length > 0) {
|
if (this.resizing.observers.length > 0) {
|
||||||
this.zone.run(() => {
|
this.zone.run(() => {
|
||||||
@ -217,14 +208,8 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
|||||||
this.destroy$.next();
|
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 {
|
private getNewBoundingRectangle({ top, bottom, left, right }: BoundingRectangle, clientX: number): BoundingRectangle {
|
||||||
const updatedRight = right += clientX;
|
const updatedRight = Math.round(right + clientX);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top,
|
top,
|
||||||
@ -250,8 +235,4 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
|||||||
scrollLeft: nativeElement.scrollLeft
|
scrollLeft: nativeElement.scrollLeft
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private minimumAllowedSize({ width = 0 }: BoundingRectangle): boolean {
|
|
||||||
return width > ResizableDirective.MINIMUM_COLUMN_SIZE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -65,16 +65,19 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
|
|||||||
if (event.cancelable) {
|
if (event.cancelable) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
this.unlistenMouseMove = this.renderer.listen(
|
|
||||||
this.element.nativeElement,
|
if (!this.unlistenMouseMove) {
|
||||||
'mousemove',
|
this.unlistenMouseMove = this.renderer.listen(
|
||||||
(mouseMoveEvent: MouseEvent) => {
|
this.element.nativeElement,
|
||||||
this.onMousemove(mouseMoveEvent);
|
'mousemove',
|
||||||
}
|
(mouseMoveEvent: MouseEvent) => {
|
||||||
);
|
this.onMousemove(mouseMoveEvent);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.unlistenMouseUp = this.renderer.listen(
|
this.unlistenMouseUp = this.renderer.listen(
|
||||||
this.element.nativeElement,
|
'document',
|
||||||
'mouseup',
|
'mouseup',
|
||||||
(mouseUpEvent: MouseEvent) => {
|
(mouseUpEvent: MouseEvent) => {
|
||||||
this.onMouseup(mouseUpEvent);
|
this.onMouseup(mouseUpEvent);
|
||||||
|
@ -379,19 +379,11 @@ describe('ProcessListCloudComponent', () => {
|
|||||||
component.reload();
|
component.reload();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
|
const newColumns = [...component.columns];
|
||||||
|
newColumns[0].width = 120;
|
||||||
|
component.onColumnsWidthChanged(newColumns);
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
|
expect(component.columns[0].width).toBe(120);
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
|
||||||
|
|
||||||
const firstColumnInitialWidth = component.columns[0].width ?? 0;
|
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove', { clientX: 25 }));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
|
||||||
|
|
||||||
expect(component.columns[0].width).toBe(firstColumnInitialWidth + 25);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-create columns when a column order gets changed', () => {
|
it('should re-create columns when a column order gets changed', () => {
|
||||||
|
@ -287,25 +287,14 @@ describe('TaskListCloudComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should re-create columns when a column width gets changed', () => {
|
it('should re-create columns when a column width gets changed', () => {
|
||||||
component.isResizingEnabled = true;
|
|
||||||
spyOn(taskListCloudService, 'getTaskByRequest').and.returnValue(of(fakeGlobalTasks));
|
|
||||||
|
|
||||||
component.reload();
|
component.reload();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
|
const newColumns = [...component.columns];
|
||||||
|
newColumns[0].width = 120;
|
||||||
|
component.onColumnsWidthChanged(newColumns);
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
|
expect(component.columns[0].width).toBe(120);
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
|
||||||
|
|
||||||
const firstColumnInitialWidth = component.columns[0].width ?? 0;
|
|
||||||
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mousemove', { clientX: 25 }));
|
|
||||||
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
|
|
||||||
|
|
||||||
expect(component.columns[0].width).toBe(firstColumnInitialWidth + 25);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should re-create columns when a column order gets changed', () => {
|
it('should re-create columns when a column order gets changed', () => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user