(feature): migrate to cutom react from next init

This commit is contained in:
Maksym Sadovnychyy 2025-11-02 20:42:55 +01:00
parent 81d602e381
commit edacd27aef
152 changed files with 16479 additions and 2101 deletions

View File

@ -1,38 +1,16 @@
# Use the specific Node.js version 20.14.0 image as the base image for building the app
FROM node:20.14.0-alpine AS build
FROM node:20.14.0-alpine
# Set the working directory inside the container
# Ambiente di sviluppo
ENV NODE_ENV=development
ENV LETSENCRYPT_SERVER=http://localhost:8080
ENV CHOKIDAR_USEPOLLING=true
ENV WATCHPACK_POLLING=true
# Lavora come utente normale
USER node
WORKDIR /app
# Copy the package.json and package-lock.json files to the working directory
COPY ClientApp/package*.json ./
# Install the project dependencies
RUN npm install
# Copy the rest of the application code to the working directory
COPY ClientApp .
# Build the Next.js application for production
RUN npm run build
# Use the same Node.js image for running the app
FROM node:20.14.0-alpine AS production
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the working directory
COPY ClientApp/package*.json ./
# Install only production dependencies
RUN npm install --only=production
# Copy the built Next.js application from the build stage to the current directory
COPY --from=build /app ./
# Expose port 3000 to access the application
EXPOSE 3000
# Start the Next.js server
CMD ["npm", "start"]
# Comando di avvio
CMD ["sh", "-c", "npm install && npm run dev"]

View File

@ -0,0 +1,38 @@
# Use the specific Node.js version 20.14.0 image as the base image for building the app
FROM node:20.14.0-alpine AS build
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the working directory
COPY ClientApp/package*.json ./
# Install the project dependencies
RUN npm install
# Copy the rest of the application code to the working directory
COPY ClientApp .
# Build the Next.js application for production
RUN npm run build
# Use the same Node.js image for running the app
FROM node:20.14.0-alpine AS production
# Set the working directory inside the container
WORKDIR /app
# Copy the package.json and package-lock.json files to the working directory
COPY ClientApp/package*.json ./
# Install only production dependencies
RUN npm install --only=production
# Copy the built Next.js application from the build stage to the current directory
COPY --from=build /app ./
# Expose port 3000 to access the application
EXPOSE 3000
# Start the Next.js server
CMD ["npm", "start"]

View File

@ -38,13 +38,13 @@ export default function Page() {
const fetchAccounts = async () => {
const newAccounts: CacheAccount[] = []
const gatAccountsResult = await httpService.get<GetAccountResponse[]>(
const getAccountsResult = await httpService.get<GetAccountResponse[]>(
GetApiRoute(ApiRoutes.ACCOUNTS)
)
if (!gatAccountsResult.isSuccess) return
if (!getAccountsResult.isSuccess) return
gatAccountsResult.data?.forEach((account) => {
getAccountsResult.data?.forEach((account) => {
newAccounts.push(toCacheAccount(account))
})

View File

@ -1,39 +0,0 @@
'use client'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { PageContainer } from '@/components/pageContainer'
import { CustomButton } from '@/controls'
import { showToast } from '@/redux/slices/toastSlice'
import { useAppDispatch } from '@/redux/store'
import { httpService } from '@/services/HttpService'
const TestPage = () => {
const dispatch = useAppDispatch()
const handleTestAgent = async () => {
httpService
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
.then((response) => {
dispatch(
showToast({
message: JSON.stringify(response),
type: 'info'
})
)
})
}
return (
<PageContainer title="CertsUI Tests">
<CustomButton
type="button"
onClick={handleTestAgent}
className="bg-green-500 text-white p-2 rounded ml-2"
>
Test Agent
</CustomButton>
</PageContainer>
)
}
export default TestPage

View File

@ -0,0 +1,78 @@
'use client'
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
import { PageContainer } from '@/components/pageContainer'
import { CustomButton, CustomFileUploader } from '@/controls'
import { showToast } from '@/redux/slices/toastSlice'
import { useAppDispatch } from '@/redux/store'
import { httpService } from '@/services/HttpService'
import { useEffect, useState } from 'react'
const TestPage = () => {
const dispatch = useAppDispatch()
const [files, setFiles] = useState<File[] | null>(null)
const handleTestAgent = async () => {
httpService
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
.then((response) => {
dispatch(
showToast({
message: JSON.stringify(response),
type: 'info'
})
)
})
}
const handleDownloadCache = async () => {}
const handleUploadCache = async () => {}
const handleRestoreFromCache = async () => {}
useEffect(() => {
if (files && files.length > 0) {
handleUploadCache()
}
}, [files])
return (
<PageContainer title="CertsUI Utils">
<CustomButton
type="button"
onClick={handleTestAgent}
className="bg-green-500 text-white p-2 rounded ml-2"
>
Test Agent
</CustomButton>
<CustomButton
type="button"
onClick={handleRestoreFromCache}
className="bg-yellow-500 text-white p-2 rounded ml-2"
>
Restore from cache
</CustomButton>
<CustomButton
type="button"
onClick={handleDownloadCache}
className="bg-blue-500 text-white p-2 rounded ml-2"
>
Download cache
</CustomButton>
<CustomFileUploader
value={files}
onChange={setFiles}
accept=".zip"
buttonClassName="bg-blue-500 text-white p-2 rounded ml-2"
title="Upload cache"
/>
</PageContainer>
)
}
export default TestPage

View File

@ -16,7 +16,7 @@ interface SideMenuProps {
const menuItems = [
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
{ icon: <FaUserPlus />, label: 'Register', path: '/register' },
{ icon: <FaThermometerHalf />, label: 'Test', path: '/test' }
{ icon: <FaThermometerHalf />, label: 'Utils', path: '/utils' }
]
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {

View File

@ -0,0 +1,98 @@
'use client'
import React, { FC, useRef } from 'react'
import { CustomButton } from './customButton'
interface CustomFileUploaderProps {
value?: File[] | null
onChange?: (files: File[] | null) => void
label?: string
labelClassName?: string
error?: string
errorClassName?: string
className?: string
buttonClassName?: string
inputClassName?: string
readOnly?: boolean
disabled?: boolean
accept?: string
multiple?: boolean
title?: string
}
const CustomFileUploader: FC<CustomFileUploaderProps> = ({
value,
onChange,
label,
labelClassName = '',
error,
errorClassName = '',
className = '',
buttonClassName = '',
inputClassName = '',
readOnly = false,
disabled = false,
accept,
multiple = false,
title
}) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!readOnly && !disabled) {
const files = e.target.files ? Array.from(e.target.files) : null
onChange?.(files && files.length > 0 ? files : null)
// Reset input value so the same file can be selected again
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
return (
<span className={`inline-flex items-center ${className}`}>
{label && <label className={`mb-1 ${labelClassName}`}>{label}</label>}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleChange}
disabled={disabled}
readOnly={readOnly}
accept={accept}
multiple={multiple}
/>
{value && value.length > 0 && (
<>
<span className="flex flex-row gap-2 mr-2 text-sm text-gray-600">
{value.map((file, idx) => (
<span key={file.name + idx}>{file.name}</span>
))}
</span>
<CustomButton
onClick={() => {
onChange?.(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}}
className={buttonClassName + ' ml-1'}
disabled={disabled || readOnly}
type="button"
>
</CustomButton>
</>
)}
<CustomButton
onClick={() => {
if (!disabled && !readOnly) fileInputRef.current?.click()
}}
className={buttonClassName}
disabled={disabled}
>
{title}
</CustomButton>
{error && (
<span className={`text-red-500 mt-1 ${errorClassName}`}>{error}</span>
)}
</span>
)
}
export { CustomFileUploader }

View File

@ -4,6 +4,7 @@ import { CustomCheckbox } from './customCheckbox'
import { CustomSelect } from './customSelect'
import { CustomEnumSelect } from './customEnumSelect'
import { CustomRadioGroup } from './customRadioGroup'
import { CustomFileUploader } from './customFileUploader'
export {
CustomButton,
@ -11,5 +12,6 @@ export {
CustomCheckbox,
CustomSelect,
CustomEnumSelect,
CustomRadioGroup
CustomRadioGroup,
CustomFileUploader
}

View File

@ -1,4 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
const nextConfig = {}
export default nextConfig

File diff suppressed because it is too large Load Diff

View File

@ -9,28 +9,30 @@
"lint": "next lint"
},
"dependencies": {
"@heroicons/react": "^2.1.3",
"@reduxjs/toolkit": "^2.2.5",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.2.1",
"react-redux": "^9.1.2",
"react-toastify": "^10.0.5",
"uuid": "^10.0.0"
"@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.9.2",
"autoprefixer": "^10.4.21",
"next": "16.0.1",
"react": "^19",
"react-dom": "^19",
"react-icons": "^5.5.0",
"react-redux": "^9.2.0",
"react-toastify": "^11.0.5",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/uuid": "^10.0.0",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"@tailwindcss/postcss": "^4.1.16",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"postcss": "^8",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.1",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.16",
"typescript": "^5"
}
}

View File

@ -1,7 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View File

@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -18,9 +22,20 @@
}
],
"paths": {
"@/*": ["./*"]
}
"@/*": [
"./*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="MaksIT.Core" Version="1.5.1" />
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />

View File

@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />

3
src/MaksIT.WebUI/.env Normal file
View File

@ -0,0 +1,3 @@
VITE_APP_TITLE=MaksIT.CertsUI
VITE_COMPANY=MaksIT
VITE_API_URL=http://localhost:8080/api

15
src/MaksIT.WebUI/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}"
}
]
}

12
src/MaksIT.WebUI/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"eslint.format.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}

View File

@ -0,0 +1,13 @@
FROM node:24
# Ambiente di sviluppo
ENV NODE_ENV=development
# Lavora come utente normale
USER node
WORKDIR /app
EXPOSE 5173
# Comando di avvio
CMD ["sh", "-c", "npm install && npm run dev"]

View File

@ -0,0 +1 @@
FROM node:24

View File

@ -0,0 +1,25 @@
npm create vite@latest my-react-app
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

View File

@ -0,0 +1,50 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
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: {
parser: tseslint.parser,
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
react, // Import and use the React plugin
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
'@typescript-eslint': tseslint.plugin,
},
rules: {
// react plugin rules
'react/jsx-curly-brace-presence': [
'error',
{ props: 'always', children: 'never' }
],
...reactHooks.configs.recommended.rules,
// react-refresh plugin rules
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
// ESLint core rules
'quotes': ['error', 'single'], // Enforce single quotes
'semi': ['error', 'never'], // Disallow semicolons
'indent': ['error', 2], // Enforce 2-space indentation
'jsx-quotes': ['error', 'prefer-single'], // Enforce single quotes in JSX attributes
// @typescript-eslint plugin rules
'@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_', 'ignoreRestSiblings': true }],
'@typescript-eslint/no-empty-object-type': 'off',
},
},
)

View File

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

6214
src/MaksIT.WebUI/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,53 @@
{
"name": "maksit-vault",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.11.0",
"date-fns": "^4.1.0",
"jwt-decode": "^4.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.536.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.7.1",
"react-virtualized": "^9.22.6",
"uuid": "^11.1.0",
"zod": "^4.0.14"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/lodash": "^4.17.20",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-resizable": "^3.0.8",
"@types/react-virtualized": "^9.22.2",
"@vitejs/plugin-react-swc": "^3.11.0",
"eslint": "^9.32.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.0",
"typescript-eslint": "^8.38.0",
"vite": "^7.0.6"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,18 @@
import { Routes } from 'react-router-dom'
import { GetRoutes } from './AppMap'
import { Loader } from './components/Loader'
const App = () => {
return <>
<Routes>
{GetRoutes()}
</Routes>
<Loader />
</>
}
export {
App
}

View File

@ -0,0 +1,232 @@
import { Link, Route } from 'react-router-dom'
import { HomePage } from './pages/HomePage'
import { ComponentType, FC, ReactNode } from 'react'
import { Layout } from './components/Layout'
import { LoginScreen } from './components/LoginScreen'
import { Authorization } from './components/Authorization'
import { UserOffcanvas } from './components/UserOffcanvas'
import { UserButton } from './components/UserButton'
import { Toast } from './components/Toast'
import { UtilitiesPage } from './pages/UtilitiesPage'
import { RegisterPage } from './pages/RegisterPage'
interface LayoutWrapperProps {
children: ReactNode
}
const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
const { children } = props
return <Layout
sideMenu={
{
headerChildren: <p>{import.meta.env.VITE_APP_TITLE}</p> ,
children: <ul>
{GetMenuItems(LinkArea.SideMenu)}
</ul>,
footerChildren: <></>
}
}
header={
{
children: <>
<ul className={'flex space-x-4'}>
{/* <li>Item 1</li> */}
</ul>
<ul className={'flex space-x-4'}>
<li>
<UserButton />
</li>
</ul>
</>
}
}
footer={
{
children: <p>&copy; {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}</p>
}
}
>{children}</Layout>
}
interface AppMapType {
title: string,
routes: string[],
page: ComponentType,
useAuth?: boolean,
useLayout?: boolean
linkArea?: LinkArea []
}
enum LinkArea {
SideMenu,
TopMenuLeft,
TopMenuRigt
}
const AppMap: AppMapType[] = [
{
title: 'Home',
routes: ['/', '/home'],
page: HomePage,
linkArea: [LinkArea.SideMenu]
},
{
title: 'Register',
routes: ['/register'],
page: RegisterPage,
linkArea: [LinkArea.SideMenu]
},
{
title: 'Utilities',
routes: ['/utilities'],
page: UtilitiesPage,
linkArea: [LinkArea.SideMenu]
}
// {
// title: 'Login',
// routes: ['/login'],
// page: LoginScreen,
// useAuth: false,
// useLayout: false
// },
// {
// title: 'About',
// routes: ['/about'],
// page: Home
// },
// {
// title: 'User',
// routes: ['/user/:userId'],
// page: Users
// },
// {
// title: 'Organizations',
// routes: ['/organizations', '/organization/:organizationId'],
// page: Organizations,
// linkArea: [LinkArea.SideMenu]
// },
// {
// title: 'Applications',
// routes: ['/applications'],
// page: Applications,
// linkArea: [LinkArea.SideMenu]
// },
// {
// title: 'Secrets',
// routes: ['/secrets'],
// page: Secrets,
// linkArea: [LinkArea.SideMenu]
// },
// {
// title: 'Users',
// routes: ['/users'],
// page: Users,
// linkArea: [LinkArea.SideMenu]
// },
// {
// title: 'API Keys',
// routes: ['/apikeys'],
// page: ApiKeys,
// linkArea: [LinkArea.SideMenu]
// }
]
// AGENT_TEST = 'api/agent/test',
enum ApiRoutes {
ACCOUNTS = 'GET|/accounts',
ACCOUNT_POST = 'POST|/account',
ACCOUNT_GET = 'GET|/account/{accountId}',
ACCOUNT_PATCH = 'PATCH|/account/{accountId}',
ACCOUNT_DELETE = 'DELETE|/account/{accountId}',
// ACCOUNT_ID_CONTACTS = 'GET|/account/{accountId}/contacts',
// ACCOUNT_ID_CONTACT_ID = 'GET|/account/{accountId}/contact/{index}',
// ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames',
// ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}',
// Secrets
generateSecret = 'GET|/secret/generatesecret',
// Identity
identityLogin = 'POST|/identity/login',
identityRefresh = 'POST|/identity/refresh',
identityLogout = 'POST|/identity/logout',
}
const GetMenuItems = (linkArea: LinkArea) => {
return AppMap.filter(item => item.linkArea?.includes(linkArea)).map((item, index) => {
return <li key={index}><Link to={item.routes[0]}>{item.title}</Link></li>
})
}
const GetRoutes = () => {
return AppMap.flatMap((item) =>
item.routes.map((route) => {
const {
useAuth = true,
useLayout = true,
page: Page
} = item
const PageComponent = (
<>
{useLayout ? (
<LayoutWrapper>
<Page />
</LayoutWrapper>
) : (
<Page />
)}
<Toast />
</>
)
return (
<Route
key={route}
path={route}
// element={useAuth
// ? <Authorization>
// {PageComponent}
// <UserOffcanvas />
// </Authorization>
// : PageComponent}
element={PageComponent}
/>
)
})
)
}
interface ApiRoute {
method: string,
route: string
}
const GetApiRoute = (apiRoute: ApiRoutes): ApiRoute => {
const apiUrl = import.meta.env.VITE_API_URL
const [method, route] = apiRoute.split('|')
return {
method,
route: `${apiUrl}${route}`
}
}
export {
GetMenuItems,
GetRoutes,
ApiRoutes,
GetApiRoute
}

