Skip to content

Commit

Permalink
Merge pull request #210 from e-picsa/feat/dashboard-climate-admin
Browse files Browse the repository at this point in the history
Feat(dashboard): climate data page
  • Loading branch information
chrismclarke authored Dec 21, 2023
2 parents cbbc895 + 6ca1213 commit 66bc218
Show file tree
Hide file tree
Showing 28 changed files with 863 additions and 123 deletions.
2 changes: 1 addition & 1 deletion apps/picsa-apps/dashboard/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"tsConfig": "apps/picsa-apps/dashboard/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": ["apps/picsa-apps/dashboard/src/favicon.ico", "apps/picsa-apps/dashboard/src/assets"],
"styles": ["apps/picsa-apps/dashboard/src/styles.scss"],
"styles": ["apps/picsa-apps/dashboard/src/styles.scss", "node_modules/leaflet/dist/leaflet.css"],
"stylePreprocessorOptions": {
"includePaths": ["libs/theme/src"]
},
Expand Down
8 changes: 4 additions & 4 deletions apps/picsa-apps/dashboard/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export class AppComponent implements AfterViewInit {
label: 'Resources',
href: '/resources',
},
// {
// label: 'Climate Data',
// href: '/climate-data',
// },
{
label: 'Climate Data',
href: '/climate-data',
},
// {
// label: 'Crop Information',
// href: '/crop-information',
Expand Down
4 changes: 4 additions & 0 deletions apps/picsa-apps/dashboard/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export const appRoutes: Route[] = [
path: 'resources',
loadChildren: () => import('./modules/resources/resources.module').then((m) => m.ResourcesPageModule),
},
{
path: 'climate-data',
loadChildren: () => import('./modules/climate-data/climate-data.module').then((m) => m.ClimateDataModule),
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
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 */
@Injectable({ providedIn: 'root' })
export class ClimateDataApiService {

/** Http client with type-definitions for API endpoints */
public client:ReturnType<typeof createClient<paths>>

constructor() {
this.client = createClient<paths>({ baseUrl: API_ENDPOINT,mode:'cors' });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';

import { ClimateDataHomeComponent } from './pages/home/climate-data-home.component';
import { StationPageComponent } from './pages/station/station-page.component';

@NgModule({
declarations: [],
imports: [
CommonModule,
RouterModule.forChild([
{
path: '',
component: ClimateDataHomeComponent,
},
{
path: ':stationId',
component: StationPageComponent,
},
]),
],
})
export class ClimateDataModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Injectable } from '@angular/core';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { Database } from '@picsa/server-types';
import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service';
import { SupabaseService } from '@picsa/shared/services/core/supabase';
import { IStorageEntry } from '@picsa/shared/services/core/supabase/services/supabase-storage.service';

import { ClimateDataApiService } from './climate-data-api.service';

export type IStationRow = Database['public']['Tables']['climate_stations']['Row'];

export interface IResourceStorageEntry extends IStorageEntry {
/** Url generated when upload to public bucket (will always be populated, even if bucket not public) */
publicUrl: string;
}

export type IResourceEntry = Database['public']['Tables']['resources']['Row'];

@Injectable({ providedIn: 'root' })
export class ClimateDataDashboardService extends PicsaAsyncService {
public apiStatus: number;
public stations: IStationRow[] = [];

constructor(private supabaseService: SupabaseService, private api: ClimateDataApiService) {
super();
}

public override async init() {
await this.supabaseService.ready();
await this.checkStatus();
await this.listStations();
}

private async checkStatus() {
const { client } = this.api;
const { response } = await client.GET('/v1/status/');
this.apiStatus = response.status;
}

private async listStations() {
// HACK - endpoint not operational
// TODO - when running should refresh from server as cron task
const { data, error } = await this.supabaseService.db.table('climate_stations').select<'*', IStationRow>('*');
if (error) {
throw error;
}
this.stations = data || [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<div class="page-content">
<div style="display: flex; align-items: center">
<h2 style="flex: 1">Climate Data</h2>
@if(service.apiStatus; as status){
<div class="server-status">
Server Status <span class="status-code" [attr.data-status]="status">{{ status }}</span>
</div>
}
</div>
<h3>Stations</h3>
<div style="display: flex; gap: 1rem">
<div style="flex: 1">
<table mat-table class="stations-table" [dataSource]="service.stations" style="width: 200px">
<ng-container matColumnDef="station_id">
<th mat-header-cell *matHeaderCellDef>station_id</th>
<td mat-cell *matCellDef="let station">{{ station.station_id }}</td>
</ng-container>
<ng-container matColumnDef="station_name">
<th mat-header-cell *matHeaderCellDef>station_name</th>
<td mat-cell *matCellDef="let station">{{ station.station_name }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr
mat-row
class="station-row"
[routerLink]="row.station_id"
*matRowDef="let row; columns: displayedColumns"
></tr>
</table>
</div>
<picsa-map style="height: 500px; width: 500px" [markers]="mapMarkers"></picsa-map>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.status-code {
color: white;
padding: 8px;
border-radius: 4px;
background: gray;
&[data-status='200'] {
background: green;
}
}

table.station-table {
max-height: 50vh;
display: block;
overflow: auto;
}
tr.station-row {
cursor: pointer;
&:hover {
background: whitesmoke;
}
}
th {
font-weight: bold;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ClimateDataHomeComponent } from './climate-data-home.component';

describe('ClimateDataHomeComponent', () => {
let component: ClimateDataHomeComponent;
let fixture: ComponentFixture<ClimateDataHomeComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ClimateDataHomeComponent],
}).compileComponents();

fixture = TestBed.createComponent(ClimateDataHomeComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { IMapMarker, PicsaMapComponent } from '@picsa/shared/features/map/map';

import { ClimateDataDashboardService, IStationRow } from '../../climate-data.service';

@Component({
selector: 'dashboard-climate-data-home',
standalone: true,
imports: [CommonModule, MatTableModule, RouterModule, PicsaMapComponent],
templateUrl: './climate-data-home.component.html',
styleUrls: ['./climate-data-home.component.scss'],
})
export class ClimateDataHomeComponent implements OnInit {
public displayedColumns: (keyof IStationRow)[] = ['station_id', 'station_name'];

public mapMarkers: IMapMarker[];

constructor(public service: ClimateDataDashboardService) {}

async ngOnInit() {
await this.service.ready();
this.mapMarkers = this.service.stations.map((m) => ({
latlng: [m.latitude as number, m.longitude as number],
number: m.station_id,
}));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="page-content">
@if(station){
<h2>{{ station.station_name }}</h2>
<table>
<tr>
@for(key of stationSummary.keys; track $index){
<th>{{ key }}</th>
}
</tr>
@for(value of stationSummary.values; track $index){
<td>{{ value }}</td>
}
</table>
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
table {
font-family: arial, sans-serif;
border-collapse: collapse;
width: 100%;
}

td,
th {
border: 1px solid #dddddd;
text-align: left;
padding: 8px;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { StationPageComponent } from './station-page.component';

describe('StationPageComponent', () => {
let component: StationPageComponent;
let fixture: ComponentFixture<StationPageComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [StationPageComponent],
}).compileComponents();

fixture = TestBed.createComponent(StationPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 { ClimateDataDashboardService, IStationRow } from '../../climate-data.service';

@Component({
selector: 'dashboard-station-page',
standalone: true,
imports: [CommonModule],
templateUrl: './station-page.component.html',
styleUrls: ['./station-page.component.scss'],
})
export class StationPageComponent implements OnInit {
public station: IStationRow | undefined;

public get stationSummary() {
return {
keys: Object.keys(this.station || {}),
values: Object.values(this.station || {}),
};
}

constructor(
private service: ClimateDataDashboardService,
private route: ActivatedRoute,
private notificationService: PicsaNotificationService
) {}

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` });
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Climate Data Types

The climate data api runs on a remote server and exports endpoint definitions using the OpenAPI spec.

These definitions can be converted into typescript types on-demand using the command:

```sh
npx openapi-typescript "https://api.epicsa.idems.international/openapi.json" -o "apps\picsa-apps\dashboard\src\app\modules\climate-data\types\api.d.ts"
```
Loading

0 comments on commit 66bc218

Please sign in to comment.