Add relate edit component

- Add relate edit component
-- Add standard relate handling to BaseRelateComponent

- Re-structure relate fields handling
-- Store the object instead of just the name
-- Map valueObject to the proper attributes when saving
This commit is contained in:
Clemente Raposo 2021-01-04 23:32:14 +00:00 committed by Dillon-Brown
parent 9af33c927b
commit d4493e5759
14 changed files with 516 additions and 17 deletions

View file

@ -0,0 +1,153 @@
import {LanguageStore} from '@store/language/language.store';
import {OnDestroy, OnInit} from '@angular/core';
import {Observable, of} from 'rxjs';
import {DataTypeFormatter} from '@services/formatters/data-type.formatter.service';
import {catchError, map, tap} from 'rxjs/operators';
import {AttributeMap, Record} from '@app-common/record/record.model';
import {RelateService} from '@services/record/relate/relate.service';
import {BaseFieldComponent} from '@fields/base/base-field.component';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
export class BaseRelateComponent extends BaseFieldComponent implements OnInit, OnDestroy {
selectedValues: AttributeMap[] = [];
status: '' | 'searching' | 'not-found' | 'error' | 'found' = '';
constructor(
protected languages: LanguageStore,
protected typeFormatter: DataTypeFormatter,
protected relateService: RelateService,
protected moduleNameMapper: ModuleNameMapper
) {
super(typeFormatter);
}
get module(): string {
if (!this.record || !this.record.module) {
return null;
}
return this.record.module;
}
ngOnInit(): void {
this.initValue();
if (this.relateService) {
this.relateService.init(this.getRelatedModule());
}
}
ngOnDestroy(): void {
this.subs.forEach(sub => sub.unsubscribe());
}
search = (text: string): Observable<any> => {
this.status = 'searching';
return this.relateService.search(text, this.getRelateFieldName()).pipe(
tap(() => this.status = 'found'),
catchError(() => {
this.status = 'error';
return of([]);
}),
map(records => {
if (!records || records.length < 1) {
this.status = 'not-found';
return [];
}
const flatRecords: AttributeMap[] = [];
records.forEach((record: Record) => {
if (record && record.attributes) {
flatRecords.push(record.attributes);
}
});
this.status = '';
return flatRecords;
}),
);
};
getRelateFieldName(): string {
return (this.field && this.field.definition && this.field.definition.rname) || 'name';
}
getRelatedModule(): string {
const legacyName = (this.field && this.field.definition && this.field.definition.module) || '';
if (!legacyName) {
return '';
}
return this.moduleNameMapper.toFrontend(legacyName);
}
getMessage(): string {
const messages = {
searching: 'LBL_SEARCHING',
'not-found': 'LBL_NOT_FOUND',
error: 'LBL_SEARCH_ERROR',
found: 'LBL_FOUND',
'no-module': 'LBL_FOUND'
};
if (messages[this.status]) {
return messages[this.status];
}
return '';
}
getInvalidClass(): string {
if (this.field.formControl && this.field.formControl.invalid && this.field.formControl.touched) {
return 'is-invalid';
}
if (this.hasSearchError()) {
return 'is-invalid';
}
return '';
}
hasSearchError(): boolean {
return this.status === 'error' || this.status === 'not-found';
}
resetStatus(): void {
this.status = '';
}
getPlaceholderLabel(): string {
return this.languages.getAppString('LBL_TYPE_TO_SEARCH') || '';
}
protected buildRelate(id: string, relateValue: string): any {
const relate = {id};
if (this.getRelateFieldName()) {
relate[this.getRelateFieldName()] = relateValue;
}
return relate;
}
protected initValue(): void {
if (!this.field.valueObject) {
return;
}
if (!this.field.valueObject.id) {
return;
}
this.selectedValues = [];
this.selectedValues.push(this.field.valueObject);
}
}

View file

