diff --git a/projects/nae-example-app/src/app/app.module.ts b/projects/nae-example-app/src/app/app.module.ts index f59fbd84eb..fa54cfaaf0 100644 --- a/projects/nae-example-app/src/app/app.module.ts +++ b/projects/nae-example-app/src/app/app.module.ts @@ -5,6 +5,7 @@ import {AppComponent} from './app.component'; import { AuthenticationModule, ConfigurationService, + PanelItemComponentRegistryService, CovalentModule, ComponentRegistryService, DialogModule, @@ -49,6 +50,7 @@ import {PasswordFormComponent} from './doc/forms/email-form/password-form.compon import {RegisterFormComponent} from './doc/forms/register-form/register-form.component'; import {HeadersComponent} from './doc/headers/headers.component'; import {PanelsComponent} from './doc/panels/panels.component'; +import {EnumerationValuePanelItemComponent} from './doc/panels/test-enum/enumeration-value-panel-item.component'; import {DashboardExampleComponent} from './doc/dashboard-example/dashboard-example.component'; import {FilterRepositoryExampleComponent} from './doc/filter-repository-example/filter-repository-example.component'; import {ProfileComponent} from './doc/profile/profile.component'; @@ -164,6 +166,7 @@ export function HttpLoaderFactory(http: HttpClient) { RegisterFormComponent, HeadersComponent, PanelsComponent, + EnumerationValuePanelItemComponent, DashboardExampleComponent, FilterRepositoryExampleComponent, ProfileComponent, @@ -285,7 +288,8 @@ export function HttpLoaderFactory(http: HttpClient) { }) export class AppModule { - constructor(registry: ComponentRegistryService) { + constructor(registry: ComponentRegistryService, + panelItemRegistry: PanelItemComponentRegistryService) { registry.register('email', (injector: Injector) => new ComponentPortal(EmailSubmissionFormComponent, null, injector)); registry.register('workflow-view', (injector: Injector) => new ComponentPortal(WorkflowViewExampleComponent, null, injector)); registry.register('task-view', (injector: Injector) => new ComponentPortal(TaskViewComponent, null, injector)); @@ -294,5 +298,11 @@ export class AppModule { registry.register(Dashboard.FILTER_TAB_VIEW_ID, (injector: Injector) => new ComponentPortal(FilterFieldTabViewComponent, null, injector)); registry.registerType(Dashboard.FILTER_CASE_VIEW_ID, FilterFieldTabbedCaseViewComponent); registry.registerType(Dashboard.FILTER_TASK_VIEW_ID, FilterFieldTabbedTaskViewComponent); + + + panelItemRegistry.registerIfAbsent('enumeration', "totok", injector => + new ComponentPortal(EnumerationValuePanelItemComponent, null, injector)); + panelItemRegistry.registerIfAbsent('enumeration_map', "totok", injector => + new ComponentPortal(EnumerationValuePanelItemComponent, null, injector)); } } diff --git a/projects/nae-example-app/src/app/doc/panels/panels.component.ts b/projects/nae-example-app/src/app/doc/panels/panels.component.ts index abb9ed4c84..3c3768777d 100644 --- a/projects/nae-example-app/src/app/doc/panels/panels.component.ts +++ b/projects/nae-example-app/src/app/doc/panels/panels.component.ts @@ -57,13 +57,41 @@ export class PanelsComponent implements OnInit { processIdentifier: 'net', title: 'Case title', icon: 'nature', - immediateData: [], color: 'purple', creationDate: [2020, 4, 6, 13, 37], author: { email: 'example@example.com', fullName: 'Net Grif', }, + immediateData: [ + { + stringId: 'enum_status', + title: 'Enum value', + type: 'enumeration', + value: {defaultValue: 'Approved', translations: {}}, + component: { + name: 'value', + properties: { + 'Approved-background': '#bde3fb', + 'Approved-color': '#0790FF', + } + } + }, + { + stringId: 'icon_status', + title: 'Icon', + type: 'enumeration', + value: {defaultValue: 'Approval', translations: {}}, + component: { + name: 'icon', + optionIcons: [ + {key: 'Preparation', type: 'material', value: 'schedule'}, + {key: 'Approved', type: 'material', value: 'check_circle'}, + {key: 'Approval', type: 'material', value: 'verified'} + ] + } + } + ], resetArcTokens: null, stringId: null, petriNetId: null, @@ -88,8 +116,9 @@ export class PanelsComponent implements OnInit { this.featuredFields$ = new BehaviorSubject>([ new HeaderColumn(HeaderColumnType.META, 'visualId', 'Visual ID', 'text'), new HeaderColumn(HeaderColumnType.META, 'title', 'Title', 'text'), + new HeaderColumn(HeaderColumnType.IMMEDIATE, 'enum_status', 'Enum value', 'enumeration', true, 'net'), + new HeaderColumn(HeaderColumnType.IMMEDIATE, 'icon_status', 'Icon', 'enumeration', true, 'net'), new HeaderColumn(HeaderColumnType.META, 'author', 'Author', 'text'), - new HeaderColumn(HeaderColumnType.META, 'creationDate', 'Creation date', 'text'), ]); this.workflowFields$ = new BehaviorSubject>([ new HeaderColumn(HeaderColumnType.META, WorkflowMetaField.INITIALS, 'Initials', 'text'), diff --git a/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.html b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.html new file mode 100644 index 0000000000..33bed641bf --- /dev/null +++ b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.html @@ -0,0 +1,7 @@ +{{data.featuredValue.value}} diff --git a/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.scss b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.scss new file mode 100644 index 0000000000..fb4e6fd754 --- /dev/null +++ b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.scss @@ -0,0 +1,7 @@ +.enumeration-value-panel-item { + display: inline-block; + min-width: 0; + padding: 4px 12px; + font-weight: 600; + line-height: 24px; +} diff --git a/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.ts b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.ts new file mode 100644 index 0000000000..929772091e --- /dev/null +++ b/projects/nae-example-app/src/app/doc/panels/test-enum/enumeration-value-panel-item.component.ts @@ -0,0 +1,34 @@ +import {Component, Inject} from '@angular/core'; +import {NAE_PANEL_ITEM_DATA, PanelItemPortalData} from 'netgrif-components-core'; + +@Component({ + selector: 'nae-enumeration-value-panel-item', + templateUrl: './enumeration-value-panel-item.component.html', + styleUrls: ['./enumeration-value-panel-item.component.scss'] +}) +export class EnumerationValuePanelItemComponent { + public readonly backgroundColor: string | undefined; + public readonly color: string | undefined; + + constructor(@Inject(NAE_PANEL_ITEM_DATA) public readonly data: PanelItemPortalData) { + const optionKey = this.resolveOptionKey(data.featuredValue.rawValue); + this.backgroundColor = this.resolveStyleProperty(optionKey, 'background'); + this.color = this.resolveStyleProperty(optionKey, 'color'); + } + + private resolveOptionKey(rawValue: unknown): string | undefined { + if (typeof rawValue === 'string') { + return rawValue; + } + if (rawValue && typeof rawValue === 'object' && 'defaultValue' in rawValue + && typeof rawValue.defaultValue === 'string') { + return rawValue.defaultValue; + } + return undefined; + } + + private resolveStyleProperty(optionKey: string | undefined, property: string): string | undefined { + const properties = this.data.featuredValue.component?.properties; + return optionKey ? properties?.[`${optionKey}-${property}`] ?? properties?.[property] : properties?.[property]; + } +} diff --git a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-case-view/tabbed-case-view.component.ts b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-case-view/tabbed-case-view.component.ts index 7083d46bb6..9e634594ce 100644 --- a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-case-view/tabbed-case-view.component.ts +++ b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-case-view/tabbed-case-view.component.ts @@ -15,7 +15,7 @@ import { Filter, NAE_NEW_CASE_CONFIGURATION, NAE_BASE_FILTER, AllowedNetsServiceFactory, AllowedNetsService, SavedFilterMetadata, OverflowService, - HeaderSortingMode, NAE_HEADER_SORTING_MODE, Case + HeaderSortingMode, NAE_HEADER_SORTING_MODE, Case, CasePanelConfiguration, NAE_CASE_PANEL_CONFIGURATION } from '@netgrif/components-core'; import {HeaderComponent} from '@netgrif/components'; import {Subject} from 'rxjs'; @@ -23,6 +23,7 @@ import {Subject} from 'rxjs'; interface ExampleInjectedData extends InjectedTabbedCaseViewData { exampleUseCache: boolean; headerSortingMode: HeaderSortingMode; + showCasePanelIcon?: boolean; } const localAllowedNetsFactory = (factory: AllowedNetsServiceFactory) => { @@ -54,6 +55,10 @@ const headerSortingModeFactory = (injectedTabData: ExampleInjectedData): HeaderS return injectedTabData.headerSortingMode; }; +const casePanelConfigurationFactory = (injectedTabData: ExampleInjectedData): CasePanelConfiguration => { + return {showCasePanelIcon: injectedTabData.showCasePanelIcon ?? true}; +}; + @Component({ selector: 'nae-app-tabbed-case-view', templateUrl: './tabbed-case-view.component.html', @@ -72,7 +77,8 @@ const headerSortingModeFactory = (injectedTabData: ExampleInjectedData): HeaderS deps: [AllowedNetsServiceFactory]}, {provide: NAE_SEARCH_CATEGORIES, useFactory: defaultCaseSearchCategoriesFactory, deps: [CategoryFactory]}, {provide: NAE_NEW_CASE_CONFIGURATION, useFactory: newCaseConfigFactory, deps: [NAE_TAB_DATA]}, - {provide: NAE_HEADER_SORTING_MODE, useFactory: headerSortingModeFactory, deps: [NAE_TAB_DATA]} + {provide: NAE_HEADER_SORTING_MODE, useFactory: headerSortingModeFactory, deps: [NAE_TAB_DATA]}, + {provide: NAE_CASE_PANEL_CONFIGURATION, useFactory: casePanelConfigurationFactory, deps: [NAE_TAB_DATA]} ] }) export class TabbedCaseViewComponent extends AbstractTabbedCaseViewComponent implements AfterViewInit { diff --git a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.spec.ts b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.spec.ts index 971a1eb53a..546b66fef8 100644 --- a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.spec.ts +++ b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.spec.ts @@ -34,7 +34,8 @@ describe('TabViewExampleComponent', () => { })); expect(component.tabs[1].injectedObject).toEqual(jasmine.objectContaining({ exampleUseCache: false, - headerSortingMode: HeaderSortingMode.MULTI + headerSortingMode: HeaderSortingMode.MULTI, + showCasePanelIcon: false })); expect(component.tabs[2].injectedObject).toEqual(jasmine.objectContaining({ exampleUseCache: true, diff --git a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.ts b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.ts index 4500a59b92..a31cb3c02c 100644 --- a/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.ts +++ b/projects/nae-example-app/src/app/doc/tabbed-case-view/tabbed-views-example.component.ts @@ -58,7 +58,8 @@ export class TabbedViewsExampleComponent { tabViewComponent: TabbedTaskViewComponent, tabViewOrder: 0, exampleUseCache: false, - headerSortingMode: HeaderSortingMode.MULTI + headerSortingMode: HeaderSortingMode.MULTI, + showCasePanelIcon: false } }, { diff --git a/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.spec.ts b/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.spec.ts index 1eabab9bf5..68f75831ac 100644 --- a/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.spec.ts +++ b/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.spec.ts @@ -11,6 +11,8 @@ import {ConfigurationService} from '../../configuration/configuration.service'; import {MaterialModule} from '../../material/material.module'; import {PaperViewService} from '../../navigation/quick-panel/components/paper-view.service'; import {ComponentRegistryService} from "../../registry/component-registry.service"; +import {ComponentPortal} from '@angular/cdk/portal'; +import {EnumerationField} from '../enumeration-field/models/enumeration-field'; describe('AbstractDataFieldTemplateComponent', () => { let component: TestDatafieldTemplateComponent; @@ -19,7 +21,8 @@ describe('AbstractDataFieldTemplateComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [MaterialModule, AngularResizeEventModule, NoopAnimationsModule], - declarations: [TestDatafieldTemplateComponent, TestWrapperComponent], + declarations: [TestDatafieldTemplateComponent, TestWrapperComponent, TestDataFieldComponent, + TestExactDataFieldComponent], providers: [ {provide: ConfigurationService, useClass: TestConfigurationService}, {provide: ViewService, useClass: TestViewService}, @@ -38,6 +41,28 @@ describe('AbstractDataFieldTemplateComponent', () => { expect(component).toBeTruthy(); }); + it('should use the default data field component when the requested component is not registered', () => { + const registry = TestBed.inject(ComponentRegistryService); + registry.register('enumeration-default', injector => new ComponentPortal(TestDataFieldComponent, null, injector)); + + component.dataField = new EnumerationField('', '', 'Approved', [], {}, undefined, undefined, + undefined, undefined, [], {name: 'totok'}); + + expect(component.componentPortal).toBeTruthy(); + expect(component.componentPortal.component).toBe(TestDataFieldComponent); + }); + + it('should prefer the requested data field component over the default component', () => { + const registry = TestBed.inject(ComponentRegistryService); + registry.register('enumeration-default', injector => new ComponentPortal(TestDataFieldComponent, null, injector)); + registry.register('enumeration-totok', injector => new ComponentPortal(TestExactDataFieldComponent, null, injector)); + + component.dataField = new EnumerationField('', '', 'Approved', [], {}, undefined, undefined, + undefined, undefined, [], {name: 'totok'}); + + expect(component.componentPortal.component).toBe(TestExactDataFieldComponent); + }); + afterEach(() => { TestBed.resetTestingModule(); }); @@ -56,6 +81,20 @@ class TestDatafieldTemplateComponent extends AbstractDataFieldTemplateComponent } } +@Component({ + selector: 'ncc-test-data-field', + template: '' +}) +class TestDataFieldComponent { +} + +@Component({ + selector: 'ncc-test-exact-data-field', + template: '' +}) +class TestExactDataFieldComponent { +} + @Component({ selector: 'ncc-test-wrapper', template: '' diff --git a/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.ts b/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.ts index 296824da72..0292e6d04b 100644 --- a/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.ts +++ b/projects/netgrif-components-core/src/lib/data-fields/data-field-template/abstract-data-field-template.component.ts @@ -8,7 +8,6 @@ import {FormControl} from "@angular/forms"; import {ComponentPortal} from "@angular/cdk/portal"; import {ComponentRegistryService} from "../../registry/component-registry.service"; import {DATA_FIELD_PORTAL_DATA, DataFieldPortalData} from "../models/data-field-portal-data-injection-token"; -import {ButtonField} from '../button-field/models/button-field'; /** * Provides a responsive layout to data fields where their appearance can change based on the width of space they have available. @@ -128,11 +127,12 @@ export abstract class AbstractDataFieldTemplateComponent implements OnInit { public hasComponent(): boolean { - return this._componentRegistry.contains(this.dataField.getTypedComponentType()); + return this.resolveRegisteredComponentType(this.dataField) !== undefined; } public resolveComponentPortal(dataField: DataField, showLargeLayout: WrappedBoolean, formControlRef: FormControl, additionalFieldProperties?: {[k:string]: string | number}): ComponentPortal { - if (this.hasComponent()) { + const componentType = this.resolveRegisteredComponentType(dataField); + if (componentType) { const portalInjector = Injector.create({ providers: [ { @@ -146,11 +146,20 @@ export abstract class AbstractDataFieldTemplateComponent implements OnInit { }], parent: this.injector }); - return this._componentRegistry.get(this.dataField.getTypedComponentType(), portalInjector); + return this._componentRegistry.get(componentType, portalInjector); } return undefined; } + protected resolveRegisteredComponentType(dataField: DataField): string | undefined { + const componentType = dataField.getTypedComponentType(); + if (this._componentRegistry.contains(componentType)) { + return componentType; + } + const defaultComponentType = dataField.getTypedDefaultComponentType(); + return this._componentRegistry.contains(defaultComponentType) ? defaultComponentType : undefined; + } + /** * @returns `false` if the data field uses the `TemplateAppearance.MATERIAL` and `true` otherwise. */ diff --git a/projects/netgrif-components-core/src/lib/data-fields/models/abstract-data-field.ts b/projects/netgrif-components-core/src/lib/data-fields/models/abstract-data-field.ts index 9f3b1b19e3..944b019776 100644 --- a/projects/netgrif-components-core/src/lib/data-fields/models/abstract-data-field.ts +++ b/projects/netgrif-components-core/src/lib/data-fields/models/abstract-data-field.ts @@ -388,6 +388,15 @@ export abstract class DataField { return this.component?.name ?? DEFAULT; } + public getTypedDefaultComponentType(): string { + const componentType = this.getComponentType(); + const typedComponentType = this.getTypedComponentType(); + if (componentType === DEFAULT || !typedComponentType.endsWith(componentType)) { + return typedComponentType; + } + return `${typedComponentType.slice(0, -componentType.length)}${DEFAULT}`; + } + public abstract getTypedComponentType(): string; public destroy(): void { diff --git a/projects/netgrif-components-core/src/lib/panel/abstract/featured-value.ts b/projects/netgrif-components-core/src/lib/panel/abstract/featured-value.ts index b18a4525c2..7d9333c536 100644 --- a/projects/netgrif-components-core/src/lib/panel/abstract/featured-value.ts +++ b/projects/netgrif-components-core/src/lib/panel/abstract/featured-value.ts @@ -1,12 +1,15 @@ import {FilterMetadataAllowedNets} from '../../search/models/persistance/filter-metadata-allowed-nets'; +import {Component} from '../../data-fields/models/component'; /** * Represents a value featured on a panel */ export interface FeaturedValue { value: string; + rawValue?: unknown; icon: string; type: string; + component?: Component; /** * Only for immediate filter fields */ diff --git a/projects/netgrif-components-core/src/lib/panel/abstract/panel-with-immediate-data.ts b/projects/netgrif-components-core/src/lib/panel/abstract/panel-with-immediate-data.ts index 5945dde083..e8dd5243d9 100644 --- a/projects/netgrif-components-core/src/lib/panel/abstract/panel-with-immediate-data.ts +++ b/projects/netgrif-components-core/src/lib/panel/abstract/panel-with-immediate-data.ts @@ -26,57 +26,69 @@ export abstract class AbstractPanelWithImmediateDataComponent extends AbstractPa protected parseImmediateValue(immediate: ImmediateData): FeaturedValue { if (immediate && immediate.value !== undefined || immediate && immediate.type === 'button') { + let featuredValue: FeaturedValue; switch (immediate.type) { case 'date': - return { + featuredValue = { value: toMoment(immediate.value as NaeDate).format(DATE_FORMAT_STRING), icon: 'event', type: immediate.type }; + break; case 'dateTime': - return { + featuredValue = { value: toMoment(immediate.value as NaeDate).format(DATE_TIME_FORMAT_STRING), icon: 'event', type: immediate.type }; + break; case 'enumeration': - return {value: this.getTranslation(immediate.value), icon: undefined, type: immediate.type}; + featuredValue = {value: this.getTranslation(immediate.value), icon: undefined, type: immediate.type}; + break; case 'multichoice': - return { + featuredValue = { value: immediate.value.map(it => this.getTranslation(it)).join(', '), icon: undefined, type: immediate.type }; + break; case 'enumeration_map': - return { + featuredValue = { value: this.getTranslation(immediate.options[immediate.value]), icon: undefined, type: immediate.type }; + break; case 'multichoice_map': - return { + featuredValue = { value: immediate.value.map(it => this.getTranslation(immediate.options[it])).join(', '), icon: undefined, type: immediate.type }; + break; case 'file': - return {value: immediate.value?.name, icon: 'insert_drive_file', type: immediate.type}; + featuredValue = {value: immediate.value?.name, icon: 'insert_drive_file', type: immediate.type}; + break; case 'fileList': - return { + featuredValue = { value: immediate.value?.namesPaths.map(obj => obj.name).join(', '), icon: 'file_copy', type: immediate.type }; + break; case 'actorList': - return {value: immediate.value?.actorValues?.map(obj => obj.fullName).join(', '), icon: 'account_circle', type: immediate.type}; + featuredValue = {value: immediate.value?.actorValues?.map(obj => obj.fullName).join(', '), icon: 'account_circle', type: immediate.type}; + break; case 'actor': - return {value: immediate.value?.fullName, icon: 'account_circle', type: immediate.type}; + featuredValue = {value: immediate.value?.fullName, icon: 'account_circle', type: immediate.type}; + break; case 'boolean': - return { + featuredValue = { value: this._translate.instant('dataField.values.boolean.' + immediate.value), icon: undefined, type: immediate.type }; + break; case 'button': let buttonValue: string; if ((immediate as any).placeholder?.defaultValue !== undefined) { @@ -86,28 +98,35 @@ export abstract class AbstractPanelWithImmediateDataComponent extends AbstractPa } else { buttonValue = this._translate.instant('dialog.submit'); } - return { + featuredValue = { value: buttonValue, icon: undefined, type: immediate.type }; + break; case 'filter': - return { + featuredValue = { value: undefined, icon: undefined, type: immediate.type, filterMetadata: {filterMetadata: immediate.filterMetadata, allowedNets: immediate.allowedNets} }; + break; case 'number': if (immediate.format !== undefined) { - return this.formatCurrencyPipe(immediate.value, immediate.format.code, immediate.format.fractionSize, + featuredValue = this.formatCurrencyPipe(immediate.value, immediate.format.code, immediate.format.fractionSize, immediate.format.locale, immediate.type); } else if (immediate.component?.name === 'currency') { - return this.formatCurrencyPipe(immediate.value, immediate.component.properties['code'], + featuredValue = this.formatCurrencyPipe(immediate.value, immediate.component.properties['code'], immediate.component.properties['fractionSize'], immediate.component.properties['locale'], immediate.type); + } else { + featuredValue = {value: immediate.value, icon: undefined, type: immediate.type}; } - return {value: immediate.value, icon: undefined, type: immediate.type}; + break; default: - return {value: immediate.value, icon: undefined, type: immediate.type}; + featuredValue = {value: immediate.value, icon: undefined, type: immediate.type}; } + featuredValue.component = immediate.component; + featuredValue.rawValue = immediate.value; + return featuredValue; } else { return {value: '', icon: '', type: ''}; } diff --git a/projects/netgrif-components-core/src/lib/panel/case-panel/abstract-case-panel.component.ts b/projects/netgrif-components-core/src/lib/panel/case-panel/abstract-case-panel.component.ts index e669acb59b..867c88173d 100644 --- a/projects/netgrif-components-core/src/lib/panel/case-panel/abstract-case-panel.component.ts +++ b/projects/netgrif-components-core/src/lib/panel/case-panel/abstract-case-panel.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, Optional} from '@angular/core'; +import {Component, inject, Input, Optional} from '@angular/core'; import {Observable} from 'rxjs'; import {Case} from '../../resources/interface/case'; import {toMoment} from '../../resources/types/nae-date-type'; @@ -21,6 +21,7 @@ import {CurrencyPipe} from '@angular/common'; import {PermissionService} from '../../authorization/permission/permission.service'; import {PermissionType} from '../../process/permissions'; import {FormControl} from '@angular/forms'; +import {NAE_CASE_PANEL_CONFIGURATION} from './case-panel-configuration-injection-token'; @Component({ selector: 'ncc-abstract-case-panel', @@ -28,13 +29,14 @@ import {FormControl} from '@angular/forms'; }) export abstract class AbstractCasePanelComponent extends AbstractPanelWithImmediateDataComponent { + private readonly _casePanelConfiguration = inject(NAE_CASE_PANEL_CONFIGURATION); @Input() public case_: Case; @Input() public approval: boolean; @Input() responsiveBody = true; @Input() first: boolean; @Input() last: boolean; - @Input() showCasePanelIcon = true; + @Input() showCasePanelIcon = this._casePanelConfiguration.showCasePanelIcon; @Input() showDeleteMenu = false; @Input() textEllipsis = false; protected _approvalFormControl: FormControl; diff --git a/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.spec.ts b/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.spec.ts new file mode 100644 index 0000000000..ecbcf75fe5 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.spec.ts @@ -0,0 +1,20 @@ +import {TestBed} from '@angular/core/testing'; +import {NAE_CASE_PANEL_CONFIGURATION} from './case-panel-configuration-injection-token'; + +describe('NAE_CASE_PANEL_CONFIGURATION', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('should show the case panel icon by default', () => { + expect(TestBed.inject(NAE_CASE_PANEL_CONFIGURATION).showCasePanelIcon).toBeTrue(); + }); + + it('should support an application-level override', () => { + TestBed.configureTestingModule({ + providers: [ + {provide: NAE_CASE_PANEL_CONFIGURATION, useValue: {showCasePanelIcon: false}} + ] + }); + + expect(TestBed.inject(NAE_CASE_PANEL_CONFIGURATION).showCasePanelIcon).toBeFalse(); + }); +}); diff --git a/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.ts b/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.ts new file mode 100644 index 0000000000..8575216c65 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/panel/case-panel/case-panel-configuration-injection-token.ts @@ -0,0 +1,10 @@ +import {InjectionToken} from '@angular/core'; + +export interface CasePanelConfiguration { + readonly showCasePanelIcon: boolean; +} + +export const NAE_CASE_PANEL_CONFIGURATION = new InjectionToken('NaeCasePanelConfiguration', { + providedIn: 'root', + factory: () => ({showCasePanelIcon: true}) +}); diff --git a/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.spec.ts b/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.spec.ts index 688896ad21..612bf03406 100644 --- a/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.spec.ts +++ b/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.spec.ts @@ -1,9 +1,10 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { Component} from '@angular/core'; +import {Component, Injector} from '@angular/core'; import { AbstractPanelItemComponent } from './abstract-panel-item.component'; import { FeaturedValue } from '../abstract/featured-value'; +import {PanelItemComponentRegistryService} from './panel-item-component-registry.service'; describe('AbstractPanelItemComponent', () => { let component: TestPanelItemComponent; @@ -46,8 +47,8 @@ describe('AbstractPanelItemComponent', () => { template: '' }) class TestPanelItemComponent extends AbstractPanelItemComponent { - constructor() { - super(); + constructor(registry: PanelItemComponentRegistryService, injector: Injector) { + super(registry, injector); this.leadingIcon = 'label'; this.leadingIconEnabled = true; this.textEllipsis = true; diff --git a/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.ts b/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.ts index b5db6072a4..321b7df228 100644 --- a/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.ts +++ b/projects/netgrif-components-core/src/lib/panel/panel-item/abstract-panel-item.component.ts @@ -1,18 +1,43 @@ -import {Component, Input} from '@angular/core'; -import { FeaturedValue } from '../abstract/featured-value'; +import {Component, Injector, Input, OnChanges} from '@angular/core'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {FeaturedValue} from '../abstract/featured-value'; +import {PanelItemComponentRegistryService} from './panel-item-component-registry.service'; +import {NAE_PANEL_ITEM_DATA, PanelItemPortalData} from './panel-item-portal-data-injection-token'; @Component({ selector: 'ncc-abstract-panel-item-component', template: '' }) -export abstract class AbstractPanelItemComponent { +export abstract class AbstractPanelItemComponent implements OnChanges { @Input() leadingIcon: string; @Input() leadingIconEnabled: boolean; @Input() featuredValue: FeaturedValue; @Input() textEllipsis = false; - constructor() { + public componentPortal: ComponentPortal | undefined; + protected constructor(protected readonly registry: PanelItemComponentRegistryService, + protected readonly injector: Injector) { + } + + public ngOnChanges(): void { + this.componentPortal = this.resolveComponentPortal(); + } + + protected resolveComponentPortal(): ComponentPortal | undefined { + const componentName = this.featuredValue?.component?.name; + const portalData: PanelItemPortalData = { + featuredValue: this.featuredValue, + textEllipsis: this.textEllipsis + }; + const portalInjector = Injector.create({ + providers: [{provide: NAE_PANEL_ITEM_DATA, useValue: portalData}], + parent: this.injector + }); + if (componentName && this.registry.contains(this.featuredValue.type, componentName)) { + return this.registry.get(this.featuredValue.type, componentName, portalInjector); + } + return this.registry.getDefault(this.featuredValue.type, portalInjector); } } diff --git a/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.spec.ts b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.spec.ts new file mode 100644 index 0000000000..1b0b879bd8 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.spec.ts @@ -0,0 +1,86 @@ +import {Component, Injector} from '@angular/core'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {PanelItemComponentRegistryService} from './panel-item-component-registry.service'; + +describe('PanelItemComponentRegistryService', () => { + let service: PanelItemComponentRegistryService; + + beforeEach(() => { + service = new PanelItemComponentRegistryService(); + }); + + it('should register and resolve a panel item component', () => { + service.register('text', 'custom', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + + expect(service.contains('text', 'custom')).toBeTrue(); + expect(service.get('text', 'custom', Injector.create({providers: []})).component).toBe(TestPanelItemComponent); + }); + + it('should keep renderers with the same component name separated by field type', () => { + service.register('text', 'custom', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + + expect(service.contains('number', 'custom')).toBeFalse(); + expect(service.get('number', 'custom', Injector.create({providers: []}))).toBeUndefined(); + }); + + it('should keep ambiguous field type and component name combinations separated', () => { + service.register('date-time', 'custom', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + service.register('date', 'time-custom', injector => new ComponentPortal(AlternativePanelItemComponent, null, injector)); + + expect(service.get('date-time', 'custom', Injector.create({providers: []})).component) + .toBe(TestPanelItemComponent); + expect(service.get('date', 'time-custom', Injector.create({providers: []})).component) + .toBe(AlternativePanelItemComponent); + }); + + it('should not replace an existing renderer when registering a default', () => { + service.register('text', 'custom', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + + const registered = service.registerIfAbsent('text', 'custom', injector => + new ComponentPortal(AlternativePanelItemComponent, null, injector)); + + expect(registered).toBeFalse(); + expect(service.get('text', 'custom', Injector.create({providers: []})).component) + .toBe(TestPanelItemComponent); + }); + + it('should register a default renderer when no renderer exists', () => { + const registered = service.registerIfAbsent('text', 'custom', injector => + new ComponentPortal(TestPanelItemComponent, null, injector)); + + expect(registered).toBeTrue(); + expect(service.contains('text', 'custom')).toBeTrue(); + }); + + it('should register and resolve a field type default renderer', () => { + service.registerDefault('enumeration', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + + expect(service.getDefault('enumeration', Injector.create({providers: []})).component) + .toBe(TestPanelItemComponent); + }); + + it('should not replace an existing field type default renderer', () => { + service.registerDefault('enumeration', injector => new ComponentPortal(TestPanelItemComponent, null, injector)); + + const registered = service.registerDefaultIfAbsent('enumeration', injector => + new ComponentPortal(AlternativePanelItemComponent, null, injector)); + + expect(registered).toBeFalse(); + expect(service.getDefault('enumeration', Injector.create({providers: []})).component) + .toBe(TestPanelItemComponent); + }); +}); + +@Component({ + selector: 'ncc-test-panel-item', + template: '' +}) +class TestPanelItemComponent { +} + +@Component({ + selector: 'ncc-alternative-panel-item', + template: '' +}) +class AlternativePanelItemComponent { +} diff --git a/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.ts b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.ts new file mode 100644 index 0000000000..b0f040925b --- /dev/null +++ b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-component-registry.service.ts @@ -0,0 +1,58 @@ +import {Injectable, Injector} from '@angular/core'; +import {ComponentPortal} from '@angular/cdk/portal'; + +export type PanelItemComponentFactory = (injector: Injector) => ComponentPortal; + +@Injectable({ + providedIn: 'root' +}) +export class PanelItemComponentRegistryService { + + private readonly registry = new Map>(); + private readonly defaultRegistry = new Map(); + + public register(fieldType: string, componentName: string, factory: PanelItemComponentFactory): void { + this.getOrCreateFieldTypeRegistry(fieldType).set(componentName, factory); + } + + public registerIfAbsent(fieldType: string, componentName: string, factory: PanelItemComponentFactory): boolean { + if (this.contains(fieldType, componentName)) { + return false; + } + this.register(fieldType, componentName, factory); + return true; + } + + public registerDefault(fieldType: string, factory: PanelItemComponentFactory): void { + this.defaultRegistry.set(fieldType, factory); + } + + public registerDefaultIfAbsent(fieldType: string, factory: PanelItemComponentFactory): boolean { + if (this.defaultRegistry.has(fieldType)) { + return false; + } + this.registerDefault(fieldType, factory); + return true; + } + + public contains(fieldType: string, componentName: string): boolean { + return this.registry.get(fieldType)?.has(componentName) ?? false; + } + + public get(fieldType: string, componentName: string, injector: Injector): ComponentPortal | undefined { + return this.registry.get(fieldType)?.get(componentName)?.(injector); + } + + public getDefault(fieldType: string, injector: Injector): ComponentPortal | undefined { + return this.defaultRegistry.get(fieldType)?.(injector); + } + + private getOrCreateFieldTypeRegistry(fieldType: string): Map { + let fieldTypeRegistry = this.registry.get(fieldType); + if (!fieldTypeRegistry) { + fieldTypeRegistry = new Map(); + this.registry.set(fieldType, fieldTypeRegistry); + } + return fieldTypeRegistry; + } +} diff --git a/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-portal-data-injection-token.ts b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-portal-data-injection-token.ts new file mode 100644 index 0000000000..0ff6e97c63 --- /dev/null +++ b/projects/netgrif-components-core/src/lib/panel/panel-item/panel-item-portal-data-injection-token.ts @@ -0,0 +1,9 @@ +import {InjectionToken} from '@angular/core'; +import {FeaturedValue} from '../abstract/featured-value'; + +export interface PanelItemPortalData { + featuredValue: FeaturedValue; + textEllipsis: boolean; +} + +export const NAE_PANEL_ITEM_DATA = new InjectionToken('NaePanelItemData'); diff --git a/projects/netgrif-components-core/src/lib/panel/public-api.ts b/projects/netgrif-components-core/src/lib/panel/public-api.ts index abcceadb52..47c23cd03b 100644 --- a/projects/netgrif-components-core/src/lib/panel/public-api.ts +++ b/projects/netgrif-components-core/src/lib/panel/public-api.ts @@ -1,12 +1,15 @@ /* COMPONENTS */ export * from './abstract-panel.component'; export * from './case-panel/abstract-case-panel.component'; +export * from './case-panel/case-panel-configuration-injection-token'; export * from './task-panel/abstract-task-panel.component'; export * from './workflow-panel/abstract-workflow-panel.component'; export * from './abstract/tabbed-virtual-scroll.component'; export * from './immediate/abstract-immediate-filter-text.component'; export * from './immediate/abstract-immediate-filter-text-content.component'; export * from './panel-item/abstract-panel-item.component'; +export * from './panel-item/panel-item-component-registry.service'; +export * from './panel-item/panel-item-portal-data-injection-token'; export * from './task-panel-single/abstract-single-task.component'; /* DATA */ diff --git a/projects/netgrif-components-core/src/lib/view/case-view/components/default-case-list/abstract-default-case-list.component.ts b/projects/netgrif-components-core/src/lib/view/case-view/components/default-case-list/abstract-default-case-list.component.ts index 6f09d38a97..8e6ceaa6ee 100644 --- a/projects/netgrif-components-core/src/lib/view/case-view/components/default-case-list/abstract-default-case-list.component.ts +++ b/projects/netgrif-components-core/src/lib/view/case-view/components/default-case-list/abstract-default-case-list.component.ts @@ -1,4 +1,4 @@ -import {Component, EventEmitter, Inject, Input, OnDestroy, Optional, Output} from '@angular/core'; +import {Component, EventEmitter, inject, Inject, Input, OnDestroy, Optional, Output} from '@angular/core'; import {Observable, Subject, Subscription} from 'rxjs'; import {Case} from '../../../../resources/interface/case'; import {HeaderColumn} from '../../../../header/models/header-column'; @@ -9,6 +9,7 @@ import {InjectedTabData} from '../../../../tabs/interfaces'; import {ActivatedRoute} from '@angular/router'; import {filter, takeUntil} from 'rxjs/operators'; import {TabbedVirtualScrollComponent} from '../../../../panel/abstract/tabbed-virtual-scroll.component'; +import {NAE_CASE_PANEL_CONFIGURATION} from '../../../../panel/case-panel/case-panel-configuration-injection-token'; @Component({ selector: 'ncc-abstract-default-case-list', @@ -16,10 +17,12 @@ import {TabbedVirtualScrollComponent} from '../../../../panel/abstract/tabbed-vi }) export abstract class AbstractDefaultCaseListComponent extends TabbedVirtualScrollComponent implements OnDestroy { + private readonly _casePanelConfiguration = inject(NAE_CASE_PANEL_CONFIGURATION); + @Input() selectedHeaders$: Observable>; @Input() responsiveBody = true; @Output() caseClick: EventEmitter; - @Input() showCasePanelIcon = true; + @Input() showCasePanelIcon = this._casePanelConfiguration.showCasePanelIcon; @Input() showDeleteMenu = false; @Input() textEllipsis = false; @Input() width: string; diff --git a/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.html b/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.html index 9d2f0f6132..69b527060d 100644 --- a/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.html +++ b/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.html @@ -15,7 +15,7 @@ [ngClass]="{'panel-header-padding': !textEllipsis && field.type !== 'button'}"> diff --git a/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.spec.ts b/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.spec.ts index ce35de387b..0692ff4905 100644 --- a/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.spec.ts +++ b/projects/netgrif-components/src/lib/panel/case-panel/case-panel.component.spec.ts @@ -29,6 +29,7 @@ import { MockAuthenticationMethodService } from '@netgrif/components-core'; import { RouterTestingModule } from '@angular/router/testing'; +import {By} from '@angular/platform-browser'; describe('CasePanelComponent', () => { let component: CasePanelComponent; @@ -72,6 +73,14 @@ describe('CasePanelComponent', () => { expect(component).toBeTruthy(); }); + it('should hide the leading case icon when disabled', () => { + fixture.componentInstance.showCasePanelIcon = false; + fixture.detectChanges(); + + const panelItems = fixture.debugElement.queryAll(By.css('nc-panel-item')); + expect(panelItems[0].properties['leadingIconEnabled']).toBeFalse(); + }); + afterEach(() => { TestBed.resetTestingModule(); }); @@ -79,9 +88,10 @@ describe('CasePanelComponent', () => { @Component({ selector: 'nc-test-wrapper', - template: ' ' + template: ' ' }) class TestWrapperComponent { + showCasePanelIcon = true; selectedHeaders = of([ new HeaderColumn(HeaderColumnType.META, CaseMetaField.VISUAL_ID, 'string', 'string'), new HeaderColumn(HeaderColumnType.META, CaseMetaField.AUTHOR, 'string', 'string'), diff --git a/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.html b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.html new file mode 100644 index 0000000000..2e54229ca3 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.html @@ -0,0 +1,9 @@ + + {{icon?.value}} + + {{data.featuredValue.value}} + diff --git a/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.scss b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.scss new file mode 100644 index 0000000000..605c3e8c3e --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.scss @@ -0,0 +1,18 @@ +@use '../panel-item-renderer'; + +.enumeration-icon-panel-item { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.enumeration-icon-panel-item mat-icon { + flex: 0 0 auto; +} + +.enumeration-icon-panel-item > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.spec.ts b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.spec.ts new file mode 100644 index 0000000000..96c238fe61 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.spec.ts @@ -0,0 +1,103 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import { + MaterialModule, + NAE_PANEL_ITEM_DATA, + PanelItemPortalData +} from '@netgrif/components-core'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatIconRegistry} from '@angular/material/icon'; +import {DomSanitizer} from '@angular/platform-browser'; +import {EnumerationIconPanelItemComponent} from './enumeration-icon-panel-item.component'; + +describe('EnumerationIconPanelItemComponent', () => { + let fixture: ComponentFixture; + let portalData: PanelItemPortalData; + + beforeEach(async () => { + portalData = { + featuredValue: { + type: 'enumeration', + icon: undefined, + value: 'Approved', + rawValue: {defaultValue: 'approved', translations: {}}, + component: { + name: 'icon', + optionIcons: [ + {key: 'approved', type: 'material', value: 'check_circle'}, + {key: 'rejected', type: 'material', value: 'cancel'} + ] + } + }, + textEllipsis: true + } as PanelItemPortalData; + + await TestBed.configureTestingModule({ + imports: [MaterialModule, NoopAnimationsModule], + declarations: [EnumerationIconPanelItemComponent], + providers: [{ + provide: NAE_PANEL_ITEM_DATA, + useFactory: () => portalData + }] + }).compileComponents(); + }); + + function createComponent(): void { + fixture = TestBed.createComponent(EnumerationIconPanelItemComponent); + fixture.detectChanges(); + } + + it('should render the icon matching the raw enumeration key', () => { + createComponent(); + + const icon: HTMLElement = fixture.nativeElement.querySelector('mat-icon'); + + expect(icon.textContent.trim()).toBe('check_circle'); + expect(fixture.nativeElement.textContent).toContain('Approved'); + }); + + it('should render the matching SVG icon', () => { + const iconRegistry = TestBed.inject(MatIconRegistry); + const sanitizer = TestBed.inject(DomSanitizer); + iconRegistry.addSvgIconLiteral('approved-status', sanitizer.bypassSecurityTrustHtml('')); + portalData.featuredValue.component.optionIcons = [ + {key: 'approved', type: 'svg', value: 'approved-status'} + ]; + + createComponent(); + + const icon: HTMLElement = fixture.nativeElement.querySelector('mat-icon'); + expect(icon.getAttribute('data-mat-icon-name')).toBe('approved-status'); + expect(fixture.nativeElement.textContent).toContain('Approved'); + }); + + it('should match an enumeration map icon by its string key', () => { + portalData.featuredValue.type = 'enumeration_map'; + portalData.featuredValue.rawValue = 'approved'; + + createComponent(); + + const icon: HTMLElement = fixture.nativeElement.querySelector('mat-icon'); + expect(icon.textContent.trim()).toBe('check_circle'); + expect(fixture.nativeElement.textContent).toContain('Approved'); + }); + + it('should render only the value when no icon matches the raw enumeration key', () => { + portalData.featuredValue.rawValue = {defaultValue: 'unknown', translations: {}}; + + createComponent(); + + expect(fixture.nativeElement.querySelector('mat-icon')).toBeNull(); + expect(fixture.nativeElement.textContent.trim()).toBe('Approved'); + }); + + it('should prevent the value from overflowing its cell', () => { + createComponent(); + + const value: HTMLElement = fixture.nativeElement.querySelector('.enumeration-icon-panel-item > span'); + const styles = getComputedStyle(value); + + expect(styles.minWidth).toBe('0px'); + expect(styles.overflow).toBe('hidden'); + expect(styles.textOverflow).toBe('ellipsis'); + }); +}); diff --git a/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.ts b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.ts new file mode 100644 index 0000000000..d871292917 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component.ts @@ -0,0 +1,29 @@ +import {Component, Inject} from '@angular/core'; +import {Icon, NAE_PANEL_ITEM_DATA, PanelItemPortalData} from '@netgrif/components-core'; + +@Component({ + selector: 'nc-enumeration-icon-panel-item', + templateUrl: './enumeration-icon-panel-item.component.html', + styleUrls: ['./enumeration-icon-panel-item.component.scss'] +}) +export class EnumerationIconPanelItemComponent { + + public readonly icon: Icon | undefined; + + constructor(@Inject(NAE_PANEL_ITEM_DATA) public readonly data: PanelItemPortalData) { + const optionKey = this.resolveOptionKey(data.featuredValue.rawValue); + this.icon = data.featuredValue.component?.optionIcons?.find(optionIcon => + optionIcon.key === optionKey); + } + + private resolveOptionKey(rawValue: unknown): string | undefined { + if (typeof rawValue === 'string') { + return rawValue; + } + if (rawValue && typeof rawValue === 'object' && 'defaultValue' in rawValue + && typeof rawValue.defaultValue === 'string') { + return rawValue.defaultValue; + } + return undefined; + } +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.html b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.html new file mode 100644 index 0000000000..52dbbca7d8 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.html @@ -0,0 +1,5 @@ + + diff --git a/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.scss b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.scss new file mode 100644 index 0000000000..5198c73cea --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.scss @@ -0,0 +1,54 @@ +@use '../panel-item-renderer'; + +:host { + display: flex; + align-items: center; +} + +:host(.panel-item-ellipsis) { + height: 24px; + min-height: 24px; +} + +.panel-html { + display: inline-block; + line-height: 20px; + vertical-align: middle; +} + +.panel-html.text-column-ellipsis { + height: 20px; + max-height: 20px; +} + +.panel-html.text-column-ellipsis ::ng-deep br { + display: none; +} + +.panel-html ::ng-deep * { + line-height: inherit; + vertical-align: baseline; +} + +.panel-html ::ng-deep p, +.panel-html ::ng-deep div, +.panel-html ::ng-deep h1, +.panel-html ::ng-deep h2, +.panel-html ::ng-deep h3, +.panel-html ::ng-deep h4, +.panel-html ::ng-deep h5, +.panel-html ::ng-deep h6 { + display: inline; + margin: 0; + padding: 0; + font: inherit; +} + +.panel-html ::ng-deep ul, +.panel-html ::ng-deep ol, +.panel-html ::ng-deep blockquote, +.panel-html ::ng-deep pre, +.panel-html ::ng-deep table { + margin: 0; + padding: 0; +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.spec.ts b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.spec.ts new file mode 100644 index 0000000000..d4a5848dbf --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.spec.ts @@ -0,0 +1,85 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {HtmlPanelItemComponent} from './html-panel-item.component'; +import { + MaterialModule, + NAE_PANEL_ITEM_DATA, + PanelItemPortalData +} from '@netgrif/components-core'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {By} from '@angular/platform-browser'; +import {MatTooltip} from '@angular/material/tooltip'; + +describe('HtmlPanelItemComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MaterialModule, NoopAnimationsModule], + declarations: [HtmlPanelItemComponent], + providers: [{ + provide: NAE_PANEL_ITEM_DATA, + useValue: { + featuredValue: { + type: 'text', + icon: undefined, + value: '


Duck Duck Go · ' + + 'Approved

', + component: {name: 'htmltextarea'} + }, + textEllipsis: true + } as PanelItemPortalData + }] + }).compileComponents(); + + fixture = TestBed.createComponent(HtmlPanelItemComponent); + fixture.detectChanges(); + }); + + it('should render sanitized HTML elements', () => { + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + const badge: HTMLElement = fixture.nativeElement.querySelector('mark'); + + expect(anchor.textContent).toBe('Duck Duck Go'); + expect(badge.textContent).toBe('Approved'); + expect(anchor.target).toBe('_blank'); + expect(anchor.rel).toBe('nofollow noopener noreferrer'); + expect(fixture.nativeElement.querySelector('script')).toBeNull(); + expect(fixture.debugElement.query(By.directive(MatTooltip))).toBeNull(); + }); + + it('should isolate link clicks from the case panel', () => { + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + const event = new MouseEvent('click', {bubbles: true, cancelable: true}); + const stopPropagation = spyOn(event, 'stopPropagation'); + anchor.addEventListener('click', clickEvent => clickEvent.preventDefault()); + + anchor.dispatchEvent(event); + + expect(stopPropagation).toHaveBeenCalled(); + }); + + it('should constrain rendered content to one clipped line in ellipsis mode', () => { + const content: HTMLElement = fixture.nativeElement.querySelector('.panel-text'); + const paragraph: HTMLElement = fixture.nativeElement.querySelector('p'); + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + const host: HTMLElement = fixture.nativeElement; + const styles = getComputedStyle(content); + const paragraphStyles = getComputedStyle(paragraph); + const hostBounds = host.getBoundingClientRect(); + const contentBounds = content.getBoundingClientRect(); + const anchorBounds = anchor.getBoundingClientRect(); + + expect(hostBounds.height).toBe(24); + expect(styles.maxWidth).toBe('100%'); + expect(styles.height).toBe('20px'); + expect(styles.maxHeight).toBe('20px'); + expect(styles.lineHeight).toBe('20px'); + expect(styles.overflow).toBe('hidden'); + expect(paragraphStyles.display).toBe('inline'); + expect(paragraphStyles.marginTop).toBe('0px'); + expect(paragraphStyles.marginBottom).toBe('0px'); + expect(anchorBounds.height).toBeGreaterThan(0); + expect(anchorBounds.top).toBeGreaterThanOrEqual(contentBounds.top); + expect(anchorBounds.bottom).toBeLessThanOrEqual(contentBounds.bottom); + }); +}); diff --git a/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.ts b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.ts new file mode 100644 index 0000000000..8cadfeb792 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/html-panel-item/html-panel-item.component.ts @@ -0,0 +1,29 @@ +import {AfterViewInit, Component, ElementRef, HostBinding, Inject, ViewChild} from '@angular/core'; +import {NAE_PANEL_ITEM_DATA, PanelItemPortalData} from '@netgrif/components-core'; +import {configurePanelItemLinks, handlePanelItemLinkClick} from '../panel-item-link-click'; + +@Component({ + selector: 'nc-html-panel-item', + templateUrl: './html-panel-item.component.html', + styleUrls: ['./html-panel-item.component.scss'] +}) +export class HtmlPanelItemComponent implements AfterViewInit { + + @ViewChild('content', {static: true}) private readonly content: ElementRef; + + constructor(@Inject(NAE_PANEL_ITEM_DATA) public readonly data: PanelItemPortalData) { + } + + @HostBinding('class.panel-item-ellipsis') + public get ellipsis(): boolean { + return this.data.textEllipsis; + } + + public ngAfterViewInit(): void { + configurePanelItemLinks(this.content.nativeElement); + } + + public handleClick(event: MouseEvent): void { + handlePanelItemLinkClick(event); + } +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.html b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.html new file mode 100644 index 0000000000..25e4b75440 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.scss b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.scss new file mode 100644 index 0000000000..c541db602e --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.scss @@ -0,0 +1,8 @@ +@use '../panel-item-renderer'; + +.panel-markdown ::ng-deep td-markdown, +.panel-markdown ::ng-deep td-markdown > div, +.panel-markdown ::ng-deep td-markdown p { + display: inline; + margin: 0; +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.spec.ts b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.spec.ts new file mode 100644 index 0000000000..1751f8e3ee --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.spec.ts @@ -0,0 +1,57 @@ +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {MarkdownPanelItemComponent} from './markdown-panel-item.component'; +import { + CovalentModule, + MaterialModule, + NAE_PANEL_ITEM_DATA, + PanelItemPortalData +} from '@netgrif/components-core'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; + +describe('MarkdownPanelItemComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CovalentModule, MaterialModule, NoopAnimationsModule], + declarations: [MarkdownPanelItemComponent], + providers: [{ + provide: NAE_PANEL_ITEM_DATA, + useValue: { + featuredValue: { + type: 'text', + icon: undefined, + value: '**Open** [Duck Duck Go](https://duckduckgo.com).', + component: {name: 'richtextarea'} + }, + textEllipsis: true + } as PanelItemPortalData + }] + }).compileComponents(); + + fixture = TestBed.createComponent(MarkdownPanelItemComponent); + fixture.detectChanges(); + }); + + it('should render a markdown link', () => { + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + + expect(anchor).toBeTruthy(); + expect(anchor.textContent).toBe('Duck Duck Go'); + expect(anchor.href).toBe('https://duckduckgo.com/'); + expect(anchor.target).toBe('_blank'); + expect(anchor.rel).toBe('noopener noreferrer'); + expect(fixture.componentInstance.tooltipText).toBe('Open Duck Duck Go.'); + }); + + it('should isolate link clicks from the case panel', () => { + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + const event = new MouseEvent('click', {bubbles: true, cancelable: true}); + const stopPropagation = spyOn(event, 'stopPropagation'); + anchor.addEventListener('click', clickEvent => clickEvent.preventDefault()); + + anchor.dispatchEvent(event); + + expect(stopPropagation).toHaveBeenCalled(); + }); +}); diff --git a/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.ts b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.ts new file mode 100644 index 0000000000..8246947237 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/markdown-panel-item/markdown-panel-item.component.ts @@ -0,0 +1,32 @@ +import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, Inject, ViewChild} from '@angular/core'; +import {NAE_PANEL_ITEM_DATA, PanelItemPortalData} from '@netgrif/components-core'; +import {configurePanelItemLinks, extractPanelItemText, handlePanelItemLinkClick} from '../panel-item-link-click'; + +@Component({ + selector: 'nc-markdown-panel-item', + templateUrl: './markdown-panel-item.component.html', + styleUrls: ['./markdown-panel-item.component.scss'] +}) +export class MarkdownPanelItemComponent implements AfterViewInit { + + @ViewChild('content', {static: true}) private readonly content: ElementRef; + public tooltipText = ''; + + constructor(@Inject(NAE_PANEL_ITEM_DATA) public readonly data: PanelItemPortalData, + private readonly changeDetectorRef: ChangeDetectorRef) { + } + + public ngAfterViewInit(): void { + this.configureLinks(); + } + + public configureLinks(): void { + configurePanelItemLinks(this.content.nativeElement); + this.tooltipText = extractPanelItemText(this.content.nativeElement); + this.changeDetectorRef.detectChanges(); + } + + public handleClick(event: MouseEvent): void { + handlePanelItemLinkClick(event); + } +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item-link-click.ts b/projects/netgrif-components/src/lib/panel/panel-item/panel-item-link-click.ts new file mode 100644 index 0000000000..843c0bb8e8 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item-link-click.ts @@ -0,0 +1,21 @@ +export function configurePanelItemLinks(container: HTMLElement): void { + container.querySelectorAll('a[href]').forEach(anchor => { + anchor.target = '_blank'; + const relValues = new Set(anchor.rel.split(/\s+/).filter(value => value.length > 0)); + relValues.add('noopener'); + relValues.add('noreferrer'); + anchor.rel = Array.from(relValues).join(' '); + }); +} + +export function extractPanelItemText(container: HTMLElement): string { + return (container.textContent ?? '').replace(/\s+/g, ' ').trim(); +} + +export function handlePanelItemLinkClick(event: MouseEvent): void { + const anchor = (event.target as Element | null)?.closest('a'); + if (!anchor) { + return; + } + event.stopPropagation(); +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item-renderer.scss b/projects/netgrif-components/src/lib/panel/panel-item/panel-item-renderer.scss new file mode 100644 index 0000000000..2c735544e8 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item-renderer.scss @@ -0,0 +1,25 @@ +:host { + display: block; + min-width: 0; + width: 100%; + overflow: hidden; +} + +.panel-text { + display: block; + min-width: 0; + max-width: 100%; + width: 100%; + + &.text-column-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.text-column-word-wrap { + overflow-wrap: anywhere; + white-space: pre-line; + word-break: break-word; + } +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.html b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.html index b3697b721d..55761488e9 100644 --- a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.html +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.html @@ -3,17 +3,22 @@ {{leadingIcon ? leadingIcon : 'label'}} {{featuredValue.icon}} - - {{featuredValue.value}} - - - + + + + + {{featuredValue.value}} + + + + + diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.scss b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.scss index 9de4a5be60..964f3a34fe 100644 --- a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.scss +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.scss @@ -1,6 +1,21 @@ +:host { + display: block; + min-width: 0; + max-width: 100%; + overflow: hidden; +} + .text-column-margin { + width: calc(100% - 15px); + min-width: 0; + max-width: calc(100% - 15px); margin-right: 15px; + font-style: normal; + font-weight: 500; + font-size: 14px; line-height: 24px; + letter-spacing: 0.1px; + overflow: hidden; } .panel-icon { @@ -8,13 +23,17 @@ } .text-column-ellipsis { + max-width: 100%; + max-height: 24px; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; } .text-column-word-wrap { + max-width: 100%; word-break: break-word !important; + overflow-wrap: anywhere; white-space: pre-line !important; } @@ -25,13 +44,3 @@ .width-100 { width: 100%; } - -.panel-text { - font-style: normal; - font-weight: 500; - font-size: 14px; - line-height: 20px; - letter-spacing: 0.1px; - -} - diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.spec.ts b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.spec.ts index 047524540e..9030602f4d 100644 --- a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.spec.ts +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.spec.ts @@ -1,7 +1,17 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PanelItemComponent } from './panel-item.component'; -import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { FeaturedValue } from '@netgrif/components-core'; +import {Component, Injector, NO_ERRORS_SCHEMA} from '@angular/core'; +import { + FeaturedValue, + MaterialModule, + PanelItemComponentRegistryService +} from '@netgrif/components-core'; +import {CommonModule} from '@angular/common'; +import {ComponentPortal, PortalModule} from '@angular/cdk/portal'; +import {By} from '@angular/platform-browser'; +import {FlexModule} from '@ngbracket/ngx-layout'; +import {HtmlPanelItemComponent} from './html-panel-item/html-panel-item.component'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; describe('PanelItemComponent', () => { let component: PanelItemComponent; @@ -9,7 +19,13 @@ describe('PanelItemComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [TestWrapperComponent], + imports: [CommonModule, MaterialModule, PortalModule, FlexModule, NoopAnimationsModule], + declarations: [ + PanelItemComponent, + TestWrapperComponent, + TestPanelItemRendererComponent, + HtmlPanelItemComponent + ], schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); @@ -17,8 +33,8 @@ describe('PanelItemComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(TestWrapperComponent); - component = fixture.debugElement.children[0].componentInstance; fixture.detectChanges(); + component = fixture.debugElement.query(By.directive(PanelItemComponent)).componentInstance; }); it('should create', () => { @@ -33,6 +49,63 @@ describe('PanelItemComponent', () => { expect(component.leadingIcon).toEqual('label'); }); + it('should use the registered component renderer', () => { + const registry = TestBed.inject(PanelItemComponentRegistryService); + registry.register('text', 'custom', (injector: Injector) => + new ComponentPortal(TestPanelItemRendererComponent, null, injector)); + fixture.componentInstance.featuredValue = { + type: 'text', + icon: undefined, + value: 'custom value', + component: {name: 'custom'} + }; + + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.directive(TestPanelItemRendererComponent))).toBeTruthy(); + }); + + it('should use the default renderer for an unknown component', () => { + fixture.componentInstance.featuredValue = { + type: 'text', + icon: undefined, + value: 'fallback value', + component: {name: 'unknown'} + }; + + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.directive(TestPanelItemRendererComponent))).toBeFalsy(); + expect(fixture.nativeElement.textContent).toContain('fallback value'); + }); + + it('should keep the HTML renderer visible inside a panel row', () => { + const registry = TestBed.inject(PanelItemComponentRegistryService); + registry.register('text', 'htmltextarea', (injector: Injector) => + new ComponentPortal(HtmlPanelItemComponent, null, injector)); + fixture.componentInstance.leadingIconEnabled = false; + fixture.componentInstance.featuredValue = { + type: 'text', + icon: undefined, + value: 'Duck Duck Go · Approved · ✅', + component: {name: 'htmltextarea'} + }; + + fixture.detectChanges(); + + const row: HTMLElement = fixture.nativeElement.querySelector('.test-panel-row'); + const content: HTMLElement = fixture.nativeElement.querySelector('.panel-html'); + const anchor: HTMLAnchorElement = fixture.nativeElement.querySelector('.panel-html a'); + const rowBounds = row.getBoundingClientRect(); + const contentBounds = content.getBoundingClientRect(); + + expect(content.textContent.trim()).toBe('Duck Duck Go · Approved · ✅'); + expect(contentBounds.height).toBe(20); + expect(contentBounds.top).toBeGreaterThanOrEqual(rowBounds.top); + expect(contentBounds.bottom).toBeLessThanOrEqual(rowBounds.bottom); + expect(anchor.getBoundingClientRect().height).toBeGreaterThan(0); + }); + afterEach(() => { TestBed.resetTestingModule(); }); @@ -40,8 +113,11 @@ describe('PanelItemComponent', () => { @Component({ selector: 'nc-test-wrapper', - template: '' + template: '
' + + '
' + + '' + + '
' }) class TestWrapperComponent { leadingIcon = 'label'; @@ -53,3 +129,10 @@ class TestWrapperComponent { value: 'text' } as FeaturedValue; } + +@Component({ + selector: 'nc-test-panel-item-renderer', + template: 'custom renderer' +}) +class TestPanelItemRendererComponent { +} diff --git a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.ts b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.ts index b9ece7c4f4..e11897466f 100644 --- a/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.ts +++ b/projects/netgrif-components/src/lib/panel/panel-item/panel-item.component.ts @@ -1,5 +1,5 @@ -import { Component } from '@angular/core'; -import { AbstractPanelItemComponent } from '@netgrif/components-core'; +import {Component, Injector} from '@angular/core'; +import {AbstractPanelItemComponent, PanelItemComponentRegistryService} from '@netgrif/components-core'; @Component({ selector: 'nc-panel-item', @@ -8,7 +8,7 @@ import { AbstractPanelItemComponent } from '@netgrif/components-core'; }) export class PanelItemComponent extends AbstractPanelItemComponent { - constructor() { - super(); + constructor(registry: PanelItemComponentRegistryService, injector: Injector) { + super(registry, injector); } } diff --git a/projects/netgrif-components/src/lib/panel/panel.module.spec.ts b/projects/netgrif-components/src/lib/panel/panel.module.spec.ts new file mode 100644 index 0000000000..9c619b2fe4 --- /dev/null +++ b/projects/netgrif-components/src/lib/panel/panel.module.spec.ts @@ -0,0 +1,65 @@ +import {Component, Injector} from '@angular/core'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {PanelItemComponentRegistryService} from '@netgrif/components-core'; +import {PanelComponentModule} from './panel.module'; +import {MarkdownPanelItemComponent} from './panel-item/markdown-panel-item/markdown-panel-item.component'; +import {HtmlPanelItemComponent} from './panel-item/html-panel-item/html-panel-item.component'; +import { + EnumerationIconPanelItemComponent +} from './panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component'; + +describe('PanelComponentModule', () => { + let registry: PanelItemComponentRegistryService; + let injector: Injector; + + beforeEach(() => { + registry = new PanelItemComponentRegistryService(); + injector = Injector.create({providers: []}); + }); + + it('should register the built-in panel item renderers', () => { + new PanelComponentModule(registry); + + expect(registry.get('text', 'richtextarea', injector).component).toBe(MarkdownPanelItemComponent); + expect(registry.get('text', 'htmltextarea', injector).component).toBe(HtmlPanelItemComponent); + expect(registry.get('enumeration', 'icon', injector).component).toBe(EnumerationIconPanelItemComponent); + expect(registry.get('enumeration_map', 'icon', injector).component).toBe(EnumerationIconPanelItemComponent); + }); + + it('should preserve a custom renderer during repeated module initialization', () => { + registry.register('text', 'richtextarea', portalInjector => + new ComponentPortal(CustomPanelItemComponent, null, portalInjector)); + + new PanelComponentModule(registry); + new PanelComponentModule(registry); + + expect(registry.get('text', 'richtextarea', injector).component).toBe(CustomPanelItemComponent); + expect(registry.get('text', 'htmltextarea', injector).component).toBe(HtmlPanelItemComponent); + expect(registry.get('enumeration', 'icon', injector).component).toBe(EnumerationIconPanelItemComponent); + expect(registry.get('enumeration_map', 'icon', injector).component).toBe(EnumerationIconPanelItemComponent); + }); + + it('should keep ambiguous field type and component name combinations separated', () => { + registry.register('date-time', 'custom', portalInjector => + new ComponentPortal(CustomPanelItemComponent, null, portalInjector)); + registry.register('date', 'time-custom', portalInjector => + new ComponentPortal(AlternativePanelItemComponent, null, portalInjector)); + + expect(registry.get('date-time', 'custom', injector).component).toBe(CustomPanelItemComponent); + expect(registry.get('date', 'time-custom', injector).component).toBe(AlternativePanelItemComponent); + }); +}); + +@Component({ + selector: 'nc-custom-panel-item', + template: '' +}) +class CustomPanelItemComponent { +} + +@Component({ + selector: 'nc-alternative-panel-item', + template: '' +}) +class AlternativePanelItemComponent { +} diff --git a/projects/netgrif-components/src/lib/panel/panel.module.ts b/projects/netgrif-components/src/lib/panel/panel.module.ts index 5705ad4756..00da07eea1 100644 --- a/projects/netgrif-components/src/lib/panel/panel.module.ts +++ b/projects/netgrif-components/src/lib/panel/panel.module.ts @@ -1,4 +1,4 @@ -import {NgModule} from '@angular/core'; +import {Injector, NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {PanelComponent} from './panel.component'; import {FlexModule} from '@ngbracket/ngx-layout'; @@ -12,7 +12,9 @@ import { SnackBarModule, TranslateLibModule, CurrencyModule, - UtilityModule + UtilityModule, + CovalentModule, + PanelItemComponentRegistryService } from '@netgrif/components-core'; import {TaskContentComponentModule} from '../task-content/task-content.module'; import {PublicWorkflowPanelComponent} from './public-workflow-panel/public-workflow-panel.component'; @@ -30,6 +32,14 @@ import { SideMenuMultiUserAssignComponentModule } from "../side-menu/content-components/multi-user-assign/side-menu-multi-user-assign-component.module"; import {MatExpansionModule} from '@angular/material/expansion'; +import {PortalModule, ComponentPortal} from '@angular/cdk/portal'; +import { + MarkdownPanelItemComponent +} from './panel-item/markdown-panel-item/markdown-panel-item.component'; +import {HtmlPanelItemComponent} from './panel-item/html-panel-item/html-panel-item.component'; +import { + EnumerationIconPanelItemComponent +} from './panel-item/enumeration-icon-panel-item/enumeration-icon-panel-item.component'; @NgModule({ declarations: [ @@ -42,6 +52,9 @@ import {MatExpansionModule} from '@angular/material/expansion'; ImmediateFilterTextComponent, ImmediateFilterTextContentComponent, PanelItemComponent, + MarkdownPanelItemComponent, + HtmlPanelItemComponent, + EnumerationIconPanelItemComponent, TaskListPaginationComponent, SingleTaskComponent ], @@ -57,7 +70,9 @@ import {MatExpansionModule} from '@angular/material/expansion'; CurrencyModule, MatExpansionModule, SideMenuMultiUserAssignComponentModule, - UtilityModule + UtilityModule, + CovalentModule, + PortalModule ], exports: [ PanelComponent, @@ -73,4 +88,14 @@ import {MatExpansionModule} from '@angular/material/expansion'; ] }) export class PanelComponentModule { + constructor(registry: PanelItemComponentRegistryService) { + registry.registerIfAbsent('text', 'richtextarea', (injector: Injector) => + new ComponentPortal(MarkdownPanelItemComponent, null, injector)); + registry.registerIfAbsent('text', 'htmltextarea', (injector: Injector) => + new ComponentPortal(HtmlPanelItemComponent, null, injector)); + registry.registerIfAbsent('enumeration', 'icon', (injector: Injector) => + new ComponentPortal(EnumerationIconPanelItemComponent, null, injector)); + registry.registerIfAbsent('enumeration_map', 'icon', (injector: Injector) => + new ComponentPortal(EnumerationIconPanelItemComponent, null, injector)); + } }