View File

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

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,160 @@
import axios from 'axios'
import { readIdentity } from './localStorage/identity'
import { ApiRoutes, GetApiRoute } from './AppMap'
import { store } from './redux/store'
import { refreshJwt } from './redux/slices/identitySlice'
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
// Create an Axios instance
const axiosInstance = axios.create({
timeout: 10000, // Set a timeout if needed
})
let isRefreshing = false
let refreshPromise: Promise<unknown> | null = null
// Add a request interceptor
axiosInstance.interceptors.request.use(
async config => {
// Dispatch request
store.dispatch(showLoader())
// List of URLs to exclude from adding Bearer token
const excludeUrls = [
GetApiRoute(ApiRoutes.identityLogin).route,
GetApiRoute(ApiRoutes.identityRefresh).route
]
// Check if the URL is in the exclude list
if (config.url && excludeUrls.includes(config.url)) {
return config
}
const identity = readIdentity()
const now = new Date()
if (identity) {
if (new Date(identity.expiresAt) < now) {
// Token expired, refresh if possible
if (new Date(identity.refreshTokenExpiresAt) > now) {
if (!isRefreshing) {
isRefreshing = true
refreshPromise = store.dispatch(refreshJwt())
.finally(() => { isRefreshing = false })
}
await refreshPromise
const newIdentity = readIdentity()
if (newIdentity) {
config.headers.Authorization = `${newIdentity.tokenType} ${newIdentity.token}`
}
}
} else {
config.headers.Authorization = `${identity.tokenType} ${identity.token}`
}
}
return config
},
error => {
// Handle request error
store.dispatch(hideLoader())
return Promise.reject(error)
}
)
// Add a response interceptor
axiosInstance.interceptors.response.use(
response => {
// Dispatch request end
store.dispatch(hideLoader())
return response
},
error => {
// Handle response error
store.dispatch(hideLoader())
if (error.response && error.response.status === 401) {
// Handle unauthorized error (e.g., redirect to login)
}
return Promise.reject(error)
}
)
const getData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.get<TResponse>(url, {
headers: {
'Content-Type': 'application/json'
}
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
const postData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.post<TResponse>(url, data, {
headers: {
'Content-Type': 'application/json'
}
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
const patchData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.patch<TResponse>(url, data, {
headers: {
'Content-Type': 'application/json'
}
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
const putData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.put<TResponse>(url, data, {
headers: {
'Content-Type': 'application/json'
}
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
const deleteData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
try {
const response = await axiosInstance.delete<TResponse>(url, {
headers: {
'Content-Type': 'application/json'
}
})
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
}
}
export {
axiosInstance,
getData,
postData,
patchData,
putData,
deleteData
}

View File

@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice'
interface AuthorizationProps {
children: React.ReactNode;
}
const Authorization = (props: AuthorizationProps) => {
const { children } = props
const navigate = useNavigate()
const dispatch = useAppDispatch()
const { identity } = useAppSelector((state) => state.identity)
const [loading, setLoading] = useState(true)
useEffect(() => {
dispatch(setIdentityFromLocalStorage())
setLoading(false)
}, [dispatch])
useEffect(() => {
if (!loading) {
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
navigate('/login', { replace: true })
}
}
}, [identity, navigate, loading])
// Render a simple loading spinner while loading (Tailwind v4 compatible)
if (loading) {
return (
<div className={'flex items-center justify-center h-screen w-screen bg-white'}>
<div className={'animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500'}></div>
</div>
)
}
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
return null
}
return <>{children}</>
}
export {
Authorization
}

View File

@ -0,0 +1,433 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
import { PagedResponse } from '../../models/PagedResponse'
import { Plus, Trash2, Edit } from 'lucide-react'
import { debounce } from 'lodash'
interface FilterProps {
columnId: string
}
interface CellProps<T, K extends keyof T = keyof T> {
columnId: string
data: T
value: T[K]
}
export interface DataTableColumn<T, K extends keyof T = keyof T> {
id: string
accessorKey: K
header: string
filter: (
props: FilterProps,
onFilterChange: (filterId: string, columnId: string, filters: string) => void
) => React.ReactNode
cell: (props: CellProps<T, K>) => React.ReactNode
}
interface DataTableProps<T> {
rawd?: PagedResponse<T>
columns: DataTableColumn<T>[]
maxRecordsPerPage?: number
idFields?: string[]
allowAddRow?: () => boolean
onAddRow?: () => void
allowEditRow?: (ids: Record<string, string>) =>boolean
onEditRow?: (ids: Record<string, string>) => void
allowDeleteRow?: (ids: Record<string, string>) =>boolean
onDeleteRow?: (ids: Record<string, string>) => void
onFilterChange?: (filters: Record<string, string>) => void
onPreviousPage?: (pageNumber: number) => void
onNextPage?: (pageNumber: number) => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
storageKey?: string
}
const DEFAULT_ACTION_WIDTH = 80
const DEFAULT_COL_WIDTH = 150
const HEADER_ROWS = 2
const ROW_HEIGHT = 40
const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>) => {
const {
rawd,
columns,
idFields = ['id'],
allowAddRow = () => false,
onAddRow,
allowEditRow = (_) => false,
onEditRow,
allowDeleteRow = (_) => false,
onDeleteRow,
onFilterChange,
onPreviousPage,
onNextPage,
colspan = 12,
storageKey,
} = props
const {
items = [],
pageNumber = 0,
pageSize = 0,
totalCount = 0,
totalPages = 0,
hasPreviousPage = false,
hasNextPage = false,
} = rawd || {}
const gridRef = useRef<MultiGrid>(null)
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const [filterValues, setFilterValues] = useState<Record<string, Record<string, string>>>({})
const [colWidths, setColWidths] = useState<number[]>(() => {
const defaultWidths = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)]
if (storageKey) {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
// Check if parsed is an array and matches the expected length
if (Array.isArray(parsed) && parsed.length === columns.length + 1) { // +1 for action column
return parsed
}
else {
// Remove invalid storage and fall back to default
localStorage.removeItem(storageKey)
return defaultWidths
}
}
}
catch {
// Ignore and fall back to default
return defaultWidths
}
}
// If no storage key or no valid stored widths, return default
return defaultWidths
})
useEffect(() => {
if (storageKey) {
localStorage.setItem(storageKey, JSON.stringify(colWidths))
}
if (gridRef.current) {
gridRef.current.recomputeGridSize()
gridRef.current.forceUpdateGrids()
}
}, [colWidths, storageKey])
const debouncedOnFilterChange = useMemo(
() => (onFilterChange ? debounce(onFilterChange, 500) : undefined),
[onFilterChange]
)
const handleFilterChange = (
filterId: string,
columnId: string,
filters: string
) => {
setFilterValues((prev) => {
const newValues = {
...prev,
[filterId]: {
...prev[filterId],
[columnId]: filters,
},
}
const linqQueries = Object.fromEntries(
Object.entries(newValues).map(([fid, cols]) => {
const q = Object.values(cols)
.filter((v) => v)
.map((v) => `(${v})`)
.join(' && ')
return [fid, q]
})
)
debouncedOnFilterChange?.(linqQueries)
return newValues
})
}
const handlePreviousPage = () => onPreviousPage?.(pageNumber - 1)
const handleNextPage = () => onNextPage?.(pageNumber + 1)
const getRealIdsFromRow = (rowIndex: number) => {
const row = items[rowIndex]
const ids = Object.fromEntries(
idFields.map((key) => [key, `${row[key]}`])
)
return ids
}
const handleAddRow = () => onAddRow?.()
const handleEditRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
onEditRow?.(ids)
}
const handleDeleteRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
onDeleteRow?.(ids)
}
const handleAllowAddRow = () => {
return allowAddRow?.()
}
const handleAllowEditRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
return allowEditRow?.(ids)
}
const handleAllowDeleteRow = (rowIndex: number) => {
const ids = getRealIdsFromRow(rowIndex)
return allowDeleteRow?.(ids)
}
const handleRowClick = (idx: number) =>
setSelectedRowIndex((prev) => (prev === idx ? null : idx))
const handleHeaderResize = (colIdx: number, startX: number, startWidth: number) => {
const onMouseMove = (e: MouseEvent) => {
const delta = e.clientX - startX
setColWidths(prev => {
const next = [...prev]
next[colIdx] = Math.max(40, startWidth + delta)
return next
})
}
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
const cellRenderer = ({ columnIndex, key, rowIndex, style }: GridCellProps) => {
const isActionCol = columnIndex === 0
const col = columns[columnIndex - 1]
// classi statiche; solo width/height via style
const commonClasses = [
'box-border',
'flex',
'items-center',
'px-2',
'py-1',
'border-b',
'border-r',
'border-gray-200',
'overflow-hidden',
'whitespace-nowrap',
'truncate',
rowIndex >= HEADER_ROWS ? 'cursor-pointer' : '',
rowIndex >= HEADER_ROWS && selectedRowIndex === rowIndex - HEADER_ROWS ? 'bg-sky-100' : '',
].filter(Boolean).join(' ')
// Header
if (rowIndex === 0) {
const allowAddRowResult = handleAllowAddRow()
if (isActionCol) {
return (
<div key={key} style={style} className={commonClasses}>
<button
onClick={handleAddRow}
disabled={!allowAddRowResult}
className={`p-1 ${allowAddRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Plus />
</button>
</div>
)
}
// il div esterno mantiene style/className, Resizable solo sul contenuto
return (
<div
key={key}
style={{ ...style, userSelect: 'none' }}
className={commonClasses}
>
<span>{col.header}</span>
<div
style={{
position: 'absolute',
right: 0,
top: 0,
height: '100%',
width: 8,
cursor: 'col-resize',
zIndex: 10,
userSelect: 'none',
}}
onMouseDown={e => {
e.preventDefault()
handleHeaderResize(columnIndex, e.clientX, colWidths[columnIndex])
}}
/>
</div>
)
}
// Filter row
if (rowIndex === 1) {
return isActionCol ? (
<div key={key} style={style} className={commonClasses} />
) : (
<div key={key} style={style} className={commonClasses}>
{col.filter({ columnId: col.id }, handleFilterChange)}
</div>
)
}
// Data rows
const dataIdx = rowIndex - HEADER_ROWS
if (isActionCol) {
const allowEditRowResult = handleAllowEditRow(dataIdx)
const allowDeleteRowResult = handleAllowDeleteRow(dataIdx)
return (
<div
key={key}
style={style}
className={commonClasses} onClick={() => handleRowClick(dataIdx)}>
<button
onClick={e => {
e.stopPropagation()
handleEditRow(dataIdx)
}}
disabled={!allowEditRowResult}
className={`p-1 ${allowEditRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Edit />
</button>
<button
onClick={e => {
e.stopPropagation()
handleDeleteRow(dataIdx)
}}
disabled={!allowDeleteRowResult}
className={`p-1 ${allowDeleteRowResult
? 'cursor-pointer'
: 'cursor-not-allowed opacity-50'
}`}>
<Trash2 />
</button>
</div>
)
}
const row = items[dataIdx]
return (
<div
key={key}
style={style}
className={commonClasses}
onClick={() => setSelectedRowIndex(dataIdx)}
>
{col.cell({
columnId: col.id,
data: row,
value: row[col.accessorKey]
})}
</div>
)
}
// Handler for MultiGrid scroll
const handleGridScroll = ({
scrollTop,
clientHeight,
scrollHeight,
}: {
scrollTop: number
clientHeight: number
scrollHeight: number
}) => {
// At bottom
if (scrollTop + clientHeight >= scrollHeight - 2 && hasNextPage) {
handleNextPage()
}
// At top
if (scrollTop <= 2 && hasPreviousPage) {
handlePreviousPage()
}
}
return (
<div className={`col-span-${colspan} flex flex-col h-full w-full`}>
<div className={'flex-1'}>
<AutoSizer>
{({ height, width }) => (
<MultiGrid
ref={gridRef}
cellRenderer={cellRenderer}
columnCount={columns.length + 1}
columnWidth={({ index }) => colWidths[index]}
fixedColumnCount={1}
fixedRowCount={HEADER_ROWS}
height={height}
rowCount={items.length + HEADER_ROWS}
rowHeight={({ index }) => index === 1 ? ROW_HEIGHT * 2 : ROW_HEIGHT}
width={width}
onScroll={({ scrollTop, clientHeight, scrollHeight }) =>
handleGridScroll({ scrollTop, clientHeight, scrollHeight })
}
/>
)}
</AutoSizer>
</div>
<div className={'mt-4 text-sm'}>
<div className={'flex justify-end gap-4'}>
<span>Page Size: {pageSize}</span>
<span>Total Pages: {totalPages}</span>
<span>Total Count: {totalCount}</span>
</div>
<div className={'flex items-center justify-between mt-2'}>
<button
onClick={handlePreviousPage}
disabled={!hasPreviousPage}
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
aria-disabled={!hasPreviousPage}
>
Previous
</button>
<span>Page {pageNumber} of {totalPages}</span>
<button
onClick={handleNextPage}
disabled={!hasNextPage}
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
aria-disabled={!hasNextPage}
>
Next
</button>
</div>
</div>
</div>
)
}
export { DataTable }

View File

@ -0,0 +1,161 @@
import { useMemo, useState } from 'react'
import { debounce } from 'lodash'
import { useAppDispatch } from '../../redux/hooks'
import { postData } from '../../axiosConfig'
import { PagedRequest } from '../../models/PagedRequest'
import { PagedResponse } from '../../models/PagedResponse'
interface FilterPropsBase {
filterId?: string
columnId: string
accessorKey: string
value?: FilterState
disabled?: boolean
onFilterChange?: (filterId: string, columnId: string, filters: string) => void
}
interface NormalFilterProps extends FilterPropsBase {
type: 'normal',
}
interface RemoteFilterProps extends FilterPropsBase {
type: 'remote',
route: string
}
type FilterProps = NormalFilterProps | RemoteFilterProps
interface FilterState {
value: string,
operator: string
}
const DataTableFilter = <T extends { [key: string]: string }>(props: FilterProps) => {
const {
type,
filterId = 'filters',
columnId,
accessorKey,
value = {
value: '',
operator: 'contains'
},
disabled = false,
onFilterChange
} = props
const dispatch = useAppDispatch()
const [filterState, setFilterState] = useState<FilterState>(value)
const debounceOnFilterChange = useMemo(() => {
if (!onFilterChange) return
return debounce((route: string, filters: string) => {
postData<PagedRequest, PagedResponse<T>>(route, {
pageSize: 100,
filters
}).then((response) => {
if (!response) return
const linqQuery = response.items.map(item => `${columnId} == "${item['id']}"`).join(' || ')
onFilterChange?.(filterId, columnId, linqQuery)
}).finally(() => {
})
}, 500)
}, [filterId, columnId, dispatch, onFilterChange])
const handleFilterChange = (value: string, operator: string) => {
setFilterState({
value,
operator
})
if (value === '') {
onFilterChange?.(filterId, columnId, '')
return
}
let linqQuery = ''
switch (operator) {
case 'contains':
linqQuery = `${accessorKey}.Contains("${value}")`
break
case 'startsWith':
linqQuery = `${accessorKey}.StartsWith("${value}")`
break
case 'endsWith':
linqQuery = `${accessorKey}.EndsWith("${value}")`
break
case '=':
linqQuery = `${accessorKey} == "${value}"`
break
case '!=':
linqQuery = `${accessorKey} != "${value}"`
break
case '>':
linqQuery = `${accessorKey} > "${value}"`
break
case '<':
linqQuery = `${accessorKey} < "${value}"`
break
case '>=':
linqQuery = `${accessorKey} >= "${value}"`
break
case '<=':
linqQuery = `${accessorKey} <= "${value}"`
break
default:
linqQuery = `${accessorKey}.Contains("${value}")` // Default case handles using 'contains' as the safe fallback
break
}
if (type === 'normal') {
onFilterChange?.(filterId, columnId, linqQuery)
}
if (type === 'remote' && debounceOnFilterChange) {
const { route } = props as RemoteFilterProps
debounceOnFilterChange(route, linqQuery)
}
}
return (
<div className={'flex flex-col gap-2'}>
<input
type={'text'}
placeholder={'Filter...'}
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
value={filterState.value}
onChange={e => handleFilterChange(e.target.value, filterState.operator)}
disabled={disabled}
/>
<select
value={filterState.operator}
onChange={e => handleFilterChange(filterState.value, e.target.value)}
disabled={disabled}
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
>
<option value={'contains'}>Contains</option>
<option value={'startsWith'}>Starts With</option>
<option value={'endsWith'}>Ends With</option>
<option value={'='}>=</option>
<option value={'!='}>!=</option>
<option value={'>'}>{'>'}</option>
<option value={'<'}>{'<'}</option>
<option value={'>='}>{'>='}</option>
<option value={'<='}>{'<='}</option>
</select>
</div>
)
}
export {
DataTableFilter
}

View File

@ -0,0 +1,63 @@
import { useEffect, useState } from 'react'
import { getData } from '../../axiosConfig'
import { useAppDispatch } from '../../redux/hooks'
import { formatISODateString } from '../../functions'
interface NormalLabelProps {
type: 'normal',
value?: string,
dataType?: 'string' | 'date'
}
interface RemoteLabelProps {
type: 'remote',
route: string,
columnId: string,
accessorKey: string,
}
type LabelProps = NormalLabelProps | RemoteLabelProps
const DataTableLabel = <T extends { [key: string]: never }>(props: LabelProps) => {
const dispatch = useAppDispatch()
const [label, setLabel] = useState<string>('')
useEffect(() => {
const { type } = props
if (type === 'normal') {
const { value = '', dataType = 'string' } = props as NormalLabelProps
switch (dataType) {
case 'date':
setLabel(formatISODateString(value))
break
case 'string':
default:
setLabel(value)
break
}
}
if (type === 'remote') {
const { route, accessorKey } = props as RemoteLabelProps
getData<T>(route)
.then(response => {
if (!response) return
setLabel(response[accessorKey])
}).finally(() => {
})
}
}, [props, dispatch])
return <p>{label}</p>
}
export {
DataTableLabel
}

View File

@ -0,0 +1,18 @@
import { DataTableColumn } from './DataTable'
const createColumn = <T, K extends keyof T>(col: DataTableColumn<T, K>): DataTableColumn<T, K> => {
return col
}
/**
* TypeScript cannot express "an array of DataTableColumn<T, K> for various K" without using any or a cast in the helper.
* This is a known limitation and your use of a helper like createColumns is the best practical solution.
* @param cols
* @returns
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createColumns = <T>(cols: DataTableColumn<T, any>[]) => {
return cols as unknown as DataTableColumn<T, keyof T>[]
}
export { createColumn, createColumns }

View File

@ -0,0 +1,29 @@
import {
DataTable,
DataTableColumn
} from './DataTable'
import {
createColumn,
createColumns
} from './helpers'
import {
DataTableLabel
} from './DataTableLabel'
import {
DataTableFilter
} from './DataTableFilter'
export type {
DataTableColumn
}
export {
DataTable,
DataTableLabel,
DataTableFilter,
createColumn,
createColumns
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
interface FormContainerProps {
children?: ReactNode
}
const FormContainer: FC<FormContainerProps> = (props) => {
const {
children
} = props
return <div className={'grid grid-rows-[auto_1fr_auto] h-full gap-0'}>
{children}
</div>
}
export {
FormContainer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
interface FormContentProps {
children?: ReactNode
}
const FormContent: FC<FormContentProps> = (props) => {
const {
children
} = props
return <div className={'bg-gray-100 w-full h-full p-4 overflow-y-auto'}>
{children}
</div>
}
export {
FormContent
}

View File

@ -0,0 +1,31 @@
import { FC, ReactNode } from 'react'
interface FormFooterProps {
children?: ReactNode,
leftChildren?: ReactNode,
rightChildren?: ReactNode
}
const FormFooter: FC<FormFooterProps> = (props) => {
const {
children,
leftChildren,
rightChildren
} = props
return <div className={'bg-gray-200 p-4 h-14 flex justify-between items-center'}>
{children ?? <>
<div className={'flex space-x-4'}>{leftChildren}</div>
<div className={'flex space-x-4'}>{rightChildren}</div>
</>}
</div>
}
export {
FormFooter
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
interface FormHeaderProps {
children?: ReactNode
}
const FormHeader: FC<FormHeaderProps> = (props) => {
const {
children
} = props
return <h1 className={'bg-gray-200 p-4 h-14 text-2xl font-bold'}>
{children}
</h1>
}
export {
FormHeader
}

View File

@ -0,0 +1,11 @@
import { FormContainer } from './FormContainer'
import { FormHeader } from './FormHeader'
import { FormContent } from './FormContent'
import { FormFooter } from './FormFooter'
export {
FormContainer,
FormHeader,
FormContent,
FormFooter
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Container: FC<ContentProps> = (props) => {
const { children } = props
return <div className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
{children}
</div>
}
export {
Container
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Content: FC<ContentProps> = (props) => {
const { children } = props
return <main className={'bg-gray-100 h-full w-full overflow-y-auto'}>
{children}
</main>
}
export {
Content
}

View File

@ -0,0 +1,16 @@
import { FC, ReactNode } from 'react'
export interface FooterProps {
children: ReactNode
}
const Footer: FC<FooterProps> = (props) => {
const { children } = props
return <footer className={'bg-blue-500 text-white p-4 h-14 flex items-center justify-center'}>
{children}
</footer>
}
export {
Footer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface HeaderProps {
children: ReactNode
}
const Header: FC<HeaderProps> = (props) => {
const { children } = props
return <header>
<div className={'bg-blue-500 text-white p-4 h-14 flex justify-between items-center'}>
{children}
</div>
</header>
}
export {
Header
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContainerProps {
children: ReactNode
}
const MainContainer: FC<ContainerProps> = (props) => {
const { children } = props
return <div className={'grid grid-cols-[250px_1fr] h-screen w-screen'}>
{children}
</div>
}
export {
MainContainer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface ContainerProps {
headerChildren?: ReactNode
children: ReactNode
footerChildren?: ReactNode
}
const Container: FC<ContainerProps> = (props) => {
const { children } = props
return <aside className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
{children}
</aside>
}
export {
Container
}

View File

@ -0,0 +1,17 @@
import { FC, ReactNode } from 'react'
interface ContentProps {
children: ReactNode
}
const Content: FC<ContentProps> = (props) => {
const { children } = props
return <main className={'bg-gray-200 h-full p-4 overflow-y-auto border-r border-blue-500'}>
{children}
</main>
}
export {
Content
}

View File

@ -0,0 +1,16 @@
import { FC, ReactNode } from 'react'
export interface FooterProps {
children: ReactNode
}
const Footer: FC<FooterProps> = (props) => {
const { children } = props
return <footer className={'bg-blue-500 text-white p-4 h-14'}>
{children}
</footer>
}
export {
Footer
}

View File

@ -0,0 +1,19 @@
import { FC, ReactNode } from 'react'
export interface HeaderProps {
children: ReactNode
}
const Header: FC<HeaderProps> = (props) => {
const { children } = props
return <header>
<div className={'bg-blue-500 text-white p-4 h-14'}>
{children}
</div>
</header>
}
export {
Header
}

View File

@ -0,0 +1,32 @@
import { FC, ReactNode } from 'react'
import { Container } from '../Container'
import { Header } from './Header'
import { Content } from './Content'
import { Footer } from './Footer'
export interface SideMenuProps {
headerChildren?: ReactNode
children: ReactNode
footerChildren?: ReactNode
}
const SideMenu: FC<SideMenuProps> = (props) => {
const { headerChildren, children, footerChildren } = props
return <Container>
<Header>
{headerChildren}
</Header>
<Content>
{children}
</Content>
<Footer>
{footerChildren}
</Footer>
</Container>
}
export {
SideMenu
}

View File

@ -0,0 +1,40 @@
import { FC } from 'react'
import { MainContainer } from './MainContainer'
import { SideMenu, SideMenuProps } from './SideMenu'
import { Container } from './Container'
import { Header, HeaderProps } from './Header'
import { Footer, FooterProps } from './Footer'
import { Content } from './Content'
interface LayoutProps {
sideMenu: SideMenuProps
header: HeaderProps
children: React.ReactNode
footer: FooterProps
}
const Layout: FC<LayoutProps> = (props) => {
const { sideMenu, header, children, footer } = props
return <MainContainer>
<SideMenu
headerChildren={sideMenu.headerChildren}
footerChildren={sideMenu.footerChildren}
>
{sideMenu.children}
</SideMenu>
<Container>
<Header>
{header.children}
</Header>
<Content>
{children}
</Content>
<Footer>{footer.children}</Footer>
</Container>
</MainContainer>
}
export {
Layout
}

View File

@ -0,0 +1,84 @@
import { FC, useEffect, useRef, useState } from 'react'
interface LazyLoadTableColumnProps {
key: string
title: string
dataIndex: string
renderColumn?: (value: any) => React.ReactNode
}
interface LazyLoadTableProps {
data: any[]
columns: LazyLoadTableColumnProps[]
loadMore: () => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
}
const LazyLoadTable: FC<LazyLoadTableProps> = (props) => {
const {
data,
columns,
loadMore,
colspan = 6
} = props
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const observerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
},
{ threshold: 1.0 }
)
if (observerRef.current) {
observer.observe(observerRef.current)
}
return () => {
if (observerRef.current) {
observer.unobserve(observerRef.current)
}
}
}, [loadMore])
const handleRowClick = (index: number) => {
setSelectedRowIndex(selectedRowIndex === index ? null : index)
}
return (
<div className={`col-span-${colspan}`}>
<table className={'w-full border-collapse'}>
<thead>
<tr>
{columns.map((column, index) => (
<th key={index} className={'bg-gray-50 text-left py-2 px-4 border-b'}>{column.title}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr
key={index}
className={`hover:bg-gray-100 ${selectedRowIndex === index ? 'bg-blue-100' : ''} transition-colors`}
onClick={() => handleRowClick(index)}
>
{columns.map((column, colIndex) => (
<td className={'py-2 px-4 border-b'} key={colIndex}>
{column.renderColumn ? column.renderColumn(row[column.dataIndex]) : row[column.dataIndex]}
</td>
))}
</tr>
))}
</tbody>
</table>
<div ref={observerRef} className={'text-center py-4'}>Loading more...</div>
</div>
)
}
export { LazyLoadTable }

View File

@ -0,0 +1,17 @@
import { useAppSelector } from '../redux/hooks'
import { RootState } from '../redux/store'
const Loader = () => {
const loading = useAppSelector((state: RootState) => state.loader.loading)
if (!loading) return null
return (
<div className={'fixed inset-0 flex items-center justify-center bg-gray-800/75 z-50'}>
<div className={'ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32 animate-spin'} style={{ borderTopColor: '#3498db' }}></div>
</div>
)
}
export { Loader }

View File

@ -0,0 +1,139 @@
import React, { useEffect, useState } from 'react'
import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest'
import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { login } from '../redux/slices/identitySlice'
import { useFormState } from '../hooks/useFormState'
import { useNavigate } from 'react-router-dom'
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors'
const LoginScreen: React.FC = () => {
const [use2FA, setUse2FA] = useState(false)
const [use2FARecovery, setUse2FARecovery] = useState(false)
const navigate = useNavigate()
const dispatch = useAppDispatch()
const { identity } = useAppSelector((state) => state.identity)
const {
formState,
errors,
formIsValid,
handleInputChange
} = useFormState<LoginRequest>({
initialState: { username: '', password: '' },
validationSchema: LoginRequestSchema,
})
useEffect(() => {
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
navigate('/login', { replace: true })
} else {
navigate('/', { replace: true })
}
}, [identity, navigate])
const handleUse2FA = (e: React.ChangeEvent<HTMLInputElement>) => {
setUse2FA(e.target.checked)
if (!e.target.checked) {
setUse2FARecovery(false)
}
}
const handleLogin = () => {
if (!formIsValid) return
if (formState.twoFactorCode === '') delete formState.twoFactorCode
if (formState.twoFactorRecoveryCode === '') delete formState.twoFactorRecoveryCode
dispatch(login(formState))
}
// Utility classes
const inputClasses =
'block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200'
const checkboxClasses =
'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-200'
const buttonPrimaryClasses =
'w-full py-2 px-4 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold'
return (
<div className={'flex items-center justify-center min-h-screen bg-gray-100'}>
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}>
{/* Logo */}
<div className={'flex justify-center'}>
<img src={'/logo.png'} alt={'Logo'} className={'h-12 w-auto'} />
</div>
{/* Form */}
<div className={'space-y-4'}>
<div className={'space-y-4'}>
<TextBoxComponent
label={'Email'}
placeholder={'Email...'}
value={formState.username}
onChange={(e) => handleInputChange('username', e.target.value)}
errorText={errors.username}
/>
<TextBoxComponent
label={'Password'}
placeholder={'Password...'}
type={'password'}
value={formState.password}
onChange={(e) => handleInputChange('password', e.target.value)}
errorText={errors.password}
/>
</div>
{/* 2FA Options */}
<div className={'flex items-center gap-4'}>
<CheckBoxComponent
label={'Use 2FA'}
value={use2FA}
onChange={handleUse2FA}
/>
{use2FA && (
<CheckBoxComponent
label={'Use 2FA Recovery'}
value={use2FARecovery}
onChange={(e) => setUse2FARecovery(e.target.checked)}
/>
)}
</div>
{/* 2FA Inputs */}
{use2FA && (
<div className={'space-y-4'}>
{use2FARecovery ? (
<TextBoxComponent
label={'2FA Recovery Code'}
placeholder={'Recovery code...'}
value={formState.twoFactorRecoveryCode}
onChange={(e) => handleInputChange('twoFactorRecoveryCode', e.target.value)}
errorText={errors.twoFactorRecoveryCode}
/>
) : (
<TextBoxComponent
label={'2FA Code'}
placeholder={'Authentication code...'}
value={formState.twoFactorCode}
onChange={(e) => handleInputChange('twoFactorCode', e.target.value)}
errorText={errors.twoFactorCode}
/>
)}
</div>
)}
{/* Submit */}
<ButtonComponent
label={'Sign in'}
buttonHierarchy={'primary'}
onClick={handleLogin}
/>
</div>
</div>
</div>
)
}
export { LoginScreen }

