diff --git a/apps/picsa-apps/dashboard/src/app/app.component.html b/apps/picsa-apps/dashboard/src/app/app.component.html index 1330340b3..64c995e17 100644 --- a/apps/picsa-apps/dashboard/src/app/app.component.html +++ b/apps/picsa-apps/dashboard/src/app/app.component.html @@ -27,12 +27,7 @@ @for (link of navLinks; track link.href) { - + {{ link.label }} } @@ -40,12 +35,7 @@
Global Admin
@for (link of globalLinks; track link.href) { - + {{ link.label }} } diff --git a/apps/picsa-apps/dashboard/src/app/app.component.ts b/apps/picsa-apps/dashboard/src/app/app.component.ts index 976da1c88..8e795648e 100644 --- a/apps/picsa-apps/dashboard/src/app/app.component.ts +++ b/apps/picsa-apps/dashboard/src/app/app.component.ts @@ -22,10 +22,10 @@ export class AppComponent implements AfterViewInit { title = 'picsa-apps-dashboard'; navLinks: INavLink[] = [ - { - label: 'Home', - href: '/', - }, + // { + // label: 'Home', + // href: '', + // }, { label: 'Resources', href: '/resources', diff --git a/apps/picsa-apps/dashboard/src/app/app.routes.ts b/apps/picsa-apps/dashboard/src/app/app.routes.ts index a9ece7996..c49395830 100644 --- a/apps/picsa-apps/dashboard/src/app/app.routes.ts +++ b/apps/picsa-apps/dashboard/src/app/app.routes.ts @@ -13,4 +13,9 @@ export const appRoutes: Route[] = [ path: 'translations', loadChildren: () => import('./modules/translations/translations.module').then((m) => m.TranslationsPageModule), }, + { + path: '', + redirectTo: 'resources', + pathMatch: 'full', + }, ]; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts index a13fe5cf6..414381324 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data-api.service.ts @@ -1,18 +1,99 @@ import { Injectable } from '@angular/core'; +import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; import createClient from 'openapi-fetch'; import { paths } from './types/api'; const API_ENDPOINT = 'https://api.epicsa.idems.international'; -/** Service to interact with external PICSA Climate API */ +/** Custom client which tracks responses by callback id */ +type ICallbackClient = (id:string)=>ReturnType> + +/** Type-safe http client with added support for callbacks */ +type IClient = ReturnType> & {useMeta:ICallbackClient} + + + +interface IMetaEntry{ + status:'pending' | 'success' | 'error' | 'unknown', + rawResponse?:Response, +} + + +/** + * Service to interact with external PICSA Climate API + * All methods are exposed through a type-safe `client` property, or can additionally use + * a custom client that includes status notification updates via the `useMeta` method + * @example + * Use custom callback that will show user notifications on error and record to service + * ```ts + * const {response, data, error} = await api.useMeta('myRequestId').POST(...) + * ``` + * Use default client without additional callbacks + * ```ts + * const {response, data, error} = await api.client.POST(...) + * ``` + * */ @Injectable({ providedIn: 'root' }) export class ClimateDataApiService { + + /** Request additional meta by id */ + public meta:Record={} /** Http client with type-definitions for API endpoints */ - public client:ReturnType> - - constructor() { - this.client = createClient({ baseUrl: API_ENDPOINT,mode:'cors' }); + public client:IClient + + constructor(private notificationService:PicsaNotificationService) { + const client = createClient({ baseUrl: API_ENDPOINT,mode:'cors' }); + this.client = {...client,useMeta:()=>{ + return client + }} + } + + + /** + * Provide an id which which will be updated alongside requests. + * The cache will also include interceptors to provide user notification on error + **/ + public useMeta(id:string){ + const customFetch = this.createCustomFetchClient(id) + const customClient = createClient({ baseUrl: API_ENDPOINT,mode:'cors',fetch:customFetch }); + return customClient + } + + /** Create a custom implementation of fetch client to handle status updates and notifications */ + private createCustomFetchClient(id:string){ + return async (...args:Parameters)=>{ + this.meta[id]={status:'pending'} + const response = await window.fetch(...args); + this.meta[id].status = this.getCallbackStatus(response.status) + this.meta[id].rawResponse = response + if(this.meta[id].status ==='error' ){ + await this.showCustomFetchErrorMessage(id,response) + } + return response + } + } + + /** Show error message when using custom fetch with callbacks */ + private async showCustomFetchErrorMessage(id:string,response:Response){ + // clone body so that open-api can still consume when constructing full fetch response + const clone = response.clone() + try { + const json = await clone.json() + const errorText = json.detail || 'failed, see console logs for details' + this.notificationService.showUserNotification({matIcon:'error',message:`[${id}] ${errorText}`}) + } catch (error) { + console.error(error) + console.error('Fetch Error',error) + this.notificationService.showUserNotification({matIcon:'error',message:`[${id}] 'failed, see console logs for details'`}) + } + } + + private getCallbackStatus(statusCode:number):IMetaEntry['status']{ + if(200 <= statusCode && statusCode <=299) return 'success' + if(400 <= statusCode && statusCode <=499) return 'error' + if(500 <= statusCode && statusCode <=599) return 'error' + return 'unknown' } } diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts index b387f5862..e33d57360 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/climate-data.service.ts @@ -1,9 +1,12 @@ import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; // eslint-disable-next-line @nx/enforce-module-boundaries import { Database } from '@picsa/server-types'; import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service'; +import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; import { SupabaseService } from '@picsa/shared/services/core/supabase'; import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service'; +import { ngRouterMergedSnapshot$ } from '@picsa/utils'; import { ClimateDataApiService } from './climate-data-api.service'; @@ -20,21 +23,46 @@ export type IResourceEntry = Database['public']['Tables']['resources']['Row']; export class ClimateDataDashboardService extends PicsaAsyncService { public apiStatus: number; public stations: IStationRow[] = []; + public activeStation: IStationRow; - constructor(private supabaseService: SupabaseService, private api: ClimateDataApiService) { + constructor( + private supabaseService: SupabaseService, + private api: ClimateDataApiService, + private notificationService: PicsaNotificationService, + private router: Router + ) { super(); + this.ready(); } public override async init() { await this.supabaseService.ready(); await this.checkStatus(); await this.listStations(); + this.subscribeToRouteChanges(); + } + + private setActiveStation(id: number) { + const station = this.stations.find((station) => station.station_id === id); + if (station) { + this.activeStation = station; + } else { + this.activeStation = undefined as any; + this.notificationService.showUserNotification({ matIcon: 'error', message: `Station data not found` }); + } + } + + private subscribeToRouteChanges() { + // Use merged router as service cannot access route params directly like component + ngRouterMergedSnapshot$(this.router).subscribe(({ params }) => { + if (params.stationId) { + this.setActiveStation(parseInt(params.stationId)); + } + }); } private async checkStatus() { - const { client } = this.api; - const { response } = await client.GET('/v1/status/'); - this.apiStatus = response.status; + await this.api.useMeta('serverStatus').GET('/v1/status/'); } private async listStations() { @@ -44,6 +72,12 @@ export class ClimateDataDashboardService extends PicsaAsyncService { if (error) { throw error; } + if (data.length === 0) { + this.notificationService.showUserNotification({ + matIcon: 'warning', + message: 'climate_stations_rows must be imported into database for this feature to work', + }); + } this.stations = data || []; } } diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html index db6cd8153..39cc37626 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.html @@ -1,9 +1,10 @@

Climate Data

- @if(service.apiStatus; as status){ + @if(api.meta.serverStatus; as meta){
- Server Status {{ status }} + Server Status + {{ meta.rawResponse?.status }}
}
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts index 182f99612..094aa4b0c 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/home/climate-data-home.component.ts @@ -5,6 +5,7 @@ import { RouterModule } from '@angular/router'; import { IMapMarker, PicsaMapComponent } from '@picsa/shared/features/map/map'; import { ClimateDataDashboardService, IStationRow } from '../../climate-data.service'; +import { ClimateDataApiService } from '../../climate-data-api.service'; @Component({ selector: 'dashboard-climate-data-home', @@ -18,7 +19,7 @@ export class ClimateDataHomeComponent implements OnInit { public mapMarkers: IMapMarker[]; - constructor(public service: ClimateDataDashboardService) {} + constructor(public service: ClimateDataDashboardService, public api: ClimateDataApiService) {} async ngOnInit() { await this.service.ready(); diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html new file mode 100644 index 000000000..613ca95f3 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.html @@ -0,0 +1,29 @@ +
+

Rainfall Summary

+ +
+ + + + view_list + Table + + + + + + + description + Definition + +
{{summary.metadata | json}}
+
+
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss new file mode 100644 index 000000000..58125726c --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.scss @@ -0,0 +1,25 @@ +:host { + display: block; +} + +mat-icon.spin { + animation: spin 2s linear infinite; +} + +@-webkit-keyframes spin { + 0% { + -webkit-transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.spec.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.spec.ts new file mode 100644 index 000000000..8f36dfe9e --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.spec.ts @@ -0,0 +1,155 @@ +export const RAINFALL_SUMMARY_MOCK = { + metadata: { + annual_rain: { + annual_rain: ['TRUE'], + n_rain: ['TRUE'], + na_rm: ['FALSE'], + }, + start_rains: { + threshold: [1], + start_day: [1], + end_day: [366], + total_rainfall: ['TRUE'], + amount_rain: [25], + over_days: [3], + proportion: ['FALSE'], + number_rain_days: ['FALSE'], + dry_spell: ['TRUE'], + spell_max_dry_days: [10], + spell_interval: [21], + dry_period: ['FALSE'], + _last_updated: ['2022-11-25'], + }, + end_rains: { + start_day: [121], + end_day: [366], + interval_length: [1], + min_rainfall: [10], + }, + end_season: { + start_day: [121], + end_day: [366], + capacity: [100], + water_balance_max: [60], + evaporation: ['value'], + evaporation_value: [5], + }, + seasonal_rain: { + seasonal_rain: ['TRUE'], + n_rain: ['TRUE'], + na_rm: ['FALSE'], + rain_day: [0.85], + total_rain: [0], + }, + }, + data: [ + { + year: 1945, + station_name: 'CHIPATA MET', + annual_rain: '', + n_rain: '', + start_rains: '', + end_rains: 365, + end_season: '', + seasonal_rain: '', + n_seasonal_rain: '', + season_length: '', + }, + { + year: 1946, + station_name: 'CHIPATA MET', + annual_rain: 1081.2, + n_rain: 90, + start_rains: 1, + end_rains: 366, + end_season: 121, + seasonal_rain: 652.2, + n_seasonal_rain: 54, + season_length: 120, + }, + { + year: 1947, + station_name: 'CHIPATA MET', + annual_rain: 1055.3, + n_rain: 89, + start_rains: 3, + end_rains: 362, + end_season: 121, + seasonal_rain: 760, + n_seasonal_rain: 64, + season_length: 118, + }, + { + year: 1948, + station_name: 'CHIPATA MET', + annual_rain: 1212.3, + n_rain: 88, + start_rains: 5, + end_rains: 359, + end_season: 125, + seasonal_rain: 901.9, + n_seasonal_rain: 65, + season_length: 120, + }, + { + year: 1949, + station_name: 'CHIPATA MET', + annual_rain: 896.7, + n_rain: 68, + start_rains: 20, + end_rains: 366, + end_season: 121, + seasonal_rain: 492, + n_seasonal_rain: 35, + season_length: 101, + }, + { + year: 1950, + station_name: 'CHIPATA MET', + annual_rain: 1048.1, + n_rain: 82, + start_rains: 2, + end_rains: 365, + end_season: 121, + seasonal_rain: 870.6, + n_seasonal_rain: 66, + season_length: 119, + }, + { + year: 1951, + station_name: 'CHIPATA MET', + annual_rain: 1100.1, + n_rain: 98, + start_rains: 5, + end_rains: 366, + end_season: 121, + seasonal_rain: 564, + n_seasonal_rain: 57, + season_length: 116, + }, + { + year: 1952, + station_name: 'CHIPATA MET', + annual_rain: 893.1999999999999, + n_rain: 74, + start_rains: 2, + end_rains: 365, + end_season: 121, + seasonal_rain: 697, + n_seasonal_rain: 51, + season_length: 119, + }, + { + year: 1953, + station_name: 'CHIPATA MET', + annual_rain: 1056.9, + n_rain: 84, + start_rains: 1, + end_rains: 366, + end_season: 121, + seasonal_rain: 775.5, + n_seasonal_rain: 58, + season_length: 120, + }, + ], +}; diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts new file mode 100644 index 000000000..9fe7655b5 --- /dev/null +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/components/rainfall-summary/rainfall-summary.ts @@ -0,0 +1,82 @@ +import { JsonPipe } from '@angular/common'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { IDataTableOptions, PicsaDataTableComponent } from '@picsa/shared/features/data-table'; +import { SupabaseService } from '@picsa/shared/services/core/supabase'; + +import { ClimateDataDashboardService } from '../../../../climate-data.service'; +import { ClimateDataApiService } from '../../../../climate-data-api.service'; + +interface IRainfallSummary { + data: any[]; + metadata: any; +} + +@Component({ + selector: 'dashboard-climate-rainfall-summary', + templateUrl: './rainfall-summary.html', + standalone: true, + imports: [MatButtonModule, MatIconModule, MatTabsModule, PicsaDataTableComponent, JsonPipe], + styleUrl: './rainfall-summary.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RainfallSummaryComponent implements AfterViewInit { + public summary: IRainfallSummary = { data: [], metadata: {} }; + constructor( + public api: ClimateDataApiService, + private service: ClimateDataDashboardService, + private cdr: ChangeDetectorRef, + private supabase: SupabaseService + ) {} + + public tableOptions: IDataTableOptions = { + paginatorSizes: [25, 50], + }; + + public get res() { + return this.api.meta.rainfallSummary || {}; + } + + private get db() { + return this.supabase.db.table('climate_products'); + } + + async ngAfterViewInit() { + const { station_id } = this.service.activeStation; + // Load data stored in supabase db if available. Otherwise load from api + const { data } = await this.db.select('*').eq('station_id', station_id).eq('type', 'rainfallSummary').single(); + if (data) { + this.loadData(data?.data || { data: [], metadata: {} }); + } else { + await this.refreshData(); + } + } + + public async refreshData() { + const { station_id, country_code } = this.service.activeStation; + const { response, data, error } = await this.api.useMeta('rainfallSummary').POST('/v1/annual_rainfall_summaries/', { + body: { + country: `${country_code}` as any, + station_id: `${station_id}`, + summaries: ['annual_rain', 'start_rains', 'end_rains', 'end_season', 'seasonal_rain', 'seasonal_length'], + }, + }); + console.log('rainfallSummary', { response, data, error }); + this.loadData(data as any); + // TODO - generalise way to persist db updates from api queries + const dbRes = await this.supabase.db.table('climate_products').upsert({ + data, + station_id, + type: 'rainfallSummary', + }); + console.log('climate data persist', dbRes); + } + + private loadData(summary: IRainfallSummary) { + this.tableOptions.exportFilename = `${this.service.activeStation.station_name}_rainfallSummary.csv`; + this.summary = summary; + this.cdr.markForCheck(); + } +} diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html index 5cd92e295..437e5e86b 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.html @@ -11,5 +11,8 @@

{{ station.station_name }}

{{ value }} } + + } @else { + }
diff --git a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts index 77e55d000..85d6a6439 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/climate-data/pages/station/station-page.component.ts @@ -1,19 +1,21 @@ import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { ClimateDataDashboardService, IStationRow } from '../../climate-data.service'; +import { ClimateDataDashboardService } from '../../climate-data.service'; +import { RainfallSummaryComponent } from './components/rainfall-summary/rainfall-summary'; @Component({ selector: 'dashboard-station-page', standalone: true, - imports: [CommonModule], + imports: [CommonModule, MatProgressBarModule, RainfallSummaryComponent], templateUrl: './station-page.component.html', styleUrls: ['./station-page.component.scss'], }) export class StationPageComponent implements OnInit { - public station: IStationRow | undefined; + public get station() { + return this.service.activeStation; + } public get stationSummary() { return { @@ -22,18 +24,9 @@ export class StationPageComponent implements OnInit { }; } - constructor( - private service: ClimateDataDashboardService, - private route: ActivatedRoute, - private notificationService: PicsaNotificationService - ) {} + constructor(private service: ClimateDataDashboardService) {} async ngOnInit() { await this.service.ready(); - const { stationId } = this.route.snapshot.params; - this.station = this.service.stations.find((station) => station.station_id === parseInt(stationId)); - if (!this.station) { - this.notificationService.showUserNotification({ matIcon: 'error', message: `Station data not found` }); - } } } diff --git a/apps/picsa-server/README.md b/apps/picsa-server/README.md index f9839907e..41ddc6d5b 100644 --- a/apps/picsa-server/README.md +++ b/apps/picsa-server/README.md @@ -1,3 +1,3 @@ # PICSA Server -See docs at: https://docs.picsa.app/advanced/server/setup +See docs at: https://docs.picsa.app/server/setup diff --git a/apps/picsa-server/supabase/migrations/20240112001154_climate_products_create.sql b/apps/picsa-server/supabase/migrations/20240112001154_climate_products_create.sql new file mode 100644 index 000000000..2f691ac5d --- /dev/null +++ b/apps/picsa-server/supabase/migrations/20240112001154_climate_products_create.sql @@ -0,0 +1,9 @@ +create table + public.climate_products ( + created_at timestamp with time zone not null default now(), + station_id bigint null, + type text not null, + data jsonb not null, + constraint climate_products_pkey primary key (station_id, type), + constraint climate_products_station_id_fkey foreign key (station_id) references climate_stations (station_id) on delete cascade + ) tablespace pg_default; \ No newline at end of file diff --git a/apps/picsa-server/supabase/seed.sql b/apps/picsa-server/supabase/seed.sql index e69de29bb..f3f351705 100644 --- a/apps/picsa-server/supabase/seed.sql +++ b/apps/picsa-server/supabase/seed.sql @@ -0,0 +1,4 @@ +-- CC note - copy csv not supported in supabase +-- https://github.com/orgs/supabase/discussions/9314 + +-- COPY climate_stations FROM 'data/climate_stations_rows.csv' WITH (FORMAT csv); \ No newline at end of file diff --git a/apps/picsa-server/supabase/types/index.ts b/apps/picsa-server/supabase/types/index.ts index b1f716bc0..c5486929a 100644 --- a/apps/picsa-server/supabase/types/index.ts +++ b/apps/picsa-server/supabase/types/index.ts @@ -34,6 +34,37 @@ export interface Database { } public: { Tables: { + climate_products: { + Row: { + created_at: string + data: Json + id: number + station_id: number | null + type: string + } + Insert: { + created_at?: string + data: Json + id?: number + station_id?: number | null + type: string + } + Update: { + created_at?: string + data?: Json + id?: number + station_id?: number | null + type?: string + } + Relationships: [ + { + foreignKeyName: "climate_products_station_id_fkey" + columns: ["station_id"] + referencedRelation: "climate_stations" + referencedColumns: ["station_id"] + } + ] + } climate_stations: { Row: { country_code: string | null diff --git a/libs/shared/src/features/data-table/data-table.component.html b/libs/shared/src/features/data-table/data-table.component.html new file mode 100644 index 000000000..b85436fc8 --- /dev/null +++ b/libs/shared/src/features/data-table/data-table.component.html @@ -0,0 +1,39 @@ +
+ + @if(tableOptions.search){ + + Search Data + + search + + } + + @if(tableOptions.exportFilename){ + + } +
+ + + + @for(column of tableOptions.displayColumns; track column){ + + + + + } + + +
{{ column }}{{ el[column] }}
+ + + + diff --git a/libs/shared/src/features/data-table/data-table.component.scss b/libs/shared/src/features/data-table/data-table.component.scss new file mode 100644 index 000000000..aa4eef843 --- /dev/null +++ b/libs/shared/src/features/data-table/data-table.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; +} +.search-field { + margin-top: 2rem; + width: 100%; + input { + text-align: left; + } +} +tr:hover { + background: whitesmoke; +} diff --git a/libs/shared/src/features/data-table/data-table.component.spec.ts b/libs/shared/src/features/data-table/data-table.component.spec.ts new file mode 100644 index 000000000..58e1caa18 --- /dev/null +++ b/libs/shared/src/features/data-table/data-table.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DataTableComponent } from './data-table.component'; + +describe('DataTableComponent', () => { + let component: DataTableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DataTableComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DataTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/libs/shared/src/features/data-table/data-table.component.ts b/libs/shared/src/features/data-table/data-table.component.ts new file mode 100644 index 000000000..8b1e75c11 --- /dev/null +++ b/libs/shared/src/features/data-table/data-table.component.ts @@ -0,0 +1,109 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, ViewChild } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import download from 'downloadjs'; +import { unparse } from 'papaparse'; + +export interface IDataTableOptions { + /** Optional list of columns to display (default selects first keys from first data entry) */ + displayColumns?: string[]; + /** Provide filename to export data as csv. If omitted export option will not be presented */ + exportFilename?: string; + /** Specify size options to show in page paginator, e.g. [5,10,25] or just [25] (no paginator if left blank) */ + paginatorSizes?: number[]; + /** Specify whether to enable search input box and table filtering (will include all data during filter) */ + search?: boolean; + /** Specify whether to include column sort headers (default true) */ + sort?: boolean; + /** Bind to row click events */ + handleRowClick?: (row: any) => void; +} + +/** + * The `picsa-data-table` component is a lightweight wrapper around `mat-table`, used + * to simplify display of basic tables. + * + * By default the table has support for sort, pagination and data search (filter) + * + * For more advanced use cases such as custom column display prefer to directly use `mat-table` + */ +@Component({ + selector: 'picsa-data-table', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatSortModule, + MatTableModule, + MatPaginator, + ], + templateUrl: './data-table.component.html', + styleUrls: ['./data-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PicsaDataTableComponent implements OnChanges { + @Input() data: Record[] = []; + + /** User option overrides */ + @Input() options: IDataTableOptions = {}; + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + public tableOptions: Required = { + displayColumns: [], + exportFilename: '', + paginatorSizes: [], + search: true, + sort: true, + handleRowClick: () => null, + }; + + public dataSource: MatTableDataSource; + + constructor(private cdr: ChangeDetectorRef) {} + + // Load data when inputs updated (prefer changes over input setters to avoid duplicate load) + ngOnChanges(): void { + this.loadData(this.data, this.options); + } + + public applyFilter(value: string) { + this.dataSource.filter = value.trim().toLowerCase(); + } + + public handleExport() { + const { displayColumns, exportFilename } = this.tableOptions; + const csv = unparse(this.dataSource.filteredData, { columns: displayColumns }); + download(csv, exportFilename, 'text/csv'); + } + + private loadData(data: T[] = [], overrides: IDataTableOptions = {}) { + // Assign default columns from first data entry if not specified + const displayColumns = overrides.displayColumns || Object.keys(data[0] || {}); + + // Merge default options with generated and user overrides + const mergedOptions = { ...this.tableOptions, displayColumns, ...overrides }; + this.tableOptions = mergedOptions; + + this.dataSource = new MatTableDataSource(data); + + // apply data sort and paginate if enabled + if (mergedOptions.paginatorSizes.length > 0) { + this.dataSource.paginator = this.paginator; + } + + // sort will be disabled in html template if not included + this.dataSource.sort = this.sort; + this.cdr.markForCheck(); + } +} diff --git a/libs/shared/src/features/data-table/index.ts b/libs/shared/src/features/data-table/index.ts new file mode 100644 index 000000000..4eb29f724 --- /dev/null +++ b/libs/shared/src/features/data-table/index.ts @@ -0,0 +1 @@ +export * from './data-table.component'; diff --git a/libs/shared/src/features/dialog/dialog.module.ts b/libs/shared/src/features/dialog/dialog.module.ts index 70bc01e37..0b9719d87 100644 --- a/libs/shared/src/features/dialog/dialog.module.ts +++ b/libs/shared/src/features/dialog/dialog.module.ts @@ -6,7 +6,7 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { PicsaTranslateModule } from '../../modules'; -import { PicsaLoadingModule } from '../loading'; +import { PicsaLoadingComponent } from '../loading/loading'; import { PicsaActionDialog, PicsaDialogComponent, PicsaSelectDialog } from './components/dialog'; import { PicsaDialogService } from './dialog.service'; @@ -18,7 +18,7 @@ import { PicsaDialogService } from './dialog.service'; MatButtonModule, MatIconModule, A11yModule, - PicsaLoadingModule, + PicsaLoadingComponent, PicsaTranslateModule, CommonModule, ], diff --git a/libs/shared/src/features/index.ts b/libs/shared/src/features/index.ts index aa435282d..9adf9998a 100644 --- a/libs/shared/src/features/index.ts +++ b/libs/shared/src/features/index.ts @@ -1,4 +1,5 @@ export * from './animations'; export * from './charts'; +export * from './data-table'; export * from './dialog'; export * from './video-player'; diff --git a/libs/shared/src/features/loading/index.ts b/libs/shared/src/features/loading/index.ts deleted file mode 100644 index fdcd3b176..000000000 --- a/libs/shared/src/features/loading/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; - -import { PicsaLoadingComponent } from './loading'; - -@NgModule({ - declarations: [PicsaLoadingComponent], - exports: [PicsaLoadingComponent] -}) -export class PicsaLoadingModule {} diff --git a/libs/shared/src/features/loading/loading.scss b/libs/shared/src/features/loading/loading.scss index 5ade40b0c..5919e8002 100644 --- a/libs/shared/src/features/loading/loading.scss +++ b/libs/shared/src/features/loading/loading.scss @@ -1,4 +1,8 @@ +:host { + display: block; + width: 100px; +} .picsa-loading-container { - width: 50px; + width: 100%; margin: auto; } diff --git a/libs/shared/src/features/loading/loading.ts b/libs/shared/src/features/loading/loading.ts index 530064df8..9f42ca9a7 100644 --- a/libs/shared/src/features/loading/loading.ts +++ b/libs/shared/src/features/loading/loading.ts @@ -1,21 +1,27 @@ +import { CommonModule } from '@angular/common'; import { Component, Input } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import SVGS from './svgs'; @Component({ + imports: [CommonModule], selector: 'picsa-loading', templateUrl: './loading.html', styleUrls: ['./loading.scss'], + standalone: true, }) export class PicsaLoadingComponent { - @Input() set name(name: IPicsaLoaders) { + @Input() name: IPicsaLoaders; + loaderHtml: SafeHtml; + constructor(private sanitizer: DomSanitizer) {} + + ngOnInit() { + const svgName = this.name || 'bars'; // select svg by name (or use default bars) - const svg = name && SVGS[name] ? SVGS[name] : SVGS.BARS; + const svg = SVGS[svgName] || SVGS.BARS; this.loaderHtml = this.convertSVGToImageData(svg); } - loaderHtml: SafeHtml; - constructor(private sanitizer: DomSanitizer) {} /********************************************************************** * Helper Methods diff --git a/libs/shared/src/features/map/map.ts b/libs/shared/src/features/map/map.ts index f0d5be5da..dcdfa4ebd 100644 --- a/libs/shared/src/features/map/map.ts +++ b/libs/shared/src/features/map/map.ts @@ -71,7 +71,7 @@ export class PicsaMapComponent { }); marker.addTo(this.map); }); - if (fitMap) { + if (fitMap && mapMarkers.length > 0) { this.fitMapToMarkers(mapMarkers); } } diff --git a/libs/theme/src/_fonts.scss b/libs/theme/src/_fonts.scss index 2147add06..49023e014 100644 --- a/libs/theme/src/_fonts.scss +++ b/libs/theme/src/_fonts.scss @@ -3,8 +3,6 @@ font-style: normal; font-weight: 400; - // Note, google does not host these fonts directly - // Downloaded updated icons from https://github.com/jossef/material-design-icons-iconfont/tree/master/dist/fonts src: local('Material Icons'), local('assets/fonts/MaterialIcons-Regular'), url(./fonts/MaterialIcons-Regular.woff2) format('woff2'); // url(./fonts/MaterialIcons-Regular.woff) format('woff'), diff --git a/libs/theme/src/fonts/MaterialIcons-Regular.woff2 b/libs/theme/src/fonts/MaterialIcons-Regular.woff2 index 2eb4fb499..5492a6e75 100644 Binary files a/libs/theme/src/fonts/MaterialIcons-Regular.woff2 and b/libs/theme/src/fonts/MaterialIcons-Regular.woff2 differ diff --git a/libs/theme/src/fonts/Readme.md b/libs/theme/src/fonts/Readme.md new file mode 100644 index 000000000..10c913571 --- /dev/null +++ b/libs/theme/src/fonts/Readme.md @@ -0,0 +1,12 @@ +# Material Design Icons + +Source for font can be obtained by inspecting url: +https://fonts.googleapis.com/icon?family=Material+Icons + +E.g. +https://fonts.gstatic.com/s/materialicons/v140/flUhRq6tzZclQEJ-Vdg-IuiaDsNc.woff2 + +NOTE - material icons should not be confused with (newer) material symbols +https://github.com/angular/components/issues/24845 + +(see discussion about handling outdated fonts at: https://github.com/google/material-design-icons/issues/786) diff --git a/libs/utils/angular.ts b/libs/utils/angular.ts index 60ce40726..188c23ba8 100644 --- a/libs/utils/angular.ts +++ b/libs/utils/angular.ts @@ -1,4 +1,12 @@ -import type { Route, Router } from '@angular/router'; +import { + NavigationEnd, + type ActivatedRoute, + type ActivatedRouteSnapshot, + type Params, + type Route, + type Router, +} from '@angular/router'; +import { filter, map, startWith } from 'rxjs'; export function throwIfAlreadyLoaded(parentModule: any, moduleName: string) { if (parentModule) { @@ -24,3 +32,39 @@ export function registerEmbeddedRoutes(routes: Route[], router: Router, prefix: const filteredRoutes = router.config.filter((route) => !route.path?.startsWith(prefix)); router.resetConfig([...filteredRoutes, ...mappedRoutes]); } + +/** + * When accessing ActivatedRoute from a provider router hierarchy includes all routers, not just + * current view router (as identified when using from within a component) + * + * Workaround to check all nested routers for params and combined. Adapted from: + * https://medium.com/simars/ngrx-router-store-reduce-select-route-params-6baff607dd9 + */ + +function mergeRouterSnapshots(router: Router) { + const merged: Partial = { data: {}, params: {}, queryParams: {} }; + let route: ActivatedRoute | undefined = router.routerState.root; + while (route !== undefined) { + const { data, params, queryParams } = route.snapshot; + merged.data = { ...merged.data, ...data }; + merged.params = { ...merged.params, ...params }; + merged.queryParams = { ...merged.queryParams, ...queryParams }; + route = route.children.find((child) => child.outlet === 'primary'); + } + return merged as ActivatedRouteSnapshot; +} + +/** + * Subscribe to snapshot across all active routers + * This may be useful in cases where a service wants to subscribe to route parameter changes + * (default behaviour would only detect changes to top-most route) + * + * Adapted from https://github.com/angular/angular/issues/46891#issuecomment-1190590046 + */ +export function ngRouterMergedSnapshot$(router: Router) { + return router.events.pipe( + filter((e) => e instanceof NavigationEnd), + map(() => mergeRouterSnapshots(router)), + startWith(mergeRouterSnapshots(router)) + ); +} diff --git a/package.json b/package.json index 386795c7d..437dc9808 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "@swc-node/register": "~1.6.7", "@swc/core": "~1.3.85", "@types/c3": "^0.7.8", + "@types/downloadjs": "^1.4.6", "@types/hammerjs": "^2.0.41", "@types/intro.js": "^5.1.1", "@types/jest": "29.4.4", diff --git a/yarn.lock b/yarn.lock index 47bde6748..7eeeaaac2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8162,6 +8162,13 @@ __metadata: languageName: node linkType: hard +"@types/downloadjs@npm:^1.4.6": + version: 1.4.6 + resolution: "@types/downloadjs@npm:1.4.6" + checksum: 0e98425946c12315a7b9646edb75285bcc0fda2d59f9d296fa3bf9455ef62d6d73383c52ee86eec50e9b4cee6f9af9dba78c6fb56bc6444395b4ac4f76a86ec0 + languageName: node + linkType: hard + "@types/eslint-scope@npm:^3.7.3": version: 3.7.4 resolution: "@types/eslint-scope@npm:3.7.4" @@ -18991,6 +18998,7 @@ __metadata: "@swc-node/register": ~1.6.7 "@swc/core": ~1.3.85 "@types/c3": ^0.7.8 + "@types/downloadjs": ^1.4.6 "@types/hammerjs": ^2.0.41 "@types/intro.js": ^5.1.1 "@types/jest": 29.4.4