[ADF-2859] conditional evaluation of disabled state for content actions (#3450)

* react on [disabled] binding changes

* [disabled] binding updates for context menu items

* evaluating disabled state with a function

* unit test

* restore original description

* remove irrelevant test

* fix tests
This commit is contained in:
Denys Vuika
2018-06-07 23:28:01 +01:00
committed by Eugenio Romano
parent 08fd49c4e3
commit cb88a22a76
10 changed files with 199 additions and 93 deletions

View File

@@ -279,6 +279,12 @@
</data-columns> </data-columns>
<content-actions> <content-actions>
<content-action
target="all"
title="Action for 'custom' node"
[disabled]="isCustomActionDisabled"
(execute)="runCustomAction($event)">
</content-action>
<!-- Conditional actions demo --> <!-- Conditional actions demo -->
<content-action <content-action
icon="get_app" icon="get_app"

View File

@@ -542,4 +542,15 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy {
} }
} }
} }
isCustomActionDisabled = (node: MinimalNodeEntity): boolean => {
if (node && node.entry && node.entry.name === 'custom') {
return false;
}
return true;
}
runCustomAction(event) {
console.log(event);
}
} }

View File

@@ -87,11 +87,11 @@ export class MyView {
| Name | Type | Default value | Description | | Name | Type | Default value | Description |
| -- | -- | -- | -- | | -- | -- | -- | -- |
| disableWithNoPermission | `boolean` | | Should this action be disabled in the menu if the user doesn't have permission for it? | | disableWithNoPermission | `boolean` | | Should this action be disabled in the menu if the user doesn't have permission for it? |
| disabled | `boolean` | false | Is the menu item disabled? | | disabled | `boolean \|Function` | false | Is the menu item disabled? |
| handler | `string` | | System actions. Can be "delete", "download", "copy" or "move". | | handler | `string` | | System actions. Can be "delete", "download", "copy" or "move". |
| icon | `string` | | The name of the icon to display next to the menu command (can be left blank). | | icon | `string` | | The name of the icon to display next to the menu command (can be left blank). |
| permission | `string` | | The permission type. | | permission | `string` | | The permission type. |
| target | `string` | [`ContentActionTarget`](../../lib/content-services/document-list/models/content-action.model.ts).All | Type of item that the action appies to. Can be "document" or "folder" | | target | `string` | [`ContentActionTarget`](../../lib/content-services/document-list/models/content-action.model.ts).All | Type of item that the action applies to. Can be "document" or "folder" |
| title | `string` | "Action" | The title of the action as shown in the menu. | | title | `string` | "Action" | The title of the action as shown in the menu. |
| visible | `boolean \| Function` | true | Visibility state (see examples). | | visible | `boolean \| Function` | true | Visibility state (see examples). |
@@ -393,7 +393,85 @@ Please note that if you want to preserve `this` context within the evaluator fun
its property should be declared as a lambda one: its property should be declared as a lambda one:
```ts ```ts
functionName = (parameters): boolean => { funcName = (parameters): boolean => {
// implementation
return true;
}
```
### Conditional disabled state
Similar to `visible` property, it is possible to control the `disabled` state with the following scenarios:
- direct value of `boolean` type
- binding to a property of the `boolean` type
- binding to a property of the `Function` type that evaluates condition and returns `boolean` value
#### Using direct value of boolean type
```html
<content-action
target="all"
title="Action for 'custom' node"
[disabled]="true"
(execute)="runCustomAction($event)">
</content-action>
```
#### Using a property of the boolean type
```html
<content-action
target="all"
title="Action for 'custom' node"
[disabled]="shouldDisableAction"
(execute)="runCustomAction($event)">
</content-action>
```
The markup above relies on the `shouldDisableAction` property declared at your component class level:
```ts
export class MyComponent {
@Input()
shouldDisableAction = true;
}
```
#### Using a property of the Function type
```html
<content-action
target="all"
title="Action for 'custom' node"
[disabled]="isCustomActionDisabled"
(execute)="runCustomAction($event)">
</content-action>
```
The code above relies on the `isCustomActionDisabled` property of a `Function` type declared at your component class level:
```ts
export class MyComponent {
isCustomActionDisabled = (node: MinimalNodeEntity): boolean => {
if (node && node.entry && node.entry.name === 'custom') {
return false;
}
return true;
}
}
```
Code above checks the node name, and evaluates to `true` only if corresponding node is called "custom".
Please note that if you want to preserve `this` context within the evaluator function,
its property should be declared as a lambda one:
```ts
funcName = (parameters): boolean => {
// implementation // implementation
return true; return true;
} }

View File

@@ -66,7 +66,7 @@ export class ContentActionComponent implements OnInit, OnChanges, OnDestroy {
/** Is the menu item disabled? */ /** Is the menu item disabled? */
@Input() @Input()
disabled: boolean = false; disabled: boolean | Function = false;
/** Emitted when the user selects the action from the menu. */ /** Emitted when the user selects the action from the menu. */
@Output() @Output()
@@ -117,6 +117,15 @@ export class ContentActionComponent implements OnInit, OnChanges, OnDestroy {
this.folderActionModel.visible = changes.visible.currentValue; this.folderActionModel.visible = changes.visible.currentValue;
} }
} }
if (changes.disabled && !changes.disabled.firstChange) {
if (this.documentActionModel) {
this.documentActionModel.disabled = changes.disabled.currentValue;
}
if (this.folderActionModel) {
this.folderActionModel.disabled = changes.disabled.currentValue;
}
}
} }
ngOnDestroy() { ngOnDestroy() {

View File

@@ -359,6 +359,28 @@ describe('DocumentList', () => {
expect(actions[0].title).toBe('Action1'); expect(actions[0].title).toBe('Action1');
}); });
it('should evaluate conditional disabled state for content action', () => {
documentList.actions = [
new ContentActionModel({
target: 'document',
title: 'Action1',
disabled: (): boolean => true
}),
new ContentActionModel({
target: 'document',
title: 'Action2',
disabled: (): boolean => false
})
];
const nodeFile = { entry: { isFile: true, name: 'xyz' } };
const actions = documentList.getNodeActions(nodeFile);
expect(actions.length).toBe(2);
expect(actions[0].disabled).toBeTruthy();
expect(actions[1].disabled).toBeFalsy();
});
it('should not disable the action if there is copy permission', () => { it('should not disable the action if there is copy permission', () => {
let documentMenu = new ContentActionModel({ let documentMenu = new ContentActionModel({
disableWithNoPermission: true, disableWithNoPermission: true,
@@ -418,7 +440,7 @@ describe('DocumentList', () => {
let actions = documentList.getNodeActions(nodeFile); let actions = documentList.getNodeActions(nodeFile);
expect(actions.length).toBe(1); expect(actions.length).toBe(1);
expect(actions[0].title).toEqual('FileAction'); expect(actions[0].title).toEqual('FileAction');
expect(actions[0].disabled).toBeUndefined(); expect(actions[0].disabled).toBeFalsy();
}); });
it('should not disable the action if there is the right permission for the folder', () => { it('should not disable the action if there is the right permission for the folder', () => {
@@ -438,7 +460,7 @@ describe('DocumentList', () => {
let actions = documentList.getNodeActions(nodeFile); let actions = documentList.getNodeActions(nodeFile);
expect(actions.length).toBe(1); expect(actions.length).toBe(1);
expect(actions[0].title).toEqual('FolderAction'); expect(actions[0].title).toEqual('FolderAction');
expect(actions[0].disabled).toBeUndefined(); expect(actions[0].disabled).toBeFalsy();
}); });
it('should not disable the action if there are no permissions for the file and disable with no permission is false', () => { it('should not disable the action if there are no permissions for the file and disable with no permission is false', () => {
@@ -458,7 +480,7 @@ describe('DocumentList', () => {
let actions = documentList.getNodeActions(nodeFile); let actions = documentList.getNodeActions(nodeFile);
expect(actions.length).toBe(1); expect(actions.length).toBe(1);
expect(actions[0].title).toEqual('FileAction'); expect(actions[0].title).toEqual('FileAction');
expect(actions[0].disabled).toBeUndefined(); expect(actions[0].disabled).toBeFalsy();
}); });
it('should not disable the action if there are no permissions for the folder and disable with no permission is false', () => { it('should not disable the action if there are no permissions for the folder and disable with no permission is false', () => {
@@ -478,7 +500,7 @@ describe('DocumentList', () => {
let actions = documentList.getNodeActions(nodeFile); let actions = documentList.getNodeActions(nodeFile);
expect(actions.length).toBe(1); expect(actions.length).toBe(1);
expect(actions[0].title).toEqual('FolderAction'); expect(actions[0].title).toEqual('FolderAction');
expect(actions[0].disabled).toBeUndefined(); expect(actions[0].disabled).toBeFalsy();
}); });
it('should disable the action if there are no permissions for the file and disable with no permission is true', () => { it('should disable the action if there are no permissions for the file and disable with no permission is true', () => {

View File

@@ -465,7 +465,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
.map(action => new ContentActionModel(action)); .map(action => new ContentActionModel(action));
actionsByTarget.forEach((action) => { actionsByTarget.forEach((action) => {
this.disableActionsWithNoPermissions(node, action); action.disabled = this.isActionDisabled(action, node);
}); });
return actionsByTarget; return actionsByTarget;
@@ -475,10 +475,16 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte
return []; return [];
} }
disableActionsWithNoPermissions(node: MinimalNodeEntity, action: ContentActionModel) { private isActionDisabled(action: ContentActionModel, node: MinimalNodeEntity): boolean {
if (action.permission && action.disableWithNoPermission && !this.contentService.hasPermission(node.entry, action.permission)) { if (typeof action.disabled === 'function') {
action.disabled = true; return action.disabled(node);
} }
if (action.permission && action.disableWithNoPermission && !this.contentService.hasPermission(node.entry, action.permission)) {
return true;
}
return false;
} }
@HostListener('contextmenu', ['$event']) @HostListener('contextmenu', ['$event'])

View File

@@ -23,7 +23,7 @@ export class ContentActionModel {
target: string; target: string;
permission: string; permission: string;
disableWithNoPermission: boolean = false; disableWithNoPermission: boolean = false;
disabled: boolean = false; disabled: boolean | Function = false;
visible: boolean | Function = true; visible: boolean | Function = true;
constructor(obj?: any) { constructor(obj?: any) {
@@ -35,7 +35,10 @@ export class ContentActionModel {
this.target = obj.target; this.target = obj.target;
this.permission = obj.permission; this.permission = obj.permission;
this.disableWithNoPermission = obj.disableWithNoPermission; this.disableWithNoPermission = obj.disableWithNoPermission;
if (obj.hasOwnProperty('disabled')) {
this.disabled = obj.disabled; this.disabled = obj.disabled;
}
if (obj.hasOwnProperty('visible')) { if (obj.hasOwnProperty('visible')) {
this.visible = obj.visible; this.visible = obj.visible;

View File

@@ -29,14 +29,11 @@ import { ContextMenuService } from './context-menu.service';
template: ` template: `
<button mat-button [matMenuTriggerFor]="contextMenu"></button> <button mat-button [matMenuTriggerFor]="contextMenu"></button>
<mat-menu #contextMenu="matMenu" class="context-menu"> <mat-menu #contextMenu="matMenu" class="context-menu">
<button <button *ngFor="let link of links"
*ngFor="let link of links"
mat-menu-item mat-menu-item
(click)="onMenuItemClick($event, link)" [disabled]="link.model?.disabled"
[attr.disabled]="link.model?.disabled || undefined"> (click)="onMenuItemClick($event, link)">
<mat-icon *ngIf="showIcons && link.model?.icon"> <mat-icon *ngIf="showIcons && link.model?.icon">{{ link.model.icon }}</mat-icon>
{{ link.model?.icon }}
</mat-icon>
{{ (link.title || link.model?.title) | translate }} {{ (link.title || link.model?.title) | translate }}
</button> </button>
</mat-menu> </mat-menu>
@@ -47,9 +44,7 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
private mouseLocation: { left: number, top: number } = {left: 0, top: 0}; private mouseLocation: { left: number, top: number } = {left: 0, top: 0};
private menuElement = null; private menuElement = null;
private openSubscription: Subscription; private subscriptions: Subscription[] = [];
private closeSubscription: Subscription;
private contextSubscription: Subscription;
private contextMenuListenerFn: () => void; private contextMenuListenerFn: () => void;
@Input() @Input()
@@ -68,7 +63,7 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])
onResize(event) { onResize(event) {
if (this.mdMenuElement) { if (this.mdMenuElement) {
this.setPositionAfterCDKrecalculation(); this.updatePosition();
} }
} }
@@ -80,9 +75,10 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
) {} ) {}
ngOnInit() { ngOnInit() {
this.contextSubscription = this.contextMenuService.show.subscribe(e => this.showMenu(e.event, e.obj)); this.subscriptions.push(
this.contextMenuService.show.subscribe(e => this.showMenu(e.event, e.obj)),
this.openSubscription = this.menuTrigger.onMenuOpen.subscribe(() => { this.menuTrigger.onMenuOpen.subscribe(() => {
const container = this.overlayContainer.getContainerElement(); const container = this.overlayContainer.getContainerElement();
if (container) { if (container) {
this.contextMenuListenerFn = this.renderer.listen(container, 'contextmenu', (e: Event) => { this.contextMenuListenerFn = this.renderer.listen(container, 'contextmenu', (e: Event) => {
@@ -90,23 +86,25 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
}); });
} }
this.menuElement = this.getContextMenuElement(); this.menuElement = this.getContextMenuElement();
}); }),
this.closeSubscription = this.menuTrigger.onMenuClose.subscribe(() => { this.menuTrigger.onMenuClose.subscribe(() => {
this.menuElement = null; this.menuElement = null;
if (this.contextMenuListenerFn) { if (this.contextMenuListenerFn) {
this.contextMenuListenerFn(); this.contextMenuListenerFn();
} }
}); })
);
} }
ngOnDestroy() { ngOnDestroy() {
if (this.contextMenuListenerFn) { if (this.contextMenuListenerFn) {
this.contextMenuListenerFn(); this.contextMenuListenerFn();
} }
this.contextSubscription.unsubscribe();
this.openSubscription.unsubscribe(); this.subscriptions.forEach(subscription => subscription.unsubscribe());
this.closeSubscription.unsubscribe(); this.subscriptions = [];
this.menuElement = null; this.menuElement = null;
} }
@@ -132,16 +130,10 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
this.menuTrigger.openMenu(); this.menuTrigger.openMenu();
if (this.mdMenuElement) { if (this.mdMenuElement) {
this.setPositionAfterCDKrecalculation(); this.updatePosition();
} }
} }
setPositionAfterCDKrecalculation() {
setTimeout(() => {
this.setPosition();
}, 0);
}
get mdMenuElement() { get mdMenuElement() {
return this.menuElement; return this.menuElement;
} }
@@ -153,7 +145,8 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
}; };
} }
private setPosition() { private updatePosition() {
setTimeout(() => {
if (this.mdMenuElement.clientWidth + this.mouseLocation.left > this.viewport.getViewportRect().width) { if (this.mdMenuElement.clientWidth + this.mouseLocation.left > this.viewport.getViewportRect().width) {
this.menuTrigger.menu.xPosition = 'before'; this.menuTrigger.menu.xPosition = 'before';
this.mdMenuElement.parentElement.style.left = this.mouseLocation.left - this.mdMenuElement.clientWidth + 'px'; this.mdMenuElement.parentElement.style.left = this.mouseLocation.left - this.mdMenuElement.clientWidth + 'px';
@@ -169,6 +162,7 @@ export class ContextMenuHolderComponent implements OnInit, OnDestroy {
this.menuTrigger.menu.yPosition = 'below'; this.menuTrigger.menu.yPosition = 'below';
this.mdMenuElement.parentElement.style.top = this.locationCss().top; this.mdMenuElement.parentElement.style.top = this.locationCss().top;
} }
}, 0);
} }
private getContextMenuElement() { private getContextMenuElement() {

View File

@@ -952,20 +952,6 @@ fdescribe('DataTable', () => {
expect(dataTable.getCellTooltip(row, col)).toBeNull(); expect(dataTable.getCellTooltip(row, col)).toBeNull();
}); });
it('should cache the rows menu', () => {
let emitted = 0;
dataTable.showRowActionsMenu.subscribe(() => { emitted++; });
const column = <DataColumn> {};
const row = <DataRow> { getValue: function (key: string) { return 'id'; } };
dataTable.getRowActions(row, column);
dataTable.getRowActions(row, column);
dataTable.getRowActions(row, column);
expect(emitted).toBe(1);
});
it('should reset the menu cache after rows change', () => { it('should reset the menu cache after rows change', () => {
let emitted = 0; let emitted = 0;
dataTable.showRowActionsMenu.subscribe(() => { emitted++; }); dataTable.showRowActionsMenu.subscribe(() => { emitted++; });

View File

@@ -166,8 +166,6 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
private click$: Observable<DataRowEvent>; private click$: Observable<DataRowEvent>;
private differ: any; private differ: any;
private rowMenuCache: object = {};
private subscriptions: Subscription[] = []; private subscriptions: Subscription[] = [];
private singleClickStreamSub: Subscription; private singleClickStreamSub: Subscription;
private multiClickStreamSub: Subscription; private multiClickStreamSub: Subscription;
@@ -299,7 +297,6 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
private initTable() { private initTable() {
this.data = new ObjectDataTableAdapter(this.rows, this.columns); this.data = new ObjectDataTableAdapter(this.rows, this.columns);
this.setupData(this.data); this.setupData(this.data);
this.rowMenuCache = {};
} }
private setupData(adapter: DataTableAdapter) { private setupData(adapter: DataTableAdapter) {
@@ -556,15 +553,9 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
} }
getRowActions(row: DataRow, col: DataColumn): any[] { getRowActions(row: DataRow, col: DataColumn): any[] {
const id = row.getValue('id');
if (!this.rowMenuCache[id]) {
let event = new DataCellEvent(row, col, []); let event = new DataCellEvent(row, col, []);
this.showRowActionsMenu.emit(event); this.showRowActionsMenu.emit(event);
this.rowMenuCache[id] = event.value.actions; return event.value.actions;
}
return this.rowMenuCache[id];
} }
onExecuteRowAction(row: DataRow, action: any) { onExecuteRowAction(row: DataRow, action: any) {