switching to sqlite

This commit is contained in:
2025-10-02 12:00:24 -05:00
parent ab304a469b
commit 05b17878d7
144 changed files with 4848 additions and 8327 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -20,12 +20,6 @@ jobs:
sparse-checkout: | sparse-checkout: |
api api
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Install Nest CLI - name: Install Nest CLI
run: | run: |
npm install -g @nestjs/cli npm install -g @nestjs/cli
@@ -37,16 +31,11 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
pnpm install npm ci
- name: Build - name: Build
run: | run: |
pnpm --filter api build npm run build
- name: Deploy
if: ${{ github.event_name != 'pull_request' }}
run: |
pnpm --filter api --prod deploy ./.prod/api
- name: Log into Docker Hub - name: Log into Docker Hub
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}

View File

@@ -19,17 +19,17 @@ jobs:
version_number: ${{ github.run_id }} version_number: ${{ github.run_id }}
secrets: inherit secrets: inherit
# deploy-api: deploy-api:
# name: deploy-api name: deploy-api
# needs: needs:
# - changes - changes
# - build-api - build-api
# uses: ./.github/workflows/deploy.yaml uses: ./.github/workflows/deploy.yaml
# with: with:
# app_name: flying-api app_name: flying-api
# environment_name: test environment_name: test
# version_number: ${{ github.run_id }} version_number: ${{ github.run_id }}
# secrets: inherit secrets: inherit
build-app: build-app:
if: ${{ needs.changes.outputs.app == 'true' }} if: ${{ needs.changes.outputs.app == 'true' }}
@@ -42,14 +42,14 @@ jobs:
version_number: ${{ github.run_id }} version_number: ${{ github.run_id }}
secrets: inherit secrets: inherit
# deploy-app: deploy-app:
# name: deploy-app name: deploy-app
# needs: needs:
# - changes - changes
# - build-app - build-app
# uses: ./.github/workflows/deploy.yaml uses: ./.github/workflows/deploy.yaml
# with: with:
# app_name: flying-app app_name: flying-app
# environment_name: test environment_name: test
# version_number: ${{ github.run_id }} version_number: ${{ github.run_id }}
# secrets: inherit secrets: inherit

1
.gitignore vendored
View File

@@ -6,4 +6,3 @@ node_modules
.dapr .dapr
**/**/secrets.json **/**/secrets.json
.prod .prod
database

View File

@@ -22,24 +22,25 @@
FROM node:22-slim AS base FROM --platform=linux/amd64 node:22-slim AS base
FROM base AS migrate
WORKDIR migrations
COPY ./migrations .
RUN npm i -g pnpm
RUN pnpm install
FROM base AS api FROM base AS api
COPY ./api/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
WORKDIR api WORKDIR api
COPY ./.prod/api . COPY ./api/dist ./dist
COPY ./api/package.json package-lock.json .
RUN npm ci
EXPOSE 3000 EXPOSE 3000
CMD ["node", "dist/main.js"]
ENTRYPOINT ["/entrypoint.sh"]
# ENTRYPOINT ["tail", "-f", "/dev/null"] # ENTRYPOINT ["tail", "-f", "/dev/null"]
FROM base AS app FROM base AS app
WORKDIR /app WORKDIR app
COPY ./.prod/app . COPY ./app/dist ./dist
RUN npm i -g serve RUN npm i -g serve
EXPOSE 8080 EXPOSE 8080
CMD [ "serve", "-s", "dist", "-p", "8080" ] CMD [ "serve", "-s", "dist", "-p", "8080" ]

3
api/.gitignore vendored
View File

@@ -57,3 +57,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
local.settings.json local.settings.json
/src/database/*.db

3
api/entrypoint.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
npx typeorm migration:run -d ./dist/config/typeorm-cli.config.js
node ./dist/main.js

View File

@@ -17,7 +17,7 @@
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "npm run build && npx typeorm -d dist/config/typeorm-cli.config.js", "typeorm": "npm run build && npx typeorm -d dist/database/data-source.js",
"migration:generate": "npm run typeorm -- migration:generate", "migration:generate": "npm run typeorm -- migration:generate",
"migration:run": "npm run typeorm -- migration:run", "migration:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert" "migration:revert": "npm run typeorm -- migration:revert"
@@ -25,21 +25,29 @@
"dependencies": { "dependencies": {
"@azure/storage-blob": "^12.27.0", "@azure/storage-blob": "^12.27.0",
"@microsoft/microsoft-graph-client": "^3.0.7", "@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/axios": "^3.0.3", "@nestjs/axios": "^4.0.1",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^11.1.6",
"@nestjs/config": "^3.2.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^11.1.6",
"@nestjs/passport": "^10.0.3", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^10.0.0", "@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/typeorm": "^11.0.0", "@nestjs/typeorm": "^11.0.0",
"@noahspan/azure-database": "^3.1.2", "@noahspan/azure-database": "^3.1.2",
"@noahspan/noahspan-modules": "^1.1.5", "@noahspan/noahspan-modules": "^1.2.8",
"@schematics/angular": "^17.3.7", "@schematics/angular": "^17.3.7",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"better-sqlite3": "^12.2.0",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"express-session": "^1.18.2",
"jwks-rsa": "^3.2.0",
"node-gyp": "^11.4.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.25", "typeorm": "^0.3.25",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"uuidv4": "^6.2.13" "uuidv4": "^6.2.13"
@@ -48,11 +56,13 @@
"@microsoft/microsoft-graph-types": "^2.40.0", "@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^11.1.6",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/passport-azure-ad": "^4.3.6", "@types/passport-azure-ad": "^4.3.6",
"@types/passport-openidconnect": "^0.1.3",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"jest": "^29.5.0", "jest": "^29.5.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService]
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,15 +1,17 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { FeatureFlagModule } from './featureFlag/feature-flag.module' import { HealthModule } from './health/health.module';
import { LogModule } from './log/log.module'; import { LogModule } from './log/log.module';
import { PilotModule } from './pilot/pilot.module'; import { PilotModule } from './pilot/pilot.module';
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core'; import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from './filters/http-exception.filter'; import { HttpExceptionFilter } from './filters/http-exception.filter';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthGuard, AuthModule, UserModule } from '@noahspan/noahspan-modules'; import { AuthModule, MsGraphModule } from '@noahspan/noahspan-modules';
import configuration from './config/configuration'; import configuration from './config/configuration';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { dataSourceOptions } from './config/typeorm-cli.config'; import { dataSourceOptions } from './database/data-source';
import { TrackModule } from './track/track.module'; import { TrackModule } from './track/track.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({ @Module({
imports: [ imports: [
@@ -18,9 +20,9 @@ import { TrackModule } from './track/track.module';
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
return { return {
clientId: configService.get<string>('clientId'), audience: configService.get<string>('audience'),
clientSecret: configService.get<string>('clientSecret'), issuerUrl: configService.get<string>('issuer'),
tenantId: configService.get<string>('tenantId') jwksUri: configService.get<string>('jwksUri')
}; };
}, },
}), }),
@@ -28,12 +30,15 @@ import { TrackModule } from './track/track.module';
isGlobal: true, isGlobal: true,
load: [configuration] load: [configuration]
}), }),
FeatureFlagModule, // HealthModule,
LogModule, // LogModule,
PilotModule, PilotModule,
TrackModule, ServeStaticModule.forRoot({
rootPath: join(__dirname, '../..', 'client', 'dist')
}),
// TrackModule,
TypeOrmModule.forRoot(dataSourceOptions), TypeOrmModule.forRoot(dataSourceOptions),
UserModule.registerAsync({ MsGraphModule.registerAsync({
inject: [ConfigService], inject: [ConfigService],
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
@@ -49,7 +54,7 @@ import { TrackModule } from './track/track.module';
{ {
provide: APP_FILTER, provide: APP_FILTER,
useClass: HttpExceptionFilter useClass: HttpExceptionFilter
}, }
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,5 +0,0 @@
export interface AuthModuleOptions {
tenantId: string;
clientId: string;
clientSecret: string;
}

View File

@@ -1,4 +0,0 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { AuthModuleOptions } from './auth.interface';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<AuthModuleOptions>().build()

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { AzureAdStrategy } from './auth.strategy';
import { ConfigurableModuleClass } from './auth.module-definition';
@Module({
imports: [
PassportModule.register({
defaultStrategy: 'azure-ad'
})
],
providers: [AzureAdStrategy]
})
export class AuthModule extends ConfigurableModuleClass {}

View File

@@ -1,25 +0,0 @@
import { Inject, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { AuthModuleOptions } from './auth.interface'
import { MODULE_OPTIONS_TOKEN } from "./auth.module-definition";
import { BearerStrategy } from 'passport-azure-ad'
@Injectable()
export class AzureAdStrategy extends PassportStrategy(
BearerStrategy,
'azure-ad'
) {
constructor(@Inject(MODULE_OPTIONS_TOKEN) authModuleOptions: AuthModuleOptions) {
super({
identityMetadata: `https://login.microsoftonline.com/${authModuleOptions.tenantId}/.well-known/openid-configuration`,
clientID: authModuleOptions.clientId,
audience: `api://${authModuleOptions.clientId}`,
loggingLevel: 'info',
loggingNoPII: false
})
}
async validate(data: any): Promise<any> {
return data;
}
}

View File

@@ -1,6 +1,9 @@
export default () => ({ export default () => ({
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING, azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
audience: process.env.AUDIENCE,
clientId: process.env.CLIENT_ID, clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET, clientSecret: process.env.CLIENT_SECRET,
issuer: process.env.ISSUER_URL,
jwksUri: process.env.JWKS_URI,
tenantId: process.env.TENANT_ID tenantId: process.env.TENANT_ID
}) })

View File

@@ -1,17 +1,16 @@
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import configuration from './configuration';
config(); config();
const configService = new ConfigService(); const configService = new ConfigService();
export const dataSourceOptions: DataSourceOptions = { export const dataSourceOptions: DataSourceOptions = {
type: 'sqlite', type: 'better-sqlite3',
database: configService.get<string>('DB_PATH'), database: configService.get<string>('DB_PATH'),
entities: ['dist/**/*.entity.js'], entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'], migrations: ['dist/database/migrations/*.js'],
synchronize: configService.get<boolean>('DB_SYNC') synchronize: configService.get<boolean>('DB_SYNC')
} }

View File

@@ -0,0 +1,4 @@
dbs:
- path: /var/lib/data/flying.db
replicas:
- path: /mnt/data/backup

View File

