First commit
Some checks failed
Main / changes (push) Successful in 34s
Main / deploy (push) Has been cancelled
Main / build-and-test (push) Has been cancelled

This commit is contained in:
2026-07-17 12:28:28 -05:00
commit 9d9cdbaebc
183 changed files with 9015 additions and 0 deletions

6
client/.env Normal file
View File

@@ -0,0 +1,6 @@
VITE_API_URL=http://localhost:3000
# VITE_API_URL=https://flying-api-dev.greensea-e83e7646.centralus.azurecontainerapps.io/
VITE_CLIENT_ID=b9beb879-aea1-4434-af04-7ab233021b48
VITE_TENANT_ID=7fdd758c-7277-4d47-bf0a-0b8b164ef2a6
VITE_REDIRECT_URL=http://localhost:8080
VITE_ISSUER_URI="https://7fdd758c-7277-4d47-bf0a-0b8b164ef2a6.ciamlogin.com/7fdd758c-7277-4d47-bf0a-0b8b164ef2a6/v2.0/"

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
client/README.md Normal file
View File

@@ -0,0 +1 @@
# APP

28
client/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Noah Spannbauer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

47
client/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "app",
"private": true,
"version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.16.1",
"oidc-spa": "^10.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.77.0",
"react-router-dom": "^7.1.5",
"tailwindcss": "^4.3.0",
"typeorm-extension": "^3.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.5.20",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
},
"files": [
"dist"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

1
client/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

23
client/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Route, Routes } from 'react-router-dom';
import Profiles from './components/profiles/Profiles';
import Experiences from './components/experiences/Experiences';
import Projects from './components/projects/Projects';
import SiteNav from './components/siteNav/SiteNav';
import SkillsPage from './components/skillsPage/SkillsPage';
const App = () => {
return (
<div className='bg-[#f5f5f5]' data-theme="lofi">
<SiteNav />
<Routes>
<Route path='/' element={<Profiles />} />
<Route path='/experiences' element={<Experiences />} />
<Route path='/projects' element={<Projects />} />
<Route path='/skills' element={<SkillsPage />} />
</Routes>
</div>
)
}
export default App;

View File

@@ -0,0 +1,7 @@
export interface AlertProps {
children?: React.ReactNode;
className?: string;
closeIcon?: React.ReactNode;
onClose?: () => void;
severity: 'info' | 'error' | 'success' | 'warning';
}

View File

@@ -0,0 +1,44 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AlertProps } from "./AlertProps.interface";
import { faCircleCheck, faCircleInfo, faCircleXmark, faTriangleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons";
const Alert = ({
children,
className,
closeIcon,
severity,
onClose,
...rest
}: AlertProps) => {
const severityVariants = {
info: 'alert-info',
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning'
};
return (
<>
<div
role='alert'
className={`alert ${severity ? severityVariants[severity] : ''} ${className ? className : ''}`}
{...rest}
>
<span>
{severity === 'info' && <FontAwesomeIcon icon={faCircleInfo} />}
{severity === 'error' && <FontAwesomeIcon icon={faCircleXmark} />}
{severity === 'success' && (
<FontAwesomeIcon icon={faCircleCheck} />
)}
{severity === 'warning' && (
<FontAwesomeIcon icon={faTriangleExclamation} />
)}
</span>
<span>{children}</span>
{closeIcon && <button className='btn' onClick={onClose}>{<FontAwesomeIcon icon={faXmark} />}</button>}
</div>
</>
);
};
export default Alert;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,159 @@
import { useEffect, useReducer } from "react";
import { SkillCategoryFormProps } from './SkillCategoryFormProps.interface'
import { Controller, FormProvider, useForm } from "react-hook-form";
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import { FormMode } from "../../enums/formMode";
import { initialState, reducer } from "./reducer";
import httpClient from "../../httpClient/httpClient";
import { AxiosError, AxiosResponse } from "axios";
import { ScreenSize } from "../../enums/screenSize";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSave, faXmark } from "@fortawesome/free-solid-svg-icons";
const SkillCategoryForm = ({ isDrawerOpen, mode, onOpenClose, categoryId }: SkillCategoryFormProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const defaultValues = {
name: ''
}
const methods = useForm({
defaultValues: defaultValues
})
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL)
}
const onSubmit = async (data: unknown) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true })
if (!categoryId) {
await httpClient.post(`api/skill-categories`, data);
} else {
await httpClient.put(`api/skill-categories`)
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false })
onOpenClose(FormMode.CANCEL)
} catch (error) {
const axiosError = error as AxiosError;
dispatch({ type: 'SET_ERROR', payload: axiosError.message })
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
}
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true })
}
}, [mode])
useEffect(() => {
const getSkillCategory = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const response: AxiosResponse = await httpClient.get(
`api/skill-categories/${categoryId}`
);
const category = response.data
methods.reset(category)
} catch (error) {
const axiosError = error as AxiosError;
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false})
}
}
if (categoryId && isDrawerOpen) {
getSkillCategory()
}
}, [categoryId])
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[33%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Skill Category`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className='col-span-3 self-center'>
<span>Name *</span>
</div>
<div className='col-span-9'>
<Controller
name="name"
control={methods.control}
rules={{ required: 'A skill name is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
onClick={onCancel}
data-testid="skill-category-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={state.isDisabled}
type="submit"
data-testid="skill-category-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
)
};
export default SkillCategoryForm;

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface SkillCategoryFormProps {
categoryId: string | undefined;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -0,0 +1,5 @@
export interface SkillCategoryFormState {
error: string | undefined;
isDisabled: boolean;
isLoading: boolean;
}

View File

@@ -0,0 +1,42 @@
import { SkillFormState } from '../SkillForm/SkillFormState.interface';
import { SkillCategoryFormState } from './SkillCategoryFormState.interface';
type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
export const initialState: SkillCategoryFormState = {
error: undefined,
isDisabled: false,
isLoading: true
}
export const reducer = (
state: SkillCategoryFormState,
action: Action
): SkillCategoryFormState => {
switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
}
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
}
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
}
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,297 @@
import { useEffect, useReducer } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { initialState, reducer } from './reducer';
import { SkillFormProps } from './SkillFormProps.interface';
import { FormMode } from '../../enums/formMode';
import httpClient from '../../httpClient/httpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ScreenSize } from '../../enums/screenSize';
import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints';
const SkillForm = ({ isDrawerOpen, mode, onOpenClose, skillId }: SkillFormProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const defaultValues = {
name: '',
categoryId: '',
levelId: '',
rankId: ''
}
const methods = useForm({
defaultValues: defaultValues
});
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
};
const onSubmit = async (data: unknown) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
console.log(data)
if (!skillId) {
await httpClient.post(`api/skills`, data);
} else {
await httpClient.put(`api/skills/skill/${skillId}`, data);
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true });
}
}, [mode]);
useEffect(() => {
const getSkill = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const skillResponse: AxiosResponse = await httpClient.get(
`api/skills/${skillId}`
);
const skill = skillResponse.data;
methods.reset(skill);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
if (skillId && isDrawerOpen) {
getSkill();
}
}, [skillId]);
useEffect(() => {
const getOptions = async () => {
try {
const skillCategoryResponse: AxiosResponse = await httpClient.get(
`api/skill-categories`
)
const newSkillCategoryOptions = skillCategoryResponse.data.map((category: any) => {
return {
label: category.name,
value: category.id
}
})
const skillLevelResponse: AxiosResponse = await httpClient.get(
`api/skill-levels`
);
const newSkillLevelOptions = skillLevelResponse.data.map((level: any) => {
return {
label: level.name,
value: level.id
}
});
const skillRankResponse: AxiosResponse = await httpClient.get(
`api/skill-ranks`
);
const newSkillRankOptions = skillRankResponse.data.map((rank: any) => {
return {
label: rank.name,
value: rank.id
}
});
newSkillCategoryOptions.unshift({
label: '',
value: '',
})
newSkillLevelOptions.unshift({
label: '',
value: '',
})
newSkillRankOptions.unshift({
label: '',
value: '',
})
dispatch({ type: 'SET_OPTIONS', payload: { categoryOptions: newSkillCategoryOptions, levelOptions: newSkillLevelOptions, rankOptions: newSkillRankOptions }})
} catch (error) {
}
}
if (isDrawerOpen) {
getOptions()
}
}, [isDrawerOpen])
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[33%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Skill`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className='col-span-3 self-center'>
<span>Name *</span>
</div>
<div className='col-span-9'>
<Controller
name="name"
control={methods.control}
rules={{ required: 'A skill name is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Cateogry</span>
</div>
<div className='col-span-9'>
<Controller
name="categoryId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<select
className='select w-full'
aria-labelledby='category'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
>
{state.categoryOptions?.map((categoryOption: { label: string, value: string }) => {
return (
<option value={categoryOption.value}>{categoryOption.label}</option>
)
})}
</select>
)
}}
/>
</div>
<div className='col-span-3 self-center'>
<span>Level</span>
</div>
<div className='col-span-9'>
<Controller
name="levelId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<select
className='select w-full'
aria-labelledby='levels'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
>
{state.levelOptions?.map((levelOption: { label: string, value: string }) => {
return (
<option value={levelOption.value}>{levelOption.label}</option>
)
})}
</select>
)
}}
/>
</div>
<div className='col-span-3 self-center'>
<span>Rank</span>
</div>
<div className='col-span-9'>
<Controller
name="rankId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<select
className='select w-full'
aria-labelledby='rank'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
>
{state.rankOptions?.map((rankOption: { label: string, value: string }) => {
return (
<option value={rankOption.value}>{rankOption.label}</option>
)
})}
</select>
)
}}
/>
</div>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
onClick={onCancel}
data-testid="skill-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={state.isDisabled}
type="submit"
data-testid="skill-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
)
}
export default SkillForm;

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface SkillFormProps {
skillId?: string;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -0,0 +1,8 @@
export interface SkillFormState {
error: string | undefined;
isDisabled: boolean;
isLoading: boolean;
categoryOptions: { label: string; value: string }[];
levelOptions: { label: string; value: string }[];
rankOptions: { label: string; value: string }[];
}

View File

@@ -0,0 +1,53 @@
import { SkillFormState } from "./SkillFormState.interface"
type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_OPTIONS'; payload: { categoryOptions: { label: string, value: string; }[], levelOptions: { label: string, value: string; }[], rankOptions: { label: string, value: string; }[] }}
export const initialState: SkillFormState = {
error: undefined,
isDisabled: false,
isLoading: true,
categoryOptions: [],
levelOptions: [],
rankOptions: [],
};
export const reducer = (
state: SkillFormState,
action: Action
): SkillFormState => {
switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
};
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_OPTIONS': {
return {
...state,
categoryOptions: action.payload.categoryOptions,
levelOptions: action.payload.levelOptions,
rankOptions: action.payload.rankOptions
}
}
default: {
return state;
}
}
};

