Add searchdefs to metadata store

- Refactor listview store into metadata store to keep all defs
-- Update the cache to be able to store metadata per module
-- Update the cache to be able to sync requested metadata with existing one
-- Update fetch methods to be able to fetch only requested definitions
- Add search defs to the metadata store
- Update karma/jasmine tests
This commit is contained in:
Clemente Raposo 2020-06-16 11:47:24 +01:00 committed by Dillon-Brown
parent b62d1907aa
commit 34ff62476b
12 changed files with 556 additions and 182 deletions

View file

@ -15,6 +15,8 @@ import {ListViewStore} from '@store/list-view/list-view.store';
import {listviewStoreMock} from '@store/list-view/list-view.store.spec.mock';
import {LanguageStore} from '@store/language/language.store';
import {languageMockData} from '@store/language/language.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {metadataMockData} from '@store/metadata/metadata.store.spec.mock';
describe('ListcontainerUiComponent', () => {
@ -47,6 +49,13 @@ describe('ListcontainerUiComponent', () => {
appStrings$: of(languageMockData.appStrings).pipe(take(1))
}
},
{
provide: MetadataStore, useValue: {
listMetadata$: of({
fields: metadataMockData.listView
}).pipe(take(1)),
}
},
],
declarations: [ListcontainerUiComponent]
})

View file

@ -15,7 +15,7 @@
</td>
</ng-container>
<ng-container *ngFor="let column of vm.metadata.fields" [cdkColumnDef]="column.fieldName">
<ng-container *ngFor="let column of vm.listMetadata.fields" [cdkColumnDef]="column.fieldName">
<th cdk-header-cell scope="col" class="primary-table-header"
*cdkHeaderCellDef>{{
vm.language.appStrings[column.label] || column.label}}

View file

@ -1,13 +1,19 @@
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {TableBodyComponent} from './table-body.component';
import {CdkTableModule} from '@angular/cdk/table';
import {ApolloTestingModule} from 'apollo-angular/testing';
import {Component} from '@angular/core';
import {ListViewStore} from '@store/list-view/list-view.store';
import {listviewStoreMock} from '@store/list-view/list-view.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {metadataMockData} from '@store/metadata/metadata.store.spec.mock';
import {LanguageStore} from '@store/language/language.store';
import {languageMockData} from '@store/language/language.store.spec.mock';
import {of} from 'rxjs';
import {take} from 'rxjs/operators';
@Component({
selector: 'tabke-body-ui-test-host-component',
selector: 'table-body-ui-test-host-component',
template: '<scrm-table-body [module]="module"></scrm-table-body>'
})
class TableBodyUITestHostComponent {
@ -26,29 +32,46 @@ describe('TablebodyUiComponent', () => {
],
declarations: [TableBodyComponent, TableBodyUITestHostComponent],
providers: [
{provide: ListViewStore, useValue: listviewStoreMock},
{
provide: ListViewStore, useValue: listviewStoreMock
provide: MetadataStore, useValue: {
listMetadata$: of({
fields: metadataMockData.listView
}).pipe(take(1)),
}
},
{
provide: LanguageStore, useValue: {
vm$: of(languageMockData).pipe(take(1)),
appListStrings$: of(languageMockData.appListStrings).pipe(take(1)),
appStrings$: of(languageMockData.appStrings).pipe(take(1))
}
},
],
})
.compileComponents();
});
beforeEach(() => {
beforeEach(async(() => {
testHostFixture = TestBed.createComponent(TableBodyUITestHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
});
}));
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should create', async(() => {
testHostFixture.whenStable().then(() => {
expect(testHostComponent).toBeTruthy();
});
}));
it('should have table body', () => {
const tableBodyElement = testHostFixture.nativeElement.querySelector('table');
it('should have table body', async(() => {
expect(testHostComponent).toBeTruthy();
expect(tableBodyElement).toBeTruthy();
expect(tableBodyElement.outerHTML).toContain('aria-describedby="table-body"');
});
testHostFixture.whenStable().then(() => {
const tableBodyElement = testHostFixture.nativeElement.querySelector('table');
expect(testHostComponent).toBeTruthy();
expect(tableBodyElement).toBeTruthy();
expect(tableBodyElement.outerHTML).toContain('aria-describedby="table-body"');
});
}));
});