@@ -1,13 +1,13 @@
import { MigrationInterface, QueryRunner } from "typeorm"; import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialMigration1754234179211 implements MigrationInterface { export class InitialMigration1758802917932 implements MigrationInterface {
name = 'InitialMigration1754234179211' name = 'InitialMigration1758802917932'
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`); await queryRunner.query(`CREATE TABLE "certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "endorsements" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`); await queryRunner.query(`CREATE TABLE "endorsements" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "medical" ("id" varchar PRIMARY KEY NOT NULL, "class" varchar NOT NULL, "expirationDate" datetime NOT NULL, "pilotId" varchar)`); await queryRunner.query(`CREATE TABLE "medical" ("id" varchar PRIMARY KEY NOT NULL, "class" varchar NOT NULL, "expirationDate" datetime NOT NULL, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "pilots" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "address" varchar NOT NULL, "city" varchar NOT NULL, "state" varchar NOT NULL, "postalCode" varchar NOT NULL, "email" varchar NOT NULL, "phone" varchar NOT NULL)`); await queryRunner.query(`CREATE TABLE "pilots" ("id" varchar PRIMARY KEY NOT NULL, "name" varchar NOT NULL, "address" varchar NOT NULL, "city" varchar NOT NULL, "state" varchar NOT NULL, "postalCode" varchar NOT NULL, "email" varchar NOT NULL, "phone" varchar NOT NULL, "userId" varchar NOT NULL)`);
await queryRunner.query(`CREATE TABLE "logs" ("id" varchar PRIMARY KEY NOT NULL, "date" datetime NOT NULL, "aircraftMakeModel" varchar NOT NULL, "aircraftIdentity" varchar NOT NULL, "routeFrom" varchar NOT NULL, "routeTo" varchar NOT NULL, "durationOfFlight" integer NOT NULL, "singleEngineLand" integer, "simulatorAtd" integer, "landingsDay" integer, "landingsNight" integer, "groundTrainingReceived" integer, "flightTrainingReceived" integer, "crossCountry" integer, "night" integer, "solo" integer, "pilotInCommand" integer, "instrumentActual" integer, "instrumentSimulated" integer, "instrumentApproaches" integer, "instrumentHolds" integer, "instrumentNavTrack" integer, "notes" varchar, "pilotId" varchar)`); await queryRunner.query(`CREATE TABLE "logs" ("id" varchar PRIMARY KEY NOT NULL, "date" datetime NOT NULL, "aircraftMakeModel" varchar NOT NULL, "aircraftIdentity" varchar NOT NULL, "routeFrom" varchar NOT NULL, "routeTo" varchar NOT NULL, "durationOfFlight" integer NOT NULL, "singleEngineLand" integer, "simulatorAtd" integer, "landingsDay" integer, "landingsNight" integer, "groundTrainingReceived" integer, "flightTrainingReceived" integer, "crossCountry" integer, "night" integer, "solo" integer, "pilotInCommand" integer, "instrumentActual" integer, "instrumentSimulated" integer, "instrumentApproaches" integer, "instrumentHolds" integer, "instrumentNavTrack" integer, "notes" varchar, "pilotId" varchar)`);
await queryRunner.query(`CREATE TABLE "tracks" ("id" varchar PRIMARY KEY NOT NULL, "url" varchar NOT NULL, "order" integer NOT NULL, "logId" varchar)`); await queryRunner.query(`CREATE TABLE "tracks" ("id" varchar PRIMARY KEY NOT NULL, "url" varchar NOT NULL, "order" integer NOT NULL, "logId" varchar)`);
await queryRunner.query(`CREATE TABLE "temporary_certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar, CONSTRAINT "FK_05a68997dc2d27dfcc642a4cf51" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); await queryRunner.query(`CREATE TABLE "temporary_certificates" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar NOT NULL, "number" varchar NOT NULL, "issueDate" datetime NOT NULL, "pilotId" varchar, CONSTRAINT "FK_05a68997dc2d27dfcc642a4cf51" FOREIGN KEY ("pilotId") REFERENCES "pilots" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`);

View File

@@ -1,41 +0,0 @@
import {
Controller,
Get,
HttpException,
Param,
UseGuards,
} from '@nestjs/common';
import { FeatureFlagService } from './feature-flag.service';
import { CustomError } from '../error/customError';
import { AuthGuard } from '@nestjs/passport';
@Controller('featureFlags')
@UseGuards(AuthGuard('azure-ad'))
export class FeatureFlagController {
constructor(private readonly featureFlagService: FeatureFlagService) {}
@Get(':partitionKey/:rowKey')
async find(
@Param('partitionKey') partitionKey: string,
@Param('rowKey') rowKey: string
) {
try {
return await this.featureFlagService.find(partitionKey, rowKey);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get()
async findAll() {
try {
return await this.featureFlagService.findAll();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -1,5 +0,0 @@
export class FeatureFlagDto {
partitionKey: string;
rowKey: string;
active: string;
}

View File

@@ -1,7 +0,0 @@
import { EntityString } from '@noahspan/azure-database';
export class FeatureFlag {
@EntityString() partitionKey: string;
@EntityString() rowKey: string;
@EntityString() active: string;
}

View File

@@ -1,27 +0,0 @@
import { Module } from '@nestjs/common';
import { FeatureFlagController } from './feature-flag.controller';
import { FeatureFlagService } from './feature-flag.service';
import { AzureTableStorageModule } from '@noahspan/azure-database';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FeatureFlag } from './feature-flag.entity';
@Module({
imports: [
AzureTableStorageModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
connectionString: configService.get<string>('azureStorageConnectionString')
};
},
inject: [ConfigService]
}),
AzureTableStorageModule.forFeature(FeatureFlag, {
createTableIfNotExists: false,
table: 'featureFlags'
}),
],
controllers: [FeatureFlagController],
providers: [FeatureFlagService]
})
export class FeatureFlagModule {}

View File

@@ -1,18 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository, Repository } from '@noahspan/azure-database';
import { FeatureFlag } from './feature-flag.entity';
@Injectable()
export class FeatureFlagService {
constructor(
@InjectRepository(FeatureFlag) private readonly featureFlagRepository: Repository<FeatureFlag>
) {}
async find(partitionKey: string, rowKey: string): Promise<FeatureFlag> {
return await this.featureFlagRepository.find(partitionKey, rowKey);
}
async findAll(): Promise<FeatureFlag[]> {
return await this.featureFlagRepository.findAll();
}
}

View File

@@ -0,0 +1,42 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
describe('HealthController', () => {
let controller; HealthController;
const mockHealthService = {
isDatabaseConnected: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController],
providers: [HealthService]
}).compile();
controller = module.get<HealthController>(HealthController);
})
it('isHealthy => should return true', () => {
expect(controller).toBeDefined();
});
it('should return database connected', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(true);
const result = await controller.isHealthy();
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(true);
})
it('isHealthy => should return error', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(false);
const result = await controller.isHealthy();
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(false);
})
})

View File

@@ -0,0 +1,27 @@
import {
Controller,
Get,
HttpException,
} from '@nestjs/common';
import { HealthService } from './health.service';
import { CustomError } from 'src/error/customError';
@Controller('health')
export class HealthController {
constructor(
private readonly healthService: HealthService
) {}
@Get()
async isHealthy(): Promise<boolean> {
try {
return await this.healthService.isDatabaseConnected();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [
HealthService
]
})
export class HealthModule {}

View File

@@ -0,0 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthService } from './health.service';
describe('HealthService', () => {
let service: HealthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HealthService]
}).compile();
service = module.get<HealthService>(HealthService);
});
});

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { CustomError } from '../error/customError';
import { DataSource } from 'typeorm';
@Injectable()
export class HealthService {
constructor(private dataSource: DataSource) {}
async isDatabaseConnected(): Promise<boolean> {
try {
const isDatabaseConnected: boolean = this.dataSource.isInitialized;
if (isDatabaseConnected) {
return isDatabaseConnected;
} else {
throw new CustomError('Database not connected', 'Database not connected', 400)
}
} catch (error) {
throw error;
}
}
}

View File

@@ -1,33 +1,3 @@
// export class Log {
// partitionKey: string;
// rowKey: string;
// pilotId: string;
// pilotName: string;
// date: string;
// aircraftMakeModel: string;
// aircraftIdentity: string;
// routeFrom: string;
// routeTo: string;
// durationOfFlight: number | null;
// singleEngineLand: number | null;
// simulatorAtd?: number | null;
// landingsDay?: number | null;
// landingsNight?: number | null;
// groundTrainingReceived?: number;
// flightTrainingReceived?: number;
// crossCountry?: number | null;
// night?: number | null;
// solo?: number | null;
// pilotInCommand?: number | null;
// instrumentActual?: number | null;
// instrumentSimulated?: number | null;
// instrumentApproaches?: number | null;
// instrumentHolds?: number | null;
// instrumentNavTrack?: number | null;
// tracks?: string[];
// notes?: string;
// }
import { PilotEntity } from 'src/pilot/pilot.entity'; import { PilotEntity } from 'src/pilot/pilot.entity';
import { TrackEntity } from 'src/track/track.entity'; import { TrackEntity } from 'src/track/track.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm';

View File

@@ -3,15 +3,26 @@ import { AppModule } from './app.module';
import { HttpService } from '@nestjs/axios'; import { HttpService } from '@nestjs/axios';
import { HttpExceptionFilter } from './filters/http-exception.filter'; import { HttpExceptionFilter } from './filters/http-exception.filter';
import { InternalServerErrorException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import * as session from 'express-session';
async function bootstrap() { async function bootstrap() {
const httpService = new HttpService(); const httpService = new HttpService();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors(); app.enableCors({
origin: 'http://localhost:8080', // Allow requests from your frontend's origin
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // If you need to send cookies or authorization headers
});
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.use(
session({
secret: 'blah',
resave: false,
saveUninitialized: false
})
)
httpService.axiosRef.interceptors.response.use( httpService.axiosRef.interceptors.response.use(
(response) => { (response) => {

View File

@@ -14,8 +14,8 @@ import { PilotDto } from './pilot.dto';
import { PilotEntity } from './pilot.entity'; import { PilotEntity } from './pilot.entity';
import { PilotService } from './pilot.service'; import { PilotService } from './pilot.service';
import { CustomError } from '../error/customError'; import { CustomError } from '../error/customError';
import { AuthGuard } from '@noahspan/noahspan-modules'
import { PilotInterceptor } from './interceptors/pilot.interceptor'; import { PilotInterceptor } from './interceptors/pilot.interceptor';
import { AuthGuard } from '@noahspan/noahspan-modules';
@Controller('pilots') @Controller('pilots')
// @UseInterceptors(new PilotInterceptor()) // @UseInterceptors(new PilotInterceptor())
@@ -34,6 +34,7 @@ export class PilotController {
} }
@Get() @Get()
@UseGuards(AuthGuard)
async findAll() { async findAll() {
try { try {
return await this.pilotService.findAll(); return await this.pilotService.findAll();
@@ -44,7 +45,7 @@ export class PilotController {
} }
} }
// @UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Post() @Post()
async create(@Body() pilotDto: PilotDto) { async create(@Body() pilotDto: PilotDto) {
try { try {

View File

@@ -1,22 +1,3 @@
// import { EntityString } from '@noahspan/azure-database';
// export class Pilot {
// @EntityString() partitionKey: string;
// @EntityString() rowKey: string;
// @EntityString() id: string;
// @EntityString() name: string;
// @EntityString() address?: string;
// @EntityString() city?: string;
// @EntityString() state?: string;
// @EntityString() postalCode?: string;
// @EntityString() email?: string;
// @EntityString() phone?: string;
// @EntityString() medicalClass?: string;
// @EntityString() medicalExpiration: string;
// @EntityString() certificates: string;
// @EntityString() endorsements: string;
// }
import { LogEntity } from 'src/log/log.entity'; import { LogEntity } from 'src/log/log.entity';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { CertificateEntity } from '../certificate/certificate.entity'; import { CertificateEntity } from '../certificate/certificate.entity';
@@ -49,6 +30,9 @@ export class PilotEntity {
@Column() @Column()
phone: string; phone: string;
@Column()
userId: string | null;
@OneToMany(() => LogEntity, (log: LogEntity) => log.pilot, {onDelete: 'CASCADE', onUpdate: 'CASCADE'}) @OneToMany(() => LogEntity, (log: LogEntity) => log.pilot, {onDelete: 'CASCADE', onUpdate: 'CASCADE'})
logs: LogEntity[]; logs: LogEntity[];

View File

@@ -1,70 +0,0 @@
/*
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/
import { LogLevel } from '@azure/msal-browser';
/**
* Configuration object to be passed to MSAL instance on creation.
* For a full list of MSAL.js configuration parameters, visit:
* https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/configuration.md
*/
export const msalConfig = {
auth: {
clientId: import.meta.env.VITE_CLIENT_ID, // This is the ONLY mandatory field that you need to supply.
authority: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}`, // Replace the placeholder with your tenant subdomain
redirectUri: import.meta.env.VITE_REDIRECT_URL, // Points to window.location.origin. You must register this URI on Microsoft Entra admin center/App Registration.
postLogoutRedirectUri: '/', // Indicates the page to navigate after logout.
navigateToLoginRequestUrl: false, // If "true", will navigate back to the original request location before processing the auth code response.
},
cache: {
cacheLocation: 'sessionStorage', // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs.
storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge
},
system: {
loggerOptions: {
loggerCallback: (level: any, message: any, containsPii: any) => {
if (containsPii) {
return;
}
switch (level) {
case LogLevel.Error:
console.error(message);
return;
case LogLevel.Info:
console.info(message);
return;
case LogLevel.Verbose:
console.debug(message);
return;
case LogLevel.Warning:
console.warn(message);
return;
default:
return;
}
},
},
},
};
/**
* Scopes you add here will be prompted for user consent during sign-in.
* By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request.
* For more information about OIDC scopes, visit:
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent#openid-connect-scopes
*/
export const loginRequest = {
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`],
};
/**
* An optional silentRequest object can be used to achieve silent SSO
* between applications by providing a "login_hint" property.
*/
// export const silentRequest = {
// scopes: ["openid", "profile"],
// loginHint: "example@domain.net"
// };

View File

@@ -1,81 +0,0 @@
import { useState } from 'react';
import { IActionMenuProps } from './IActionMenuProps';
import {
IconButton,
Icon,
IconName,
ListItemIcon,
ListItemText,
Menu,
MenuItem
} from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { useIsAuthenticated } from '@azure/msal-react';
const ActionMenu = ({ id, onDelete, onOpenCloseForm, onOpenCloseTracks }: IActionMenuProps) => {
const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>(
null
);
const isAuthenticated = useIsAuthenticated();
const onOpenActionMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElAction(event.currentTarget);
};
const onCloseActionMenu = () => {
setAnchorElAction(null);
};
return (
<div>
<IconButton onClick={onOpenActionMenu}>
<Icon iconName={IconName.ELLIPSIS_VERTICAL} size="sm" />
</IconButton>
<Menu
anchorEl={anchorElAction}
keepMounted
open={Boolean(anchorElAction)}
onClose={onCloseActionMenu}
>
{isAuthenticated &&
<>
<MenuItem onClick={() => onOpenCloseForm(FormMode.EDIT, id)}>
<ListItemIcon>
<Icon iconName={IconName.PEN} size="lg" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
{onOpenCloseTracks &&
<MenuItem onClick={() => onOpenCloseTracks!(FormMode.EDIT, id)}>
<ListItemIcon>
<Icon iconName={IconName.MAP_LOCATION_DOT} size="lg" />
</ListItemIcon>
<ListItemText>Tracks</ListItemText>
</MenuItem>
}
</>
}
<MenuItem onClick={() => onOpenCloseForm(FormMode.VIEW, id)}>
<ListItemIcon>
<Icon iconName={IconName.EYE} size="lg" />
</ListItemIcon>
<ListItemText>View</ListItemText>
</MenuItem>
{isAuthenticated &&
<>
<hr className="my-3" />
<MenuItem onClick={() => onDelete(id)}>
<ListItemIcon>
<Icon iconName={IconName.TRASH} size="lg" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
</>
}
</Menu>
</div>
);
};
export default ActionMenu;

View File

@@ -1,77 +0,0 @@
import { Box, Card, CardContent, Container, Grid, Skeleton, Spinner, Stack, theme, Typography, useMediaQuery } from "@noahspan/noahspan-components";
import LogbookCard from "../logbookCard/LogbookCard";
import { useEffect, useState } from "react";
import { useLogs } from "../../hooks/logs/UseLogs";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
const Flights = () => {
const [flights, setFlights] = useState<ILogbookEntry[]>([]);
const { logs, isLoading } = useLogs();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
useEffect(() => {
console.log(logs)
const flights: ILogbookEntry[] | undefined = logs?.filter((log: ILogbookEntry) => {
if (log.tracks && log.tracks.length > 0) {
return log;
}
})
if (flights && flights.length > 0) {
setFlights(flights)
}
}, [logs])
return (
<Container>
<Box sx={{ margin: '20px' }}>
<Grid container spacing={2}>
<Grid size={isMedium ? 11 : 6}>
<Typography variant="h4">Flights</Typography>
</Grid>
{!isLoading &&
<Grid size={12}>
<LogbookCard logs={flights} mode='flights' />
</Grid>
}
{isLoading && [...Array(6)].map((_element, index) => {
return (
<Grid display='flex' justifyContent='center' size={12}>
<Card
key={index}
sx={{
width: '100%'
}}
>
<CardContent>
<Grid container>
<Grid size={12}>
<Skeleton height={60} width={200} />
<Skeleton height={30} width={200} />
</Grid>
<Grid size={12}>
<Skeleton height={300} />
</Grid>
<Grid size={12}>
{[...Array(4)].map((_element, index) => {
return (
<>
<Skeleton height={20} width={300} />
<Skeleton height={40} width={300} />
</>
)
})}
</Grid>
</Grid>
</CardContent>
</Card>
</Grid>
)
})}
</Grid>
</Box>
</Container>
)
}
export default Flights;

View File

@@ -1,102 +0,0 @@
import { Card, CardContent, CardHeader, Grid, Typography } from "@noahspan/noahspan-components";
import { LogbookCardProps } from "./LogbookCardProps.interface";
import ActionMenu from "../actionMenu/ActionMenu";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps) => {
return (
<Grid container spacing={2}>
{logs.map((log) => {
const date = new Date(log.date);
const formattedDate: string = `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
return (
<Grid size={12}>
<Card key={log.id}>
<CardHeader
action={mode === 'logbook' ? <ActionMenu id={log.id} onDelete={onDelete!} onOpenCloseForm={onOpenCloseForm!} /> : null}
title={formattedDate}
slotProps={{
subheader: {
fontSize: '16px'
},
title: {
fontSize: '24px',
}
}}
/>
<CardContent>
<Grid container spacing={1}>
{mode === 'flights' && log.tracks && log.tracks.length > 0 &&
<Grid size={12}>
<LogTrackMaps
logId={log.id}
tracks={log.tracks}
/>
</Grid>
}
<Grid size={12}>
<Typography variant="subtitle2">Aircraft Make and Model</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.aircraftMakeModel}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Route From</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.routeFrom}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Route To</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.routeTo}</Typography>
</Grid>
<Grid size={12}>
<Typography variant="subtitle2">Duration Of Flight</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.durationOfFlight}</Typography>
</Grid>
{mode === 'logbook' && log.tracks && log.tracks.length > 0 &&
<>
<Grid size={12}>
<Typography variant="subtitle2">Tracks</Typography>
</Grid>
{log.tracks.map((track: { id: string; order: number; url: string}) => {
const trackSplit = track.url.split('/')
const filename = trackSplit[trackSplit.length - 1];
return (
<Grid size={12}>
<Typography variant="body1">{filename}</Typography>
</Grid>
)
})}
</>
}
{log.notes &&
<>
<Grid size={12}>
<Typography variant="subtitle2">Notes</Typography>
</Grid>
<Grid size={12}>
<Typography variant="body1">{log.notes}</Typography>
</Grid>
</>
}
</Grid>
</CardContent>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default LogbookCard;

