diff --git a/projects/api/src/lib/models/index.ts b/projects/api/src/lib/models/index.ts index 21225b1e4..bba272161 100644 --- a/projects/api/src/lib/models/index.ts +++ b/projects/api/src/lib/models/index.ts @@ -57,3 +57,4 @@ export * from './dropdown-list-filter.model'; export * from './edit-filter-configuration.model'; export * from './unique-values-response.model'; export * from './tileset-3d-style.model'; +export * from './layer-export-capabilities.model'; diff --git a/projects/core/src/lib/components/attribute-list/attribute-list-content/attribute-list-content.component.ts b/projects/core/src/lib/components/attribute-list/attribute-list-content/attribute-list-content.component.ts index 88ddfb3dc..ee9bfc327 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list-content/attribute-list-content.component.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list-content/attribute-list-content.component.ts @@ -92,11 +92,10 @@ export class AttributeListContentComponent implements OnInit { if (!selectedTab || !selectedTab.layerId) { return of(null); } - const layerId = selectedTab.layerId; return forkJoin([ - this.simpleAttributeFilterService.getFilterForAttribute$(BaseComponentTypeEnum.ATTRIBUTE_LIST, layerId, $event.columnId).pipe(take(1)), - this.simpleAttributeFilterService.getFiltersExcludingAttribute$(BaseComponentTypeEnum.ATTRIBUTE_LIST, layerId, $event.columnId).pipe(take(1)), - of(layerId), + this.simpleAttributeFilterService.getFilterForAttribute$(BaseComponentTypeEnum.ATTRIBUTE_LIST, selectedTab.layerId, $event.columnId).pipe(take(1)), + this.simpleAttributeFilterService.getFiltersExcludingAttribute$(BaseComponentTypeEnum.ATTRIBUTE_LIST, selectedTab.layerId, $event.columnId).pipe(take(1)), + of(selectedTab), of(applicationId), this.columns$.pipe( take(1), @@ -109,16 +108,17 @@ export class AttributeListContentComponent implements OnInit { if (!result) { return; } - const [ attributeFilterModel, otherFilters, layerId, applicationId, attributeAlias ] = result; - if (applicationId === null) { + const [ attributeFilterModel, otherFilters, selectedTab, applicationId, attributeAlias ] = result; + if (applicationId === null || !selectedTab.layerId) { return; } const data: FilterDialogData = { + tabSourceId: selectedTab.tabSourceId, columnName: $event.columnId, - layerId, + layerId: selectedTab.layerId, filter: attributeFilterModel, columnType: $event.attributeType, - cqlFilter: CqlFilterHelper.getFilters(otherFilters).get(layerId), + cqlFilter: CqlFilterHelper.getFilters(otherFilters).get(selectedTab.layerId), applicationId, attributeAlias, }; diff --git a/projects/core/src/lib/components/attribute-list/attribute-list-export-button/attribute-list-export-button.component.ts b/projects/core/src/lib/components/attribute-list/attribute-list-export-button/attribute-list-export-button.component.ts index ff185f614..fedf84a62 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list-export-button/attribute-list-export-button.component.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list-export-button/attribute-list-export-button.component.ts @@ -5,7 +5,7 @@ import { import { AttributeListExportService, SupportedExportFormats } from '../services/attribute-list-export.service'; import { Store } from '@ngrx/store'; import { - selectColumnsForSelectedTab, selectSelectedTab, selectSelectedTabLayerId, selectSortForSelectedTab, + selectColumnsForSelectedTab, selectSelectedTab, selectSortForSelectedTab, } from '../state/attribute-list.selectors'; import { selectCQLFilters } from '../../../state/filter-state/filter.selectors'; import { selectLayers } from '../../../map/state/map.selectors'; @@ -37,20 +37,20 @@ export class AttributeListExportButtonComponent implements OnDestroy { constructor() { combineLatest([ this.store$.select(selectLayers), - this.store$.select(selectSelectedTabLayerId), + this.store$.select(selectSelectedTab), ]) .pipe( takeUntil(this.destroyed), distinctUntilChanged(), - concatMap(([ layers, layerId ]) => { - if (layerId === null) { + concatMap(([ layers, selectedTab ]) => { + if (!selectedTab || selectedTab.layerId === null || typeof selectedTab.layerId === 'undefined') { return of([]); } - const layer = layers.find(l => l.id === layerId); + const layer = layers.find(l => l.id === selectedTab.layerId); if (layer?.hiddenFunctionality?.includes(HiddenLayerFunctionality.export)) { return of([]); } - return this.exportService.getExportFormats$(layerId); + return this.exportService.getExportFormats$(selectedTab.tabSourceId, selectedTab.layerId); }), ) .subscribe(formats => this.supportedFormatsSubject.next(formats)); @@ -77,7 +77,7 @@ export class AttributeListExportButtonComponent implements OnDestroy { } const filter = filters.get(tab.layerId); const attributes = columns.filter(c => c.visible).map(c => c.id); - return this.exportService.export$({ layerId: tab.layerId, serviceLayerName: tab.label, format, filter, sort, attributes }); + return this.exportService.export$({ tabSourceId: tab.tabSourceId, layerId: tab.layerId, serviceLayerName: tab.label, format, filter, sort, attributes }); })) .subscribe(() => { this.isExportingSubject.next(false); diff --git a/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.spec.ts b/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.spec.ts index 17c391503..3b260453a 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.spec.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.spec.ts @@ -2,11 +2,16 @@ import { render, screen } from '@testing-library/angular'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { AttributeListFilterComponent, FilterDialogData } from './attribute-list-filter.component'; import { SimpleAttributeFilterService } from '../../../filter/services/simple-attribute-filter.service'; -import { AttributeType, UniqueValuesService, FilterTypeEnum } from '@tailormap-viewer/api'; +import { + AttributeType, UniqueValuesService, FilterTypeEnum, TAILORMAP_API_V1_SERVICE, TailormapApiV1MockService, +} from '@tailormap-viewer/api'; import { SharedModule } from '@tailormap-viewer/shared'; import { AttributeFilterComponent } from '@tailormap-viewer/shared'; import userEvent from '@testing-library/user-event'; import { of } from 'rxjs'; +import { ATTRIBUTE_LIST_DEFAULT_SOURCE } from '../models/attribute-list-default-source.const'; +import { provideMockStore } from '@ngrx/store/testing'; +import { selectAttributeListTabs, selectAttributeListVisible } from '../state/attribute-list.selectors'; describe('AttributeListFilterComponent', () => { @@ -18,6 +23,7 @@ describe('AttributeListFilterComponent', () => { layerId: '1', columnType: AttributeType.STRING, applicationId: '1', + tabSourceId: ATTRIBUTE_LIST_DEFAULT_SOURCE, }; const attributeFilterService = { setFilter: jest.fn(), removeFilter: jest.fn() }; const uniqueValuesService = { @@ -29,6 +35,13 @@ describe('AttributeListFilterComponent', () => { { provide: MAT_DIALOG_DATA, useValue: dialogData }, { provide: SimpleAttributeFilterService, useValue: attributeFilterService }, { provide: UniqueValuesService, useValue: uniqueValuesService }, + provideMockStore({ + selectors: [ + { selector: selectAttributeListTabs, value: [] }, + { selector: selectAttributeListVisible, value: true }, + ], + }), + { provide: TAILORMAP_API_V1_SERVICE, useClass: TailormapApiV1MockService }, ], imports: [SharedModule], declarations: [AttributeFilterComponent], diff --git a/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.ts b/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.ts index c583b0e4b..8562d1bd3 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list-filter/attribute-list-filter.component.ts @@ -1,12 +1,14 @@ import { Component, inject, OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { map, Observable } from 'rxjs'; -import { BaseComponentTypeEnum, AttributeType, UniqueValuesService } from '@tailormap-viewer/api'; +import { BaseComponentTypeEnum, AttributeType } from '@tailormap-viewer/api'; import { FilterConditionEnum, FilterTypeEnum, AttributeFilterModel } from '@tailormap-viewer/api'; import { SimpleAttributeFilterService } from '../../../filter/services/simple-attribute-filter.service'; import { AttributeFilterHelper } from '@tailormap-viewer/shared'; +import { AttributeListManagerService } from '../services/attribute-list-manager.service'; export interface FilterDialogData { + tabSourceId: string; columnName: string; layerId: string; filter: AttributeFilterModel | null; @@ -38,7 +40,7 @@ export class AttributeListFilterComponent implements OnInit { private simpleAttributeFilterService = inject(SimpleAttributeFilterService); private dialogRef = inject(MatDialogRef); private data: FilterDialogData = inject(MAT_DIALOG_DATA); - private uniqueValuesService = inject(UniqueValuesService); + private managerService = inject(AttributeListManagerService); public ngOnInit(): void { this.uniqueValues$ = this.getUniqueValues$(); @@ -73,7 +75,7 @@ export class AttributeListFilterComponent implements OnInit { } public getUniqueValues$(): Observable { - return this.uniqueValuesService.getUniqueValues$({ + return this.managerService.getUniqueValues$(this.data.tabSourceId, { attribute: this.data.columnName, layerId: this.data.layerId, filter: this.data.cqlFilter, diff --git a/projects/core/src/lib/components/attribute-list/attribute-list.module.ts b/projects/core/src/lib/components/attribute-list/attribute-list.module.ts index 0c64890f5..e7f1c641a 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list.module.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list.module.ts @@ -50,5 +50,7 @@ export class AttributeListModule { // Service is instantiated here, watches changes to visible layers to create tabs //eslint-disable-next-line @angular-eslint/prefer-inject public attributeListManagerService: AttributeListManagerService, - ) {} + ) { + this.attributeListManagerService.initDefaultAttributeListSource(); + } } diff --git a/projects/core/src/lib/components/attribute-list/attribute-list/attribute-list.component.spec.ts b/projects/core/src/lib/components/attribute-list/attribute-list/attribute-list.component.spec.ts index bbf97d111..2eee6def6 100644 --- a/projects/core/src/lib/components/attribute-list/attribute-list/attribute-list.component.spec.ts +++ b/projects/core/src/lib/components/attribute-list/attribute-list/attribute-list.component.spec.ts @@ -1,17 +1,19 @@ import { render, screen, waitFor } from '@testing-library/angular'; -import { getLoadedStoreNoRows, getLoadedStoreWithMultipleTabs, getLoadingStore } from '../state/mocks/attribute-list-state-test-data'; +import { getLoadedStoreNoRows, getLoadingStore } from '../state/mocks/attribute-list-state-test-data'; import { provideMockStore } from '@ngrx/store/testing'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AttributeListComponent } from './attribute-list.component'; -import { AttributeListState, attributeListStateKey } from '../state/attribute-list.state'; -import { initialMapState, mapStateKey } from '../../../map/state/map.state'; +import { AttributeListState, attributeListStateKey, initialAttributeListState } from '../state/attribute-list.state'; +import { initialMapState, MapState, mapStateKey } from '../../../map/state/map.state'; import { - getAppLayerModel, getLayerTreeNode, TAILORMAP_API_V1_SERVICE, TailormapApiConstants, TailormapApiV1MockService, + AttributeType, FeatureModel, FeaturesResponseModel, getAppLayerModel, getLayerTreeNode, TAILORMAP_API_V1_SERVICE, + TailormapApiConstants, + TailormapApiV1MockService, } from '@tailormap-viewer/api'; import { MatIconModule } from '@angular/material/icon'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { PanelResizerComponent, SharedImportsModule } from '@tailormap-viewer/shared'; +import { LoadingStateEnum, PanelResizerComponent, SharedImportsModule } from '@tailormap-viewer/shared'; import { AttributeListContentComponent } from '../attribute-list-content/attribute-list-content.component'; import { AttributeListTableComponent } from '../attribute-list-table/attribute-list-table.component'; import { AttributeListTabToolbarComponent } from '../attribute-list-tab-toolbar/attribute-list-tab-toolbar.component'; @@ -24,18 +26,24 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { initialFilterState } from '../../../state/filter-state/filter.state'; import { AttributeListExportButtonComponent } from '../attribute-list-export-button/attribute-list-export-button.component'; -import { coreStateKey } from '../../../state/core.state'; +import { CoreState, coreStateKey } from '../../../state/core.state'; import { coreReducer } from '../../../state/core.reducer'; import { ExtendedAppLayerModel } from '../../../map/models'; import { CoreSharedModule } from '../../../shared'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { provideHttpClient, withXsrfConfiguration } from '@angular/common/http'; import { getMapServiceMock } from '../../../test-helpers/map-service.mock.spec'; +import { AttributeListSourceModel } from '../models/attribute-list-source.model'; +import { Observable, of } from 'rxjs'; +import { TestBed } from '@angular/core/testing'; +import { AttributeListManagerService } from '../services/attribute-list-manager.service'; +import { EffectsModule } from '@ngrx/effects'; +import { AttributeListEffects } from '../state/attribute-list.effects'; const getStore = ( attributeListStore: { [attributeListStateKey]: AttributeListState }, layers: ExtendedAppLayerModel[] = [], -) => { +): { [mapStateKey]: MapState; [attributeListStateKey]: AttributeListState; [coreStateKey]: CoreState } => { return { ...attributeListStore, [mapStateKey]: { @@ -47,9 +55,12 @@ const getStore = ( ] : []).map(l => ({ ...l, initialChildren: l.childrenIds || [] })), }, [coreStateKey]: { - loadStatus: 'INITIAL', + loadStatus: LoadingStateEnum.INITIAL, filters: initialFilterState, - viewer: {}, // <-- Ensure viewer is always present + viewer: { + id: 'viewer_1', + components: [], + }, // <-- Ensure viewer is always present }, }; }; @@ -69,6 +80,123 @@ const setup = async (store: any) => { }); }; +const createDummyFeatures = ( + count: number, + rowOverride?: (index: number) => Partial, +): FeatureModel[] => { + const rows: FeatureModel[] = []; + for (let i = 0; i < count; i++) { + rows.push(createDummyFeature(`${i + 1}`, rowOverride ? rowOverride(i) : undefined)); + } + return rows; +}; + +const createDummyFeature = ( + id: string, + overrides?: Partial, +): FeatureModel => ({ + __fid: id, + attributes: { + attribute1: id + ': Test', + attribute2: id + ': Some other value', + attribute3: id + ': The last value', + }, + ...(overrides || {}), +}); + +const setupWithActualState = async () => { + const store = getStore( + { [attributeListStateKey]: { ...initialAttributeListState, visible: true } }, + // : getLoadedStoreWithMultipleTabs(), + [ + { ...getAppLayerModel({ id: '1', title: 'Layer 1', hasAttributes: true, visible: true }) }, + { ...getAppLayerModel({ id: '2', title: 'Layer 2', hasAttributes: true, visible: true }) }, + ], + ); + const reducers = { + [attributeListStateKey]: attributeListReducer, + [mapStateKey]: mapReducer, + [coreStateKey]: coreReducer, + }; + const mockService = new TailormapApiV1MockService(); + mockService.getFeatures$ = jest.fn(({ layerId }) => { + if (layerId === '1') { + return of({ + features: createDummyFeatures(10), + columnMetadata: [ + { name: 'attribute1', alias: 'Attribute 1', type: AttributeType.STRING }, + { name: 'attribute2', alias: 'Attribute 2', type: AttributeType.STRING }, + { name: 'attribute3', alias: 'Attribute 3', type: AttributeType.STRING }, + ], + template: null, + total: 10, + page: 0, + pageSize: 10, + }); + } + if (layerId === '2') { + return of({ + features: createDummyFeatures(10, idx => ({ + attributes: { + attribute1: (idx + 1) + ': The Netherlands', + attribute2: (idx + 1) + ': Utrecht', + attribute3: (idx + 1) + ': Zonnebaan', + }, + })), + columnMetadata: [ + { name: 'attribute1', alias: 'Country', type: AttributeType.STRING }, + { name: 'attribute2', alias: 'City', type: AttributeType.STRING }, + { name: 'attribute3', alias: 'Street', type: AttributeType.STRING }, + ], + template: null, + total: 10, + page: 0, + pageSize: 10, + }); + } + return of({ + features: [], + columnMetadata: [], + template: null, + total: 0, + page: 0, + pageSize: 0, + }); + }); + const renderResult = await render(AttributeListComponent, { + imports: [ + CoreSharedModule, + SharedImportsModule, + NoopAnimationsModule, + MatIconTestingModule, + StoreModule.forRoot(reducers, { initialState: store }), + EffectsModule.forRoot([AttributeListEffects]), + ], + providers: [ + provideHttpClient( + withXsrfConfiguration({ + cookieName: TailormapApiConstants.XSRF_COOKIE_NAME, + headerName: TailormapApiConstants.XSRF_HEADER_NAME, + }), + ), + { provide: TAILORMAP_API_V1_SERVICE, useValue: mockService }, + AttributeListManagerService, + ], + declarations: [ + AttributeListComponent, + PanelResizerComponent, + AttributeListContentComponent, + AttributeListTableComponent, + AttributeListTabComponent, + AttributeListTabToolbarComponent, + AttributeListExportButtonComponent, + ], + }); + const managerService = TestBed.inject(AttributeListManagerService); + managerService.initDefaultAttributeListSource(); + return renderResult; +}; + describe('AttributeList', () => { it('does not render for hidden attribute list', async () => { @@ -99,54 +227,15 @@ describe('AttributeList', () => { }); it('renders attribute list with multiple tabs and switches content after clicking tab', async () => { - const store = getStore( - getLoadedStoreWithMultipleTabs(), - [ - { ...getAppLayerModel({ id: '1', hasAttributes: true, visible: true }) }, - { ...getAppLayerModel({ id: '2', hasAttributes: true, visible: true }) }, - ], - ); - const reducers = { - [attributeListStateKey]: attributeListReducer, - [mapStateKey]: mapReducer, - [coreStateKey]: coreReducer, - }; - await render(AttributeListComponent, { - imports: [ - CoreSharedModule, - SharedImportsModule, - NoopAnimationsModule, - MatIconTestingModule, - StoreModule.forRoot(reducers, { initialState: store }), - ], - providers: [ - provideHttpClient( - withXsrfConfiguration({ - cookieName: TailormapApiConstants.XSRF_COOKIE_NAME, - headerName: TailormapApiConstants.XSRF_HEADER_NAME, - }), - ), - { provide: TAILORMAP_API_V1_SERVICE, useClass: TailormapApiV1MockService }, - ], - declarations: [ - AttributeListComponent, - PanelResizerComponent, - AttributeListContentComponent, - AttributeListTableComponent, - AttributeListTabComponent, - AttributeListTabToolbarComponent, - AttributeListExportButtonComponent, - ], - }); - - expect(await screen.getByText('First tab')).toBeInTheDocument(); - expect(await screen.getByText('Tab 2')).toBeInTheDocument(); + await setupWithActualState(); + expect(await screen.findByText('Layer 1')).toBeInTheDocument(); + expect(await screen.findByText('Layer 2')).toBeInTheDocument(); expect(await screen.findByText('Attribute 1')).toBeInTheDocument(); expect(await screen.queryByText('City')).not.toBeInTheDocument(); expect(await screen.findByText('1: Test')).toBeInTheDocument(); expect(await screen.findByText('10: Test')).toBeInTheDocument(); - const tabEl = await screen.findByText('Tab 2'); + const tabEl = await screen.findByText('Layer 2'); tabEl.style.pointerEvents = 'auto'; await userEvent.click(tabEl); @@ -160,4 +249,59 @@ describe('AttributeList', () => { expect(await screen.findByText('10: Zonnebaan')).toBeInTheDocument(); }); + it('renders tabs from other source', async () => { + await setupWithActualState(); + const source: AttributeListSourceModel = { + id: 'source_2', + tabs$: of([{ + id: 'tab_3', + label: 'Third tab', + layerId: '3', + }]), + dataLoader: { + getFeatures$: jest.fn((): Observable => { + return of({ + features: [ + { __fid: '1', attributes: { name: 'Pro', title: 'Tailormap Pro' } }, + { __fid: '2', attributes: { name: 'Core', title: 'Tailormap Core' } }, + ], + columnMetadata: [ + { name: 'name', alias: 'Name', type: AttributeType.STRING }, + { name: 'title', alias: 'Title', type: AttributeType.STRING }, + ], + template: null, + total: null, + page: null, + pageSize: null, + }); + }), + getLayerExportCapabilities$(): Observable { + return of({ exportable: false, outputFormats: [] }); + }, + getLayerExport$(): Observable { + return of(null); + }, + getUniqueValues$(): Observable { + return of({ values: [], filterApplied: false }); + }, + }, + }; + + const managerService = TestBed.inject(AttributeListManagerService); + managerService.addAttributeListSource$(source); + + expect(await screen.findByText('Layer 1')).toBeInTheDocument(); + expect(await screen.findByText('Layer 2')).toBeInTheDocument(); + expect(await screen.findByText('Third tab')).toBeInTheDocument(); + + const tab3El = await screen.findByText('Third tab'); + tab3El.style.pointerEvents = 'auto'; + await userEvent.click(tab3El); + + expect(await screen.findByText('Title')).toBeInTheDocument(); + expect(await screen.findByText('Name')).toBeInTheDocument(); + expect(await screen.findByText('Tailormap Pro')).toBeInTheDocument(); + expect(await screen.findByText('Tailormap Core')).toBeInTheDocument(); + }); + }); diff --git a/projects/core/src/lib/components/attribute-list/models/attribute-list-api-service.model.ts b/projects/core/src/lib/components/attribute-list/models/attribute-list-api-service.model.ts new file mode 100644 index 000000000..45b9063b5 --- /dev/null +++ b/projects/core/src/lib/components/attribute-list/models/attribute-list-api-service.model.ts @@ -0,0 +1,43 @@ +import { + FeaturesResponseModel, LayerExportCapabilitiesModel, Sortorder, UniqueValueParams, UniqueValuesResponseModel, +} from '@tailormap-viewer/api'; +import { Observable } from 'rxjs'; +import { HttpResponse } from '@angular/common/http'; + +export interface GetFeaturesParams { + applicationId: string; + layerId: string; + __fid?: string; + filter?: string; + page?: number; + sortBy?: string; + sortOrder?: Sortorder; + includeGeometry?: boolean; +} + +export interface GetLayerExportCapabilitiesParams { + applicationId: string; + layerId: string; +} + +export interface GetLayerExportParams { + applicationId: string; + layerId: string; + outputFormat: string; + filter?: string; + sort: { column: string; direction: string} | null; + attributes?: string[]; + crs?: string; +} + +export interface AttributeListApiServiceModel { + + getFeatures$(params: GetFeaturesParams): Observable; + + getLayerExportCapabilities$(params: GetLayerExportCapabilitiesParams): Observable; + + getLayerExport$(params: GetLayerExportParams): Observable>; + + getUniqueValues$(params: UniqueValueParams): Observable; + +} diff --git a/projects/core/src/lib/components/attribute-list/models/attribute-list-default-source.const.ts b/projects/core/src/lib/components/attribute-list/models/attribute-list-default-source.const.ts new file mode 100644 index 000000000..7a1763504 --- /dev/null +++ b/projects/core/src/lib/components/attribute-list/models/attribute-list-default-source.const.ts @@ -0,0 +1 @@ +export const ATTRIBUTE_LIST_DEFAULT_SOURCE = 'tm-default-layers-source'; diff --git a/projects/core/src/lib/components/attribute-list/models/attribute-list-source.model.ts b/projects/core/src/lib/components/attribute-list/models/attribute-list-source.model.ts new file mode 100644 index 000000000..07b3172d3 --- /dev/null +++ b/projects/core/src/lib/components/attribute-list/models/attribute-list-source.model.ts @@ -0,0 +1,13 @@ +import { Observable } from 'rxjs'; +import { AttributeListApiServiceModel } from './attribute-list-api-service.model'; + +export interface TabModel { + id: string; + label: string; +} + +export interface AttributeListSourceModel { + id: string; + tabs$: Observable; + dataLoader: AttributeListApiServiceModel; +} diff --git a/projects/core/src/lib/components/attribute-list/models/attribute-list-tab.model.ts b/projects/core/src/lib/components/attribute-list/models/attribute-list-tab.model.ts index 58b7c5be0..a40b40bad 100644 --- a/projects/core/src/lib/components/attribute-list/models/attribute-list-tab.model.ts +++ b/projects/core/src/lib/components/attribute-list/models/attribute-list-tab.model.ts @@ -1,6 +1,7 @@ export interface AttributeListTabModel { id: string; label: string; + tabSourceId: string; layerId?: string; selectedDataId: string; initialDataLoaded: boolean; diff --git a/projects/core/src/lib/components/attribute-list/services/attribute-list-api.service.ts b/projects/core/src/lib/components/attribute-list/services/attribute-list-api.service.ts new file mode 100644 index 000000000..6047859d2 --- /dev/null +++ b/projects/core/src/lib/components/attribute-list/services/attribute-list-api.service.ts @@ -0,0 +1,31 @@ +import { + AttributeListApiServiceModel, GetFeaturesParams, GetLayerExportCapabilitiesParams, GetLayerExportParams, +} from '../models/attribute-list-api-service.model'; +import { inject, Injectable } from '@angular/core'; +import { TAILORMAP_API_V1_SERVICE, UniqueValueParams, UniqueValuesService } from '@tailormap-viewer/api'; + +@Injectable({ + providedIn: 'root', +}) +export class AttributeListApiService implements AttributeListApiServiceModel { + + private api = inject(TAILORMAP_API_V1_SERVICE); + private uniqueValuesService = inject(UniqueValuesService); + + public getFeatures$(params: GetFeaturesParams) { + return this.api.getFeatures$(params); + } + + public getLayerExportCapabilities$(params: GetLayerExportCapabilitiesParams) { + return this.api.getLayerExportCapabilities$(params); + } + + public getLayerExport$(params: GetLayerExportParams) { + return this.api.getLayerExport$(params); + } + + public getUniqueValues$(params: UniqueValueParams) { + return this.uniqueValuesService.getUniqueValues$(params); + } + +} diff --git a/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.spec.ts b/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.spec.ts index 8df1fa8ee..8824adbf1 100644 --- a/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.spec.ts +++ b/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.spec.ts @@ -1,12 +1,11 @@ import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { createMockStore } from '@ngrx/store/testing'; -import { selectAttributeListData, selectAttributeListTabs } from '../state/attribute-list.selectors'; +import { selectAttributeListData, selectAttributeListTabs, selectAttributeListVisible } from '../state/attribute-list.selectors'; import { selectViewerId } from '../../../state/core.selectors'; import { AttributeListDataService } from './attribute-list-data.service'; import { - AttributeType, FeaturesResponseModel, getColumnMetadataModel, getFeatureModel, TailormapApiV1ServiceModel, - TAILORMAP_API_V1_SERVICE, + AttributeType, FeaturesResponseModel, getColumnMetadataModel, getFeatureModel, TAILORMAP_API_V1_SERVICE, TailormapApiV1ServiceModel, } from '@tailormap-viewer/api'; import { Store } from '@ngrx/store'; import { FilterService } from '../../../filter/services/filter.service'; @@ -14,6 +13,9 @@ import { AttributeListTabModel } from '../models/attribute-list-tab.model'; import { AttributeListDataModel } from '../models/attribute-list-data.model'; import { FeatureUpdatedService } from '../../../services/feature-updated.service'; import { MapService } from '@tailormap-viewer/map'; +import { selectVisibleLayersWithAttributes } from '../../../map'; +import { ATTRIBUTE_LIST_DEFAULT_SOURCE } from '../models/attribute-list-default-source.const'; +import { AttributeListManagerService } from './attribute-list-manager.service'; const setup = ( features?: FeaturesResponseModel, @@ -25,8 +27,8 @@ const setup = ( } as unknown as TailormapApiV1ServiceModel; const tabs: AttributeListTabModel[] = [ - { id: '1', layerId: '1', label: 'TEST 1', selectedDataId: '1', loadingData: false, initialDataLoaded: false }, - { id: '2', layerId: '2', label: 'TEST 2', selectedDataId: '2', loadingData: false, initialDataLoaded: false }, + { tabSourceId: ATTRIBUTE_LIST_DEFAULT_SOURCE, id: '1', layerId: '1', label: 'TEST 1', selectedDataId: '1', loadingData: false, initialDataLoaded: false }, + { tabSourceId: ATTRIBUTE_LIST_DEFAULT_SOURCE, id: '2', layerId: '2', label: 'TEST 2', selectedDataId: '2', loadingData: false, initialDataLoaded: false }, ]; const data: AttributeListDataModel[] = [ { id: '1', columns: [], tabId: '1', pageIndex: 0, pageSize: 10, rows: [], totalCount: null, sortDirection: '' }, @@ -34,9 +36,11 @@ const setup = ( ]; const store = createMockStore({ selectors: [ + { selector: selectAttributeListVisible, value: fillStore }, { selector: selectAttributeListTabs, value: fillStore ? tabs : [] }, { selector: selectAttributeListData, value: fillStore ? data : [] }, { selector: selectViewerId, value: '1' }, + { selector: selectVisibleLayersWithAttributes, value: [{ id: '1', title: '' }, { id: '2', title: '' }] }, ], }) as Store; @@ -101,6 +105,8 @@ describe('AttributeListDataService', () => { template: null, }; const { service, api } = setup(response, true); + const managerService = TestBed.inject(AttributeListManagerService); + managerService.initDefaultAttributeListSource(); service.loadDataForTab$('1').subscribe(result => { expect(api.getFeatures$).toHaveBeenCalledWith({ layerId: '1', diff --git a/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.ts b/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.ts index 0337d44d5..606ab5df6 100644 --- a/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.ts +++ b/projects/core/src/lib/components/attribute-list/services/attribute-list-data.service.ts @@ -6,7 +6,7 @@ import { AttributeListRowModel } from '../models/attribute-list-row.model'; import { Store } from '@ngrx/store'; import { selectAttributeListTab, selectAttributeListTabData, selectAttributeListTabs } from '../state/attribute-list.selectors'; import { - ColumnMetadataModel, FeatureModel, Sortorder, TAILORMAP_API_V1_SERVICE, AttributeTypeHelper, + ColumnMetadataModel, FeatureModel, Sortorder, AttributeTypeHelper, } from '@tailormap-viewer/api'; import { LoadAttributeListDataResultModel } from '../models/load-attribute-list-data-result.model'; import { AttributeListDataModel } from '../models/attribute-list-data.model'; @@ -16,12 +16,13 @@ import { AttributeListColumnModel } from '../models/attribute-list-column.model' import { FilterService } from '../../../filter/services/filter.service'; import * as AttributeListActions from '../state/attribute-list.actions'; import { FeatureUpdatedService } from '../../../services/feature-updated.service'; +import { AttributeListManagerService } from './attribute-list-manager.service'; @Injectable({ providedIn: 'root', }) export class AttributeListDataService implements OnDestroy { - private api = inject(TAILORMAP_API_V1_SERVICE); + private api = inject(AttributeListManagerService); private store$ = inject(Store); private filterService = inject(FilterService); private featureUpdatedService = inject(FeatureUpdatedService); @@ -81,7 +82,7 @@ export class AttributeListDataService implements OnDestroy { return this.store$.select(selectViewerId) .pipe( filter(TypesHelper.isDefined), - concatMap(applicationId => this.api.getFeatures$({ + concatMap(applicationId => this.api.getFeatures$(tab.tabSourceId, { layerId, applicationId, page: start, @@ -92,7 +93,7 @@ export class AttributeListDataService implements OnDestroy { : (selectedData.sortDirection === 'asc' ? Sortorder.ASC : undefined), })), ).pipe( - catchError(() => of(null)), + catchError(_e => of(null)), map((response): LoadAttributeListDataResultModel => { if (response === null) { // eslint-disable-next-line max-len diff --git a/projects/core/src/lib/components/attribute-list/services/attribute-list-export.service.ts b/projects/core/src/lib/components/attribute-list/services/attribute-list-export.service.ts index 2a8afac54..700ef2a75 100644 --- a/projects/core/src/lib/components/attribute-list/services/attribute-list-export.service.ts +++ b/projects/core/src/lib/components/attribute-list/services/attribute-list-export.service.ts @@ -1,7 +1,4 @@ import { Injectable, inject } from '@angular/core'; -import { - TAILORMAP_API_V1_SERVICE, -} from '@tailormap-viewer/api'; import { catchError, combineLatest, map, Observable, of, switchMap, take, tap } from 'rxjs'; import { Store } from '@ngrx/store'; import { selectViewerId } from '../../../state/core.selectors'; @@ -10,6 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { FileHelper, SnackBarMessageComponent, SnackBarMessageOptionsModel } from '@tailormap-viewer/shared'; import { HttpResponse } from '@angular/common/http'; import { DateTime } from 'luxon'; +import { AttributeListManagerService } from './attribute-list-manager.service'; export enum SupportedExportFormats { CSV = 'csv', @@ -26,7 +24,7 @@ export enum SupportedExportFormats { export class AttributeListExportService { private store$ = inject(Store); private snackBar = inject(MatSnackBar); - private api = inject(TAILORMAP_API_V1_SERVICE); + private managerService = inject(AttributeListManagerService); private dateLocale = inject(MAT_DATE_LOCALE); private static CSV_FORMATS = [ 'csv', 'text/csv' ]; private static XLSX_FORMATS = [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'excel2007' ]; @@ -37,8 +35,8 @@ export class AttributeListExportService { private cachedFormats: Map = new Map(); - public getExportFormats$(layerId: string): Observable { - return this.getExportCapabilities$(layerId).pipe( + public getExportFormats$(tabSourceId: string, layerId: string): Observable { + return this.getExportCapabilities$(tabSourceId, layerId).pipe( map(formats => { const supportedFormats: SupportedExportFormats[] = []; if (this.hasSupport(formats, AttributeListExportService.CSV_FORMATS)) { @@ -65,6 +63,7 @@ export class AttributeListExportService { } public export$(params: { + tabSourceId: string; layerId: string; serviceLayerName: string; format: SupportedExportFormats; @@ -73,7 +72,7 @@ export class AttributeListExportService { attributes: string[]; }): Observable { return combineLatest([ - this.getOutputFormat$(params.layerId, params.format), + this.getOutputFormat$(params.tabSourceId, params.layerId, params.format), this.store$.select(selectViewerId), ]).pipe( take(1), @@ -83,7 +82,7 @@ export class AttributeListExportService { this.showSnackbarMessage(defaultErrorMessage); return of(null); } - return this.api.getLayerExport$({ + return this.managerService.getLayerExport$(params.tabSourceId, { applicationId, layerId: params.layerId, outputFormat, @@ -123,8 +122,8 @@ export class AttributeListExportService { SnackBarMessageComponent.open$(this.snackBar, config).subscribe(); } - private getOutputFormat$(layerId: string, format: SupportedExportFormats): Observable { - return this.getExportCapabilities$(layerId) + private getOutputFormat$(tabSourceId: string, layerId: string, format: SupportedExportFormats): Observable { + return this.getExportCapabilities$(tabSourceId, layerId) .pipe( take(1), map(formats => { @@ -169,7 +168,7 @@ export class AttributeListExportService { return 'txt'; } - private getExportCapabilities$(layerId: string): Observable { + private getExportCapabilities$(tabSourceId: string, layerId: string): Observable { return this.store$.select(selectViewerId).pipe( take(1), switchMap(applicationId => { @@ -181,7 +180,7 @@ export class AttributeListExportService { if (cached) { return of(cached); } - return this.api.getLayerExportCapabilities$({ applicationId, layerId }) + return this.managerService.getLayerExportCapabilities$(tabSourceId, { applicationId, layerId }) .pipe( catchError(() => of({ exportable: false, outputFormats: [] })), tap(capabilities => { diff --git a/projects/core/src/lib/components/attribute-list/services/attribute-list-manager.service.ts b/projects/core/src/lib/components/attribute-list/services/attribute-list-manager.service.ts index 733fb9b85..a2e86edf1 100644 --- a/projects/core/src/lib/components/attribute-list/services/attribute-list-manager.service.ts +++ b/projects/core/src/lib/components/attribute-list/services/attribute-list-manager.service.ts @@ -1,15 +1,26 @@ import { Injectable, OnDestroy, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { concatMap, filter, map, takeUntil, withLatestFrom } from 'rxjs/operators'; -import { combineLatest, forkJoin, Observable, of, Subject } from 'rxjs'; +import { BehaviorSubject, combineLatest, forkJoin, Observable, of, Subject, switchMap } from 'rxjs'; import { selectAttributeListTabs, selectAttributeListVisible } from '../state/attribute-list.selectors'; import { changeAttributeListTabs } from '../state/attribute-list.actions'; import { AttributeListTabModel } from '../models/attribute-list-tab.model'; import { nanoid } from 'nanoid'; import { AttributeListDataModel } from '../models/attribute-list-data.model'; import { selectVisibleLayersWithAttributes } from '../../../map/state/map.selectors'; -import { AppLayerModel, HiddenLayerFunctionality } from '@tailormap-viewer/api'; +import { + FeaturesResponseModel, HiddenLayerFunctionality, LayerExportCapabilitiesModel, UniqueValueParams, UniqueValuesResponseModel, +} from '@tailormap-viewer/api'; import { DEFAULT_ATTRIBUTE_LIST_CONFIG } from '../models/attribute-list-config.model'; +import { AttributeListSourceModel, TabModel } from '../models/attribute-list-source.model'; +import { AttributeListApiService } from './attribute-list-api.service'; +import { HttpResponse } from '@angular/common/http'; +import { GetFeaturesParams, GetLayerExportCapabilitiesParams, GetLayerExportParams } from '../models/attribute-list-api-service.model'; +import { ATTRIBUTE_LIST_DEFAULT_SOURCE } from '../models/attribute-list-default-source.const'; + +interface TabModelWithTabSourceId extends TabModel { + tabSourceId: string; +} interface TabFromLayerResult { tab: AttributeListTabModel; @@ -20,8 +31,25 @@ interface TabFromLayerResult { providedIn: 'root', }) export class AttributeListManagerService implements OnDestroy { + private store$ = inject(Store); + private defaultApiService = inject(AttributeListApiService); + private sources$ = new BehaviorSubject([]); + private tabsFromSources$ = this.sources$.asObservable() + .pipe( + switchMap(sources => { + if (sources.length === 0) { + return of([]); + } + return combineLatest(sources.map(s => { + return s.tabs$.pipe(map(tabs => tabs.map(tab => ({ + ...tab, + tabSourceId: s.id, + })))); + })).pipe(map(tabs => tabs.flat())); + }), + ); public static readonly EMPTY_ATTRIBUTE_LIST_TAB: AttributeListTabModel = { id: '', @@ -29,6 +57,7 @@ export class AttributeListManagerService implements OnDestroy { selectedDataId: '', initialDataLoaded: false, loadingData: false, + tabSourceId: '', }; public static readonly EMPTY_ATTRIBUTE_LIST_DATA: AttributeListDataModel = { @@ -46,18 +75,17 @@ export class AttributeListManagerService implements OnDestroy { constructor() { combineLatest([ - this.store$.select(selectVisibleLayersWithAttributes), + this.tabsFromSources$, this.store$.select(selectAttributeListVisible), ]) .pipe( takeUntil(this.destroyed), - filter(([ _layers, attributeListVisible ]) => attributeListVisible), - map(([ layers, _attributeListVisible ]) => layers), - map(layers => layers.filter(l => !l.hiddenFunctionality?.includes(HiddenLayerFunctionality.attributeList))), + filter(([ _tabSources, attributeListVisible ]) => attributeListVisible), + map(([ tabSources, _attributeListVisible ]) => tabSources), withLatestFrom(this.store$.select(selectAttributeListTabs)), - concatMap(([ layers, tabs ]) => { - const closedTabs = this.getClosedTabs(layers, tabs); - const newTabs$ = this.getNewTabs$(layers, tabs); + concatMap(([ tabSources, tabs ]) => { + const closedTabs = this.getClosedTabs(tabSources, tabs); + const newTabs$ = this.getNewTabs$(tabSources, tabs); return forkJoin([ of(closedTabs), newTabs$ ]); }), filter(([ closedTabs, newTabs ]) => closedTabs.length > 0 || newTabs.length > 0), @@ -76,33 +104,85 @@ export class AttributeListManagerService implements OnDestroy { this.destroyed.complete(); } - private getClosedTabs(visibleLayers: AppLayerModel[], currentTabs: AttributeListTabModel[]): string[] { + public getFeatures$(tabSourceId: string, params: GetFeaturesParams): Observable { + const source = this.sources$.getValue().find(s => s.id === tabSourceId); + if (!source) { + return of({ features: [], columnMetadata: [], total: null, page: null, pageSize: null, template: null }); + } + return source.dataLoader.getFeatures$(params); + } + + public getLayerExportCapabilities$(tabSourceId: string, params: GetLayerExportCapabilitiesParams): Observable { + const source = this.sources$.getValue().find(s => s.id === tabSourceId); + if (!source) { + return of({ exportable: false, outputFormats: [] }); + } + return source.dataLoader.getLayerExportCapabilities$(params); + } + + public getLayerExport$(tabSourceId: string, params: GetLayerExportParams): Observable | null> { + const source = this.sources$.getValue().find(s => s.id === tabSourceId); + if (!source) { + return of(null); + } + return source.dataLoader.getLayerExport$(params); + } + + public getUniqueValues$(tabSourceId: string, params: UniqueValueParams): Observable { + const source = this.sources$.getValue().find(s => s.id === tabSourceId); + if (!source) { + return of({ values: [], filterApplied: false }); + } + return source.dataLoader.getUniqueValues$(params); + } + + public addAttributeListSource$(source: AttributeListSourceModel): void { + this.sources$.next([ + ...this.sources$.getValue(), + source, + ]); + } + + public initDefaultAttributeListSource(): void { + this.addAttributeListSource$({ + id: ATTRIBUTE_LIST_DEFAULT_SOURCE, + tabs$: this.store$.select(selectVisibleLayersWithAttributes).pipe( + map(layers => { + return layers + .filter(l => !l.hiddenFunctionality?.includes(HiddenLayerFunctionality.attributeList)) + .map(l => ({ id: l.id, label: l.title || l.layerName })); + })), + dataLoader: this.defaultApiService, + }); + } + + private getClosedTabs(visibleTabs: TabModel[], currentTabs: AttributeListTabModel[]): string[] { if (!currentTabs || currentTabs.length === 0) { return []; } return currentTabs - .filter(tab => visibleLayers.findIndex(l => l.id === tab.layerId) === -1) + .filter(tab => visibleTabs.findIndex(l => l.id === tab.layerId) === -1) .map(tab => tab.id); } private getNewTabs$( - visibleLayers: AppLayerModel[], + visibleTabs: TabModelWithTabSourceId[], currentTabs: AttributeListTabModel[], ): Observable { - if (!visibleLayers || visibleLayers.length === 0) { + if (!visibleTabs || visibleTabs.length === 0) { return of([]); } - const layersWithoutTab = visibleLayers.filter(layer => currentTabs.findIndex(t => t.layerId === layer.id) === -1); + const layersWithoutTab = visibleTabs.filter(layer => currentTabs.findIndex(t => t.layerId === layer.id) === -1); if (layersWithoutTab.length === 0) { return of([]); } return forkJoin(layersWithoutTab.map>(layer => { - return this.createTabFromLayer$(layer, DEFAULT_ATTRIBUTE_LIST_CONFIG.pageSize); + return this.createTabFromModel$(layer, DEFAULT_ATTRIBUTE_LIST_CONFIG.pageSize); })); } - private createTabFromLayer$( - layer: AppLayerModel, + private createTabFromModel$( + tabModel: TabModelWithTabSourceId, pageSize = 10, ): Observable { const id = nanoid(); @@ -110,9 +190,10 @@ export class AttributeListManagerService implements OnDestroy { const tab: AttributeListTabModel = { ...AttributeListManagerService.EMPTY_ATTRIBUTE_LIST_TAB, id, - layerId: layer.id, - label: layer.title || layer.layerName, + layerId: tabModel.id, + label: tabModel.label, selectedDataId: dataId, + tabSourceId: tabModel.tabSourceId, }; const data: AttributeListDataModel = { ...AttributeListManagerService.EMPTY_ATTRIBUTE_LIST_DATA, diff --git a/projects/core/src/lib/components/attribute-list/state/attribute-list.effects.ts b/projects/core/src/lib/components/attribute-list/state/attribute-list.effects.ts index 6e4561b99..a80173e69 100644 --- a/projects/core/src/lib/components/attribute-list/state/attribute-list.effects.ts +++ b/projects/core/src/lib/components/attribute-list/state/attribute-list.effects.ts @@ -7,9 +7,9 @@ import { AttributeListDataService } from '../services/attribute-list-data.servic import { Store } from '@ngrx/store'; import { selectAttributeListDataForId, selectAttributeListRow, selectAttributeListTabForDataId } from './attribute-list.selectors'; import { TypesHelper } from '@tailormap-viewer/shared'; -import { TAILORMAP_API_V1_SERVICE } from '@tailormap-viewer/api'; import { selectViewerId } from '../../../state/core.selectors'; import { MapService } from '@tailormap-viewer/map'; +import { AttributeListManagerService } from '../services/attribute-list-manager.service'; @Injectable() export class AttributeListEffects { @@ -17,7 +17,7 @@ export class AttributeListEffects { private store$ = inject(Store); private attributeListDataService = inject(AttributeListDataService); private mapService = inject(MapService); - private api = inject(TAILORMAP_API_V1_SERVICE); + private managerService = inject(AttributeListManagerService); public loadDataForTab$ = createEffect(() => { @@ -53,7 +53,7 @@ export class AttributeListEffects { if (!row || !row.__fid || !tab || !tab.layerId || applicationId === null) { return of({ type: 'noop' }); } - return this.api.getFeatures$({ + return this.managerService.getFeatures$(tab.tabSourceId, { applicationId, layerId: tab.layerId, __fid: row.__fid, diff --git a/projects/core/src/lib/components/attribute-list/state/mocks/attribute-list-state-test-data.ts b/projects/core/src/lib/components/attribute-list/state/mocks/attribute-list-state-test-data.ts index 00d197ae4..60c4da952 100644 --- a/projects/core/src/lib/components/attribute-list/state/mocks/attribute-list-state-test-data.ts +++ b/projects/core/src/lib/components/attribute-list/state/mocks/attribute-list-state-test-data.ts @@ -4,10 +4,12 @@ import { AttributeListTabModel } from '../../models/attribute-list-tab.model'; import { AttributeListManagerService } from '../../services/attribute-list-manager.service'; import { AttributeListDataModel } from '../../models/attribute-list-data.model'; import { AttributeType } from '@tailormap-viewer/api'; +import { ATTRIBUTE_LIST_DEFAULT_SOURCE } from '../../models/attribute-list-default-source.const'; export const createDummyAttributeListTab = ( overrides?: Partial, ): AttributeListTabModel => ({ + tabSourceId: ATTRIBUTE_LIST_DEFAULT_SOURCE, id: '1', selectedDataId: '1', loadingData: true,