Feature/4 pilots add (#20)

* changing api url to env variable

* updating env variables

* updating env variables

* updating env variables

* updating env variables

* updating env variables

* updating env variables

* feature/4-pilots---add

* feature/4-pilots---add

* feature/4-pilots---add

* feature/4-pilots---add

* ad pilot

* add pilot

* add pilot

* add pilot

* add pilot

* add pilot

* adding pilots

* add pilot

* add pilot

* add pilots

* add pilot

* add pilot

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilots

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* Testing menu button

* Testing menu button

* Testing menu button

* Testing menu button

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

* adding pilot

---------

Co-authored-by: Noah Spannbauer <nspannbauer@sensonix.com>
This commit was merged in pull request #20.
This commit is contained in:
2024-09-23 20:52:59 -05:00
committed by GitHub
parent 80b4fb8c1d
commit 071e211525
53 changed files with 4184 additions and 13161 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,30 +1,35 @@
name: Feature name: Feature
on: on:
push: push:
branches: branches:
- feature/** - feature/**
env:
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_CLIENT_ID: ${{ vars.VITE_CLIENT_ID }}
VITE_TENANT_ID: ${{ vars.VITE_TENANT_ID }}
VITE_REDIRECT_URL: ${{ vars.VITE_REDIRECT_URL }}
jobs: jobs:
build_and_deploy_job: build_and_deploy_job:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Build and Deploy Job name: Build and Deploy Job
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
- name: Build and Deploy - name: Build and Deploy
id: builddeploy id: builddeploy
uses: Azure/static-web-apps-deploy@v1 uses: Azure/static-web-apps-deploy@v1
with: with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload" action: 'upload'
app_build_command: ${{ vars.APP_BUILD_COMMAND }} app_build_command: ${{ vars.APP_BUILD_COMMAND }}
app_location: ${{ vars.APP_LOCATION }} app_location: ${{ vars.APP_LOCATION }}
api_build_command: ${{ vars.API_BUILD_COMMAND }} api_build_command: ${{ vars.API_BUILD_COMMAND }}
api_location: ${{ vars.API_LOCATION}} api_location: ${{ vars.API_LOCATION}}
output_location: ${{ vars.OUTPUT_LOCATION }} output_location: ${{ vars.OUTPUT_LOCATION }}
deployment_environment: "development" deployment_environment: 'development'

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.vscode/* .vscode/*
node_modules node_modules
**/.env **/.env
**/.env.*
.DS_Store .DS_Store

View File

@@ -20,7 +20,9 @@
"start:azure": "npm run build && func host start" "start:azure": "npm run build && func host start"
}, },
"dependencies": { "dependencies": {
"@azure/data-tables": "^13.2.2",
"@azure/functions": "^1.0.3", "@azure/functions": "^1.0.3",
"@nestjs/axios": "^3.0.3",
"@nestjs/azure-database": "^3.0.0", "@nestjs/azure-database": "^3.0.0",
"@nestjs/azure-func-http": "^0.10.0", "@nestjs/azure-func-http": "^0.10.0",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@@ -28,13 +30,14 @@
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0", "@nestjs/platform-express": "^10.0.0",
"@noahspan/noahspan-modules": "^0.2.7", "@noahspan/noahspan-modules": "^0.3.9",
"@schematics/angular": "^17.3.7", "@schematics/angular": "^17.3.7",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
"devDependencies": { "devDependencies": {
"@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": "^10.0.0",

View File

@@ -1,4 +1,11 @@
import { Controller, Get, Query } from '@nestjs/common'; import {
Controller,
Get,
Headers,
Query,
Res,
StreamableFile
} from '@nestjs/common';
import { import {
AppConfigService, AppConfigService,
MsGraphService, MsGraphService,
@@ -6,10 +13,17 @@ import {
} from '@noahspan/noahspan-modules'; } from '@noahspan/noahspan-modules';
import { FeatureFlagValue } from '@azure/app-configuration'; import { FeatureFlagValue } from '@azure/app-configuration';
import { Public } from '@noahspan/noahspan-modules'; import { Public } from '@noahspan/noahspan-modules';
import { Person } from '@microsoft/microsoft-graph-types';
import { AppService } from './app.service';
import { createReadStream } from 'fs';
import { join } from 'path';
import { arrayBuffer } from 'stream/consumers';
import type { Response } from 'express';
@Controller() @Controller()
export class AppController { export class AppController {
constructor( constructor(
private readonly appService: AppService,
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
private readonly msGraphService: MsGraphService private readonly msGraphService: MsGraphService
) {} ) {}
@@ -37,21 +51,58 @@ export class AppController {
} }
} }
@Public() @Get('userPhoto')
@Get('profilePhoto') async getProfilePhoto(@Headers() headers: any): Promise<StreamableFile> {
async getProfilePhoto(@Query() query: any): Promise<any> {} try {
const graphToken: string = await this.msGraphService.getMsGraphAuth(
headers.authorization.replace('Bearer ', ''),
['user.read']
);
const client: MsGraphClient =
await this.msGraphService.getMsGraphClientDelegated(graphToken);
const blob: Blob = await client.api(`me/photos('48x48')/$value`).get();
const arrayBuffer: ArrayBuffer = await blob.arrayBuffer();
const buffer: Buffer = Buffer.from(arrayBuffer);
return new StreamableFile(buffer, {
type: 'application/json',
disposition: `attachment; filename="user_photo.png"`
});
} catch (error) {
return error;
}
}
@Get('userProfile') @Get('userProfile')
async getUserProfile(@Query() query: any) { async getUserProfile(@Headers() headers: any) {
try { try {
const username: string = query.username; const graphToken: string = await this.msGraphService.getMsGraphAuth(
headers.authorization.replace('Bearer ', ''),
['user.read']
);
const client: MsGraphClient = const client: MsGraphClient =
await this.msGraphService.getMsGraphClient(); await this.msGraphService.getMsGraphClientDelegated(graphToken);
const userProfile = await client.api(`users/${username}`).get(); const userProfile = await client.api(`me`).get();
return userProfile; return userProfile;
} catch (error) { } catch (error) {
return error; return error;
} }
} }
@Get('personSearch')
async searchUsers(
@Headers() headers: any,
@Query('search') search: any
): Promise<Person[]> {
try {
const accessToken: string = headers.authorization.replace('Bearer ', '');
const personSearchResults: Person[] =
await this.appService.getPersonSearchResults(accessToken, search);
console.log(personSearchResults);
return personSearchResults;
} catch (error) {
return error;
}
}
} }

View File

@@ -9,6 +9,9 @@ import {
} from '@noahspan/noahspan-modules'; } from '@noahspan/noahspan-modules';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { PilotModule } from './pilot/pilot.module';
import { APP_FILTER } from '@nestjs/core';
import { HttpExceptionFilter } from './filters/http-exception.filter';
@Module({ @Module({
imports: [ imports: [
@@ -28,7 +31,8 @@ import { APP_GUARD } from '@nestjs/core';
tenantId: process.env.TENANT_ID, tenantId: process.env.TENANT_ID,
clientId: process.env.CLIENT_ID, clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET clientSecret: process.env.CLIENT_SECRET
}) }),
PilotModule
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -36,6 +40,10 @@ import { APP_GUARD } from '@nestjs/core';
provide: APP_GUARD, provide: APP_GUARD,
useClass: AuthGuard useClass: AuthGuard
}, },
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
AppService AppService
] ]
}) })

View File