View File

@ -0,0 +1,56 @@
import { FC, ReactNode, useCallback, useEffect } from 'react'
export interface OffcanvasProps {
children: ReactNode
isOpen?: boolean
onOpen?: () => void
onClose?: () => void
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
}
const Offcanvas: FC<OffcanvasProps> = (props) => {
const {
children,
isOpen = false,
onOpen,
onClose,
colspan = 6
} = props
const handleOnOpen = useCallback(() => {
onOpen?.()
}, [onOpen])
const handleOnClose = useCallback(() => {
onClose?.()
}, [onClose])
useEffect(() => {
if (isOpen) handleOnOpen()
else handleOnClose()
}, [isOpen, handleOnOpen, handleOnClose])
const leftSpan = 12 - colspan
return (
<div
className={[
'fixed inset-0 h-screen w-screen',
'bg-black/20 backdrop-blur-md',
'z-40 transition-opacity duration-300 ease-in-out',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
].join(' ')}
>
<div className={'grid grid-cols-12 h-full w-full'}>
{/* colonna di offset */}
<div className={`col-span-${leftSpan}`} />
{/* area principale */}
<div className={`col-span-${colspan} min-h-0`}>
{children}
</div>
</div>
</div>
)
}
export { Offcanvas }

View File

@ -0,0 +1,13 @@
// Define the types for the toast
interface AddToastProps {
message: string;
type: 'info' | 'success' | 'warning' | 'error';
duration?: number;
}
export const addToast = (message: string, type: 'info' | 'success' | 'warning' | 'error', duration?: number): void => {
const event = new CustomEvent<AddToastProps>('add-toast', {
detail: { message, type, duration },
})
window.dispatchEvent(event)
}

