mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-07-31 17:38:48 +00:00
[ADF-112] Change task details feature (due date) (#2071)
* Add Assignee to readonly mode * Style adoption and first steps to editable mode * Switch between mode coverage * Rebase fix * Because of design and requirement changes, revert unnecessary parts * Small refactoring before the introduction of CardViewDateItem * Fix AdfCardView tests * Editable Card date item * Do not allow edit on task details after the task is completed. * Update task details request * Login footer switch fix * Login customisable copyright text * Card text item (first sketches) * Small fix for supported card items' template * Dynamic component loading for card view items * Test and linting fixes * Updating Readme.md * Update Readme.md * Fix Readme.md errors * CardViewTextItemComponent tests * Rebase fix
This commit is contained in:
committed by
Eugenio Romano
parent
08766eee23
commit
07b0e98b20
@@ -0,0 +1,25 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { Directive, ViewContainerRef } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[adf-card-view-content-proxy]'
|
||||
})
|
||||
export class AdfCardViewContentProxyDirective {
|
||||
constructor(public viewContainerRef: ViewContainerRef) { }
|
||||
}
|
@@ -0,0 +1,23 @@
|
||||
<div class="adf-property-label">{{ property.label }}</div>
|
||||
<div class="adf-property-value">
|
||||
<span *ngIf="!isEditble()">
|
||||
<span [attr.data-automation-id]="'card-dateitem-' + property.key">{{ property.displayValue }}</span>
|
||||
</span>
|
||||
<span *ngIf="isEditble()" class="adf-dateitem-editable">
|
||||
<input class="adf-invisible-date-input" [mdDatepicker]="picker" value="{{property.value}}"><!--
|
||||
--><span
|
||||
class="adf-datepicker-toggle"
|
||||
[attr.data-automation-id]="'datepicker-label-toggle-' + property.key"
|
||||
(click)="showDatePicker($event)">{{ property.displayValue }}
|
||||
</span>
|
||||
<button
|
||||
[attr.data-automation-id]="'datepickertoggle-' + property.key"
|
||||
[mdDatepickerToggle]="picker">
|
||||
</button>
|
||||
<md-datepicker #picker
|
||||
[attr.data-automation-id]="'datepicker-' + property.key"
|
||||
(selectedChanged)="dateChanged($event)"
|
||||
[startAt]="property.value">
|
||||
</md-datepicker>
|
||||
</span>
|
||||
</div>
|
@@ -0,0 +1,28 @@
|
||||
@import 'theming';
|
||||
|
||||
.#{$ADF} {
|
||||
&-invisible-date-input {
|
||||
height: 24px;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&-dateitem-editable {
|
||||
cursor: pointer;
|
||||
|
||||
button {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 0.5;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,151 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MdDatepickerModule, MdInputModule, MdNativeDateModule } from '@angular/material';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { CardViewDateItemModel } from '../../models/card-view-dateitem.model';
|
||||
import { CardViewUpdateService } from '../../services/adf-card-view-update.service';
|
||||
import { CardViewDateItemComponent } from './adf-card-view-dateitem.component';
|
||||
|
||||
describe('CardViewDateItemComponent', () => {
|
||||
|
||||
let fixture: ComponentFixture<CardViewDateItemComponent>;
|
||||
let component: CardViewDateItemComponent;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
MdDatepickerModule,
|
||||
MdInputModule,
|
||||
MdNativeDateModule
|
||||
],
|
||||
declarations: [
|
||||
CardViewDateItemComponent
|
||||
],
|
||||
providers: [
|
||||
CardViewUpdateService
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CardViewDateItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.property = <CardViewDateItemModel>{
|
||||
type: 'date',
|
||||
label: 'Date label',
|
||||
value: new Date('07/10/2017'),
|
||||
key: 'datekey',
|
||||
default: '',
|
||||
format: '',
|
||||
editable: false,
|
||||
get displayValue(): string {
|
||||
return 'Jul 10 2017';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('should render the label and value', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-property-label'));
|
||||
expect(labelValue).not.toBeNull();
|
||||
expect(labelValue.nativeElement.innerText).toBe('Date label');
|
||||
|
||||
let value = fixture.debugElement.query(By.css('.adf-property-value'));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText.trim()).toBe('Jul 10 2017');
|
||||
});
|
||||
|
||||
it('should render value when editable:true', () => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let value = fixture.debugElement.query(By.css('.adf-property-value'));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText.trim()).toBe('Jul 10 2017');
|
||||
});
|
||||
|
||||
it('should render the picker and toggle in case of editable:true', () => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let datePicker = fixture.debugElement.query(By.css(`[data-automation-id="datepicker-${component.property.key}"]`));
|
||||
let datePickerToggle = fixture.debugElement.query(By.css(`[data-automation-id="datepickertoggle-${component.property.key}"]`));
|
||||
expect(datePicker).not.toBeNull('Datepicker should be in DOM');
|
||||
expect(datePickerToggle).not.toBeNull('Datepicker toggle should be shown');
|
||||
});
|
||||
|
||||
it('should NOT render the picker and toggle in case of editable:false', () => {
|
||||
component.property.editable = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
let datePicker = fixture.debugElement.query(By.css(`[data-automation-id="datepicker-${component.property.key}"]`));
|
||||
let datePickerToggle = fixture.debugElement.query(By.css(`[data-automation-id="datepickertoggle-${component.property.key}"]`));
|
||||
expect(datePicker).toBeNull('Datepicker should NOT be in DOM');
|
||||
expect(datePickerToggle).toBeNull('Datepicker toggle should NOT be shown');
|
||||
});
|
||||
|
||||
it('should NOT render the picker and toggle in case of editable:true but (general) editable:false', () => {
|
||||
component.editable = false;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let datePicker = fixture.debugElement.query(By.css(`[data-automation-id="datepicker-${component.property.key}"]`));
|
||||
let datePickerToggle = fixture.debugElement.query(By.css(`[data-automation-id="datepickertoggle-${component.property.key}"]`));
|
||||
expect(datePicker).toBeNull('Datepicker should NOT be in DOM');
|
||||
expect(datePickerToggle).toBeNull('Datepicker toggle should NOT be shown');
|
||||
});
|
||||
|
||||
it('should open the datetimepicker when clicking on the label', () => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
spyOn(component.datepicker, 'open');
|
||||
|
||||
let datePickerLabelToggle = fixture.debugElement.query(By.css(`[data-automation-id="datepicker-label-toggle-${component.property.key}"]`));
|
||||
datePickerLabelToggle.triggerEventHandler('click', {});
|
||||
|
||||
expect(component.datepicker.open).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger an update event on the CardViewUpdateService', (done) => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
const cardViewUpdateService = TestBed.get(CardViewUpdateService);
|
||||
const expectedDate = new Date('11/11/2017');
|
||||
fixture.detectChanges();
|
||||
|
||||
cardViewUpdateService.itemUpdated$.subscribe(
|
||||
(updateNotification) => {
|
||||
expect(updateNotification.target).toBe(component.property);
|
||||
expect(updateNotification.changed).toEqual({ datekey: expectedDate });
|
||||
done();
|
||||
}
|
||||
);
|
||||
|
||||
component.datepicker.selectedChanged.next(expectedDate);
|
||||
});
|
||||
});
|
@@ -0,0 +1,51 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { Component, Input, ViewChild } from '@angular/core';
|
||||
import { MdDatepicker } from '@angular/material';
|
||||
import { CardViewDateItemModel } from '../../models/card-view-dateitem.model';
|
||||
import { CardViewUpdateService } from '../../services/adf-card-view-update.service';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-card-view-dateitem',
|
||||
templateUrl: './adf-card-view-dateitem.component.html',
|
||||
styleUrls: ['./adf-card-view-dateitem.component.scss']
|
||||
})
|
||||
export class CardViewDateItemComponent {
|
||||
@Input()
|
||||
property: CardViewDateItemModel;
|
||||
|
||||
@Input()
|
||||
editable: boolean;
|
||||
|
||||
@ViewChild(MdDatepicker)
|
||||
public datepicker: MdDatepicker<any>;
|
||||
|
||||
constructor(private cardViewUpdateService: CardViewUpdateService) {}
|
||||
|
||||
isEditble() {
|
||||
return this.editable && this.property.editable;
|
||||
}
|
||||
|
||||
showDatePicker() {
|
||||
this.datepicker.open();
|
||||
}
|
||||
|
||||
dateChanged(changed) {
|
||||
this.cardViewUpdateService.update(this.property, { [this.property.key]: changed });
|
||||
}
|
||||
}
|
@@ -0,0 +1,155 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { Component, Input } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
|
||||
import { CardViewItem } from '../../interface/card-view-item.interface';
|
||||
import { AdfCardViewContentProxyDirective } from './adf-card-view-content-proxy.directive';
|
||||
import { CardViewItemDispatcherComponent } from './adf-card-view-item-dispatcher.component';
|
||||
|
||||
@Component({
|
||||
selector: 'whatever-you-want-to-have',
|
||||
template: '<div data-automation-id="found-me">Hey I am shiny!</div>'
|
||||
})
|
||||
export class CardViewShinyCustomElementItemComponent {
|
||||
@Input() property: CardViewItem;
|
||||
@Input() editable: boolean;
|
||||
}
|
||||
|
||||
describe('CardViewItemDispatcherComponent', () => {
|
||||
|
||||
let fixture: ComponentFixture<CardViewItemDispatcherComponent>;
|
||||
let component: CardViewItemDispatcherComponent;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [],
|
||||
declarations: [
|
||||
CardViewItemDispatcherComponent,
|
||||
CardViewShinyCustomElementItemComponent,
|
||||
AdfCardViewContentProxyDirective
|
||||
],
|
||||
providers: []
|
||||
});
|
||||
|
||||
// entryComponents are not supported yet on TestBed, that is why this ugly workaround:
|
||||
// https://github.com/angular/angular/issues/10760
|
||||
TestBed.overrideModule(BrowserDynamicTestingModule, {
|
||||
set: { entryComponents: [ CardViewShinyCustomElementItemComponent ] }
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CardViewItemDispatcherComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.property = <CardViewItem>{
|
||||
type: 'shiny-custom-element',
|
||||
label: 'Shiny custom element',
|
||||
value: null,
|
||||
key: 'customkey',
|
||||
default: '',
|
||||
editable: false,
|
||||
get displayValue(): string {
|
||||
return 'custom value displayed';
|
||||
}
|
||||
};
|
||||
component.editable = true;
|
||||
|
||||
fixture.detectChanges();
|
||||
component.ngOnChanges(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
describe('Subcompomnent creation', () => {
|
||||
|
||||
it('should load the CardViewShinyCustomElementItemComponent', () => {
|
||||
const innerElement = fixture.debugElement.query(By.css('[data-automation-id="found-me"]'));
|
||||
expect(innerElement).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should load the CardViewShinyCustomElementItemComponent only ONCE', () => {
|
||||
component.ngOnChanges();
|
||||
component.ngOnChanges();
|
||||
component.ngOnChanges();
|
||||
fixture.detectChanges();
|
||||
|
||||
const shinyCustomElementItemComponent = fixture.debugElement.queryAll(By.css('whatever-you-want-to-have'));
|
||||
|
||||
expect(shinyCustomElementItemComponent.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should pass through the property and editable parameters', () => {
|
||||
const shinyCustomElementItemComponent = fixture.debugElement.query(By.css('whatever-you-want-to-have')).componentInstance;
|
||||
|
||||
expect(shinyCustomElementItemComponent.property).toBe(component.property);
|
||||
expect(shinyCustomElementItemComponent.editable).toBe(component.editable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Angular lifecycle methods', () => {
|
||||
|
||||
let shinyCustomElementItemComponent;
|
||||
|
||||
const lifeCycleMethods = [
|
||||
'ngOnChanges',
|
||||
'ngOnInit',
|
||||
'ngDoCheck',
|
||||
'ngAfterContentInit',
|
||||
'ngAfterContentChecked',
|
||||
'ngAfterViewInit',
|
||||
'ngAfterViewChecked',
|
||||
'ngOnDestroy'
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
shinyCustomElementItemComponent = fixture.debugElement.query(By.css('whatever-you-want-to-have')).componentInstance;
|
||||
});
|
||||
|
||||
it('should call through the lifecycle methods', () => {
|
||||
lifeCycleMethods.forEach((lifeCycleMethod) => {
|
||||
shinyCustomElementItemComponent[lifeCycleMethod] = () => {};
|
||||
spyOn(shinyCustomElementItemComponent, lifeCycleMethod);
|
||||
const param1 = {};
|
||||
const param2 = {};
|
||||
|
||||
component[lifeCycleMethod].call(component, param1, param2);
|
||||
|
||||
expect(shinyCustomElementItemComponent[lifeCycleMethod]).toHaveBeenCalledWith(param1, param2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT call through the lifecycle methods if the method does not exist (no error should be thrown)', () => {
|
||||
lifeCycleMethods.forEach((lifeCycleMethod) => {
|
||||
shinyCustomElementItemComponent[lifeCycleMethod] = undefined;
|
||||
|
||||
const execution = () => {
|
||||
component[lifeCycleMethod].call(component);
|
||||
};
|
||||
|
||||
expect(execution).not.toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,97 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 {
|
||||
Component,
|
||||
ComponentFactoryResolver,
|
||||
Input,
|
||||
OnChanges,
|
||||
Type,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CardViewItem } from '../../interface/card-view-item.interface';
|
||||
import { AdfCardViewContentProxyDirective } from './adf-card-view-content-proxy.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-card-view-item-dispatcher',
|
||||
template: '<ng-template adf-card-view-content-proxy></ng-template>'
|
||||
})
|
||||
export class CardViewItemDispatcherComponent implements OnChanges {
|
||||
@Input()
|
||||
property: CardViewItem;
|
||||
|
||||
@Input()
|
||||
editable: boolean;
|
||||
|
||||
@ViewChild(AdfCardViewContentProxyDirective)
|
||||
private content: AdfCardViewContentProxyDirective;
|
||||
|
||||
private loaded: boolean = false;
|
||||
private componentReference: any = null;
|
||||
|
||||
public ngOnInit;
|
||||
public ngDoCheck;
|
||||
|
||||
constructor(private resolver: ComponentFactoryResolver) {
|
||||
const dynamicLifecycleMethods = [
|
||||
'ngOnInit',
|
||||
'ngDoCheck',
|
||||
'ngAfterContentInit',
|
||||
'ngAfterContentChecked',
|
||||
'ngAfterViewInit',
|
||||
'ngAfterViewChecked',
|
||||
'ngOnDestroy'
|
||||
];
|
||||
|
||||
dynamicLifecycleMethods.forEach((dynamicLifecycleMethod) => {
|
||||
this[dynamicLifecycleMethod] = this.proxy.bind(this, dynamicLifecycleMethod);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnChanges(...args) {
|
||||
if (!this.loaded) {
|
||||
this.loadComponent();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
this.proxy('ngOnChanges', ...args);
|
||||
}
|
||||
|
||||
private loadComponent() {
|
||||
const upperCamelCasedType = this.getUpperCamelCase(this.property.type),
|
||||
className = `CardView${upperCamelCasedType}ItemComponent`;
|
||||
|
||||
const factories = Array.from(this.resolver['_factories'].keys());
|
||||
const factoryClass = <Type<any>>factories.find((x: any) => x.name === className);
|
||||
const factory = this.resolver.resolveComponentFactory(factoryClass);
|
||||
this.componentReference = this.content.viewContainerRef.createComponent(factory);
|
||||
|
||||
this.componentReference.instance.editable = this.editable;
|
||||
this.componentReference.instance.property = this.property;
|
||||
}
|
||||
|
||||
private getUpperCamelCase(type: string): string {
|
||||
const camelCasedType = type.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase(); });
|
||||
return camelCasedType[0].toUpperCase() + camelCasedType.substr(1);
|
||||
}
|
||||
|
||||
private proxy(methodName, ...args) {
|
||||
if (this.componentReference.instance[methodName]) {
|
||||
this.componentReference.instance[methodName].apply(this.componentReference.instance, args);
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,39 @@
|
||||
<div class="adf-property-label">{{ property.label }}</div>
|
||||
<div class="adf-property-value">
|
||||
<span *ngIf="!isEditble()">
|
||||
<span [attr.data-automation-id]="'card-textitem-value-' + property.key">{{ property.displayValue }}</span>
|
||||
</span>
|
||||
<span *ngIf="isEditble()">
|
||||
<div *ngIf="!inEdit" (click)="setEditMode(true)" class="adf-textitem-readonly" [attr.data-automation-id]="'card-textitem-edit-toggle-' + property.key">
|
||||
<span [attr.data-automation-id]="'card-textitem-value-' + property.key">{{ property.displayValue }}</span>
|
||||
<md-icon [attr.data-automation-id]="'card-textitem-edit-icon-' + property.key" class="adf-textitem-icon">create</md-icon>
|
||||
</div>
|
||||
<div *ngIf="inEdit" class="adf-textitem-editable">
|
||||
<md-input-container floatPlaceholder="never" class="adf-input-container">
|
||||
<input *ngIf="!property.multiline" #editorInput
|
||||
mdInput
|
||||
class="adf-input"
|
||||
[placeholder]="property.default"
|
||||
[(ngModel)]="editedValue"
|
||||
[attr.data-automation-id]="'card-textitem-editinput-' + property.key">
|
||||
<textarea *ngIf="property.multiline" #editorInput
|
||||
mdInput
|
||||
mdTextareaAutosize
|
||||
mdAutosizeMaxRows="1"
|
||||
mdAutosizeMaxRows="5"
|
||||
class="adf-textarea"
|
||||
[placeholder]="property.default"
|
||||
[(ngModel)]="editedValue"
|
||||
[attr.data-automation-id]="'card-textitem-edittextarea-' + property.key"></textarea>
|
||||
</md-input-container>
|
||||
<md-icon
|
||||
class="adf-textitem-icon adf-update-icon"
|
||||
(click)="update()"
|
||||
[attr.data-automation-id]="'card-textitem-update-' + property.key">done</md-icon>
|
||||
<md-icon
|
||||
class="adf-textitem-icon adf-reset-icon"
|
||||
(click)="reset()"
|
||||
[attr.data-automation-id]="'card-textitem-reset-' + property.key">clear</md-icon>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
@@ -0,0 +1,80 @@
|
||||
@import 'theming';
|
||||
|
||||
.#{$ADF} {
|
||||
&-textitem-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
position: relative;
|
||||
top: 3px;
|
||||
padding-left: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&-update-icon {
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
&-textitem-readonly {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover md-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-textitem-editable {
|
||||
display: flex;
|
||||
|
||||
md-icon:hover {
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
md-input-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border: 1px solid #EEE;
|
||||
}
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-wrapper {
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-underline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-placeholder-wrapper {
|
||||
padding-top: 2em;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-placeholder {
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-element {
|
||||
font-family: inherit;
|
||||
position: relative;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ .mat-input-element:focus {
|
||||
padding: 5px;
|
||||
left: -6px;
|
||||
top: -3px;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ input.mat-input-element {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
&-textitem-editable /deep/ input.mat-input-element:focus {
|
||||
margin-bottom: -7px;
|
||||
}
|
||||
}
|
@@ -0,0 +1,147 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MdDatepickerModule, MdIconModule, MdInputModule, MdNativeDateModule } from '@angular/material';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { CardViewTextItemModel } from '../../models/card-view-textitem.model';
|
||||
import { CardViewUpdateService } from '../../services/adf-card-view-update.service';
|
||||
import { CardViewTextItemComponent } from './adf-card-view-textitem.component';
|
||||
|
||||
describe('CardViewTextItemComponent', () => {
|
||||
|
||||
let fixture: ComponentFixture<CardViewTextItemComponent>;
|
||||
let component: CardViewTextItemComponent;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
FormsModule,
|
||||
NoopAnimationsModule,
|
||||
MdDatepickerModule,
|
||||
MdIconModule,
|
||||
MdInputModule,
|
||||
MdNativeDateModule
|
||||
],
|
||||
declarations: [
|
||||
CardViewTextItemComponent
|
||||
],
|
||||
providers: [
|
||||
CardViewUpdateService
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CardViewTextItemComponent);
|
||||
component = fixture.componentInstance;
|
||||
component.property = <CardViewTextItemModel>{
|
||||
type: 'text',
|
||||
label: 'Text label',
|
||||
value: 'Lorem ipsum',
|
||||
key: 'textkey',
|
||||
default: '',
|
||||
editable: false,
|
||||
get displayValue(): string {
|
||||
return 'Lorem ipsum';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('should render the label and value', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-property-label'));
|
||||
expect(labelValue).not.toBeNull();
|
||||
expect(labelValue.nativeElement.innerText).toBe('Text label');
|
||||
|
||||
let value = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-value-${component.property.key}"]`));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText.trim()).toBe('Lorem ipsum');
|
||||
});
|
||||
|
||||
it('should render value when editable:true', () => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let value = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-value-${component.property.key}"]`));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText.trim()).toBe('Lorem ipsum');
|
||||
});
|
||||
|
||||
it('should render the edit icon in case of editable:true', () => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let editIcon = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-edit-icon-${component.property.key}"]`));
|
||||
expect(editIcon).not.toBeNull('Edit icon should be shown');
|
||||
});
|
||||
|
||||
it('should NOT render the edit icon in case of editable:false', () => {
|
||||
component.editable = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
let editIcon = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-edit-icon-${component.property.key}"]`));
|
||||
expect(editIcon).toBeNull('Edit icon should NOT be shown');
|
||||
});
|
||||
|
||||
it('should NOT render the picker and toggle in case of editable:true but (general) editable:false', () => {
|
||||
component.editable = false;
|
||||
component.property.editable = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
let editIcon = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-edit-icon-${component.property.key}"]`));
|
||||
expect(editIcon).toBeNull('Edit icon should NOT be shown');
|
||||
});
|
||||
|
||||
it('should trigger an update event on the CardViewUpdateService', (done) => {
|
||||
component.editable = true;
|
||||
component.property.editable = true;
|
||||
const cardViewUpdateService = TestBed.get(CardViewUpdateService);
|
||||
const expectedText = 'changed text';
|
||||
fixture.detectChanges();
|
||||
|
||||
cardViewUpdateService.itemUpdated$.subscribe(
|
||||
(updateNotification) => {
|
||||
expect(updateNotification.target).toBe(component.property);
|
||||
expect(updateNotification.changed).toEqual({ textkey: expectedText });
|
||||
done();
|
||||
}
|
||||
);
|
||||
|
||||
let editIcon = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-edit-toggle-${component.property.key}"]`));
|
||||
editIcon.triggerEventHandler('click', null);
|
||||
fixture.detectChanges();
|
||||
|
||||
let editInput = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-editinput-${component.property.key}"]`));
|
||||
editInput.nativeElement.value = expectedText;
|
||||
editInput.nativeElement.dispatchEvent(new Event('input'));
|
||||
fixture.detectChanges();
|
||||
|
||||
let updateInput = fixture.debugElement.query(By.css(`[data-automation-id="card-textitem-update-${component.property.key}"]`));
|
||||
updateInput.triggerEventHandler('click', null);
|
||||
});
|
||||
});
|
@@ -0,0 +1,65 @@
|
||||
/*!
|
||||
* @license
|
||||
* Copyright 2016 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 { Component, Input, OnChanges, ViewChild } from '@angular/core';
|
||||
import { CardViewTextItemModel } from '../../models/card-view-textitem.model';
|
||||
import { CardViewUpdateService } from '../../services/adf-card-view-update.service';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-card-view-textitem',
|
||||
templateUrl: './adf-card-view-textitem.component.html',
|
||||
styleUrls: ['./adf-card-view-textitem.component.scss']
|
||||
})
|
||||
export class CardViewTextItemComponent implements OnChanges {
|
||||
@Input()
|
||||
property: CardViewTextItemModel;
|
||||
|
||||
@Input()
|
||||
editable: boolean;
|
||||
|
||||
@ViewChild('editorInput')
|
||||
private editorInput: any;
|
||||
|
||||
inEdit: boolean = false;
|
||||
editedValue: string;
|
||||
|
||||
constructor(private cardViewUpdateService: CardViewUpdateService) {}
|
||||
|
||||
ngOnChanges() {
|
||||
this.editedValue = this.property.value;
|
||||
}
|
||||
|
||||
isEditble() {
|
||||
return this.editable && this.property.editable;
|
||||
}
|
||||
|
||||
setEditMode(editStatus: boolean): void {
|
||||
this.inEdit = editStatus;
|
||||
setTimeout(() => {
|
||||
this.editorInput.nativeElement.click();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.editedValue = this.property.value;
|
||||
this.setEditMode(false);
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.cardViewUpdateService.update(this.property, { [this.property.key]: this.editedValue });
|
||||
}
|
||||
}
|
@@ -1,18 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.adf-header__label {
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
width: 50%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.adf-header__value {
|
||||
color: rgb(68, 138, 255);
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
cursor: pointer;
|
||||
}
|
@@ -1,6 +1,7 @@
|
||||
<div class="mdl-cell mdl-cell--12-col" *ngFor="let property of properties; let idx = index">
|
||||
<div [attr.data-automation-id]="'header-' + property.key">
|
||||
<div class="adf-header__label">{{ property.label }}</div>
|
||||
<div class="adf-header__value">{{ getPropertyValue(property) }}</div>
|
||||
</div>
|
||||
<div class="adf-property-list">
|
||||
<ng-container *ngFor="let property of properties">
|
||||
<div [attr.data-automation-id]="'header-'+property.key" class="adf-property">
|
||||
<adf-card-view-item-dispatcher [property]="property" [editable]="editable"></adf-card-view-item-dispatcher>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
@@ -0,0 +1,32 @@
|
||||
@import 'theming';
|
||||
|
||||
.#{$ADF} {
|
||||
&-property-list {
|
||||
display: table;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
&-property {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
&-property /deep/ &-property-label {
|
||||
display: table-cell;
|
||||
min-width: 100px;
|
||||
padding-right: 30px;
|
||||
word-wrap: break-word;
|
||||
color: rgb(186, 186, 186);
|
||||
vertical-align: top;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
&-property /deep/ &-property-value {
|
||||
width: 100%;
|
||||
display: table-cell;
|
||||
color: rgb(101, 101, 101);
|
||||
vertical-align: top;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
}
|
@@ -16,8 +16,17 @@
|
||||
*/
|
||||
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MdDatepickerModule, MdIconModule, MdInputModule, MdNativeDateModule } from '@angular/material';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { CardViewModel } from '../../models/card-view.model';
|
||||
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';
|
||||
import { CardViewDateItemModel } from '../../models/card-view-dateitem.model';
|
||||
import { CardViewTextItemModel } from '../../models/card-view-textitem.model';
|
||||
import { CardViewUpdateService } from '../../services/adf-card-view-update.service';
|
||||
import { AdfCardViewContentProxyDirective } from './adf-card-view-content-proxy.directive';
|
||||
import { CardViewDateItemComponent } from './adf-card-view-dateitem.component';
|
||||
import { CardViewItemDispatcherComponent } from './adf-card-view-item-dispatcher.component';
|
||||
import { CardViewTextItemComponent } from './adf-card-view-textitem.component';
|
||||
import { CardViewComponent } from './adf-card-view.component';
|
||||
|
||||
describe('AdfCardView', () => {
|
||||
@@ -27,12 +36,32 @@ describe('AdfCardView', () => {
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
MdDatepickerModule,
|
||||
MdIconModule,
|
||||
MdInputModule,
|
||||
MdNativeDateModule,
|
||||
FormsModule
|
||||
],
|
||||
declarations: [
|
||||
CardViewComponent
|
||||
CardViewComponent,
|
||||
CardViewItemDispatcherComponent,
|
||||
AdfCardViewContentProxyDirective,
|
||||
CardViewTextItemComponent,
|
||||
CardViewDateItemComponent
|
||||
],
|
||||
providers: [
|
||||
CardViewUpdateService
|
||||
]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
// entryComponents are not supported yet on TestBed, that is why this ugly workaround:
|
||||
// https://github.com/angular/angular/issues/10760
|
||||
TestBed.overrideModule(BrowserDynamicTestingModule, {
|
||||
set: { entryComponents: [ CardViewTextItemComponent, CardViewDateItemComponent ] }
|
||||
});
|
||||
|
||||
TestBed.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -41,59 +70,73 @@ describe('AdfCardView', () => {
|
||||
});
|
||||
|
||||
it('should render the label and value', async(() => {
|
||||
component.properties = [new CardViewModel({label: 'My label', value: 'My value'})];
|
||||
component.properties = [new CardViewTextItemModel({label: 'My label', value: 'My value', key: 'some key'})];
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-header__label'));
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-property-label'));
|
||||
expect(labelValue).not.toBeNull();
|
||||
expect(labelValue.nativeElement.innerText).toBe('My label');
|
||||
|
||||
let value = fixture.debugElement.query(By.css('.adf-header__value'));
|
||||
let value = fixture.debugElement.query(By.css('.adf-property-value'));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText).toBe('My value');
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
it('should pass through editable property to the items', () => {
|
||||
component.editable = true;
|
||||
component.properties = [new CardViewDateItemModel({
|
||||
label: 'My date label',
|
||||
value: '2017-06-14',
|
||||
key: 'some-key',
|
||||
editable: true
|
||||
})];
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
let datePicker = fixture.debugElement.query(By.css(`[data-automation-id="datepicker-some-key"]`));
|
||||
expect(datePicker).not.toBeNull('Datepicker should be in DOM');
|
||||
});
|
||||
|
||||
it('should render the date in the correct format', async(() => {
|
||||
component.properties = [new CardViewModel({
|
||||
label: 'My date label', value: '2017-06-14',
|
||||
component.properties = [new CardViewDateItemModel({
|
||||
label: 'My date label', value: '2017-06-14', key: 'some key',
|
||||
format: 'MMM DD YYYY'
|
||||
})];
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-header__label'));
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-property-label'));
|
||||
expect(labelValue).not.toBeNull();
|
||||
expect(labelValue.nativeElement.innerText).toBe('My date label');
|
||||
|
||||
let value = fixture.debugElement.query(By.css('.adf-header__value'));
|
||||
let value = fixture.debugElement.query(By.css('.adf-property-value'));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText).toBe('Jun 14 2017');
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
it('should render the default value if the value is empty', async(() => {
|
||||
component.properties = [new CardViewModel({
|
||||
component.properties = [new CardViewTextItemModel({
|
||||
label: 'My default label',
|
||||
default: 'default value'
|
||||
value: null,
|
||||
default: 'default value',
|
||||
key: 'some key'
|
||||
})];
|
||||
fixture.detectChanges();
|
||||
fixture.whenStable().then(() => {
|
||||
fixture.detectChanges();
|
||||
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-header__label'));
|
||||
let labelValue = fixture.debugElement.query(By.css('.adf-property-label'));
|
||||
expect(labelValue).not.toBeNull();
|
||||
expect(labelValue.nativeElement.innerText).toBe('My default label');
|
||||
|
||||
let value = fixture.debugElement.query(By.css('.adf-header__value'));
|
||||
let value = fixture.debugElement.query(By.css('.adf-property-value'));
|
||||
expect(value).not.toBeNull();
|
||||
expect(value.nativeElement.innerText).toBe('default value');
|
||||
});
|
||||
|
||||
}));
|
||||
});
|
||||
|
@@ -16,26 +16,17 @@
|
||||
*/
|
||||
|
||||
import { Component, Input } from '@angular/core';
|
||||
import * as moment from 'moment';
|
||||
import { CardViewModel } from '../../models/card-view.model';
|
||||
import { CardViewItem } from '../../interface/card-view-item.interface';
|
||||
|
||||
@Component({
|
||||
selector: 'adf-card-view',
|
||||
templateUrl: './adf-card-view.component.html',
|
||||
styleUrls: ['./adf-card-view.component.css']
|
||||
styleUrls: ['./adf-card-view.component.scss']
|
||||
})
|
||||
export class CardViewComponent {
|
||||
@Input()
|
||||
properties: CardViewItem [];
|
||||
|
||||
@Input()
|
||||
properties: CardViewModel [];
|
||||
|
||||
getPropertyValue(property: CardViewModel): string {
|
||||
if (!property.value) {
|
||||
return property.default;
|
||||
} else if (property.format) {
|
||||
return moment(property.value).format(property.format);
|
||||
}
|
||||
return property.value;
|
||||
}
|
||||
|
||||
editable: boolean;
|
||||
}
|
||||
|
@@ -17,14 +17,34 @@
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MdButtonModule, MdDatepickerModule, MdIconModule, MdInputModule, MdNativeDateModule } from '@angular/material';
|
||||
import { AdfCardViewContentProxyDirective } from './adf-card-view-content-proxy.directive';
|
||||
import { CardViewDateItemComponent } from './adf-card-view-dateitem.component';
|
||||
import { CardViewItemDispatcherComponent } from './adf-card-view-item-dispatcher.component';
|
||||
import { CardViewTextItemComponent } from './adf-card-view-textitem.component';
|
||||
import { CardViewComponent } from './adf-card-view.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule
|
||||
CommonModule,
|
||||
MdDatepickerModule,
|
||||
MdNativeDateModule,
|
||||
MdInputModule,
|
||||
MdIconModule,
|
||||
MdButtonModule,
|
||||
FormsModule
|
||||
],
|
||||
declarations: [
|
||||
CardViewComponent
|
||||
CardViewComponent,
|
||||
CardViewItemDispatcherComponent,
|
||||
AdfCardViewContentProxyDirective,
|
||||
CardViewTextItemComponent,
|
||||
CardViewDateItemComponent
|
||||
],
|
||||
entryComponents: [
|
||||
CardViewTextItemComponent,
|
||||
CardViewDateItemComponent
|
||||
],
|
||||
exports: [
|
||||
CardViewComponent
|
||||
|
Reference in New Issue
Block a user