Feature/4 pilots add #20

Merged
noahspannbauer merged 64 commits from feature/4-pilots---add into main 2024-09-23 21:52:59 -04:00
53 changed files with 4184 additions and 13161 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -5,6 +5,12 @@ on:
branches:
- 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:
build_and_deploy_job:
runs-on: ubuntu-latest
@@ -20,11 +26,10 @@ jobs:
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: "upload"
action: 'upload'
app_build_command: ${{ vars.APP_BUILD_COMMAND }}
app_location: ${{ vars.APP_LOCATION }}
api_build_command: ${{ vars.API_BUILD_COMMAND }}
api_location: ${{ vars.API_LOCATION}}
output_location: ${{ vars.OUTPUT_LOCATION }}
deployment_environment: "development"
deployment_environment: 'development'

1
.gitignore vendored
View File

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

View File

@@ -20,7 +20,9 @@
"start:azure": "npm run build && func host start"
},
"dependencies": {
"@azure/data-tables": "^13.2.2",
"@azure/functions": "^1.0.3",
"@nestjs/axios": "^3.0.3",
"@nestjs/azure-database": "^3.0.0",
"@nestjs/azure-func-http": "^0.10.0",
"@nestjs/common": "^10.0.0",
@@ -28,13 +30,14 @@
"@nestjs/core": "^10.0.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@noahspan/noahspan-modules": "^0.2.7",
"@noahspan/noahspan-modules": "^0.3.9",
"@schematics/angular": "^17.3.7",
"dotenv": "^16.4.5",
"reflect-metadata": "0.1.13",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^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 {
AppConfigService,
MsGraphService,
@@ -6,10 +13,17 @@ import {
} from '@noahspan/noahspan-modules';
import { FeatureFlagValue } from '@azure/app-configuration';
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()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly appConfigService: AppConfigService,
private readonly msGraphService: MsGraphService
) {}
@@ -37,21 +51,58 @@ export class AppController {
}
}
@Public()
@Get('profilePhoto')
async getProfilePhoto(@Query() query: any): Promise<any> {}
@Get('userPhoto')
async getProfilePhoto(@Headers() headers: any): Promise<StreamableFile> {
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')
async getUserProfile(@Query() query: any) {
async getUserProfile(@Headers() headers: any) {
try {
const username: string = query.username;
const graphToken: string = await this.msGraphService.getMsGraphAuth(
headers.authorization.replace('Bearer ', ''),
['user.read']
);
const client: MsGraphClient =
await this.msGraphService.getMsGraphClient();
const userProfile = await client.api(`users/${username}`).get();
await this.msGraphService.getMsGraphClientDelegated(graphToken);
const userProfile = await client.api(`me`).get();
return userProfile;
} catch (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';
import { ConfigModule } from '@nestjs/config';
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({
imports: [
@@ -28,7 +31,8 @@ import { APP_GUARD } from '@nestjs/core';
tenantId: process.env.TENANT_ID,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET
})
}),
PilotModule
],
controllers: [AppController],
providers: [
@@ -36,6 +40,10 @@ import { APP_GUARD } from '@nestjs/core';
provide: APP_GUARD,
useClass: AuthGuard
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
},
AppService
]
})

View File