View File

@ -0,0 +1,70 @@
import { useState, useEffect, FC } from 'react'
import { v4 as uuidv4 } from 'uuid'
// Define types for a toast
interface Toast {
id: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
duration?: number;
}
const Toast: FC = () => {
const [toasts, setToasts] = useState<Toast[]>([])
useEffect(() => {
const handleAddToast = (event: CustomEvent<Toast>) => {
const { message, type, duration } = event.detail
// Add the new toast
const id = uuidv4()
setToasts((prev) => [...prev, { id, message, type, duration }])
// Auto-remove if a duration is specified
if (duration) {
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, duration)
}
}
// Listen for the custom event
window.addEventListener('add-toast', handleAddToast as EventListener)
return () => {
// Cleanup event listener on component unmount
window.removeEventListener('add-toast', handleAddToast as EventListener)
}
}, [])
// Remove toast manually
const handleClose = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}
return (
<div className={'fixed bottom-16 right-4 flex flex-col gap-2 z-50'}>
{toasts.map((toast) => (
<div
key={toast.id}
className={`relative flex items-center justify-between gap-3 px-4 py-3 pr-10 rounded-md shadow-md text-white
${toast.type === 'success' ? 'bg-green-500' : ''}
${toast.type === 'error' ? 'bg-red-500' : ''}
${toast.type === 'warning' ? 'bg-yellow-500' : ''}
${toast.type === 'info' ? 'bg-blue-500' : ''}
`}
>
<span>{toast.message}</span>
<button
onClick={() => handleClose(toast.id)}
className={'absolute top-2 right-2 text-xl font-bold text-white hover:opacity-75'}
>&times;</button>
</div>
))}
</div>
)
}
export {
Toast
}

View File

@ -0,0 +1,26 @@
import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { setShowUserOffcanvas } from '../redux/slices/identitySlice'
const UserButton = () => {
const dispatch = useAppDispatch()
//const { identity } = useAppSelector(state => state.identity)
const identity = {
username: 'JohnDoe',
isGlobalAdmin: true
}
if (!identity) return <></>
return <button
className={'bg-white text-blue-500 px-2 py-1 rounded'}
onClick={() => {
dispatch(setShowUserOffcanvas())
}}>
{`${identity.username} ${identity.isGlobalAdmin ? '(Global Admin)' : ''}`.trim()}
</button>
}
export {
UserButton
}

View File

@ -0,0 +1,48 @@
import { useAppDispatch, useAppSelector } from '../redux/hooks'
import { logout, setHideUserOffcanvas } from '../redux/slices/identitySlice'
import { ButtonComponent } from './editors'
import { FormContainer, FormContent, FormHeader } from './FormLayout'
import { Offcanvas } from './Offcanvas'
const UserOffcanvas = () => {
const dispatch = useAppDispatch()
const { identity, showUserOffcanvas } = useAppSelector(state => state.identity)
return (
<Offcanvas isOpen={showUserOffcanvas} colspan={4}>
<FormContainer>
<FormHeader>{identity?.username}</FormHeader>
<FormContent>
<div className={'flex flex-col justify-between h-full'}>
<div className={'flex flex-col items-center gap-3'}>
<ButtonComponent
label={'Edit User'}
route={`/user/${identity?.userId}`}
onClick={() => {
dispatch(setHideUserOffcanvas())
}}
/>
</div>
<div className={'flex flex-col items-center gap-3'}>
<ButtonComponent
label={'Back'}
buttonHierarchy={'secondary'}
onClick={() => dispatch(setHideUserOffcanvas())}
/>
<ButtonComponent
label={'Logout'}
buttonHierarchy={'error'}
onClick={() => dispatch(logout(false))}
/>
</div>
</div>
</FormContent>
</FormContainer>
</Offcanvas>
)
}
export {
UserOffcanvas
}

View File

@ -0,0 +1,79 @@
import React from 'react'
import { Link } from 'react-router-dom'
interface ConditionalButtonProps {
label: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
route?: string;
buttonHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
onClick?: () => void;
disabled?: boolean;
}
const ButtonComponent: React.FC<ConditionalButtonProps> = (props) => {
const {
label,
colspan,
route,
buttonHierarchy,
onClick,
disabled = false
} = props
const handleClick = (e?: React.MouseEvent) => {
if (disabled) {
e?.preventDefault()
return
}
onClick?.()
}
let buttonClass = ''
switch (buttonHierarchy) {
case 'primary':
buttonClass = 'bg-blue-500 text-white'
break
case 'secondary':
buttonClass = 'bg-gray-500 text-white'
break
case 'success':
buttonClass = 'bg-green-500 text-white'
break
case 'warning':
buttonClass = 'bg-yellow-500 text-white'
break
case 'error':
buttonClass = 'bg-red-500 text-white'
break
default:
buttonClass = 'bg-blue-500 text-white'
break
}
const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer'
return route
? (
<Link
to={route}
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} text-center ${disabledClass}`}
onClick={handleClick}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
style={disabled ? { pointerEvents: 'none' } : undefined}
>
{label}
</Link>
) : (
<button
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} ${disabledClass}`}
onClick={handleClick}
disabled={disabled}
>
{label}
</button>
)
}
export { ButtonComponent }

View File

