96 switch from azure table storage to sqlite #99

Merged
noahspannbauer merged 15 commits from 96-switch-from-azure-table-storage-to-sqlite into main 2025-11-23 12:07:08 -05:00
144 changed files with 4848 additions and 8327 deletions
Showing only changes of commit 05b17878d7 - Show all commits

BIN
.DS_Store vendored

Binary file not shown.

View File

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

View File

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

3
.gitignore vendored
View File

@@ -5,5 +5,4 @@ node_modules
.DS_Store
.dapr
**/**/secrets.json
.prod
database
.prod

View File

@@ -22,24 +22,25 @@
FROM node:22-slim AS base
FROM base AS migrate
WORKDIR migrations
COPY ./migrations .
RUN npm i -g pnpm
RUN pnpm install
FROM --platform=linux/amd64 node:22-slim AS base
FROM base AS api
COPY ./api/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
WORKDIR api
COPY ./.prod/api .
COPY ./api/dist ./dist
COPY ./api/package.json package-lock.json .
RUN npm ci
EXPOSE 3000
CMD ["node", "dist/main.js"]
ENTRYPOINT ["/entrypoint.sh"]
# ENTRYPOINT ["tail", "-f", "/dev/null"]
FROM base AS app
WORKDIR /app
COPY ./.prod/app .
WORKDIR app
COPY ./app/dist ./dist
RUN npm i -g serve
EXPOSE 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
/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: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",
"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:run": "npm run typeorm -- migration:run",
"migration:revert": "npm run typeorm -- migration:revert"
@@ -25,21 +25,29 @@
"dependencies": {
"@azure/storage-blob": "^12.27.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/axios": "^3.0.3",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.1.6",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.6",
"@nestjs/jwt": "^11.0.0",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/serve-static": "^5.0.3",
"@nestjs/typeorm": "^11.0.0",
"@noahspan/azure-database": "^3.1.2",
"@noahspan/noahspan-modules": "^1.1.5",
"@noahspan/noahspan-modules": "^1.2.8",
"@schematics/angular": "^17.3.7",
"@types/multer": "^1.4.12",
"better-sqlite3": "^12.2.0",
"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",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.25",
"uuid": "^10.0.0",
"uuidv4": "^6.2.13"
@@ -48,11 +56,13 @@
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^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-session": "^1.18.2",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-azure-ad": "^4.3.6",
"@types/passport-openidconnect": "^0.1.3",
"@types/supertest": "^6.0.0",
"jest": "^29.5.0",
"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 { FeatureFlagModule } from './featureFlag/feature-flag.module'
import { HealthModule } from './health/health.module';
import { LogModule } from './log/log.module';
import { PilotModule } from './pilot/pilot.module';
import { APP_FILTER, APP_GUARD, Reflector } from '@nestjs/core';
import { HttpExceptionFilter } from './filters/http-exception.filter';
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 { TypeOrmModule } from '@nestjs/typeorm';
import { dataSourceOptions } from './config/typeorm-cli.config';
import { dataSourceOptions } from './database/data-source';
import { TrackModule } from './track/track.module';
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
@@ -18,9 +20,9 @@ import { TrackModule } from './track/track.module';
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
clientId: configService.get<string>('clientId'),
clientSecret: configService.get<string>('clientSecret'),
tenantId: configService.get<string>('tenantId')
audience: configService.get<string>('audience'),
issuerUrl: configService.get<string>('issuer'),
jwksUri: configService.get<string>('jwksUri')
};
},
}),
@@ -28,12 +30,15 @@ import { TrackModule } from './track/track.module';
isGlobal: true,
load: [configuration]
}),
FeatureFlagModule,
LogModule,
// HealthModule,
// LogModule,
PilotModule,
TrackModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../..', 'client', 'dist')
}),
// TrackModule,
TypeOrmModule.forRoot(dataSourceOptions),
UserModule.registerAsync({
MsGraphModule.registerAsync({
inject: [ConfigService],
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
@@ -49,7 +54,7 @@ import { TrackModule } from './track/track.module';
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
}
]
})
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 () => ({
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
audience: process.env.AUDIENCE,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
issuer: process.env.ISSUER_URL,
jwksUri: process.env.JWKS_URI,
tenantId: process.env.TENANT_ID
})

View File

