Add RecordView configurable TopWidgets

- Structure re-factor
-- Move record view components to record view folder
-- Move record view store to record view folder
-- Add containers folder
--- add @containers alias
-- Move Subpanels to containers folder
-- Move subpanel-statistics.store to single-value-statistics.store

- Make administration repair a cache reset action

- Add dynamic top widget component
- Add statistics top widget component
- Add support for recordView defs topWidget configuration

- Adjust and standardize Statistic API structure
-- Add metadata to response.
--- Metadata should drive widgets behavior
-- Change existing StatisticsProviderInterface implementations to send back metadata

- Add SubpanelDataQueryHandler to make custom queries easier
-- Allows to get the query done to fetch subpanel data
-- Allows to run custom queries
- Add WonOpportunityAmountByYear using custom query
- Implement SubpanelOpportunitiesTotal using custom query

- Add karma/jasmine tests
This commit is contained in:
Clemente Raposo 2020-10-27 11:08:18 +00:00 committed by Dillon-Brown
parent 3b0fd86837
commit 139ad660b8
97 changed files with 1192 additions and 204 deletions

View file

@ -16,6 +16,7 @@ module.exports = {
["@fields", "./core/app/fields"],
["@services", "./core/app/src/services"],
["@components", "./core/app/src/components"],
["@containers", "./core/app/src/containers"],
["@store", "./core/app/src/store"]
],
"extensions": [".ts", ".js"]

View file

@ -2,3 +2,5 @@ parameters:
legacy.cache_reset_actions:
users:
- edit
administration:
- repair

View file

@ -0,0 +1,9 @@
export interface WidgetMetadata {
type: string;
labelKey?: string;
options: WidgetOptionMap;
}
export interface WidgetOptionMap {
[key: string]: any;
}

View file