@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
interface CheckBoxComponentProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
label: string;
value: boolean;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
errorText?: string;
disabled?: boolean;
}
const CheckBoxComponent: React.FC<CheckBoxComponentProps> = (props) => {
const {
colspan = 6,
label,
value,
onChange,
errorText,
disabled = false
} = props
const prevValue = useRef<boolean>(value)
useEffect(() => {
prevValue.current = value
}, [value])
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (prevValue.current === e.target.checked)
return
prevValue.current = e.target.checked
onChange?.(e)
}
return (
<div className={`mb-4 col-span-${colspan}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
<input
type={'checkbox'}
checked={value}
onChange={handleOnChange}
className={`mr-2 leading-tight ${errorText ? 'border-red-500' : ''}`}
disabled={disabled}
/>
{label}
</label>
{errorText && (
<p className={'text-red-500 text-xs italic mt-2'}>
{errorText}
</p>
)}
</div>
)
}
export {
CheckBoxComponent
}

View File

@ -0,0 +1,192 @@
import { ChangeEvent, FC, useState, useEffect, useRef } from 'react'
import { parseISO, formatISO, format, getDaysInMonth, addMonths, subMonths } from 'date-fns'
import { ButtonComponent } from './ButtonComponent'
import { TextBoxComponent } from './TextBoxComponent'
import { CircleX } from 'lucide-react'
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
interface DateTimePickerComponentProps {
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
label: string
value?: string
onChange?: (isoString?: string) => void
errorText?: string
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
colspan = 6,
label,
value,
onChange,
errorText,
placeholder,
readOnly = false,
disabled = false,
}) => {
const prevValueRef = useRef<string | undefined>(value)
const parsedValue = value ? parseISO(value) : undefined
const [showDropdown, setShowDropdown] = useState(false)
const [currentViewDate, setCurrentViewDate] = useState<Date>(parsedValue || new Date())
const [tempDate, setTempDate] = useState<Date>(parsedValue || new Date())
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (value !== prevValueRef.current) {
const newDate = parsedValue || new Date()
setCurrentViewDate(newDate)
setTempDate(newDate)
prevValueRef.current = value
}
}, [value, parsedValue])
const formatForDisplay = (date: Date) => format(date, DISPLAY_FORMAT)
const daysCount = getDaysInMonth(currentViewDate)
const daysArray = Array.from({ length: daysCount }, (_, i) => i + 1)
const handlePrevMonth = () => setCurrentViewDate((prev) => subMonths(prev, 1))
const handleNextMonth = () => setCurrentViewDate((prev) => addMonths(prev, 1))
const handleDayClick = (day: number) => {
const newDate = new Date(tempDate)
newDate.setFullYear(currentViewDate.getFullYear(), currentViewDate.getMonth(), day)
setTempDate(newDate)
}
const handleTimeChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const [hours, minutes] = e.target.value.split(':').map(Number)
const newDate = new Date(tempDate)
newDate.setHours(hours, minutes, 0, 0)
setTempDate(newDate)
}
const handleClear = () => {
if (prevValueRef.current !== undefined) {
onChange?.(undefined)
prevValueRef.current = undefined
}
setShowDropdown(false)
}
const handleConfirm = () => {
const isoString = formatISO(tempDate, { representation: 'complete' })
if (isoString !== prevValueRef.current) {
onChange?.(isoString)
prevValueRef.current = isoString
}
setShowDropdown(false)
}
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
return [
!!value && !readOnly && (
<button
key={'clear'}
type={'button'}
onClick={handleClear}
className={className}
tabIndex={-1}
aria-label={'Clear'}
>
<CircleX />
</button>
),
].filter(Boolean)
}
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowDropdown(false)
}
}
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showDropdown])
return (
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`} ref={dropdownRef}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
<div className={'relative'}>
<input
type={'text'}
value={value ? formatForDisplay(parsedValue!) : ''}
onFocus={() => !readOnly && !disabled && setShowDropdown(true)}
readOnly
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
disabled={disabled}
/>
{/* Fixed Action Buttons */}
<div className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}>
{actionButtons()}
</div>
</div>
{showDropdown && !readOnly && !disabled && (
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
<div className={'flex justify-between items-center px-3 py-2'}>
<button onClick={handlePrevMonth} type={'button'}>
&lt;
</button>
<span>{format(currentViewDate, 'MMMM yyyy')}</span>
<button onClick={handleNextMonth} type={'button'}>
&gt;
</button>
</div>
<div className={'grid grid-cols-7 gap-1 px-3 py-2'}>
{daysArray.map((day) => (
<div
key={day}
onClick={() => handleDayClick(day)}
className={`p-2 cursor-pointer text-center ${
tempDate.getDate() === day &&
tempDate.getMonth() === currentViewDate.getMonth() &&
tempDate.getFullYear() === currentViewDate.getFullYear()
? 'bg-blue-500 text-white rounded'
: 'hover:bg-gray-200 rounded'
}`}
>
{day}
</div>
))}
</div>
<div className={'px-3 py-2'}>
<TextBoxComponent
label={'Time'}
type={'time'}
value={format(tempDate, 'HH:mm')}
onChange={handleTimeChange}
placeholder={'HH:MM'}
readOnly={readOnly}
/>
</div>
<div className={'px-3 py-2 gap-2 flex justify-between'}>
<ButtonComponent label={'Clear'} buttonHierarchy={'secondary'} onClick={handleClear} />
<ButtonComponent label={'Confirm'} buttonHierarchy={'primary'} onClick={handleConfirm} />
</div>
</div>
)}
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
)
}
export { DateTimePickerComponent }

View File

@ -0,0 +1,106 @@
import React, { useState } from 'react'
interface DualListboxComponentProps {
label?: string;
availableItemsLabel?: string;
selectedItemsLabel?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
idFieldName?: string;
availableItems: string[];
selectedItems: string[];
onChange: (selectedItems: string[]) => void;
errorText?: string;
}
const DualListboxComponent: React.FC<DualListboxComponentProps> = (props) => {
const {
label,
availableItemsLabel = 'Available Items',
selectedItemsLabel = 'Selected Items',
colspan = 6,
availableItems,
selectedItems,
onChange,
errorText
} = props
const [available, setAvailable] = useState<string[]>(availableItems)
const [selected, setSelected] = useState<string[]>(selectedItems)
const moveToSelected = () => {
const movedItems = available.filter(item => selected.includes(item))
setAvailable(available.filter(item => !movedItems.includes(item)))
setSelected([...selected, ...movedItems])
onChange([...selected, ...movedItems])
}
const moveToAvailable = () => {
const movedItems = selected.filter(item => !available.includes(item))
setSelected(selected.filter(item => !movedItems.includes(item)))
setAvailable([...available, ...movedItems])
onChange(selected.filter(item => !movedItems.includes(item)))
}
return (
<div className={`col-span-${colspan}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
{label}
</label>
<div className={'flex justify-center items-center gap-4 w-full h-full'}>
<div className={'flex flex-col'}>
<h3>{availableItemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{available.map(item => (
<li
key={item}
className={'cursor-pointer hover:bg-gray-200'}
onClick={() => setAvailable(available.filter(i => i !== item))}
>
{item}
</li>
))}
</ul>
</div>
<div className={'flex flex-col gap-2'}>
<button
onClick={moveToSelected}
className={'border px-4 py-2 bg-blue-500 text-white hover:bg-blue-600'}
>
&gt;
</button>
<button
onClick={moveToAvailable}
className={'border px-4 py-2 bg-red-500 text-white hover:bg-red-600'}
>
&lt;
</button>
</div>
<div className={'flex flex-col'}>
<h3>{selectedItemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{selected.map(item => (
<li
key={item}
className={'cursor-pointer hover:bg-gray-200'}
onClick={() => setSelected(selected.filter(i => i !== item))}
>
{item}
</li>
))}
</ul>
</div>
</div>
{errorText && (
<p className={'text-red-500 text-xs italic mt-2'}>
{errorText}
</p>
)}
</div>
)
}
export {
DualListboxComponent
}

View File

@ -0,0 +1,147 @@
import React, { useRef, useState } from 'react'
import { ButtonComponent } from './ButtonComponent'
interface FileUploadComponentProps {
label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
multiple?: boolean
onChange?: (files: File[]) => void
disabled?: boolean
}
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
label = 'Select files',
colspan = 6,
multiple = true,
onChange,
disabled = false,
}) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [showPopup, setShowPopup] = useState(false)
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
const popupRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Focus popup when it opens
React.useEffect(() => {
if (showPopup && popupRef.current) {
popupRef.current.focus()
}
}, [showPopup])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : []
setSelectedFiles(files)
onChange?.(files)
}
const handleClear = () => {
setSelectedFiles([])
if (inputRef.current) inputRef.current.value = ''
onChange?.([])
}
const handleSelectFiles = () => {
if (!disabled) inputRef.current?.click()
}
return (
<div className={`grid grid-cols-4 gap-2 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
{/* File input (hidden) */}
<input
ref={inputRef}
type={'file'}
multiple={multiple}
style={{ display: 'none' }}
onChange={handleFileChange}
disabled={disabled}
/>
{/* Files counter with hover popup */}
<div
className={'col-span-1 flex items-center justify-center relative'}
onMouseEnter={e => {
setShowPopup(true)
if (!showPopup) {
setPopupPos({ x: e.clientX, y: e.clientY })
}
}}
onMouseLeave={e => {
// Only close if not moving into popup
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) {
setShowPopup(false)
}
}}
>
<span
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}
</span>
{showPopup && selectedFiles.length > 0 && (
<div
ref={popupRef}
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
tabIndex={0}
style={{
left: popupPos.x + 2,
top: popupPos.y + 2,
maxWidth: '400px',
whiteSpace: 'nowrap',
minWidth: '120px',
pointerEvents: 'auto',
outline: 'none',
}}
onBlur={e => {
// Only close if focus moves outside popup and counter
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) {
setShowPopup(false)
}
}}
onMouseLeave={e => {
// Only close if not moving back to counter
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
if (!parent) setShowPopup(false)
}}
onKeyDown={e => {
if (e.key === 'Escape') setShowPopup(false)
}}
onFocus={() => {}}
>
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
{selectedFiles.map((file, idx) => (
<li key={file.name + idx} className={'truncate'} title={file.name}>
{file.name}
</li>
))}
</ul>
</div>
)}
</div>
{/* Clear selection button */}
<ButtonComponent
label={'Clear selection'}
buttonHierarchy={'secondary'}
onClick={handleClear}
disabled={disabled || selectedFiles.length === 0}
colspan={1}
/>
{/* Select files button */}
<ButtonComponent
colspan={2}
label={label}
buttonHierarchy={'primary'}
onClick={handleSelectFiles}
disabled={disabled}
/>
</div>
)
}
export { FileUploadComponent }

View File