@@ -1,17 +1,16 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
import { ConfigService } from '@nestjs/config';
import configuration from './configuration';
config();
const configService = new ConfigService();
export const dataSourceOptions: DataSourceOptions = {
type: 'sqlite',
type: 'better-sqlite3',
database: configService.get<string>('DB_PATH'),
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
migrations: ['dist/database/migrations/*.js'],
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";
export class InitialMigration1754234179211 implements MigrationInterface {
name = 'InitialMigration1754234179211'
export class InitialMigration1758802917932 implements MigrationInterface {
name = 'InitialMigration1758802917932'
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 "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 "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 "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)`);

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 { TrackEntity } from 'src/track/track.entity';
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 { HttpExceptionFilter } from './filters/http-exception.filter';
import { InternalServerErrorException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import * as session from 'express-session';
async function bootstrap() {
const httpService = new HttpService();
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.useGlobalFilters(new HttpExceptionFilter());
app.use(
session({
secret: 'blah',
resave: false,
saveUninitialized: false
})
)
httpService.axiosRef.interceptors.response.use(
(response) => {

View File

@@ -14,8 +14,8 @@ import { PilotDto } from './pilot.dto';
import { PilotEntity } from './pilot.entity';
import { PilotService } from './pilot.service';
import { CustomError } from '../error/customError';
import { AuthGuard } from '@noahspan/noahspan-modules'
import { PilotInterceptor } from './interceptors/pilot.interceptor';
import { AuthGuard } from '@noahspan/noahspan-modules';
@Controller('pilots')
// @UseInterceptors(new PilotInterceptor())
@@ -34,6 +34,7 @@ export class PilotController {
}
@Get()
@UseGuards(AuthGuard)
async findAll() {
try {
return await this.pilotService.findAll();
@@ -44,7 +45,7 @@ export class PilotController {
}
}
// @UseGuards(AuthGuard)
@UseGuards(AuthGuard)
@Post()
async create(@Body() pilotDto: PilotDto) {
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 { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { CertificateEntity } from '../certificate/certificate.entity';
@@ -49,6 +30,9 @@ export class PilotEntity {
@Column()
phone: string;
@Column()
userId: string | null;
@OneToMany(() => LogEntity, (log: LogEntity) => log.pilot, {onDelete: 'CASCADE', onUpdate: 'CASCADE'})
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,
"version": "2.0.0-alpha",
"type": "module",
@@ -11,25 +11,27 @@
"serve": "serve -s dist"
},
"dependencies": {
"@azure/msal-browser": "^4.0.1",
"@azure/msal-react": "3.0.1",
"@noahspan/noahspan-components": "^1.9.1",
"@noahspan/noahspan-components": "^2.0.0-alpha-11",
"@tailwindcss/vite": "^4.1.13",
"axios": "^1.7.2",
"dotenv": "^16.4.7",
"leaflet": "^1.9.4",
"react": "^18",
"react-dom": "^18",
"oidc-spa": "^7.2.4",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.51.4",
"react-leaflet": "^4",
"react-leaflet-kml": "^2.1.2",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.23.0",
"swiper": "^11.2.6"
"swiper": "^11.2.6",
"tailwindcss": "^4.1.13"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0",
"@types/leaflet": "^1.9.16",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.8",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"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 Pilots from './components/pilots/Pilots';
import SiteNav from './components/siteNav/SiteNav';
import { useIsAuthenticated } from '@azure/msal-react';
import { useAuth } from 'react-oidc-context';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const App = () => {
const isAuthenticated = useIsAuthenticated()
const auth = useAuth();
const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
return isAuthenticated ? children : <Navigate to='/' />
return auth.isAuthenticated ? children : <Navigate to='/' />
}
return (
@@ -23,7 +23,6 @@ const App = () => {
<Route path='/' element={<Flights />} />
<Route path="/logbook" element={<Logbook />} />
<Route path="/pilots" element={<Pilots />} />
</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 {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Icon,
IconName,
Spinner
Loading
} from '@noahspan/noahspan-components';
import { IDialogConfirmationProps } from './IConfirmationDialogProps';
@@ -22,25 +19,20 @@ const ConfirmationDialog = ({
}: IDialogConfirmationProps) => {
return (
<Dialog
onClose={onCancel}
open={isOpen}
sx={{
'& .MuiDialog-paper': { width: '2000px' }
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ textAlign: 'center' }}>
{!isLoading && <DialogContentText>{contentText}</DialogContentText>}
{isLoading && <Spinner />}
<h3>{title}</h3>
<DialogContent>
{!isLoading && <div>{contentText}</div>}
{isLoading && <Loading size='xl' />}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} variant="outlined" startIcon={<Icon iconName={IconName.XMARK} />}>
<Button onClick={onCancel} startContent={<Icon iconName={IconName.XMARK} />}>
No
</Button>
<Button
onClick={onConfirm}
variant="contained"
startIcon={<Icon iconName={IconName.CIRCLE_CHECK} />}
startContent={<Icon iconName={IconName.CIRCLE_CHECK} />}
>
Yes
</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 {
alert: Alert | undefined;
experienceCollapseOpen: boolean;
instrumentCollapseOpen: boolean;
isDisabled: boolean;
isLoading: boolean;
landingsCollapseOpen: boolean;
pilotOptions: { label: string; value: string }[];
selectedPilotName: string;
}

View File

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

View File

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

View File

@@ -1,27 +1,23 @@
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 { AxiosInstance, AxiosResponse } from "axios";
import { useAccessToken } from "../../hooks/accessToken/UseAcessToken";
import { LogTracksProps } from "./LogTracksProps.interface";
import { FormMode } from "../../enums/formMode";
import { useIsAuthenticated } from "@azure/msal-react";
import { ILogbookEntry } from "../logbook/ILogbookEntry";
import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog";
import { initialState, reducer } from "./reducer";
import LogTrackMaps from "../logTrackMaps/LogTrackMaps";
import { useAuth } from "react-oidc-context";
const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracksProps) => {
const [state, dispatch] = useReducer(reducer, initialState)
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const auth = useAuth();
const getConfig = async () => {
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
const config = auth.isAuthenticated
? { headers: { Authorization: auth.user?.access_token } }
: {};
return config
@@ -131,33 +127,28 @@ const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracks
return (
<Drawer
open={isDrawerOpen}
anchor='right'
PaperProps={{
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
position='right'
width='25%'
>
<Grid container spacing={2}>
<Grid size={11}>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`}</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={1}>
<div>
<div>
<h4>{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`}</h4>
</div>
<div>
<IconButton disabled={state.isLoading ? true : false} onClick={onCancel}>
<Icon iconName={IconName.XMARK} />
</IconButton>
</Grid>
</div>
{mode === FormMode.EDIT &&
<>
{state.isLoading &&
<>
<Grid display="flex" justifyContent="center" size={12}>
<Spinner />
</Grid>
<Grid display="flex" justifyContent="center" size={12}>
<div>
<Loading size='xl' />
</div>
<div>
Loading...
</Grid>
</div>
</>
}
{!state.isLoading && state.tracks.length > 0 && state.tracks.map((track, index) => {
@@ -166,43 +157,40 @@ const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedLogId }: LogTracks
return (
<>
<Grid size={11}>
<TextField disabled={true} fullWidth value={filename} />
</Grid>
<Grid size={1}>
<div>
<Input disabled={true} value={filename} />
</div>
<div>
<IconButton onClick={() => onDeleteTrack(filename, index)}><Icon iconName={IconName.TRASH} /></IconButton>
</Grid>
</div>
</>
)
})}
<Grid display='flex' gap={2} justifyContent='right' size={12}>
<div>
<Button
disabled={state.isLoading ? true : false}
startIcon={<Icon iconName={IconName.XMARK} />}
variant="outlined"
startContent={<Icon iconName={IconName.XMARK} />}
onClick={onCancel}
size="small"
size='sm'
>
Close
</Button>
{mode.toString() !== FormMode.VIEW && (
<Button
component='label'
disabled={state.isLoading ? true : false}
startIcon={<Icon iconName={IconName.UPLOAD} />}
variant='contained'
startContent={<Icon iconName={IconName.UPLOAD} />}
>
Upload Track
{/* <input hidden onChange={handleFileUpload} type='file' /> */}
</Button>
)}
</Grid>
</div>
</>
}
{mode === FormMode.VIEW &&
<LogTrackMaps logId={selectedLogId!} tracks={state.tracks} />
}
</Grid>
</div>
{/* {state.isConfirmDialogOpen && (
<ConfirmationDialog
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 {
Alert,
Box,
Button,
ColumnDef,
Grid,
Icon,
IconButton,
IconName,
Spinner,
Table,
theme,
Typography,
useMediaQuery
Loading,
Table
} from '@noahspan/noahspan-components';
import { initialState, reducer } from './reducer';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { authColumns, unauthColumns } from './columns';
import ActionMenu from '../actionMenu/ActionMenu';
@@ -27,13 +20,12 @@ import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import { ILogbookEntry } from './ILogbookEntry';
import LogbookCard from '../logbookCard/LogbookCard';
import LogTracks from '../logTracks/LogTracks';
import { useOidc } from '../../auth/oidcConfig';
const Logbook: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken();
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const { isUserLoggedIn } = useOidc()
const actionsColumn: ColumnDef<ILogbookEntry> = {
header: 'Actions',
meta: {
@@ -65,10 +57,7 @@ const Logbook: React.FC<unknown> = () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(`api/logs`, config);
const response: AxiosResponse = await httpClient.get(`api/logs`);
const entries: ILogbookEntry[] = response.data;
console.log(entries)
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
@@ -162,12 +151,7 @@ const Logbook: React.FC<unknown> = () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/logs/${state.selectedLogId}`, config);
await httpClient.delete(`api/logs/${state.selectedLogId}`);
dispatch({
type: 'SET_DELETE',
@@ -196,7 +180,7 @@ const Logbook: React.FC<unknown> = () => {
useEffect(() => {
let newColumns: ColumnDef<ILogbookEntry>[];
if (isAuthenticated) {
if (isUserLoggedIn) {
newColumns = [...authColumns];
} else {
newColumns = [...unauthColumns];
@@ -216,7 +200,7 @@ const Logbook: React.FC<unknown> = () => {
}
dispatch({ type: 'SET_COLUMNS', payload: newColumns })
}, [isAuthenticated])
}, [isUserLoggedIn])
useEffect(() => {
if (!state.isFormOpen) {
@@ -225,57 +209,56 @@ const Logbook: React.FC<unknown> = () => {
}, [state.isFormOpen, state.isTracksOpen]);
return (
<Box sx={{ margin: '20px' }}>
<Grid container spacing={2}>
<Grid size={isMedium ? 11 : 6}>
<Typography variant="h4">Logbook</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={isMedium ? 1 : 6}>
{isAuthenticated &&
<>
<div className='mr-10 ml-10 grid grid-cols-12'>
<div className='prose max-w-none col-span-10 mt-5 mb-5'>
<h1>Logbook</h1>
</div>
<div className='col-span-2 justify-self-end self-center'>
{isUserLoggedIn &&
<Button
color='primary'
onClick={() => onOpenCloseLogForm(FormMode.ADD)}
startIcon={<Icon iconName={IconName.PLUS} />}
variant="contained"
startContent={<Icon iconName={IconName.PLUS} />}
data-testid="pilot-add-button"
>
Add Entry
</Button>
}
</Grid>
</div>
{!state.isLoading && state.alert && (
<Grid display="flex" justifyContent="center" size={12}>
<div>
<Alert
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
sx={{ width: '100%' }}
>
{state.alert.message}
</Alert>
</Grid>
</div>
)}
{!state.isLoading && (
<Grid size={12}>
{isMedium && state.columns && state.columns.length > 0 && state.entries.length > 0 && (
<div>
{state.columns && state.columns.length > 0 && state.entries.length > 0 && (
<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} />
}
</Grid>
</div>
)}
{state.isLoading && !state.alert && (
<>
<Grid display="flex" justifyContent="center" size={12}>
<Spinner />
</Grid>
<Grid display="flex" justifyContent="center" size={12}>
<div>
<Loading size='xl' />
</div>
<div>
Loading...
</Grid>
</div>
</>
)}
</Grid>
</div>
{state.isFormOpen && (
<LogForm
logId={state.selectedLogId}
@@ -302,7 +285,7 @@ const Logbook: React.FC<unknown> = () => {
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 {
Button,
Drawer,
Grid,
Icon,
IconButton,
IconName,
Input,
PeoplePicker,
StateSelect,
TextField,
theme,
Typography,
useMediaQuery
StateSelect
} from '@noahspan/noahspan-components';
import { IPilotFormProps } from './IPilotFormProps';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode';
import { Person } from '@microsoft/microsoft-graph-types';
import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates';
import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements';
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> = ({
pilotId,
@@ -31,7 +28,7 @@ const PilotForm: React.FC<IPilotFormProps> = ({
mode,
onOpenClose
}: IPilotFormProps) => {
const httpClient: AxiosInstance = useHttpClient();
const [peoplePickerValue, setPeoplePickerValue] = useState<string>('');
const [peoplePickerResults, setPeoplePickerResults] = useState<any[]>([]);
const [isPeoplePickerLoading, setIsPeoplePickerLoading] =
useState<boolean>(false);
@@ -39,8 +36,7 @@ const PilotForm: React.FC<IPilotFormProps> = ({
displayName: ''
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { getAccessToken } = useAccessToken();
const isAuthenticated = useIsAuthenticated();
const { isUserLoggedIn } = useOidc();
const defaultValues = {
name: '',
address: '',
@@ -55,27 +51,26 @@ const PilotForm: React.FC<IPilotFormProps> = ({
});
const [isDisabled, setIsDisabled] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const isMedium = useMediaQuery(theme.breakpoints.up('md'));
const httpClient = useHttpClient();
const onPeoplePickerSearch = async (
_event: React.SyntheticEvent,
value: string
) => {
setIsPeoplePickerLoading(true);
setPeoplePickerValue(value)
try {
if (value !== '') {
const searchString: string = value;
const accessToken: string = await getAccessToken();
const oidc = await getOidc();
const response: AxiosResponse = await httpClient.get(
`api/user/search?search=${searchString}`,
{
`api/msgraph/search?search=${searchString}`, {
headers: {
Authorization: accessToken
Authorization: oidc.isUserLoggedIn ? `Bearer ${(await oidc.getTokens()).accessToken}` : ''
}
}
);
console.log(response)
setPeoplePickerResults(response.data);
} else {
setPeoplePickerResults([]);
@@ -87,13 +82,10 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
};
const onPeoplePickerSelectionChange = (
_event: React.SyntheticEvent,
value: Person,
_reason: string
) => {
methods.setValue('name', value.displayName!.toString());
setSelectedPerson(value);
const onPersonSelected = (person: Person) => {
methods.setValue('name', person.displayName!.toString());
setPeoplePickerValue(person.displayName!);
setPeoplePickerResults([])
};
const onCancel = () => {
@@ -107,20 +99,10 @@ const PilotForm: React.FC<IPilotFormProps> = ({
try {
setIsLoading(true);
const accessToken: string = await getAccessToken();
if (!pilotId) {
await httpClient.post(`api/pilots`, data, {
headers: {
Authorization: accessToken
}
});
await httpClient.post(`api/pilots`, data);
} else {
await httpClient.put(`api/pilots/pilot/${pilotId}`, data, {
headers: {
Authorization: accessToken
}
})
await httpClient.put(`api/pilots/pilot/${pilotId}`, data)
}
methods.reset(defaultValues)
@@ -146,12 +128,8 @@ const PilotForm: React.FC<IPilotFormProps> = ({
try {
setIsLoading(true);
const config = isAuthenticated
? { headers: { Authorization: await getAccessToken() } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/pilots/${pilotId}`,
config
`api/pilots/${pilotId}`
);
const pilot = response.data;
@@ -174,54 +152,49 @@ const PilotForm: React.FC<IPilotFormProps> = ({
return (
<Drawer
open={isDrawerOpen}
anchor="right"
position='right'
data-testid="pilot-drawer"
PaperProps={{
sx: {
padding: '30px',
width: isMedium ? '33%' : '75%'
}
}}
width='50%'
>
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<Grid container spacing={2}>
<Grid size={11}>
<Typography variant="h4">{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}</Typography>
</Grid>
<Grid display="flex" justifyContent="right" size={1}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className='col-span-10 self-center'>
<h2 style={{ margin: 0 }}>{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}</h2>
</div>
<div className='col-span-2 justify-self-end self-center'>
<IconButton onClick={onCancel}>
<Icon iconName={IconName.XMARK} />
</IconButton>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Name *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>Name *</h6>
</div>
<div className='col-span-9'>
<PeoplePicker
disabled={isDisabled}
loading={isPeoplePickerLoading}
// loading={isPeoplePickerLoading}
onInputChanged={onPeoplePickerSearch}
onSelectionChanged={onPeoplePickerSelectionChange}
options={peoplePickerResults}
value={selectedPerson}
onPersonSelected={onPersonSelected}
people={peoplePickerResults}
value={peoplePickerValue}
width='w-full'
/>
</Grid>
{isAuthenticated &&
</div>
{isUserLoggedIn &&
<>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Address *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
<div className='col-span-3 self-center'>
<h6>Address *</h6>
</div>
<div className='col-span-9'>
<Controller
name="address"
control={methods.control}
rules={{ required: 'An address is required' }}
render={({ field: { onChange, value } }) => (
<TextField
<Input
disabled={isDisabled}
error={methods.formState.errors.address ? true : false}
fullWidth
color={methods.formState.errors.address ? 'error' : undefined}
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message
@@ -229,23 +202,23 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
onChange={onChange}
value={value}
width='w-full'
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">City *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>City *</h6>
</div>
<div className='col-span-9'>
<Controller
name="city"
control={methods.control}
rules={{ required: 'A city is required' }}
render={({ field: { onChange, value } }) => (
<TextField
<Input
disabled={isDisabled}
error={methods.formState.errors.city ? true : false}
fullWidth
color={methods.formState.errors.city ? 'error' : undefined}
helperText={
methods.formState.errors.city
? methods.formState.errors.city.message
@@ -253,14 +226,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
onChange={onChange}
value={value}
width='w-full'
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">State *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>State *</h6>
</div>
<div className='col-span-9'>
<Controller
name="state"
control={methods.control}
@@ -269,7 +243,6 @@ const PilotForm: React.FC<IPilotFormProps> = ({
<StateSelect
disabled={isDisabled}
// error={methods.formState.errors.state ? true : false}
fullWidth
// helperText={
// methods.formState.errors.state
// ? methods.formState.errors.state.message?.toString()
@@ -277,25 +250,24 @@ const PilotForm: React.FC<IPilotFormProps> = ({
// }
onChange={onChange}
value={value}
variant="outlined"
width='w-full'
data-testid="pilot-form-state-dropdown"
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Postal Code *</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>Postal Code *</h6>
</div>
<div className='col-span-9'>
<Controller
name="postalCode"
control={methods.control}
rules={{ required: 'A postal code is required' }}
render={({ field: { onChange, value } }) => (
<TextField
<Input
disabled={isDisabled}
error={methods.formState.errors.postalCode ? true : false}
fullWidth
color={methods.formState.errors.postalCode ? 'error' : undefined}
helperText={
methods.formState.errors.postalCode
? methods.formState.errors.postalCode.message
@@ -303,14 +275,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
onChange={onChange}
value={value}
width='w-full'
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Email</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>Email</h6>
</div>
<div className='col-span-9'>
<Controller
name="email"
control={methods.control}
@@ -321,10 +294,9 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
}}
render={({ field: { onChange, value } }) => (
<TextField
<Input
disabled={isDisabled}
fullWidth
error={methods.formState.errors.email ? true : false}
color={methods.formState.errors.email ? 'error' : undefined}
helperText={
methods.formState.errors.email
? methods.formState.errors.email.message
@@ -332,14 +304,15 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
onChange={onChange}
value={value}
width='w-full'
/>
)}
/>
</Grid>
<Grid size={isMedium ? 3 : 12}>
<Typography variant="h6">Phone Number</Typography>
</Grid>
<Grid size={isMedium ? 9 : 12}>
</div>
<div className='col-span-3 self-center'>
<h6>Phone Number</h6>
</div>
<div className='col-span-9'>
<Controller
name="phone"
control={methods.control}
@@ -350,10 +323,9 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
}}
render={({ field: { onChange, value } }) => (
<TextField
<Input
disabled={isDisabled}
fullWidth
error={methods.formState.errors.phone ? true : false}
color={methods.formState.errors.phone ? 'error' : undefined}
helperText={
methods.formState.errors.phone
? methods.formState.errors.phone.message
@@ -361,10 +333,11 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}
onChange={onChange}
value={value}
width='w-full'
/>
)}
/>
</Grid>
</div>
</>
}
{/* {isAuthenticated &&
@@ -380,35 +353,33 @@ const PilotForm: React.FC<IPilotFormProps> = ({
<Grid size={12}>
<PilotFormEndorsements isDisabled={isDisabled} mode={mode} />
</Grid> */}
<Grid display="flex" gap={2} justifyContent="right" size={12}>
<div className='col-span-12 justify-self-end self-center'>
<Button
disabled={
isDisabled && mode.toString() !== FormMode.VIEW
? isDisabled
: false
}
startIcon={<Icon iconName={IconName.XMARK} />}
variant="outlined"
startContent={<Icon iconName={IconName.XMARK} />}
onClick={onCancel}
outline={true}
data-testid="pilot-cancel-button"
size="small"
>
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</Button>
{mode.toString() !== FormMode.VIEW && (
<Button
color='primary'
disabled={isDisabled}
startIcon={<Icon iconName={IconName.SAVE} />}
size="small"
startContent={<Icon iconName={IconName.SAVE} />}
type="submit"
variant="contained"
data-testid="pilot-save-button"
>
Save
</Button>
)}
</Grid>
</Grid>
</div>
</div>
</form>
</FormProvider>
</Drawer>

View File

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

View File

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

View File

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

View File

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

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