View File

@@ -1,29 +0,0 @@
import { Card, CardContent, CardHeader, Grid, Typography } from "@noahspan/noahspan-components"
import { PilotCardProps } from "./PilotCardProps.interface"
import ActionMenu from "../actionMenu/ActionMenu"
const PilotCard = ({ pilots, onDelete, onOpenCloseForm }: PilotCardProps) => {
return (
<Grid container spacing={2}>
{pilots.map((pilot) => {
return (
<Grid size={12}>
<Card key={pilot.rowKey}>
<CardHeader
action={<ActionMenu id={pilot.rowKey} onDelete={onDelete} onOpenCloseForm={onOpenCloseForm} />}
title={pilot.name}
slotProps={{
title: {
fontSize: '24px',
}
}}
/>
</Card>
</Grid>
)
})}
</Grid>
)
}
export default PilotCard;

View File

@@ -1,32 +0,0 @@
import {
AuthenticationResult,
InteractionRequiredAuthError
} from '@azure/msal-browser';
import { useMsal } from '@azure/msal-react';
export const useAccessToken = () => {
const { accounts, instance } = useMsal();
const getAccessToken = async () => {
const tokenRequest = {
account: accounts[0],
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`]
};
try {
const response: AuthenticationResult =
await instance.acquireTokenSilent(tokenRequest);
return `Bearer ${response.accessToken}`;
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
await instance.acquireTokenRedirect(tokenRequest);
}
throw error;
}
};
return {
getAccessToken
};
};

View File

@@ -1,4 +0,0 @@
body {
background-color: #f2f2f2;
margin: 8px;
}

View File

@@ -1,39 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import AppContextProvider from './context/appContext/AppContextProvider.tsx';
import { BrowserRouter } from 'react-router-dom';
import { AuthenticationResult, EventMessage, EventType, PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
import { msalConfig } from './auth/msalConfig';
import './index.css';
const msalInstance: PublicClientApplication = new PublicClientApplication(msalConfig);
msalInstance.initialize().then(() => {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.setActiveAccount(accounts[0]);
}
msalInstance.addEventCallback((event: EventMessage) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const payload = event.payload as AuthenticationResult;
const account = payload.account;
msalInstance.setActiveAccount(account);
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MsalProvider instance={msalInstance}>
<AppContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AppContextProvider>
</MsalProvider>
</React.StrictMode>
);
})

View File

@@ -1,16 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
preview: {
port: 8080,
strictPort: true
},
server: {
port: 8080,
strictPort: true,
host: '0.0.0.0'
}
});

View File

0
client/README.md Normal file
View File

View File

@@ -1,5 +1,5 @@
{ {
"name": "app", "name": "client",
"private": true, "private": true,
"version": "2.0.0-alpha", "version": "2.0.0-alpha",
"type": "module", "type": "module",
@@ -11,25 +11,27 @@
"serve": "serve -s dist" "serve": "serve -s dist"
}, },
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.0.1", "@noahspan/noahspan-components": "^2.0.0-alpha-11",
"@azure/msal-react": "3.0.1", "@tailwindcss/vite": "^4.1.13",
"@noahspan/noahspan-components": "^1.9.1",
"axios": "^1.7.2", "axios": "^1.7.2",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"react": "^18", "oidc-spa": "^7.2.4",
"react-dom": "^18", "react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.51.4", "react-hook-form": "^7.51.4",
"react-leaflet": "^4", "react-leaflet": "^4",
"react-leaflet-kml": "^2.1.2", "react-leaflet-kml": "^2.1.2",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.23.0", "react-router-dom": "^6.23.0",
"swiper": "^11.2.6" "swiper": "^11.2.6",
"tailwindcss": "^4.1.13"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0", "@microsoft/microsoft-graph-types": "^2.40.0",
"@types/leaflet": "^1.9.16", "@types/leaflet": "^1.9.16",
"@types/react": "^18.2.66", "@types/react": "^19.1.12",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^19.1.8",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.0" "vite": "^5.2.0"

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 618 B

After

Width:  |  Height:  |  Size: 618 B

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -3,17 +3,17 @@ import Flights from './components/flights/Flights';
import Logbook from './components/logbook/Logbook'; import Logbook from './components/logbook/Logbook';
import Pilots from './components/pilots/Pilots'; import Pilots from './components/pilots/Pilots';
import SiteNav from './components/siteNav/SiteNav'; import SiteNav from './components/siteNav/SiteNav';
import { useIsAuthenticated } from '@azure/msal-react'; import { useAuth } from 'react-oidc-context';
interface ProtectedRouteProps { interface ProtectedRouteProps {
children: React.ReactNode; children: React.ReactNode;
} }
const App = () => { const App = () => {
const isAuthenticated = useIsAuthenticated() const auth = useAuth();
const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
return isAuthenticated ? children : <Navigate to='/' /> return auth.isAuthenticated ? children : <Navigate to='/' />
} }
return ( return (
@@ -23,7 +23,6 @@ const App = () => {
<Route path='/' element={<Flights />} /> <Route path='/' element={<Flights />} />
<Route path="/logbook" element={<Logbook />} /> <Route path="/logbook" element={<Logbook />} />
<Route path="/pilots" element={<Pilots />} /> <Route path="/pilots" element={<Pilots />} />
</Routes> </Routes>
</> </>
); );

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,8 @@
import { createReactOidc } from "oidc-spa/react";
export const { OidcProvider, useOidc, getOidc, withLoginEnforced, enforceLogin } = createReactOidc(async () => ({
issuerUri: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}/v2.0`,
clientId: import.meta.env.VITE_CLIENT_APP_ID,
homeUrl: import.meta.env.BASE_URL,
scopes: ['email', 'openid', 'profile', `api://${import.meta.env.VITE_API_APP_ID}/user_impersonation`]
}));

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { IActionMenuProps } from './IActionMenuProps';
import {
IconButton,
Icon,
IconName
} from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { useAuth } from 'react-oidc-context';
const ActionMenu = ({ id, onDelete, onOpenCloseForm, onOpenCloseTracks }: IActionMenuProps) => {
const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>(
null
);
const auth = useAuth();
const onOpenActionMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElAction(event.currentTarget);
};
const onCloseActionMenu = () => {
setAnchorElAction(null);
};
return (
<></>
// <div>
// <IconButton onClick={onOpenActionMenu}>
// <Icon iconName={IconName.ELLIPSIS_VERTICAL} size="sm" />
// </IconButton>
// <Menu
// anchorEl={anchorElAction}
// keepMounted
// open={Boolean(anchorElAction)}
// onClose={onCloseActionMenu}
// >
// {auth.isAuthenticated &&
// <>
// <MenuItem onClick={() => onOpenCloseForm(FormMode.EDIT, id)}>
// <ListItemIcon>
// <Icon iconName={IconName.PEN} size="lg" />
// </ListItemIcon>
// <ListItemText>Edit</ListItemText>
// </MenuItem>
// {onOpenCloseTracks &&
// <MenuItem onClick={() => onOpenCloseTracks!(FormMode.EDIT, id)}>
// <ListItemIcon>
// <Icon iconName={IconName.MAP_LOCATION_DOT} size="lg" />
// </ListItemIcon>
// <ListItemText>Tracks</ListItemText>
// </MenuItem>
// }
// </>
// }
// <MenuItem onClick={() => onOpenCloseForm(FormMode.VIEW, id)}>
// <ListItemIcon>
// <Icon iconName={IconName.EYE} size="lg" />
// </ListItemIcon>
// <ListItemText>View</ListItemText>
// </MenuItem>
// {auth.isAuthenticated &&
// <>
// <hr className="my-3" />
// <MenuItem onClick={() => onDelete(id)}>
// <ListItemIcon>
// <Icon iconName={IconName.TRASH} size="lg" />
// </ListItemIcon>
// <ListItemText>Delete</ListItemText>
// </MenuItem>
// </>
// }
// </Menu>
// </div>
);
};
export default ActionMenu;