@ -0,0 +1,67 @@
import React, { useState } from 'react'
interface ListboxComponentProps {
label?: string;
itemsLabel?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
items: string[];
onChange: (items: string[]) => void;
errorText?: string;
}
const ListboxComponent: React.FC<ListboxComponentProps> = (props) => {
const {
label,
itemsLabel = 'Items',
colspan = 6,
items,
onChange,
errorText
} = props
const [selectedItems, setSelectedItems] = useState<string[]>([])
const toggleItemSelection = (item: string) => {
if (selectedItems.includes(item)) {
const updatedSelection = selectedItems.filter(i => i !== item)
setSelectedItems(updatedSelection)
onChange(updatedSelection)
} else {
const updatedSelection = [...selectedItems, item]
setSelectedItems(updatedSelection)
onChange(updatedSelection)
}
}
return (
<div className={`col-span-${colspan}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
{label}
</label>
<div className={'flex flex-col'}>
<h3>{itemsLabel}</h3>
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
{items.map(item => (
<li
key={item}
className={`cursor-pointer hover:bg-gray-200 ${selectedItems.includes(item) ? 'bg-gray-300' : ''}`}
onClick={() => toggleItemSelection(item)}
>
{item}
</li>
))}
</ul>
</div>
{errorText && (
<p className={'text-red-500 text-xs italic mt-2'}>
{errorText}
</p>
)}
</div>
)
}
export {
ListboxComponent
}

View File

@ -0,0 +1,80 @@
import React, { useEffect, useRef, useState } from 'react'
interface RadioOption {
value: string
label: string
}
interface RadioGroupComponentProps {
options: RadioOption[]
label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
value?: string
onChange?: (value: string) => void
errorText?: string
readOnly?: boolean
disabled?: boolean
}
const RadioGroupComponent: React.FC<RadioGroupComponentProps> = (props) => {
const {
options,
label,
colspan = 6,
value = '',
onChange,
errorText,
readOnly = false,
disabled = false
} = props
const prevValue = useRef<string>(value)
const [selectedValue, setSelectedValue] = useState<string>(value)
useEffect(() => {
prevValue.current = value
setSelectedValue(value)
}, [value])
const handleOptionChange = (val: string) => {
if (readOnly || disabled) return
if (prevValue.current === val) return
prevValue.current = val
setSelectedValue(val)
onChange?.(val)
}
return (
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
<div className={'flex flex-col'}>
{options.map(option => {
// Use default cursor (arrow) if disabled or readOnly, else pointer
const isInactive = disabled || readOnly
return (
<label
key={option.value}
className={`flex items-center mb-2 ${disabled ? 'opacity-50' : ''} ${isInactive ? 'cursor-default' : 'cursor-pointer'}`}
>
<input
type={'radio'}
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionChange(option.value)}
className={'mr-2'}
disabled={disabled}
readOnly={readOnly}
/>
{option.label}
</label>
)
})}
</div>
{errorText && (
<p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>
)}
</div>
)
}
export { RadioGroupComponent }

View File

@ -0,0 +1,116 @@
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
import { SelectBoxComponent } from '.'
import { PagedRequest } from '../../models/PagedRequest'
import { ApiRoutes, GetApiRoute } from '../../AppMap'
import { useAppDispatch } from '../../redux/hooks'
import { postData } from '../../axiosConfig'
import { disableLoader, enableLoader } from '../../redux/slices/loaderSlice'
import { PagedResponse } from '../../models/PagedResponse'
import { SearchResponseBase } from '../../models/SearchResponseBase'
import { deepEqual } from '../../functions'
interface RemoteSelectBoxProps<TRequest extends PagedRequest> {
apiRoute: ApiRoutes
additionalFilters?: TRequest
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
// Field used to compare with the value
idField?: string
// Fields to search against when filtering options
filterFields?: string[]
value?: string | number
onChange: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
}
const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSelectBoxProps<TRequest>) => {
const {
apiRoute,
additionalFilters,
label,
colspan = 12,
errorText,
idField = 'id',
filterFields = ['name'],
value = '',
onChange,
placeholder,
readOnly = false,
} = props
const dispatch = useAppDispatch()
const [options, setOptions] = useState<SearchResponseBase []>([])
const prevPagedRequest = useRef<TRequest | null>(null)
const handleFilterChange = useCallback((filters?: string, showLoader: boolean = false) => {
const pagedRequest = {
pageSize: 10,
filters,
...additionalFilters
} as TRequest
if (deepEqual(pagedRequest, prevPagedRequest.current))
return
prevPagedRequest.current = pagedRequest
if (!showLoader)
dispatch(disableLoader())
postData<TRequest, PagedResponse<SearchResponseBase>>(GetApiRoute(apiRoute).route, pagedRequest)
.then((response) => {
if (!response) return
setOptions(response.items)
})
.catch((error) => {
console.error('RemoteSelectBox fetch error:', error)
})
.finally(() => {
dispatch(enableLoader())
})
}, [apiRoute, additionalFilters, dispatch])
// Initialize options on mount
useEffect(() => {
handleFilterChange(undefined, true)
}, [handleFilterChange])
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange?.(e)
}
return <SelectBoxComponent
colspan={colspan}
label={label}
placeholder={placeholder}
options={options?.map(item => {
return {
value: item.id,
label: item.name
}
})}
idField={idField}
filterFields={filterFields}
onFilterChange={(text) => handleFilterChange(text, false)}
value={value}
onChange={handleOnChange}
errorText={errorText}
readOnly={readOnly}
/>
}
export {
RemoteSelectBoxComponent
}

View File

@ -0,0 +1,147 @@
import { Copy, Dices, Eye, EyeOff } from 'lucide-react'
import { ChangeEvent, FC, useRef, useState } from 'react'
import { TrngResponse } from '../../models/TrngResponse'
import { getData } from '../../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../../AppMap'
interface PasswordGeneratorProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
value?: string
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
enableCopy?: boolean
enableGenerate?: boolean
}
const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
const {
label,
colspan = 12,
errorText,
value = '',
onChange,
placeholder,
readOnly = false,
enableCopy = false,
enableGenerate = false
} = props
const prevValue = useRef<string>(value)
// Stato locale per alternare la visibilità della password
const [showPassword, setShowPassword] = useState(false)
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
if (prevValue.current === e.target.value)
return
prevValue.current = e.target.value
onChange?.(e)
}
const handleGenerateSecret = () => {
getData<TrngResponse>(`${GetApiRoute(ApiRoutes.generateSecret).route}`)
.then(response => {
if (!response) return
const fakeEvent = {
target: { value: response.secret }
} as ChangeEvent<HTMLInputElement>
handleOnChange(fakeEvent)
})
}
const handleCopy = async () => {
if (!value) return
await navigator.clipboard.writeText(value)
}
// Controlla se c'è contenuto per mostrare il pulsante di toggle
const hasContent = String(value).length > 0
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
return [
hasContent && (
<button
key={'eye'}
type={'button'}
onClick={() => setShowPassword(prev => !prev)}
className={className}
tabIndex={-1}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeOff /> : <Eye />}
</button>
),
enableGenerate && !readOnly && (
<button
key={'generate'}
type={'button'}
onClick={handleGenerateSecret}
className={className}
tabIndex={-1}
aria-label={'Generate secret'}
>
<Dices />
</button>
),
enableCopy && hasContent && (
<button
key={'copy'}
type={'button'}
onClick={handleCopy}
className={className}
tabIndex={-1}
aria-label={'Copy secret'}
>
<Copy />
</button>
),
].filter(Boolean)
}
return (
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
<div className={'relative'}>
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`
shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
${errorText ? 'border-red-500' : ''}
${readOnly ? 'bg-gray-100 text-gray-500' : ''}
`}
readOnly={readOnly}
/>
{/* Action buttons container */}
<div
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
>
{actionButtons()}
</div>
</div>
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
)
}
export { SecretComponent }

View File

@ -0,0 +1,220 @@
import { debounce } from 'lodash'
import { CircleX } from 'lucide-react'
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
export interface SelectBoxComponentOption {
value: string | number
label: string
}
interface SelectBoxComponentProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
options?: SelectBoxComponentOption[]
// Field used to compare with the value
idField?: string
// Fields to search against when filtering options
filterFields?: string[]
// Callback function called with a filter string, debounced
onFilterChange?: (filters: string) => void
value?: string | number
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
const {
label,
colspan = 12,
errorText,
options = [],
idField = 'id',
filterFields,
onFilterChange,
value = '',
onChange,
placeholder,
readOnly = false,
disabled = false,
} = props
// Local state to control dropdown visibility and current filter text
const [showDropdown, setShowDropdown] = useState(false)
const [filterValue, setFilterValue] = useState<string>('')
// Memoized debounced callback for filter changes.
const debounceOnFilterChange = useMemo(() => {
return onFilterChange ? debounce(onFilterChange, 500) : undefined
}, [onFilterChange])
// Refs to store previous values to detect changes
const initRef = useRef(false)
const prevValue = useRef(value)
const prevFilterValue = useRef(filterValue)
// Update the selected value and notify parent via onValueChange callback.
const handleValueChange = useCallback(
(newValue: string | number) => {
prevValue.current = newValue
// Simulate a ChangeEvent with the new value
onChange?.({ target: { value: newValue } } as ChangeEvent<HTMLInputElement>)
},
[onChange]
)
// Handle input changes for filtering options.
const handleFilterChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const newFilter = e.target.value
setFilterValue(newFilter)
// If filter value hasn't changed, exit early.
if (prevFilterValue.current === newFilter) {
return
}
// Build a filter query string based on the filterFields.
const query = filterFields
?.map((field) => `${field}.Contains("${newFilter}")`)
.filter(Boolean)
.join(' || ') ?? ''
// If debounced filter callback is provided, invoke it.
if (debounceOnFilterChange) {
debounceOnFilterChange(query)
}
// Clear the selected value when user types in filter.
if (showDropdown) {
handleValueChange('')
}
prevFilterValue.current = newFilter
},
[filterFields, debounceOnFilterChange, showDropdown, handleValueChange, disabled]
)
// Effect to sync external value with filter text and trigger filtering.
useEffect(() => {
// When value is cleared, also clear the filter.
if (value === '') {
if (prevValue.current !== value) {
// Simulate clearing the filter input.
handleFilterChange({ target: { value: '' } } as ChangeEvent<HTMLInputElement>)
}
return
}
// Find the option that matches the current value.
const selectedOption = options.find((option) => option.value === value)
if (selectedOption) {
if (filterValue !== selectedOption.label) {
setFilterValue(selectedOption.label) // Only update if the filterValue is different.
}
return
}
// If the value does not correspond to an existing option,
// trigger filtering using the idField.
if (debounceOnFilterChange && !initRef.current) {
debounceOnFilterChange(`${idField} == "${value}"`)
initRef.current = true
}
}, [value, filterValue, options, idField, debounceOnFilterChange, handleFilterChange])
// Handle click on an option from the dropdown.
const handleOptionClick = (optionValue: string | number) => {
if (disabled) return
// Update the selected value.
handleValueChange(optionValue)
// Update the input to display the selected option's label.
const selectedOption = options.find((option) => option.value === optionValue)
setFilterValue(selectedOption?.label ?? '')
// Close the dropdown.
setShowDropdown(false)
}
const actionButtons = () => {
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
if (disabled) return null
return [
!!filterValue && !readOnly && (
<button
key={'clear'}
type={'button'}
onClick={() => {
setFilterValue('')
handleValueChange('')
if (debounceOnFilterChange) debounceOnFilterChange('')
}}
className={className}
tabIndex={-1}
aria-label={'Clear'}
>
<CircleX />
</button>
),
].filter(Boolean)
}
return (
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
{/* Label for the select input */}
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
<div className={'relative'}>
<input
type={'text'}
value={filterValue}
onChange={handleFilterChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
${errorText ? 'border-red-500' : ''}
${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}
${readOnly && !disabled ? 'text-gray-500 cursor-default' : ''}`}
disabled={readOnly || disabled}
// Open dropdown when input is focused.
onFocus={() => { if (!disabled) setShowDropdown(true) }}
// Delay closing dropdown to allow click events on options.
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
/>
{/* Action Buttons */}
<div
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
>
{actionButtons()}
</div>
</div>
{showDropdown && !disabled && (
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
{options.length > 0 ? (
options.map((option) => (
<div
key={option.value}
className={'px-4 py-2 cursor-pointer hover:bg-gray-200'}
onMouseDown={() => handleOptionClick(option.value)}
>
{option.label}
</div>
))
) : (
<div className={'px-4 py-2 text-gray-500'}>No options found</div>
)}
</div>
)}
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
)
}
export { SelectBoxComponent }

View File

@ -0,0 +1,120 @@
import { Eye, EyeOff } from 'lucide-react'
import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'
interface TextBoxComponentProps {
label: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
errorText?: string
value?: string | number
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
type?: 'text' | 'password' | 'textarea' | 'number' | 'email' | 'time'
placeholder?: string
readOnly?: boolean
disabled?: boolean
}
const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
const {
label,
colspan = 12,
errorText,
value = '',
onChange,
type = 'text',
placeholder,
readOnly = false,
disabled = false,
} = props
const prevValue = useRef<string | number>(value)
useEffect(() => {
prevValue.current = value
}, [value])
// Stato locale per gestire la visibilità della password
const [showPassword, setShowPassword] = useState(false)
const handleOnChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (prevValue.current === e.target.value)
return
prevValue.current = e.target.value
onChange?.(e)
}
// Se il type è "textarea", comportamento invariato
if (type === 'textarea') {
return (
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
<textarea
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
readOnly={readOnly}
disabled={disabled}
/>
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
)
}
// Verifica se il valore non è vuoto (per tipo "password" useremo questa condizione)
const hasContent = String(value).length > 0
return (
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
{type === 'password' ? (
// Wrapper che contiene input e bottone show/hide, ma bottone solo se c'è contenuto
<div className={'relative'}>
<input
type={showPassword ? 'text' : 'password'}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
readOnly={readOnly}
disabled={disabled}
/>
{hasContent && (
<button
type={'button'}
onClick={() => setShowPassword(prev => !prev)}
className={'absolute inset-y-0 right-0 pr-3 flex items-center text-gray-600'}
tabIndex={-1} // Non interferisce con l'ordine di tabulazione
>
{showPassword ? <Eye /> : <EyeOff />}
</button>
)}
</div>
) : (
// Input normale per tutti gli altri tipi (text, number, email, time)
<input
type={type}
value={value}
onChange={handleOnChange}
placeholder={placeholder}
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
errorText ? 'border-red-500' : ''
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
readOnly={readOnly}
disabled={disabled}
/>
)}
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
</div>
)
}
export { TextBoxComponent }

View File

@ -0,0 +1,85 @@
import React, { useState, ReactNode } from 'react'
interface TreeNode {
id: string;
name: string;
content?: ReactNode | ((ids: string[]) => ReactNode); // Custom content at each node
children?: TreeNode[]; // Nested nodes for infinite depth
defaultCollapsed?: boolean; // Default collapse/expand state
}
interface TreeViewProps {
data: TreeNode[];
label?: string;
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
}
const TreeViewComponent: React.FC<TreeViewProps> = (props) => {
const { data, label, colspan = 6 } = props
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>(
() =>
data.reduce((acc, node) => {
const initCollapse = (node: TreeNode, collapsed: Record<string, boolean>): Record<string, boolean> => {
collapsed[node.id] = node.defaultCollapsed ?? true
if (node.children) {
node.children.forEach((child) => initCollapse(child, collapsed))
}
return collapsed
}
return initCollapse(node, acc)
}, {})
)
const toggleCollapse = (id: string) => {
setCollapsedItems((prev) => ({
...prev,
[id]: !prev[id],
}))
}
const renderTree = (nodes: TreeNode[], parentIds: string[] = []) => {
return nodes.map((node) => {
const nodeIds = [...parentIds, node.id]
return (
<div key={node.id} className={'mb-2 ml-4'}>
{/* Node Header */}
<div className={'flex items-center justify-between'}>
<div className={'flex items-center'}>
<span className={'font-bold'}>{node.name}</span>
</div>
<button
onClick={() => toggleCollapse(node.id)}
className={'text-sm text-blue-500 focus:outline-none'}
>
{collapsedItems[node.id] ? 'Expand' : 'Collapse'}
</button>
</div>
{/* Node Content */}
{!collapsedItems[node.id] && (
<>
{node.content && (
<div className={'ml-4 mt-2'}>
{typeof node.content === 'function' ? node.content(nodeIds) : node.content}
</div>
)}
{node.children && <div className={'ml-4'}>{renderTree(node.children, nodeIds)}</div>}
</>
)}
</div>
)
})
}
return (
<div className={`col-span-${colspan}`}>
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
<div className={'border p-4 rounded-md'}>{renderTree(data)}</div>
</div>
)
}
export { TreeViewComponent }

View File

@ -0,0 +1,28 @@
import { ButtonComponent } from './ButtonComponent'
import { CheckBoxComponent } from './CheckBoxComponent'
import { TextBoxComponent } from './TextBoxComponent'
import { DateTimePickerComponent } from './DateTimePickerComponent'
import { DualListboxComponent } from './DualListboxComponent'
import { TreeViewComponent } from './TreeViewComponent'
import { ListboxComponent } from './ListBoxComponent'
import { SecretComponent } from './SecretComponent'
import { SelectBoxComponent } from './SelectBoxComponent'
import { RemoteSelectBoxComponent } from './RemoteSelectBoxComponent'
import { RadioGroupComponent } from './RadioGroupComponent'
import { FileUploadComponent } from './FileUploadComponent'
export {
ButtonComponent,
CheckBoxComponent,
DateTimePickerComponent,
TextBoxComponent,
DualListboxComponent,
TreeViewComponent,
ListboxComponent,
RemoteSelectBoxComponent,
SecretComponent,
SelectBoxComponent,
RadioGroupComponent,
FileUploadComponent,
}

View File

@ -0,0 +1,41 @@
import { PatchAccountRequest } from '../models/letsEncryptServer/account/requests/PatchAccountRequest'
import { PatchOperation } from '../models/PatchOperation'
import { CacheAccountHostname } from './CacheAccountHostname'
export interface CacheAccount {
accountId: string
isDisabled: boolean
description: string
contacts: string[]
challengeType?: string
hostnames?: CacheAccountHostname[]
isEditMode: boolean
isStaging: boolean
}
const toPatchAccountRequest = (account: CacheAccount): PatchAccountRequest => {
return {
description: { op: PatchOperation.None, value: account.description },
isDisabled: { op: PatchOperation.None, value: account.isDisabled },
contacts: account.contacts.map((contact, index) => ({
index: index,
op: PatchOperation.None,
value: contact
})),
hostnames: account.hostnames?.map((hostname, index) => ({
hostname: {
index: index,
op: PatchOperation.None,
value: hostname.hostname
},
isDisabled: {
index: index,
op: PatchOperation.None,
value: hostname.isDisabled
}
}))
}
}
export { toPatchAccountRequest }

View File

@ -0,0 +1,6 @@
export interface CacheAccountHostname {
hostname: string
expires: Date
isUpcomingExpire: boolean
isDisabled: boolean
}

View File

@ -0,0 +1,4 @@
export enum ChallengeType {
http01 = 'http-01',
dns01 = 'dns-01'
}

