First commit
This commit is contained in:
6
client/.env
Normal file
6
client/.env
Normal 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
24
client/.gitignore
vendored
Normal 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
1
client/README.md
Normal file
@@ -0,0 +1 @@
|
||||
# APP
|
||||
28
client/eslint.config.js
Normal file
28
client/eslint.config.js
Normal 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
13
client/index.html
Normal 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
47
client/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
BIN
client/public/noahspan-logo.png
Normal file
BIN
client/public/noahspan-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
1
client/public/vite.svg
Normal file
1
client/public/vite.svg
Normal 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
23
client/src/App.tsx
Normal 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;
|
||||
7
client/src/alert/Alert.interface.ts
Normal file
7
client/src/alert/Alert.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface AlertProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
closeIcon?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
severity: 'info' | 'error' | 'success' | 'warning';
|
||||
}
|
||||
44
client/src/alert/Alert.tsx
Normal file
44
client/src/alert/Alert.tsx
Normal 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;
|
||||
1
client/src/assets/react.svg
Normal file
1
client/src/assets/react.svg
Normal 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 |
159
client/src/components/SkillCategoryForm/SkillCategoryForm.tsx
Normal file
159
client/src/components/SkillCategoryForm/SkillCategoryForm.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface SkillCategoryFormProps {
|
||||
categoryId: string | undefined;
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface SkillCategoryFormState {
|
||||
error: string | undefined;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
42
client/src/components/SkillCategoryForm/reducer.ts
Normal file
42
client/src/components/SkillCategoryForm/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
297
client/src/components/SkillForm/SkillForm.tsx
Normal file
297
client/src/components/SkillForm/SkillForm.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface SkillFormProps {
|
||||
skillId?: string;
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
53
client/src/components/SkillForm/reducer.ts
Normal file
53
client/src/components/SkillForm/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
62
client/src/components/actionMenu/ActionMenu.tsx
Normal file
62
client/src/components/actionMenu/ActionMenu.tsx
Normal 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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
export interface ActionMenuProps {
|
||||
id: string;
|
||||
onDelete: (entryId: string) => void;
|
||||
onOpenCloseForm: (formMode: FormMode, id: string) => void;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ConfirmationDialogProps {
|
||||
contentText: string;
|
||||
isLoading: boolean;
|
||||
isOpen: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
}
|
||||
354
client/src/components/experienceForm/ExperienceForm.tsx
Normal file
354
client/src/components/experienceForm/ExperienceForm.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface ExperienceFormProps {
|
||||
experienceId?: string;
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
59
client/src/components/experienceForm/reducer.ts
Normal file
59
client/src/components/experienceForm/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
18
client/src/components/experiences/Experience.interface.ts
Normal file
18
client/src/components/experiences/Experience.interface.ts
Normal 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;
|
||||
}
|
||||
370
client/src/components/experiences/Experiences.tsx
Normal file
370
client/src/components/experiences/Experiences.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
93
client/src/components/experiences/reducer.ts
Normal file
93
client/src/components/experiences/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MultiSelectDropdownProps {
|
||||
options: { label: string; value: string }[];
|
||||
}
|
||||
213
client/src/components/profileForm/ProfileForm.tsx
Normal file
213
client/src/components/profileForm/ProfileForm.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface ProfileFormProps {
|
||||
profileId?: string;
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ProfileFormState {
|
||||
error: string | undefined;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
42
client/src/components/profileForm/reducer.ts
Normal file
42
client/src/components/profileForm/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
14
client/src/components/profiles/Profile.interface.ts
Normal file
14
client/src/components/profiles/Profile.interface.ts
Normal 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;
|
||||
}
|
||||
348
client/src/components/profiles/Profiles.tsx
Normal file
348
client/src/components/profiles/Profiles.tsx
Normal 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;
|
||||
14
client/src/components/profiles/ProfilesState.interface.ts
Normal file
14
client/src/components/profiles/ProfilesState.interface.ts
Normal 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;
|
||||
}
|
||||
93
client/src/components/profiles/reducer.ts
Normal file
93
client/src/components/profiles/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
310
client/src/components/projectForm/ProjectForm.tsx
Normal file
310
client/src/components/projectForm/ProjectForm.tsx
Normal 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;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface ProjectFormProps {
|
||||
projectId?: string;
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Profile } from "../profiles/Profile.interface";
|
||||
|
||||
export interface ProjectFormState {
|
||||
error: string | undefined;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
profileOptions: Profile[];
|
||||
}
|
||||
50
client/src/components/projectForm/reducer.ts
Normal file
50
client/src/components/projectForm/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
10
client/src/components/projects/Project.interface.ts
Normal file
10
client/src/components/projects/Project.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
iconName: string;
|
||||
name: string;
|
||||
order: string;
|
||||
repoUrl: string;
|
||||
siteUrl: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
}
|
||||
372
client/src/components/projects/Projects.tsx
Normal file
372
client/src/components/projects/Projects.tsx
Normal 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;
|
||||
14
client/src/components/projects/ProjectsState.interface.ts
Normal file
14
client/src/components/projects/ProjectsState.interface.ts
Normal 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;
|
||||
}
|
||||
93
client/src/components/projects/reducer.ts
Normal file
93
client/src/components/projects/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
172
client/src/components/siteNav/SiteNav.tsx
Normal file
172
client/src/components/siteNav/SiteNav.tsx
Normal 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;
|
||||
309
client/src/components/skillCategories/SkillCategories.tsx
Normal file
309
client/src/components/skillCategories/SkillCategories.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface SkillCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
80
client/src/components/skillCategories/reducer.ts
Normal file
80
client/src/components/skillCategories/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
client/src/components/skills/Skill.interface.ts
Normal file
7
client/src/components/skills/Skill.interface.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface Skill {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
level: string;
|
||||
rank: string;
|
||||
}
|
||||
353
client/src/components/skills/Skills.tsx
Normal file
353
client/src/components/skills/Skills.tsx
Normal 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;
|
||||
15
client/src/components/skills/SkillsState.interface.ts
Normal file
15
client/src/components/skills/SkillsState.interface.ts
Normal 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;
|
||||
}
|
||||
101
client/src/components/skills/reducer.ts
Normal file
101
client/src/components/skills/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
client/src/components/skillsPage/SkillsPage.tsx
Normal file
34
client/src/components/skillsPage/SkillsPage.tsx
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface SkillsPageState {
|
||||
activeTab: string;
|
||||
}
|
||||
22
client/src/components/skillsPage/reducer.ts
Normal file
22
client/src/components/skillsPage/reducer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
client/src/context/appContext/AppContext.tsx
Normal file
5
client/src/context/appContext/AppContext.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Context, createContext } from 'react';
|
||||
import { AppContextProps } from './AppContextProps.interface';
|
||||
|
||||
export const AppContext: Context<AppContextProps> =
|
||||
createContext<AppContextProps>({} as AppContextProps);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Action } from './reducer';
|
||||
import { AppContextState } from './AppContextState.interface';
|
||||
|
||||
export interface AppContextProps {
|
||||
state: AppContextState;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}
|
||||
29
client/src/context/appContext/AppContextProvider.tsx
Normal file
29
client/src/context/appContext/AppContextProvider.tsx
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface AppContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { User } from '@microsoft/microsoft-graph-types';
|
||||
|
||||
export interface AppContextState {
|
||||
userProfile: User;
|
||||
}
|
||||
22
client/src/context/appContext/reducer.ts
Normal file
22
client/src/context/appContext/reducer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
6
client/src/enums/formMode.ts
Normal file
6
client/src/enums/formMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum FormMode {
|
||||
ADD = 'ADD',
|
||||
EDIT = 'EDIT',
|
||||
VIEW = 'VIEW',
|
||||
CANCEL = 'CANCEL'
|
||||
}
|
||||
7
client/src/enums/screenSize.ts
Normal file
7
client/src/enums/screenSize.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum ScreenSize {
|
||||
SM = 'SM',
|
||||
MD = 'MD',
|
||||
LG = 'LG',
|
||||
XL = 'XL',
|
||||
XXL = 'XXL'
|
||||
}
|
||||
4
client/src/enums/userRole.ts
Normal file
4
client/src/enums/userRole.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum UserRole {
|
||||
READ = 'Root.Read',
|
||||
WRITE = 'Root.Write'
|
||||
}
|
||||
32
client/src/hooks/accessToken/UseAccessToken.tsx
Normal file
32
client/src/hooks/accessToken/UseAccessToken.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
11
client/src/hooks/appContext/UseAppContext.tsx
Normal file
11
client/src/hooks/appContext/UseAppContext.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
62
client/src/hooks/breakpoints/UseBreakpoints.tsx
Normal file
62
client/src/hooks/breakpoints/UseBreakpoints.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
41
client/src/hooks/profiles/UseProfiles.tsx
Normal file
41
client/src/hooks/profiles/UseProfiles.tsx
Normal 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
|
||||
};
|
||||
};
|
||||
29
client/src/hooks/skills/UseSkills.tsx
Normal file
29
client/src/hooks/skills/UseSkills.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
31
client/src/hooks/userRole/UseUserRole.tsx
Normal file
31
client/src/hooks/userRole/UseUserRole.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
25
client/src/httpClient/httpClient.tsx
Normal file
25
client/src/httpClient/httpClient.tsx
Normal 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
4
client/src/interfaces.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Alert {
|
||||
severity: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
4
client/src/interfaces/Alert.interface.ts
Normal file
4
client/src/interfaces/Alert.interface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Alert {
|
||||
severity: 'info' | 'error' | 'success' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
4
client/src/interfaces/Status.interface.ts
Normal file
4
client/src/interfaces/Status.interface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Status {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
18
client/src/main.tsx
Normal file
18
client/src/main.tsx
Normal 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
21
client/src/oidc.ts
Normal 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
42
client/src/styles.css
Normal 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
11
client/src/tanstack.d.ts
vendored
Normal 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
12
client/src/vite-env.d.ts
vendored
Normal 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
26
client/tsconfig.app.json
Normal 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
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
client/tsconfig.node.json
Normal file
24
client/tsconfig.node.json
Normal 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
29
client/vite.config.ts
Normal 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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user