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

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 (
<Routes>
{useFeatureFlag('flying-pilots')?.enabled && (
<Route path="/" element={<Pilots />} />
)}
</Routes>
<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>
<Button
color="default"
variant="light"
onClick={onOpenCloseDrawer}
startContent={<FontAwesomeIcon icon={faPlus} />}
data-testid="new-pilot-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
</Button>
</div>
<div>
<Button color="primary" onClick={onOpenCloseDrawer}>
Save
</Button>
</div>
</div>
</DrawerFooter>
</DrawerContent>
</Drawer>
<>
<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
className="flex justify-center gap-3"
variant="filled"
onClick={onOpenCloseDrawer}
data-testid="pilot-add-button"
>
<PlusIcon size="lg" />
Add Pilot
</Button>
</div>
{pilots.length > 0 && <Table defaultData={pilots} columns={columns} />}
</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 {
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>
)}
</NavbarItem>
</NavbarContent>
<NavbarContent justify="end">
{!isAuthenticated && (
<Button
as={Link}
color="primary"
href="#"
onClick={initializeLogin}
data-testid="login-link"
>
Login
</Button>
)}
{isAuthenticated && <Avatar name={accounts[0]?.username} />}
</NavbarContent>
{loading && isAuthenticated && (
<div className="flex justify-center gap-3">
<Spinner size="xs" />
<Typography variant="small">Loading...</Typography>
</div>
)}
{!isAuthenticated && (
<Button
variant="text"
size="sm"
onClick={handleSignIn}
loading={inProgress === InteractionStatus.Login ? true : false}
>
Sign In
</Button>
)}
</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>
<BrowserRouter>
<App />
</BrowserRouter>
</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"],