@@ -1,8 +1,44 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MsGraphService, MsGraphClient } from '@noahspan/noahspan-modules';
import { Person } from '@microsoft/microsoft-graph-types';
@Injectable() @Injectable()
export class AppService { export class AppService {
constructor(private readonly msGraphService: MsGraphService) {}
getHello(): string { getHello(): string {
return 'Hello World!'; return 'Hello World!';
} }
async getPersonSearchResults(
accessToken: string,
search: string
): Promise<Person[]> {
try {
const graphToken: string = await this.msGraphService.getMsGraphAuth(
accessToken,
['user.read']
);
const client: MsGraphClient =
await this.msGraphService.getMsGraphClientDelegated(graphToken);
const results: any = await client
.api(`me/people/?$search=${search}`)
.get();
let personResults: Person[];
if (results.value) {
personResults = results.value.filter((result: Person) => {
if (result.userPrincipalName !== null) {
return result;
}
});
} else {
personResults = [];
}
return personResults;
} catch (error) {
throw new Error(error);
}
}
} }

View File

@@ -0,0 +1,9 @@
export class CustomError extends Error {
statusCode: number;
constructor(message, name, statusCode) {
super(message);
this.name = name;
this.statusCode = statusCode;
}
}

View File

@@ -0,0 +1,26 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException
} from '@nestjs/common';
import { Request, Response } from 'express';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(excpetion: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = excpetion.getStatus();
console.log(excpetion.cause);
response.status(status).json({
name: excpetion.cause,
message: excpetion.message,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url
});
}
}

View File