View File

@ -0,0 +1,154 @@
import { FC, useEffect, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { ButtonComponent, CheckBoxComponent, RadioGroupComponent, SelectBoxComponent } from '../components/editors'
import { CacheAccount } from '../entities/CacheAccount'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { deleteData, getData } from '../axiosConfig'
import { ApiRoutes, GetApiRoute } from '../AppMap'
import { enumToArr, formatISODateString } from '../functions'
import { ChallengeType } from '../entities/ChallengeType'
import { Radio } from 'lucide-react'
const Home: FC = () => {
const [rawd, setRawd] = useState<GetAccountResponse[]>([])
const [editingAccount, setEditingAccount] = useState<GetAccountResponse | null>(
null
)
useEffect(() => {
console.log(GetApiRoute(ApiRoutes.ACCOUNTS).route)
getData<GetAccountResponse []>(GetApiRoute(ApiRoutes.ACCOUNTS).route).then((response) => {
if (!response) return
setRawd(response)
})
}, [])
const handleAccountUpdate = (updatedAccount: CacheAccount) => {
// setAccounts(
// accounts.map((account) =>
// account.accountId === updatedAccount.accountId
// ? updatedAccount
// : account
// )
// )
}
const deleteAccount = (accountId: string) => {
deleteData<void>(
GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
.route.replace('{accountId}', accountId)
).then((result) => {
if (!result) return
setRawd(rawd.filter((account) => account.accountId !== accountId))
})
}
return <FormContainer>
<FormHeader>Home</FormHeader>
<FormContent>
<div className={'grid grid-cols-12 gap-4 w-full'}>
{rawd.length === 0 ?
<div className={'text-center text-gray-600 col-span-12'}>
No accounts registered.
</div> :
rawd.map((acc) => (
<div key={acc.accountId} className={'bg-white shadow-lg rounded-lg p-6 mb-6 col-span-12'}>
<div className={'grid grid-cols-12 gap-4 w-full'}>
<h2 className={'col-span-8'}>
Account: {acc.accountId}
</h2>
<ButtonComponent
colspan={2}
onClick={() => deleteAccount(acc.accountId)}
label={'Delete'}
buttonHierarchy={'error'}
/>
<ButtonComponent
colspan={2}
onClick={() => setEditingAccount(acc)}
label={'Edit'}
/>
<h3 className={'col-span-12'}>
Description: {acc.description}
</h3>
<CheckBoxComponent
colspan={12}
value={acc.isDisabled}
label={'Disabled'}
disabled={true}
/>
<h3 className={'col-span-12'}>Contacts:</h3>
<ul className={'col-span-12'}>
{acc.contacts.map((contact) => (
<li key={contact} className={'pb-2'}>
{contact}
</li>
))}
</ul>
<RadioGroupComponent
colspan={12}
label={'LetsEncrypt Environment'}
options={[
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' }
]}
value={acc.challengeType ? 'staging' : 'production'}
disabled={true}
/>
<h3 className={'col-span-12'}>Hostnames:</h3>
<ul className={'col-span-12'}>
{acc.hostnames?.map((hostname) => (
<li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-3'}>{hostname.hostname}</span>
<span className={'col-span-3'}>{formatISODateString(hostname.expires)}</span>
<span className={'col-span-3'}>
<span className={`${hostname.isUpcomingExpire
? 'bg-yellow-200 text-yellow-800'
: 'bg-green-200 text-green-800'}`}>{hostname.isUpcomingExpire
? 'Upcoming'
: 'Not Upcoming'}
</span>
</span>
<CheckBoxComponent
colspan={3}
value={hostname.isDisabled}
label={'Disabled'}
disabled={true}
/>
</li>
))}
</ul>
<SelectBoxComponent
label={'Environment'}
options={[
{ value: 'production', label: 'Production' },
{ value: 'staging', label: 'Staging' }
]}
value={acc.isStaging ? 'staging' : 'production'}
disabled={true}
/>
</div>
</div>
))}
</div>
</FormContent>
<FormFooter />
</FormContainer>
}
export { Home }

View File

@ -0,0 +1,227 @@
import { FC } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { postData } from '../axiosConfig'
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
import { ApiRoutes, GetApiRoute } from '../AppMap'
import { ButtonComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
import { ChallengeType } from '../entities/ChallengeType'
import z, { array, boolean, object, Schema, string } from 'zod'
import { useFormState } from '../hooks/useFormState'
import { enumToArr } from '../functions'
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
import { addToast } from '../components/Toast/addToast'
import { useNavigate } from 'react-router-dom'
interface RegisterFormProps {
description: string
contact: string
contacts: string[]
hostname: string
hostnames: string[]
challengeType: ChallengeType
isStaging: boolean
}
const RegisterFormProto = (): RegisterFormProps => ({
description: '',
contact: '',
contacts: [],
hostname: '',
hostnames: [],
challengeType: ChallengeType.http01,
isStaging: true
})
const RegisterFormSchema: Schema<RegisterFormProps> = object({
description: string(),
contact: string(),
contacts: array(string()),
hostname: string(),
hostnames: array(string()),
challengeType: z.enum(ChallengeType),
isStaging: boolean()
})
interface RegisterProps {
}
const Register: FC<RegisterProps> = () => {
const navigate = useNavigate()
const {
formState,
errors,
formIsValid,
handleInputChange
} = useFormState<RegisterFormProps>({
initialState: RegisterFormProto(),
validationSchema: RegisterFormSchema,
})
const handleSubmit = () => {
if (!formIsValid) return
const requestData: PostAccountRequest = {
description: formState.description,
contacts: formState.contacts,
hostnames: formState.hostnames,
challengeType: formState.challengeType,
isStaging: formState.isStaging,
}
const request = PostAccountRequestSchema.safeParse(requestData)
if (!request.success) {
request.error.issues.forEach(error => {
addToast(error.message, 'error')
})
return
}
postData<PostAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data)
.then(response => {
if (!response) return
navigate('/')
})
}
return <FormContainer>
<FormHeader>Register</FormHeader>
<FormContent>
<div className={'grid grid-cols-12 gap-4 w-full'}>
<TextBoxComponent
colspan={12}
label={'Account Description'}
value={formState.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder={'Account Description'}
errorText={errors.description}
/>
<h3 className={'col-span-12'}>Contacts:</h3>
<ul className={'col-span-12'}>
{formState.contacts.map((contact) => (
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
<span className={'col-span-10'}>{contact}</span>
<ButtonComponent
colspan={2}
label={'TRASH'}
onClick={() => {
const updatedContacts = formState.contacts.filter(c => c !== contact)
handleInputChange('contacts', updatedContacts)
}}
/>
</li>
))}
</ul>
<TextBoxComponent
colspan={10}
label={'New Contact'}
value={formState.contact}
onChange={(e) => {
if (formState.contacts.includes(e.target.value))
return
handleInputChange('contact', e.target.value)
}}
placeholder={'Add contact'}
type={'text'}
errorText={errors.contact}
/>
<ButtonComponent
colspan={2}
label={'PLUS'}
onClick={() => {
handleInputChange('contacts', [...formState.contacts, formState.contact])
handleInputChange('contact', '')
}}
disabled={formState.contact.trim() === ''}
/>
<div className={'col-span-12'}>
<SelectBoxComponent
label={'Challenge Type'}
options={enumToArr(ChallengeType).map(ct => ({ value: ct.value, label: ct.displayValue }))}
value={formState.challengeType}
placeholder={'Select Challenge Type'}
onChange={(e) => handleInputChange('challengeType', e.target.value)}
errorText={errors.challengeType}
/>
</div>
<h3 className={'col-span-12'}>Hostnames:</h3>
<ul className={'col-span-12'}>
{formState.hostnames.map((hostname) => (
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full'}>
<span className={'col-span-10'}>{hostname}</span>
<ButtonComponent
colspan={2}
label={'TRASH'}
onClick={() => {
const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
handleInputChange('hostnames', updatedHostnames)
}}
/>
</li>
))}
</ul>
<TextBoxComponent
colspan={10}
label={'New Hostname'}
value={formState.hostname}
onChange={(e) => {
if (formState.hostnames.includes(e.target.value))
return
handleInputChange('hostname', e.target.value)
}}
placeholder={'Add hostname'}
type={'text'}
errorText={errors.hostname}
/>
<ButtonComponent
colspan={2}
label={'PLUS'}
onClick={() => {
handleInputChange('hostnames', [...formState.hostnames, formState.hostname])
handleInputChange('hostname', '')
}}
disabled={formState.hostname.trim() === ''}
/>
<RadioGroupComponent
colspan={12}
label={'LetsEncrypt Environment'}
options={[
{ value: 'staging', label: 'Staging' },
{ value: 'production', label: 'Production' }
]}
value={formState.isStaging ? 'staging' : 'production'}
onChange={(value) => {
handleInputChange('isStaging', value === 'staging')
}}
errorText={errors.isStaging}
/>
</div>
</FormContent>
<FormFooter rightChildren={
<ButtonComponent
label={'Create Account'}
onClick={handleSubmit}
/>
} />
</FormContainer>
}
export { Register }

View File

@ -0,0 +1,42 @@
import { FC, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
import { ButtonComponent, FileUploadComponent } from '../components/editors'
const Utilities: FC = () => {
const [files, setFiles] = useState<File[]>([])
return <FormContainer>
<FormHeader>Utilities</FormHeader>
<FormContent>
<div className={'grid grid-cols-12 gap-4 w-full'}>
<ButtonComponent
colspan={3}
label={'Test agent'}
buttonHierarchy={'primary'}
onClick={() => {}}
/>
<ButtonComponent
colspan={3}
label={'Download cache files'}
buttonHierarchy={'secondary'}
onClick={() => {}}
/>
<FileUploadComponent
colspan={6}
label={'Upload cache files'}
multiple={true}
onChange={setFiles}
/>
<span className={'col-span-12'}></span>
</div>
</FormContent>
<FormFooter />
</FormContainer>
}
export { Utilities }

View File

@ -0,0 +1,9 @@
import {
parseAclEntry,
parseAclEntries
} from './parseAclEntry'
export {
parseAclEntry,
parseAclEntries,
}

View File

@ -0,0 +1,49 @@
import { ScopeEntityType } from '../../models/identity/ScopeEntityType'
import { ScopePermission } from '../../models/identity/ScopePermissions'
export interface AclEntry {
entityType: ScopeEntityType,
entityId: string,
scope: ScopePermission
}
const parseAclEntry = (aclEntry: string): AclEntry | null => {
if (typeof aclEntry !== 'string')
return null
const parts = aclEntry.split(':')
if (parts.length !== 3)
return null
const entityTypeMap: Record<string, ScopeEntityType> = {
O: ScopeEntityType.Organization,
A: ScopeEntityType.Application,
S: ScopeEntityType.Secret,
I: ScopeEntityType.Identity,
K: ScopeEntityType.ApiKey
}
const entityType = entityTypeMap[parts[0]] ?? 'Unknown'
const entityId = parts[1]
const scopePermission = parseInt(parts[2], 16) as ScopePermission
const aclEntryResult: AclEntry = {
entityType,
entityId,
scope: scopePermission
}
console.log('Parsed ACL Entry:', aclEntryResult)
return aclEntryResult
}
const parseAclEntries = (aclEntries: string[]): AclEntry [] => {
return aclEntries
.map(parseAclEntry)
.filter((entry): entry is AclEntry => entry !== null)
}
export { parseAclEntry, parseAclEntries }

View File

@ -0,0 +1,19 @@
import { parseISO, isValid, format } from 'date-fns'
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
const formatISODateString = (isoString: string): string => {
if (!isoString)
return ''
const parsed = parseISO(isoString)
if (!isValid(parsed))
return 'ISO Date String is invalid'
return format(parsed, DISPLAY_FORMAT)
}
export {
formatISODateString
}

View File

@ -0,0 +1,7 @@
import { isValidISODateString } from './isValidDateString'
import { formatISODateString } from './formatISODateString'
export {
isValidISODateString,
formatISODateString
}

View File

@ -0,0 +1,15 @@
import { parseISO, isValid } from 'date-fns'
const isValidISODateString = (dateString: string): boolean => {
if (!dateString) return false
const parsed = parseISO(dateString)
return isValid(parsed)
}
export {
isValidISODateString
}

View File

@ -0,0 +1,35 @@
const deepCopy = <T>(obj: T, seen = new WeakMap<object, unknown>()): T =>{
if (
obj === null ||
typeof obj !== 'object' ||
obj instanceof Date ||
obj instanceof RegExp ||
obj instanceof Function
) {
return obj
}
if (seen.has(obj as object)) {
return seen.get(obj as object) as T
}
if (Array.isArray(obj)) {
const arrCopy: unknown[] = []
seen.set(obj, arrCopy)
for (const item of obj) {
arrCopy.push(deepCopy(item, seen))
}
return arrCopy as T
}
const objCopy = {} as { [K in keyof T]: T[K] }
seen.set(obj, objCopy)
for (const key of Object.keys(obj) as Array<keyof T>) {
objCopy[key] = deepCopy(obj[key], seen)
}
return objCopy
}
export { deepCopy }

View File

@ -0,0 +1,316 @@
import { PatchOperation } from '../../models/PatchOperation'
import { deepCopy } from './deepCopy.js'
import { deepEqual } from './deepEqual.js'
type IdLike = string | number | null | undefined
export type Identifiable<I extends string | number = string | number> = {
id?: I | null
}
type OperationBag<K extends string = string> = {
operations?: Partial<Record<K | 'collectionItemOperation', PatchOperation>>
}
type EnsureId<T extends Identifiable> = { id?: T['id'] }
type PlainObject = Record<string, unknown>
type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag
/** Policy non-generica: chiavi sempre stringhe */
export type ArrayPolicy = {
/** Nome del campo “radice” che implica re-parenting (es. 'organizationId') */
rootKey?: string
/** Nomi degli array figli da trattare in caso di re-parenting (es. ['applicationRoles']) */
childArrayKeys?: string[]
/** Se true, in re-parenting i figli vengono azzerati (default TRUE) */
dropChildrenOnRootChange?: boolean
/** Nome del campo ruolo (default 'role') */
roleFieldKey?: string
/** Se true, quando role diventa null si rimuove lintero item (default TRUE) */
deleteItemWhenRoleRemoved?: boolean
}
export type DeepDeltaOptions<T> = {
/** Policy per i campi array del payload (mappati per nome chiave) */
arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>>
}
export type Delta<T> =
Partial<{
[K in keyof T]:
T[K] extends (infer U)[]
? DeltaArrayItem<(U & Identifiable)>[]
: T[K] extends object
? Delta<T[K] & OperationBag<Extract<keyof T, string>>>
: T[K]
}> & OperationBag<Extract<keyof T, string>>
/** Safe index per evitare TS2536 quando si indicizza su chiavi dinamiche */
const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined =>{
const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined
return arrays?.[key]
}
const isPlainObject = (value: unknown): value is PlainObject =>
typeof value === 'object' && value !== null && !Array.isArray(value)
export const deepDelta = <T extends Record<string, unknown>>(
formState: T,
backupState: T,
options?: DeepDeltaOptions<T>
): Delta<T> => {
const delta = {} as Delta<T>
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
const ops = (bag.operations ??= {} as Record<string, PatchOperation>)
ops[key] = op
}
const calculateDelta = (
form: PlainObject,
backup: PlainObject,
parentDelta: PlainObject & OperationBag
) => {
const keys = Array.from(new Set([...Object.keys(form), ...Object.keys(backup)]))
for (const rawKey of keys) {
const key = rawKey as keyof T & string
const formValue = form[key]
const backupValue = backup[key]
// --- ARRAY ---
if (Array.isArray(formValue) && Array.isArray(backupValue)) {
const policy = getArrayPolicy(options, key)
const arrayDelta = calculateArrayDelta(
formValue as Identifiable[],
backupValue as Identifiable[],
policy
)
if (arrayDelta.length > 0) {
;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key]
}
continue
}
// --- OBJECT ---
if (isPlainObject(formValue) && isPlainObject(backupValue)) {
if (!deepEqual(formValue, backupValue)) {
const nestedDelta: PlainObject & OperationBag = {}
calculateDelta(
formValue as PlainObject,
(backupValue as PlainObject) ?? {},
nestedDelta
)
if (Object.keys(nestedDelta).length > 0) {
;(parentDelta as Delta<T>)[key] = nestedDelta as unknown as Delta<T>[typeof key]
}
}
continue
}
// --- PRIMITIVE / TYPE CHANGED ---
if (!deepEqual(formValue, backupValue)) {
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
setOp(parentDelta, key, formValue === null ? PatchOperation.RemoveField : PatchOperation.SetField)
}
}
}
const calculateArrayDelta = <U extends Identifiable>(
formArray: U[],
backupArray: U[],
policy?: ArrayPolicy
): DeltaArrayItem<U>[] => {
const arrayDelta: DeltaArrayItem<U>[] = []
const getId = (item?: U): IdLike => (item ? item.id ?? null : null)
const childrenKeys = policy?.childArrayKeys ?? []
const dropChildren = policy?.dropChildrenOnRootChange ?? true
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
const rootKey = policy?.rootKey
const sameRoot = (f: U, b: U): boolean => {
if (!rootKey) return true
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
}
// Mappe id → item per lookup veloce
const formMap = new Map<string | number, U>()
const backupMap = new Map<string | number, U>()
for (const item of formArray) {
const id = getId(item)
if (id !== null && id !== undefined) formMap.set(id as string | number, item)
}
for (const item of backupArray) {
const id = getId(item)
if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
}
// 1) Gestione elementi presenti nel form
for (const formItem of formArray) {
const fid = getId(formItem)
// 1.a) Nuovo item (senza id)
if (fid === null || fid === undefined) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
// ⬇️ NON droppiamo i figli su "add": li normalizziamo come AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
}
}
arrayDelta.push(addItem)
continue
}
// 1.b) Ha id ma non esiste nel backup ⇒ AddToCollection
const backupItem = backupMap.get(fid as string | number)
if (!backupItem) {
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.id = fid as U['id']
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
// ⬇️ Anche qui: manteniamo i figli, marcandoli come AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
})
;(addItem as PlainObject)[ck] = normalized
}
}
arrayDelta.push(addItem)
continue
}
// 1.c) Re-parenting: root cambiata
if (!sameRoot(formItem, backupItem)) {
// REMOVE vecchio
const removeItem = {} as DeltaArrayItem<U>
removeItem.id = fid as U['id']
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
// ADD nuovo
const addItem = {} as DeltaArrayItem<U>
Object.assign(addItem, formItem as Partial<U>)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
if (dropChildren) {
// ⬇️ SOLO qui, in caso di re-parenting e se richiesto, azzera i figli
for (const ck of childrenKeys) {
if (ck in (addItem as PlainObject)) {
;(addItem as PlainObject)[ck] = []
}
}
} else {
// Mantieni i figli marcandoli come AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
const normalized = (v as Identifiable[]).map(child => {
const c = {} as DeltaArrayItem<Identifiable>
Object.assign(c, child as Partial<Identifiable>)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
}); (addItem as PlainObject)[ck] = normalized
}
}
}
arrayDelta.push(addItem)
continue
}
// 1.d) Ruolo → null ⇒ rimozione item (se abilitato)
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
if (deleteOnRoleNull) {
const formRole = (formItem as PlainObject)[roleKey]
const backupRole = (backupItem as PlainObject)[roleKey]
const roleBecameNull = backupRole !== null && formRole === null
if (roleBecameNull) {
const removeItem = {} as DeltaArrayItem<U>
removeItem.id = fid as U['id']
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
continue
}
}
// 1.e) Diff puntuale su campi
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
itemDeltaBase.id = fid as U['id']
calculateDelta(
formItem as PlainObject,
backupItem as PlainObject,
itemDeltaBase
)
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id')
if (hasMeaningfulChanges) {
arrayDelta.push(itemDeltaBase as DeltaArrayItem<U>)
}
}
// 2) Elementi rimossi
for (const backupItem of backupArray) {
const bid = getId(backupItem)
if (bid === null || bid === undefined) continue
if (!formMap.has(bid as string | number)) {
const removeItem = {} as DeltaArrayItem<U>
removeItem.id = bid as U['id']
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
}
}
return arrayDelta
}
calculateDelta(
deepCopy(formState) as PlainObject,
deepCopy(backupState) as PlainObject,
delta as PlainObject & OperationBag
)
return delta
}
export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => {
if (!isPlainObject(delta)) return false
if ('operations' in delta && isPlainObject(delta.operations)) return true
for (const key in delta) {
const v = (delta as PlainObject)[key]
if (isPlainObject(v) && deltaHasOperations(v as Delta<{}>)) return true
if (Array.isArray(v)) {
for (const item of v) {
if (isPlainObject(item) && deltaHasOperations(item as Delta<{}>)) return true
}
}
}
return false
}