View File

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

View File

@@ -0,0 +1,7 @@
import { FormMode } from '../../enums/formMode';
export interface ActionMenuProps {
id: string;
onDelete: (entryId: string) => void;
onOpenCloseForm: (formMode: FormMode, id: string) => void;
}

View File

@@ -0,0 +1,51 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Icon,
IconName,
Spinner
} from '@noahspan/noahspan-components';
import { ConfirmationDialogProps } from './ConfirmationDialogProps.interface';
const ConfirmationDialog = ({
contentText,
isLoading,
isOpen,
onCancel,
onConfirm,
title
}: ConfirmationDialogProps) => {
return (
<Dialog
onClose={onCancel}
open={isOpen}
sx={{
'& .MuiDialog-paper': { width: '2000px' }
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ textAlign: 'center' }}>
{!isLoading && <DialogContentText>{contentText}</DialogContentText>}
{isLoading && <Spinner />}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} variant="outlined" startIcon={<Icon iconName={IconName.XMARK} />}>
No
</Button>
<Button
onClick={onConfirm}
variant="contained"
startIcon={<Icon iconName={IconName.CIRCLE_CHECK}/>}
>
Yes
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmationDialog;

View File

@@ -0,0 +1,8 @@
export interface ConfirmationDialogProps {
contentText: string;
isLoading: boolean;
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
title: string;
}

View File

@@ -0,0 +1,354 @@
import { useEffect, useReducer } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { initialState, reducer } from './reducer';
import { ExperienceFormProps } from './ExperienceFormProps.interface';
import { FormMode } from '../../enums/formMode';
import httpClient from '../../httpClient/httpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ScreenSize } from '../../enums/screenSize';
import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints';
import { Profile } from '../profiles/Profile.interface';
import { useProfiles } from '../../hooks/profiles/UseProfiles';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import { useSkills } from '../../hooks/skills/UseSkills';
const ExperienceForm = ({ isDrawerOpen, mode, onOpenClose, experienceId }: ExperienceFormProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const appContext = useAppContext();
const { profiles } = useProfiles();
const { skills } = useSkills();
const defaultValues = {
profileId: '',
companyName: '',
companyGeneric: '',
title: '',
startDate: '',
endDate: '',
skills: [],
summary: '',
statusId: 1,
createdBy: '',
updatedBy: ''
}
const methods = useForm({
defaultValues: defaultValues
});
// const watchRepoName = methods.watch(['repoName'])
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
};
const onSubmit = async (data: any) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
if (!experienceId) {
const newData = {
...data,
createdBy: appContext.state.userProfile.userPrincipalName
}
await httpClient.post(`api/experiences`, newData);
} else {
const newData = {
...data,
updatedBy: appContext.state.userProfile.userPrincipalName
}
await httpClient.put(`api/experiences/${experienceId}`, newData);
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true });
}
}, [mode]);
useEffect(() => {
const getExperience = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const response: AxiosResponse = await httpClient.get(
`api/experiences/${experienceId}`
);
const experience = response.data;
methods.reset(experience);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
if (experienceId && isDrawerOpen) {
getExperience();
}
}, [experienceId]);
useEffect(() => {
if (profiles && FormMode.ADD) {
const newProfileOptions = profiles
newProfileOptions.unshift({
id: '',
name: '',
headline: '',
summary: '',
userId: '',
experiences: [],
skills: [],
status: null
})
dispatch({ type: 'SET_PROFILE_OPTIONS', payload: newProfileOptions });
}
}, [profiles]);
useEffect(() => {
dispatch({ type: 'SET_SKILL_OPTIONS', payload: skills })
}, [skills])
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[33%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Experience`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className={`col-span-3 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
<span id='pilot'>Profile</span>
</div>
<div className='col-span-9 self-center'>
<Controller
name="profileId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<select
className='select w-full'
aria-labelledby='profile'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
>
{state.profileOptions?.map((profileOption: Profile) => {
return (
<option value={profileOption.id}>{profileOption.name}</option>
)
})}
</select>
);
}}
/>
</div>
<div className={`col-span-3 self-center after:content-['*'] after:ms-0.5`}>
<span>Company Name</span>
</div>
<div className='col-span-9'>
<Controller
name="companyName"
control={methods.control}
rules={{ required: 'A company name is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Company Generic</span>
</div>
<div className='col-span-9'>
<Controller
name="companyGeneric"
control={methods.control}
rules={{ required: 'A generic company description is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Title</span>
</div>
<div className='col-span-9'>
<Controller
name='title'
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Start Date</span>
</div>
<div className='col-span-9'>
<Controller
name='startDate'
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>End Date</span>
</div>
<div className='col-span-9'>
<Controller
name='endDate'
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Summary</span>
</div>
<div className='col-span-9'>
<Controller
name="summary"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className={`col-span-3 self-center after:content-['*'] after:ms-0.5`}>
<span>Skills</span>
</div>
<div className='col-span-9'>
<Controller
name="skills"
control={methods.control}
render={({ field: { onChange, value } }) => (
// <input
// type='text'
// className='input w-full'
// disabled={state.isDisabled}
// onChange={onChange}
// value={value}
// />
<Listbox value={value} onChange={onChange}>
<ListboxOptions anchor='bottom'>
{state.skillOptions?.map((skill) => (
<ListboxOption key={skill.id} value={skill.id}>
{skill.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
)}
/>
</div>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
onClick={onCancel}
data-testid="experience-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={state.isDisabled}
type="submit"
data-testid="experience-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
)
}
export default ExperienceForm;

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface ExperienceFormProps {
experienceId?: string;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -0,0 +1,10 @@
import { Profile } from "../profiles/Profile.interface";
import { Skill } from "../skills/Skill.interface";
export interface ExperienceFormState {
error: string | undefined;
isDisabled: boolean;
isLoading: boolean;
profileOptions: Profile[];
skillOptions: Skill[] | undefined;
}

View File

@@ -0,0 +1,59 @@
import { Profile } from "../profiles/Profile.interface";
import { Skill } from "../skills/Skill.interface";
import { ExperienceFormState } from "./ExperienceFormState.interface"
type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_PROFILE_OPTIONS'; payload: Profile[] }
| { type: 'SET_SKILL_OPTIONS'; payload: Skill[] | undefined };
export const initialState: ExperienceFormState = {
error: undefined,
isDisabled: false,
isLoading: true,
profileOptions: [],
skillOptions: []
};
export const reducer = (
state: ExperienceFormState,
action: Action
): ExperienceFormState => {
switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
};
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_PROFILE_OPTIONS': {
return {
...state,
profileOptions: action.payload
}
}
case 'SET_SKILL_OPTIONS': {
return {
...state,
skillOptions: action.payload
}
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,18 @@
import { Status } from '../../interfaces/Status.interface';
import { Profile } from '../profiles/Profile.interface';
import { Skill } from "../skills/Skill.interface";
export interface Experience {
id: string;
profileId: string;
companyName: string;
companyGenericName: string;
location: string;
title: string;
startDate: Date;
endDate?: Date;
summary?: string;
skills: Skill[];
profile: Profile;
status: Status;
}

View File

@@ -0,0 +1,370 @@
import { Experience } from './Experience.interface';
import { FormMode } from '../../enums/formMode';
import { useEffect, useReducer } from 'react';
import { initialState, reducer } from './reducer';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import httpClient from '../../httpClient/httpClient'
import { useOidc } from '../../oidc';
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import ExperienceForm from '../experienceForm/ExperienceForm';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import ActionMenu from '../actionMenu/ActionMenu';
import { ScreenSize } from '../../enums/screenSize';
import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UserRole } from '../../enums/userRole';
import { useUserRole } from '../../hooks/userRole/UseUserRole';
import Alert from '../../alert/Alert';
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table';
import { Profile } from '../profiles/Profile.interface';
interface ActionsProps {
id: string;
}
const Experiences = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseExperienceForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseExperienceForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeleteExperience(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
const getExperiences = async (): Promise<Experience[]> => {
try {
const response: AxiosResponse = await httpClient.get(
`api/experiences`,
);
const experiences: Experience[] = response.data;
console.log(experiences)
return experiences;
} catch (error) {
const axiosError = error as AxiosError;
throw new Error(axiosError.message);
}
};
const onOpenCloseExperienceForm = (mode: FormMode, experienceId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM',
payload: {
formMode: mode,
selectedExperienceId: experienceId,
isFormOpen: true
}
});
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM',
payload: {
formMode: mode,
selectedExperienceId: undefined,
isFormOpen: false
}
});
break;
}
};
const onDeleteExperience = (entryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: true, selectedExperienceId: entryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
await httpClient.delete(`api/experiences/${state.selectedExperienceId}`);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedExperienceId: undefined }
});
await getExperiences();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of experiences failed with the following message: ${axiosError.message}` }
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedExperienceId: undefined }
});
};
const columns: ColumnDef<Experience>[]= [
{
id: 'profile',
accessorKey: 'profile',
header: 'Profile',
cell: (info: CellContext<Experience, unknown>) => {
const profile: any = info.getValue();
return profile.name
}
},
{
id: 'companyName',
accessorKey: 'companyName',
header: 'Company Name',
},
{
id: 'companyGeneric',
accessorKey: 'companyGeneric',
header: 'Company Generic'
},
{
id: 'title',
accessorKey: 'title',
header: 'Title'
},
{
id: 'startDate',
accessorKey: 'startDate',
header: 'Start Date'
},
{
id: 'endDate',
accessorKey: 'endDate',
header: 'End Date'
},
{
id: 'summary',
accessorKey: 'summary',
header: 'Summary'
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: (info: CellContext<Experience, unknown>) => {
console.log(info.getValue())
const status: any = info.getValue();
console.log(status)
return status.name
}
},
{
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
headerAlign: 'text-center'
},
cell: (info: CellContext<Experience, unknown>) => {
return (
<Actions id={info.row.original.id} />
)
}
}
];
const table = useReactTable({
data: state.experiences,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
useEffect(() => {
const loadExperiences = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true })
const experiences = await getExperiences();
if (experiences.length > 0) {
dispatch({ type: 'SET_EXPERIENCES', payload: experiences });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No experiences found.'}});
}
} catch (error) {
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of experiences failed with the following message: ${error}` }
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
}
if (!state.isFormOpen || !state.isConfirmDialogOpen) {
loadExperiences();
}
}, [state.isFormOpen, state.isConfirmDialogOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'}`}>
<div className='grid grid-cols-12'>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<h2>Experiences</h2>
</div>
<div className='col-span-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenCloseExperienceForm(FormMode.ADD)}
data-testid="skill-add-button"
>
<FontAwesomeIcon icon={faPlus} />
Add Experience
</button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
</div>
)}
{state.experiences.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 p-5 bg-base-100 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{table.getHeaderGroups().map((headerGroup) => (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
colSpan={header.colSpan}
key={header.id}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* {header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null} */}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
<>
{table.getRowModel().rows.map((row) => {
return (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</>
</tbody>
</table>
</div>
}
{state.experiences.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
</div>
</div>
{state.isFormOpen &&
<ExperienceForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenCloseExperienceForm(mode)}
experienceId={state.selectedExperienceId}
/>
}
</>
)
}
export default Experiences;

View File

@@ -0,0 +1,14 @@
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces';
import { Experience } from './Experience.interface';
export interface ExperiencesState {
alert: Alert | undefined;
experiences: Experience[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedExperienceId: string | undefined;
}

View File

@@ -0,0 +1,93 @@
import { FormMode } from '../../enums/formMode';
import { ExperiencesState } from './ExperiencesState.interface'
import { Experience } from './Experience.interface';
import { Alert } from '../../interfaces';
type Action =
| {
type: 'SET_DELETE';
payload: {
isConfirmationDialogOpen: boolean;
selectedExperienceId: string | undefined;
};
}
| { type: 'SET_EXPERIENCES'; payload: Experience[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| {
type: 'SET_OPEN_CLOSE_EXPERIENCE_FORM';
payload: {
formMode: FormMode;
selectedExperienceId: string | undefined;
isFormOpen: boolean;
};
};
export const initialState: ExperiencesState = {
alert: undefined,
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
experiences: [],
selectedExperienceId: undefined
};
export const reducer = (
state: ExperiencesState,
action: Action
): ExperiencesState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmationDialogOpen,
selectedExperienceId: action.payload.selectedExperienceId
};
}
case 'SET_EXPERIENCES': {
return {
...state,
experiences: action.payload
};
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
};
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
};
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_OPEN_CLOSE_EXPERIENCE_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedExperienceId: action.payload.selectedExperienceId
};
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from "react";
import { MultiSelectDropdownProps } from "./MultiSelectDropdownProps";
const MultiSelectDropdown = ({ options }: MultiSelectDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState([])
const dropdownRef = useRef(null)
const handleToggleOption = (value: any) => {
let updated;
if (selectedValues.includes(value)) {
updated = selectedValues.filter((item) => item !== value);
} else {
updated = [...selectedValues, value];
}
setSelectedValues(updated);
if (onChange) onChange(updated)
}
const handleRemoveBadge = (event, value) => {
event.stopPropagation();
const updated = selectedValues.filter((item) => item !== value);
setSelectedValues(updated)
if (onChange) onchange(updated)
}
useEffect(() => {
const handleOutsideClick = (event: any) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleOutsideClick);
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [])
return (
<div ref={dropdownRef} className="form-control w-full max-w-xs relative">
<div className={`dropdown w-full ${isOpen ? 'dropdown-open' : ''}`}>
<div
tabIndex={0}
role="button"
className="select select-bordered w-full h-auto min-h-12 flex flex-wrap items-center gap-1 p-2 bg-base-100 text-left cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
>
{selectedValues.length === 0 ? (
<span className="text-base-content/50"></span>
) : (
<div className="flex flex-wrap gap-1">
{selectedValues.map((val) => {
const option = options.find((o: any) => o.value === val);
return (
<div key={val} className="badge badge-primary gap-1 py-3 px-2">
{option?.label || val}
<button
onClick={(event) => handleRemoveBadge(event, val)}
className="btn btn-ghost btn-xs p-0 min-h-0 h-auto text-primary-content hover:bg-transparent"
>
</button>
</div>
);
})}
</div>
)}
</div>
<ul
tabIndex={0}
className="dropdown-content menu p-2 shadow-lg bg-base-100 rounded-box w-full max-h-60 overflow-y-auto z-[1] border border-base-200"
>
{options.map((option) => (
<li key={option.value} className="p-0">
<label className="label cursor-pointer justify-start gap-3 px-4 py-2 hover:bg-base-200 rounded-lg w-full">
<input
type="checkbox"
className="checkbox checkbox-primary checkbox-sm"
checked={selectedValues.includes(option.value)}
onChange={() => handleToggleOption(option.value)}
/>
<span className="label-text text-base-content">{option.label}</span>
</label>
</li>
))}
</ul>
</div>
</div>
);
}
export default MultiSelectDropdown;

View File

@@ -0,0 +1,3 @@
export interface MultiSelectDropdownProps {
options: { label: string; value: string }[];
}

View File

@@ -0,0 +1,213 @@
import { useEffect, useReducer } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { initialState, reducer } from './reducer';
import { ProfileFormProps } from './ProfileFormProps.interface';
import { FormMode } from '../../enums/formMode';
import httpClient from '../../httpClient/httpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ScreenSize } from '../../enums/screenSize';
import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
const ProfileForm = ({ isDrawerOpen, mode, onOpenClose, profileId }: ProfileFormProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const appContext = useAppContext();
const defaultValues = {
name: '',
headline: '',
summary: '',
userId: '',
statusId: 1
}
const methods = useForm({
defaultValues: defaultValues
});
// const watchRepoName = methods.watch(['repoName'])
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
};
const onSubmit = async (data: any) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
console.log(data)
if (!profileId) {
const newData = {
...data,
createdBy: appContext.state.userProfile.userPrincipalName,
userId: appContext.state.userProfile.userPrincipalName
}
console.log(newData)
await httpClient.post(`api/profiles`, newData);
} else {
const newData = {
...data,
updatedBy: appContext.state.userProfile.userPrincipalName
}
console.log(newData)
await httpClient.put(`api/profiles/${profileId}`, newData);
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true });
}
}, [mode]);
useEffect(() => {
const getProfile = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const response: AxiosResponse = await httpClient.get(
`api/profiles/${profileId}`
);
const entry = response.data;
methods.reset(entry);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
if (profileId && isDrawerOpen) {
getProfile();
}
}, [profileId]);
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[33%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Profile`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className='col-span-3 self-center'>
<span>Name *</span>
</div>
<div className='col-span-9'>
<Controller
name="name"
control={methods.control}
rules={{ required: 'A profile title is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Headline</span>
</div>
<div className='col-span-9'>
<Controller
name='headline'
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Summary</span>
</div>
<div className='col-span-9'>
<Controller
name="summary"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
onClick={onCancel}
data-testid="profile-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={state.isDisabled}
type="submit"
data-testid="profile-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
)
}
export default ProfileForm;

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface ProfileFormProps {
profileId?: string;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -0,0 +1,5 @@
export interface ProfileFormState {
error: string | undefined;
isDisabled: boolean;
isLoading: boolean;
}

View File

@@ -0,0 +1,42 @@
import { Profile } from "../profiles/Profile.interface";
import { ProfileFormState } from "./ProfileFormState.interface"
type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean };
export const initialState: ProfileFormState = {
error: undefined,
isDisabled: false,
isLoading: true
};
export const reducer = (
state: ProfileFormState,
action: Action
): ProfileFormState => {
switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
};
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,14 @@
import { Status } from "../../interfaces/Status.interface";
import { Experience } from "../experiences/Experience.interface";
import { Skill } from "../skills/Skill.interface";
export interface Profile {
id: string;
name: string;
experiences: Experience[];
headline: string;
skills: Skill[];
status: Status | null;
summary: string;
userId: string;
}

View File

@@ -0,0 +1,348 @@
import { Profile } from './Profile.interface';
import { FormMode } from '../../enums/formMode';
import { useEffect, useReducer } from 'react';
import { initialState, reducer } from './reducer';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import httpClient from '../../httpClient/httpClient'
import { useOidc } from '../../oidc';
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import ProfileForm from '../profileForm/ProfileForm';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import ActionMenu from '../actionMenu/ActionMenu';
import { ScreenSize } from '../../enums/screenSize';
import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UserRole } from '../../enums/userRole';
import { useUserRole } from '../../hooks/userRole/UseUserRole';
import Alert from '../../alert/Alert';
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table';
interface ActionsProps {
id: string;
}
const Profiles = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseProfileForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseProfileForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<>
<li><a onClick={() => onDeleteProfile(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
</>
}
</ul>
</div>
)
}
const getProfiles = async (): Promise<Profile[]> => {
try {
const response: AxiosResponse = await httpClient.get(
`api/profiles`,
);
const profiles: Profile[] = response.data;
console.log(profiles)
return profiles;
return []
} catch (error) {
const axiosError = error as AxiosError;
throw new Error(axiosError.message);
}
};
const onOpenCloseProfileForm = (mode: FormMode, profileId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_PROFILE_FORM',
payload: {
formMode: mode,
selectedProfileId: profileId,
isFormOpen: true
}
});
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_PROFILE_FORM',
payload: {
formMode: mode,
selectedProfileId: undefined,
isFormOpen: false
}
});
break;
}
};
const onDeleteProfile = (entryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: true, selectedProfileId: entryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
await httpClient.delete(`api/profiles/profile/${state.selectedProfileId}`);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedProfileId: undefined }
});
await getProfiles();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of profiles failed with the following message: ${axiosError.message}` }
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedProfileId: undefined }
});
};
const columns: ColumnDef<Profile>[]= [
{
id: 'name',
accessorKey: 'name',
header: 'Name'
},
{
id: 'headline',
accessorKey: 'headline',
header: 'Headline',
},
{
id: 'summary',
accessorKey: 'summary',
header: 'Summary'
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: (info: CellContext<Profile, unknown>) => {
console.log(info.getValue())
const status: any = info.getValue();
console.log(status)
return status.name
}
},
{
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
headerAlign: 'text-center'
},
cell: (info: CellContext<Profile, unknown>) => {
return (
<Actions id={info.row.original.id} />
)
}
}
];
const table = useReactTable({
data: state.profiles,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
useEffect(() => {
const loadProfiles = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true })
const profiles = await getProfiles();
if (profiles.length > 0) {
dispatch({ type: 'SET_PROFILES', payload: profiles });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No profiles found.'}});
}
} catch (error) {
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of profiles failed with the following message: ${error}` }
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
}
if (!state.isFormOpen || !state.isConfirmDialogOpen) {
loadProfiles();
}
}, [state.isFormOpen, state.isConfirmDialogOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'}`}>
<div className='grid grid-cols-12'>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<h2>Profiles</h2>
</div>
<div className='col-span-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenCloseProfileForm(FormMode.ADD)}
data-testid="skill-add-button"
>
<FontAwesomeIcon icon={faPlus} />
Add Profile
</button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
</div>
)}
{state.profiles.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 p-5 bg-base-100 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{table.getHeaderGroups().map((headerGroup) => (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
colSpan={header.colSpan}
key={header.id}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* {header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null} */}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
<>
{table.getRowModel().rows.map((row) => {
return (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</>
</tbody>
</table>
</div>
}
{state.profiles.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
</div>
</div>
{state.isFormOpen &&
<ProfileForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenCloseProfileForm(mode)}
profileId={state.selectedProfileId}
/>
}
</>
)
}
export default Profiles;

View File

@@ -0,0 +1,14 @@
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces';
import { Profile } from './Profile.interface';
export interface ProfilesState {
alert: Alert | undefined;
profiles: Profile[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedProfileId: string | undefined;
}

View File

@@ -0,0 +1,93 @@
import { FormMode } from '../../enums/formMode';
import { ProfilesState } from './ProfilesState.interface'
import { Profile } from './Profile.interface';
import { Alert } from '../../interfaces';
type Action =
| {
type: 'SET_DELETE';
payload: {
isConfirmationDialogOpen: boolean;
selectedProfileId: string | undefined;
};
}
| { type: 'SET_PROFILES'; payload: Profile[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| {
type: 'SET_OPEN_CLOSE_PROFILE_FORM';
payload: {
formMode: FormMode;
selectedProfileId: string | undefined;
isFormOpen: boolean;
};
};
export const initialState: ProfilesState = {
alert: undefined,
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
profiles: [],
selectedProfileId: undefined
};
export const reducer = (
state: ProfilesState,
action: Action
): ProfilesState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmationDialogOpen,
selectedProfileId: action.payload.selectedProfileId
};
}
case 'SET_PROFILES': {
return {
...state,
profiles: action.payload
};
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
};
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
};
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_OPEN_CLOSE_PROFILE_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedProfileId: action.payload.selectedProfileId
};
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,310 @@
import { useEffect, useReducer } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { initialState, reducer } from './reducer';
import { ProjectFormProps } from './ProjectFormProps.interface';
import { FormMode } from '../../enums/formMode';
import httpClient from '../../httpClient/httpClient';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ScreenSize } from '../../enums/screenSize';
import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints';
import { useProfiles } from '../../hooks/profiles/UseProfiles';
import { Profile } from '../profiles/Profile.interface';
const ProjectForm = ({ isDrawerOpen, mode, onOpenClose, projectId }: ProjectFormProps) => {
const [state, dispatch] = useReducer(reducer, initialState);
const { profiles } = useProfiles()
const defaultValues = {
profileId: '',
name: '',
iconName: '',
summary: '',
repoUrl: '',
siteUrl: '',
order: '',
statusId: 1
}
const methods = useForm({
defaultValues: defaultValues
});
// const watchRepoName = methods.watch(['repoName'])
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
};
const onSubmit = async (data: unknown) => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
console.log(data)
if (!projectId) {
await httpClient.post(`api/projects`, data);
} else {
await httpClient.put(`api/projects/project/${projectId}`, data);
}
methods.reset(defaultValues);
dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
// useEffect(() => {
// if (watchRepoName.length > 0) {
// methods.setValue('rowKey', watchRepoName[0])
// }
// }, [watchRepoName])
useEffect(() => {
if (mode === FormMode.VIEW) {
dispatch({ type: 'SET_IS_DISABLED', payload: true });
}
}, [mode]);
useEffect(() => {
const getProject = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const response: AxiosResponse = await httpClient.get(
`api/projects/project/${projectId}`
);
const entry = response.data;
methods.reset(entry);
} catch (error) {
const axiosError = error as AxiosError;
console.log(axiosError)
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
if (projectId && isDrawerOpen) {
getProject();
}
}, [projectId]);
useEffect(() => {
if (profiles && FormMode.ADD) {
const newProfileOptions = profiles
newProfileOptions.unshift({
id: '',
name: '',
headline: '',
summary: '',
userId: '',
experiences: [],
skills: []
})
dispatch({ type: 'SET_PROFILE_OPTIONS', payload: newProfileOptions });
}
}, [profiles]);
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[33%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Project`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className={`col-span-3 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
<span id='profile'>Profile</span>
</div>
<div className='col-span-9 self-center'>
<Controller
name="profileId"
control={methods.control}
render={({ field: { onChange, value } }) => {
return (
<select
className='select w-full'
aria-labelledby='profile'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
>
{state.profileOptions?.map((profileOption: Profile) => {
return (
<option value={profileOption.id}>{profileOption.name}</option>
)
})}
</select>
);
}}
/>
</div>
<div className='col-span-3 self-center'>
<span>Order</span>
</div>
<div className='col-span-9'>
<Controller
name="order"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Name *</span>
</div>
<div className='col-span-9'>
<Controller
name="name"
control={methods.control}
rules={{ required: 'A project title is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Icon Name</span>
</div>
<div className='col-span-9'>
<Controller
name="iconName"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Repo URL</span>
</div>
<div className='col-span-9'>
<Controller
name="repoUrl"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Site URL</span>
</div>
<div className='col-span-9'>
<Controller
name="siteUrl"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-3 self-center'>
<span>Summary</span>
</div>
<div className='col-span-9'>
<Controller
name="summary"
control={methods.control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</div>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
state.isDisabled && mode.toString() !== FormMode.VIEW
? state.isDisabled
: false
}
onClick={onCancel}
data-testid="project-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={state.isDisabled}
type="submit"
data-testid="project-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
</div>
</form>
</FormProvider>
</div>
</div>
</div>
)
}
export default ProjectForm;

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface ProjectFormProps {
projectId?: string;
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
}

View File

@@ -0,0 +1,8 @@
import { Profile } from "../profiles/Profile.interface";
export interface ProjectFormState {
error: string | undefined;
isDisabled: boolean;
isLoading: boolean;
profileOptions: Profile[];
}

View File

@@ -0,0 +1,50 @@
import { Profile } from "../profiles/Profile.interface";
import { ProjectFormState } from "./ProjectFormState.interface"
type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_PROFILE_OPTIONS'; payload: Profile[] };
export const initialState: ProjectFormState = {
error: undefined,
isDisabled: false,
isLoading: true,
profileOptions: []
};
export const reducer = (
state: ProjectFormState,
action: Action
): ProjectFormState => {
switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
};
}
case 'SET_IS_DISABLED': {
return {
...state,
isDisabled: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_PROFILE_OPTIONS': {
return {
...state,
profileOptions: action.payload
}
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,10 @@
export interface Project {
id: string;
iconName: string;
name: string;
order: string;
repoUrl: string;
siteUrl: string;
summary: string;
status: string;
}

View File

@@ -0,0 +1,372 @@
import { Project } from './Project.interface';
// import {
// Alert,
// Box,
// Button,
// Card,
// CardActions,
// CardContent,
// CardHeader,
// Container,
// Grid,
// Icon,
// IconName,
// Skeleton,
// Stack,
// Typography
// } from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { useEffect, useReducer } from 'react';
import { initialState, reducer } from './reducer';
import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import httpClient from '../../httpClient/httpClient'
import { useOidc } from '../../oidc';
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import ProjectForm from '../projectForm/ProjectForm';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
import ActionMenu from '../actionMenu/ActionMenu';
import { ScreenSize } from '../../enums/screenSize';
import { faEllipsisVertical, faEye, faPen, faPlane, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { UserRole } from '../../enums/userRole';
import { useUserRole } from '../../hooks/userRole/UseUserRole';
import Alert from '../../alert/Alert';
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table';
interface ActionsProps {
id: string;
}
const Projects = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseProjectForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseProjectForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeleteProject(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
const getProjects = async (): Promise<Project[]> => {
try {
const response: AxiosResponse = await httpClient.get(
`api/projects`,
);
const projects: Project[] = response.data;
console.log(projects)
projects.sort((a, b) => Number(a.order) - Number(b.order));
return projects;
return []
} catch (error) {
const axiosError = error as AxiosError;
throw new Error(axiosError.message);
}
};
const onOpenCloseProjectForm = (mode: FormMode, projectId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_PROJECT_FORM',
payload: {
formMode: mode,
selectedProjectId: projectId,
isFormOpen: true
}
});
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_PROJECT_FORM',
payload: {
formMode: mode,
selectedProjectId: undefined,
isFormOpen: false
}
});
break;
}
};
const onDeleteProject = (entryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: true, selectedProjectId: entryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
await httpClient.delete(`api/projects/project/${state.selectedProjectId}`);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedProjectId: undefined }
});
await getProjects();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of projects failed with the following message: ${axiosError.message}` }
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedProjectId: undefined }
});
};
const columns: ColumnDef<Project>[]= [
{
id: 'name',
accessorKey: 'name',
header: 'Name'
},
{
id: 'order',
accessorKey: 'order',
header: 'Order',
},
{
id: 'repoUrl',
accessorKey: 'repoUrl',
header: 'Repo URL',
},
{
id: 'siteUrl',
accessorKey: 'siteUrl',
header: 'Site URL',
},
{
id: 'summary',
accessorKey: 'summary',
header: 'Summary'
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: (info: CellContext<Project, unknown>) => {
const status: any = info.getValue();
return status.name
}
},
{
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
headerAlign: 'text-center'
},
cell: (info: CellContext<Project, unknown>) => {
return (
<Actions id={info.row.original.id} />
)
}
}
];
const table = useReactTable({
data: state.projects,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
useEffect(() => {
const loadProjects = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true })
const projects = await getProjects();
if (projects.length > 0) {
dispatch({ type: 'SET_PROJECTS', payload: projects });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No projects found.'}});
}
} catch (error) {
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of projects failed with the following message: ${error}` }
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
}
if (!state.isFormOpen || !state.isConfirmDialogOpen) {
loadProjects();
}
}, [state.isFormOpen, state.isConfirmDialogOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'}`}>
<div className='grid grid-cols-12'>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<h2>Projects</h2>
</div>
<div className='col-span-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenCloseProjectForm(FormMode.ADD)}
data-testid="skill-add-button"
>
<FontAwesomeIcon icon={faPlus} />
Add Project
</button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
</div>
)}
{state.projects.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 p-5 bg-base-100 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{table.getHeaderGroups().map((headerGroup) => (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
colSpan={header.colSpan}
key={header.id}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* {header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null} */}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
<>
{table.getRowModel().rows.map((row) => {
return (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</>
</tbody>
</table>
</div>
}
{state.projects.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
</div>
</div>
{state.isFormOpen &&
<ProjectForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenCloseProjectForm(mode)}
projectId={state.selectedProjectId}
/>
}
</>
)
}
export default Projects;

View File

@@ -0,0 +1,14 @@
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces';
import { Project } from './Project.interface';
export interface ProjectsState {
alert: Alert | undefined;
projects: Project[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedProjectId: string | undefined;
}

View File

@@ -0,0 +1,93 @@
import { FormMode } from '../../enums/formMode';
import { ProjectsState } from './ProjectsState.interface'
import { Project } from './Project.interface';
import { Alert } from '../../interfaces';
type Action =
| {
type: 'SET_DELETE';
payload: {
isConfirmationDialogOpen: boolean;
selectedProjectId: string | undefined;
};
}
| { type: 'SET_PROJECTS'; payload: Project[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| {
type: 'SET_OPEN_CLOSE_PROJECT_FORM';
payload: {
formMode: FormMode;
selectedProjectId: string | undefined;
isFormOpen: boolean;
};
};
export const initialState: ProjectsState = {
alert: undefined,
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
projects: [],
selectedProjectId: undefined
};
export const reducer = (
state: ProjectsState,
action: Action
): ProjectsState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmationDialogOpen,
selectedProjectId: action.payload.selectedProjectId
};
}
case 'SET_PROJECTS': {
return {
...state,
projects: action.payload
};
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
};
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
};
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
};
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
};
}
case 'SET_OPEN_CLOSE_PROJECT_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedProjectId: action.payload.selectedProjectId
};
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { useAppContext } from '../../hooks/appContext/UseAppContext';
import { AxiosResponse } from 'axios';
import { User } from '@microsoft/microsoft-graph-types';
import { useOidc } from '../../oidc';
import httpClient from '../../httpClient/httpClient'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faBars, faSignIn, faSignOut, faUserTie } from '@fortawesome/free-solid-svg-icons'
import { NavLink, useLocation } from 'react-router-dom';
import { useBreakpoints } from '../../hooks/breakpoints/UseBreakpoints';
import { ScreenSize } from '../../enums/screenSize';
const SiteNav = () => {
const [userPhoto, setUserPhoto] = useState<string>();
const appContext = useAppContext();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn, logout, login } = useOidc()
const pages = [
{
name: 'Profiles',
path: '/'
},
{
name: 'Experiences',
path: '/experiences'
},
{
name: 'Projects',
path: '/projects'
},
{
name: 'Skills',
path: '/skills'
}
];
const getUserProfile = async (): Promise<User> => {
try {
const response: AxiosResponse = await httpClient.get(`api/msgraph/profile`);
const userProfile: User = response.data;
return userProfile;
} catch (error) {
throw new Error();
}
};
const getUserPhoto = async (): Promise<string> => {
try {
const response: AxiosResponse = await httpClient.get(`api/msgraph/photo`, {
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) {
throw new Error();
}
};
const Brand = () => {
return (
<>
<img
className='mr-1'
height={35}
width={35}
src='noahspan-logo.png'
/>
<FontAwesomeIcon className='mt-1' icon={faUserTie} size='2x' />
</>
)
}
const Links = () => {
return (
<>
{pages.map((page) => {
return (
<li><NavLink to={page.path}>{page.name}</NavLink></li>
)
})}
</>
)
}
useEffect(() => {
const setUserProfile = async () => {
try {
const userProfile = await getUserProfile();
// const userPhoto = await getUserPhoto();
// setUserPhoto(userPhoto);
appContext.dispatch({
type: 'SET_USER_PROFILE',
payload: userProfile
});
} catch (error) {
console.log(error);
}
};
if (
isUserLoggedIn &&
Object.keys(appContext.state.userProfile).length === 0
) {
setUserProfile();
}
}, [isUserLoggedIn]);
return (
<div className="navbar bg-base-100 shadow-sm w-full">
<div className={`navbar-start ${screenSize !== ScreenSize.SM && screenSize !== ScreenSize.MD ? 'ml-8' : ''}`}>
{screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? (
<div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost lg:hidden">
<FontAwesomeIcon icon={faBars} size='xl' />
</div>
<ul
tabIndex={-1}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<Links />
</ul>
</div>
) : (
<Brand />
)}
</div>
<div className="navbar-center">
<ul className="menu menu-horizontal px-1">
{screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? (
<Brand />
) : (
<Links />
)}
</ul>
</div>
<div className={`navbar-end ${screenSize !== ScreenSize.SM && screenSize !== ScreenSize.MD ? 'mr-8' : ''}`}>
{!isUserLoggedIn &&
<button className='btn btn-ghost' onClick={() => login()}><FontAwesomeIcon icon={faSignIn} />Sign In</button>
}
{isUserLoggedIn &&
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button'>
<div className={`avatar ${userPhoto ? userPhoto : 'avatar-placeholder'}`}>
{userPhoto &&
<div className='w-12 rounded-full'>
<img src={userPhoto} />
</div>
}
{!userPhoto &&
<div className='bg-neutral text-neutral-content w-10 rounded-full'>
<span>NS</span>
</div>
}
</div>
</div>
<ul
tabIndex={0}
className='dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300'
>
<li><a onClick={() => logout({redirectTo: 'specific url', url: '/'})}><FontAwesomeIcon icon={faSignOut} />Sign Out</a></li>
</ul>
</div>
}
</div>
</div>
);
};
export default SiteNav;

View File

@@ -0,0 +1,309 @@
import { AxiosError, AxiosResponse } from "axios";
import { FormMode } from "../../enums/formMode";
import { initialState, reducer } from "./reducer";
import httpClient from "../../httpClient/httpClient";
import { SkillCategory } from "./SkillCategory.interface";
import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from "@tanstack/react-table";
import { useEffect, useReducer } from "react";
import { ScreenSize } from "../../enums/screenSize";
import Alert from "../../alert/Alert";
import { UserRole } from "../../enums/userRole";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEllipsisVertical, faEye, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { useUserRole } from "../../hooks/userRole/UseUserRole";
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import { useOidc } from "../../oidc";
import SkillCategoryForm from '../SkillCategoryForm/SkillCategoryForm';
interface ActionsProps {
id: string;
}
const SkillCategories = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseSkillCategoryForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseSkillCategoryForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeleteSkill(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
const getSkillCategories = async () => {
try {
let response: AxiosResponse;
response = await httpClient.get(
`api/skill-categories`
);
console.log(response)
if (response.data.length > 0) {
dispatch({ type: 'SET_SKILL_CATEGORIES', payload: response.data });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No skill categories found.' }})
dispatch({ type: 'SET_SKILL_CATEGORIES', payload: [] });
}
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of skill categories failed with the following message: ${axiosError.message}`}
})
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
};
const onOpenCloseSkillCategoryForm = async (mode: FormMode, categoryId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedCategoryId: categoryId,
isFormOpen: true
}
})
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
isFormOpen: false,
selectedCategoryId: undefined,
}
})
break;
}
};
const onDeleteSkill = (categoryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: true, selectedCategoryId: categoryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
await httpClient.delete(`api/skill-categories/${state.selectedCategoryId}`);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedCategoryId: undefined }
});
await getSkillCategories();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of skill categories failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedCategoryId: undefined }
});
};
const columns: ColumnDef<SkillCategory>[] = [
{
id: 'name',
accessorKey: 'name',
header: 'Name'
}
];
const table = useReactTable({
data: state.categories,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
useEffect(() => {
if (!state.isFormOpen) {
getSkillCategories();
}
}, [state.isFormOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'}`}>
<div className='grid grid-cols-12'>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<h2>Categories</h2>
</div>
<div className='col-span-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenCloseSkillCategoryForm(FormMode.ADD)}
data-testid="skill-add-button"
>
<FontAwesomeIcon icon={faPlus} />
Add Category
</button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
</div>
)}
{state.categories.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 bg-base-100 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{table.getHeaderGroups().map((headerGroup) => (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
colSpan={header.colSpan}
key={header.id}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* {header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null} */}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
<>
{table.getRowModel().rows.map((row) => {
return (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</>
</tbody>
</table>
</div>
}
{state.categories.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
</div>
</div>
{state.isFormOpen && (
<SkillCategoryForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode: FormMode) => onOpenCloseSkillCategoryForm(mode)}
categoryId={state.selectedCategoryId}
/>
)}
{/* {state.isConfirmDialogOpen && (
<ConfirmationDialog
contentText="Are you sure you want to delete the skill?"
isLoading={state.isConfirmDialogLoading}
isOpen={state.isConfirmDialogOpen}
onCancel={onConfirmationDialogCancel}
onConfirm={onConfirmationDialogConfirm}
title="Confirm Delete"
/>
)} */}
</>
);
}
export default SkillCategories;

View File

@@ -0,0 +1,14 @@
import { FormMode } from "../../enums/formMode";
import { Alert } from "../../interfaces";
import { SkillCategory } from "./SkillCategory.interface";
export interface SkillCategoriesState {
alert: Alert | undefined;
categories: SkillCategory[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedCategoryId: string | undefined;
}

View File

@@ -0,0 +1,4 @@
export interface SkillCategory {
id: string;
name: string;
}

View File

@@ -0,0 +1,80 @@
import { FormMode } from "../../enums/formMode";
import { Alert } from "../../interfaces";
import { SkillCategoriesState } from "./SkillCategoriesState.interface";
import { SkillCategory } from "./SkillCategory.interface";
type Action =
| { type: 'SET_DELETE'; payload: { isConfirmDialogOpen: boolean, selectedCategoryId: string | undefined }}
| { type: 'SET_SKILL_CATEGORIES'; payload: SkillCategory[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_OPEN_CLOSE_ENTRY_FORM'; payload: { formMode: FormMode; isFormOpen: boolean, selectedCategoryId: string | undefined }}
export const initialState: SkillCategoriesState = {
alert: undefined,
categories: [],
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
selectedCategoryId: undefined
}
export const reducer = (
state: SkillCategoriesState,
action: Action
): SkillCategoriesState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmDialogOpen,
selectedCategoryId: action.payload.selectedCategoryId
}
}
case 'SET_SKILL_CATEGORIES': {
return {
...state,
categories: action.payload
}
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
}
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
}
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
}
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
}
}
case 'SET_OPEN_CLOSE_ENTRY_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedCategoryId: action.payload.selectedCategoryId
}
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,7 @@
export interface Skill {
id: string;
name: string;
category: string;
level: string;
rank: string;
}

