From 51c4a2380f9a0d58c6f18972d6b4ca4e26182b15 Mon Sep 17 00:00:00 2001 From: matt-litwiller Date: Fri, 19 May 2023 15:00:56 -0400 Subject: [PATCH 01/30] Create filter & list components --- src/app/app.module.ts | 4 +++- .../simple-filters.component.html | 1 + .../simple-filters.component.scss | 0 .../simple-filters.component.spec.ts | 23 +++++++++++++++++++ .../simple-filters.component.ts | 15 ++++++++++++ .../simple-feature-list.component.html | 1 + .../simple-feature-list.component.scss | 0 .../simple-feature-list.component.spec.ts | 23 +++++++++++++++++++ .../simple-feature-list.component.ts | 15 ++++++++++++ 9 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/app/pages/filters/simple-filters/simple-filters.component.html create mode 100644 src/app/pages/filters/simple-filters/simple-filters.component.scss create mode 100644 src/app/pages/filters/simple-filters/simple-filters.component.spec.ts create mode 100644 src/app/pages/filters/simple-filters/simple-filters.component.ts create mode 100644 src/app/pages/list/simple-feature-list/simple-feature-list.component.html create mode 100644 src/app/pages/list/simple-feature-list/simple-feature-list.component.scss create mode 100644 src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts create mode 100644 src/app/pages/list/simple-feature-list/simple-feature-list.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7cc0690f..3f19dbfe 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,6 +31,8 @@ import { ServiceWorkerModule } from '@angular/service-worker'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip'; import { concatMap, first } from 'rxjs'; +import { SimpleFeatureListComponent } from './pages/list/simple-feature-list/simple-feature-list.component'; +import { SimpleFiltersComponent } from './pages/filters/simple-filters/simple-filters.component'; export const defaultTooltipOptions: MatTooltipDefaultOptions = { showDelay: 500, @@ -40,7 +42,7 @@ export const defaultTooltipOptions: MatTooltipDefaultOptions = { }; @NgModule({ - declarations: [AppComponent], + declarations: [AppComponent, SimpleFeatureListComponent, SimpleFiltersComponent], imports: [ BrowserModule, BrowserAnimationsModule, diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.html b/src/app/pages/filters/simple-filters/simple-filters.component.html new file mode 100644 index 00000000..8e7b4821 --- /dev/null +++ b/src/app/pages/filters/simple-filters/simple-filters.component.html @@ -0,0 +1 @@ +

simple-filters works!

diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.scss b/src/app/pages/filters/simple-filters/simple-filters.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.spec.ts b/src/app/pages/filters/simple-filters/simple-filters.component.spec.ts new file mode 100644 index 00000000..6d7cc1ae --- /dev/null +++ b/src/app/pages/filters/simple-filters/simple-filters.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SimpleFiltersComponent } from './simple-filters.component'; + +describe('SimpleFiltersComponent', () => { + let component: SimpleFiltersComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ SimpleFiltersComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SimpleFiltersComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.ts b/src/app/pages/filters/simple-filters/simple-filters.component.ts new file mode 100644 index 00000000..06e7e37f --- /dev/null +++ b/src/app/pages/filters/simple-filters/simple-filters.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-simple-filters', + templateUrl: './simple-filters.component.html', + styleUrls: ['./simple-filters.component.scss'] +}) +export class SimpleFiltersComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.html b/src/app/pages/list/simple-feature-list/simple-feature-list.component.html new file mode 100644 index 00000000..35821544 --- /dev/null +++ b/src/app/pages/list/simple-feature-list/simple-feature-list.component.html @@ -0,0 +1 @@ +

simple-feature-list works!

diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.scss b/src/app/pages/list/simple-feature-list/simple-feature-list.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts b/src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts new file mode 100644 index 00000000..75132fd2 --- /dev/null +++ b/src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SimpleFeatureListComponent } from './simple-feature-list.component'; + +describe('SimpleFeatureListComponent', () => { + let component: SimpleFeatureListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ SimpleFeatureListComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(SimpleFeatureListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.ts b/src/app/pages/list/simple-feature-list/simple-feature-list.component.ts new file mode 100644 index 00000000..f2935c77 --- /dev/null +++ b/src/app/pages/list/simple-feature-list/simple-feature-list.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-simple-feature-list', + templateUrl: './simple-feature-list.component.html', + styleUrls: ['./simple-feature-list.component.scss'] +}) +export class SimpleFeatureListComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} From ddee8cfb27128576a47226f6217e24d5fd903783 Mon Sep 17 00:00:00 2001 From: matt-litwiller Date: Tue, 30 May 2023 10:23:12 -0400 Subject: [PATCH 02/30] reformatted folder structure for lists & filters --- src/app/app.component.html | 2 ++ src/app/app.module.ts | 4 ++-- .../{simple-filters => }/simple-filters.component.html | 0 .../{simple-filters => }/simple-filters.component.scss | 0 .../{simple-filters => }/simple-filters.component.spec.ts | 0 .../filters/{simple-filters => }/simple-filters.component.ts | 0 .../simple-feature-list.component.html | 0 .../simple-feature-list.component.scss | 0 .../simple-feature-list.component.spec.ts | 0 .../simple-feature-list.component.ts | 0 10 files changed, 4 insertions(+), 2 deletions(-) rename src/app/pages/filters/{simple-filters => }/simple-filters.component.html (100%) rename src/app/pages/filters/{simple-filters => }/simple-filters.component.scss (100%) rename src/app/pages/filters/{simple-filters => }/simple-filters.component.spec.ts (100%) rename src/app/pages/filters/{simple-filters => }/simple-filters.component.ts (100%) rename src/app/pages/list/{simple-feature-list => }/simple-feature-list.component.html (100%) rename src/app/pages/list/{simple-feature-list => }/simple-feature-list.component.scss (100%) rename src/app/pages/list/{simple-feature-list => }/simple-feature-list.component.spec.ts (100%) rename src/app/pages/list/{simple-feature-list => }/simple-feature-list.component.ts (100%) diff --git a/src/app/app.component.html b/src/app/app.component.html index 47f20005..a8e7a1b1 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -4,4 +4,6 @@ + + \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3f19dbfe..721cda09 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,8 +31,8 @@ import { ServiceWorkerModule } from '@angular/service-worker'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip'; import { concatMap, first } from 'rxjs'; -import { SimpleFeatureListComponent } from './pages/list/simple-feature-list/simple-feature-list.component'; -import { SimpleFiltersComponent } from './pages/filters/simple-filters/simple-filters.component'; +import { SimpleFeatureListComponent } from './pages/list/simple-feature-list.component'; +import { SimpleFiltersComponent } from './pages/filters/simple-filters.component'; export const defaultTooltipOptions: MatTooltipDefaultOptions = { showDelay: 500, diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.html b/src/app/pages/filters/simple-filters.component.html similarity index 100% rename from src/app/pages/filters/simple-filters/simple-filters.component.html rename to src/app/pages/filters/simple-filters.component.html diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.scss b/src/app/pages/filters/simple-filters.component.scss similarity index 100% rename from src/app/pages/filters/simple-filters/simple-filters.component.scss rename to src/app/pages/filters/simple-filters.component.scss diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.spec.ts b/src/app/pages/filters/simple-filters.component.spec.ts similarity index 100% rename from src/app/pages/filters/simple-filters/simple-filters.component.spec.ts rename to src/app/pages/filters/simple-filters.component.spec.ts diff --git a/src/app/pages/filters/simple-filters/simple-filters.component.ts b/src/app/pages/filters/simple-filters.component.ts similarity index 100% rename from src/app/pages/filters/simple-filters/simple-filters.component.ts rename to src/app/pages/filters/simple-filters.component.ts diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.html b/src/app/pages/list/simple-feature-list.component.html similarity index 100% rename from src/app/pages/list/simple-feature-list/simple-feature-list.component.html rename to src/app/pages/list/simple-feature-list.component.html diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.scss b/src/app/pages/list/simple-feature-list.component.scss similarity index 100% rename from src/app/pages/list/simple-feature-list/simple-feature-list.component.scss rename to src/app/pages/list/simple-feature-list.component.scss diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts b/src/app/pages/list/simple-feature-list.component.spec.ts similarity index 100% rename from src/app/pages/list/simple-feature-list/simple-feature-list.component.spec.ts rename to src/app/pages/list/simple-feature-list.component.spec.ts diff --git a/src/app/pages/list/simple-feature-list/simple-feature-list.component.ts b/src/app/pages/list/simple-feature-list.component.ts similarity index 100% rename from src/app/pages/list/simple-feature-list/simple-feature-list.component.ts rename to src/app/pages/list/simple-feature-list.component.ts From 00247d85d9fd58a7734d7cc1803764d9b32605d3 Mon Sep 17 00:00:00 2001 From: matt-litwiller Date: Tue, 30 May 2023 13:09:27 -0400 Subject: [PATCH 03/30] simple list reformatting list still is empty - working on figuring out why --- src/app/app.module.ts | 12 + src/app/pages/list/shared/entity.enums.ts | 26 ++ .../pages/list/shared/entity.interfaces.ts | 139 +++++++ src/app/pages/list/shared/entity.utils.ts | 74 ++++ src/app/pages/list/shared/state.ts | 222 +++++++++++ src/app/pages/list/shared/store.ts | 358 +++++++++++++++++ src/app/pages/list/shared/strategy.ts | 87 ++++ src/app/pages/list/shared/view.ts | 374 ++++++++++++++++++ .../list/simple-feature-list.component.html | 49 ++- .../list/simple-feature-list.component.scss | 72 ++++ .../list/simple-feature-list.component.ts | 344 +++++++++++++++- .../list/simple-feature-list.interface.ts | 26 ++ 12 files changed, 1779 insertions(+), 4 deletions(-) create mode 100644 src/app/pages/list/shared/entity.enums.ts create mode 100644 src/app/pages/list/shared/entity.interfaces.ts create mode 100644 src/app/pages/list/shared/entity.utils.ts create mode 100644 src/app/pages/list/shared/state.ts create mode 100644 src/app/pages/list/shared/store.ts create mode 100644 src/app/pages/list/shared/strategy.ts create mode 100644 src/app/pages/list/shared/view.ts create mode 100644 src/app/pages/list/simple-feature-list.interface.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 721cda09..714a5445 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -30,6 +30,12 @@ import { AppComponent } from './app.component'; import { ServiceWorkerModule } from '@angular/service-worker'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; import { concatMap, first } from 'rxjs'; import { SimpleFeatureListComponent } from './pages/list/simple-feature-list.component'; import { SimpleFiltersComponent } from './pages/filters/simple-filters.component'; @@ -44,6 +50,12 @@ export const defaultTooltipOptions: MatTooltipDefaultOptions = { @NgModule({ declarations: [AppComponent, SimpleFeatureListComponent, SimpleFiltersComponent], imports: [ + CommonModule, + MatIconModule, + MatDividerModule, + MatButtonModule, + MatTooltipModule, + MatPaginatorModule, BrowserModule, BrowserAnimationsModule, RouterModule.forRoot([]), diff --git a/src/app/pages/list/shared/entity.enums.ts b/src/app/pages/list/shared/entity.enums.ts new file mode 100644 index 00000000..2c38e96a --- /dev/null +++ b/src/app/pages/list/shared/entity.enums.ts @@ -0,0 +1,26 @@ +export enum EntityOperationType { + Insert = 'Insert', + Update = 'Update', + Delete = 'Delete' +} + +export enum EntityTableColumnRenderer { + Default = 'Default', + HTML = 'HTML', + UnsanitizedHTML = 'UnsanitizedHTML', + Editable = 'Editable', + Icon = 'Icon', + ButtonGroup = 'ButtonGroup' +} + +export enum EntityTableScrollBehavior { + Auto = 'auto', + Instant = 'instant', + Smooth = 'smooth' +} + +export enum EntityTableSelectionState { + None = 'None', + All = 'All', + Some = 'Some' +} diff --git a/src/app/pages/list/shared/entity.interfaces.ts b/src/app/pages/list/shared/entity.interfaces.ts new file mode 100644 index 00000000..cc6b92f7 --- /dev/null +++ b/src/app/pages/list/shared/entity.interfaces.ts @@ -0,0 +1,139 @@ +import { Observable } from 'rxjs'; + +import { + EntityOperationType, + EntityTableColumnRenderer +} from './entity.enums'; +import { EntityStore } from './store'; + +export type EntityKey = string | number; + +export interface EntityState { + [key: string]: any; +} + +export interface EntityRecord { + entity: E; + state: S; + revision: number; + ref: string; + edition?: boolean; +} + +export interface EntityStoreOptions { + getKey?: (entity: object) => EntityKey; + getProperty?: (entity: object, property: string) => any; +} + +export interface EntityStateManagerOptions { + getKey?: (entity: object) => EntityKey; + store?: EntityStore; +} + +export interface EntityStoreStrategyOptions {} + +export interface EntityStoreStrategyFuncOptions extends EntityStoreStrategyOptions { + filterClauseFunc: EntityFilterClause; +} + +export interface EntityTransactionOptions { + getKey?: (entity: object) => EntityKey; +} + +export type EntityFilterClause = (entity: E) => boolean; + +export interface EntitySortClause { + valueAccessor: (entity: E) => string | number; + direction: string; + + // If true, null and undefined values will be first + // If false, null and undefined values will be last + // If undefined, default to true and false when sorting in descending and + // ascending order, respectively + nullsFirst?: boolean; +} + +export interface EntityJoinClause { + source: Observable; + reduce: (param1: object, param2: any) => object; +} + +export interface EntityOperation { + key: EntityKey; + type: EntityOperationType; + previous: E | undefined; + current: E | undefined; + store?: EntityStore; + meta?: {[key: string]: any}; +} + +export interface EntityOperationState { + added: boolean; + canceled: boolean; +} + +export interface EntityTableTemplate { + columns: EntityTableColumn[]; + selection?: boolean; + selectionCheckbox?: boolean; + selectMany?: boolean; + sort?: boolean; + fixedHeader?: boolean; + tableHeight?:string; + valueAccessor?: (entity: object, property: string, record: EntityRecord) => any; + headerClassFunc?: () => { + [key: string]: boolean; + }; + rowClassFunc?: (entity: object, record: EntityRecord) => { + [key: string]: boolean; + }; + cellClassFunc?: (entity: object, column: EntityTableColumn, record: EntityRecord) => { + [key: string]: boolean; + }; +} + +export interface EntityTableColumnValidation { + readonly?: boolean; + mandatory?: boolean; + maxlength?: number; + minlength?: number; + minValue?: number; + maxValue?: number; +} + +export interface TableRelation { + table: string; +} + +export interface EntityTableColumn { + validation?: EntityTableColumnValidation; + name: string; + title: string; + renderer?: EntityTableColumnRenderer; + valueAccessor?: (entity: object, record: EntityRecord) => any; + visible?: boolean; + linkColumnForce?: string; + sort?: boolean; + type?: string; + multiple?: boolean; + domainValues?: Array; + relation?: TableRelation; + tooltip?: string; + cellClassFunc?: (entity: object, record: EntityRecord) => { + [key: string]: boolean; + }; +} + +export interface SelectOption { + id: number; + value: string; +} + +export interface EntityTableButton { + icon: string; + click: (entity: object, record: EntityRecord) => void; + color?: 'primary' | 'accent' | 'warn'; + disabled?: boolean; + style?: 'mat-mini-fab' | 'mat-icon-button'; + editMode?: boolean; +} diff --git a/src/app/pages/list/shared/entity.utils.ts b/src/app/pages/list/shared/entity.utils.ts new file mode 100644 index 00000000..e762775a --- /dev/null +++ b/src/app/pages/list/shared/entity.utils.ts @@ -0,0 +1,74 @@ +import { t } from 'typy'; + +import { EntityKey } from './entity.interfaces'; + +/** + * Get an entity's named property. Nested properties are supported + * with the dotted notation. (i.e 'author.name') + * + * Note: this method is a 'best attempt' at getting an entity's property. + * It fits the most common cases but you might need to explicitely define + * a property getter when using an EntityStore, for example. + * @param entity Entity + * @param property Property name + * @returns Property value + */ +export function getEntityProperty(entity: object, property: string): any { + return t(entity, property).safeObject; +} + +/** + * Get an entity's id. An entity's id can be one of: + * 'entity.meta.id', 'entity.meta.idProperty' or 'entity.id'. + * + * Note: See the note in the 'getEntityProperty' documentation. + * @param entity Entity + * @returns Entity id + */ +export function getEntityId(entity: object): EntityKey { + const meta = (entity as any).meta || {}; + return meta.id ? meta.id : getEntityProperty(entity, meta.idProperty || 'id'); +} + +/** + * Get an entity's title. An entity's title can be one of: + * 'entity.meta.title', 'entity.meta.titleProperty' or 'entity.title'. + * @param entity Entity + * @returns Entity title + */ +export function getEntityTitle(entity: object): string { + const meta = (entity as any).meta || {}; + return meta.title ? meta.title : getEntityProperty(entity, meta.titleProperty || 'title'); +} + +/** + * Get an entity's HTML title. An entity's HTML title can be one of: + * 'entity.meta.titleHtml', 'entity.meta.titleHtmlProperty' or 'entity.titleHtml'. + * @param entity Entity + * @returns Entity HTML title + */ +export function getEntityTitleHtml(entity: object): string { + const meta = (entity as any).meta || {}; + return meta.titleHtml ? meta.titleHtml : getEntityProperty(entity, meta.titleHtmlProperty || 'titleHtml'); +} + +/** + * Get an entity's icon. An entity's icon can be one of: + * 'entity.meta.icon', 'entity.meta.iconProperty' or 'entity.icon'. + * @param entity Entity + * @returns Entity icon + */ +export function getEntityIcon(entity: object): string { + const meta = (entity as any).meta || {}; + return meta.icon ? meta.icon : getEntityProperty(entity, meta.iconProperty || 'icon'); +} + +/** + * Get an entity's revision. + * @param entity Entity + * @returns Entity revision + */ +export function getEntityRevision(entity: object): number { + const meta = (entity as any).meta || {}; + return meta.revision || 0; +} diff --git a/src/app/pages/list/shared/state.ts b/src/app/pages/list/shared/state.ts new file mode 100644 index 00000000..906de940 --- /dev/null +++ b/src/app/pages/list/shared/state.ts @@ -0,0 +1,222 @@ +import { ReplaySubject } from 'rxjs'; + +import { EntityKey, EntityState, EntityStateManagerOptions } from './entity.interfaces'; +import { getEntityId } from './entity.utils'; +import { EntityStore } from './store'; + +/** + * This class is used to track a store's entities state + */ +export class EntityStateManager { + + /** + * State index + */ + readonly index = new Map(); + + /** + * Change emitter + */ + readonly change$ = new ReplaySubject(1); + + /** + * Method to get an entity's id + */ + readonly getKey: (E) => EntityKey; + + private store: EntityStore | undefined; + + constructor(options: EntityStateManagerOptions = {}) { + this.store = options.store ? options.store : undefined; + this.getKey = options.getKey + ? options.getKey + : (this.store ? this.store.getKey : getEntityId); + this.next(); + } + + /** + * Clear state + */ + clear() { + if (this.index.size > 0) { + this.index.clear(); + this.next(); + } + } + + /** + * Get an entity's state + * @param entity Entity + * @returns State + */ + get(entity: E): S { + return (this.index.get(this.getKey(entity)) || {}) as S; + } + + /** + * Set an entity's state + * @param entity Entity + * @param state State + */ + set(entity: E, state: S) { + this.setMany([entity], state); + } + + /** + * Set many entitie's state + * @param entitie Entities + * @param state State + */ + setMany(entities: E[], state: S) { + entities.forEach((entity: E) => { + this.index.set(this.getKey(entity), Object.assign({}, state)); + }); + this.next(); + } + + /** + * Set state of all entities that already have a state. This is not + * the same as setting the state of all the store's entities. + * @param state State + */ + setAll(state: S) { + Array.from(this.index.keys()).forEach((key: EntityKey) => { + this.index.set(key, Object.assign({}, state)); + }); + this.next(); + } + + /** + * Update an entity's state + * @param entity Entity + * @param changes State changes + */ + update(entity: E, changes: Partial, exclusive = false) { + this.updateMany([entity], changes, exclusive); + } + + /** + * Update many entitie's state + * @param entitie Entities + * @param changes State changes + */ + updateMany(entities: E[], changes: Partial, exclusive = false) { + if (exclusive === true) { + return this.updateManyExclusive(entities, changes); + } + + entities.forEach((entity: E) => { + const state = Object.assign({}, this.get(entity), changes); + this.index.set(this.getKey(entity), state); + }); + this.next(); + } + + /** + * Reversee an entity's state + * @param entity Entity + * @param keys State keys to reverse + */ + reverse(entity: E, keys: string[]) { + this.reverseMany([entity], keys); + } + + /** + * Reverse many entitie's state + * @param entitie Entities + * @param keys State keys to reverse + */ + reverseMany(entities: E[], keys: string[]) { + entities.forEach((entity: E) => { + const currentState = this.get(entity); + const changes = keys.reduce((acc: {[key: string]: boolean}, key: string) => { + acc[key] = currentState[key] || false; + return acc; + }, {}) as Partial; + const reversedChanges = this.reverseChanges(changes); + const state = Object.assign({}, currentState, reversedChanges); + this.index.set(this.getKey(entity), state); + }); + this.next(); + } + + /** + * Update state of all entities that already have a state. This is not + * the same as updating the state of all the store's entities. + * @param changes State + */ + updateAll(changes: Partial) { + const allKeys = this.getAllKeys(); + Array.from(allKeys).forEach((key: EntityKey) => { + const state = Object.assign({}, this.index.get(key), changes); + this.index.set(key, state); + }); + this.next(); + } + + /** + * When some state changes are flagged as 'exclusive', reverse + * the state of all other entities. Changes are reversable when + * they are boolean. + * @param entitie Entities + * @param changes State changes + */ + private updateManyExclusive(entities: E[], changes: Partial) { + const reverseChanges = this.reverseChanges(changes); + + const keys = entities.map((entity: E) => this.getKey(entity)); + const allKeys = new Set(keys.concat(Array.from(this.getAllKeys()))); + allKeys.forEach((key: EntityKey) => { + const state = this.index.get(key) || {} as S; + + if (keys.indexOf(key) >= 0) { + this.index.set(key, Object.assign({}, state, changes)); + } else { + // Update only if the reverse changes would modify + // a key already present in the current state + const shouldUpdate = Object.keys(reverseChanges).some((changeKey: string) => { + return state[changeKey] !== undefined && + state[changeKey] !== reverseChanges[changeKey]; + }); + if (shouldUpdate === true) { + this.index.set(key, Object.assign({}, state, reverseChanges)); + } + } + }); + + this.next(); + } + + /** + * Compute a 'reversed' version of some state changes. + * Changes are reversable when they are boolean. + * @param changes State changes + * @returns Reversed state changes + */ + private reverseChanges(changes: Partial): Partial { + return Object.entries(changes).reduce((reverseChanges: Partial, bunch: [string, any]) => { + const [changeKey, value] = bunch; + if (typeof value === typeof true) { + (reverseChanges as object)[changeKey] = !value; + } + return reverseChanges; + }, {}); + } + + /** + * Return all the keys in that state and in the store it's bound to, if any. + * @returns Set of keys + */ + private getAllKeys(): Set { + const storeKeys = this.store ? Array.from(this.store.index.keys()) : []; + return new Set(Array.from(this.index.keys()).concat(storeKeys)); + } + + /** + * Emit 'change' event + */ + private next() { + this.change$.next(); + } + +} diff --git a/src/app/pages/list/shared/store.ts b/src/app/pages/list/shared/store.ts new file mode 100644 index 00000000..50d52d42 --- /dev/null +++ b/src/app/pages/list/shared/store.ts @@ -0,0 +1,358 @@ +import { BehaviorSubject } from 'rxjs'; + +import { EntityStateManager } from './state'; +import { EntityView } from './view'; +import { EntityKey, EntityState, EntityRecord, EntityStoreOptions } from './entity.interfaces'; +import { getEntityId, getEntityProperty } from './entity.utils'; +import { EntityStoreStrategy } from './strategy'; + +/** + * An entity store class holds any number of entities + * as well as their state. It can be observed, filtered and sorted and + * provides methods to insert, update or delete entities. + */ +export class EntityStore { + + /** + * Observable of the raw entities + */ + readonly entities$ = new BehaviorSubject([]); + + /** + * Number of entities + */ + readonly count$ = new BehaviorSubject(0); + get count(): number { return this.count$.value; } + + /** + * Whether the store is empty + */ + readonly empty$ = new BehaviorSubject(true); + get empty(): boolean { return this.empty$.value; } + + /** + * Entity store state + */ + readonly state: EntityStateManager; + + /** + * View of all the entities + */ + readonly view: EntityView; + + /** + * View of all the entities and their state + */ + readonly stateView: EntityView>; + + /** + * Method to get an entity's id + */ + readonly getKey: (E) => EntityKey; + + /** + * Method to get an entity's named property + */ + readonly getProperty: (E, prop: string) => any; + + /** + * Store index + */ + get index(): Map { return this._index; } + private _index: Map; + + /** + * Store index + */ + get pristine(): boolean { return this._pristine; } + private _pristine: boolean = true; + + /** + * Strategies + */ + private strategies: EntityStoreStrategy[] = []; + + constructor(entities: E[], options: EntityStoreOptions = {}) { + this.getKey = options.getKey ? options.getKey : getEntityId; + this.getProperty = options.getProperty ? options.getProperty : getEntityProperty; + + this.state = this.createStateManager(); + this.view = this.createDataView(); + this.stateView = this.createStateView(); + + this.view.lift(); + this.stateView.lift(); + + if (entities.length > 0) { + this.load(entities); + } else { + this._index = this.generateIndex(entities); + } + } + + /** + * Get an entity from the store by key + * @param key Key + * @returns Entity + */ + get(key: EntityKey): E { + return this.index.get(key); + } + + /** + * Get all entities in the store + * @returns Array of entities + */ + all(): E[] { + return this.entities$.value; + } + + /** + * Set this store's entities + * @param entities Entities + */ + load(entities: E[], pristine: boolean = true) { + this._index = this.generateIndex(entities); + this._pristine = pristine; + this.next(); + } + + /** + * Clear the store's entities but keep the state and views intact. + * Views won't return any data but future data will be subject to the + * current views filter and sort + */ + softClear() { + if (this.index && this.index.size > 0) { + this.index.clear(); + this._pristine = true; + this.next(); + } else if (this.index) { + this.updateCount(); + } + } + + /** + * Clear the store's entities, state and views + */ + clear() { + this.stateView.clear(); + this.view.clear(); + this.state.clear(); + this.softClear(); + } + + destroy() { + this.stateView.destroy(); + this.view.destroy(); + this.clear(); + } + + /** + * Insert an entity into the store + * @param entity Entity + */ + insert(entity: E) { + this.insertMany([entity]); + } + + /** + * Insert many entities into the store + * @param entities Entities + */ + insertMany(entities: E[]) { + entities.forEach((entity: E) => this.index.set(this.getKey(entity), entity)); + this._pristine = false; + this.next(); + } + + /** + * Update or insert an entity into the store + * @param entity Entity + */ + update(entity: E) { + this.updateMany([entity]); + } + + /** + * Update or insert many entities into the store + * @param entities Entities + */ + updateMany(entities: E[]) { + entities.forEach((entity: E) => this.index.set(this.getKey(entity), entity)); + this._pristine = false; + this.next(); + } + + /** + * Delete an entity from the store + * @param entity Entity + */ + delete(entity: E) { + this.deleteMany([entity]); + } + + /** + * Delete many entities from the store + * @param entities Entities + */ + deleteMany(entities: E[]) { + entities.forEach((entity: E) => this.index.delete(this.getKey(entity))); + this._pristine = false; + this.next(); + } + + /** + * Add a strategy to this store + * @param strategy Entity store strategy + * @returns Entity store + */ + addStrategy(strategy: EntityStoreStrategy, activate: boolean = false): EntityStore { + const existingStrategy = this.strategies.find((_strategy: EntityStoreStrategy) => { + return strategy.constructor === _strategy.constructor; + }); + if (existingStrategy !== undefined) { + throw new Error('A strategy of this type already exists on that EntityStore.'); + } + + this.strategies.push(strategy); + strategy.bindStore(this); + + if (activate === true) { + strategy.activate(); + } + + return this; + } + + /** + * Remove a strategy from this store + * @param strategy Entity store strategy + * @returns Entity store + */ + removeStrategy(strategy: EntityStoreStrategy): EntityStore { + const index = this.strategies.indexOf(strategy); + if (index >= 0) { + this.strategies.splice(index, 1); + strategy.unbindStore(this); + } + return this; + } + + /** + * Return strategies of a given type + * @param type Entity store strategy class + * @returns Strategies + */ + getStrategyOfType(type: typeof EntityStoreStrategy): EntityStoreStrategy { + return this.strategies.find((strategy: EntityStoreStrategy) => { + return strategy instanceof type; + }); + } + + /** + * Activate strategies of a given type + * @param type Entity store strategy class + */ + activateStrategyOfType(type: typeof EntityStoreStrategy) { + const strategy = this.getStrategyOfType(type); + if (strategy !== undefined) { + strategy.activate(); + } + } + + /** + * Deactivate strategies of a given type + * @param type Entity store strategy class + */ + deactivateStrategyOfType(type: typeof EntityStoreStrategy) { + const strategy = this.getStrategyOfType(type); + if (strategy !== undefined) { + strategy.deactivate(); + } + } + + /** + * Generate a complete index of all the entities + * @param entities Entities + * @returns Index + */ + private generateIndex(entities: E[]): Map { + const entries = entities.map((entity: E) => [this.getKey(entity), entity]); + return new Map(entries as [EntityKey, E][]); + } + + /** + * Push the index's entities into the entities$ observable + */ + private next() { + this.entities$.next(Array.from(this.index.values())); + this.updateCount(); + } + + /** + * Update the store's count and empty + */ + private updateCount() { + const count = this.index.size; + const empty = count === 0; + this.count$.next(count); + this.empty$.next(empty); + } + + /** + * Create the entity state manager + * @returns EntityStateManager + */ + private createStateManager() { + return new EntityStateManager({store: this}); + } + + /** + * Create the data view + * @returns EntityView + */ + private createDataView() { + return new EntityView(this.entities$); + } + + /** + * Create the state view + * @returns EntityView> + */ + private createStateView() { + return new EntityView>(this.view.all$()) + .join({ + source: this.state.change$, + reduce: (entity: E): EntityRecord => { + const key = this.getKey(entity); + const state = this.state.get(entity); + const currentRecord = this.stateView.get(key); + + if ( + currentRecord !== undefined && + currentRecord.entity === entity && + this.statesAreTheSame(currentRecord.state, state) + ) { + return currentRecord; + } + + const revision = currentRecord ? currentRecord.revision + 1 : 1; + const ref = `${key}-${revision}`; + return {entity, state, revision, ref}; + } + }) + .createIndex((record: EntityRecord) => this.getKey(record.entity)); + } + + private statesAreTheSame(currentState: S, newState: S): boolean { + if (currentState === newState) { + return true; + } + + const currentStateIsEmpty = Object.keys(currentState).length === 0; + const newStateIsEmpty = Object.keys(newState).length === 0; + return currentStateIsEmpty && newStateIsEmpty; + } + +} diff --git a/src/app/pages/list/shared/strategy.ts b/src/app/pages/list/shared/strategy.ts new file mode 100644 index 00000000..d7d25dd6 --- /dev/null +++ b/src/app/pages/list/shared/strategy.ts @@ -0,0 +1,87 @@ +import { BehaviorSubject } from 'rxjs'; + +import { EntityStoreStrategyOptions } from './entity.interfaces'; +import { EntityStore } from './store'; + +/** + * Entity store strategies. They can do pretty much anything during a store's + * lifetime. For example, they may act as triggers when something happens. + * Sharing a strategy is a good idea when multiple strategies would have + * on cancelling effect on each other. + * + * At creation, strategy is inactive and needs to be manually activated. + */ +export class EntityStoreStrategy { + + /** + * Feature store + * @internal + */ + protected stores: EntityStore[] = []; + + /** + * Whether this strategy is active + * @internal + */ + get active(): boolean { return this.active$.value; } + readonly active$: BehaviorSubject = new BehaviorSubject(false); + + constructor(protected options: EntityStoreStrategyOptions = {}) { + this.options = options; + } + + /** + * Activate the strategy. If it's already active, it'll be deactivated + * and activated again. + */ + activate() { + if (this.active === true) { + this.doDeactivate(); + } + this.active$.next(true); + this.doActivate(); + } + + /** + * Activate the strategy. If it's already active, it'll be deactivated + * and activated again. + */ + deactivate() { + this.active$.next(false); + this.doDeactivate(); + } + + /** + * Bind this strategy to a store + * @param store Feature store + */ + bindStore(store: EntityStore) { + if (this.stores.indexOf(store) < 0) { + this.stores.push(store); + } + } + + /** + * Unbind this strategy from store + * @param store Feature store + */ + unbindStore(store: EntityStore) { + const index = this.stores.indexOf(store); + if (index >= 0) { + this.stores.splice(index, 1); + } + } + + /** + * Do the stataegy activation + * @internal + */ + protected doActivate() {} + + /** + * Do the strategy deactivation + * @internal + */ + protected doDeactivate() {} + +} diff --git a/src/app/pages/list/shared/view.ts b/src/app/pages/list/shared/view.ts new file mode 100644 index 00000000..35714d6c --- /dev/null +++ b/src/app/pages/list/shared/view.ts @@ -0,0 +1,374 @@ +import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs'; +import { debounceTime, map, skip } from 'rxjs/operators'; + +import { ObjectUtils, uuid } from '@igo2/utils'; +import { + EntityKey, + EntityFilterClause, + EntitySortClause, + EntityJoinClause +} from './entity.interfaces'; + +/** + * An entity view streams entities from an observable source. These entities + * can be filtered or sorted without affecting the source. A view can also + * combine data from multiple sources, joined together. + */ +export class EntityView { + + /** + * Observable stream of values + */ + readonly values$ = new BehaviorSubject([]); + + /** + * Subscription to the source (and joined sources) values + */ + private values$$: Subscription; + + /** + * Whether this view has been lifted + */ + private lifted = false; + + /** + * Join clauses + */ + private joins: EntityJoinClause[] = []; + + /** + * Observable of a filter clause + */ + private filter$ = new BehaviorSubject(undefined); + + /** + * Observable of filter clauses + */ + private filters$: BehaviorSubject = new BehaviorSubject([]); + + /** + * Filters index + */ + private filterIndex: Map = new Map(); + + /** + * Observable of a sort clause + */ + private sort$ = new BehaviorSubject(undefined); + + /** + * Method for indexing + */ + get getKey(): (V) => EntityKey { return this.getKey$.value; } + private getKey$: BehaviorSubject<(V) => EntityKey> = new BehaviorSubject(undefined); + + /** + * Number of entities + */ + readonly count$ = new BehaviorSubject(0); + get count(): number { return this.count$.value; } + + /** + * Whether the store is empty + */ + readonly empty$ = new BehaviorSubject(true); + get empty(): boolean { return this.empty$.value; } + + /** + * Store index + */ + get index(): Map { return this._index; } + private _index: Map; + + constructor(private source$: BehaviorSubject) {} + + /** + * Get a value from the view by key + * @param key Key + * @returns Value + */ + get(key: EntityKey): V { + if (this._index === undefined) { + throw new Error('This view has no index, therefore, this method is unavailable.'); + } + return this.index.get(key); + } + + /** + * Get all the values + * @returns Array of values + */ + all(): V[] { + return this.values$.value; + } + + /** + * Observe all the values + * @returns Observable of values + */ + all$(): BehaviorSubject { + return this.values$; + } + + /** + * Get the first value that respects a criteria + * @returns A value + */ + firstBy(clause: EntityFilterClause): V { + return this.values$.value.find(clause); + } + + /** + * Observe the first value that respects a criteria + * @returns Observable of a value + */ + firstBy$(clause: EntityFilterClause): Observable { + return this.values$.pipe(map((values: V[]) => values.find(clause))); + } + + /** + * Get all the values that respect a criteria + * @returns Array of values + */ + manyBy(clause: EntityFilterClause): V[] { + return this.values$.value.filter(clause); + } + + /** + * Observe all the values that respect a criteria + * @returns Observable of values + */ + manyBy$(clause: EntityFilterClause): Observable { + return this.values$.pipe(map((values: V[]) => values.filter(clause))); + } + + /** + * Clear the filter and sort and unsubscribe from the source + */ + clear() { + this.filter(undefined); + this.sort(undefined); + } + + destroy() { + if (this.values$$ !== undefined) { + this.values$$.unsubscribe(); + } + this.clear(); + } + + /** + * Create an index + * @param getKey Method to get a value's id + * @returns The view + */ + createIndex(getKey: (E) => EntityKey): EntityView { + this._index = new Map(); + this.getKey$.next(getKey); + return this; + } + + /** + * Join another source to the stream (chainable) + * @param clause Join clause + * @returns The view + */ + join(clause: EntityJoinClause): EntityView { + if (this.lifted === true) { + throw new Error('This view has already been lifted, therefore, no join is allowed.'); + } + this.joins.push(clause); + return this; + } + + /** + * Filter values (chainable) + * @param clause Filter clause + * @returns The view + */ + filter(clause: EntityFilterClause): EntityView { + this.filter$.next(clause); + return this; + } + + /** + * @param clause Filter clause + * @returns The filter id + */ + addFilter(clause: EntityFilterClause): string { + const id = uuid(); + this.filterIndex.set(id, clause); + this.filters$.next(Array.from(this.filterIndex.values())); + return id; + } + + /** + * Remove a filter by id + * @param clause Filter clause + */ + removeFilter(id: string) { + this.filterIndex.delete(id); + this.filters$.next(Array.from(this.filterIndex.values())); + } + + /** + * Sort values (chainable) + * @param clauseSort clause + * @returns The view + */ + sort(clause: EntitySortClause): EntityView { + this.sort$.next(clause); + return this; + } + + /** + * Create the final observable + * @returns Observable + */ + lift() { + this.lifted = true; + const source$ = this.joins.length > 0 ? this.liftJoinedSource() : this.liftSource(); + const observables$ = [ + source$, + this.filters$, + this.filter$, + this.sort$, + this.getKey$ + ]; + + this.values$$ = combineLatest(observables$) + .pipe(skip(1), debounceTime(5)) + .subscribe((bunch: any[]) => { + const [_values, filters, filter, sort, getKey] = bunch; + const values = this.processValues(_values, filters, filter, sort); + const generateIndex = getKey !== undefined; + this.setValues(values, generateIndex); + }); + } + + /** + * Create the source observable when no joins are defined + * @returns Observable + */ + private liftSource(): Observable { + return this.source$ as any as Observable; + } + + /** + * Create the source observable when joins are defined + * @returns Observable + */ + private liftJoinedSource(): Observable { + const sources$ = [this.source$, combineLatest( + this.joins.map((join: EntityJoinClause) => join.source) + )]; + + return combineLatest(sources$) + .pipe( + map((bunch: [E[], any[]]) => { + const [entities, joinData] = bunch; + return entities.reduce((values: V[], entity: E) => { + const value = this.computeJoinedValue(entity, joinData); + if (value !== undefined) { + values.push(value); + } + return values; + }, []); + }) + ); + } + + /** + * Apply joins to a source's entity and return the final value + * @returns Final value + */ + private computeJoinedValue(entity: E, joinData: any[]): V | undefined { + let value = entity as Partial; + let joinIndex = 0; + while (value !== undefined && joinIndex < this.joins.length) { + value = this.joins[joinIndex].reduce(value, joinData[joinIndex]); + joinIndex += 1; + } + return value as V; + } + + /** + * Filter and sort values before streaming them + * @param values Values + * @param filters Filter clauses + * @param filter Filter clause + * @param sort Sort clause + * @returns Filtered and sorted values + */ + private processValues( + values: V[], filters: EntityFilterClause[], filter: EntityFilterClause, sort: EntitySortClause + ): V[] { + values = values.slice(0); + values = this.filterValues(values, filters.concat([filter])); + values = this.sortValues(values, sort); + return values; + } + + /** + * Filter values + * @param values Values + * @param filters Filter clauses + * @returns Filtered values + */ + private filterValues(values: V[], clauses: EntityFilterClause[]): V[] { + if (clauses.length === 0) { return values; } + + return values + .filter((value: V) => { + return clauses + .filter((clause: EntityFilterClause) => clause !== undefined) + .every((clause: EntityFilterClause) => clause(value)); + }); + } + + /** + * Sort values + * @param values Values + * @param sort Sort clause + * @returns Sorted values + */ + private sortValues(values: V[], clause: EntitySortClause): V[] { + if (clause === undefined) { return values; } + return values.sort((v1: V, v2: V) => { + return ObjectUtils.naturalCompare( + clause.valueAccessor(v1), + clause.valueAccessor(v2), + clause.direction, + clause.nullsFirst + ); + }); + } + + /** + * Set value and optionally generate an index + * @param values Values + * @param generateIndex boolean + */ + private setValues(values: V[], generateIndex: boolean) { + if (generateIndex === true) { + this._index = this.generateIndex(values); + } + + this.values$.next(values); + + const count = values.length; + const empty = count === 0; + this.count$.next(count); + this.empty$.next(empty); + } + + /** + * Generate a complete index of all the values + * @param entities Entities + * @returns Index + */ + private generateIndex(values: V[]): Map { + const entries = values.map((value: V) => [this.getKey(value), value]); + return new Map(entries as [EntityKey, V][]); + } +} diff --git a/src/app/pages/list/simple-feature-list.component.html b/src/app/pages/list/simple-feature-list.component.html index 35821544..c1177c72 100644 --- a/src/app/pages/list/simple-feature-list.component.html +++ b/src/app/pages/list/simple-feature-list.component.html @@ -1 +1,48 @@ -

simple-feature-list works!

+ +
+
+
+ + + + {{ attribute.description }}: + + + +
+
+ + + {{ attribute.description }}: + + + +
+
+
+
+
+ +
+
+
+ + \ No newline at end of file diff --git a/src/app/pages/list/simple-feature-list.component.scss b/src/app/pages/list/simple-feature-list.component.scss index e69de29b..dda11d4b 100644 --- a/src/app/pages/list/simple-feature-list.component.scss +++ b/src/app/pages/list/simple-feature-list.component.scss @@ -0,0 +1,72 @@ +.entities-container { + overflow-y: auto; + height: 200px; + width: 100% +} + +.entityGrey, .entityWhite, .selectedEntity { + padding: 8px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.entityGrey, .selectedEntity { + background-color: #f2f1f1; +} + +.h1, .h2, .h3, .h4, .h5, .h6 { + font-family: "Roboto", sans-serif; + font-weight: bold; + color: #095797; +} + +.h1 { + font-size: 48px; + line-height: 54px; + margin-top: 72px; + margin-bottom: 32px; +} + +.h2 { + font-size: 35px; + line-height: 40px; + margin-top: 48px; + margin-bottom: 16px; +} + +.h3 { + font-size: 28px; + line-height: 32px; + margin-top: 24px; + margin-bottom: 16px; +} + +.h4 { + font-size: 21px; + line-height: 24px; + margin-top: 24px; + margin-bottom: 8px; +} + +.h5 { + font-size: 19px; + line-height: 24px; + margin-top: 26px; +} + +.h6 { + font-size: 16px; + line-height: 24px; + margin-top: 16px; +} + +.attribute, .description { + font-family: "Open Sans", sans-serif; + font-size: 16px; + line-height: 24px; +} + +.selectButton, .unselectButton { + padding: 0px 16px; +} diff --git a/src/app/pages/list/simple-feature-list.component.ts b/src/app/pages/list/simple-feature-list.component.ts index f2935c77..f5fdf525 100644 --- a/src/app/pages/list/simple-feature-list.component.ts +++ b/src/app/pages/list/simple-feature-list.component.ts @@ -1,15 +1,353 @@ -import { Component, OnInit } from '@angular/core'; +import { Feature } from 'geojson'; +import { ConfigService } from '@igo2/core'; +import { Component, Input, OnInit, OnChanges, OnDestroy, Output, EventEmitter, SimpleChanges } from '@angular/core'; +import { EntityStore } from './shared/store'; +import { SimpleFeatureList, AttributeOrder, SortBy, Paginator } from './simple-feature-list.interface'; +import { BehaviorSubject, Subscription } from 'rxjs'; @Component({ selector: 'app-simple-feature-list', templateUrl: './simple-feature-list.component.html', styleUrls: ['./simple-feature-list.component.scss'] }) -export class SimpleFeatureListComponent implements OnInit { - constructor() { } +export class SimpleFeatureListComponent implements OnInit, OnChanges, OnDestroy { + @Input() entityStore: EntityStore; // a store that contains all the entities + @Input() clickedEntities: Array; // an array that contains the entities clicked in the map + @Input() simpleFiltersValue: object; // an object containing the value of the filters + @Output() listSelection = new EventEmitter(); // an event emitter that outputs the entity selected in the list + + public entitiesAll: Array; // an array containing all the entities in the store + public entitiesList: Array; // an array containing all the entities in the list + public entitiesShown: Array; // an array containing the entities currently shown + public entitiesList$: BehaviorSubject> = new BehaviorSubject([]); // an observable of an array of filtered entities + public entitiesList$$: Subscription; // subscription to filtered list + public entityIsSelected: boolean; // a boolean stating whether an entity has been selected in the list or not + + public simpleFeatureListConfig: SimpleFeatureList; // the simpleFeatureList config input by the user + public attributeOrder: AttributeOrder; // the attribute order specified in the simpleFeatureList config + public sortBy: SortBy; // the sorting to use, input in the SimpleFeatureList config + public formatURL: boolean; // whether to format an URL or not, input in the SimpleFeature List config + public formatEmail: boolean; // whether to format an email or not, input in the SimpleFeatureList config + public paginator: Paginator; // the paginator config input, in the SimpleFeatureList Config + + public pageSize: number; // the number of elements in a page, input in the paginator config + public showFirstLastPageButtons: boolean; // whether to show the First page and Last page buttons or not, input in the paginator config + public showPreviousNextPageButtons: boolean; // whether to show the Previous page and Next Page buttons or not, input in the paginator config + + public currentPageNumber$: BehaviorSubject = new BehaviorSubject(1); // observable of the current page number + public currentPageNumber$$: Subscription; // subscription to the current page number + public numberOfPages: number; // calculated number of pages + public elementsLowerBound: number; // the lowest index (+ 1) of an element in the current page + public elementsUpperBound: number; /// the highest index (+ 1) of an element in the current page + + constructor(private configService: ConfigService) {} ngOnInit(): void { + // get the entities from the layer/store + this.entitiesAll = this.entityStore.entities$.getValue() as Array; + this.entitiesList = this.entityStore.entities$.getValue() as Array; + + // get the config input by the user + this.simpleFeatureListConfig = this.configService.getConfig('simpleFeatureList'); + + // get the attribute order to use to display the elements in the list + this.attributeOrder = this.simpleFeatureListConfig.attributeOrder; + + // get the sorting config and sort the entities accordingly (sort ascending by default) + this.sortBy = this.simpleFeatureListConfig.sortBy; + if (this.sortBy) { + this.sortEntities(this.entitiesAll); + this.sortEntities(this.entitiesList); + } + + // get the formatting configs for URLs and emails (not formatted by default) + this.formatURL = this.simpleFeatureListConfig.formatURL !== undefined ? this.simpleFeatureListConfig.formatURL : false; + this.formatEmail = this.simpleFeatureListConfig.formatEmail !== undefined ? this.simpleFeatureListConfig.formatEmail : false; + + // if it exist, get the paginator config, including the page size, the buttons options and calculate the number of pages to use + this.paginator = this.simpleFeatureListConfig.paginator; + if (this.paginator) { + // 5 elements displayed by default + this.pageSize = this.paginator.pageSize !== undefined ? this.paginator.pageSize : 5; + // buttons shown by default + this.showFirstLastPageButtons = this.paginator.showFirstLastPageButtons !== undefined ? + this.paginator.showFirstLastPageButtons : true; + this.showPreviousNextPageButtons = this.paginator.showPreviousNextPageButtons !== undefined ? + this.paginator.showPreviousNextPageButtons : true; + this.entitiesList$.next(this.entitiesList); + // if the paginator config does not exist, all the entities are shown + } else { + this.entitiesShown = this.entitiesList; + } + + // subscribe to the current page number + this.currentPageNumber$$ = this.currentPageNumber$.subscribe((currentPageNumber: number) => { + // calculate the new lower and upper bounds to display + this.elementsLowerBound = (currentPageNumber - 1) * this.pageSize + 1; + this.elementsUpperBound = currentPageNumber * this.pageSize > this.entitiesList.length ? this.entitiesList.length : + currentPageNumber * this.pageSize; + + // slice the entities to show the current ones + this.entitiesShown = this.entitiesList.slice(this.elementsLowerBound - 1, this.elementsUpperBound); + }); + + // subscribe to the current entities list + this.entitiesList$$ = this.entitiesList$.subscribe((entitiesList: Array) => { + // replace the entities list + this.entitiesList = entitiesList; + // calculate new number of pages + this.numberOfPages = Math.ceil(this.entitiesList.length / this.pageSize); + // return to first page + this.currentPageNumber$.next(1); + }); + } + + ngOnChanges(changes: SimpleChanges) { + // if the most recent change is a click on entities on the map... + if (changes.clickedEntities) { + if (!changes.clickedEntities.firstChange) { + // change selected state to false for all entities + this.entityStore.state.updateAll({selected: false}); + // get array of clicked entities + const clickedEntities: Array = changes.clickedEntities.currentValue as Array; + // if an entity or entities have been clicked... + if (clickedEntities?.length > 0 && clickedEntities !== undefined) { + // ...show current entities in list + this.entityStore.state.updateMany(clickedEntities, {selected: true}); + this.entitiesList$.next(clickedEntities); + // ...else show all entities in list + } else { + this.entitiesList$.next(this.entitiesAll); + } + } + // if the most recent change is a filter change... + } else if (changes.simpleFiltersValue) { + if (!changes.simpleFiltersValue.firstChange) { + const currentFiltersValue: object = changes.simpleFiltersValue.currentValue; + let nonNullFiltersValue: Array = []; + + // for each filter value... + for (let filter in currentFiltersValue) { + const currentFilterValue: any = currentFiltersValue[filter]; + // ...if the filter value is not null... + if (currentFilterValue !== "" && currentFilterValue !== null) { + // ...push the filter value in an array, then filter the entiites + const filterValue: object = {}; + filterValue[filter] = currentFilterValue; + nonNullFiltersValue.push(filterValue); + } + } + this.filterEntities(nonNullFiltersValue); + } + } + } + + ngOnDestroy() { + this.currentPageNumber$$.unsubscribe(); + this.entitiesList$$.unsubscribe(); } + /** + * @description Sort entities according to an attribute + * @param entities The entities to sort + */ + sortEntities(entities: Array) { + if (this.sortBy.order === undefined || this.sortBy.order === 'ascending') { + entities.sort((a, b) => (a['properties'][this.sortBy.attributeName] > b['properties'][this.sortBy.attributeName]) ? 1 : + ((b['properties'][this.sortBy.attributeName] > a['properties'][this.sortBy.attributeName]) ? -1 : 0)); + } else if (this.sortBy.order === 'descending') { + entities.sort((a, b) => (a['properties'][this.sortBy.attributeName] > b['properties'][this.sortBy.attributeName]) ? -1 : + ((b['properties'][this.sortBy.attributeName] > a['properties'][this.sortBy.attributeName]) ? 1 : 0)); + } + } + + /** + * @description Check if an attribute has to be formatted and format it if necessary + * @param attribute A "raw" attribute from an entity + * @returns A potentially formatted attribute + */ + checkAttributeFormatting(attribute: any) { + attribute = this.isPhoneNumber(attribute); + attribute = this.isPostalCode(attribute); + attribute = this.isURL(attribute); + attribute = this.isEmail(attribute); + + return attribute; + } + + /** + * @description Create a personnalized attribute or a formatted attribute + * @param entity An entity (feature) + * @param attribute The attribute to get or to create + * @returns The personnalized or formatted attribute as a string + */ + createAttribute(entity: Feature, attribute: any): string { + let newAttribute: string; + // if the attribute has a personnalized attribute input by the user in the config... + if (attribute.personalizedFormatting) { + newAttribute = this.createPersonalizedAttribute(entity, attribute.personalizedFormatting); + // if the attribute is not personnalized... + } else { + newAttribute = this.checkAttributeFormatting(entity.properties[attribute.attributeName]); + } + return newAttribute; + } + + /** + * @description Create a personnalized attribute + * @param entity The entity containing the attribute + * @param personalizedFormatting The personnalized formatting specified by the user in the config + * @returns A personnalized attribute + */ + createPersonalizedAttribute(entity: Feature, personalizedFormatting: string): string { + let personalizedAttribute: string = personalizedFormatting; + + // get the attributes for the personnalized attribute + const attributeList: Array = personalizedFormatting.match(/(?<=\[)(.*?)(?=\])/g); + + // for each attribute in the list... + attributeList.forEach(attribute => { + // ...get the attibute value, format it if needed and replace it in the string + personalizedAttribute = personalizedAttribute.replace(attribute, this.checkAttributeFormatting(entity.properties[attribute])); + }); + // remove the square brackets surrounding the attributes + personalizedAttribute = personalizedAttribute.replace(/[\[\]]/g, ''); + personalizedAttribute = personalizedAttribute.replace(/^([^A-zÀ-ÿ0-9])*|([^A-zÀ-ÿ0-9])*$/g, ''); + + return personalizedAttribute; + } + + /** + * @description Format an attribute representing a phone number if the string matches the given pattern + * @param attribute The attribute to format + * @returns A formatted string representing a phone number or the original attribute + */ + isPhoneNumber(attribute: any): any { + let possiblePhoneNumber: string = ('' + attribute).replace(/\D/g, ''); + const match: Array = possiblePhoneNumber.match(/^(\d{3})(\d{3})(\d{4})$/); + if (match) { + return `(${match[1]}) ${match[2]}-${match[3]}`; + } + return attribute; + } + + /** + * @description Format an attribute representing an email address if the string matches the given pattern + * @param attribute The attribute to format + * @returns A formatted string representing an email address or the original attribute + */ + isEmail(attribute: any): any { + let possibleEmail: string = '' + attribute; + const match: Array = possibleEmail.match(/(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/); + if (match && this.formatEmail) { + return `Courriel`; + } else if (match && !this.formatEmail) { + return `${match[0]}`; + } + return attribute; + } + + /** + * @description Format an attribute representing a postal code if the string matches the given pattern + * @param attribute The attribute to format + * @returns A formatted string representing a postal code or the original attribute + */ + isPostalCode(attribute: any): any { + let possiblePostalCode: string = '' + attribute; + const match: Array = possiblePostalCode.match(/^([A-CEGHJ-NPR-TVXY]\d[A-CEGHJ-NPR-TV-Z])[ -]?(\d[A-CEGHJ-NPR-TV-Z]\d)$/i); + if (match) { + return (match[1] + ' ' + match [2]).toUpperCase(); + } + return attribute; + } + + /** + * @description Format an attribute representing an URL if the string matches the given pattern + * @param attribute The attribute to format + * @returns A formatted string representing an URL or the original attribute + */ + isURL(attribute: any): any { + let possibleURL: string = '' + attribute; + const match: Array = possibleURL.match(/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g); + if (match && this.formatURL) { + return `Site Web`; + } else if (match && !this.formatURL) { + return `${match[0]}`; + } + return attribute; + } + + /** + * @description Fired when the user selects an entity in the list + * @param entity + */ + selectEntity(entity: Feature) { + this.entitiesList$.next([entity]); + this.entityIsSelected = true; + + // update the store and emit the entity to parent + this.entityStore.state.updateAll({selected: false}); + this.entityStore.state.update(entity, {selected: true}, true); + let entityCollection: {added: Array} = {added: []}; + entityCollection.added.push(entity); + this.listSelection.emit(entityCollection); + } + + /** + * @description Fired when the user unselects the entity in the list + */ + unselectEntity(entity: Feature) { + // show all entities + this.entitiesList$.next(this.entitiesAll); + this.entityIsSelected = false; + this.currentPageNumber$.next(this.currentPageNumber$.getValue()); + this.entityStore.state.updateAll({selected: false}); + } + + /** + * @description Fired when the user changes the page + * @param currentPageNumber The current page number + */ + onPageChange(currentPageNumber: number) { + // update the current page number + this.currentPageNumber$.next(currentPageNumber); + } + + /** + * @description Filter entities according to non null filter values + * @param currentNonNullFiltersValue An array of objects containing the non null filter values + */ + filterEntities(currentNonNullFiltersValue: Array) { + // if there is/are non null filter value(s)... + if (currentNonNullFiltersValue.length) { + let filteredEntities: Array = []; + // .. for each filter value... + for (let currentFilterValue of currentNonNullFiltersValue) { + const currentFilterValueKeys: Array = Object.keys(currentFilterValue); + for (let currentFilterValueKey of currentFilterValueKeys) { + const currentFilterValueValue: any = currentFilterValue[currentFilterValueKey]; + // if the filter value is of type string (attribute filter)... + if (typeof currentFilterValueValue === 'string') { + for (let entity of this.entitiesAll) { + const entityProperties: Array = Object.keys(entity.properties); + if (entityProperties.includes(currentFilterValueKey)) { + if (entity.properties[currentFilterValueKey].toLowerCase().includes(currentFilterValueValue.toLowerCase())) { + filteredEntities.push(entity); + } + } + } + // if the filter value is of type object (spatial filter)... + } else { + // TODO + } + } + } + this.entitiesList$.next(filteredEntities); + // if there is not any non null filter values, reset entities lsit + } else { + this.entitiesList$.next(this.entitiesAll); + } + } } + diff --git a/src/app/pages/list/simple-feature-list.interface.ts b/src/app/pages/list/simple-feature-list.interface.ts new file mode 100644 index 00000000..8dc273da --- /dev/null +++ b/src/app/pages/list/simple-feature-list.interface.ts @@ -0,0 +1,26 @@ +export interface SimpleFeatureList { + layerId: string; // the layerId from which the entities are extracted + attributeOrder: AttributeOrder; // the order in which the attributes are shown in the list (see AttributeOrder) + sortBy?: SortBy; // sort the entities by a given attribute (see SortBy) + formatURL?: boolean; // format an URL to show a description (true) or the whole URL (false) + formatEmail?: boolean; // format an URL to show a description (true) or the who email address (false) + paginator?: Paginator; // paginator (see Paginator) + } + + export interface AttributeOrder { + attributeName: string; // name of the attribute in the data source + personalizedFormatting?: string; // string used to merge multiple attributes + description?: string; // description to put in front of the value of the attribute + header?: string; // HTML header to use (ex. "h2") + } + + export interface SortBy { + attributeName: string; // the attribute used for the sort + order?: string; // order of the sort (ascending or descending) + } + + export interface Paginator { + pageSize?: number; // the number of entities per page + showFirstLastPageButtons?: boolean; // show the "Go to First Page" and "Go to Last Page" buttons + showPreviousNextPageButtons?: boolean; // show the "Go to Previous Page" and "Go to Next Page" buttons + } From f6ebeb753b5f793100d62526ddff094ea2dc6105 Mon Sep 17 00:00:00 2001 From: matt-litwiller Date: Fri, 2 Jun 2023 12:31:54 -0400 Subject: [PATCH 04/30] Combining code from igo2-lib and igo2 into igo2-quebec filters section appears but list does not. Problem with selectedWorkspace not being recognized (whereas it is recognized in the igo2/igo2-lib version) --- src/app/app.component.html | 10 +- src/app/app.module.ts | 4 +- .../filters/simple-filters.component.html | 56 +++- .../filters/simple-filters.component.scss | 102 +++++++ .../filters/simple-filters.component.spec.ts | 23 -- .../pages/filters/simple-filters.component.ts | 271 ++++++++++++++++- .../pages/filters/simple-filters.interface.ts | 16 + .../pages/filters/simple-filters.module.ts | 49 +++ .../simple-feature-list-header.component.html | 6 + .../simple-feature-list-header.component.scss | 20 ++ .../simple-feature-list-header.component.ts | 30 ++ ...mple-feature-list-paginator.component.html | 57 ++++ ...mple-feature-list-paginator.component.scss | 33 ++ ...simple-feature-list-paginator.component.ts | 97 ++++++ .../list/simple-feature-list.component.html | 2 +- .../simple-feature-list.component.spec.ts | 23 -- .../pages/list/simple-feature-list.module.ts | 43 +++ src/app/pages/portal/portal.animation.ts | 286 +++++++++++++++--- src/app/pages/portal/portal.component.html | 12 + src/app/pages/portal/portal.component.scss | 1 + src/app/pages/portal/portal.component.ts | 45 ++- src/app/pages/portal/portal.module.ts | 4 + src/config/config.json | 31 ++ src/contexts/_default.json | 56 ++-- src/locale/en.json | 15 + src/locale/fr.json | 15 + 26 files changed, 1163 insertions(+), 144 deletions(-) delete mode 100644 src/app/pages/filters/simple-filters.component.spec.ts create mode 100644 src/app/pages/filters/simple-filters.interface.ts create mode 100644 src/app/pages/filters/simple-filters.module.ts create mode 100644 src/app/pages/list/simple-feature-list-header/simple-feature-list-header.component.html create mode 100644 src/app/pages/list/simple-feature-list-header/simple-feature-list-header.component.scss create mode 100644 src/app/pages/list/simple-feature-list-header/simple-feature-list-header.component.ts create mode 100644 src/app/pages/list/simple-feature-list-paginator/simple-feature-list-paginator.component.html create mode 100644 src/app/pages/list/simple-feature-list-paginator/simple-feature-list-paginator.component.scss create mode 100644 src/app/pages/list/simple-feature-list-paginator/simple-feature-list-paginator.component.ts delete mode 100644 src/app/pages/list/simple-feature-list.component.spec.ts create mode 100644 src/app/pages/list/simple-feature-list.module.ts diff --git a/src/app/app.component.html b/src/app/app.component.html index a8e7a1b1..b59609f1 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,9 +1,9 @@ - + - - - \ No newline at end of file + + + diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 714a5445..e79a84f1 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -37,8 +37,6 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { concatMap, first } from 'rxjs'; -import { SimpleFeatureListComponent } from './pages/list/simple-feature-list.component'; -import { SimpleFiltersComponent } from './pages/filters/simple-filters.component'; export const defaultTooltipOptions: MatTooltipDefaultOptions = { showDelay: 500, @@ -48,7 +46,7 @@ export const defaultTooltipOptions: MatTooltipDefaultOptions = { }; @NgModule({ - declarations: [AppComponent, SimpleFeatureListComponent, SimpleFiltersComponent], + declarations: [AppComponent], imports: [ CommonModule, MatIconModule, diff --git a/src/app/pages/filters/simple-filters.component.html b/src/app/pages/filters/simple-filters.component.html index 8e7b4821..7215d803 100644 --- a/src/app/pages/filters/simple-filters.component.html +++ b/src/app/pages/filters/simple-filters.component.html @@ -1 +1,55 @@ -

simple-filters works!

+
+ {{ 'simpleFilters.filtersTitle' | translate }} +
+ +
+
+
+
+ {{ getLabel(control.key) }} + + {{ getLabel(control.key) }} + + + + {{ option.nom }} + + + + + +
+
+
+ +
+ + +
+
+
\ No newline at end of file diff --git a/src/app/pages/filters/simple-filters.component.scss b/src/app/pages/filters/simple-filters.component.scss index e69de29b..9f32bc09 100644 --- a/src/app/pages/filters/simple-filters.component.scss +++ b/src/app/pages/filters/simple-filters.component.scss @@ -0,0 +1,102 @@ +.filters-container { + background-color: #f2f1f1; + padding: 16px; +} + +.filters-container:hover { + cursor: default; +} + +.filters-header { + background-color: #223654; + height: 54px; + display: flex; + align-items: center; +} + +.filters-title { + font-family: "Open Sans", sans-serif; + font-weight: bold; + color: #ffffff; + font-size: 19px; + margin-left: 16px; +} + +.filter-description { + font-family: "Open Sans", sans-serif; + font-weight: bold; + color: #095797; + font-size: 16px; + margin-top: 24px; + margin-bottom: 0px; +} + +::ng-deep .mat-form-field-appearance-outline .mat-form-field-outline { + background-color: white; + border-radius: 5px; +} + +::ng-deep .mat-select-arrow { + opacity: 0; +} + +.filter-field { + width: 100%; + cursor: pointer; +} + +.filters-buttons-abled { + font-family: "Open Sans", sans-serif; + font-weight: bold; + font-size: 16px; + border: 1.5px solid #095797; + padding: 8px 24px; + background-clip: padding-box; + display: inline-block; + cursor: pointer; +} + +.filters-buttons-disabled { + font-family: "Open Sans", sans-serif; + font-weight: bold; + font-size: 16px; + border: 1.5px solid #0957977A; + padding: 8px 24px; + background-clip: padding-box; + display: inline-block; + cursor: default; +} + +.filter-button-abled { + color: #ffffff; + background-color: #095797; + box-shadow: 0px 2px 8px #22365429; + margin-right: 8px; +} + +.filter-button-abled:hover { + background-color: #156BB2; + border: 1.5px solid #156BB2; +} + +.filter-button-disabled { + color: #ffffff; + background-color: #0957977A; + margin-right: 8px; +} + +.reset-button-abled { + color: #156BB2; + background-color: #ffffff; + margin-left: 8px; +} + +.reset-button-abled:hover { + background-color: #09579729; +} + +.reset-button-disabled { + color: #0957977A; + background-color: #ffffff; + margin-left: 8px; +} \ No newline at end of file diff --git a/src/app/pages/filters/simple-filters.component.spec.ts b/src/app/pages/filters/simple-filters.component.spec.ts deleted file mode 100644 index 6d7cc1ae..00000000 --- a/src/app/pages/filters/simple-filters.component.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { SimpleFiltersComponent } from './simple-filters.component'; - -describe('SimpleFiltersComponent', () => { - let component: SimpleFiltersComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [ SimpleFiltersComponent ] - }) - .compileComponents(); - - fixture = TestBed.createComponent(SimpleFiltersComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/src/app/pages/filters/simple-filters.component.ts b/src/app/pages/filters/simple-filters.component.ts index 06e7e37f..01c2fff1 100644 --- a/src/app/pages/filters/simple-filters.component.ts +++ b/src/app/pages/filters/simple-filters.component.ts @@ -1,15 +1,278 @@ -import { Component, OnInit } from '@angular/core'; +import { FeatureCollection } from 'geojson'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Component, OnInit, OnDestroy, Output, EventEmitter } from '@angular/core'; +import { SimpleFilter, TypeOptions, Option } from './simple-filters.interface'; +import { ConfigService } from '@igo2/core'; +import { map } from 'rxjs/operators'; +import { FormBuilder, FormGroup, AbstractControl } from '@angular/forms'; +import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; +import { Subscription } from 'rxjs'; @Component({ selector: 'app-simple-filters', templateUrl: './simple-filters.component.html', styleUrls: ['./simple-filters.component.scss'] }) -export class SimpleFiltersComponent implements OnInit { +export class SimpleFiltersComponent implements OnInit, OnDestroy { + @Output() filterSelection: EventEmitter = new EventEmitter(); - constructor() { } + public terrAPIBaseURL: string = "https://geoegl.msp.gouv.qc.ca/apis/terrapi/"; // base URL of the terrAPI API + public terrAPITypes: Array; // an array of strings containing the types available from terrAPI + public simpleFiltersConfig: Array; // simpleFilters config input by the user in the config file + public allTypesOptions: Array = []; // array that contains all the options for each filter + public filteredTypesOptions: Array = []; // array that contains the filtered options for each filter - ngOnInit(): void { + public filtersFormGroup: FormGroup; // form group containing the controls (one control per filter) + public previousFiltersFormGroupValue: object; // object representing the previous value held in each control + public filtersFormGroupValueChange$$: Subscription; // subscription to form group value changes + + constructor(private configService: ConfigService, private http: HttpClient, private formBuilder: FormBuilder) {} + + // getter of the form group controls + get controls(): {[key: string]: AbstractControl} { + return this.filtersFormGroup.controls; + } + + public async ngOnInit(): Promise { + // get the simpleFilters config input by the user in the config file + this.simpleFiltersConfig = this.configService.getConfig('simpleFilters'); + + // create a form group used to hold various controls + this.filtersFormGroup = this.formBuilder.group({}); + + // get all the types from terrAPI + await this.getTypesFromTerrAPI().then((terrAPITypes: Array) => { + this.terrAPITypes = terrAPITypes; + }); + + // for each filter input by the user... + for (let filter of this.simpleFiltersConfig) { + if (filter.type) { + // ...get the options from terrAPI and push them in the array containing all the options and add a control in the form group + await this.getOptionsOfFilter(filter).then((typeOptions: TypeOptions) => { + this.allTypesOptions.push(typeOptions); + this.filtersFormGroup.addControl(typeOptions.type, this.formBuilder.control('')); + }); + } + } + // deep-copy the array containing all the options to the one that will contain the filtered options (same at the start) + this.filteredTypesOptions = JSON.parse(JSON.stringify(this.allTypesOptions)); + + // set previous value of the form group (each control value is an empty string at the start) + this.previousFiltersFormGroupValue = this.filtersFormGroup.value; + + // when the user types in a field, filter the options of the filter and emit the value of the filters + this.filtersFormGroupValueChange$$ = this.filtersFormGroup.valueChanges.subscribe((spatialFiltersFormCurrentValue: object) => { + this.filterOptions(spatialFiltersFormCurrentValue); + this.filterSelection.emit(this.filtersFormGroup.value); + }); + } + + public ngOnDestroy() { + // unsubscribe from form group value change + this.filtersFormGroupValueChange$$.unsubscribe(); + } + + /** + * @description Used to display the name of a value in a field + * @param value An object representing the current value of the control + * @returns A string representing the value to display in the field + */ + public displayName(value: Option): string { + return value?.nom ? value.nom : ''; + } + + /** + * @description Get all the options for a given filter + * @param filter A SimpleFilter object representing the filter + * @returns The options for the given filter in the TypeOptions format + */ + private async getOptionsOfFilter(filter: SimpleFilter): Promise { + let typeOptions: TypeOptions; + + // if type is included in terrAPI... + if (this.terrAPITypes.includes(filter.type)) { + // ...get options from terrAPI + await this.getOptionsFromTerrAPI(true, filter.type).then((featureCollection: FeatureCollection) => { + let options: Array