View File

@ -0,0 +1,70 @@
import { deepCopy } from './deepCopy.js'
const deepEqual = (objA: unknown, objB: unknown): boolean => {
const copyA = deepCopy(objA)
const copyB = deepCopy(objB)
if (copyA === copyB) {
return true
}
if (Array.isArray(copyA) && Array.isArray(copyB)) {
return deepEqualArrays(copyA, copyB)
}
if (
typeof copyA !== 'object' ||
typeof copyB !== 'object' ||
copyA === null ||
copyB === null
) {
return false
}
const keysA = Object.keys(copyA)
const keysB = Object.keys(copyB)
if (keysA.length !== keysB.length) {
return false
}
for (const key of keysA) {
if (!keysB.includes(key)) {
return false
}
const valA = (copyA as Record<string, unknown>)[key]
const valB = (copyB as Record<string, unknown>)[key]
if (!deepEqual(valA, valB)) {
return false
}
}
return true
}
const deepEqualArrays = (arrA: unknown[], arrB: unknown[]): boolean => {
const copyA = deepCopy(arrA)
const copyB = deepCopy(arrB)
if (copyA.length !== copyB.length) {
return false
}
if (copyA.length === 0 && copyB.length === 0) {
return true
}
for (const itemA of copyA) {
const matchIndex = copyB.findIndex((itemB) => deepEqual(itemA, itemB))
if (matchIndex === -1) {
return false
}
copyB.splice(matchIndex, 1)
}
return true
}
export { deepEqual, deepEqualArrays }

View File

@ -0,0 +1,35 @@
const deepMerge = <T>(target: T, source: T, seen = new WeakMap<object, unknown>()): T => {
if (target === null || typeof target !== 'object') return source
if (source === null || typeof source !== 'object') return target
if (seen.has(target as object)) {
return seen.get(target as object) as T
}
if (Array.isArray(target) && Array.isArray(source)) {
const mergedArray: unknown[] = []
seen.set(target, mergedArray)
const maxLength = Math.max(target.length, source.length)
for (let i = 0; i < maxLength; i++) {
mergedArray[i] = deepMerge(target[i], source[i], seen)
}
return mergedArray as T
}
const mergedObject = { ...target } as Record<string | number | symbol, unknown>
seen.set(target as object, mergedObject)
for (const key of Object.keys(source) as Array<keyof T>) {
const sourceValue = source[key]
if (sourceValue !== undefined) {
const targetValue = target[key]
mergedObject[key] = deepMerge(targetValue, sourceValue, seen)
}
}
return mergedObject as T
}
export { deepMerge }

View File

@ -0,0 +1,18 @@
import { deepCopy } from './deepCopy'
import {
deepDelta,
deltaHasOperations
} from './deepDelta'
import { deepEqualArrays, deepEqual } from './deepEqual'
import { deepMerge } from './deepMerge'
export {
deepCopy,
deepDelta,
deltaHasOperations,
deepEqualArrays,
deepEqual,
deepMerge
}

View File

@ -0,0 +1,39 @@
export interface EnumArrayProps {
value: number | string;
displayValue: string;
}
const enumToArr = (enumType: unknown): EnumArrayProps[] => {
if (!enumType) return []
const enumEntries = Object.entries(enumType)
const addedValues = new Set()
const result: EnumArrayProps[] = []
enumEntries.forEach(([key, value]) => {
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
if (!isNaN(Number(key))) return
// Skip already added values for string enums with reverse mapping
if (addedValues.has(value)) return
addedValues.add(value)
result.push({
value: value,
displayValue: key,
})
})
// Sort the result array by displayValue (key)
result.sort((a, b) => {
if (typeof a.displayValue === 'string' && typeof b.displayValue === 'string') {
return a.displayValue.localeCompare(b.displayValue)
}
return 0
})
return result
}
export { enumToArr }

View File

@ -0,0 +1,17 @@
const enumToObj = (enumType: unknown) => {
if (!enumType) return {}
const enumEntries = Object.entries(enumType)
const result: { [key: string]: number | string } = {}
enumEntries.forEach(([key, value]) => {
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
if (!isNaN(Number(key))) return
result[key] = value
})
return result
}
export { enumToObj }

View File

@ -0,0 +1,23 @@
import { EnumArrayProps, enumToArr } from './enumToArr'
const getEnumValue = <T>(enumType: T, enumValue: number) : EnumArrayProps | undefined => {
return enumToArr(enumType).find((item) => item.value == enumValue)
}
const enumToString = <T>(enumType: T, enumValue?: number | null): string => {
if (enumValue === undefined || enumValue === null) {
return ''
}
const enumVal = getEnumValue(enumType, enumValue)
if (!enumVal)
return ''
return `${enumVal.value} - ${enumVal.displayValue}`
}
export {
enumToString
}

View File

@ -0,0 +1,10 @@
import { enumToArr } from './enumToArr'
const flagsToString = <T>(enumType: T, flags: number): string => {
return enumToArr(enumType)
.filter(opt => (flags & opt.value as number) === opt.value && opt.value !== 0)
.map(opt => opt.displayValue)
.join(', ') || 'None'
}
export { flagsToString }

View File

@ -0,0 +1,5 @@
const hasAnyFlag = <T extends number>(current: T = 0 as T, flags: T): boolean => {
return (current & flags) !== 0
}
export { hasAnyFlag }

View File

@ -0,0 +1,5 @@
const hasFlag = <T extends number>(current: T = 0 as T, flag: T): boolean => {
return (current & flag) === flag
}
export { hasFlag }

View File

@ -0,0 +1,37 @@
import {
enumToArr
} from './enumToArr'
import {
enumToObj
} from './enumToObj'
import {
enumToString
} from './enumToString'
import {
flagsToString
} from './flagsToString'
import {
toggleFlag
} from './toggleFlag'
import {
hasFlag
} from './hasFlag'
import {
hasAnyFlag
} from './hasAnyFlag'
export {
enumToArr,
enumToObj,
enumToString,
flagsToString,
toggleFlag,
hasFlag,
hasAnyFlag
}

View File

@ -0,0 +1,5 @@
const toggleFlag = <T extends number>(current: T = 0 as T, flag: T): T => {
return ((current & flag) === flag ? (current & ~flag) : (current | flag)) as T
}
export { toggleFlag }

View File

@ -0,0 +1,55 @@
import {
isValidISODateString,
formatISODateString
} from './date'
import {
deepCopy,
deepDelta,
deltaHasOperations,
deepEqual,
deepMerge,
} from './deep'
import {
enumToArr,
enumToObj,
enumToString,
flagsToString,
toggleFlag,
hasFlag,
hasAnyFlag
} from './enum'
import {
isGuid
} from './isGuid'
import {
parseAclEntry,
parseAclEntries
} from './acl'
export {
isValidISODateString,
formatISODateString,
deepCopy,
deepDelta,
deltaHasOperations,
deepEqual,
deepMerge,
enumToArr,
enumToObj,
enumToString,
flagsToString,
toggleFlag,
hasFlag,
hasAnyFlag,
isGuid,
parseAclEntry,
parseAclEntries
}

Some files were not shown because too many files have changed in this diff Show More