View file

@ -1,7 +1,7 @@
import {Component, Input} from '@angular/core';
import {combineLatest, Observable} from 'rxjs';
import {LanguageStore, LanguageStrings} from '@store/language/language.store';
import {ListViewMeta, ListViewMetaStore} from '@store/list-view-meta/list-view-meta.store';
import {ListViewMeta, MetadataStore} from '@store/metadata/metadata.store.service';
import {map} from 'rxjs/operators';
import {ListViewStore, RecordSelection} from '@store/list-view/list-view.store';
import {SelectionStatus} from '@components/bulk-action-menu/bulk-action-menu.component';
@ -13,31 +13,31 @@ import {SelectionStatus} from '@components/bulk-action-menu/bulk-action-menu.com
export class TableBodyComponent {
@Input() module;
language$: Observable<LanguageStrings> = this.language.vm$;
metadata$: Observable<ListViewMeta> = this.metadata.vm$;
listMetadata$: Observable<ListViewMeta> = this.metadata.listMetadata$;
selection$: Observable<RecordSelection> = this.data.selection$;
dataSource$: ListViewStore = this.data;
vm$ = combineLatest([
this.language$,
this.metadata$,
this.listMetadata$,
this.selection$
]).pipe(
map((
[
language,
metadata,
listMetadata,
selection
]
) => {
const displayedColumns: string[] = ['checkbox'];
metadata.fields.forEach((field) => {
listMetadata.fields.forEach((field) => {
displayedColumns.push(field.fieldName);
});
return {
language,
metadata,
listMetadata,
selected: selection.selected,
selectionStatus: selection.status,
displayedColumns
@ -47,7 +47,7 @@ export class TableBodyComponent {
constructor(
protected language: LanguageStore,
protected metadata: ListViewMetaStore,
protected metadata: MetadataStore,
protected data: ListViewStore
) {
}

View file

@ -14,6 +14,8 @@ import {themeImagesMockData} from '@store/theme-images/theme-images.store.spec.m
import {take} from 'rxjs/operators';
import {ListViewStore} from '@store/list-view/list-view.store';
import {listviewStoreMock} from '@store/list-view/list-view.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {metadataMockData} from '@store/metadata/metadata.store.spec.mock';
describe('TableUiComponent', () => {
let component: TableUiComponent;
@ -40,6 +42,13 @@ describe('TableUiComponent', () => {
images$: of(themeImagesMockData).pipe(take(1))
}
},
{
provide: MetadataStore, useValue: {
listMetadata$: of({
fields: metadataMockData.listView
}).pipe(take(1)),
}
},
],
})
.compileComponents();

View file

@ -8,7 +8,7 @@ import {NavigationStore} from '@base/store/navigation/navigation.store';
import {UserPreferenceStore} from '@base/store/user-preference/user-preference.store';
import {ThemeImagesStore} from '@base/store/theme-images/theme-images.store';
import {AppStateStore} from '@base/store/app-state/app-state.store';
import {ListViewMetaStore} from '@store/list-view-meta/list-view-meta.store';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {BaseModuleResolver} from '@services/metadata/base-module.resolver';
import {forkJoin, Observable} from 'rxjs';
@ -19,7 +19,7 @@ export class BaseListResolver extends BaseModuleResolver {
protected systemConfigStore: SystemConfigStore,
protected languageStore: LanguageStore,
protected navigationStore: NavigationStore,
protected listViewMetaStore: ListViewMetaStore,
protected metadataStore: MetadataStore,
protected userPreferenceStore: UserPreferenceStore,
protected themeImagesStore: ThemeImagesStore,
protected moduleNameMapper: ModuleNameMapper,
@ -41,7 +41,7 @@ export class BaseListResolver extends BaseModuleResolver {
resolve(route: ActivatedRouteSnapshot): Observable<any> {
return forkJoin({
base: super.resolve(route),
metadata: this.listViewMetaStore.load(route.params.module),
metadata: this.metadataStore.load(route.params.module, this.metadataStore.getMetadataTypes()),
});
}
}

View file

@ -1,151 +0,0 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable} from 'rxjs';
import {distinctUntilChanged, map, shareReplay, tap} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {deepClone} from '@base/utils/object-utils';
import {StateStore} from '@base/store/state';
import {AppStateStore} from '@store/app-state/app-state.store';
export interface ListViewMeta {
fields: Field[];
}
export interface Field {
fieldName: string;
width: string;
label: string;
link: boolean;
default: boolean;
module: string;
id: string;
sortable: boolean;
}
const initialState: ListViewMeta = {
fields: []
};
let internalState: ListViewMeta = deepClone(initialState);
let cache$: Observable<any> = null;
let loadedModule: string;
@Injectable({
providedIn: 'root',
})
export class ListViewMetaStore implements StateStore {
protected store = new BehaviorSubject<ListViewMeta>(internalState);
protected state$ = this.store.asObservable();
protected resourceName = 'viewDefinition';
protected fieldsMetadata = {
fields: [
'id',
'_id',
'listView'
]
};
/**
* Public long-lived observable streams
*/
fields$ = this.state$.pipe(map(state => state.fields), distinctUntilChanged());
/**
* ViewModel that resolves once all the data is ready (or updated)...
*/
vm$: Observable<ListViewMeta> = combineLatest(
[
this.fields$,
])
.pipe(
map((
[
fields,
]) => ({fields})
)
);
constructor(protected recordGQL: RecordGQL, protected appState: AppStateStore) {
}
/**
* Clear state
*/
public clear(): void {
loadedModule = '';
cache$ = null;
this.updateState(deepClone(initialState));
}
/**
* Initial ListViewMeta load if not cached and update state.
*
* @param {string} module to fetch
*/
public load(moduleID: string): any {
return this.getListViewMeta(moduleID).pipe(
tap(listViewMeta => {
loadedModule = moduleID;
this.updateState({
...internalState,
fields: listViewMeta.fields,
});
})
)
}
/**
* Update the state
*
* @param {{}} state to set
*/
protected updateState(state: ListViewMeta): void {
this.store.next(internalState = state);
}
/**
* Get ListViewMeta cached Observable or call the backend
*
* @param {string} module to fetch
* @returns {{}} Observable<any>
*/
protected getListViewMeta(moduleID: string): Observable<any> {
if (cache$ == null || loadedModule != moduleID) {
cache$ = this.fetchViewDef(moduleID).pipe(
shareReplay(1)
);
}
return cache$;
}
/**
* Fetch the ListViewMeta from the backend
*
* @param {string} module to fetch
* @returns {{}} Observable<{}>
*/
protected fetchViewDef(moduleID: string): Observable<{}> {
return this.recordGQL.fetch(this.resourceName, `/api/metadata/view-definitions/${moduleID}`, this.fieldsMetadata)
.pipe(
map(({data}) => {
let listViewMeta: ListViewMeta = {
fields: []
};
if (data && data.viewDefinition.listView) {
data.viewDefinition.listView.forEach((field: Field) => {
listViewMeta.fields.push(
field
)
});
}
return listViewMeta;
})
);
}
}

View file

@ -0,0 +1,243 @@
import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {distinctUntilChanged, map, shareReplay, tap} from 'rxjs/operators';
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {deepClone} from '@base/utils/object-utils';
import {StateStore} from '@base/store/state';
import {AppStateStore} from '@store/app-state/app-state.store';
export interface ListViewMeta {
fields: Field[];
}
export interface Field {
fieldName: string;
width: string;
label: string;
link: boolean;
default: boolean;
module: string;
id: string;
sortable: boolean;
}
export interface SearchMetaField {
name?: string;
type?: string;
label?: string;
default?: boolean;
options?: string;
}
export interface SearchMeta {
layout: {
basic?: { [key: string]: SearchMetaField };
advanced?: { [key: string]: SearchMetaField };
};
}
export interface Metadata {
detailView?: any;
editView?: any;
listView?: ListViewMeta;
search?: SearchMeta;
}
const initialState: Metadata = {
detailView: {},
editView: {},
listView: {} as ListViewMeta,
search: {} as SearchMeta
};
let internalState: Metadata = deepClone(initialState);
export interface MetadataCache {
[key: string]: BehaviorSubject<Metadata>;
}
const initialCache: MetadataCache = {} as MetadataCache;
let cache: MetadataCache = deepClone(initialCache);
@Injectable({
providedIn: 'root',
})
export class MetadataStore implements StateStore {
/**
* Public long-lived observable streams
*/
fields$: Observable<Field[]>;
listMetadata$: Observable<ListViewMeta>;
protected store = new BehaviorSubject<Metadata>(internalState);
protected state$ = this.store.asObservable();
protected resourceName = 'viewDefinition';
protected fieldsMetadata = {
fields: [
'id',
'_id',
]
};
protected types = [
'listView',
'search'
];
constructor(protected recordGQL: RecordGQL, protected appState: AppStateStore) {
this.fields$ = this.state$.pipe(map(state => state.listView.fields), distinctUntilChanged());
this.listMetadata$ = this.state$.pipe(map(state => state.listView), distinctUntilChanged());
}
/**
* Clear state
*/
public clear(): void {
Object.keys(cache).forEach(key => {
cache[key].unsubscribe();
});
cache = deepClone(initialCache);
this.updateState(deepClone(initialState));
}
/**
* Get all metadata types
*
* @returns {string[]} metadata types
*/
public getMetadataTypes(): string[] {
return this.types;
}
/**
* Initial ListViewMeta load if not cached and update state.
*
* @param {string} moduleID to fetch
* @param {string[]} types to fetch
* @returns {any} data
*/
public load(moduleID: string, types: string[]): any {
if (!types) {
types = this.getMetadataTypes();
}
return this.getMetadata(moduleID, types).pipe(
tap((metadata: Metadata) => {
this.updateState(metadata);
})
);
}
/**
* Update the state
*
* @param {object} state to set
*/
protected updateState(state: Metadata): void {
this.store.next(internalState = state);
}
/**
* Get ListViewMeta cached Observable or call the backend
*
* @param {string} module to fetch
* @param {string[]} types to retrieve
* @returns {object} Observable<any>
*/
protected getMetadata(module: string, types: string[]): Observable<Metadata> {
let metadataCache: BehaviorSubject<Metadata> = null;
// check for currently missing and
const missing = {};
const loadedTypes = {};
if (cache[module]) {
metadataCache = cache[module];
types.forEach(type => {
const cached = metadataCache.value;
if (!cached[type]) {
missing[type] = type;
return;
}
if (Object.keys(cached[type]).length === 0) {
missing[type] = type;
} else {
loadedTypes[type] = cached[type];
}
});
if (Object.keys(missing).length === 0) {
return metadataCache.asObservable();
}
} else {
cache[module] = new BehaviorSubject({} as Metadata);
}
return this.fetchMetadata(module, types).pipe(
map((value: Metadata) => {
Object.keys(loadedTypes).forEach((type) => {
value[type] = loadedTypes[type];
});
return value;
}),
shareReplay(1),
tap((value: Metadata) => {
cache[module].next(value);
})
);
}
/**
* Fetch the Metadata from the backend
*
* @param {string} module to fetch
* @param {string[]} types to retrieve
* @returns {object} Observable<{}>
*/
protected fetchMetadata(module: string, types: string[]): Observable<Metadata> {
const fieldsToRetrieve = {
fields: [
...this.fieldsMetadata.fields,
...types
]
};
return this.recordGQL.fetch(this.resourceName, `/api/metadata/view-definitions/${module}`, fieldsToRetrieve)
.pipe(
map(({data}) => {
const metadata: Metadata = {} as Metadata;
if (data && data.viewDefinition.listView) {
const listViewMeta: ListViewMeta = {
fields: []
};
data.viewDefinition.listView.forEach((field: Field) => {
listViewMeta.fields.push(
field
);
});
metadata.listView = listViewMeta;
}
if (data && data.viewDefinition.search) {
metadata.search = data.viewDefinition.search;
}
return metadata;
})
);
}
}

View file

@ -0,0 +1,212 @@
import {RecordGQL} from '@services/api/graphql-api/api.record.get';
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
import {appStateStoreMock} from '@store/app-state/app-state.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
/* eslint-disable camelcase, @typescript-eslint/camelcase */
export const metadataMockData = {
search: {
layout: {
basic: {
name: {
name: 'name',
default: true,
width: '10%'
},
current_user_only: {
name: 'current_user_only',
label: 'LBL_CURRENT_USER_FILTER',
type: 'bool',
default: true,
width: '10%'
},
favorites_only: {
name: 'favorites_only',
label: 'LBL_FAVORITES_FILTER',
type: 'bool'
}
},
advanced: {
name: {
name: 'name',
default: true,
width: '10%'
},
website: {
name: 'website',
default: true,
width: '10%'
},
phone: {
name: 'phone',
label: 'LBL_ANY_PHONE',
type: 'name',
default: true,
width: '10%'
},
email: {
name: 'email',
label: 'LBL_ANY_EMAIL',
type: 'name',
default: true,
width: '10%'
},
address_street: {
name: 'address_street',
label: 'LBL_ANY_ADDRESS',
type: 'name',
default: true,
width: '10%'
},
address_city: {
name: 'address_city',
label: 'LBL_CITY',
type: 'name',
default: true,
width: '10%'
},
address_state: {
name: 'address_state',
label: 'LBL_STATE',
type: 'name',
default: true,
width: '10%'
},
address_postalcode: {
name: 'address_postalcode',
label: 'LBL_POSTAL_CODE',
type: 'name',
default: true,
width: '10%'
},
billing_address_country: {
name: 'billing_address_country',
label: 'LBL_COUNTRY',
type: 'name',
options: 'countries_dom',
default: true,
width: '10%'
},
account_type: {
name: 'account_type',
default: true,
width: '10%'
},
industry: {
name: 'industry',
default: true,
width: '10%'
},
assigned_user_id: {
name: 'assigned_user_id',
type: 'enum',
label: 'LBL_ASSIGNED_TO',
function: {
name: 'get_user_array',
params: [
false
]
},
default: true,
width: '10%'
}
}
}
},
listView: [
{
fieldName: 'name',
width: '20%',
label: 'LBL_LIST_ACCOUNT_NAME',
link: true,
default: true,
module: '',
id: '',
sortable: false
},
{
fieldName: 'billing_address_city',
width: '10%',
label: 'LBL_LIST_CITY',
link: false,
default: true,
module: '',
id: '',
sortable: false
},
{
fieldName: 'billing_address_country',
width: '10%',
label: 'LBL_BILLING_ADDRESS_COUNTRY',
link: false,
default: true,
module: '',
id: '',
sortable: false
},
{
fieldName: 'phone_office',
width: '10%',
label: 'LBL_LIST_PHONE',
link: false,
default: true,
module: '',
id: '',
sortable: false
},
{
fieldName: 'assigned_user_name',
width: '10%',
label: 'LBL_LIST_ASSIGNED_USER',
link: false,
default: true,
module: 'Employees',
id: 'ASSIGNED_USER_ID',
sortable: false
},
{
fieldName: 'email1',
width: '15%',
label: 'LBL_EMAIL_ADDRESS',
link: true,
default: true,
module: '',
id: '',
sortable: false,
customCode: '{$EMAIL1_LINK}'
},
{
fieldName: 'date_entered',
width: '5%',
label: 'LBL_DATE_ENTERED',
link: false,
default: true,
module: '',
id: '',
sortable: false
}
]
};
/* eslint-enable camelcase, @typescript-eslint/camelcase */
class MetadataRecordGQLSpy extends RecordGQL {
constructor() {
super(null);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public fetch(module: string, id: string, metadata: { fields: string[] }): Observable<any> {
return of({
data: {
viewDefinition: metadataMockData
}
}).pipe(shareReplay(1));
}
}
export const metadataStoreMock = new MetadataStore(new MetadataRecordGQLSpy(), appStateStoreMock);

View file

@ -0,0 +1,20 @@
import {metadataMockData, metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {take} from 'rxjs/operators';
describe('Metadata Store', () => {
const service: MetadataStore = metadataStoreMock;
beforeEach(() => {
});
it('#load', (done: DoneFn) => {
service.load('accounts', metadataStoreMock.getMetadataTypes()).pipe(take(1)).subscribe(data => {
expect(data.listView.fields).toEqual(metadataMockData.listView);
expect(data.search).toEqual(metadataMockData.search);
done();
});
});
});

View file

@ -6,7 +6,7 @@ import {SystemConfigStore} from '@store/system-config/system-config.store';
import {ThemeImagesStore} from '@store/theme-images/theme-images.store';
import {UserPreferenceStore} from '@store/user-preference/user-preference.store';
import {StateStore, StateStoreMap, StateStoreMapEntry} from '@base/store/state';
import {ListViewMetaStore} from '@store/list-view-meta/list-view-meta.store';
import {MetadataStore} from '@store/metadata/metadata.store.service';
@Injectable({
providedIn: 'root',
@ -17,16 +17,16 @@ export class StateManager {
constructor(
protected appStore: AppStateStore,
protected languageStore: LanguageStore,
protected listViewMetaStore: ListViewMetaStore,
protected metadataStore: MetadataStore,
protected navigationStore: NavigationStore,
protected systemConfigStore: SystemConfigStore,
protected themeImagesStore: ThemeImagesStore,
protected userPreferenceStore: UserPreferenceStore
) {
this.stateStores.appStore = this.buildMapEntry(appStore, false);
this.stateStores.languageStore = this.buildMapEntry(languageStore, false)
this.stateStores.listViewMetaStore = this.buildMapEntry(listViewMetaStore, false)
this.stateStores.navigationStore = this.buildMapEntry(navigationStore, true);
this.stateStores.languageStore = this.buildMapEntry(languageStore, false);
this.stateStores.listViewMetaStore = this.buildMapEntry(metadataStore, false);
this.stateStores.systemConfigStore = this.buildMapEntry(systemConfigStore, false);
this.stateStores.themeImagesStore = this.buildMapEntry(themeImagesStore, false);
this.stateStores.userPreferenceStore = this.buildMapEntry(userPreferenceStore, true);

View file

@ -26,6 +26,8 @@ import {listviewStoreMock} from '@store/list-view/list-view.store.spec.mock';
import {systemConfigStoreMock} from '@store/system-config/system-config.store.spec.mock';
import {UserPreferenceStore} from '@store/user-preference/user-preference.store';
import {userPreferenceStoreMock} from '@store/user-preference/user-preference.store.spec.mock';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {metadataMockData} from '@store/metadata/metadata.store.spec.mock';
@Component({
selector: 'list-test-host-component',
@ -75,7 +77,7 @@ describe('ListComponent', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
updateLoading: (key: string, loading: boolean): void => {
}
}
} as AppStateStore
},
{
provide: LanguageStore, useValue: {
@ -94,7 +96,14 @@ describe('ListComponent', () => {
},
{
provide: UserPreferenceStore, useValue: userPreferenceStoreMock
}
},
{
provide: MetadataStore, useValue: {
listMetadata$: of({
fields: metadataMockData.listView
}).pipe(take(1)),
}
},
],
})
.compileComponents();