@ -1,3 +1,5 @@
import {ViewContext} from '@app-common/views/view.model';
export interface StatisticsQueryMap {
[key: string]: StatisticsQuery;
}
@ -5,6 +7,7 @@ export interface StatisticsQueryMap {
export interface StatisticsQuery {
key: string;
params: any;
context: ViewContext;
}
export interface StatisticsMap {
@ -14,14 +17,21 @@ export interface StatisticsMap {
export interface Statistic {
id: string;
data: any;
metadata?: StatisticMetadata;
}
export interface SubpanelStatisticsData {
export interface StatisticMetadata {
[key: string]: any;
type: string;
}
export interface SingleValueStatisticsData {
type: string;
value: string;
}
export interface SubpanelStatistic extends Statistic {
export interface SingleValueStatistic extends Statistic {
id: string;
data: SubpanelStatisticsData;
data: SingleValueStatisticsData;
}

View file

@ -1 +1,7 @@
export type ViewMode = 'detail' | 'edit' | 'list';
export interface ViewContext {
module: string;
id?: string;
}

View file

@ -4,7 +4,7 @@ import {Router} from '@angular/router';
import {AuthGuard} from '@services/auth/auth-guard.service';
import {BaseModuleResolver} from '@services/metadata/base-module.resolver';
import {SystemConfigStore} from '@store/system-config/system-config.store';
import {RecordComponent} from '@views/record/record.component';
import {RecordComponent} from '@views/record/components/record-view/record.component';
import {BaseRecordResolver} from '@services/metadata/base-record.resolver';
import {LoginAuthGuard} from '@services/auth/login-auth-guard.service';
import {BaseMetadataResolver} from '@services/metadata/base-metadata.resolver';

View file

@ -22,7 +22,7 @@ import {ModuleTitleModule} from '@components/module-title/module-title.module';
import {ListHeaderModule} from '@components/list-header/list-header.module';
import {ListContainerModule} from '@components/list-container/list-container.module';
import {ListModule} from '@views/list/list.module';
import {RecordModule} from '@views/record/record.module';
import {RecordModule} from '@views/record/components/record-view/record.module';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';

View file

@ -2,8 +2,8 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {HistoryTimelineWidgetComponent} from './history-timeline-widget.component';
import {CollectionViewer, ListRange} from '@angular/cdk/collections';
import {of} from 'rxjs';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {FieldModule} from '@fields/field.module';
import {CommonModule} from '@angular/common';
import {ScrollingModule} from '@angular/cdk/scrolling';

View file

@ -1,5 +1,4 @@
import {Injectable} from '@angular/core';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {CollectionViewer, DataSource} from '@angular/cdk/collections';
import {HistoryTimelineEntry} from '@components/history-timeline-widget/history-timeline-widget.model';
import {BehaviorSubject, Observable, Subscription} from 'rxjs';
@ -230,7 +229,7 @@ export class HistoryTimelineAdapter extends DataSource<HistoryTimelineEntry> {
private fetchedPages = new Set<number>();
private pageSize = 5;
constructor(protected store: RecordViewStore) {
constructor() {
super();
}

View file

@ -8,7 +8,7 @@ import {SettingsMenuModule} from '@components/settings-menu/settings-menu.module
import {ImageModule} from '@components/image/image.module';
import {RouterTestingModule} from '@angular/router/testing';
import {ButtonModule} from '@components/button/button.module';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
@Component({
selector: 'status-bar-test-host-component',

View file

@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {ModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service';
@Component({

View file

@ -1,8 +0,0 @@
import {Observable} from 'rxjs';
import {SubpanelStoreMap} from '@store/subpanel/subpanel.store';
import {RecordViewStore} from '@store/record-view/record-view.store';
export interface SubpanelContainerConfig {
subpanels$: Observable<SubpanelStoreMap>;
recordStore: RecordViewStore;
}

View file

@ -1,23 +0,0 @@
import {Injectable} from '@angular/core';
import { ActionHandlerMap } from '@base/app-common/actions/action.model';
import {RecordActionData} from '@views/record/actions/record.action';
import {SubPanelCreateAction} from './actions/create/create.action';
@Injectable({
providedIn: 'root',
})
export class SubPanelActionManager {
actions: ActionHandlerMap = {
};
constructor(
protected create: SubPanelCreateAction
) {
this.actions[create.key] = create;
}
run(action: string, data: RecordActionData): void {
this.actions[action].run(data);
}
}

View file

@ -1,13 +1,13 @@
import {Injectable} from '@angular/core';
import {ActionData, ActionHandler} from '@app-common/actions/action.model';
import {ModuleNameMapper} from '@services/navigation/module-name-mapper/module-name-mapper.service';
import {Router} from '@angular/router';
import {SubpanelActionData, SubpanelActionHandler} from '@containers/subpanel/actions/subpanel.action';
@Injectable({
providedIn: 'root'
})
export class SubPanelCreateAction extends ActionHandler {
export class SubpanelCreateAction extends SubpanelActionHandler {
key = 'create';
constructor(
@ -17,22 +17,20 @@ export class SubPanelCreateAction extends ActionHandler {
super();
}
run(data: ActionData): void {
const store = data.store;
const action = data.action;
run(data: SubpanelActionData): void {
const moduleName = action.module;
const moduleName = data.subpanelMeta.module;
const route = `/${moduleName}/edit`;
this.router.navigate([route], {
queryParams: {
// eslint-disable-next-line camelcase,@typescript-eslint/camelcase
return_module: this.moduleNameMapper.toLegacy(moduleName),
return_module: this.moduleNameMapper.toLegacy(data.parentModule),
// eslint-disable-next-line camelcase,@typescript-eslint/camelcase
return_action: 'DetailView',
// eslint-disable-next-line camelcase,@typescript-eslint/camelcase
return_record: (store.getRecord() && store.getRecord().id) || ''
return_id: data.parentId || ''
}
}).then();
}

View file

@ -0,0 +1,15 @@
import {ActionData, ActionHandler} from '@app-common/actions/action.model';
export interface SubpanelActionData extends ActionData {
parentId: string;
parentModule: string;
}
export interface SubpanelActionHandlerMap {
[key: string]: SubpanelActionHandler;
}
export abstract class SubpanelActionHandler extends ActionHandler {
abstract run(data: SubpanelActionData): void;
}

View file

@ -4,7 +4,7 @@ import {LanguageStore} from '@store/language/language.store';
import {SortDirection} from '@components/sort-button/sort-button.model';
import {TableConfig} from '@components/table/table.model';
import {ColumnDefinition} from '@app-common/metadata/list.metadata.model';
import {SubpanelStore} from '@store/subpanel/subpanel.store';
import {SubpanelStore} from '@containers/subpanel/store/subpanel/subpanel.store';
import {map} from 'rxjs/operators';
@Injectable()

View file

@ -53,7 +53,6 @@
<scrm-subpanel class="sub-panel"
[store]="item.value"
[recordStore]="config.recordStore"
[maxColumns$]="maxColumns$"
*ngIf="item.value.show">
</scrm-subpanel>

View file

@ -2,8 +2,8 @@ import {Component, Input, OnInit} from '@angular/core';
import {map, take, tap} from 'rxjs/operators';
import {combineLatest, Observable} from 'rxjs';
import {LanguageStore, LanguageStringMap, LanguageStrings} from '@store/language/language.store';
import {SubpanelContainerConfig} from '@components/subpanel-container/subpanel-container.model';
import {SubpanelStore, SubpanelStoreMap} from '@store/subpanel/subpanel.store';
import {SubpanelContainerConfig} from '@containers/subpanel/components/subpanel-container/subpanel-container.model';
import {SubpanelStore, SubpanelStoreMap} from '@containers/subpanel/store/subpanel/subpanel.store';
import {MaxColumnsCalculator} from '@services/ui/max-columns-calculator/max-columns-calculator.service';
interface SubpanelContainerViewModel {
@ -54,7 +54,7 @@ export class SubpanelContainerComponent implements OnInit {
}
getMaxColumns(): Observable<number> {
return this.maxColumnCalculator.getMaxColumns(this.config.recordStore.widgets$);
return this.maxColumnCalculator.getMaxColumns(this.config.sidebarActive$);
}
toggleSubPanels(): void {

View file

@ -0,0 +1,7 @@
import {Observable} from 'rxjs';
import {SubpanelStoreMap} from '@containers/subpanel/store/subpanel/subpanel.store';
export interface SubpanelContainerConfig {
subpanels$: Observable<SubpanelStoreMap>;
sidebarActive$: Observable<boolean>;
}

View file

@ -1,6 +1,6 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '../../app-manager/app-manager.module';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {SubpanelContainerComponent} from './subpanel-container.component';
import {SubpanelModule} from '../subpanel/subpanel.module';
import {ImageModule} from '@components/image/image.module';

View file

@ -0,0 +1,21 @@
import {Injectable} from '@angular/core';
import {SubpanelCreateAction} from '../../actions/create/create.action';
import {SubpanelActionData, SubpanelActionHandlerMap} from '@containers/subpanel/actions/subpanel.action';
@Injectable({
providedIn: 'root',
})
export class SubpanelActionManager {
actions: SubpanelActionHandlerMap = {};
constructor(
protected create: SubpanelCreateAction
) {
this.actions[create.key] = create;
}
run(action: string, data: SubpanelActionData): void {
this.actions[action].run(data);
}
}

View file

@ -5,16 +5,16 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
import {RouterModule} from '@angular/router';
import {RouterTestingModule} from '@angular/router/testing';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {MetadataStore} from '@base/store/metadata/metadata.store.service';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {ImageModule} from '@components/image/image.module';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {LanguageStore} from '@store/language/language.store';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {ApolloTestingModule} from 'apollo-angular/testing';
import {ButtonGroupModule} from '../button-group/button-group.module';
import {PanelModule} from '../panel/panel.module';
import {ButtonGroupModule} from '@components/button-group/button-group.module';
import {PanelModule} from '@components/panel/panel.module';
import {SubpanelComponent} from './subpanel.component';
import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
@ -22,10 +22,9 @@ const store = recordviewStoreMock.getSubpanels().contacts;
@Component({
selector: 'subpanel-test-host-component',
template: '<scrm-subpanel [recordStore]="recordStore" [store]="store"></scrm-subpanel>'
template: '<scrm-subpanel [store]="store"></scrm-subpanel>'
})
class SubpanelComponentTestHostComponent {
recordStore = recordviewStoreMock;
store = store;
}

View file

@ -4,12 +4,11 @@ import {AnyButtonInterface} from '@components/dropdown-button/dropdown-button.mo
import {LanguageStore} from '@store/language/language.store';
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
import {ButtonGroupInterface} from '../button-group/button-group.model';
import {SubPanelActionManager} from './actions-mananger.service';
import {SubpanelTableAdapter} from '@components/subpanel/adapter/table.adapter';
import {SubpanelStore} from '@store/subpanel/subpanel.store';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {ButtonGroupInterface} from '@components/button-group/button-group.model';
import {SubpanelTableAdapter} from '@containers/subpanel/adapters/table.adapter';
import {SubpanelStore} from '@containers/subpanel/store/subpanel/subpanel.store';
import {TableConfig} from '@components/table/table.model';
import {SubpanelActionManager} from '@containers/subpanel/components/subpanel/action-manager.service';
@Component({
selector: 'scrm-subpanel',
@ -21,14 +20,13 @@ import {TableConfig} from '@components/table/table.model';
export class SubpanelComponent implements OnInit {
@Input() store: SubpanelStore;
@Input() maxColumns$: Observable<number>;
@Input() recordStore: RecordViewStore;
adapter: SubpanelTableAdapter;
config$: Observable<ButtonGroupInterface>;
tableConfig: TableConfig;
constructor(
protected actionManager: SubPanelActionManager,
protected actionManager: SubpanelActionManager,
protected languages: LanguageStore,
) {
}
@ -94,7 +92,8 @@ export class SubpanelComponent implements OnInit {
onClick: (): void => {
this.actionManager.run(action.key, {
subpanelMeta: this.store.metadata,
store: this.recordStore,
parentModule: this.store.parentModule,
parentId: this.store.parentId,
action
});
}

View file

@ -1,13 +1,13 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '../../app-manager/app-manager.module';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {SubpanelComponent} from './subpanel.component';
import {PanelModule} from '@components/panel/panel.module';
import {ImageModule} from '@components/image/image.module';
import {RouterModule} from '@angular/router';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ButtonGroupModule} from '../button-group/button-group.module';
import {ButtonGroupModule} from '@components/button-group/button-group.module';
import {TableModule} from '@components/table/table.module';
@NgModule({

View file

@ -1,8 +1,8 @@
import {Injectable} from '@angular/core';
import {SubpanelStore} from '@store/subpanel/subpanel.store';
import {SubpanelStore} from '@containers/subpanel/store/subpanel/subpanel.store';
import {RecordListStoreFactory} from '@store/record-list/record-list.store.factory';
import {LanguageStore} from '@store/language/language.store';
import {SubpanelStatisticsStoreFactory} from '@store/subpanel/subpanel-statistics.store.factory';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
@Injectable({
providedIn: 'root',
@ -12,7 +12,7 @@ export class SubpanelStoreFactory {
constructor(
protected listStoreFactory: RecordListStoreFactory,
protected languageStore: LanguageStore,
protected statisticsStoreFactory: SubpanelStatisticsStoreFactory
protected statisticsStoreFactory: SingleValueStatisticsStoreFactory
) {
}

View file

@ -1,7 +1,7 @@
import {SubpanelStoreFactory} from '@store/subpanel/subpanel.store.factory';
import {SubpanelStoreFactory} from '@containers/subpanel/store/subpanel/subpanel.store.factory';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {listStoreFactoryMock} from '@store/record-list/record-list.store.spec.mock';
import {subpanelStatisticsFactoryMock} from '@store/subpanel/subpanel-statistics.store.spec.mock';
import {subpanelStatisticsFactoryMock} from '@store/single-value-statistics/single-value-statistics.store.spec.mock';
export const subpanelFactoryMock = new SubpanelStoreFactory(
listStoreFactoryMock,

View file

@ -6,8 +6,8 @@ import {RecordListStoreFactory} from '@store/record-list/record-list.store.facto
import {LanguageStore} from '@store/language/language.store';
import {SubPanel} from '@app-common/metadata/subpanel.metadata.model';
import {Statistic, StatisticsQuery} from '@app-common/statistics/statistics.model';
import {SubpanelStatisticsStore} from '@store/subpanel/subpanel-statistics.store';
import {SubpanelStatisticsStoreFactory} from '@store/subpanel/subpanel-statistics.store.factory';
import {SingleValueStatisticsStore} from '@store/single-value-statistics/single-value-statistics.store';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
export interface SubpanelStoreMap {
[key: string]: SubpanelStore;
@ -17,17 +17,19 @@ export interface SubpanelStoreMap {
export class SubpanelStore implements StateStore {
show = false;
parentModule: string;
parentId: string;
recordList: RecordListStore;
statistics: SubpanelStatisticsStore;
statistics: SingleValueStatisticsStore;
metadata$: Observable<SubPanel>;
metadata: SubPanel;
loading$: Observable<boolean>;
protected metadataState: BehaviorSubject<SubPanel>;
constructor(
protected listStoreFactory: RecordListStoreFactory,
protected languageStore: LanguageStore,
protected statisticsStoreFactory: SubpanelStatisticsStoreFactory
protected statisticsStoreFactory: SingleValueStatisticsStoreFactory
) {
this.recordList = listStoreFactory.create();
this.statistics = statisticsStoreFactory.create();
@ -72,6 +74,7 @@ export class SubpanelStore implements StateStore {
*/
public init(parentModule: string, parentId: string, meta: SubPanel): void {
this.parentModule = parentModule;
this.parentId = parentId;
this.metadata = meta;
this.metadataState.next(this.metadata);
this.recordList.init(meta.module, false, 'list_max_entries_per_subpanel');
@ -134,9 +137,9 @@ export class SubpanelStore implements StateStore {
meta.module,
{
key: meta.module,
params: {
parentModule,
parentId
context: {
module: parentModule,
id: parentId
}
} as StatisticsQuery,
false

View file

@ -0,0 +1,35 @@
<div *ngIf="(vm$ | async) as vm"
class="d-flex justify-content-center widget-bar">
<div class="p-2 widget-bar-entry-message" *ngIf="this.messageLabelKey">
{{vm.appStrings[this.messageLabelKey] || '' | uppercase}}
</div>
<ng-container *ngFor="let item of statistics | keyvalue">
<div class="d-flex justify-content-center widget-bar-entry">
<div class="p-2 widget-bar-entry-label" *ngIf="item.value.labelKey && vm.appStrings[item.value.labelKey]">
{{vm.appStrings[item.value.labelKey] | uppercase}}:
</div>
<ng-container *ngIf="item.key && vm.statistics[item.key]">
<div class="p-2 widget-bar-entry-value"
*ngIf="!vm.statistics[item.key].loading && vm.statistics[item.key].field">
<scrm-field [type]="vm.statistics[item.key].field.type" [field]="vm.statistics[item.key].field"
mode="list"></scrm-field>
</div>
</ng-container>
<div class="p-2 widget-bar-entry-loading" *ngIf="(item.value.store.loading$ | async) as loading">
<scrm-inline-loading-spinner></scrm-inline-loading-spinner>
<ng-container *ngIf="!loading && (!item.key || !vm.statistics[item.key])">
-
</ng-container>
</div>
</div>
</ng-container>
</div>

View file

@ -0,0 +1,54 @@
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
import {StatisticsFetchGQL} from '@store/statistics/graphql/api.statistics.get';
import {StatisticsMap, StatisticsQueryMap} from '@app-common/statistics/statistics.model';
import {Observable, of} from 'rxjs';
import {shareReplay} from 'rxjs/operators';
class StatisticsFetchGQLSpy extends StatisticsFetchGQL {
constructor() {
super(null);
}
public fetch(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
module: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
queries: StatisticsQueryMap,
): Observable<StatisticsMap> {
if (queries.opportunities) {
return of({
history: {
id: 'opportunities',
data: {
value: '5400'
},
metadata: {
dataType: 'currency',
type: 'single-value-statistic'
}
}
}).pipe(shareReplay(1));
}
if (queries['accounts-won-opportunity-amount-by-year']) {
return of({
history: {
id: 'accounts-won-opportunity-amount-by-year',
data: {
value: '1466.6666666666667'
},
metadata: {
dataType: 'currency',
type: 'single-value-statistic'
}
}
}).pipe(shareReplay(1));
}
}
}
export const topWidgetStatisticsFactoryMock = new SingleValueStatisticsStoreFactory(
new StatisticsFetchGQLSpy(),
);

View file

@ -0,0 +1,124 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {StatisticsTopWidgetComponent} from './statistics-top-widget.component';
import {Component} from '@angular/core';
import {ViewContext} from '@app-common/views/view.model';
import {WidgetMetadata} from '@app-common/metadata/widget.metadata';
import {LanguageStore} from '@store/language/language.store';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {topWidgetStatisticsFactoryMock} from './statistics-top-widget.component.spec.mock';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
import {FieldModule} from '@fields/field.module';
import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing';
import {CommonModule} from '@angular/common';
import {RouterTestingModule} from '@angular/router/testing';
import {ApolloTestingModule} from 'apollo-angular/testing';
@Component({
selector: 'statistics-top-widget-test-host-component',
template: '<scrm-statistics-top-widget [context]="context" [config]="config"></scrm-statistics-top-widget>'
})
class StatisticsTopWidgetHostComponent {
context: ViewContext = {
module: 'accounts',
id: '123'
};
config: WidgetMetadata = {
type: 'statistics',
options: {
statistics: [
{
labelKey: 'LBL_AVERAGE_SPEND_PER_YEAR',
type: 'accounts-won-opportunity-amount-by-year'
},
{
labelKey: 'LBL_OPPORTUNITIES_TOTAL',
type: 'opportunities'
},
],
}
};
}
describe('StatisticsTopWidgetComponent', () => {
let testHostComponent: StatisticsTopWidgetHostComponent;
let testHostFixture: ComponentFixture<StatisticsTopWidgetHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
StatisticsTopWidgetHostComponent,
StatisticsTopWidgetComponent,
],
imports: [
BrowserDynamicTestingModule,
FieldModule,
CommonModule,
RouterTestingModule,
ApolloTestingModule
],
providers: [
{provide: LanguageStore, useValue: languageStoreMock},
{provide: SingleValueStatisticsStoreFactory, useValue: topWidgetStatisticsFactoryMock},
],
}).compileComponents();
testHostFixture = TestBed.createComponent(StatisticsTopWidgetHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
}));
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have statistics', () => {
expect(testHostComponent).toBeTruthy();
const widget = testHostFixture.nativeElement.getElementsByTagName('scrm-statistics-top-widget')[0];
expect(widget).toBeTruthy();
const widgetBar = widget.getElementsByClassName('widget-bar').item(0);
expect(widgetBar).toBeTruthy();
const entries = widgetBar.getElementsByClassName('widget-bar-entry');
expect(entries).toBeTruthy();
expect(entries.length).toEqual(2);
const averageSpend = entries.item(0);
expect(averageSpend).toBeTruthy();
const averageSpendLabel = averageSpend.getElementsByClassName('widget-bar-entry-label').item(0);
const averageSpendValue = averageSpend.getElementsByClassName('widget-bar-entry-value').item(0);
const averageSpendField = averageSpendValue.getElementsByTagName('scrm-field').item(0);
const averageSpendCurrency = averageSpendField.getElementsByTagName('scrm-currency-detail').item(0);
expect(averageSpendLabel).toBeTruthy();
expect(averageSpendLabel.textContent).toContain('AVERAGE SPEND PER YEAR:');
expect(averageSpendValue).toBeTruthy();
expect(averageSpendField).toBeTruthy();
expect(averageSpendCurrency).toBeTruthy();
expect(averageSpendCurrency.textContent).toContain('$1,467');
const oppTotal = entries.item(1);
expect(oppTotal).toBeTruthy();
const oppTotalLabel = oppTotal.getElementsByClassName('widget-bar-entry-label').item(0);
const oppTotalValue = oppTotal.getElementsByClassName('widget-bar-entry-value').item(0);
const oppTotalField = oppTotalValue.getElementsByTagName('scrm-field').item(0);
const oppTotalCurrency = oppTotalField.getElementsByTagName('scrm-currency-detail').item(0);
expect(oppTotalLabel).toBeTruthy();
expect(oppTotalLabel.textContent).toContain('TOTAL OPPORTUNITY VALUE:');
expect(oppTotalValue).toBeTruthy();
expect(oppTotalField).toBeTruthy();
expect(oppTotalCurrency).toBeTruthy();
expect(oppTotalCurrency.textContent).toContain('$5,400');
});
});

View file

@ -0,0 +1,106 @@
import {Component, OnInit} from '@angular/core';
import {BaseWidgetComponent} from '@containers/top-widget/top-widget.model';
import {
SingleValueStatisticsState,
SingleValueStatisticsStore
} from '@store/single-value-statistics/single-value-statistics.store';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
import {map, take} from 'rxjs/operators';
import {LanguageStore, LanguageStringMap} from '@store/language/language.store';
import {combineLatest, Observable} from 'rxjs';
import {StatisticsQuery} from '@app-common/statistics/statistics.model';
interface StatisticsTopWidgetState {
statistics: { [key: string]: SingleValueStatisticsState };
appStrings: LanguageStringMap;
}
interface StatisticsEntry {
labelKey: string;
type: string;
store: SingleValueStatisticsStore;
}
interface StatisticsEntryMap {
[key: string]: StatisticsEntry;
}
@Component({
selector: 'scrm-statistics-top-widget',
templateUrl: './statistics-top-widget.component.html',
styles: []
})
export class StatisticsTopWidgetComponent extends BaseWidgetComponent implements OnInit {
statistics: StatisticsEntryMap = {};
vm$: Observable<StatisticsTopWidgetState>;
messageLabelKey: string;
constructor(
protected language: LanguageStore,
protected factory: SingleValueStatisticsStoreFactory
) {
super();
}
ngOnInit(): void {
if (!this.context || !this.context.module) {
this.messageLabelKey = 'LBL_BAD_CONFIG_BAD_CONTEXT';
return;
}
if (!this.config) {
this.messageLabelKey = 'LBL_BAD_CONFIG_NO_CONFIG';
return;
}
if (!this.config.options || !this.config.options.statistics || !this.config.options.statistics.length) {
this.messageLabelKey = 'LBL_BAD_CONFIG_NO_STATISTICS_KEY';
return;
}
const statistics$: Observable<SingleValueStatisticsState>[] = [];
this.config.options.statistics.forEach(statistic => {
if (!statistic.type) {
return;
}
this.statistics[statistic.type] = {
labelKey: statistic.labelKey || '',
type: statistic.type,
store: this.factory.create()
};
this.statistics[statistic.type].store.init(
this.context.module,
{
key: statistic.type,
context: {...this.context}
} as StatisticsQuery,
).pipe(take(1)).subscribe();
statistics$.push(this.statistics[statistic.type].store.state$);
});
this.vm$ = combineLatest([combineLatest(statistics$), this.language.appStrings$]).pipe(
map(([statistics, appStrings]) => {
const statsMap: { [key: string]: SingleValueStatisticsState } = {};
statistics.forEach(value => {
statsMap[value.statistic.id] = value;
});
return {
statistics: statsMap,
appStrings
};
})
);
}
}

View file

@ -0,0 +1,19 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {FieldModule} from '@fields/field.module';
import {StatisticsTopWidgetComponent} from '@base/containers/top-widget/components/statistics-top-widget/statistics-top-widget.component';
import {InlineLoadingSpinnerModule} from '@components/inline-loading-spinner/inline-loading-spinner.module';
@NgModule({
declarations: [StatisticsTopWidgetComponent],
exports: [
StatisticsTopWidgetComponent
],
imports: [
CommonModule,
FieldModule,
InlineLoadingSpinnerModule
]
})
export class StatisticsTopWidgetModule {
}

View file

@ -0,0 +1,7 @@
<ndc-dynamic *ngIf="type && config"
[ndcDynamicComponent]="componentType"
[ndcDynamicInputs]="{
'config': config,
'context': context
}"
></ndc-dynamic>

View file

@ -0,0 +1,131 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Component} from '@angular/core';
import {ViewContext} from '@app-common/views/view.model';
import {WidgetMetadata} from '@app-common/metadata/widget.metadata';
import {LanguageStore} from '@store/language/language.store';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
import {topWidgetStatisticsFactoryMock} from '../statistics-top-widget/statistics-top-widget.component.spec.mock';
import {CommonModule} from '@angular/common';
import {BrowserDynamicTestingModule} from '@angular/platform-browser-dynamic/testing';
import {FieldModule} from '@fields/field.module';
import {RouterTestingModule} from '@angular/router/testing';
import {ApolloTestingModule} from 'apollo-angular/testing';
import {TopWidgetModule} from '@containers/top-widget/components/top-widget/top-widget.module';
@Component({
selector: 'top-widget-test-host-component',
template: '<scrm-top-widget [type]="type" [context]="context" [config]="config"></scrm-top-widget>'
})
class TopWidgetHostComponent {
type = 'statistics';
context: ViewContext = {
module: 'accounts',
id: '123'
};
config: WidgetMetadata = {
type: 'statistics',
options: {
statistics: [
{
labelKey: 'LBL_AVERAGE_SPEND_PER_YEAR',
type: 'accounts-won-opportunity-amount-by-year'
},
{
labelKey: 'LBL_OPPORTUNITIES_TOTAL',
type: 'opportunities'
},
],
}
};
}
describe('TopWidgetComponent', () => {
let testHostComponent: TopWidgetHostComponent;
let testHostFixture: ComponentFixture<TopWidgetHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [
TopWidgetHostComponent,
],
imports: [
TopWidgetModule,
BrowserDynamicTestingModule,
FieldModule,
CommonModule,
RouterTestingModule,
ApolloTestingModule
],
providers: [
{provide: LanguageStore, useValue: languageStoreMock},
{provide: SingleValueStatisticsStoreFactory, useValue: topWidgetStatisticsFactoryMock},
],
}).compileComponents();
testHostFixture = TestBed.createComponent(TopWidgetHostComponent);
testHostComponent = testHostFixture.componentInstance;
testHostFixture.detectChanges();
}));
it('should create', () => {
expect(testHostComponent).toBeTruthy();
});
it('should have statistics widget', () => {
expect(testHostComponent).toBeTruthy();
const topWidget = testHostFixture.nativeElement.getElementsByTagName('scrm-top-widget')[0];
expect(topWidget).toBeTruthy();
const widget = topWidget.getElementsByTagName('scrm-statistics-top-widget').item(0);
expect(widget).toBeTruthy();
const widgetBar = widget.getElementsByClassName('widget-bar').item(0);
expect(widgetBar).toBeTruthy();
const entries = widgetBar.getElementsByClassName('widget-bar-entry');
expect(entries).toBeTruthy();
expect(entries.length).toEqual(2);
const averageSpend = entries.item(0);
expect(averageSpend).toBeTruthy();
const averageSpendLabel = averageSpend.getElementsByClassName('widget-bar-entry-label').item(0);
const averageSpendValue = averageSpend.getElementsByClassName('widget-bar-entry-value').item(0);
const averageSpendField = averageSpendValue.getElementsByTagName('scrm-field').item(0);
const averageSpendCurrency = averageSpendField.getElementsByTagName('scrm-currency-detail').item(0);
expect(averageSpendLabel).toBeTruthy();
expect(averageSpendLabel.textContent).toContain('AVERAGE SPEND PER YEAR:');
expect(averageSpendValue).toBeTruthy();
expect(averageSpendField).toBeTruthy();
expect(averageSpendCurrency).toBeTruthy();
expect(averageSpendCurrency.textContent).toContain('$1,467');
const oppTotal = entries.item(1);
expect(oppTotal).toBeTruthy();
const oppTotalLabel = oppTotal.getElementsByClassName('widget-bar-entry-label').item(0);
const oppTotalValue = oppTotal.getElementsByClassName('widget-bar-entry-value').item(0);
const oppTotalField = oppTotalValue.getElementsByTagName('scrm-field').item(0);
const oppTotalCurrency = oppTotalField.getElementsByTagName('scrm-currency-detail').item(0);
expect(oppTotalLabel).toBeTruthy();
expect(oppTotalLabel.textContent).toContain('TOTAL OPPORTUNITY VALUE:');
expect(oppTotalValue).toBeTruthy();
expect(oppTotalField).toBeTruthy();
expect(oppTotalCurrency).toBeTruthy();
expect(oppTotalCurrency.textContent).toContain('$5,400');
});
});

View file

@ -0,0 +1,23 @@
import {Component, Input, OnInit} from '@angular/core';
import {componentTypeMap} from '@containers/top-widget/components/top-widget/top-widget.manifest';
import {BaseWidgetComponent} from '@containers/top-widget/top-widget.model';
@Component({
selector: 'scrm-top-widget',
templateUrl: './top-widget.component.html',
styles: []
})
export class TopWidgetComponent extends BaseWidgetComponent implements OnInit {
@Input('type') type: string;
map = componentTypeMap;
get componentType(): any {
return this.map[this.type];
}
ngOnInit(): void {
}
}

View file

@ -0,0 +1,10 @@
import {StatisticsTopWidgetModule} from '@containers/top-widget/components/statistics-top-widget/statistics-top-widget.module';
import {StatisticsTopWidgetComponent} from '@containers/top-widget/components/statistics-top-widget/statistics-top-widget.component';
export const topWidgetModules = [
StatisticsTopWidgetModule,
];
export const componentTypeMap = {
statistics: StatisticsTopWidgetComponent,
};

View file

@ -0,0 +1,17 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {TopWidgetComponent} from './top-widget.component';
import {DynamicModule} from 'ng-dynamic-component';
import {topWidgetModules} from '@containers/top-widget/components/top-widget/top-widget.manifest';
@NgModule({
declarations: [TopWidgetComponent],
exports: [TopWidgetComponent],
imports: [
CommonModule,
...topWidgetModules,
DynamicModule,
]
})
export class TopWidgetModule {
}

View file

@ -0,0 +1,8 @@
import {Input} from '@angular/core';
import {WidgetMetadata} from '@app-common/metadata/widget.metadata';
import {ViewContext} from '@app-common/views/view.model';
export class BaseWidgetComponent {
@Input('config') config: WidgetMetadata;
@Input('context') context: ViewContext;
}

View file

@ -40,7 +40,9 @@ export const languageMockData = {
LBL_PANEL_ASSIGNMENT: 'OTHER',
// eslint-disable-next-line camelcase,@typescript-eslint/camelcase
lbl_account_information: 'OVERVIEW',
LBL_PANEL_ADVANCED: 'MORE INFORMATION'
LBL_PANEL_ADVANCED: 'MORE INFORMATION',
LBL_AVERAGE_SPEND_PER_YEAR: 'Average Spend Per Year',
LBL_OPPORTUNITIES_TOTAL: 'Total Opportunity Value',
},
appListStrings: {
// eslint-disable-next-line camelcase,@typescript-eslint/camelcase

View file

@ -10,8 +10,10 @@ import {ModeActions} from '@app-common/actions/action.model';
import {ColumnDefinition, ListViewMeta, SearchMeta} from '@app-common/metadata/list.metadata.model';
import {LineAction} from '@app-common/actions/line-action.model';
import {SubPanelMeta} from '@app-common/metadata/subpanel.metadata.model';
import {WidgetMetadata} from '@app-common/metadata/widget.metadata';
export interface RecordViewMetadata {
topWidget?: WidgetMetadata;
actions: ModeActions;
templateMeta: RecordTemplateMetadata;
panels: Panel[];
@ -310,7 +312,7 @@ export class MetadataStore implements StateStore {
};
const receivedMeta = data.viewDefinition.recordView;
const entries = {templateMeta: 'templateMeta', actions: 'actions', panels: 'panels'};
const entries = {templateMeta: 'templateMeta', actions: 'actions', panels: 'panels', topWidget: 'topWidget'};
this.addDefinedMeta(recordViewMeta, receivedMeta, entries);

View file

@ -0,0 +1,16 @@
import {Injectable} from '@angular/core';
import {StatisticsFetchGQL} from '@store/statistics/graphql/api.statistics.get';
import {SingleValueStatisticsStore} from '@store/single-value-statistics/single-value-statistics.store';
@Injectable({
providedIn: 'root',
})
export class SingleValueStatisticsStoreFactory {
constructor(protected fetchGQL: StatisticsFetchGQL) {
}
create(): SingleValueStatisticsStore {
return new SingleValueStatisticsStore(this.fetchGQL);
}
}

View file

@ -1,4 +1,4 @@
import {SubpanelStatisticsStoreFactory} from '@store/subpanel/subpanel-statistics.store.factory';
import {SingleValueStatisticsStoreFactory} from '@store/single-value-statistics/single-value-statistics.store.factory';
import {StatisticsFetchGQL} from '@store/statistics/graphql/api.statistics.get';
import {StatisticsMap, StatisticsQueryMap} from '@app-common/statistics/statistics.model';
import {Observable, of} from 'rxjs';
@ -27,6 +27,6 @@ class StatisticsFetchGQLSpy extends StatisticsFetchGQL {
}
}
export const subpanelStatisticsFactoryMock = new SubpanelStatisticsStoreFactory(
export const subpanelStatisticsFactoryMock = new SingleValueStatisticsStoreFactory(
new StatisticsFetchGQLSpy(),
);

View file

@ -2,10 +2,10 @@ import {Injectable} from '@angular/core';
import {BehaviorSubject, Observable} from 'rxjs';
import {deepClone} from '@base/utils/object-utils';
import {
SingleValueStatistic,
SingleValueStatisticsData,
Statistic,
StatisticsQuery,
SubpanelStatistic,
SubpanelStatisticsData
StatisticsQuery
} from '@app-common/statistics/statistics.model';
import {StatisticsFetchGQL} from '@store/statistics/graphql/api.statistics.get';
import {StatisticsState, StatisticsStore} from '@store/statistics/statistics.store';
@ -17,24 +17,24 @@ const initialState = {
query: {} as StatisticsQuery,
statistic: {
id: '',
data: {} as SubpanelStatisticsData
} as SubpanelStatistic,
data: {} as SingleValueStatisticsData
} as SingleValueStatistic,
loading: false
} as SubpanelStatisticsState;
} as SingleValueStatisticsState;
export interface SubpanelStatisticsState extends StatisticsState {
statistic: SubpanelStatistic;
export interface SingleValueStatisticsState extends StatisticsState {
statistic: SingleValueStatistic;
field?: Field;
}
@Injectable()
export class SubpanelStatisticsStore extends StatisticsStore {
state$: Observable<SubpanelStatisticsState>;
export class SingleValueStatisticsStore extends StatisticsStore {
state$: Observable<SingleValueStatisticsState>;
statistic$: Observable<Statistic>;
loading$: Observable<boolean>;
protected cache$: Observable<any> = null;
protected internalState: SubpanelStatisticsState = deepClone(initialState);
protected store = new BehaviorSubject<SubpanelStatisticsState>(this.internalState);
protected internalState: SingleValueStatisticsState = deepClone(initialState);
protected store = new BehaviorSubject<SingleValueStatisticsState>(this.internalState);
constructor(
protected fetchGQL: StatisticsFetchGQL,
@ -46,7 +46,12 @@ export class SubpanelStatisticsStore extends StatisticsStore {
}
protected addNewState(statistic: Statistic): void {
const field = FieldManager.buildShallowField(statistic.data.type, statistic.data.value);
if (!statistic.metadata || !statistic.metadata.dataType) {
return;
}
const field = FieldManager.buildShallowField(statistic.metadata.dataType, statistic.data.value);
field.metadata = {
digits: 0
@ -65,7 +70,7 @@ export class SubpanelStatisticsStore extends StatisticsStore {
*
* @param {object} state to set
*/
protected updateState(state: SubpanelStatisticsState): void {
protected updateState(state: SingleValueStatisticsState): void {
super.updateState(state);
}
}

View file

@ -21,12 +21,12 @@ export class StateManager {
protected navigationStore: NavigationStore,
protected systemConfigStore: SystemConfigStore,
protected themeImagesStore: ThemeImagesStore,
protected userPreferenceStore: UserPreferenceStore
protected userPreferenceStore: UserPreferenceStore,
) {
this.stateStores.appStore = this.buildMapEntry(appStore, false);
this.stateStores.navigationStore = this.buildMapEntry(navigationStore, true);
this.stateStores.languageStore = this.buildMapEntry(languageStore, true);
this.stateStores.listViewMetaStore = this.buildMapEntry(metadataStore, false);
this.stateStores.metadataStore = 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

@ -33,7 +33,8 @@ export class StatisticsFetchGQL {
edges {
node {
_id,
data
data,
metadata
}
}
}
@ -56,7 +57,8 @@ export class StatisticsFetchGQL {
statistics[key] = {
// eslint-disable-next-line no-underscore-dangle
id: edge.node._id,
data: edge.node.data
data: edge.node.data,
metadata: edge.node.metadata
} as Statistic;
});
}

View file

@ -1,16 +0,0 @@
import {Injectable} from '@angular/core';
import {StatisticsFetchGQL} from '@store/statistics/graphql/api.statistics.get';
import {SubpanelStatisticsStore} from '@store/subpanel/subpanel-statistics.store';
@Injectable({
providedIn: 'root',
})
export class SubpanelStatisticsStoreFactory {
constructor(protected fetchGQL: StatisticsFetchGQL) {
}
create(): SubpanelStatisticsStore {
return new SubpanelStatisticsStore(this.fetchGQL);
}
}

View file

@ -0,0 +1,15 @@
.widget-bar {
background-color: $coral-pink;
color: $white;
font-weight: bolder;
}
.widget-bar .widget-bar-entry-value {
font-weight: bolder;
}
.widget-bar .inline-spinner > div {
width: 0.45em;
height: 0.45em;
background-color: white;
}

View file

@ -20,6 +20,7 @@
-webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
-moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
border-top: none;
}
.panel-title {

View file

@ -22,6 +22,7 @@
@import 'components/loading-spinner';
@import 'components/inline-loading-spinner';
@import 'components/widget';
@import 'components/widget-bar';
@import 'components/chips';
@import 'layout/panel';

View file

@ -36,6 +36,9 @@
"@components/*": [
"./src/components/*"
],
"@containers/*": [
"./src/containers/*"
],
"@store/*": [
"./src/store/*"
]

View file

@ -1,7 +1,7 @@
import {Injectable} from '@angular/core';
import {RecordActionData, RecordActionHandler} from '@views/record/actions/record.action';
import {ViewMode} from '@app-common/views/view.model';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
@Injectable({
providedIn: 'root'

View file

@ -1,5 +1,5 @@
import {ActionData, ActionHandler} from '@app-common/actions/action.model';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {ViewMode} from '@app-common/views/view.model';
export interface RecordActionData extends ActionData {

View file

@ -1,5 +1,5 @@
import {RecordActionsAdapter} from '@store/record-view/adapters/actions.adapter';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordActionsAdapter} from '@views/record/adapters/actions.adapter';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {languageStoreMock} from '@store/language/language.store.spec.mock';
import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
import {recordActionsManagerMock} from '@views/record/actions/record-action-manager.service.spec.mock';

View file

@ -1,5 +1,5 @@
import {Injectable} from '@angular/core';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {MetadataStore} from '@store/metadata/metadata.store.service';
import {LanguageStore} from '@store/language/language.store';
import {Action, ActionDataSource, ModeActions} from '@app-common/actions/action.model';
@ -79,9 +79,7 @@ export class RecordActionsAdapter implements ActionDataSource {
meta,
mode,
record,
languages,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
widgets
languages
]
) => {
if (!mode || !meta) {

View file

@ -1,4 +1,4 @@
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {combineLatest, Observable} from 'rxjs';
import {Injectable} from '@angular/core';
import {MetadataStore, RecordViewMetadata} from '@store/metadata/metadata.store.service';

View file

@ -1,11 +1,21 @@
<!-- Start Record View Container Section -->
<div class="record-view-container view-container container-fluid pt-1">
<div *ngIf="(vm$ | async) as vm"
class="record-view-container view-container container-fluid pt-3">
<div class="row">
<div class="col-lg-9" [ngClass]="{ 'col-lg-12': !getDisplayWidgets() }">
<div class="container-fluid pl-0 pr-0">
<div class="row no-gutters" *ngIf="vm.recordMeta && vm.recordMeta.topWidget && vm.recordMeta.topWidget.type">
<div class="col pb-3">
<scrm-top-widget [type]="vm.recordMeta.topWidget.type"
[context]="getViewContext()"
[config]="vm.recordMeta.topWidget">
</scrm-top-widget>
</div>
</div>
<div class="row no-gutters">
<div class="col">
<scrm-record-content [dataSource]="getContentAdapter()"></scrm-record-content>

View file

@ -5,8 +5,8 @@ import {RecordContainerComponent} from './record-container.component';
import {WidgetModule} from '@components/widget/widget.module';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {ThemeImagesStore} from '@store/theme-images/theme-images.store';
import {of} from 'rxjs';
import {themeImagesMockData} from '@store/theme-images/theme-images.store.spec.mock';
@ -29,7 +29,7 @@ import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
import {AppStateStore} from '@store/app-state/app-state.store';
import {appStateStoreMock} from '@store/app-state/app-state.store.spec.mock';
import {Router} from '@angular/router';
import {SubpanelModule} from '@components/subpanel/subpanel.module';
import {SubpanelModule} from '@containers/subpanel/components/subpanel/subpanel.module';
describe('RecordContainerComponent', () => {
let component: RecordContainerComponent;

View file

@ -1,12 +1,13 @@
import {Component, Input, OnInit} from '@angular/core';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {Component, OnInit} from '@angular/core';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {MetadataStore, RecordViewMetadata} from '@store/metadata/metadata.store.service';
import {combineLatest, Observable} from 'rxjs';
import {LanguageStore, LanguageStrings} from '@store/language/language.store';
import {map} from 'rxjs/operators';
import {RecordContentAdapter} from '@store/record-view/adapters/record-content.adapter';
import {RecordContentAdapter} from '@views/record/adapters/record-content.adapter';
import {RecordContentDataSource} from '@components/record-content/record-content.model';
import {SubpanelContainerConfig} from '@components/subpanel-container/subpanel-container.model';
import {SubpanelContainerConfig} from '@containers/subpanel/components/subpanel-container/subpanel-container.model';
import {ViewContext} from '@app-common/views/view.model';
@Component({
selector: 'scrm-record-container',
@ -14,7 +15,6 @@ import {SubpanelContainerConfig} from '@components/subpanel-container/subpanel-c
providers: [RecordContentAdapter]
})
export class RecordContainerComponent implements OnInit {
@Input() module;
type = '';
widgetTitle = '';
@ -60,7 +60,11 @@ export class RecordContainerComponent implements OnInit {
getSubpanelsConfig(): SubpanelContainerConfig {
return {
subpanels$: this.recordViewStore.subpanels$,
recordStore: this.recordViewStore
sidebarActive$: this.recordViewStore.widgets$
} as SubpanelContainerConfig;
}
getViewContext(): ViewContext {
return this.recordViewStore.getViewContext();
}
}

View file

@ -1,12 +1,13 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '../../app-manager/app-manager.module';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {RecordContainerComponent} from './record-container.component';
import {WidgetModule} from '../widget/widget.module';
import {WidgetModule} from '@components/widget/widget.module';
import {AngularSvgIconModule} from 'angular-svg-icon';
import {SubpanelContainerModule} from '@components/subpanel-container/subpanel-container.module';
import {SubpanelContainerModule} from '@containers/subpanel/components/subpanel-container/subpanel-container.module';
import {RecordContentModule} from '@components/record-content/record-content.module';
import {HistoryTimelineWidgetModule} from '@components/history-timeline-widget/history-timeline-widget.module';
import {TopWidgetModule} from '@containers/top-widget/components/top-widget/top-widget.module';
@NgModule({
declarations: [RecordContainerComponent],
@ -18,7 +19,8 @@ import {HistoryTimelineWidgetModule} from '@components/history-timeline-widget/h
AngularSvgIconModule,
SubpanelContainerModule,
RecordContentModule,
HistoryTimelineWidgetModule
HistoryTimelineWidgetModule,
TopWidgetModule
]
})
export class RecordContainerModule {

View file

@ -7,8 +7,8 @@ import {ModuleTitleModule} from '@components/module-title/module-title.module';
import {ImageModule} from '@components/image/image.module';
import {RouterTestingModule} from '@angular/router/testing';
import {ButtonModule} from '@components/button/button.module';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {ThemeImagesStore} from '@store/theme-images/theme-images.store';
import {themeImagesStoreMock} from '@store/theme-images/theme-images.store.spec.mock';
import {ModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service';
@ -25,9 +25,9 @@ import {MetadataStore} from '@store/metadata/metadata.store.service';
import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
import {AppStateStore} from '@store/app-state/app-state.store';
import {appStateStoreMock} from '@store/app-state/app-state.store.spec.mock';
import {RecordSettingsMenuModule} from '@components/record-settings-menu/record-settings-menu.module';
import {RecordActionsAdapter} from '@store/record-view/adapters/actions.adapter';
import {recordActionsMock} from '@store/record-view/adapters/actions.adapter.spec.mock';
import {RecordSettingsMenuModule} from '@views/record/components/record-settings-menu/record-settings-menu.module';
import {RecordActionsAdapter} from '@views/record/adapters/actions.adapter';
import {recordActionsMock} from '@views/record/adapters/actions.adapter.spec.mock';
@Component({
selector: 'record-header-test-host-component',

View file

@ -1,9 +1,9 @@
import {Component} from '@angular/core';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {ModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service';
import {combineLatest} from 'rxjs';
import {map} from 'rxjs/operators';
import {RecordActionsAdapter} from '@store/record-view/adapters/actions.adapter';
import {RecordActionsAdapter} from '@views/record/adapters/actions.adapter';
@Component({
selector: 'scrm-record-header',

View file

@ -1,9 +1,9 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '../../app-manager/app-manager.module';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {RecordHeaderComponent} from './record-header.component';
import {ModuleTitleModule} from '../module-title/module-title.module';
import {RecordSettingsMenuModule} from '@components/record-settings-menu/record-settings-menu.module';
import {ModuleTitleModule} from '@components/module-title/module-title.module';
import {RecordSettingsMenuModule} from '@views/record/components/record-settings-menu/record-settings-menu.module';
@NgModule({
declarations: [RecordHeaderComponent],

View file

@ -8,8 +8,8 @@ import {themeImagesMockData} from '@store/theme-images/theme-images.store.spec.m
import {take} from 'rxjs/operators';
import {ImageModule} from '@components/image/image.module';
import {ButtonModule} from '@components/button/button.module';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {ModuleNavigation} from '@services/navigation/module-navigation/module-navigation.service';
import {mockModuleNavigation,} from '@services/navigation/module-navigation/module-navigation.service.spec.mock';
import {SystemConfigStore} from '@store/system-config/system-config.store';
@ -25,10 +25,10 @@ import {metadataStoreMock} from '@store/metadata/metadata.store.spec.mock';
import {AppStateStore} from '@store/app-state/app-state.store';
import {appStateStoreMock} from '@store/app-state/app-state.store.spec.mock';
import {Component} from '@angular/core';
import {RecordSettingsMenuModule} from '@components/record-settings-menu/record-settings-menu.module';
import {RecordSettingsMenuModule} from '@views/record/components/record-settings-menu/record-settings-menu.module';
import {RouterTestingModule} from '@angular/router/testing';
import {RecordActionsAdapter} from '@store/record-view/adapters/actions.adapter';
import {recordActionsMock} from '@store/record-view/adapters/actions.adapter.spec.mock';
import {RecordActionsAdapter} from '@views/record/adapters/actions.adapter';
import {recordActionsMock} from '@views/record/adapters/actions.adapter.spec.mock';
@Component({
selector: 'record-setting-test-host-component',

View file

@ -1,7 +1,7 @@
import {Component} from '@angular/core';
import {BehaviorSubject, combineLatest, Subscription} from 'rxjs';
import {map} from 'rxjs/operators';
import {RecordActionsAdapter} from '@store/record-view/adapters/actions.adapter';
import {RecordActionsAdapter} from '@views/record/adapters/actions.adapter';
import {ButtonGroupInterface} from '@components/button-group/button-group.model';
import {Action} from '@app-common/actions/action.model';
import {ScreenSize, ScreenSizeObserverService} from '@services/ui/screen-size-observer/screen-size-observer.service';

View file

@ -1,6 +1,6 @@
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {AppManagerModule} from '../../app-manager/app-manager.module';
import {AppManagerModule} from '@base/app-manager/app-manager.module';
import {RecordSettingsMenuComponent} from './record-settings-menu.component';
import {ImageModule} from '@components/image/image.module';
import {ButtonModule} from '@components/button/button.module';

View file

@ -8,12 +8,12 @@ import {ImageModule} from '@components/image/image.module';
import {DynamicModule} from 'ng-dynamic-component';
import {FieldModule} from '@fields/field.module';
import {DropdownButtonModule} from '@components/dropdown-button/dropdown-button.module';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordComponent} from '@views/record/record.component';
import {RecordContainerModule} from '@components/record-container/record-container.module';
import {RecordHeaderModule} from '@components/record-header/record-header.module';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {RecordComponent} from '@views/record/components/record-view/record.component';
import {RecordContainerModule} from '@views/record/components/record-container/record-container.module';
import {RecordHeaderModule} from '@views/record/components/record-header/record-header.module';
import {StatusBarModule} from '@components/status-bar/status-bar.module';
import {recordviewStoreMock} from '@store/record-view/record-view.store.spec.mock';
import {recordviewStoreMock} from '@views/record/store/record-view/record-view.store.spec.mock';
import {ThemeImagesStore} from '@store/theme-images/theme-images.store';
import {of} from 'rxjs';
import {themeImagesMockData} from '@store/theme-images/theme-images.store.spec.mock';

View file

@ -1,9 +1,9 @@
import {Component, OnDestroy, OnInit} from '@angular/core';
import {AppStateStore} from '@store/app-state/app-state.store';
import {Observable, Subscription} from 'rxjs';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {ActivatedRoute} from '@angular/router';
import {RecordViewModel} from '@store/record-view/record-view.store.model';
import {RecordViewModel} from '@views/record/store/record-view/record-view.store.model';
@Component({
selector: 'scrm-record',

View file

@ -2,11 +2,11 @@ import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {RecordComponent} from './record.component';
import {FieldModule} from '@fields/field.module';
import {RecordContainerModule} from '@components/record-container/record-container.module';
import {RecordHeaderModule} from '@components/record-header/record-header.module';
import {RecordContainerModule} from '@views/record/components/record-container/record-container.module';
import {RecordHeaderModule} from '@views/record/components/record-header/record-header.module';
import {StatusBarModule} from '@components/status-bar/status-bar.module';
import {RecordSettingsMenuModule} from '@components/record-settings-menu/record-settings-menu.module';
import {SubpanelModule} from '@base/components/subpanel/subpanel.module';
import {RecordSettingsMenuModule} from '@views/record/components/record-settings-menu/record-settings-menu.module';
import {SubpanelModule} from '@containers/subpanel/components/subpanel/subpanel.module';
@NgModule({
declarations: [RecordComponent],

View file

@ -8,11 +8,11 @@ import {mockModuleNavigation} from '@services/navigation/module-navigation/modul
import {localStorageServiceMock} from '@services/local-storage/local-storage.service.spec.mock';
import {deepClone} from '@base/utils/object-utils';
import {messageServiceMock} from '@services/message/message.service.spec.mock';
import {RecordViewStore} from '@store/record-view/record-view.store';
import {RecordViewStore} from '@views/record/store/record-view/record-view.store';
import {RecordFetchGQL} from '@store/record/graphql/api.record.get';
import {RecordSaveGQL} from '@store/record/graphql/api.record.save';
import {Record} from '@app-common/record/record.model';
import {subpanelFactoryMock} from '@store/subpanel/subpanel.store.spec.mock';
import {subpanelFactoryMock} from '@containers/subpanel/store/subpanel/subpanel.store.spec.mock';
/* eslint-disable camelcase, @typescript-eslint/camelcase */
export const recordViewMockData = {

View file

@ -1,7 +1,7 @@
import {Injectable} from '@angular/core';
import {ViewStore} from '@store/view/view.store';
import {MetadataStore, RecordViewMetadata} from '@store/metadata/metadata.store.service';
import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs';
import {BehaviorSubject, combineLatest, Observable, of, Subscription} from 'rxjs';
import {StateStore} from '@store/state';
import {deepClone} from '@base/utils/object-utils';
import {AppStateStore} from '@store/app-state/app-state.store';
@ -13,13 +13,17 @@ import {MessageService} from '@services/message/message.service';
import {catchError, distinctUntilChanged, finalize, map, tap} from 'rxjs/operators';
import {RecordFetchGQL} from '@store/record/graphql/api.record.get';
import {Record} from '@app-common/record/record.model';
import {ViewMode} from '@base/app-common/views/view.model';
import {RecordViewData, RecordViewModel, RecordViewState} from '@store/record-view/record-view.store.model';
import {ViewContext, ViewMode} from '@app-common/views/view.model';
import {
RecordViewData,
RecordViewModel,
RecordViewState
} from '@views/record/store/record-view/record-view.store.model';
import {RecordManager} from '@store/record/record.manager';
import {ViewFieldDefinition} from '@app-common/metadata/metadata.model';
import {RecordSaveGQL} from '@store/record/graphql/api.record.save';
import {SubpanelStoreMap} from '@store/subpanel/subpanel.store';
import {SubpanelStoreFactory} from '@store/subpanel/subpanel.store.factory';
import {SubpanelStoreMap} from '@containers/subpanel/store/subpanel/subpanel.store';
import {SubpanelStoreFactory} from '@containers/subpanel/store/subpanel/subpanel.store.factory';
import {SubPanelMeta} from '@app-common/metadata/subpanel.metadata.model';
const initialState: RecordViewState = {
@ -60,6 +64,7 @@ export class RecordViewStore extends ViewStore implements StateStore {
protected state$ = this.store.asObservable();
protected subpanels: SubpanelStoreMap;
protected subpanelsState: BehaviorSubject<SubpanelStoreMap>;
protected subs: Subscription[] = [];
constructor(
protected recordFetchGQL: RecordFetchGQL,
@ -132,6 +137,21 @@ export class RecordViewStore extends ViewStore implements StateStore {
});
}
getModuleName(): string {
return this.internalState.module;
}
getRecordId(): string {
return this.internalState.recordID;
}
getViewContext(): ViewContext {
return {
module: this.getModuleName(),
id: this.getRecordId(),
};
}
getSubpanels(): SubpanelStoreMap {
return this.subpanels;
}

View file

@ -0,0 +1,102 @@
<?php
namespace SuiteCRM\Core\Legacy\Data\PresetDataHandlers;
use App\Service\ModuleNameMapperInterface;
use BeanFactory;
use SubpanelCustomQueryPort;
use SuiteCRM\Core\Legacy\LegacyHandler;
use SuiteCRM\Core\Legacy\LegacyScopeState;
class SubpanelDataQueryHandler extends LegacyHandler
{
public const HANDLER_KEY = 'subpanel-custom-query-handlers';
/**
* @var ModuleNameMapperInterface
*/
private $moduleNameMapper;
/**
* @var SubpanelCustomQueryPort
*/
private $queryHandler;
/**
* ListDataHandler constructor.
* @param string $projectDir
* @param string $legacyDir
* @param string $legacySessionName
* @param string $defaultSessionName
* @param LegacyScopeState $legacyScopeState
* @param ModuleNameMapperInterface $moduleNameMapper
*/
public function __construct(
string $projectDir,
string $legacyDir,
string $legacySessionName,
string $defaultSessionName,
LegacyScopeState $legacyScopeState,
ModuleNameMapperInterface $moduleNameMapper
) {
parent::__construct($projectDir, $legacyDir, $legacySessionName, $defaultSessionName, $legacyScopeState);
$this->moduleNameMapper = $moduleNameMapper;
}
/**
* @inheritDoc
*/
public function getHandlerKey(): string
{
return self::HANDLER_KEY;
}
/**
* @inheritDoc
*/
public function getType(): string
{
return 'subpanel';
}
/**
* @inheritDoc
*/
public function getQueries(string $parentModule, string $parentId, string $subpanel): array
{
$this->initQueryHandler();
if ($parentModule) {
$parentModule = $this->moduleNameMapper->toLegacy($parentModule);
}
$this->initController($parentModule);
$parentBean = BeanFactory::getBean($parentModule, $parentId);
return $this->queryHandler->getQueries($parentBean, $subpanel);
}
/**
* @inheritDoc
*/
public function runQuery(string $query): array
{
$this->initQueryHandler();
return $this->queryHandler->runQuery($query);
}
protected function initQueryHandler(): void
{
if ($this->queryHandler !== null) {
return;
}
/* @noinspection PhpIncludeInspection */
require_once 'include/portability/Subpanels/SubpanelCustomQueryPort.php';
$this->queryHandler = new SubpanelCustomQueryPort();
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace SuiteCRM\Core\Legacy\Statistics;
use App\Entity\Statistic;
trait StatisticsHandlingTrait
{
/**
* Build empty response statistic
* @param string $key
* @return Statistic
*/
protected function getEmptyResponse(string $key): Statistic
{
$statistic = new Statistic();
$statistic->setId($key);
$statistic->setData(['value' => '-']);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'varchar',
]);
return $statistic;
}
/**
* Build currency statistic result
* @param array $result
* @return Statistic
*/
protected function buildCurrencyResult(array $result): Statistic
{
$value = $result['value'] ?? 0;
if (empty($value)) {
$result = ['value' => 0];
}
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData($result);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'currency',
]);
return $statistic;
}
/**
* @param array $parts
* @return string
*/
protected function joinQueryParts(array $parts): string
{
$queryParts = [];
$queryParts[] = $parts['select'] ?? '';
$queryParts[] = $parts['from'] ?? '';
$queryParts[] = $parts['where'] ?? '';
$queryParts[] = $parts['group_by'] ?? '';
return implode(' ', $queryParts);
}
/**
* @param array $query
* @return array
*/
protected function extractContext(array $query): array
{
$module = $query['context']['module'] ?? '';
$id = $query['context']['id'] ?? '';
return array($module, $id);
}
}

View file

@ -26,10 +26,14 @@ class SubpanelAccountsCount implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '3'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelActivitiesNextDate implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'date',
'value' => '2020-12-05'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'date',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelCampaignsLastReceived implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'date',
'value' => '2020-08-28'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'date',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelCasesCount implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '5'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelContactsCount implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '10'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -30,6 +30,11 @@ class SubpanelContractsRenewalDate implements StatisticsProviderInterface
'value' => '2021-02-15'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'date',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelDefault implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '0'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelDocumentsCount implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '11'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelEmpty implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'varchar',
'value' => '-'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'varchar',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelEventsLastDate implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'date',
'value' => '2020-09-10'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'date',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelHistoryLastDate implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'date',
'value' => '2020-09-23'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'date',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelInvoicesTotal implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'currency',
'value' => '50000'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'currency',
]);
return $statistic;
}
}

View file

@ -26,10 +26,14 @@ class SubpanelLeadsTotal implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'int',
'value' => '4'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'int',
]);
return $statistic;
}
}

View file

@ -4,9 +4,12 @@ namespace SuiteCRM\Core\Legacy\Statistics;
use App\Entity\Statistic;
use App\Service\StatisticsProviderInterface;
use SuiteCRM\Core\Legacy\Data\PresetDataHandlers\SubpanelDataQueryHandler;
class SubpanelOpportunitiesTotal implements StatisticsProviderInterface
class SubpanelOpportunitiesTotal extends SubpanelDataQueryHandler implements StatisticsProviderInterface
{
use StatisticsHandlingTrait;
public const KEY = 'opportunities';
/**
@ -20,15 +23,32 @@ class SubpanelOpportunitiesTotal implements StatisticsProviderInterface
/**
* @inheritDoc
*/
public function getData(array $param): Statistic
public function getData(array $query): Statistic
{
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'currency',
'value' => '20000'
]);
$subpanel = 'opportunities';
[$module, $id] = $this->extractContext($query);
if (empty($module) || empty($id)) {
return $this->getEmptyResponse(self::KEY);
}
$this->init();
$this->startLegacyApp();
$queries = $this->getQueries($module, $id, $subpanel);
$parts = $queries[0];
$parts['select'] = 'SELECT SUM(opportunities.amount) as value ';
$parts['where'] .= ' AND opportunities.amount is not null ';
$dbQuery = $this->joinQueryParts($parts);
$result = $this->runQuery($dbQuery);
$statistic = $this->buildCurrencyResult($result);
$this->close();
return $statistic;
}

View file

@ -26,10 +26,14 @@ class SubpanelQuotesTotal implements StatisticsProviderInterface
$statistic = new Statistic();
$statistic->setId(self::KEY);
$statistic->setData([
'type' => 'currency',
'value' => '25000'
]);
$statistic->setMetadata([
'type' => 'single-value-statistic',
'dataType' => 'currency',
]);
return $statistic;
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace SuiteCRM\Core\Legacy\Statistics;
use App\Entity\Statistic;
use App\Service\StatisticsProviderInterface;
use SuiteCRM\Core\Legacy\Data\PresetDataHandlers\SubpanelDataQueryHandler;
class WonOpportunityAmountByYear extends SubpanelDataQueryHandler implements StatisticsProviderInterface
{
use StatisticsHandlingTrait;
public const KEY = 'accounts-won-opportunity-amount-by-year';
/**
* @inheritDoc
*/
public function getKey(): string
{
return self::KEY;
}
/**
* @inheritDoc
*/
public function getData(array $query): Statistic
{
[$module, $id] = $this->extractContext($query);
$subpanel = 'opportunities';
if (empty($module) || empty($id)) {
return $this->getEmptyResponse(self::KEY);
}
$this->init();
$this->startLegacyApp();
$queries = $this->getQueries($module, $id, $subpanel);
$parts = $queries[0];
$parts['select'] = 'SELECT SUM(opportunities.amount) as amount_by_year ';
$parts['where'] .= ' AND opportunities.date_closed is not null ';
$parts['where'] .= " AND opportunities.sales_stage = 'Closed Won' ";
$parts['group_by'] = ' GROUP BY EXTRACT(YEAR FROM opportunities.date_closed) ';
$innerQuery = $this->joinQueryParts($parts);
$dbQuery = 'SELECT AVG(opp_data.amount_by_year) as value FROM ( ' . $innerQuery . ' ) as opp_data';
$result = $this->runQuery($dbQuery);
$statistic = $this->buildCurrencyResult($result);
$this->close();
return $statistic;
}
}

View file

@ -90,6 +90,7 @@ class RecordViewDefinitionHandler extends LegacyHandler
$metadata = [
'templateMeta' => [],
'topWidget' => [],
'actions' => [],
'panels' => [],
];
@ -98,6 +99,7 @@ class RecordViewDefinitionHandler extends LegacyHandler
$vardefs = $fieldDefinition->getVardef();
$this->addTemplateMeta($viewDefs, $metadata);
$this->addTopWidgetConfig($viewDefs, $metadata);
$this->addPanelDefinitions($viewDefs, $vardefs, $metadata);
return $metadata;
@ -259,6 +261,15 @@ class RecordViewDefinitionHandler extends LegacyHandler
$metadata['templateMeta']['tabDefs'] = $viewDefs['templateMeta']['tabDefs'] ?? [];
}
/**
* @param array $viewDefs
* @param array $metadata
*/
protected function addTopWidgetConfig(array $viewDefs, array &$metadata): void
{
$metadata['topWidget'] = $viewDefs['topWidget'] ?? [];
}
/**
* @param string $field
* @return array

View file

@ -60,6 +60,12 @@ class Statistic
*/
protected $data;
/**
* @ApiProperty
* @var array|null
*/
protected $metadata;
/**
* @return string|null
*/
@ -135,4 +141,23 @@ class Statistic
return $this;
}
/**
* @return array|null
*/
public function getMetadata(): ?array
{
return $this->metadata;
}
/**
* @param array|null $metadata
* @return Statistic
*/
public function setMetadata(?array $metadata): Statistic
{
$this->metadata = $metadata;
return $this;
}
}

View file

@ -14,8 +14,8 @@ interface StatisticsProviderInterface
/**
* Get statistics data
* @param array $param
* @param array $query
* @return Statistic
*/
public function getData(array $param): Statistic;
public function getData(array $query): Statistic;
}