@ -23,7 +23,7 @@ import {EmailListFieldsComponent} from '@fields/email/templates/list/email.compo
import {TextDetailFieldComponent} from '@fields/text/templates/detail/text.component';
import {TextDetailFieldModule} from '@fields/text/templates/detail/text.module';
import {RelateDetailFieldsModule} from '@fields/relate/templates/detail/relate.module';
import {RelateDetailFieldsComponent} from '@fields/relate/templates/detail/relate.component';
import {RelateDetailFieldComponent} from '@fields/relate/templates/detail/relate.component';
import {FullNameDetailFieldsComponent} from '@fields/fullname/templates/detail/fullname.component';
import {FullNameDetailFieldsModule} from '@fields/fullname/templates/detail/fullname.module';
import {DateEditFieldComponent} from '@fields/date/templates/edit/date.component';
@ -50,6 +50,8 @@ import {MultiEnumFilterFieldModule} from '@fields/multienum/templates/filter/mul
import {MultiEnumFilterFieldComponent} from '@fields/multienum/templates/filter/multienum.component';
import {BooleanFilterFieldModule} from '@fields/boolean/templates/filter/boolean.module';
import {BooleanFilterFieldComponent} from '@fields/boolean/templates/filter/boolean.component';
import {RelateEditFieldModule} from '@fields/relate/templates/edit/relate.module';
import {RelateEditFieldComponent} from '@fields/relate/templates/edit/relate.component';
export const fieldModules = [
VarcharDetailFieldModule,
@ -67,6 +69,7 @@ export const fieldModules = [
EmailListFieldsModule,
TextDetailFieldModule,
RelateDetailFieldsModule,
RelateEditFieldModule,
FullNameDetailFieldsModule,
EnumDetailFieldModule,
EnumEditFieldModule,
@ -94,7 +97,8 @@ export const fieldComponents = [
CurrencyDetailFieldComponent,
EmailListFieldsComponent,
TextDetailFieldComponent,
RelateDetailFieldsComponent,
RelateDetailFieldComponent,
RelateEditFieldComponent,
FullNameDetailFieldsComponent,
EnumDetailFieldComponent,
EnumEditFieldComponent,
@ -136,8 +140,9 @@ export const viewFieldsMap = {
'email.list': EmailListFieldsComponent,
'email.detail': EmailListFieldsComponent,
'text.detail': TextDetailFieldComponent,
'relate.detail': RelateDetailFieldsComponent,
'relate.list': RelateDetailFieldsComponent,
'relate.detail': RelateDetailFieldComponent,
'relate.list': RelateDetailFieldComponent,
'relate.edit': RelateEditFieldComponent,
'fullname.list': FullNameDetailFieldsComponent,
'fullname.detail': FullNameDetailFieldsComponent,
'enum.list': EnumDetailFieldComponent,

View file

@ -1,5 +1,5 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {RelateDetailFieldsComponent} from './relate.component';
import {RelateDetailFieldComponent} from './relate.component';
import {Component} from '@angular/core';
import {Field} from '@app-common/record/field.model';
import {Record} from '@app-common/record/record.model';
@ -48,7 +48,7 @@ describe('RelateRecordFieldsComponent', () => {
TestBed.configureTestingModule({
declarations: [
RelateDetailFieldTestHostComponent,
RelateDetailFieldsComponent,
RelateDetailFieldComponent,
],
imports: [RouterTestingModule],
providers: [

View file

@ -7,7 +7,7 @@ import {DataTypeFormatter} from '@services/formatters/data-type.formatter.servic
templateUrl: './relate.component.html',
styleUrls: []
})
export class RelateDetailFieldsComponent extends BaseFieldComponent {
export class RelateDetailFieldComponent extends BaseFieldComponent {
constructor(protected typeFormatter: DataTypeFormatter) {
super(typeFormatter);

View file

@ -1,14 +1,14 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {RelateDetailFieldsComponent} from './relate.component';
import {RelateDetailFieldComponent} from './relate.component';
@NgModule({
declarations: [RelateDetailFieldsComponent],
exports: [RelateDetailFieldsComponent],
declarations: [RelateDetailFieldComponent],
exports: [RelateDetailFieldComponent],
imports: [
CommonModule,
AppManagerModule.forChild(RelateDetailFieldsComponent)
AppManagerModule.forChild(RelateDetailFieldComponent)
]
})
export class RelateDetailFieldsModule {

View file

@ -0,0 +1,23 @@
<tag-input #tag
(onAdd)="onAdd($event)"
(onBlur)="resetStatus()"
(onRemove)="onRemove()"
[(ngModel)]="selectedValues"
[class]="getInvalidClass()"
[clearOnBlur]="true"
[displayBy]="getRelateFieldName()"
[inputClass]="getInvalidClass()"
[onTextChangeDebounce]="500"
[onlyFromAutocomplete]="true"
[placeholder]="getPlaceholderLabel()"
[secondaryPlaceholder]="getPlaceholderLabel()"
maxItems="1">
<tag-input-dropdown [autocompleteObservable]="search"
[displayBy]="getRelateFieldName()"
[keepOpen]="false"
[showDropdownIfEmpty]="true">
</tag-input-dropdown>
</tag-input>
<small *ngIf="getMessage()" class="text-danger form-text text-muted">
<scrm-label [labelKey]="getMessage()" [module]="module"></scrm-label>
</small>

View file

@ -0,0 +1,175 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Component} from '@angular/core';
import {Field} from '@app-common/record/field.model';
import {Record} from '@app-common/record/record.model';
import {RouterTestingModule} from '@angular/router/testing';
import {UserPreferenceStore} from '@store/user-preference/user-preference.store';
import {userPreferenceStoreMock} from '@store/user-preference/user-preference.store.spec.mock';
import {NumberFormatter} from '@services/formatters/number/number-formatter.service';
import {numberFormatterMock} from '@services/formatters/number/number-formatter.spec.mock';
import {DatetimeFormatter} from '@services/formatters/datetime/datetime-formatter.service';
import {datetimeFormatterMock} from '@services/formatters/datetime/datetime-formatter.service.spec.mock';
import {DateFormatter} from '@services/formatters/datetime/date-formatter.service';
import {dateFormatterMock} from '@services/formatters/datetime/date-formatter.service.spec.mock';
import {CurrencyFormatter} from '@services/formatters/currency/currency-formatter.service';
import {currencyFormatterMock} from '@services/formatters/currency/currency-formatter.service.spec.mock';
import {LanguageStore} from '@store/language/language.store';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {RecordListStoreFactory} from '@store/record-list/record-list.store.factory';
import {listStoreFactoryMock} from '@store/record-list/record-list.store.spec.mock';
import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
import {RelateEditFieldModule} from '@fields/relate/templates/edit/relate.module';
import {take} from 'rxjs/operators';
import {interval} from 'rxjs';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
import {moduleNameMapperMock} from '@services/navigation/module-name-mapper/module-name-mapper.service.spec.mock';
export const waitUntil = async (untilTruthy: Function): Promise<boolean> => {
while (!untilTruthy()) {
await interval(25).pipe(take(1)).toPromise();
}
// eslint-disable-next-line compat/compat
return Promise.resolve(true);
};
@Component({
selector: 'relate-edit-field-test-host-component',
template: '<scrm-relate-edit [field]="field" [record]="record"></scrm-relate-edit>'
})
class RelateEditFieldTestHostComponent {
field: Field = {
type: 'relate',
value: 'Related Account',
valueObject: {
id: '123',
name: 'Related Account',
},
definition: {
module: 'accounts',
// eslint-disable-next-line camelcase, @typescript-eslint/camelcase
id_name: 'account_id',
rname: 'name'
}
};
record: Record = {
type: '',
module: 'contacts',
attributes: {
// eslint-disable-next-line camelcase, @typescript-eslint/camelcase
contact_id: '1'
}
};
}
describe('RelateRecordEditFieldComponent', () => {
let testHostComponent: RelateEditFieldTestHostComponent;
let testHostFixture: ComponentFixture<RelateEditFieldTestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
RelateEditFieldTestHostComponent,
],
imports: [
RouterTestingModule,
RelateEditFieldModule,
BrowserDynamicTestingModule,
NoopAnimationsModule
],
providers: [
{provide: UserPreferenceStore, useValue: userPreferenceStoreMock},
{provide: NumberFormatter, useValue: numberFormatterMock},
{provide: DatetimeFormatter, useValue: datetimeFormatterMock},
{provide: DateFormatter, useValue: dateFormatterMock},
{provide: CurrencyFormatter, useValue: currencyFormatterMock},
{provide: LanguageStore, useValue: languageStoreMock},
{provide: RecordListStoreFactory, useValue: listStoreFactoryMock},
{provide: ModuleNameMapper, useValue: moduleNameMapperMock},
],
})
.compileComponents();
testHostFixture = TestBed.createComponent(RelateEditFieldTestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
}));
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have value', async (done) => {
expect(testHostComponent).toBeTruthy();
testHostFixture.detectChanges();
const field = testHostFixture.nativeElement.getElementsByTagName('scrm-relate-edit')[0];
await waitUntil(() => field.getElementsByTagName('tag-input').item(0));
const tagInput = field.getElementsByTagName('tag-input').item(0);
expect(field).toBeTruthy();
expect(tagInput).toBeTruthy();
const tag = tagInput.getElementsByTagName('tag').item(0);
expect(tag).toBeTruthy();
const tagText = tag.getElementsByClassName('tag__text').item(0);
expect(tagText.textContent).toContain('Related Account');
const deleteIcon = tagInput.getElementsByTagName('delete-icon').item(0);
expect(deleteIcon).toBeTruthy();
done();
});
it('should remove value', async (done) => {
expect(testHostComponent).toBeTruthy();
testHostFixture.detectChanges();
const field = testHostFixture.nativeElement.getElementsByTagName('scrm-relate-edit')[0];
await testHostFixture.whenStable();
await waitUntil(() => field.getElementsByTagName('tag-input').item(0));
const tagInput = field.getElementsByTagName('tag-input').item(0);
expect(field).toBeTruthy();
expect(tagInput).toBeTruthy();
let tag = tagInput.getElementsByTagName('tag').item(0);
expect(tag).toBeTruthy();
const tagText = tag.getElementsByClassName('tag__text').item(0);
expect(tagText.textContent).toContain('Related Account');
const deleteIcon = tagInput.getElementsByTagName('delete-icon').item(0);
expect(deleteIcon).toBeTruthy();
deleteIcon.click();
testHostFixture.detectChanges();
await waitUntil(() => !(tagInput.getElementsByTagName('tag').item(0)));
tag = tagInput.getElementsByTagName('tag').item(0);
expect(tag).toBeFalsy();
done();
});
});

View file

@ -0,0 +1,60 @@
import {Component, ViewChild} from '@angular/core';
import {DataTypeFormatter} from '@services/formatters/data-type.formatter.service';
import {LanguageStore} from '@store/language/language.store';
import {TagInputComponent} from 'ngx-chips';
import {RelateService} from '@services/record/relate/relate.service';
import {BaseRelateComponent} from '@fields/base/base-relate.component';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
@Component({
selector: 'scrm-relate-edit',
templateUrl: './relate.component.html',
styleUrls: [],
providers: [RelateService]
})
export class RelateEditFieldComponent extends BaseRelateComponent {
@ViewChild('tag') tag: TagInputComponent;
constructor(
protected languages: LanguageStore,
protected typeFormatter: DataTypeFormatter,
protected relateService: RelateService,
protected moduleNameMapper: ModuleNameMapper
) {
super(languages, typeFormatter, relateService, moduleNameMapper);
}
ngOnInit(): void {
super.ngOnInit();
}
onAdd(item): void {
if (item) {
const relateName = this.getRelateFieldName();
this.setValue(item.id, item[relateName]);
return;
}
this.setValue('', '');
this.selectedValues = [];
return;
}
onRemove(): void {
this.setValue('', '');
this.selectedValues = [];
setTimeout(() => {
this.tag.focus(true, true);
}, 200);
}
protected setValue(id: string, relateValue: string): void {
const relate = this.buildRelate(id, relateValue);
this.field.value = relateValue;
this.field.valueObject = relate;
this.field.formControl.setValue(relateValue);
}
}

View file

@ -0,0 +1,23 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {RelateEditFieldComponent} from './relate.component';
import {TagInputModule} from 'ngx-chips';
import {LabelModule} from '@components/label/label.module';
import {FormsModule} from '@angular/forms';
import {InlineLoadingSpinnerModule} from '@components/inline-loading-spinner/inline-loading-spinner.module';
@NgModule({
declarations: [RelateEditFieldComponent],
exports: [RelateEditFieldComponent],
imports: [
CommonModule,
AppManagerModule.forChild(RelateEditFieldComponent),
TagInputModule,
LabelModule,
FormsModule,
InlineLoadingSpinnerModule
]
})
export class RelateEditFieldModule {
}

View file

@ -39,6 +39,7 @@ export interface FieldDefinition {
inline_edit?: boolean;
validation?: ValidationDefinition;
validations?: ValidationDefinition[];
template?: string;
}
export interface FieldMetadata {
@ -59,6 +60,7 @@ export interface Field {
type: string;
value?: string;
valueList?: string[];
valueObject?: any;
name?: string;
label?: string;
labelKey?: string;

View file

@ -28,19 +28,19 @@ export class FieldManager {
public buildField(record: Record, viewField: ViewFieldDefinition, language: LanguageStore = null): Field {
const definition = (viewField && viewField.fieldDefinition) || {} as FieldDefinition;
const {value, valueList} = this.parseValue(viewField, definition, record);
const {value, valueList, valueObject} = this.parseValue(viewField, definition, record);
const {validators, asyncValidators} = this.getValidators(record, viewField);
return this.setupField(viewField, value, valueList, record, definition, validators, asyncValidators, language);
return this.setupField(viewField, value, valueList, valueObject, record, definition, validators, asyncValidators, language);
}
public buildFilterField(record: Record, viewField: ViewFieldDefinition, language: LanguageStore = null): Field {
const definition = (viewField && viewField.fieldDefinition) || {} as FieldDefinition;
const {value, valueList} = this.parseValue(viewField, definition, record);
const {value, valueList, valueObject} = this.parseValue(viewField, definition, record);
const {validators, asyncValidators} = this.getFilterValidators(record, viewField);
return this.setupField(viewField, value, valueList, record, definition, validators, asyncValidators, language);
return this.setupField(viewField, value, valueList, valueObject, record, definition, validators, asyncValidators, language);
}
public getFieldLabel(label: string, module: string, language: LanguageStore): string {
@ -52,7 +52,7 @@ export class FieldManager {
viewField: ViewFieldDefinition,
definition: FieldDefinition,
record: Record
): { value: string; valueList: string[] } {
): { value: string; valueList: string[]; valueObject?: any } {
const type = (viewField && viewField.type) || '';
const source = (definition && definition.source) || '';
@ -65,6 +65,8 @@ export class FieldManager {
value = '';
} else if (type === 'relate' && source === 'non-db' && rname !== '') {
value = record.attributes[viewName][rname];
const valueObject = record.attributes[viewName];
return {value, valueList, valueObject};
} else {
value = record.attributes[viewName];
}
@ -102,6 +104,7 @@ export class FieldManager {
viewField: ViewFieldDefinition,
value: string,
valueList: string[],
valueObject: any,
record: Record,
definition: FieldDefinition,
validators: ValidatorFn[],
@ -128,6 +131,10 @@ export class FieldManager {
field.valueList = valueList;
}
if (valueObject) {
field.valueObject = valueObject;
}
if (language) {
field.label = this.getFieldLabel(viewField.label, record.module, language);
}

View file

@ -0,0 +1,37 @@
import {Injectable} from '@angular/core';
import {RecordListStoreFactory} from '@store/record-list/record-list.store.factory';
import {RecordListStore} from '@store/record-list/record-list.store';
import {map, shareReplay, take} from 'rxjs/operators';
import {Record} from '@app-common/record/record.model';
import {Observable} from 'rxjs';
@Injectable()
export class RelateService {
recordList: RecordListStore;
constructor(recordListStoreFactory: RecordListStoreFactory) {
this.recordList = recordListStoreFactory.create();
}
init(module: string): void {
this.recordList.init(module, false);
}
search(term: string, field: string): Observable<Record[]> {
const criteria = this.recordList.criteria;
criteria.filters[field] = {
field,
operator: '=',
values: [term]
};
this.recordList.updateSearchCriteria(criteria, false);
return this.recordList.load(false).pipe(
map(value => value.records),
take(1),
shareReplay(1)
);
}
}

View file

@ -164,6 +164,18 @@ export class RecordListStore implements StateStore, DataSource<Record>, Selectio
return this.internalState.records;
}
getRecord(id: string): Record {
let record: Record = null;
this.records.some(item => {
if (item.id === id) {
record = item;
return true;
}
});
return record;
}
/**
* Clean destroy
*/

View file

@ -88,7 +88,9 @@ export class RecordManager {
}
if (type === 'relate' && source === 'non-db' && rname !== '') {
this.stagingState.attributes[fieldName][rname] = field.value;
this.stagingState.attributes[fieldName][rname] = field.valueObject[rname];
this.stagingState.attributes[fieldName].id = field.valueObject.id;
this.stagingState.attributes[idName] = field.valueObject.id;
return;
}