@@ -1,8 +1,25 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpService } from '@nestjs/axios';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { InternalServerErrorException } from '@nestjs/common';
async function bootstrap() { async function bootstrap() {
const httpService = new HttpService();
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
httpService.axiosRef.interceptors.response.use(
(response) => {
return response;
},
(error) => {
console.error('Internal server error exception', error);
throw new InternalServerErrorException();
}
);
await app.listen(3000); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@@ -0,0 +1,7 @@
export class Certificate {
partitionKey: string;
rowKey: string;
type: string;
issueDate: Date;
number?: string;
}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { Repository, InjectRepository } from '@nestjs/azure-database';
import { Certificate } from './certificate.entity';
@Injectable()
export class CertificateService {
private readonly partitionKey: string = 'certificate';
constructor(
@InjectRepository(Certificate)
private readonly certificateRepository: Repository<Certificate>
) {}
async find(rowKey: string): Promise<Certificate> {
return await this.certificateRepository.find(this.partitionKey, rowKey);
}
async findAll(): Promise<Certificate[]> {
return await this.certificateRepository.findAll();
}
async create(certificate: Certificate): Promise<Certificate> {
return await this.certificateRepository.create(certificate);
}
async update(rowKey: string, certificate: Certificate): Promise<Certificate> {
return await this.certificateRepository.update(
this.partitionKey,
rowKey,
certificate
);
}
async delete(rowKey: string): Promise<void> {
await this.certificateRepository.delete(this.partitionKey, rowKey);
}
}

View File

@@ -0,0 +1,6 @@
export class Endorsement {
partitionkey: string;
rowKey: string;
type: string;
issueDate: Date;
}

View File

@@ -0,0 +1,37 @@
import { InjectRepository, Repository } from '@nestjs/azure-database';
import { Injectable } from '@nestjs/common';
import { Endorsement } from './endorsement.entity';
@Injectable()
export class EndosementService {
private readonly partitionKey: string = 'endorsement';
constructor(
@InjectRepository(Endorsement)
private readonly endorsementRepository: Repository<Endorsement>
) {}
async find(rowKey: string): Promise<Endorsement> {
return await this.endorsementRepository.find(this.partitionKey, rowKey);
}
async findAll(): Promise<Endorsement[]> {
return await this.endorsementRepository.findAll();
}
async create(endorsement: Endorsement): Promise<Endorsement> {
return await this.endorsementRepository.create(endorsement);
}
async update(rowKey: string, endorsement: Endorsement): Promise<Endorsement> {
return await this.endorsementRepository.update(
this.partitionKey,
rowKey,
endorsement
);
}
async delete(rowKey: string): Promise<void> {
await this.endorsementRepository.delete(this.partitionKey, rowKey);
}
}

View File

@@ -0,0 +1,10 @@
export class PilotInfoDto {
id: string;
name: string;
address: string;
city: string;
state: string;
postalCode: string;
email?: string;
phone?: string;
}

View File

@@ -0,0 +1,12 @@
export class PilotInfoEntity {
partitionKey: string;
rowKey: string;
id: string;
name: string;
address?: string;
city?: string;
state?: string;
postalCode?: string;
email?: string;
phone?: string;
}

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { PilotInfoDto } from './pilot-info.dto';
import { PilotInfoEntity } from './pilot-info.entity';
import { TableClient, TableService } from '@noahspan/noahspan-modules';
import { odata, RestError, TableInsertEntityHeaders } from '@azure/data-tables';
import { CustomError } from '../../customError/CustomError';
@Injectable()
export class PilotInfoService {
private readonly partitionKey: string = 'info';
constructor(private readonly tableService: TableService) {}
// async find(rowKey: string): Promise<PilotInfo> {
// return await this.pilotInfoRepository.find(this.partitionKey, rowKey);
// }
async findAll(): Promise<PilotInfoEntity[]> {
try {
const client: TableClient =
await this.tableService.getTableClient('Pilots');
const entities = await client.listEntities({
queryOptions: { filter: odata`PartitionKey eq 'pilot'` }
});
const pilots: PilotInfoEntity[] = [];
for await (const entity of entities) {
const pilot: PilotInfoEntity = {
partitionKey: entity.partitionKey,
rowKey: entity.rowKey,
id: entity.id.toString(),
name: entity.name.toString()
};
pilots.push(pilot);
}
return pilots;
} catch (error) {
const restError: RestError = error as RestError;
throw new CustomError(
restError.details['odataError']['message']['value'],
restError.details['odataError']['code'],
restError.statusCode
);
}
}
async create(pilotInfoData: PilotInfoDto): Promise<TableInsertEntityHeaders> {
const client: TableClient =
await this.tableService.getTableClient('Pilots');
const pilotInfo: PilotInfoEntity = new PilotInfoEntity();
Object.assign(pilotInfo, pilotInfoData);
pilotInfo.partitionKey = 'pilot';
pilotInfo.rowKey = pilotInfo.id;
try {
return await client.createEntity(pilotInfo);
} catch (error) {
const restError: RestError = error as RestError;
throw new CustomError(
restError.details['odataError']['message']['value'],
restError.details['odataError']['code'],
restError.statusCode
);
}
}
// async update(rowKey: string, pilotInfo: PilotInfo): Promise<PilotInfo> {
// return await this.pilotInfoRepository.update(
// this.partitionKey,
// rowKey,
// pilotInfo
// );
// }
// async delete(rowKey: string): Promise<void> {
// await this.pilotInfoRepository.delete(this.partitionKey, rowKey);
// }
}

View File

@@ -0,0 +1,6 @@
export class Medical {
partitionKey: string;
rowKey: string;
certificateClass: string;
certificateExpiration: Date;
}

View File

@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { Repository, InjectRepository } from '@nestjs/azure-database';
import { Medical } from './medical.entity';
@Injectable()
export class MedicalService {
private readonly partitionKey: string = 'medical';
constructor(
@InjectRepository(Medical)
private readonly profileRepository: Repository<Medical>
) {}
async find(rowKey: string): Promise<Medical> {
return this.profileRepository.find(this.partitionKey, rowKey);
}
async findAll(): Promise<Medical[]> {
return this.profileRepository.findAll();
}
async create(profile: Medical): Promise<Medical> {
return this.profileRepository.create(profile);
}
async update(rowKey: string, profile: Medical): Promise<Medical> {
return this.profileRepository.update(this.partitionKey, rowKey, profile);
}
async delete(rowKey: string) {
return this.profileRepository.delete(this.partitionKey, rowKey);
}
}

View File

@@ -0,0 +1,40 @@
import { Body, Controller, Get, HttpException, Post } from '@nestjs/common';
import { PilotInfoService } from './info/pilot-info.service';
import { PilotInfoDto } from './info/pilot-info.dto';
import { TableInsertEntityHeaders } from '@azure/data-tables';
import { CustomError } from '../customError/CustomError';
import { PilotInfoEntity } from './info/pilot-info.entity';
@Controller('pilots')
export class PilotController {
constructor(private readonly pilotInfoService: PilotInfoService) {}
@Get()
async findAll(): Promise<PilotInfoEntity[]> {
try {
const pilots: PilotInfoEntity[] = await this.pilotInfoService.findAll();
return pilots;
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode, {
cause: customError.name
});
}
}
@Post()
async create(@Body() pilotInfoData: PilotInfoDto): Promise<void> {
try {
const response: TableInsertEntityHeaders =
await this.pilotInfoService.create(pilotInfoData);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode, {
cause: customError.name
});
}
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PilotController } from './pilot.controller';
import { TableModule } from '@noahspan/noahspan-modules';
import { PilotInfoService } from './info/pilot-info.service';
@Module({
imports: [
TableModule.register({
accountName: process.env.AZURE_STORAGE_ACCOUNT_NAME,
accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY
})
],
controllers: [PilotController],
providers: [PilotInfoService]
})
export class PilotModule {}

7905
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,13 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@azure/msal-react": "^2.0.15", "@azure/msal-browser": "^3.17.0",
"@azure/msal-react": "^2.0.19",
"@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2", "@fortawesome/react-fontawesome": "^0.2.2",
"@nextui-org/react": "^2.3.6", "@noahspan/noahspan-components": "^0.6.8",
"@noahspan/noahspan-components": "^0.2.5",
"axios": "^1.7.2", "axios": "^1.7.2",
"framer-motion": "^11.1.7", "framer-motion": "^11.1.7",
"react": "^18.2.0", "react": "^18.2.0",
@@ -25,6 +25,7 @@
"react-router-dom": "^6.23.0" "react-router-dom": "^6.23.0"
}, },
"devDependencies": { "devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",

View File

@@ -2,21 +2,23 @@ import { useEffect } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import Pilots from './components/pilots/Pilots'; import Pilots from './components/pilots/Pilots';
import { useAppContext } from './hooks/appContext/UseAppContext'; import { useAppContext } from './hooks/appContext/UseAppContext';
import axios, { AxiosResponse } from 'axios'; import { AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from './hooks/httpClient/UseHttpClient';
import { useFeatureFlag } from './hooks/featureFlag/UseFeatureFlag'; import { useFeatureFlag } from './hooks/featureFlag/UseFeatureFlag';
import SiteNav from './components/siteNav/SiteNav';
const App: React.FC<unknown> = () => { const App: React.FC<unknown> = () => {
const httpClient: AxiosInstance = useHttpClient();
const appContext = useAppContext(); const appContext = useAppContext();
useEffect(() => { useEffect(() => {
const getFeatureFlags = async () => { const getFeatureFlags = async () => {
try { try {
const featureFlagKeys: string = 'flying-pilots'; const featureFlagKeys: string = 'flying-pilots';
const response: AxiosResponse = await axios.get( const response: AxiosResponse = await httpClient.get(
`http://localhost:7071/api/featureFlags?keys=${featureFlagKeys}&label=${process.env.NODE_ENV}` `api/featureFlags?keys=${featureFlagKeys}&label=${import.meta.env.MODE}`
); );
const featureFlags: { key: string; enabled: boolean }[] = response.data; const featureFlags: { key: string; enabled: boolean }[] = response.data;
console.log(featureFlags);
if (featureFlags.length > 0) { if (featureFlags.length > 0) {
appContext.dispatch({ appContext.dispatch({
@@ -33,11 +35,14 @@ const App: React.FC<unknown> = () => {
}, []); }, []);
return ( return (
<Routes> <div className="container mx-auto">
{useFeatureFlag('flying-pilots')?.enabled && ( <SiteNav />
<Route path="/" element={<Pilots />} /> <Routes>
)} {useFeatureFlag('flying-pilots')?.enabled && (
</Routes> <Route path="/" element={<Pilots />} />
)}
</Routes>
</div>
); );
}; };

View File

@@ -1,9 +1,6 @@
import SiteNav from '../siteNav/SiteNav';
const Logbook: React.FC<unknown> = () => { const Logbook: React.FC<unknown> = () => {
return ( return (
<div> <div>
<SiteNav />
<div>Logbook goes here</div> <div>Logbook goes here</div>
</div> </div>
); );

View File

@@ -0,0 +1,5 @@
export interface IPilotFormProps {
pilotId?: string;
isDrawerOpen: boolean;
onOpenCloseDrawer: () => void;
}

View File

@@ -1,85 +1,473 @@
// import { import { useEffect, useState } from 'react';
// Accordion, import {
// AccordionItem, useForm,
// Button, Controller,
// DatePicker, FormProvider,
// Input, FieldValues
// Select, } from 'react-hook-form';
// SelectItem import {
// } from '@nextui-org/react'; Button,
// import { useForm, Controller, SubmitHandler } from 'react-hook-form'; DatePicker,
Drawer,
DrawerBody,
DrawerHeader,
DrawerFooter,
Input,
Option,
PeoplePicker,
SaveIcon,
Select,
StateSelect,
Typography,
XmarkIcon
} from '@noahspan/noahspan-components';
import { IPilotFormProps } from './IPilotFormProps';
import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates';
import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements';
import { Person } from '@microsoft/microsoft-graph-types';
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { IPilotFormCertificates } from '../pilotFormCertificates/IPilotFormCertificates';
import { IPilotFormEndorsements } from '../pilotFormEndorsements/IPilotFormEndorsements';
import { EventMessage, EventPayload, EventType } from '@azure/msal-browser';
// const PilotForm: React.FC<unknown> = () => { const PilotForm: React.FC<IPilotFormProps> = ({
// const { control, handleSubmit } = useForm(); pilotId,
isDrawerOpen,
onOpenCloseDrawer
}: IPilotFormProps) => {
const httpClient: AxiosInstance = useHttpClient();
const [peoplePickerResults, setPeoplePickerResults] = useState<Person[]>([]);
const [isPeoplePickerLoading, setIsPeoplePickerLoading] =
useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { getAccessToken } = useAccessToken();
const methods = useForm();
// const onSubmit = (data: unknown) => { const handlePeoplePickerOnClick = (
// console.log(data); event: React.MouseEvent<HTMLDivElement>
// }; ) => {
const divElement: HTMLDivElement = event.target as HTMLDivElement;
// return ( methods.setValue('id', divElement.id);
// <form className="m-10" onSubmit={handleSubmit(onSubmit)}> methods.setValue('name', divElement.textContent);
// <div className="grid grid-cols-2 gap-4"> setPeoplePickerResults([]);
// <div className="self-center"> };
// <label>First Name</label>
// </div>
// <div>
// <Controller
// name="firstName"
// control={control}
// render={({ field }) => <Input />}
// />
// </div>
// <div className="self-center">
// <label>Last Name</label>
// </div>
// <div>
// <Controller
// name="lastName"
// control={control}
// render={({ field }) => <Input />}
// />
// </div>
// </div>
// <Accordion>
// <AccordionItem title="Medical Certificate">
// <div className="grid grid-cols-2 gap-4">
// <div className="self-center">
// <label>Class</label>
// </div>
// <div>
// <Controller
// name="medicalClass"
// control={control}
// render={({ field }) => (
// <Select>
// <SelectItem key="first" value="First">
// First
// </SelectItem>
// <SelectItem key="second" value="Second">
// Second
// </SelectItem>
// <SelectItem key="Third" value="Third">
// Third
// </SelectItem>
// </Select>
// )}
// />
// </div>
// <div className="self-center">
// <label>Expires</label>
// </div>
// <div>
// <Controller
// name="medicalExpiration"
// control={control}
// render={({ field }) => <DatePicker />}
// />
// </div>
// </div>
// </AccordionItem>
// </Accordion>
// </form>
// );
// };
// export default PilotForm; const handlePeoplePickerOnChange = async (
event: React.ChangeEvent<HTMLInputElement>
) => {
setIsPeoplePickerLoading(true);
try {
methods.setValue('name', event.target.value);
const searchString: string = event.target.value;
const accessToken: string = await getAccessToken();
const response: AxiosResponse = await httpClient.get(
`api/personSearch?search=${searchString}`,
{
headers: {
Authorization: accessToken
}
}
);
console.log(response);
setPeoplePickerResults(response.data);
} catch (error) {
console.log(error);
} finally {
setIsPeoplePickerLoading(false);
}
};
const onSubmit = async (data: unknown) => {
try {
setIsLoading(true);
const accessToken: string = await getAccessToken();
const response: AxiosResponse = await httpClient.post(
`api/pilots`,
data,
{
headers: {
Authorization: accessToken
}
}
);
if (response) console.log(response);
} catch (error) {
if (axios.isAxiosError(error)) {
const errResp = error.response;
console.log(errResp?.data.message);
} else {
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
console.log(methods.formState.errors);
}, [methods.formState.errors]);
return (
<Drawer
open={isDrawerOpen}
placement="right"
size={1000}
data-testid="pilot-drawer"
>
<FormProvider {...methods}>
<DrawerHeader text="Add Pilot" onClose={onOpenCloseDrawer} />
<form onSubmit={methods.handleSubmit(onSubmit)}>
<DrawerBody>
<div className="grid grid-cols-4 gap-4">
<div className="col-span-1">
<Typography variant="h6">Name *</Typography>
</div>
<div className="col-span-3">
<Controller
name="name"
control={methods.control}
rules={{ required: 'A name must be selected' }}
render={({ field: { disabled, value } }) => (
<PeoplePicker
results={peoplePickerResults}
inputProps={{
disabled: disabled,
labelProps: {
className: 'before:content-none after:content-none'
},
onChange: (event) => handlePeoplePickerOnChange(event),
error: methods.formState.errors.name ? true : false,
helperText: methods.formState.errors.name
? methods.formState.errors.name.message?.toString()
: undefined,
value: value
}}
listItemProps={{
children: null,
onClick: handlePeoplePickerOnClick
}}
loading={isPeoplePickerLoading}
data-testid="pilot-form-people-picker"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">Address *</Typography>
</div>
<div className="col-span-3">
<Controller
name="address"
control={methods.control}
rules={{ required: 'An address is required' }}
render={({ field: { disabled, onChange, value } }) => (
<Input
className="!border-t-blue-gray-200 focus:!border-t-gray-900"
disabled={disabled}
labelProps={{
className: 'before:content-none after:content-none'
}}
error={methods.formState.errors.address ? true : false}
helperText={
methods.formState.errors.address
? methods.formState.errors.address.message?.toString()
: undefined
}
onChange={onChange}
value={value}
data-testid="pilot-form-address-input"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">City *</Typography>
</div>
<div className="col-span-3">
<Controller
name="city"
control={methods.control}
rules={{ required: 'A city is required' }}
render={({ field: { disabled, onChange, value } }) => (
<Input
disabled={disabled}
labelProps={{
className: 'before:content-none after:content-none'
}}
error={methods.formState.errors.city ? true : false}
helperText={
methods.formState.errors.city
? methods.formState.errors.city.message?.toString()
: undefined
}
onChange={onChange}
value={value}
data-testid="pilot-form-city-input"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">State *</Typography>
</div>
<div className="col-span-3">
<Controller
name="state"
control={methods.control}
rules={{ required: 'A state must be selected' }}
render={({ field: { disabled, onChange, value } }) => (
<StateSelect
disabled={disabled}
error={methods.formState.errors.state ? true : false}
helperText={
methods.formState.errors.state
? methods.formState.errors.state.message?.toString()
: undefined
}
onChange={onChange}
value={value}
variant="outlined"
data-testid="pilot-form-state-dropdown"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">Postal Code *</Typography>
</div>
<div className="col-span-3">
<Controller
name="postalCode"
control={methods.control}
rules={{ required: 'A postal code is required' }}
render={({ field: { disabled, onChange, value } }) => (
<Input
disabled={disabled}
labelProps={{
className: 'before:content-none after:content-none'
}}
error={methods.formState.errors.postalCode ? true : false}
helperText={
methods.formState.errors.postalCode
? methods.formState.errors.postalCode.message?.toString()
: undefined
}
onChange={onChange}
value={value}
data-testid="pilot-form-postal-code-input"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">Email</Typography>
</div>
<div className="col-span-3">
<Controller
name="email"
control={methods.control}
rules={{
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address'
}
}}
render={({ field: { disabled, onChange, value } }) => (
<Input
disabled={disabled}
labelProps={{
className: 'before:content-none after:content-none'
}}
error={methods.formState.errors.email ? true : false}
helperText={
methods.formState.errors.email
? methods.formState.errors.email.message?.toString()
: undefined
}
onChange={onChange}
value={value}
data-testid="pilot-form-email-input"
/>
)}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">Phone Number</Typography>
</div>
<div className="col-span-3">
<Controller
name="phone"
control={methods.control}
rules={{
pattern: {
value: /^[0-9]{3}[-\s\.][0-9]{3}[-\s\.][0-9]{4}$/i,
message: 'Enter phone number as 123-456-7890'
}
}}
render={({ field: { disabled, onChange, value } }) => (
<Input
disabled={disabled}
labelProps={{
className: 'before:content-none after:content-none'
}}
error={methods.formState.errors.phone ? true : false}
helperText={
methods.formState.errors.phone
? methods.formState.errors.phone.message?.toString()
: undefined
}
onChange={onChange}
value={value}
data-testid="pilot-form-phone-input"
/>
)}
/>
</div>
{pilotId && (
<>
<div className="col-span-1">
<Typography variant="h6">Last Review</Typography>
</div>
<div className="col-span-3">
<Controller
name="lastReview"
control={methods.control}
render={({ field }) => {
return (
<DatePicker
handleDateChanged={(date: string) => {
methods.setValue('lastReview', date);
}}
inputProps={{
value: field.value
}}
/>
);
}}
/>
</div>
</>
)}
{pilotId && (
<>
<div className="col-span-4">
<Typography variant="h5">Medical</Typography>
<hr className="my-3" />
</div>
<div className="col-span-1">
<Typography variant="h6">Class</Typography>
</div>
<div className="col-span-3">
<Controller
name="medicalClass"
control={methods.control}
render={({ field }) => {
return (
<Select
labelProps={{
className:
'before:content-none after:content-none'
}}
{...field}
>
<Option key="first" value="First">
First
</Option>
<Option key="second" value="Second">
Second
</Option>
<Option key="third" value="Third">
Third
</Option>
<Option key="basicMed" value="Basic Med">
Basic Med
</Option>
</Select>
);
}}
/>
</div>
<div className="col-span-1">
<Typography variant="h6">Expiration Date</Typography>
</div>
<div className="col-span-3">
<Controller
name="medicalExpiration"
control={methods.control}
render={({ field }) => {
return (
<DatePicker
handleDateChanged={(date: string) => {
methods.setValue('medicalExpiration', date);
}}
inputProps={{
value: field.value
}}
/>
);
}}
/>
</div>
</>
)}
{pilotId && (
<>
<div className="col-span-4">
<Typography variant="h5">Certificates</Typography>
<hr className="my-3" />
</div>
<PilotFormCertificates certificates={[]} />
</>
)}
{pilotId && (
<>
<div className="col-span-4">
<Typography variant="h5">Endorsements</Typography>
<hr className="my-3" />
</div>
<PilotFormEndorsements endorsements={[]} />
</>
)}
</div>
</DrawerBody>
<DrawerFooter>
<div className="flex gap-2 justify-end justify-self-center pt-4">
<div>
<Button
className="flex items-center gap-3"
variant="outlined"
onClick={onOpenCloseDrawer}
data-testid="pilot-cancel-button"
>
<XmarkIcon size="lg" />
Cancel
</Button>
</div>
<div>
<Button
className="flex items-center gap-3"
loading={isLoading}
variant="filled"
type="submit"
data-testid="pilot-save-button"
>
<SaveIcon size="lg" />
Save
</Button>
</div>
</div>
</DrawerFooter>
</form>
</FormProvider>
</Drawer>
);
};
export default PilotForm;

View File

@@ -0,0 +1,5 @@
import { Certificate } from './certificate.type';
export interface IPilotFormCertificates {
certificates: Certificate[];
}

View File

@@ -0,0 +1,134 @@
import { IPilotFormCertificates } from './IPilotFormCertificates';
import {
Button,
DatePicker,
Input,
Option,
PlusIcon,
Select,
TrashIcon,
Typography
} from '@noahspan/noahspan-components';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
const PilotFormCertificates: React.FC<IPilotFormCertificates> = ({
certificates
}: IPilotFormCertificates) => {
const {
control,
formState: { errors },
setValue
} = useFormContext();
const { fields, append, remove } = useFieldArray({
name: 'certificates',
control
});
return (
<>
{fields.length > 0 && (
<>
<div className="col-span-1">
<Typography variant="h6">Type</Typography>
</div>
<div className="col-span-1">
<Typography variant="h6">Number</Typography>
</div>
<div className="col-span-1">
<Typography variant="h6">Date of Issue</Typography>
</div>
<div className="col-span-1"></div>
</>
)}
{fields.map((field, index) => {
return (
<>
<div className="col-span-1">
<Controller
name={`certificates.${index}.type`}
control={control}
render={({ field }) => {
return (
<Select label="Type" {...field}>
<Option key="student" value="Student">
Student
</Option>
<Option key="private" value="Private">
Private
</Option>
<Option key="instrument" value="Instrument">
Instrument
</Option>
<Option key="recreational" value="Recreational">
Recreational
</Option>
<Option key="sport" value="Sport">
Sport
</Option>
</Select>
);
}}
/>
</div>
<div className="col-span-1">
<Controller
name={`certificates.${index}.number`}
control={control}
render={({ field }) => {
return <Input label="Number" {...field} />;
}}
/>
</div>
<div className="col-span-1">
<Controller
name={`certificates.${index}.dateOfIssue`}
control={control}
render={({ field }) => {
return (
<DatePicker
handleDateChanged={(date: string) => {
setValue(`certificates.${index}.dateOfIssue`, date);
}}
inputProps={{
value: field.value
}}
/>
);
}}
/>
</div>
<div className="col-span-1">
<Button
className="flex items-center gap-3"
onClick={() => remove(index)}
variant="outlined"
>
<TrashIcon size="lg" />
Delete
</Button>
</div>
</>
);
})}
<div className="col-span-4">
<Button
className="flex items-center gap-3"
onClick={() => {
append({
type: '',
number: '',
dateOfIssue: null
});
}}
variant="outlined"
>
<PlusIcon size="lg" />
Add Certificate
</Button>
</div>
</>
);
};
export default PilotFormCertificates;

View File

@@ -0,0 +1,5 @@
export type Certificate = {
type: string;
number: string;
dateOfIssue: Date;
};

View File

@@ -0,0 +1,5 @@
import { Endorsement } from './endorsement.type';
export interface IPilotFormEndorsements {
endorsements: Endorsement[];
}

View File

@@ -0,0 +1,120 @@
import { IPilotFormEndorsements } from './IPilotFormEndorsements';
import {
Button,
DatePicker,
Input,
Option,
PlusIcon,
Select,
TrashIcon,
Typography
} from '@noahspan/noahspan-components';
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
const PilotFormEndorsements: React.FC<IPilotFormEndorsements> = ({
endorsements
}: IPilotFormEndorsements) => {
const {
control,
formState: { errors },
setValue
} = useFormContext();
const { fields, append, remove } = useFieldArray({
name: 'endorsements',
control
});
return (
<>
{fields.length > 0 && (
<>
<div className="col-span-1">
<Typography variant="h6">Type</Typography>
</div>
<div className="col-span-1">
<Typography variant="h6">Date of Issue</Typography>
</div>
<div className="col-span-1"></div>
<div className="col-span-1"></div>
</>
)}
{fields.map((field, index) => {
return (
<>
<div className="col-span-1">
<Controller
name={`endorsements.${index}.type`}
control={control}
render={({ field }) => {
return (
<Select label="Type" {...field}>
<Option key="complex" value="Complex">
Complex
</Option>
<Option key="highPerformance" value="High Performance">
High Performance
</Option>
<Option key="highAltitude" value="High Altitude">
High Altitude
</Option>
<Option key="tailwheel" value="Tailwheel">
Tailwheel
</Option>
</Select>
);
}}
/>
</div>
<div className="col-span-1">
<Controller
name={`endorsements.${index}.dateOfIssue`}
control={control}
render={({ field }) => {
return (
<DatePicker
handleDateChanged={(date: string) => {
setValue(`endorsements.${index}.dateOfIssue`, date);
}}
inputProps={{
value: field.value
}}
/>
);
}}
/>
</div>
<div className="col-span-1">
<Button
className="flex items-center gap-3"
onClick={() => remove(index)}
variant="outlined"
>
<TrashIcon size="lg" />
Delete
</Button>
</div>
</>
);
})}
<div className="col-span-4">
<Button
className="flex items-center gap-3"
onClick={() => {
append({
type: '',
number: '',
dateOfIssue: null
});
}}
variant="outlined"
>
<PlusIcon size="lg" />
Add Endorsement
</Button>
</div>
</>
);
};
export default PilotFormEndorsements;

View File

@@ -0,0 +1,5 @@
export type Endorsement = {
type: string;
number: string;
dateOfIssue: Date;
};

View File

@@ -1,65 +1,127 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import SiteNav from '../siteNav/SiteNav'; import PilotForm from '../pilotForm/PilotForm';
// import PilotForm from '../pilotForm/PilotForm';
import { Button } from '@nextui-org/react';
import { import {
Drawer, Button,
DrawerBody, EllipsisVerticalIcon,
DrawerContent, EyeIcon,
DrawerFooter, IconButton,
DrawerHeader Menu,
MenuHandler,
MenuItem,
MenuList,
PenIcon,
PlusIcon,
Table,
TableColumnDef,
TrashIcon,
Typography
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
const Pilots: React.FC<unknown> = () => { const Pilots: React.FC<unknown> = () => {
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [pilots, setPilots] = useState<Pilot[]>([]);
const onOpenCloseDrawer = () => { const onOpenCloseDrawer = () => {
setIsDrawerOpen(!isDrawerOpen); setIsDrawerOpen(!isDrawerOpen);
}; };
type Pilot = {
partitionKey: string;
rowKey: string;
id: string;
name: string;
};
const columns: TableColumnDef[] = [
{
accessorKey: 'name',
header: 'Name'
}
// {
// id: 'actions',
// header: 'Actions',
// cellProps: {
// className: 'text-center'
// },
// cell: () => {
// return (
// <Menu placement="bottom-end">
// <MenuHandler>
// <div>
// <IconButton variant="text">
// <EllipsisVerticalIcon size="xl" />
// </IconButton>
// </div>
// </MenuHandler>
// <MenuList>
// <MenuItem className="flex gap-3">
// <PenIcon size="lg" />
// Edit
// </MenuItem>
// <MenuItem className="flex gap-3">
// <EyeIcon size="lg" />
// View
// </MenuItem>
// <hr className="my-3" />
// <MenuItem className="flex gap-3">
// <TrashIcon size="lg" />
// Delete
// </MenuItem>
// </MenuList>
// </Menu>
// );
// },
// enableSorting: false
// }
];
useEffect(() => {
const getPilots = async () => {
try {
const accessToken: string = await getAccessToken();
const response: AxiosResponse = await httpClient.get(`api/pilots`, {
headers: {
Authorization: accessToken
}
});
console.log(response.data);
setPilots(response.data);
} catch (error) {
console.log(error);
}
};
getPilots();
}, []);
return ( return (
<div className="container mx-auto"> <>
<SiteNav /> <div className="grid grid-cols-1 gap-4 w-full rounded-xl py-4 px-8 shadow-md backdrop-saturate-200 backdrop-blur-2xl bg-opacity-80 border border-white/80 bg-white mt-6">
<div className="px-6"> <div className="col-span-1">
<h1 role="heading">Pilots</h1> <Typography variant="h2">Pilots</Typography>
<Button </div>
color="default" <div className="col-span-1 justify-self-end">
variant="light" <Button
onClick={onOpenCloseDrawer} className="flex justify-center gap-3"
startContent={<FontAwesomeIcon icon={faPlus} />} variant="filled"
data-testid="new-pilot-button" onClick={onOpenCloseDrawer}
> data-testid="pilot-add-button"
New >
</Button> <PlusIcon size="lg" />
<Drawer isOpen={isDrawerOpen} data-testid="pilot-drawer"> Add Pilot
<DrawerContent> </Button>
<DrawerHeader> </div>
<h2>Add Pilot</h2> {pilots.length > 0 && <Table defaultData={pilots} columns={columns} />}
</DrawerHeader>
<DrawerBody>{/* <PilotForm /> */}</DrawerBody>
<DrawerFooter>
<div className="flex gap-4 justify-end justify-self-center">
<div>
<Button
color="default"
onClick={onOpenCloseDrawer}
data-testid="pilot-drawer-cancel-button"
>
Cancel
</Button>
</div>
<div>
<Button color="primary" onClick={onOpenCloseDrawer}>
Save
</Button>
</div>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div> </div>
</div> <PilotForm
isDrawerOpen={isDrawerOpen}
onOpenCloseDrawer={onOpenCloseDrawer}
/>
</>
); );
}; };

View File

@@ -0,0 +1,7 @@
import { InteractionStatus } from '@azure/msal-browser';
export interface ISiteNavProps {
handleSignIn: () => void;
handleSignOut: () => void;
inProgress: InteractionStatus;
}

View File

@@ -1,67 +1,258 @@
import { useEffect, useState } from 'react';
import { ISiteNavProps } from './ISiteNavProps';
// import { Link as ReactRouterLink } from 'react-router-dom';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { import {
Avatar, Button,
Link, Menu,
MenuHandler,
MenuItem,
MenuList,
Navbar, Navbar,
NavbarBrand, NavbarBrand,
NavbarContent, NavbarLinks,
NavbarItem, NavbarMenu,
Button NavbarItemProps,
} from '@nextui-org/react'; PlaneIcon,
import { Link as ReactRouterLink } from 'react-router-dom'; SignOutIcon,
Spinner,
Typography
} from '@noahspan/noahspan-components';
import { useIsAuthenticated, useMsal } from '@azure/msal-react'; import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { useAppContext } from '../../hooks/appContext/UseAppContext'; import { InteractionStatus } from '@azure/msal-browser';
import { Logo, Plane } from '@noahspan/noahspan-components'; 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 { EventMessage, EventPayload, EventType } from '@azure/msal-browser';
type EventPayloadExtended = EventPayload & { accessToken: string };
const SiteNav: React.FC<unknown> = () => { const SiteNav: React.FC<unknown> = () => {
const isAuthenticated = useIsAuthenticated(); const [loading, setLoading] = useState<boolean>(false);
const { accounts, instance } = useMsal(); const [userPhoto, setUserPhoto] = useState<string>();
const initializeLogin = () => { const httpClient: AxiosInstance = useHttpClient();
instance.loginRedirect();
};
const appContext = useAppContext(); const appContext = useAppContext();
const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken();
const { inProgress, instance } = useMsal();
const navItems: NavbarItemProps[] = [
{
name: 'Pilots',
url: '#'
}
];
const handleSignIn = async () => {
await instance.loginRedirect({
scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`]
});
};
const handleSignOut = () => {
instance.logoutRedirect();
};
const getUserProfile = async (accessToken: string): Promise<User> => {
try {
const response: AxiosResponse = await httpClient.get(`api/userProfile`, {
headers: {
Authorization: accessToken
}
});
const userProfile: User = response.data;
console.log(accounts); return userProfile;
} catch (error) {
throw new Error();
}
};
const getUserPhoto = async (accessToken: string): Promise<string> => {
try {
const response: AxiosResponse = await httpClient.get(`api/userPhoto`, {
headers: {
Authorization: accessToken
},
responseType: 'arraybuffer'
});
const arrayBufferView = new Uint8Array(response.data);
const blob = new Blob([arrayBufferView], { type: 'image/png' });
const imageUrl = window.URL.createObjectURL(blob);
return imageUrl;
} catch (error) {
console.log(error);
throw new Error();
}
};
useEffect(() => {
const callback = instance.addEventCallback(
async (message: EventMessage) => {
if (message.eventType === EventType.LOGIN_SUCCESS) {
try {
setLoading(true);
const eventPayload: EventPayloadExtended =
message.payload as EventPayloadExtended;
const userProfile: User = await getUserProfile(
eventPayload.accessToken
);
const userPhoto = await getUserPhoto(eventPayload.accessToken);
appContext.dispatch({
type: 'SET_USER_PROFILE',
payload: userProfile
});
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
}
}
);
return () => {
if (callback) {
instance.removeEventCallback(callback);
appContext.dispatch({ type: 'SET_USER_PROFILE', payload: {} });
}
};
}, []);
useEffect(() => {
const setUserProfile = async () => {
try {
setLoading(true);
const accessToken: string = await getAccessToken();
const userProfile = await getUserProfile(accessToken);
const userPhoto = await getUserPhoto(accessToken);
setUserPhoto(userPhoto);
appContext.dispatch({
type: 'SET_USER_PROFILE',
payload: userProfile
});
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if (
isAuthenticated &&
Object.keys(appContext.state.userProfile).length === 0
) {
setUserProfile();
}
}, [isAuthenticated]);
return ( return (
<Navbar <Navbar className="mt-6">
classNames={{
base: 'bg-transparent z-0'
}}
isBlurred={false}
maxWidth="full"
data-testid="flying-navbar"
>
<NavbarBrand> <NavbarBrand>
<Logo className="pr-3" height={50} width={50} data-testid="logo" /> <img height={40} width={40} src="noahspan-logo.png" />{' '}
<Plane size="2xl" /> <PlaneIcon size="2x" />
</NavbarBrand> </NavbarBrand>
<NavbarContent justify="center"> <NavbarLinks items={navItems} />
<NavbarItem> <NavbarMenu>
{appContext.state.featureFlags.find( <div className="flex items-center gap-2 hidden lg:inline-block">
(featureFlag) => featureFlag.key === 'flying-pilots' {!loading && isAuthenticated && (
)?.enabled && ( <Menu placement="bottom-end">
<Link> <MenuHandler>
<ReactRouterLink to="/">Pilots</ReactRouterLink> <>
</Link> {/* {userPhoto && */}
{/* <img className='rounded-full' src={userPhoto} /> */}
{/* } */}
{/* {!userPhoto && */}
<div className="rounded-full text-white text-center pt-2 bg-black h-[40px] w-[40px]">
NS
</div>
{/* <div className="flex gap-2">
<div className='flex-none'>
</div>
<Button className='flex-1' variant="text" size="sm">
{appContext.state.userProfile.displayName}
</Button>
</div> */}
</>
</MenuHandler>
<MenuList>
<MenuItem onClick={handleSignOut}>
<Typography
className="flex justify-center gap-3"
variant="small"
>
<SignOutIcon size="lg" />
Sign Out
</Typography>
</MenuItem>
</MenuList>
</Menu>
)} )}
</NavbarItem> {loading && isAuthenticated && (
</NavbarContent> <div className="flex justify-center gap-3">
<NavbarContent justify="end"> <Spinner size="xs" />
{!isAuthenticated && ( <Typography variant="small">Loading...</Typography>
<Button </div>
as={Link} )}
color="primary" {!isAuthenticated && (
href="#" <Button
onClick={initializeLogin} variant="text"
data-testid="login-link" size="sm"
> onClick={handleSignIn}
Login loading={inProgress === InteractionStatus.Login ? true : false}
</Button> >
)} Sign In
{isAuthenticated && <Avatar name={accounts[0]?.username} />} </Button>
</NavbarContent> )}
</div>
{/* <IconButton
variant='text'
className='ml-auto h-6 w-6 text-inherit hover:bg-transparent focus:bg-transparent active:bg-transparent lg:hidden'
ripple={false}
onClick={() => setOpenNav(!openNav)}
>
<FontAwesomeIcon icon={faBars} size='2x' />
</IconButton> */}
</NavbarMenu>
</Navbar> </Navbar>
); );
// return (
// <Navbar
// classNames={{
// base: 'bg-transparent z-0'
// }}
// isBlurred={false}
// maxWidth="full"
// data-testid="flying-navbar"
// >
// <NavbarBrand>
// <Logo className="pr-3" height={50} width={50} data-testid="logo" />
// <Plane size="2xl" />
// </NavbarBrand>
// <NavbarContent justify="center">
// <NavbarItem>
// {appContext.state.featureFlags.find(
// (featureFlag) => featureFlag.key === 'flying-pilots'
// )?.enabled && (
// <Link>
// <ReactRouterLink to="/">Pilots</ReactRouterLink>
// </Link>
// )}
// </NavbarItem>
// </NavbarContent>
// <NavbarContent justify='end'>
// <Login
// loginCompleted={loginCompleted}
// loginView='compact'
// />
// </NavbarContent>
// </Navbar>
// );
}; };
export default SiteNav; export default SiteNav;

View File

@@ -9,7 +9,8 @@ const AppContextProvider: React.FC<IAppContextProviderProps> = (
props: IAppContextProviderProps props: IAppContextProviderProps
) => { ) => {
const intialState: IAppContextState = { const intialState: IAppContextState = {
featureFlags: [] featureFlags: [],
userProfile: {}
}; };
const [state, dispatch] = useReducer(reducer, intialState); const [state, dispatch] = useReducer(reducer, intialState);
const contextValue: IAppContextProps = useMemo(() => { const contextValue: IAppContextProps = useMemo(() => {

View File

@@ -1,3 +1,6 @@
import { User } from '@microsoft/microsoft-graph-types';
export interface IAppContextState { export interface IAppContextState {
featureFlags: { key: string; enabled: boolean }[]; featureFlags: { key: string; enabled: boolean }[];
userProfile: User;
} }

View File

@@ -1,9 +1,9 @@
import { User } from '@microsoft/microsoft-graph-types';
import { IAppContextState } from './IAppContextState'; import { IAppContextState } from './IAppContextState';
export type Action = { export type Action =
type: 'SET_FEATURE_FLAGS'; | { type: 'SET_FEATURE_FLAGS'; payload: { key: string; enabled: boolean }[] }
payload: { key: string; enabled: boolean }[]; | { type: 'SET_USER_PROFILE'; payload: User };
};
export const reducer = ( export const reducer = (
state: IAppContextState, state: IAppContextState,
@@ -16,6 +16,12 @@ export const reducer = (
featureFlags: action.payload featureFlags: action.payload
}; };
} }
case 'SET_USER_PROFILE': {
return {
...state,
userProfile: action.payload
};
}
default: { default: {
return state; return state;
} }

View File

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,24 @@
import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios';
export const useHttpClient = () => {
let config: CreateAxiosDefaults<any>;
if (import.meta.env.DEV) {
config = {
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json'
}
};
} else {
config = {
headers: {
'Content-Type': 'application/json'
}
};
}
const httpClient: AxiosInstance = axios.create(config);
return httpClient;
};

View File

@@ -3,10 +3,8 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
h1 { body {
@apply text-2xl font-bold leading-7 text-gray-900; @apply bg-[#ECEFF1];
} @apply text-black;
h2 {
@apply text-2xl font-bold leading-7 text-gray-900;
} }
} }

View File

@@ -1,34 +1,28 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { MsalProvider } from '@azure/msal-react';
import { Configuration, PublicClientApplication } from '@azure/msal-browser';
import { NextUIProvider } from '@nextui-org/react';
import App from './App.tsx'; import App from './App.tsx';
import { BrowserRouter } from 'react-router-dom';
import AppContextProvider from './context/appContext/AppContextProvider.tsx'; import AppContextProvider from './context/appContext/AppContextProvider.tsx';
import { BrowserRouter } from 'react-router-dom';
import './index.css'; import './index.css';
import '@noahspan/noahspan-components/noahspan-components.css'; import '@noahspan/noahspan-components/noahspan-components.css';
import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
const configuration: Configuration = { const pca: PublicClientApplication = new PublicClientApplication({
auth: { auth: {
clientId: 'd3562a45-050d-4f9a-baed-0497c7156924', clientId: import.meta.env.VITE_CLIENT_ID,
authority: authority: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}`,
'https://login.microsoftonline.com/0f23652e-4b15-420f-991e-3d6fc769a31d', redirectUri: import.meta.env.VITE_REDIRECT_URL
redirectUri: 'http://localhost:5173'
} }
}; });
const pca = new PublicClientApplication(configuration);
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<MsalProvider instance={pca}> <MsalProvider instance={pca}>
<AppContextProvider> <AppContextProvider>
<NextUIProvider> <BrowserRouter>
<BrowserRouter> <App />
<App /> </BrowserRouter>
</BrowserRouter>
</NextUIProvider>
</AppContextProvider> </AppContextProvider>
</MsalProvider> </MsalProvider>
</React.StrictMode> </React.StrictMode>

11
app/src/vite-env.d.ts vendored
View File

@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_CLIENT_ID: string;
readonly VITE_TENANT_ID: string;
readonly VITE_REDIRECT_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,15 +1,9 @@
const { nextui } = require('@nextui-org/react');
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
'../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'
],
theme: { theme: {
extend: {} extend: {}
}, },
darkMode: 'class', darkMode: 'class',
plugins: [nextui()] plugins: []
}; };

View File

@@ -16,8 +16,8 @@
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],

View File

@@ -1,7 +1,23 @@
resource "azurerm_storage_account" "storage_account" { resource "azurerm_storage_account" "storage_account_dev" {
name = var.STORAGE_ACCOUNT_NAME name = "${var.STORAGE_ACCOUNT_NAME}dev"
resource_group_name = data.azurerm_resource_group.resource_group.name resource_group_name = data.azurerm_resource_group.resource_group.name
location = data.azurerm_resource_group.resource_group.location location = data.azurerm_resource_group.resource_group.location
account_tier = "Standard" account_tier = "Standard"
account_replication_type = "GRS" account_replication_type = "LRS"
}
resource "azurerm_storage_account" "storage_account_staging" {
name = "${var.STORAGE_ACCOUNT_NAME}staging"
resource_group_name = data.azurerm_resource_group.resource_group.name
location = data.azurerm_resource_group.resource_group.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_storage_account" "storage_account_prod" {
name = "${var.STORAGE_ACCOUNT_NAME}prod"
resource_group_name = data.azurerm_resource_group.resource_group.name
location = data.azurerm_resource_group.resource_group.location
account_tier = "Standard"
account_replication_type = "LRS"
} }

7371
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"tests" "tests"
], ],
"scripts": { "scripts": {
"start": "concurrently 'npm run start:azure -w api' 'wait-on tcp:0.0.0.0:7071 && npm run dev -w app'",
"format": "prettier --write \"**/src/**/*.ts\" \"**/test/**/*.ts\"", "format": "prettier --write \"**/src/**/*.ts\" \"**/test/**/*.ts\"",
"lint": "npm run lint -w api && npm run lint -w app", "lint": "npm run lint -w api && npm run lint -w app",
"prepare": "husky || true" "prepare": "husky || true"
@@ -14,13 +15,15 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^7.2.0",
"concurrently": "^8.2.2",
"eslint": "^8.42.0", "eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"husky": "^9.0.11", "husky": "^9.0.11",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"prettier": "3.2.5" "prettier": "3.2.5",
"wait-on": "^7.2.0"
}, },
"lint-staged": { "lint-staged": {
"**/*": "prettier --write \"**/src/**/*.ts\" \"**/test/**/*.ts\" --ignore-unknown" "**/*": "prettier --write \"**/src/**/*.ts\" \"**/test/**/*.ts\" --ignore-unknown"

View File

@@ -6,9 +6,18 @@ Feature: Pilots
When the user clicks the "Pilots" navbar link When the user clicks the "Pilots" navbar link
Then the user is on the "Pilots" page Then the user is on the "Pilots" page
Scenario: Cancel Add Pilot drawer # Scenario: Cancel Pilot drawer
Given the user is on the "Pilots" page # Given the user is on the "Pilots" page
When the user clicks the New button # When the user clicks the New button
Then the pilot drawer is visible # Then the pilot drawer is visible
When the user clicks the pilot drawer cancel button # When the user clicks the pilot drawer cancel button
Then the pilot drawer is no longer visible # Then the pilot drawer is no longer visible
# Scenario: Add pilot
# Given the user is on the "Pilots" page
# When the user clicks the Add Pilot button
# And the pilot drawer is visible
# And the user enters the pilot's information
# And the user clicks the Save button
# Then the Add Pilot drawer is no longer visible
# And the pilot is in the Pilots list

View File

@@ -3,24 +3,28 @@ import { config } from '../support/config';
export class PilotsPage { export class PilotsPage {
page: Page; page: Page;
newButton: Locator;
pilotDrawer: Locator; pilotDrawer: Locator;
pilotDrawerCancelButton: Locator; pilotAddButton: Locator;
pilotSaveButton: Locator;
pilotCancelButton: Locator;
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.newButton = page.getByTestId('new-pilot-button');
this.pilotDrawer = page.getByTestId('pilot-drawer'); this.pilotDrawer = page.getByTestId('pilot-drawer');
this.pilotDrawerCancelButton = page.getByTestId( this.pilotAddButton = page.getByTestId('add-pilot-button');
'pilot-drawer-cancel-button' this.pilotSaveButton = page.getByTestId('save-pilot-button');
); this.pilotCancelButton = page.getByTestId('pilot-form-cancel-button');
} }
public async clickNewButton() { public async clickAddPilotButton() {
await this.newButton.click(); await this.pilotAddButton.click();
}
public async clickSaveButton() {
await this.pilotSaveButton.click();
} }
public async clickPilotDrawerCancelButton() { public async clickPilotDrawerCancelButton() {
await this.pilotDrawerCancelButton.click(); await this.pilotCancelButton.click();
} }
} }

View File

@@ -4,11 +4,27 @@ import { config } from '../support/config';
import { expect } from '@playwright/test'; import { expect } from '@playwright/test';
import { PilotsPage } from '../pages/pilots.page'; import { PilotsPage } from '../pages/pilots.page';
When('the user clicks the New button', async function (this: ICustomWorld) { When(
const pilotsPage = new PilotsPage(this.page!); 'the user clicks the Add Pilot button',
async function (this: ICustomWorld) {
const pilotsPage = new PilotsPage(this.page!);
await pilotsPage.clickNewButton(); await pilotsPage.clickAddPilotButton();
}); }
);
When(
`the user enters the pilot's information`,
async function (this: ICustomWorld) {
const pilotsPage = new PilotsPage(this.page!);
}
);
// When(`the user clicks the Save button`, async function (this: ICustomWorld) {
// const pilotsPage = new PilotsPage(this.page!);
// await pil
// })
When( When(
'the user clicks the pilot drawer cancel button', 'the user clicks the pilot drawer cancel button',

View File

@@ -16,6 +16,7 @@
"@cucumber/cucumber": "^10.7.0", "@cucumber/cucumber": "^10.7.0",
"@playwright/test": "^1.44.0", "@playwright/test": "^1.44.0",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"fs-extra": "^11.2.0" "fs-extra": "^11.2.0",
"otpauth": "^9.3.2"
} }
} }