View File

@@ -0,0 +1,353 @@
import { AxiosError, AxiosResponse } from "axios";
import { useBreakpoints } from "../../hooks/breakpoints/UseBreakpoints";
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from "@tanstack/react-table";
import { useEffect, useReducer } from "react";
import { ScreenSize } from "../../enums/screenSize";
import { faEllipsisVertical, faEye, faPen, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormMode } from "../../enums/formMode";
import { useOidc } from "../../oidc";
import { initialState, reducer } from "./reducer";
import httpClient from '../../httpClient/httpClient'
import { UserRole } from "../../enums/userRole";
import Alert from "../../alert/Alert";
// import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog";
import { useUserRole } from "../../hooks/userRole/UseUserRole";
import { Skill } from "./Skill.interface";
import SkillForm from "../SkillForm/SkillForm";
interface ActionsProps {
id: string;
}
const Skills = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseSkillForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseSkillForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeleteSkill(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
const getSkills = async () => {
try {
let response: AxiosResponse;
response = await httpClient.get(
`api/skills`
);
if (response.data.length > 0) {
dispatch({ type: 'SET_SKILLS', payload: response.data });
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No skills found.' }})
dispatch({ type: 'SET_SKILLS', payload: [] });
}
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of skills failed with the following message: ${axiosError.message}`}
})
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
};
const onOpenCloseSkillForm = async (mode: FormMode, skillId?: string) => {
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
case FormMode.VIEW:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedSkillId: skillId,
isFormOpen: true
}
})
break;
case FormMode.CANCEL:
dispatch({
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
payload: {
formMode: mode,
selectedSkillId: undefined,
isFormOpen: false
}
})
break;
}
};
const onDeleteSkill = (skillId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: true, selectedSkillId: skillId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
await httpClient.delete(`api/skills/${state.selectedSkillId}`);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedSkillId: undefined }
});
await getSkills();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of skills failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
}
};
const onConfirmationDialogCancel = () => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmDialogOpen: false, selectedSkillId: undefined }
});
};
const columns: ColumnDef<Skill>[]= [
{
id: 'name',
accessorKey: 'name',
header: 'Name'
},
{
id: 'category',
accessorKey: 'category',
header: 'Category',
cell: (info: CellContext<Skill, unknown>) => {
const category: any = info.getValue();
return category.name
}
},
{
id: 'level',
accessorKey: 'level',
header: 'Level',
cell: (info: CellContext<Skill, unknown>) => {
const level: any = info.getValue();
return level.name
}
},
{
id: 'rank',
accessorKey: 'rank',
header: 'Rank',
cell: (info: CellContext<Skill, unknown>) => {
const rank: any = info.getValue();
return rank.name
}
},
{
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
headerAlign: 'text-center'
},
cell: (info: CellContext<Skill, unknown>) => {
return (
<Actions id={info.row.original.id} />
)
}
}
];
const table = useReactTable({
data: state.skills,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
useEffect(() => {
if (!state.isFormOpen) {
getSkills();
}
}, [state.isFormOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'}`}>
<div className='grid grid-cols-12'>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<h2>Skills</h2>
</div>
<div className='col-span-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenCloseSkillForm(FormMode.ADD)}
data-testid="skill-add-button"
>
<FontAwesomeIcon icon={faPlus} />
Add Skill
</button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
</div>
)}
{state.skills.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 bg-base-100 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{table.getHeaderGroups().map((headerGroup) => (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<th
className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
colSpan={header.colSpan}
key={header.id}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{/* {header.column.getCanFilter() ? (
<div>
<Filter column={header.column} table={table} />
</div>
) : null} */}
</div>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
<>
{table.getRowModel().rows.map((row) => {
return (
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<td
className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
key={cell.id}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</>
</tbody>
</table>
</div>
}
{state.skills.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
</div>
</div>
{state.isFormOpen && (
<SkillForm
isDrawerOpen={state.isFormOpen}
mode={state.formMode}
onOpenClose={(mode) => onOpenCloseSkillForm(mode)}
skillId={state.selectedSkillId}
/>
)}
{/* {state.isConfirmDialogOpen && (
<ConfirmationDialog
contentText="Are you sure you want to delete the skill?"
isLoading={state.isConfirmDialogLoading}
isOpen={state.isConfirmDialogOpen}
onCancel={onConfirmationDialogCancel}
onConfirm={onConfirmationDialogConfirm}
title="Confirm Delete"
/>
)} */}
</>
);
};
export default Skills;

View File

@@ -0,0 +1,15 @@
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces';
import { Skill } from './Skill.interface';
export interface SkillsState {
activeTab: string;
alert: Alert | undefined;
skills: Skill[];
formMode: FormMode;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedSkillId: string | undefined;
}

View File

@@ -0,0 +1,101 @@
import { FormMode } from "../../enums/formMode";
import { Alert } from "../../interfaces/Alert.interface";
import { Skill } from "./Skill.interface";
import { SkillsState } from './SkillsState.interface'
type Action =
| {
type: 'SET_DELETE';
payload: {
isConfirmDialogOpen: boolean;
selectedSkillId: string | undefined;
}
}
| { type: 'SET_SKILLS'; payload: Skill[] }
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FORM_MODE'; payload: FormMode }
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean }
| {
type: 'SET_OPEN_CLOSE_ENTRY_FORM';
payload: {
formMode: FormMode;
selectedSkillId: string | undefined;
isFormOpen: boolean;
}
}
| { type: 'SET_ACTIVE_TAB'; payload: string }
export const initialState: SkillsState = {
activeTab: 'skills',
alert: undefined,
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
skills: [],
selectedSkillId: undefined
}
export const reducer = (
state: SkillsState,
action: Action
): SkillsState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmDialogOpen,
selectedSkillId: action.payload.selectedSkillId
}
}
case 'SET_SKILLS': {
return {
...state,
skills: action.payload
}
}
case 'SET_ALERT': {
return {
...state,
alert: action.payload
}
}
case 'SET_FORM_MODE': {
return {
...state,
formMode: action.payload
}
}
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
return {
...state,
isConfirmDialogLoading: action.payload
}
}
case 'SET_IS_LOADING': {
return {
...state,
isLoading: action.payload
}
}
case 'SET_OPEN_CLOSE_ENTRY_FORM': {
return {
...state,
formMode: action.payload.formMode,
isFormOpen: action.payload.isFormOpen,
selectedSkillId: action.payload.selectedSkillId
}
}
case 'SET_ACTIVE_TAB': {
return {
...state,
activeTab: action.payload
}
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,34 @@
import { useReducer } from "react"
import { initialState, reducer } from "./reducer"
import Skills from "../skills/Skills"
import SkillCategories from "../skillCategories/SkillCategories"
const SkillsPage = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const onTabClicked = (event: any) => {
dispatch({ type: 'SET_ACTIVE_TAB', payload: event.target.ariaLabel })
}
return (
<div className='tabs tabs-lift mt-6 mr-10 ml-10'>
<label className='tab'>
<input aria-label='skills' type='radio' name='skills_tabs' onChange={onTabClicked} checked={state.activeTab === 'skills' ? true : false} />
Skills
</label>
<div className='tab-content bg-base-100 border-base-300 p-6'>
<Skills />
</div>
<label className='tab'>
<input aria-label='categories' type='radio' name='skills_tabs' onChange={onTabClicked} checked={state.activeTab === 'categories' ? true : false} />
Categories
</label>
<div className='tab-content bg-base-100 border-base-300 p-6'>
<SkillCategories />
</div>
</div>
)
}
export default SkillsPage;

View File

@@ -0,0 +1,3 @@
export interface SkillsPageState {
activeTab: string;
}

View File

@@ -0,0 +1,22 @@
import { SkillsPageState } from "./SkillsPageState.interface";
type Action =
| { type: 'SET_ACTIVE_TAB'; payload: string }
export const initialState: SkillsPageState = {
activeTab: 'skills'
}
export const reducer = (
state: SkillsPageState,
action: Action
): SkillsPageState => {
switch (action.type) {
case 'SET_ACTIVE_TAB': {
return {
...state,
activeTab: action.payload
}
}
}
}

View File

@@ -0,0 +1,5 @@
import { Context, createContext } from 'react';
import { AppContextProps } from './AppContextProps.interface';
export const AppContext: Context<AppContextProps> =
createContext<AppContextProps>({} as AppContextProps);

View File

@@ -0,0 +1,7 @@
import { Action } from './reducer';
import { AppContextState } from './AppContextState.interface';
export interface AppContextProps {
state: AppContextState;
dispatch: React.Dispatch<Action>;
}

View File

@@ -0,0 +1,29 @@
import { useMemo, useReducer } from 'react';
import { AppContext } from './AppContext';
import { AppContextProviderProps } from './AppContextProviderProps.interface';
import { AppContextProps } from './AppContextProps.interface';
import { AppContextState } from './AppContextState.interface';
import { reducer } from './reducer';
const AppContextProvider = (
props: AppContextProviderProps
) => {
const intialState: AppContextState = {
userProfile: {}
};
const [state, dispatch] = useReducer(reducer, intialState);
const contextValue: AppContextProps = useMemo(() => {
return {
state,
dispatch
};
}, [state, dispatch]);
return (
<AppContext.Provider value={contextValue}>
{props.children}
</AppContext.Provider>
);
};
export default AppContextProvider;

View File

@@ -0,0 +1,3 @@
export interface AppContextProviderProps {
children: React.ReactNode;
}

View File

@@ -0,0 +1,5 @@
import { User } from '@microsoft/microsoft-graph-types';
export interface AppContextState {
userProfile: User;
}

View File

@@ -0,0 +1,22 @@
import { User } from '@microsoft/microsoft-graph-types';
import { AppContextState } from './AppContextState.interface';
export type Action =
| { type: 'SET_USER_PROFILE'; payload: User };
export const reducer = (
state: AppContextState,
action: Action
): AppContextState => {
switch (action.type) {
case 'SET_USER_PROFILE': {
return {
...state,
userProfile: action.payload
};
}
default: {
return state;
}
}
};

View File

@@ -0,0 +1,6 @@
export enum FormMode {
ADD = 'ADD',
EDIT = 'EDIT',
VIEW = 'VIEW',
CANCEL = 'CANCEL'
}

View File

@@ -0,0 +1,7 @@
export enum ScreenSize {
SM = 'SM',
MD = 'MD',
LG = 'LG',
XL = 'XL',
XXL = 'XXL'
}

View File

@@ -0,0 +1,4 @@
export enum UserRole {
READ = 'Root.Read',
WRITE = 'Root.Write'
}

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,11 @@
import { useContext } from 'react';
import { AppContext } from '../../context/appContext/AppContext';
export const useAppContext = () => {
const { state, dispatch } = useContext(AppContext);
return {
state,
dispatch
};
};

View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { ScreenSize } from '../../enums/screenSize';
export const useBreakpoints = () => {
const [screenSize, setScreenSize] = useState<ScreenSize>();
const windowWidth = window.innerWidth;
const getWindowSize = (width: number): ScreenSize => {
let size!: ScreenSize;
switch (true) {
case width < 640: {
size = ScreenSize.SM;
break;
}
case width >= 640 && width < 1024: {
size = ScreenSize.MD;
break;
}
case width >= 1024 && width < 1280: {
size = ScreenSize.LG
break;
}
case width >= 1280 && width < 1536: {
size = ScreenSize.XL;
break;
}
case width >= 1536: {
size = ScreenSize.XXL;
break;
}
}
return size;
}
const onWindowResize = () => {
const width: number = window.innerWidth;
const newScreenSize: ScreenSize = getWindowSize(width);
setScreenSize(newScreenSize);
}
useEffect(() => {
onWindowResize()
window.addEventListener('resize', onWindowResize);
return () => {
window.removeEventListener('resize', onWindowResize);
}
}, [])
return {
screenSize
}
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react';
import { AxiosInstance, AxiosResponse } from 'axios';
import httpClient from '../../httpClient/httpClient';
import { Profile } from '../../components/profiles/Profile.interface';
export const useProfiles = () => {
const [profiles, setProfiles] = useState<Profile[]>();
const getProfile = async (profileId: string) => {
try {
const response: AxiosResponse = await httpClient.get(
`api/profile/${profileId}`
);
return response.data;
} catch (error) {
return error;
}
};
useEffect(() => {
const getProfiles = async () => {
try {
const response: AxiosResponse = await httpClient.get(
`/api/profiles`
);
setProfiles(response.data);
} catch (error) {
return error;
}
};
getProfiles();
}, []);
return {
getProfile,
profiles
};
};

View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { Skill } from "../../components/skills/Skill.interface";
import { AxiosResponse } from "axios";
import httpClient from "../../httpClient/httpClient";
export const useSkills = () => {
const [skills, setSkills] = useState<Skill[]>();
useEffect(() => {
const getSkills = async () => {
try {
const response: AxiosResponse = await httpClient.get(
`/api/skills`
);
setSkills(response.data);
} catch (error) {
return error;
}
}
getSkills()
}, [])
return {
skills
}
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { useOidc } from "../../oidc";
import { UserRole } from "../../enums/userRole";
export const useUserRole = () => {
const [userRole, setUserRole] = useState<UserRole>()
const { isUserLoggedIn, decodedIdToken } = useOidc();
useEffect(() => {
if (isUserLoggedIn && decodedIdToken) {
const rolesKeyName: string | undefined = Object.keys(decodedIdToken).find((key) => key.includes('roles'));
const idTokenRoles: string[] = decodedIdToken[rolesKeyName!] as string[];
let newUserRole: string | undefined;
for (const key in UserRole) {
if (UserRole[key as keyof typeof UserRole] === idTokenRoles[0]) {
newUserRole = key;
break;
}
}
setUserRole(UserRole[newUserRole! as keyof typeof UserRole]);
}
}, [decodedIdToken, isUserLoggedIn])
return {
userRole
}
}

View File

@@ -0,0 +1,25 @@
import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios';
import { getOidc } from '../oidc';
let config: CreateAxiosDefaults<any> = {
baseURL: import.meta.env.VITE_API_URL,
headers: {
'Content-Type': 'application/json'
}
};
const httpClient: AxiosInstance = axios.create(config)
httpClient.interceptors.request.use(async (config) => {
const oidc = await getOidc();
if (oidc.isUserLoggedIn) {
const accessToken = await oidc.getAccessToken();
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
export default httpClient;

4
client/src/interfaces.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface Alert {
severity: 'success' | 'error' | 'info' | 'warning';
message: string;
}

View File

@@ -0,0 +1,4 @@
export interface Alert {
severity: 'info' | 'error' | 'success' | 'warning';
message: string;
}

View File

@@ -0,0 +1,4 @@
export interface Status {
id: number;
name: string;
}

18
client/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { BrowserRouter } from 'react-router-dom';
import AppContextProvider from './context/appContext/AppContextProvider.tsx';
import './styles.css';
import { OidcInitializationGate } from './oidc.ts';
ReactDOM.createRoot(document.getElementById('root')!).render(
<OidcInitializationGate>
<AppContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AppContextProvider>
</OidcInitializationGate>
)

21
client/src/oidc.ts Normal file
View File

@@ -0,0 +1,21 @@
import { oidcSpa } from "oidc-spa/react-spa";
export const {
bootstrapOidc,
useOidc,
getOidc,
withLoginEnforced,
OidcInitializationGate
} = oidcSpa.createUtils()
bootstrapOidc({
implementation: 'real',
issuerUri: import.meta.env.VITE_ISSUER_URI,
clientId: import.meta.env.VITE_CLIENT_ID,
scopes: [
'email',
'openid',
'profile',
`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`
]
})

42
client/src/styles.css Normal file
View File

@@ -0,0 +1,42 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "lofi";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(94% 0 0);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(15.906% 0 0);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(21.455% 0.001 17.278);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(26.861% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(0% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(79.54% 0.103 205.9);
--color-info-content: oklch(15.908% 0.02 205.9);
--color-success: oklch(90.13% 0.153 164.14);
--color-success-content: oklch(18.026% 0.03 164.14);
--color-warning: oklch(88.37% 0.135 79.94);
--color-warning-content: oklch(17.674% 0.027 79.94);
--color-error: oklch(78.66% 0.15 28.47);
--color-error-content: oklch(15.732% 0.03 28.47);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@plugin "@tailwindcss/typography";
body {
background-color: #f5f5f5;
min-height: 100vh;
}

11
client/src/tanstack.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import '@tanstack/react-table';
/* eslint-disable */
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
align?: 'text-left' | 'text-center' | 'text-right';
className?: string;
headerAlign?: 'text-left' | 'text-center' | 'text-right';
}
}
/* eslint-enable */

12
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +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;
}

26
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
client/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

29
client/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { oidcSpa } from 'oidc-spa/vite-plugin'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
oidcSpa(),
react(),
tailwindcss()
],
preview: {
port: 8080,
strictPort: true
},
server: {
host: true,
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
});