View File

@@ -1,14 +1,11 @@
import { import {
Box,
Button, Button,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
DialogContentText,
DialogTitle,
Icon, Icon,
IconName, IconName,
Spinner Loading
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { IDialogConfirmationProps } from './IConfirmationDialogProps'; import { IDialogConfirmationProps } from './IConfirmationDialogProps';
@@ -22,25 +19,20 @@ const ConfirmationDialog = ({
}: IDialogConfirmationProps) => { }: IDialogConfirmationProps) => {
return ( return (
<Dialog <Dialog
onClose={onCancel}
open={isOpen} open={isOpen}
sx={{
'& .MuiDialog-paper': { width: '2000px' }
}}
> >
<DialogTitle>{title}</DialogTitle> <h3>{title}</h3>
<DialogContent sx={{ textAlign: 'center' }}> <DialogContent>
{!isLoading && <DialogContentText>{contentText}</DialogContentText>} {!isLoading && <div>{contentText}</div>}
{isLoading && <Spinner />} {isLoading && <Loading size='xl' />}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={onCancel} variant="outlined" startIcon={<Icon iconName={IconName.XMARK} />}> <Button onClick={onCancel} startContent={<Icon iconName={IconName.XMARK} />}>
No No
</Button> </Button>
<Button <Button
onClick={onConfirm} onClick={onConfirm}
variant="contained" startContent={<Icon iconName={IconName.CIRCLE_CHECK} />}
startIcon={<Icon iconName={IconName.CIRCLE_CHECK} />}
> >
Yes Yes
</Button> </Button>

View File

@@ -0,0 +1,61 @@
import { Card, Skeleton } from "@noahspan/noahspan-components";
import LogbookCard from "../logbookCard/LogbookCard";
import { useEffect, useState } from "react";
import { useLogs } from "../../hooks/logs/UseLogs";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
const Flights = () => {
const [flights, setFlights] = useState<ILogbookEntry[]>([]);
const { logs, isLoading } = useLogs();
useEffect(() => {
console.log(logs)
const flights: ILogbookEntry[] | undefined = logs?.filter((log: ILogbookEntry) => {
if (log.tracks && log.tracks.length > 0) {
return log;
}
})
if (flights && flights.length > 0) {
setFlights(flights)
}
}, [logs])
return (
<div className='max-w-screen-lg mx-auto'>
<div className='prose mt-5 mb-5'>
<h1>Flights</h1>
</div>
{!isLoading &&
<div>
<LogbookCard logs={flights} mode='flights' />
</div>
}
{!isLoading && [...Array(6)].map((_element, index) => {
return (
<div className='mb-5'>
<Card
key={index}
>
<div className='p-5'>
<div className='mb-2'><Skeleton height='h-[60px]' width='w-[200px]' /></div>
<div className='mb-2'><Skeleton height='h-[30px]' width='w-[200px]' /></div>
<div className='mb-2'><Skeleton height='h-[300px]' width='w-[300px]' /></div>
{[...Array(4)].map((_element, index) => {
return (
<>
<div className='mb-2'><Skeleton height='h-[20px]' width='w-[300px]' /></div>
<div className='mb-2'><Skeleton height='h-[20px]' width='w-[300px]' /></div>
</>
)
})}
</div>
</Card>
</div>
)
})}
</div>
)
}
export default Flights;

View File

@@ -2,8 +2,11 @@ import { Alert } from "../../interfaces/Alert.interface";
export interface ILogFormState { export interface ILogFormState {
alert: Alert | undefined; alert: Alert | undefined;
experienceCollapseOpen: boolean;
instrumentCollapseOpen: boolean;
isDisabled: boolean; isDisabled: boolean;
isLoading: boolean; isLoading: boolean;
landingsCollapseOpen: boolean;
pilotOptions: { label: string; value: string }[]; pilotOptions: { label: string; value: string }[];
selectedPilotName: string; selectedPilotName: string;
} }

View File

@@ -3,15 +3,21 @@ import { ILogFormState } from './ILogFormState';
type Action = type Action =
| { type: 'SET_ALERT'; payload: Alert | undefined } | { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_EXPERIENCE_COLLAPSE_OPEN'; payload: boolean }
| { type: 'SET_INSTRUMENT_COLLAPSE_OPEN'; payload: boolean }
| { type: 'SET_IS_DISABLED'; payload: boolean } | { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean } | { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_LANDINGS_COLLAPSE_OPEN'; payload: boolean }
| { type: 'SET_PILOT_OPTIONS'; payload: { label: string; value: string }[] } | { type: 'SET_PILOT_OPTIONS'; payload: { label: string; value: string }[] }
| { type: 'SET_SELECTED_PILOT_NAME'; payload: string }; | { type: 'SET_SELECTED_PILOT_NAME'; payload: string };
export const initialState: ILogFormState = { export const initialState: ILogFormState = {
alert: undefined, alert: undefined,
experienceCollapseOpen: true,
instrumentCollapseOpen: false,
isDisabled: false, isDisabled: false,
isLoading: true, isLoading: true,
landingsCollapseOpen: true,
pilotOptions: [], pilotOptions: [],
selectedPilotName: '' selectedPilotName: ''
}; };
@@ -27,18 +33,36 @@ export const reducer = (
alert: action.payload alert: action.payload
}; };
} }
case 'SET_EXPERIENCE_COLLAPSE_OPEN': {
return {
...state,
experienceCollapseOpen: action.payload
}
}
case 'SET_IS_DISABLED': { case 'SET_IS_DISABLED': {
return { return {
...state, ...state,
isDisabled: action.payload isDisabled: action.payload
}; };
} }
case 'SET_INSTRUMENT_COLLAPSE_OPEN': {
return {
...state,
instrumentCollapseOpen: action.payload
}
}
case 'SET_IS_LOADING': { case 'SET_IS_LOADING': {
return { return {
...state, ...state,
isLoading: action.payload isLoading: action.payload
}; };
} }
case 'SET_LANDINGS_COLLAPSE_OPEN': {
return {
...state,
landingsCollapseOpen: action.payload
}
}
case 'SET_PILOT_OPTIONS': { case 'SET_PILOT_OPTIONS': {
return { return {
...state, ...state,

View File

@@ -2,8 +2,7 @@ import { useEffect, useState } from 'react';
import { LogTrackMapsProps } from './LogTrackMapsProps.interface'; import { LogTrackMapsProps } from './LogTrackMapsProps.interface';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios'; import { AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken'; import { useAuth } from 'react-oidc-context'
import { useIsAuthenticated } from '@azure/msal-react';
import { MapContainer, TileLayer } from 'react-leaflet'; import { MapContainer, TileLayer } from 'react-leaflet';
import ReactLeafletKml from 'react-leaflet-kml'; import ReactLeafletKml from 'react-leaflet-kml';
import 'swiper/css'; import 'swiper/css';
@@ -15,8 +14,7 @@ import 'leaflet/dist/leaflet.css';
const LogTrackMaps = ({ logId, tracks }: LogTrackMapsProps) => { const LogTrackMaps = ({ logId, tracks }: LogTrackMapsProps) => {
const [kmls, setKmls] = useState<any[]>([]) const [kmls, setKmls] = useState<any[]>([])
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken(); const auth = useAuth()
const isAuthenticated = useIsAuthenticated();
useEffect(() => { useEffect(() => {
console.log(tracks) console.log(tracks)
@@ -26,12 +24,13 @@ const LogTrackMaps = ({ logId, tracks }: LogTrackMapsProps) => {
for (const track of tracks) { for (const track of tracks) {
const trackUrlSplit = track.url.split('/') const trackUrlSplit = track.url.split('/')
const filename = trackUrlSplit[trackUrlSplit.length - 1]; const filename = trackUrlSplit[trackUrlSplit.length - 1];
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get( const response: AxiosResponse = await httpClient.get(
`api/tracks/${logId}/${filename}`, `api/tracks/${logId}/${filename}`,
config {
headers: {
Authorization: auth.user?.access_token
}
}
); );
const kml = new DOMParser().parseFromString(response.data, 'text/xml') const kml = new DOMParser().parseFromString(response.data, 'text/xml')

View File

@@ -1,27 +1,23 @@
import { useEffect, useReducer } from "react"; import { useEffect, useReducer } from "react";
import { Button, Drawer, Grid, Icon, IconButton, IconName, Spinner, TextField, theme, Typography, useMediaQuery } from "@noahspan/noahspan-components"; import { Button, Drawer, Icon, IconButton, IconName, Input, Loading } from "@noahspan/noahspan-components";
import { useHttpClient } from "../../hooks/httpClient/UseHttpClient"; import { useHttpClient } from "../../hooks/httpClient/UseHttpClient";
import { AxiosInstance, AxiosResponse } from "axios"; import { AxiosInstance, AxiosResponse } from "axios";
import { useAccessToken } from "../../hooks/accessToken/UseAcessToken";
import { LogTracksProps } from "./LogTracksProps.interface"; import { LogTracksProps } from "./LogTracksProps.interface";
import { FormMode } from "../../enums/formMode"; import { FormMode } from "../../enums/formMode";
import { useIsAuthenticated } from "@azure/msal-react";
import { ILogbookEntry } from "../logbook/ILogbookEntry"; import { ILogbookEntry } from "../logbook/ILogbookEntry";
import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog"; import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog";
import { initialState, reducer } from "./reducer"; import { initialState, reducer } from "./reducer";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps"; import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
import { useAuth } from "react-oidc-context";
const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracksProps) => { const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracksProps) => {
const [state, dispatch] = useReducer(reducer, initialState) const [state, dispatch] = useReducer(reducer, initialState)
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken(); const auth = useAuth();
const isAuthenticated = useIsAuthenticated();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const getConfig = async () => { const getConfig = async () => {
const config = isAuthenticated const config = auth.isAuthenticated
? { headers: { Authorization: await getAccessToken() } } ? { headers: { Authorization: auth.user?.access_token } }
: {}; : {};
return config return config
@@ -131,33 +127,28 @@ const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracks
return ( return (
<Drawer <Drawer
open={isDrawerOpen} open={isDrawerOpen}
anchor='right' position='right'
PaperProps={{ width='25%'
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
> >
<Grid container spacing={2}> <div>
<Grid size={11}> <div>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`}</Typography> <h4>{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`}</h4>
</Grid> </div>
<Grid display="flex" justifyContent="right" size={1}> <div>
<IconButton disabled={state.isLoading ? true : false} onClick={onCancel}> <IconButton disabled={state.isLoading ? true : false} onClick={onCancel}>
<Icon iconName={IconName.XMARK} /> <Icon iconName={IconName.XMARK} />
</IconButton> </IconButton>
</Grid> </div>
{mode === FormMode.EDIT && {mode === FormMode.EDIT &&
<> <>
{state.isLoading && {state.isLoading &&
<> <>
<Grid display="flex" justifyContent="center" size={12}> <div>
<Spinner /> <Loading size='xl' />
</Grid> </div>
<Grid display="flex" justifyContent="center" size={12}> <div>
Loading... Loading...
</Grid> </div>
</> </>
} }
{!state.isLoading && state.tracks.length > 0 && state.tracks.map((track, index) => { {!state.isLoading && state.tracks.length > 0 && state.tracks.map((track, index) => {
@@ -166,43 +157,40 @@ const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracks
return ( return (
<> <>
<Grid size={11}> <div>
<TextField disabled={true} fullWidth value={filename} /> <Input disabled={true} value={filename} />
</Grid> </div>
<Grid size={1}> <div>
<IconButton onClick={() => onDeleteTrack(filename, index)}><Icon iconName={IconName.TRASH} /></IconButton> <IconButton onClick={() => onDeleteTrack(filename, index)}><Icon iconName={IconName.TRASH} /></IconButton>
</Grid> </div>
</> </>
) )
})} })}
<Grid display='flex' gap={2} justifyContent='right' size={12}> <div>
<Button <Button
disabled={state.isLoading ? true : false} disabled={state.isLoading ? true : false}
startIcon={<Icon iconName={IconName.XMARK} />} startContent={<Icon iconName={IconName.XMARK} />}
variant="outlined"
onClick={onCancel} onClick={onCancel}
size="small" size='sm'
> >
Close Close
</Button> </Button>
{mode.toString() !== FormMode.VIEW && ( {mode.toString() !== FormMode.VIEW && (
<Button <Button
component='label'
disabled={state.isLoading ? true : false} disabled={state.isLoading ? true : false}
startIcon={<Icon iconName={IconName.UPLOAD} />} startContent={<Icon iconName={IconName.UPLOAD} />}
variant='contained'
> >
Upload Track Upload Track
{/* <input hidden onChange={handleFileUpload} type='file' /> */} {/* <input hidden onChange={handleFileUpload} type='file' /> */}
</Button> </Button>
)} )}
</Grid> </div>
</> </>
} }
{mode === FormMode.VIEW && {mode === FormMode.VIEW &&
<LogTrackMaps logId={selectedLogId!} tracks={state.tracks} /> <LogTrackMaps logId={selectedLogId!} tracks={state.tracks} />
} }
</Grid> </div>
{/* {state.isConfirmDialogOpen && ( {/* {state.isConfirmDialogOpen && (
<ConfirmationDialog <ConfirmationDialog
contentText="Are you sure you want to delete this track?" contentText="Are you sure you want to delete this track?"

View File

@@ -2,24 +2,17 @@ import { useEffect, useReducer } from 'react';
import LogForm from '../logForm/LogForm'; import LogForm from '../logForm/LogForm';
import { import {
Alert, Alert,
Box,
Button, Button,
ColumnDef, ColumnDef,
Grid,
Icon, Icon,
IconButton, IconButton,
IconName, IconName,
Spinner, Loading,
Table, Table
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { initialState, reducer } from './reducer'; import { initialState, reducer } from './reducer';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
import { authColumns, unauthColumns } from './columns'; import { authColumns, unauthColumns } from './columns';
import ActionMenu from '../actionMenu/ActionMenu'; import ActionMenu from '../actionMenu/ActionMenu';
@@ -27,13 +20,12 @@ import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import { ILogbookEntry } from './ILogbookEntry'; import { ILogbookEntry } from './ILogbookEntry';
import LogbookCard from '../logbookCard/LogbookCard'; import LogbookCard from '../logbookCard/LogbookCard';
import LogTracks from '../logTracks/LogTracks'; import LogTracks from '../logTracks/LogTracks';
import { useOidc } from '../../auth/oidcConfig';
const Logbook: React.FC<unknown> = () => { const Logbook: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated(); const { isUserLoggedIn } = useOidc()
const { getAccessToken } = useAccessToken();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const actionsColumn: ColumnDef<ILogbookEntry> = { const actionsColumn: ColumnDef<ILogbookEntry> = {
header: 'Actions', header: 'Actions',
meta: { meta: {
@@ -65,10 +57,7 @@ const Logbook: React.FC<unknown> = () => {
try { try {
dispatch({ type: 'SET_IS_LOADING', payload: true }); dispatch({ type: 'SET_IS_LOADING', payload: true });
const config = isAuthenticated const response: AxiosResponse = await httpClient.get(`api/logs`);
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(`api/logs`, config);
const entries: ILogbookEntry[] = response.data; const entries: ILogbookEntry[] = response.data;
console.log(entries) console.log(entries)
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
@@ -162,12 +151,7 @@ const Logbook: React.FC<unknown> = () => {
try { try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken(); await httpClient.delete(`api/logs/${state.selectedLogId}`);
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/logs/${state.selectedLogId}`, config);
dispatch({ dispatch({
type: 'SET_DELETE', type: 'SET_DELETE',
@@ -196,7 +180,7 @@ const Logbook: React.FC<unknown> = () => {
useEffect(() => { useEffect(() => {
let newColumns: ColumnDef<ILogbookEntry>[]; let newColumns: ColumnDef<ILogbookEntry>[];
if (isAuthenticated) { if (isUserLoggedIn) {
newColumns = [...authColumns]; newColumns = [...authColumns];
} else { } else {
newColumns = [...unauthColumns]; newColumns = [...unauthColumns];
@@ -216,7 +200,7 @@ const Logbook: React.FC<unknown> = () => {
} }
dispatch({ type: 'SET_COLUMNS', payload: newColumns }) dispatch({ type: 'SET_COLUMNS', payload: newColumns })
}, [isAuthenticated]) }, [isUserLoggedIn])
useEffect(() => { useEffect(() => {
if (!state.isFormOpen) { if (!state.isFormOpen) {
@@ -225,57 +209,56 @@ const Logbook: React.FC<unknown> = () => {
}, [state.isFormOpen, state.isTracksOpen]); }, [state.isFormOpen, state.isTracksOpen]);
return ( return (
<Box sx={{ margin: '20px' }}> <>
<Grid container spacing={2}> <div className='mr-10 ml-10 grid grid-cols-12'>
<Grid size={isMedium ? 11 : 6}> <div className='prose max-w-none col-span-10 mt-5 mb-5'>
<Typography variant="h4">Logbook</Typography> <h1>Logbook</h1>
</Grid> </div>
<Grid display="flex" justifyContent="right" size={isMedium ? 1 : 6}> <div className='col-span-2 justify-self-end self-center'>
{isAuthenticated && {isUserLoggedIn &&
<Button <Button
color='primary'
onClick={() => onOpenCloseLogForm(FormMode.ADD)} onClick={() => onOpenCloseLogForm(FormMode.ADD)}
startIcon={<Icon iconName={IconName.PLUS} />} startContent={<Icon iconName={IconName.PLUS} />}
variant="contained"
data-testid="pilot-add-button" data-testid="pilot-add-button"
> >
Add Entry Add Entry
</Button> </Button>
} }
</Grid> </div>
{!state.isLoading && state.alert && ( {!state.isLoading && state.alert && (
<Grid display="flex" justifyContent="center" size={12}> <div>
<Alert <Alert
onClose={() => onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined }) dispatch({ type: 'SET_ALERT', payload: undefined })
} }
severity={state.alert.severity} severity={state.alert.severity}
sx={{ width: '100%' }}
> >
{state.alert.message} {state.alert.message}
</Alert> </Alert>
</Grid> </div>
)} )}
{!state.isLoading && ( {!state.isLoading && (
<Grid size={12}> <div>
{isMedium && state.columns && state.columns.length > 0 && state.entries.length > 0 && ( {state.columns && state.columns.length > 0 && state.entries.length > 0 && (
<Table columns={state.columns} data={state.entries} /> <Table columns={state.columns} data={state.entries} />
)} )}
{!isMedium && state.entries.length > 0 && {state.entries.length > 0 &&
<LogbookCard logs={state.entries} onDelete={onDeleteLog} mode='logbook' onOpenCloseForm={onOpenCloseLogForm} /> <LogbookCard logs={state.entries} onDelete={onDeleteLog} mode='logbook' onOpenCloseForm={onOpenCloseLogForm} />
} }
</Grid> </div>
)} )}
{state.isLoading && !state.alert && ( {state.isLoading && !state.alert && (
<> <>
<Grid display="flex" justifyContent="center" size={12}> <div>
<Spinner /> <Loading size='xl' />
</Grid> </div>
<Grid display="flex" justifyContent="center" size={12}> <div>
Loading... Loading...
</Grid> </div>
</> </>
)} )}
</Grid> </div>
{state.isFormOpen && ( {state.isFormOpen && (
<LogForm <LogForm
logId={state.selectedLogId} logId={state.selectedLogId}
@@ -302,7 +285,7 @@ const Logbook: React.FC<unknown> = () => {
selectedLogId={state.selectedLogId} selectedLogId={state.selectedLogId}
/> />
} }
</Box> </>
); );
}; };

View File

@@ -0,0 +1,93 @@
import { Card, CardBody, CardContent, CardHeader } from "@noahspan/noahspan-components";
import { LogbookCardProps } from "./LogbookCardProps.interface";
import ActionMenu from "../actionMenu/ActionMenu";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps) => {
return (
<div>
{logs.map((log) => {
const date = new Date(log.date);
const formattedDate: string = `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
return (
<div>
<Card key={log.id}>
<CardBody>
<CardHeader><ActionMenu id={log.id} onDelete={onDelete!} onOpenCloseForm={onOpenCloseForm!} /></CardHeader>
<CardContent>
<div>
{mode === 'flights' && log.tracks && log.tracks.length > 0 &&
<div>
<LogTrackMaps
logId={log.id}
tracks={log.tracks}
/>
</div>
}
<div>
<span>Aircraft Make and Model</span>
</div>
<div>
<span>{log.aircraftMakeModel}</span>
</div>
<div>
<span>Route From</span>
</div>
<div>
<span>{log.routeFrom}</span>
</div>
<div>
<span>Route To</span>
</div>
<div>
<span>{log.routeTo}</span>
</div>
<div>
<span>Duration Of Flight</span>
</div>
<div>
<span>{log.durationOfFlight}</span>
</div>
{mode === 'logbook' && log.tracks && log.tracks.length > 0 &&
<>
<div>
<span>Tracks</span>
</div>
{log.tracks.map((track: { id: string; order: number; url: string}) => {
const trackSplit = track.url.split('/')
const filename = trackSplit[trackSplit.length - 1];
return (
<div>
<span>{filename}</span>
</div>
)
})}
</>
}
{log.notes &&
<>
<div>
<span>Notes</span>
</div>
<div>
<span>{log.notes}</span>
</div>
</>
}
</div>
</CardContent>
</CardBody>
</Card>
</div>
)
})}
</div>
)
}
export default LogbookCard;

View File

@@ -0,0 +1,26 @@
import { Card, CardActions, CardBody, CardHeader} from "@noahspan/noahspan-components"
import { PilotCardProps } from "./PilotCardProps.interface"
import ActionMenu from "../actionMenu/ActionMenu"
const PilotCard = ({ pilots, onDelete, onOpenCloseForm }: PilotCardProps) => {
return (
<div>
{pilots.map((pilot) => {
return (
<div>
<Card key={pilot.id}>
<CardBody>
<CardHeader>{pilot.name}</CardHeader>
<CardActions>
<ActionMenu id={pilot.id} onDelete={onDelete} onOpenCloseForm={onOpenCloseForm} />
</CardActions>
</CardBody>
</Card>
</div>
)
})}
</div>
)
}
export default PilotCard;

View File

@@ -3,27 +3,24 @@ import { useForm, Controller, FormProvider } from 'react-hook-form';
import { import {
Button, Button,
Drawer, Drawer,
Grid,
Icon, Icon,
IconButton, IconButton,
IconName, IconName,
Input,
PeoplePicker, PeoplePicker,
StateSelect, StateSelect
TextField,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { IPilotFormProps } from './IPilotFormProps'; import { IPilotFormProps } from './IPilotFormProps';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
import { Person } from '@microsoft/microsoft-graph-types'; import { Person } from '@microsoft/microsoft-graph-types';
import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates'; import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates';
import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements'; import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements';
import PilotFormMedical from '../pilotFormMedical/PilotFormMedical'; import PilotFormMedical from '../pilotFormMedical/PilotFormMedical';
import { useOidc } from '../../auth/oidcConfig';
import { getOidc } from '../../auth/oidcConfig';
// import httpClient from '../../httpClient/httpClient';
const PilotForm: React.FC<IPilotFormProps> = ({ const PilotForm: React.FC<IPilotFormProps> = ({
pilotId, pilotId,
@@ -31,7 +28,7 @@ const PilotForm: React.FC<IPilotFormProps> = ({
mode, mode,
onOpenClose onOpenClose
}: IPilotFormProps) => { }: IPilotFormProps) => {
const httpClient: AxiosInstance = useHttpClient(); const [peoplePickerValue, setPeoplePickerValue] = useState<string>('');
const [peoplePickerResults, setPeoplePickerResults] = useState<any[]>([]); const [peoplePickerResults, setPeoplePickerResults] = useState<any[]>([]);
const [isPeoplePickerLoading, setIsPeoplePickerLoading] = const [isPeoplePickerLoading, setIsPeoplePickerLoading] =
useState<boolean>(false); useState<boolean>(false);
@@ -39,8 +36,7 @@ const PilotForm: React.FC<IPilotFormProps> = ({
displayName: '' displayName: ''
}); });
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const { getAccessToken } = useAccessToken(); const { isUserLoggedIn } = useOidc();
const isAuthenticated = useIsAuthenticated();
const defaultValues = { const defaultValues = {
name: '', name: '',
address: '', address: '',
@@ -55,27 +51,26 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}); });
const [isDisabled, setIsDisabled] = useState<boolean>(false); const [isDisabled, setIsDisabled] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false); const [isError, setIsError] = useState<boolean>(false);
const isMedium = useMediaQuery(theme.breakpoints.up('md')); const httpClient = useHttpClient();
const onPeoplePickerSearch = async ( const onPeoplePickerSearch = async (
_event: React.SyntheticEvent,
value: string value: string
) => { ) => {
setIsPeoplePickerLoading(true); setIsPeoplePickerLoading(true);
setPeoplePickerValue(value)
try { try {
if (value !== '') { if (value !== '') {
const searchString: string = value; const searchString: string = value;
const accessToken: string = await getAccessToken(); const oidc = await getOidc();
const response: AxiosResponse = await httpClient.get( const response: AxiosResponse = await httpClient.get(
`api/user/search?search=${searchString}`, `api/msgraph/search?search=${searchString}`, {
{
headers: { headers: {
Authorization: accessToken Authorization: oidc.isUserLoggedIn ? `Bearer ${(await oidc.getTokens()).accessToken}` : ''
} }
} }
); );
console.log(response)
setPeoplePickerResults(response.data); setPeoplePickerResults(response.data);
} else { } else {
setPeoplePickerResults([]); setPeoplePickerResults([]);
@@ -87,13 +82,10 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
}; };
const onPeoplePickerSelectionChange = ( const onPersonSelected = (person: Person) => {
_event: React.SyntheticEvent, methods.setValue('name', person.displayName!.toString());
value: Person, setPeoplePickerValue(person.displayName!);
_reason: string setPeoplePickerResults([])
) => {
methods.setValue('name', value.displayName!.toString());
setSelectedPerson(value);
}; };
const onCancel = () => { const onCancel = () => {
@@ -107,20 +99,10 @@ const PilotForm: React.FC<IPilotFormProps> = ({
try { try {
setIsLoading(true); setIsLoading(true);
const accessToken: string = await getAccessToken();
if (!pilotId) { if (!pilotId) {
await httpClient.post(`api/pilots`, data, { await httpClient.post(`api/pilots`, data);
headers: {
Authorization: accessToken
}
});
} else { } else {
await httpClient.put(`api/pilots/pilot/${pilotId}`, data, { await httpClient.put(`api/pilots/pilot/${pilotId}`, data)
headers: {
Authorization: accessToken
}
})
} }
methods.reset(defaultValues) methods.reset(defaultValues)
@@ -146,12 +128,8 @@ const PilotForm: React.FC<IPilotFormProps> = ({
try { try {
setIsLoading(true); setIsLoading(true);
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get( const response: AxiosResponse = await httpClient.get(
`api/pilots/${pilotId}`, `api/pilots/${pilotId}`
config
); );
const pilot = response.data; const pilot = response.data;
@@ -174,54 +152,49 @@ const PilotForm: React.FC<IPilotFormProps> = ({
return ( return (
<Drawer <Drawer
open={isDrawerOpen} open={isDrawerOpen}
anchor="right" position='right'
data-testid="pilot-drawer" data-testid="pilot-drawer"
PaperProps={{ width='50%'
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
> >
<FormProvider {...methods}> <FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}> <form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<Grid container spacing={2}> <div className='grid grid-cols-12 gap-3'>
<Grid size={11}> <div className='col-span-10 self-center'>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}</Typography> <h2 style={{ margin: 0 }}>{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}</h2>
</Grid> </div>
<Grid display="flex" justifyContent="right" size={1}> <div className='col-span-2 justify-self-end self-center'>
<IconButton onClick={onCancel}> <IconButton onClick={onCancel}>
<Icon iconName={IconName.XMARK} /> <Icon iconName={IconName.XMARK} />
</IconButton> </IconButton>
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">Name *</Typography> <h6>Name *</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<PeoplePicker <PeoplePicker
disabled={isDisabled} disabled={isDisabled}
loading={isPeoplePickerLoading} // loading={isPeoplePickerLoading}
onInputChanged={onPeoplePickerSearch} onInputChanged={onPeoplePickerSearch}
onSelectionChanged={onPeoplePickerSelectionChange} onPersonSelected={onPersonSelected}
options={peoplePickerResults} people={peoplePickerResults}
value={selectedPerson} value={peoplePickerValue}
width='w-full'
/> />
</Grid> </div>
{isAuthenticated && {isUserLoggedIn &&
<> <>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">Address *</Typography> <h6>Address *</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="address" name="address"
control={methods.control} control={methods.control}
rules={{ required: 'An address is required' }} rules={{ required: 'An address is required' }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
error={methods.formState.errors.address ? true : false} color={methods.formState.errors.address ? 'error' : undefined}
fullWidth
helperText={ helperText={
methods.formState.errors.address methods.formState.errors.address
? methods.formState.errors.address.message ? methods.formState.errors.address.message
@@ -229,23 +202,23 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
width='w-full'
/> />
)} )}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">City *</Typography> <h6>City *</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="city" name="city"
control={methods.control} control={methods.control}
rules={{ required: 'A city is required' }} rules={{ required: 'A city is required' }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
error={methods.formState.errors.city ? true : false} color={methods.formState.errors.city ? 'error' : undefined}
fullWidth
helperText={ helperText={
methods.formState.errors.city methods.formState.errors.city
? methods.formState.errors.city.message ? methods.formState.errors.city.message
@@ -253,14 +226,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
width='w-full'
/> />
)} )}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">State *</Typography> <h6>State *</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="state" name="state"
control={methods.control} control={methods.control}
@@ -269,7 +243,6 @@ const PilotForm: React.FC<IPilotFormProps> = ({
<StateSelect <StateSelect
disabled={isDisabled} disabled={isDisabled}
// error={methods.formState.errors.state ? true : false} // error={methods.formState.errors.state ? true : false}
fullWidth
// helperText={ // helperText={
// methods.formState.errors.state // methods.formState.errors.state
// ? methods.formState.errors.state.message?.toString() // ? methods.formState.errors.state.message?.toString()
@@ -277,25 +250,24 @@ const PilotForm: React.FC<IPilotFormProps> = ({
// } // }
onChange={onChange} onChange={onChange}
value={value} value={value}
variant="outlined" width='w-full'
data-testid="pilot-form-state-dropdown" data-testid="pilot-form-state-dropdown"
/> />
)} )}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">Postal Code *</Typography> <h6>Postal Code *</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="postalCode" name="postalCode"
control={methods.control} control={methods.control}
rules={{ required: 'A postal code is required' }} rules={{ required: 'A postal code is required' }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
error={methods.formState.errors.postalCode ? true : false} color={methods.formState.errors.postalCode ? 'error' : undefined}
fullWidth
helperText={ helperText={
methods.formState.errors.postalCode methods.formState.errors.postalCode
? methods.formState.errors.postalCode.message ? methods.formState.errors.postalCode.message
@@ -303,14 +275,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
width='w-full'
/> />
)} )}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">Email</Typography> <h6>Email</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="email" name="email"
control={methods.control} control={methods.control}
@@ -321,10 +294,9 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
}} }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
fullWidth color={methods.formState.errors.email ? 'error' : undefined}
error={methods.formState.errors.email ? true : false}
helperText={ helperText={
methods.formState.errors.email methods.formState.errors.email
? methods.formState.errors.email.message ? methods.formState.errors.email.message
@@ -332,14 +304,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
width='w-full'
/> />
)} )}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div className='col-span-3 self-center'>
<Typography variant="h6">Phone Number</Typography> <h6>Phone Number</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div className='col-span-9'>
<Controller <Controller
name="phone" name="phone"
control={methods.control} control={methods.control}
@@ -350,10 +323,9 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
}} }}
render={({ field: { onChange, value } }) => ( render={({ field: { onChange, value } }) => (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
fullWidth color={methods.formState.errors.phone ? 'error' : undefined}
error={methods.formState.errors.phone ? true : false}
helperText={ helperText={
methods.formState.errors.phone methods.formState.errors.phone
? methods.formState.errors.phone.message ? methods.formState.errors.phone.message
@@ -361,10 +333,11 @@ const PilotForm: React.FC<IPilotFormProps> = ({
} }
onChange={onChange} onChange={onChange}
value={value} value={value}
width='w-full'
/> />
)} )}
/> />
</Grid> </div>
</> </>
} }
{/* {isAuthenticated && {/* {isAuthenticated &&
@@ -380,35 +353,33 @@ const PilotForm: React.FC<IPilotFormProps> = ({
<Grid size={12}> <Grid size={12}>
<PilotFormEndorsements isDisabled={isDisabled} mode={mode} /> <PilotFormEndorsements isDisabled={isDisabled} mode={mode} />
</Grid> */} </Grid> */}
<Grid display="flex" gap={2} justifyContent="right" size={12}> <div className='col-span-12 justify-self-end self-center'>
<Button <Button
disabled={ disabled={
isDisabled && mode.toString() !== FormMode.VIEW isDisabled && mode.toString() !== FormMode.VIEW
? isDisabled ? isDisabled
: false : false
} }
startIcon={<Icon iconName={IconName.XMARK} />} startContent={<Icon iconName={IconName.XMARK} />}
variant="outlined"
onClick={onCancel} onClick={onCancel}
outline={true}
data-testid="pilot-cancel-button" data-testid="pilot-cancel-button"
size="small"
> >
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'} {mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</Button> </Button>
{mode.toString() !== FormMode.VIEW && ( {mode.toString() !== FormMode.VIEW && (
<Button <Button
color='primary'
disabled={isDisabled} disabled={isDisabled}
startIcon={<Icon iconName={IconName.SAVE} />} startContent={<Icon iconName={IconName.SAVE} />}
size="small"
type="submit" type="submit"
variant="contained"
data-testid="pilot-save-button" data-testid="pilot-save-button"
> >
Save Save
</Button> </Button>
)} )}
</Grid> </div>
</Grid> </div>
</form> </form>
</FormProvider> </FormProvider>
</Drawer> </Drawer>

View File

@@ -2,13 +2,11 @@ import { PilotFormCertificatesProps } from './PilotFormCertificatesProps.interfa
import { import {
Button, Button,
DatePicker, DatePicker,
Grid,
Icon, Icon,
IconButton, IconButton,
IconName, IconName,
Select, Input,
TextField, Select
Typography,
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
@@ -28,32 +26,29 @@ const PilotFormCertificates = ({
}); });
return ( return (
<Grid <div>
container
spacing={2}
>
{fields.length > 0 || mode !== FormMode.VIEW && {fields.length > 0 || mode !== FormMode.VIEW &&
<Grid size={12}> <div>
<Typography variant="h5">Certificates</Typography> <h5>Certificates</h5>
</Grid> </div>
} }
{fields.length > 0 && ( {fields.length > 0 && (
<> <>
<Grid size={4}> <div>
<Typography variant="h6">Type</Typography> <h6>Type</h6>
</Grid> </div>
<Grid size={4}> <div>
<Typography variant="h6">Number</Typography> <h6>Number</h6>
</Grid> </div>
<Grid size={3}> <div>
<Typography variant="h6">Date of Issue</Typography> <h6>Date of Issue</h6>
</Grid> </div>
<Grid size={1}> <div>
</Grid> </div>
{fields.map((field, index) => { {fields.map((field, index) => {
return ( return (
<> <>
<Grid size={4}> <div>
<Controller <Controller
name={`certificates.${index}.type`} name={`certificates.${index}.type`}
control={control} control={control}
@@ -61,7 +56,6 @@ const PilotFormCertificates = ({
return ( return (
<Select <Select
disabled={isDisabled} disabled={isDisabled}
fullWidth
onChange={onChange} onChange={onChange}
options={ options={
[ [
@@ -92,24 +86,23 @@ const PilotFormCertificates = ({
); );
}} }}
/> />
</Grid> </div>
<Grid size={4}> <div>
<Controller <Controller
name={`certificates.${index}.number`} name={`certificates.${index}.number`}
control={control} control={control}
render={({ field: { onChange, value } }) => { render={({ field: { onChange, value } }) => {
return ( return (
<TextField <Input
disabled={isDisabled} disabled={isDisabled}
fullWidth
onChange={onChange} onChange={onChange}
value={value} value={value}
/> />
) )
}} }}
/> />
</Grid> </div>
<Grid size={3}> <div>
<Controller <Controller
name={`certificates.${index}.dateOfIssue`} name={`certificates.${index}.dateOfIssue`}
control={control} control={control}
@@ -123,25 +116,22 @@ const PilotFormCertificates = ({
); );
}} }}
/> />
</Grid> </div>
<Grid size={1}> <div>
<IconButton <IconButton
disabled={isDisabled} disabled={isDisabled}
onClick={() => remove(index)} onClick={() => remove(index)}
sx={{
marginTop: '-5px'
}}
> >
<Icon iconName={IconName.TRASH} size='sm' /> <Icon iconName={IconName.TRASH} size='sm' />
</IconButton> </IconButton>
</Grid> </div>
</> </>
); );
})} })}
</> </>
)} )}
{!isDisabled && {!isDisabled &&
<Grid display="flex" justifyContent="right" size={12}> <div>
<Button <Button
onClick={() => { onClick={() => {
append({ append({
@@ -150,14 +140,13 @@ const PilotFormCertificates = ({
dateOfIssue: null dateOfIssue: null
}); });
}} }}
startIcon={<Icon iconName={IconName.PLUS} />} startContent={<Icon iconName={IconName.PLUS} />}
variant="contained"
> >
Add Certificate Add Certificate
</Button> </Button>
</Grid> </div>
} }
</Grid> </div>
); );
}; };

View File

@@ -2,12 +2,10 @@ import { PilotFormEndorsementsProps } from './PilotFormEndorsementsProps.interfa
import { import {
Button, Button,
DatePicker, DatePicker,
Grid,
Icon, Icon,
IconButton, IconButton,
IconName, IconName,
Select, Select
Typography
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
@@ -28,28 +26,25 @@ const PilotFormEndorsements = ({
}); });
return ( return (
<Grid <div>
container
spacing={2}
>
{fields.length > 0 || mode !== FormMode.VIEW && {fields.length > 0 || mode !== FormMode.VIEW &&
<Grid size={12}> <div>
<Typography variant="h5">Endorsements</Typography> <h5>Endorsements</h5>
</Grid> </div>
} }
{fields.length > 0 && ( {fields.length > 0 && (
<> <>
<Grid size={8}> <div>
<Typography variant="h6">Type</Typography> <h6>Type</h6>
</Grid> </div>
<Grid size={3}> <div>
<Typography variant="h6">Date of Issue</Typography> <h6>Date of Issue</h6>
</Grid> </div>
<Grid size={1}></Grid> <div></div>
{fields.map((field, index) => { {fields.map((field, index) => {
return ( return (
<> <>
<Grid size={8}> <div>
<Controller <Controller
name={`endorsements.${index}.type`} name={`endorsements.${index}.type`}
control={control} control={control}
@@ -57,7 +52,6 @@ const PilotFormEndorsements = ({
return ( return (
<Select <Select
disabled={isDisabled} disabled={isDisabled}
fullWidth
onChange={onChange} onChange={onChange}
options={ options={
[ [
@@ -84,8 +78,8 @@ const PilotFormEndorsements = ({
); );
}} }}
/> />
</Grid> </div>
<Grid size={3}> <div>
<Controller <Controller
name={`endorsements.${index}.dateOfIssue`} name={`endorsements.${index}.dateOfIssue`}
control={control} control={control}
@@ -99,25 +93,22 @@ const PilotFormEndorsements = ({
); );
}} }}
/> />
</Grid> </div>
<Grid size={1}> <div>
<IconButton <IconButton
disabled={isDisabled} disabled={isDisabled}
onClick={() => remove(index)} onClick={() => remove(index)}
sx={{
marginTop: '-5px'
}}
> >
<Icon iconName={IconName.TRASH} size="sm" /> <Icon iconName={IconName.TRASH} size="sm" />
</IconButton> </IconButton>
</Grid> </div>
</> </>
); );
})} })}
</> </>
)} )}
{!isDisabled && {!isDisabled &&
<Grid display="flex" justifyContent="right" size={12}> <div>
<Button <Button
onClick={() => { onClick={() => {
append({ append({
@@ -125,14 +116,13 @@ const PilotFormEndorsements = ({
dateOfIssue: null dateOfIssue: null
}); });
}} }}
startIcon={<Icon iconName={IconName.PLUS} />} startContent={<Icon iconName={IconName.PLUS} />}
variant="contained"
> >
Add Endorsement Add Endorsement
</Button> </Button>
</Grid> </div>
} }
</Grid> </div>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { DatePicker, Grid, Select, theme, Typography, useMediaQuery } from '@noahspan/noahspan-components'; import { DatePicker, Select } from '@noahspan/noahspan-components';
import { Controller, useFormContext } from "react-hook-form" import { Controller, useFormContext } from "react-hook-form"
import { PilotFormMedicalProps } from "./PilotFormMedicalProps.interface"; import { PilotFormMedicalProps } from "./PilotFormMedicalProps.interface";
@@ -8,17 +8,16 @@ const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
formState: { errors }, formState: { errors },
setValue setValue
} = useFormContext(); } = useFormContext();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
return ( return (
<Grid container spacing={2}> <div>
<Grid size={12}> <div>
<Typography variant="h5">Medical</Typography> <h5>Medical</h5>
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div>
<Typography variant="h6">Class</Typography> <h6>Class</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div>
<Controller <Controller
name="medicalClass" name="medicalClass"
control={control} control={control}
@@ -26,7 +25,6 @@ const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
return ( return (
<Select <Select
disabled={isDisabled} disabled={isDisabled}
fullWidth
onChange={onChange} onChange={onChange}
options={ options={
[ [
@@ -53,11 +51,11 @@ const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
); );
}} }}
/> />
</Grid> </div>
<Grid size={isMedium ? 3 : 12}> <div>
<Typography variant="h6">Expiration</Typography> <h6>Expiration</h6>
</Grid> </div>
<Grid size={isMedium ? 9 : 12}> <div>
<Controller <Controller
name="medicalExpiration" name="medicalExpiration"
control={control} control={control}
@@ -71,8 +69,8 @@ const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
); );
}} }}
/> />
</Grid> </div>
</Grid> </div>
) )
} }

View File

@@ -2,34 +2,29 @@ import { useEffect, useReducer, useState } from 'react';
import PilotForm from '../pilotForm/PilotForm'; import PilotForm from '../pilotForm/PilotForm';
import { import {
Alert, Alert,
Box,
Button, Button,
ColumnDef, ColumnDef,
Grid,
Icon, Icon,
IconName, IconName,
Table, Table,
theme,
Typography,
useMediaQuery
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
import { Pilot } from './Pilot.interface'; import { Pilot } from './Pilot.interface';
import { initialState, reducer } from './reducer'; import { initialState, reducer } from './reducer';
import ActionMenu from '../actionMenu/ActionMenu'; import ActionMenu from '../actionMenu/ActionMenu';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog'; import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import PilotCard from '../pilotCard/PilotCard'; import PilotCard from '../pilotCard/PilotCard';
import { getOidc, useOidc } from '../../auth/oidcConfig';
import { useUserRole } from '../../hooks/userRole/UseUserRole';
import { UserRole } from '../../enums/userRole';
const Pilots: React.FC<unknown> = () => { const Pilots: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated(); const { isUserLoggedIn, decodedIdToken } = useOidc();
const { getAccessToken } = useAccessToken(); const userRole = useUserRole();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const getPilots = async () => { const getPilots = async () => {
try { try {
@@ -98,12 +93,7 @@ const Pilots: React.FC<unknown> = () => {
try { try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true }); dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken(); await httpClient.delete(`api/pilots/pilot/${state.selectedPilotId}`);
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/pilots/pilot/${state.selectedPilotId}`, config);
dispatch({ dispatch({
type: 'SET_DELETE', type: 'SET_DELETE',
@@ -154,45 +144,46 @@ const Pilots: React.FC<unknown> = () => {
}, [state.isFormOpen]); }, [state.isFormOpen]);
return ( return (
<Box sx={{ margin: '20px' }}> <>
<Grid container spacing={2}> <div className='mr-10 ml-10 grid grid-cols-12'>
<Grid size={isMedium ? 11 : 6}> <div className='prose max-w-none col-span-10 mt-5 mb-5' >
<Typography variant="h4">Pilots</Typography> <h1>Pilots</h1>
</Grid> </div>
<Grid display="flex" justifyContent="right" size={isMedium ? 1 : 6}> <div className='col-span-2 justify-self-end self-center'>
{isAuthenticated && {userRole && userRole !== UserRole.READ &&
<Button <Button
color='primary'
onClick={() => onOpenClosePilotForm(FormMode.ADD)} onClick={() => onOpenClosePilotForm(FormMode.ADD)}
startIcon={<Icon iconName={IconName.PLUS} />} startContent={<Icon iconName={IconName.PLUS} />}
variant="contained"
data-testid="pilot-add-button" data-testid="pilot-add-button"
> >
Add Pilot Add Pilot
</Button> </Button>
} }
</Grid> </div>
{!state.isLoading && state.alert && ( {!state.isLoading && state.alert && (
<Grid display="flex" justifyContent="center" size={12}> <div>
<Alert <Alert
onClose={() => onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined }) dispatch({ type: 'SET_ALERT', payload: undefined })
} }
severity={state.alert.severity} severity={state.alert.severity}
sx={{ width: '100%' }}
> >
{state.alert.message} {state.alert.message}
</Alert> </Alert>
</Grid> </div>
)} )}
<Grid size={12}> <div className='col-span-12'>
{isMedium && state.pilots.length > 0 && {state.pilots.length > 0 &&
<Table columns={columns} data={state.pilots} /> <Table columns={columns} data={state.pilots} />
} }
{!isMedium && state.pilots.length > 0 && {state.pilots.length > 0 &&
<div className='lg:hidden'>
<PilotCard pilots={state.pilots} onDelete={onDeleteEntry} onOpenCloseForm={onOpenClosePilotForm} /> <PilotCard pilots={state.pilots} onDelete={onDeleteEntry} onOpenCloseForm={onOpenClosePilotForm} />
</div>
} }
</Grid> </div>
</Grid> </div>
{state.isFormOpen && ( {state.isFormOpen && (
<PilotForm <PilotForm
isDrawerOpen={state.isFormOpen} isDrawerOpen={state.isFormOpen}
@@ -211,8 +202,9 @@ const Pilots: React.FC<unknown> = () => {
title="Confirm Delete" title="Confirm Delete"
/> />
)} )}
</Box> </>
); );
}; };
export default Pilots; export default Pilots;

View File

@@ -2,22 +2,15 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAppContext } from '../../hooks/appContext/UseAppContext'; import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { import {
Avatar, Dropdown,
Button,
Icon, Icon,
IconButton,
IconName, IconName,
Menu,
MenuItem,
Navbar, Navbar,
Spinner,
Typography
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios'; import { AxiosInstance, AxiosResponse } from 'axios';
import { User } from '@microsoft/microsoft-graph-types'; import { User } from '@microsoft/microsoft-graph-types';
import { getOidc, useOidc } from '../../auth/oidcConfig';
const SiteNav = () => { const SiteNav = () => {
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@@ -25,9 +18,7 @@ const SiteNav = () => {
const [pages, setPages] = useState<{ name: string; url: string; }[]>([]); const [pages, setPages] = useState<{ name: string; url: string; }[]>([]);
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const appContext = useAppContext(); const appContext = useAppContext();
const isAuthenticated = useIsAuthenticated(); const { isUserLoggedIn, login, logout } = useOidc()
const { getAccessToken } = useAccessToken();
const { inProgress, instance } = useMsal();
const navigate = useNavigate(); const navigate = useNavigate();
const getPages = () => { const getPages = () => {
const pages = [ const pages = [
@@ -48,19 +39,23 @@ const SiteNav = () => {
setPages(pages) setPages(pages)
}; };
const handleSignIn = () => { const handleSignIn = () => {
instance.loginRedirect({ // auth.signinRedirect();
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`] // auth.signinRedirect({
}); // scope: `api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`
// })
login();
// console.log(auth.user?.access_token)
}; };
const handleSignOut = () => { const handleSignOut = () => {
instance.logoutRedirect(); // auth.signoutRedirect();
logout()
}; };
const getUserProfile = async (accessToken: string): Promise<User> => { const getUserProfile = async (): Promise<User> => {
try { try {
const response: AxiosResponse = await httpClient.get(`api/user/profile`, { const response: AxiosResponse = await httpClient.get(`api/user/profile`, {
headers: { // headers: {
Authorization: accessToken // Authorization: `Bearer ${accessToken}`
} // }
}); });
const userProfile: User = response.data; const userProfile: User = response.data;
@@ -69,12 +64,12 @@ const SiteNav = () => {
throw new Error(); throw new Error();
} }
}; };
const getUserPhoto = async (accessToken: string): Promise<string> => { const getUserPhoto = async (): Promise<string> => {
try { try {
const response: AxiosResponse = await httpClient.get(`api/user/photo`, { const response: AxiosResponse = await httpClient.get(`api/user/photo`, {
headers: { // headers: {
Authorization: accessToken // Authorization: accessToken
}, // },
responseType: 'arraybuffer' responseType: 'arraybuffer'
}); });
const arrayBufferView = new Uint8Array(response.data); const arrayBufferView = new Uint8Array(response.data);
@@ -92,12 +87,12 @@ const SiteNav = () => {
const Settings = () => { const Settings = () => {
return ( return (
<MenuItem onClick={handleSignOut}> <div>
<Icon iconName={IconName.SIGN_OUT} /> <Icon iconName={IconName.SIGN_OUT} />
<Typography sx={{ marginLeft: '10px', textAlign: 'center' }}> <span>
Sign Out Sign Out
</Typography> </span>
</MenuItem> </div>
); );
}; };
@@ -106,16 +101,21 @@ const SiteNav = () => {
try { try {
setLoading(true); setLoading(true);
const accessToken: string = await getAccessToken(); // const userProfile = await getUserProfile();
const userProfile = await getUserProfile(accessToken); // const userPhoto = await getUserPhoto();
const userPhoto = await getUserPhoto(accessToken);
setUserPhoto(userPhoto); // setUserPhoto(userPhoto);
// appContext.dispatch({
// type: 'SET_USER_PROFILE',
// payload: userProfile
// });
const oidc = await getOidc();
if (oidc.isUserLoggedIn) {
console.log((await oidc.getTokens()).accessToken)
}
appContext.dispatch({
type: 'SET_USER_PROFILE',
payload: userProfile
});
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} finally { } finally {
@@ -124,13 +124,15 @@ const SiteNav = () => {
}; };
if ( if (
isAuthenticated && isUserLoggedIn &&
Object.keys(appContext.state.userProfile).length === 0 Object.keys(appContext.state.userProfile).length === 0
) { ) {
console.log(isUserLoggedIn)
console.log()
setUserProfile(); setUserProfile();
getPages(); getPages();
} }
}, [isAuthenticated]); }, [isUserLoggedIn]);
useEffect(() => { useEffect(() => {
getPages(); getPages();
@@ -138,10 +140,11 @@ const SiteNav = () => {
return ( return (
<Navbar <Navbar
authenticated={isUserLoggedIn}
color='base'
handlePageClick={handlePageClick} handlePageClick={handlePageClick}
handleSignIn={handleSignIn} handleSignIn={handleSignIn}
isAuthenticated={isAuthenticated} logo={<Icon className='mt-1' iconName={IconName.PLANE} size="2x" />}
logo={<Icon iconName={IconName.PLANE} size="2x" />}
pages={pages} pages={pages}
settings={<Settings />} settings={<Settings />}
userPhoto={userPhoto} userPhoto={userPhoto}

Some files were not shown because too many files have changed in this diff Show More