@@ -1,8 +1,44 @@
import { Injectable } from '@nestjs/common';
import { MsGraphService, MsGraphClient } from '@noahspan/noahspan-modules';
import { Person } from '@microsoft/microsoft-graph-types';
@Injectable()
export class AppService {
constructor(private readonly msGraphService: MsGraphService) {}
getHello(): string {
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 { AppModule } from './app.module';
import { HttpService } from '@nestjs/axios';
import { HttpExceptionFilter } from './filters/http-exception.filter';
import { InternalServerErrorException } from '@nestjs/common';
async function bootstrap() {
const httpService = new HttpService();
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);
}
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"
},
"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/free-regular-svg-icons": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@nextui-org/react": "^2.3.6",
"@noahspan/noahspan-components": "^0.2.5",
"@noahspan/noahspan-components": "^0.6.8",
"axios": "^1.7.2",
"framer-motion": "^11.1.7",
"react": "^18.2.0",
@@ -25,6 +25,7 @@
"react-router-dom": "^6.23.0"
},
"devDependencies": {
"@microsoft/microsoft-graph-types": "^2.40.0",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",

View File

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

View File

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

View File

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

View File

@@ -1,85 +1,473 @@
// import {
// Accordion,
// AccordionItem,
// Button,
// DatePicker,
// Input,
// Select,
// SelectItem
// } from '@nextui-org/react';
// import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { useEffect, useState } from 'react';
import {
useForm,
Controller,
FormProvider,
FieldValues
} from 'react-hook-form';
import {
Button,
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 { control, handleSubmit } = useForm();
const PilotForm: React.FC<IPilotFormProps> = ({
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) => {
// console.log(data);
// };
const handlePeoplePickerOnClick = (
event: React.MouseEvent<HTMLDivElement>
) => {
const divElement: HTMLDivElement = event.target as HTMLDivElement;
// return (
// <form className="m-10" onSubmit={handleSubmit(onSubmit)}>
// <div className="grid grid-cols-2 gap-4">
// <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>
// );
// };
methods.setValue('id', divElement.id);
methods.setValue('name', divElement.textContent);
setPeoplePickerResults([]);
};
// 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 SiteNav from '../siteNav/SiteNav';
// import PilotForm from '../pilotForm/PilotForm';
import { Button } from '@nextui-org/react';
import { useEffect, useState } from 'react';
import PilotForm from '../pilotForm/PilotForm';
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerFooter,
DrawerHeader
Button,
EllipsisVerticalIcon,
EyeIcon,
IconButton,
Menu,
MenuHandler,
MenuItem,
MenuList,
PenIcon,
PlusIcon,
Table,
TableColumnDef,
TrashIcon,
Typography
} from '@noahspan/noahspan-components';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
const Pilots: React.FC<unknown> = () => {
const httpClient: AxiosInstance = useHttpClient();
const { getAccessToken } = useAccessToken();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [pilots, setPilots] = useState<Pilot[]>([]);
const onOpenCloseDrawer = () => {
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 (
<div className="container mx-auto">
<SiteNav />
<div className="px-6">
<h1 role="heading">Pilots</h1>
<>
<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="col-span-1">
<Typography variant="h2">Pilots</Typography>
</div>
<div className="col-span-1 justify-self-end">
<Button
color="default"
variant="light"
className="flex justify-center gap-3"
variant="filled"
onClick={onOpenCloseDrawer}
startContent={<FontAwesomeIcon icon={faPlus} />}
data-testid="new-pilot-button"
data-testid="pilot-add-button"
>
New
</Button>
<Drawer isOpen={isDrawerOpen} data-testid="pilot-drawer">
<DrawerContent>
<DrawerHeader>
<h2>Add Pilot</h2>
</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
<PlusIcon size="lg" />
Add Pilot
</Button>
</div>
<div>
<Button color="primary" onClick={onOpenCloseDrawer}>
Save
</Button>
</div>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
</div>
{pilots.length > 0 && <Table defaultData={pilots} columns={columns} />}
</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 {
Avatar,
Link,
Button,
Menu,
MenuHandler,
MenuItem,
MenuList,
Navbar,
NavbarBrand,
NavbarContent,
NavbarItem,
Button
} from '@nextui-org/react';
import { Link as ReactRouterLink } from 'react-router-dom';
NavbarLinks,
NavbarMenu,
NavbarItemProps,
PlaneIcon,
SignOutIcon,
Spinner,
Typography
} from '@noahspan/noahspan-components';
import { useIsAuthenticated, useMsal } from '@azure/msal-react';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { Logo, Plane } from '@noahspan/noahspan-components';
import { InteractionStatus } from '@azure/msal-browser';
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 isAuthenticated = useIsAuthenticated();
const { accounts, instance } = useMsal();
const initializeLogin = () => {
instance.loginRedirect();
};
const [loading, setLoading] = useState<boolean>(false);
const [userPhoto, setUserPhoto] = useState<string>();
const httpClient: AxiosInstance = useHttpClient();
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 (
<Navbar
classNames={{
base: 'bg-transparent z-0'
}}
isBlurred={false}
maxWidth="full"
data-testid="flying-navbar"
>
<Navbar className="mt-6">
<NavbarBrand>
<Logo className="pr-3" height={50} width={50} data-testid="logo" />
<Plane size="2xl" />
<img height={40} width={40} src="noahspan-logo.png" />{' '}
<PlaneIcon size="2x" />
</NavbarBrand>
<NavbarContent justify="center">
<NavbarItem>
{appContext.state.featureFlags.find(
(featureFlag) => featureFlag.key === 'flying-pilots'
)?.enabled && (
<Link>
<ReactRouterLink to="/">Pilots</ReactRouterLink>
</Link>
<NavbarLinks items={navItems} />
<NavbarMenu>
<div className="flex items-center gap-2 hidden lg:inline-block">
{!loading && isAuthenticated && (
<Menu placement="bottom-end">
<MenuHandler>
<>
{/* {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>
)}
{loading && isAuthenticated && (
<div className="flex justify-center gap-3">
<Spinner size="xs" />
<Typography variant="small">Loading...</Typography>
</div>
)}
</NavbarItem>
</NavbarContent>
<NavbarContent justify="end">
{!isAuthenticated && (
<Button
as={Link}
color="primary"
href="#"
onClick={initializeLogin}
data-testid="login-link"
variant="text"
size="sm"
onClick={handleSignIn}
loading={inProgress === InteractionStatus.Login ? true : false}
>
Login
Sign In
</Button>
)}
{isAuthenticated && <Avatar name={accounts[0]?.username} />}
</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>
);
// 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;

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import { User } from '@microsoft/microsoft-graph-types';
import { IAppContextState } from './IAppContextState';
export type Action = {
type: 'SET_FEATURE_FLAGS';
payload: { key: string; enabled: boolean }[];
};
export type Action =
| { type: 'SET_FEATURE_FLAGS'; payload: { key: string; enabled: boolean }[] }
| { type: 'SET_USER_PROFILE'; payload: User };
export const reducer = (
state: IAppContextState,
@@ -16,6 +16,12 @@ export const reducer = (
featureFlags: action.payload
};
}
case 'SET_USER_PROFILE': {
return {
...state,
userProfile: action.payload
};
}
default: {
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;
@layer base {
h1 {
@apply text-2xl font-bold leading-7 text-gray-900;
}
h2 {
@apply text-2xl font-bold leading-7 text-gray-900;
body {
@apply bg-[#ECEFF1];
@apply text-black;
}
}

View File

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

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

@@ -1 +1,12 @@
/// <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} */
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
'../node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}'
],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {}
},
darkMode: 'class',
plugins: [nextui()]
plugins: []
};

View File

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

View File

@@ -1,7 +1,23 @@
resource "azurerm_storage_account" "storage_account" {
name = var.STORAGE_ACCOUNT_NAME
resource "azurerm_storage_account" "storage_account_dev" {
name = "${var.STORAGE_ACCOUNT_NAME}dev"
resource_group_name = data.azurerm_resource_group.resource_group.name
location = data.azurerm_resource_group.resource_group.location
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"
}

7369
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,9 +6,18 @@ Feature: Pilots
When the user clicks the "Pilots" navbar link
Then the user is on the "Pilots" page
Scenario: Cancel Add Pilot drawer
Given the user is on the "Pilots" page
When the user clicks the New button
Then the pilot drawer is visible
When the user clicks the pilot drawer cancel button
Then the pilot drawer is no longer visible
# Scenario: Cancel Pilot drawer
# Given the user is on the "Pilots" page
# When the user clicks the New button
# Then the pilot drawer is visible
# When the user clicks the pilot drawer cancel button
# 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 {
page: Page;
newButton: Locator;
pilotDrawer: Locator;
pilotDrawerCancelButton: Locator;
pilotAddButton: Locator;
pilotSaveButton: Locator;
pilotCancelButton: Locator;
constructor(page: Page) {
this.page = page;
this.newButton = page.getByTestId('new-pilot-button');
this.pilotDrawer = page.getByTestId('pilot-drawer');
this.pilotDrawerCancelButton = page.getByTestId(
'pilot-drawer-cancel-button'
);
this.pilotAddButton = page.getByTestId('add-pilot-button');
this.pilotSaveButton = page.getByTestId('save-pilot-button');
this.pilotCancelButton = page.getByTestId('pilot-form-cancel-button');
}
public async clickNewButton() {
await this.newButton.click();
public async clickAddPilotButton() {
await this.pilotAddButton.click();
}
public async clickSaveButton() {
await this.pilotSaveButton.click();
}
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 { PilotsPage } from '../pages/pilots.page';
When('the user clicks the New button', async function (this: ICustomWorld) {
When(
'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(
'the user clicks the pilot drawer cancel button',

View File

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