TCP 서버 모드 추가
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
# React + 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
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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>NEW TESTMASTER</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" style="width: 100%"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7457
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "testmasterui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-checkbox": "^1.1.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-menubar": "^1.1.4",
|
||||
"@radix-ui/react-scroll-area": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"@xyflow/react": "^12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.469.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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 |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {APIProvider, useAPI} from './providers/APIProvider';
|
||||
import {LoadTestProvider} from './providers/LoadTestProvider';
|
||||
import APITester from './components/APITester';
|
||||
import LoadTestManager from './components/LoadTestManager';
|
||||
import {monacoConfig} from '@/config/monaco.js';
|
||||
import LoginDialog from '@/components/LoginDialog.jsx';
|
||||
|
||||
window.MonacoEnvironment = monacoConfig;
|
||||
|
||||
const App = ({ options = { type: 'api', contextPath: '/' } }) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
return (
|
||||
<APIProvider contextPath={options.contextPath}>
|
||||
<AppContent options={options} showLogin={showLogin} setShowLogin={setShowLogin} />
|
||||
</APIProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const AppContent = ({ options, showLogin, setShowLogin }) => {
|
||||
const { isAuthenticated, setIsAuthenticated } = useAPI();
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/account', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
setShowLogin(true);
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setIsAuthenticated(!!data.authorities);
|
||||
if (!data.authorities) {
|
||||
setShowLogin(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
setShowLogin(true);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [setIsAuthenticated]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <LoginDialog open={showLogin} onOpenChange={setShowLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadTestProvider contextPath={options.contextPath}>
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
{options.type === 'api' ? (
|
||||
<APITester />
|
||||
) : (
|
||||
<LoadTestManager />
|
||||
)}
|
||||
</div>
|
||||
</LoadTestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// const App = ({ options = { type: 'api', contextPath: '/' } }) => {
|
||||
//
|
||||
// const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// const [showLogin, setShowLogin] = useState(false);
|
||||
//
|
||||
// useEffect(() => {
|
||||
// // Check authentication status
|
||||
// const checkAuth = async () => {
|
||||
// try {
|
||||
// const response = await fetch('/api/accouont', {
|
||||
// headers: {
|
||||
// 'X-Requested-With': 'XMLHttpRequest'
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// if (response.status !== 200) {
|
||||
// setShowLogin(true);
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// const data = await response.json();
|
||||
// console.log(data);
|
||||
// setIsAuthenticated(data.authorities);
|
||||
// if (!data.authorities) {
|
||||
// setShowLogin(true);
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Auth check failed:', error);
|
||||
// setShowLogin(true);
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// checkAuth();
|
||||
// }, []);
|
||||
//
|
||||
// if (!isAuthenticated) {
|
||||
// return <LoginDialog open={showLogin} onOpenChange={setShowLogin} />;
|
||||
// }
|
||||
//
|
||||
// return (
|
||||
// <APIProvider contextPath={options.contextPath}>
|
||||
// <LoadTestProvider contextPath={options.contextPath}>
|
||||
// <div className="flex flex-col h-screen bg-background">
|
||||
// {options.type === 'api' ? (
|
||||
// <APITester />
|
||||
// ) : (
|
||||
// <LoadTestManager />
|
||||
// )}
|
||||
// </div>
|
||||
// </LoadTestProvider>
|
||||
// </APIProvider>
|
||||
// );
|
||||
// };
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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 |
@@ -0,0 +1,183 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSeparator, MenubarTrigger} from '@/components/ui/menubar';
|
||||
import MainContentArea from '@/components/MainContentArea.jsx';
|
||||
import {useAPI} from '@/providers/APIProvider.jsx';
|
||||
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
|
||||
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.jsx';
|
||||
|
||||
const menuConfig = [
|
||||
{
|
||||
id: 'response',
|
||||
label: '응답관리',
|
||||
items: [
|
||||
{id: 'response-list', label: '응답목록', action: 'response-list'},
|
||||
{id: 'response-add', label: '응답추가', action: 'response-add'}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'request',
|
||||
label: '요청관리',
|
||||
items: [
|
||||
{id: 'collection-add', label: '컬렉션 추가', action: 'collection-add'},
|
||||
{id: 'request-add', label: '요청 추가', action: 'request-add'},
|
||||
{id: 'scenario-add', label: '시나리오 추가', action: 'scenario-add'}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'queue',
|
||||
label: '큐관리',
|
||||
items: [
|
||||
{id: 'queue-status', label: '큐상태', action: 'queue-status'},
|
||||
{id: 'queue-settings', label: '큐설정', action: 'queue-settings'}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'loadtest',
|
||||
label: '부하테스트',
|
||||
items: [
|
||||
{id: 'load-test-new', label: '새 테스트', action: 'load-test-new'},
|
||||
{id: 'load-test-history', label: '테스트 이력', action: 'load-test-history'},
|
||||
{id: 'load-test-reports', label: '결과 리포트', action: 'load-test-reports'}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: '시스템관리',
|
||||
items: [
|
||||
{id: 'system-settings', label: '시스템설정', action: 'system-settings'},
|
||||
{id: 'user-management', label: '사용자관리', action: 'user-management'},
|
||||
{type: 'separator'},
|
||||
{id: 'system-logs', label: '시스템로그', action: 'system-logs'}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'account',
|
||||
label: '계정',
|
||||
items: [
|
||||
{id: 'change-password', label: '비밀번호변경', action: 'change-password'},
|
||||
{type: 'separator'},
|
||||
{id: 'logout', label: '로그아웃', action: 'logout'}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const MenuItem = ({item, onMenuClick}) => {
|
||||
if (item.type === 'separator') {
|
||||
return <MenubarSeparator/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenubarItem onClick={() => onMenuClick(item.action)}>
|
||||
{item.label}
|
||||
</MenubarItem>
|
||||
);
|
||||
};
|
||||
|
||||
const APITester = () => {
|
||||
// API 모드와 시나리오 모드 상태를 관리
|
||||
const [isApiMode, setIsApiMode] = useState(true);
|
||||
const [showAddCollection, setShowAddCollection] = useState(false);
|
||||
const [showAddApi, setShowAddApi] = useState(false);
|
||||
const { handleLogout, createCollection, createAPIRequest } = useAPI();
|
||||
|
||||
// 모드 전환 핸들러
|
||||
const toggleMode = () => {
|
||||
setIsApiMode((prevMode) => !prevMode);
|
||||
};
|
||||
|
||||
const handleAddCollection = async (name) => {
|
||||
try {
|
||||
await createCollection(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error('컬렉션 생성에 실패했습니다');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddApi = async (collectionId, apiData) => {
|
||||
try {
|
||||
await createAPIRequest(collectionId, apiData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to create API:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleMenuClick = async (action) => {
|
||||
// Handle menu item clicks
|
||||
console.log('Menu action:', action);
|
||||
switch (action) {
|
||||
case 'request-add':
|
||||
setShowAddApi(true);
|
||||
break;
|
||||
case 'collection-add':
|
||||
setShowAddCollection(true);
|
||||
break;
|
||||
case 'logout':
|
||||
const success = await handleLogout();
|
||||
if (success) {
|
||||
window.location.href = "/actionLogout.do";
|
||||
}
|
||||
break;
|
||||
case 'change-password':
|
||||
// Handle password change
|
||||
break;
|
||||
// Add other cases as needed
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen">
|
||||
{/* Fixed Menubar */}
|
||||
<div className="fixed top-0 left-0 right-0 z-50 bg-background border-b">
|
||||
<div className="flex items-center h-14">
|
||||
<div className="px-4 py-2 border-r h-full flex items-center">
|
||||
<img
|
||||
src="/img/logo.png"
|
||||
alt="testmaster"
|
||||
className="h-8 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<Menubar className="border-none flex-1">
|
||||
{menuConfig.map((menu) => (
|
||||
<MenubarMenu key={menu.id}>
|
||||
<MenubarTrigger className="font-bold">
|
||||
{menu.label}
|
||||
</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
{menu.items.map((item, index) => (
|
||||
<MenuItem
|
||||
key={item.id || `separator-${index}`}
|
||||
item={item}
|
||||
onMenuClick={handleMenuClick}
|
||||
/>
|
||||
))}
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
))}
|
||||
</Menubar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content area with top padding for menubar */}
|
||||
<div className="flex-1 pt-14">
|
||||
<MainContentArea isApiMode={isApiMode} toggleMode={toggleMode}/>
|
||||
</div>
|
||||
<AddCollectionDialog
|
||||
open={showAddCollection}
|
||||
onOpenChange={setShowAddCollection}
|
||||
onSubmit={handleAddCollection}
|
||||
/>
|
||||
{/* Add these at the bottom of your return statement */}
|
||||
<AddAPIDialog
|
||||
open={showAddApi}
|
||||
onOpenChange={setShowAddApi}
|
||||
onSubmit={handleAddApi}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default APITester;
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Alert, AlertDescription} from '@/components/ui/alert';
|
||||
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import LoadTester from './LoadTester';
|
||||
import LoadTestResult from './LoadTestResult';
|
||||
|
||||
const LoadTestManager = ({contextPath = '/'}) => {
|
||||
const [tests, setTests] = useState([]);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [currentTest, setCurrentTest] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toast, setToast] = useState({show: false, message: ''});
|
||||
const [newTestDialog, setNewTestDialog] = useState(false);
|
||||
const [deleteDialog, setDeleteDialog] = useState({show: false, id: null});
|
||||
const [newTestName, setNewTestName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadScenarioList();
|
||||
loadTestList();
|
||||
}, []);
|
||||
|
||||
const showToast = (message) => {
|
||||
setToast({show: true, message});
|
||||
setTimeout(() => setToast({show: false, message: ''}), 3000);
|
||||
};
|
||||
|
||||
const loadScenarioList = async () => {
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
setScenarios(data);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTestList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
setTests(data);
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTest = async () => {
|
||||
if (!newTestName) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({name: newTestName})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create test');
|
||||
}
|
||||
|
||||
await loadTestList();
|
||||
setNewTestDialog(false);
|
||||
setNewTestName('');
|
||||
showToast('Test created successfully');
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTest = async () => {
|
||||
if (!deleteDialog.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({id: deleteDialog.id})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete test');
|
||||
}
|
||||
|
||||
await loadTestList();
|
||||
setDeleteDialog({show: false, id: null});
|
||||
showToast('Test deleted successfully');
|
||||
|
||||
if (currentTest?.id === deleteDialog.id) {
|
||||
setCurrentTest(null);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTest = async () => {
|
||||
if (!currentTest) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(currentTest)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save test');
|
||||
}
|
||||
|
||||
showToast('Test saved successfully');
|
||||
} catch (error) {
|
||||
showToast(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 border-r p-4 bg-background">
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setNewTestDialog(true)}
|
||||
>
|
||||
New Load Test
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{tests.map(test => (
|
||||
<div
|
||||
key={test.id}
|
||||
className={`flex justify-between items-center p-2 rounded hover:bg-accent ${
|
||||
currentTest?.id === test.id ? 'bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="flex-1 text-left"
|
||||
onClick={() => setCurrentTest(test)}
|
||||
>
|
||||
{test.name}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteDialog({show: true, id: test.id})}
|
||||
>
|
||||
<i className="fa fa-trash-can"/>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-4">
|
||||
{currentTest ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">{currentTest.name}</h2>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleSaveTest}>Save</Button>
|
||||
<Button variant="outline" onClick={() => setCurrentTest(null)}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<LoadTester
|
||||
contextPath={contextPath}
|
||||
scenarios={scenarios}
|
||||
test={currentTest}
|
||||
onTestUpdate={setCurrentTest}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4">
|
||||
<LoadTestResult
|
||||
contextPath={contextPath}
|
||||
testId={currentTest.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Select a test to begin
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Test Dialog */}
|
||||
<Dialog open={newTestDialog} onOpenChange={setNewTestDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Load Test</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={newTestName}
|
||||
onChange={(e) => setNewTestName(e.target.value)}
|
||||
placeholder="Test name"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNewTestDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTest}>Create</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialog.show}
|
||||
onOpenChange={(open) => setDeleteDialog({...deleteDialog, show: open})}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Test</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p>Are you sure you want to delete this test?</p>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialog({show: false, id: null})}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteTest}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Toast */}
|
||||
{toast.show && (
|
||||
<Alert className="fixed bottom-4 right-4 max-w-md">
|
||||
<AlertDescription>{toast.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="fixed inset-0 bg-background/80 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-t-2 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadTestManager;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Client } from '@stomp/stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
import { Card, CardHeader, CardContent } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMonaco } from '@monaco-editor/react';
|
||||
|
||||
const LoadTestResult = ({ contextPath = '/', testId }) => {
|
||||
const [results, setResults] = useState([]);
|
||||
const [selectedResult, setSelectedResult] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('body');
|
||||
const stompClientRef = useRef(null);
|
||||
const monaco = useMonaco();
|
||||
const editorRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Monaco editor
|
||||
if (monaco && !editorRef.current) {
|
||||
editorRef.current = monaco.editor.create(document.getElementById('test-output'), {
|
||||
value: '',
|
||||
language: 'json',
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
theme: 'vs-dark'
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispose();
|
||||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [monaco]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!testId) return;
|
||||
|
||||
// Initialize WebSocket connection
|
||||
const socket = new SockJS(`${contextPath}load-test-result`);
|
||||
const client = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
onConnect: () => {
|
||||
client.subscribe(`/topic/${testId}`, (message) => {
|
||||
const result = JSON.parse(message.body);
|
||||
setResults(prev => [...prev, result]);
|
||||
});
|
||||
},
|
||||
onStompError: (error) => {
|
||||
console.error('STOMP error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
stompClientRef.current = client;
|
||||
client.activate();
|
||||
|
||||
return () => {
|
||||
if (client.connected) {
|
||||
client.deactivate();
|
||||
}
|
||||
};
|
||||
}, [testId, contextPath]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setResults([]);
|
||||
setSelectedResult(null);
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setValue('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const formatResponse = useCallback((response) => {
|
||||
if (!response) return '';
|
||||
|
||||
if (response.headers) {
|
||||
const sortedHeaders = Object.entries(response.headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
return sortedHeaders;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(response.body || '{}'), null, 2);
|
||||
} catch {
|
||||
return response.body || '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResultSelect = useCallback((result) => {
|
||||
setSelectedResult(result);
|
||||
if (editorRef.current) {
|
||||
const content = result.type === 'TCP' ?
|
||||
`[TCP Response]\n${result.response.body}` :
|
||||
`[HTTP Response]\nStatus: ${result.response.status}\n\n[Headers]\n${formatResponse(result.response.headers)}\n\n[Body]\n${result.response.body}`;
|
||||
editorRef.current.setValue(content);
|
||||
}
|
||||
}, [formatResponse]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-4">
|
||||
{/* Results List */}
|
||||
<Card className="w-64">
|
||||
<CardHeader>Test Results</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[600px]">
|
||||
<div className="space-y-2">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => handleResultSelect(result)}
|
||||
className={cn(
|
||||
"p-2 rounded cursor-pointer hover:bg-accent",
|
||||
selectedResult === result && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="font-medium truncate">{result.request.name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Status: {result.response.status || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result Details */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>Result Details</div>
|
||||
<Button variant="outline" onClick={reset}>
|
||||
Clear
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="body" className="h-[600px]">
|
||||
<div id="test-output" className="h-full w-full border rounded-md"></div>
|
||||
</TabsContent>
|
||||
<TabsContent value="console" className="h-[600px]">
|
||||
<div id="test-console" className="h-full w-full border rounded-md bg-zinc-950 text-zinc-50 p-4 font-mono overflow-auto">
|
||||
{results.map((result, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap mb-2">
|
||||
{`[${new Date(result.timestamp).toISOString()}] ${result.request.name}\n`}
|
||||
{`Status: ${result.response.status}\n`}
|
||||
{`Time: ${result.response.time}ms\n`}
|
||||
{`Size: ${result.response.size} bytes\n`}
|
||||
{'='.repeat(50)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadTestResult;
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const LoadTester = ({
|
||||
contextPath = '/',
|
||||
scenarios = [],
|
||||
test,
|
||||
onTestUpdate
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Handler for starting the load test
|
||||
const handleStartTest = useCallback(async () => {
|
||||
if (!test?.scenarioId) {
|
||||
alert('Please select a scenario');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}startLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to start test');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting test:', error);
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [test, contextPath]);
|
||||
|
||||
// Handler for stopping the load test
|
||||
const handleStopTest = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}stopLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to stop test');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error stopping test:', error);
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [test, contextPath]);
|
||||
|
||||
// Handler for input changes
|
||||
const handleChange = useCallback((field, value) => {
|
||||
onTestUpdate({
|
||||
...test,
|
||||
[field]: value
|
||||
});
|
||||
}, [test, onTestUpdate]);
|
||||
|
||||
if (!test) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>Load Test Configuration</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
{/* Scenario Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Scenario</Label>
|
||||
<Select
|
||||
value={test.scenarioId || ''}
|
||||
onValueChange={(value) => handleChange('scenarioId', value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select scenario" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Select scenario</SelectItem>
|
||||
{scenarios.map((scenario) => (
|
||||
<SelectItem key={scenario.id} value={scenario.id}>
|
||||
{scenario.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Thread Count */}
|
||||
<div className="space-y-2">
|
||||
<Label>Thread Count</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={test.threadCount || 1}
|
||||
onChange={(e) => handleChange('threadCount', parseInt(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Loop Count */}
|
||||
<div className="space-y-2">
|
||||
<Label>Loop Count</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={test.loopCount || 0}
|
||||
onChange={(e) => handleChange('loopCount', parseInt(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ramp Up Time */}
|
||||
<div className="space-y-2">
|
||||
<Label>Ramp-up Time (ms)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={test.rampUpTime || 0}
|
||||
onChange={(e) => handleChange('rampUpTime', parseInt(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="space-y-2">
|
||||
<Label>Duration (minutes)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={test.duration || 0}
|
||||
onChange={(e) => handleChange('duration', parseInt(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
onClick={handleStartTest}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Start Test
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleStopTest}
|
||||
disabled={isLoading}
|
||||
variant="secondary"
|
||||
>
|
||||
Stop Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadTester;
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
const LoginDialog = ({ open, onOpenChange }) => {
|
||||
const [formData, setFormData] = useState({ id: '', password: '' });
|
||||
const [message, setMessage] = useState({ type: '', content: '' });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const getCsrfToken = () => {
|
||||
const name = 'XSRF-TOKEN=';
|
||||
const decodedCookie = decodeURIComponent(document.cookie);
|
||||
const cookieArray = decodedCookie.split(';');
|
||||
|
||||
for (let cookie of cookieArray) {
|
||||
cookie = cookie.trim();
|
||||
if (cookie.indexOf(name) === 0) {
|
||||
return cookie.substring(name.length, cookie.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const showMessage = (type, content) => {
|
||||
setMessage({ type, content });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setMessage({ type: '', content: '' });
|
||||
|
||||
try {
|
||||
const response = await fetch('/actionLogin.do', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getCsrfToken()
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
id: formData.id,
|
||||
password: formData.password
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
switch(data.status) {
|
||||
case 'SUCCESS':
|
||||
window.location.href = data.redirectUrl;
|
||||
break;
|
||||
|
||||
case 'DORMANT':
|
||||
case 'PASSWORD_EXPIRED':
|
||||
showMessage('warning', data.message);
|
||||
if (data.redirectUrl) {
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirectUrl;
|
||||
}, 2000);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
showMessage('error', '로그인 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('error', '로그인 처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>로그인</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
{message.content && (
|
||||
<Alert variant={message.type === 'error' ? 'destructive' : 'default'}>
|
||||
<AlertDescription>{message.content}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="id" className="text-right">
|
||||
아이디
|
||||
</Label>
|
||||
<Input
|
||||
id="id"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, id: e.target.value }))}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
비밀번호
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? '로그인 중...' : '로그인'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginDialog;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import APINavigation from "@/components/api-tester/APINavigation";
|
||||
import APITabs from "@/components/api-tester/APITabs";
|
||||
import { useAPI } from '@/providers/APIProvider.jsx';
|
||||
import ScenarioNavigation from '@/components/api-tester/ScenarioNavigation.jsx';
|
||||
import ScenarioEditor from '@/components/api-tester/ScenarioEditor.jsx';
|
||||
|
||||
const MainContentArea = ({ isApiMode, toggleMode }) => {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{isApiMode ? <APIMode isApiMode={isApiMode} onToggleMode={toggleMode}/> : <ScenarioMode isApiMode={isApiMode} onToggleMode={toggleMode}/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// API 모드
|
||||
const APIMode = ({ isApiMode, onToggleMode }) => {
|
||||
const [apiRequests, setApiRequests] = useState([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const {
|
||||
collections,
|
||||
loadCollections,
|
||||
loading,
|
||||
error
|
||||
} = useAPI();
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [loadCollections]);
|
||||
|
||||
const handleTabChange = (value) => {
|
||||
setCurrentIndex(Number(value));
|
||||
};
|
||||
|
||||
const handleCloseTab = (index) => {
|
||||
const updatedRequests = [...apiRequests];
|
||||
updatedRequests.splice(index, 1);
|
||||
setApiRequests(updatedRequests);
|
||||
|
||||
if (currentIndex >= updatedRequests.length) {
|
||||
setCurrentIndex(Math.max(0, updatedRequests.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApi = (collectionId, apiId) => {
|
||||
const collection = collections.find((col) => col.id === collectionId);
|
||||
if (!collection) {
|
||||
console.error(`Collection with id "${collectionId}" not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const api = collection.apis?.find((api) => api.id === apiId);
|
||||
if (!api) {
|
||||
console.error(`API with id "${apiId}" not found in collection "${collectionId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newApiRequest = {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
type: api.type || 'HTTP',
|
||||
method: api.method || 'GET',
|
||||
path: api.path || '',
|
||||
headers: api.headers || [],
|
||||
queryParams: api.queryParams || [],
|
||||
contentType: api.contentType || 'application/json',
|
||||
requestBody: api.requestBody || '',
|
||||
preRequestScript: api.preRequestScript || '',
|
||||
postRequestScript: api.postRequestScript || '',
|
||||
collectionId: collectionId,
|
||||
collectionName: collection.name,
|
||||
server: api.server || null,
|
||||
description: api.description || ''
|
||||
};
|
||||
|
||||
const existingIndex = apiRequests.findIndex(
|
||||
(request) => request.id === apiId && request.collectionId === collectionId
|
||||
);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
setApiRequests((prevRequests) => [...prevRequests, newApiRequest]);
|
||||
setCurrentIndex(apiRequests.length);
|
||||
} else {
|
||||
const updatedRequests = [...apiRequests];
|
||||
updatedRequests[existingIndex] = newApiRequest;
|
||||
setApiRequests(updatedRequests);
|
||||
setCurrentIndex(existingIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFallback = () => (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
<p>No APIs opened. Select an API to test from the sidebar.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="api-mode flex h-full w-full">
|
||||
<div className="navigation w-[250px] flex-shrink-0 border-r bg-background">
|
||||
{/* APINavigation 고정 */}
|
||||
<APINavigation
|
||||
contextPath="/"
|
||||
collections={collections} // Pass collections to APINavigation
|
||||
side="left"
|
||||
onApiSelect={handleOpenApi}
|
||||
onNewCollection={() => {
|
||||
// Handle new collection creation
|
||||
}}
|
||||
isApiMode={isApiMode}
|
||||
onToggleMode={onToggleMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 h-full w-full min-w-0 overflow-hidden">
|
||||
{apiRequests.length === 0 ? (
|
||||
renderFallback() // Render fallback when no tabs exist
|
||||
) : (
|
||||
<APITabs
|
||||
apiRequests={apiRequests}
|
||||
currentIndex={currentIndex}
|
||||
onTabChange={handleTabChange}
|
||||
onCloseTab={handleCloseTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 시나리오 모드
|
||||
const ScenarioMode = ({ isApiMode, onToggleMode }) => (
|
||||
<div className="scenario-mode flex flex-1">
|
||||
{/* 왼쪽 고정: ScenarioNavigation */}
|
||||
<div className="navigation-sidebar min-w-[200px] border-r bg-gray-100">
|
||||
<ScenarioNavigation
|
||||
contextPath="/"
|
||||
side="left"
|
||||
onScenarioSelect={(scenarioId) => {
|
||||
// Handle scenario selection
|
||||
}}
|
||||
isApiMode={isApiMode}
|
||||
onToggleMode={onToggleMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 중앙: ScenarioEditor */}
|
||||
<div className="editor flex-1">
|
||||
<ScenarioEditor/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 고정: APINavigation */}
|
||||
<div className="navigation-sidebar min-w-[200px] border-l bg-gray-100">
|
||||
<APINavigation contextPath="/"
|
||||
side="right"
|
||||
onApiSelect={(collectionId, apiId) => {
|
||||
// Handle API selection
|
||||
}}
|
||||
onNewCollection={() => {
|
||||
// Handle new collection creation
|
||||
}}
|
||||
isApiMode={isApiMode}
|
||||
onToggleMode={onToggleMode}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
export default MainContentArea;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useAPI } from '@/providers/APIProvider';
|
||||
|
||||
const ServerManager = ({ onServerChange = () => {} }) => {
|
||||
const { servers, loading, error, loadServers } = useAPI();
|
||||
const initialized = useRef(false);
|
||||
const [selectedServer, setSelectedServer] = useState(null);
|
||||
|
||||
// Initial load of servers
|
||||
useEffect(() => {
|
||||
const initializeServers = async () => {
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
await loadServers();
|
||||
console.log(servers);
|
||||
}
|
||||
};
|
||||
|
||||
initializeServers();
|
||||
}, [loadServers]);
|
||||
|
||||
// Handle server selection when servers are loaded
|
||||
useEffect(() => {
|
||||
if (servers?.length > 0 && !loading && !selectedServer) {
|
||||
const defaultServer = servers[0];
|
||||
setSelectedServer(defaultServer);
|
||||
onServerChange(defaultServer.id);
|
||||
}
|
||||
}, [servers, loading, onServerChange, selectedServer]);
|
||||
|
||||
const handleServerChange = (serverId) => {
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
if (server) {
|
||||
setSelectedServer(server);
|
||||
onServerChange(serverId);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>서버 목록 불러오는 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive" className="w-full max-w-md">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!servers?.length) {
|
||||
return (
|
||||
<Alert className="w-full max-w-md">
|
||||
<AlertDescription>사용 가능한 서버가 없습니다.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={selectedServer?.id}
|
||||
onValueChange={handleServerChange}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="서버 선택">
|
||||
{selectedServer?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{servers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.id}
|
||||
value={server.id}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerManager;
|
||||
@@ -0,0 +1,307 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {ChevronRight} from 'lucide-react';
|
||||
import {Sidebar, SidebarContent, SidebarGroup, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem, SidebarProvider} from '@/components/ui/sidebar';
|
||||
import {ScrollArea} from '@/components/ui/scroll-area';
|
||||
import {Alert, AlertDescription} from '@/components/ui/alert';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Tooltip, TooltipContent, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {useAPI} from '@/providers/APIProvider';
|
||||
import DraggableApiItem from '@/components/api-tester/DraggableApiItem';
|
||||
import DeleteCollectionDialog from './DeleteCollectionDialog';
|
||||
import RenameCollectionDialog from './RenameCollectionDialog';
|
||||
import CollectionDropdownMenu from './CollectionDropdownMenu';
|
||||
import DeleteAPIDialog from '@/components/api-tester/DeleteApiDialog.jsx';
|
||||
import RenameAPIDialog from './RenameAPIDialog';
|
||||
import AddAPIDialog from '@/components/api-tester/AddAPIDialog';
|
||||
|
||||
const APINavigation = ({
|
||||
contextPath = '/',
|
||||
onApiSelect,
|
||||
side,
|
||||
isApiMode,
|
||||
onToggleMode
|
||||
}) => {
|
||||
// State
|
||||
const [selectedCollection, setSelectedCollection] = useState(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||
const [collectionToDelete, setCollectionToDelete] = useState(null);
|
||||
const [collectionToRename, setCollectionToRename] = useState(null);
|
||||
const [showAddApi, setShowAddApi] = useState(false);
|
||||
const [apiToDelete, setApiToDelete] = useState(null);
|
||||
const [apiToRename, setApiToRename] = useState(null);
|
||||
const [renameApiDialogOpen, setRenameApiDialogOpen] = useState(false);
|
||||
|
||||
|
||||
// API Context
|
||||
const {
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
loadCollections,
|
||||
deleteCollection,
|
||||
updateCollection,
|
||||
deleteAPIRequest,
|
||||
createAPIRequest,
|
||||
updateAPIRequest
|
||||
} = useAPI();
|
||||
|
||||
// Load collections on mount
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [contextPath]);
|
||||
|
||||
// Handlers
|
||||
const handleCollectionClick = (collectionId, event) => {
|
||||
// Prevent click when clicking dropdown menu
|
||||
if (!event.target.closest('[data-dropdown-trigger="true"]')) {
|
||||
setSelectedCollection(selectedCollection === collectionId ? null : collectionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiAction = {
|
||||
open: (collectionId, apiId) => {
|
||||
if (onApiSelect) onApiSelect(collectionId, apiId);
|
||||
},
|
||||
delete: async (collectionId, apiId) => {
|
||||
setApiToDelete({ collectionId, apiId });
|
||||
},
|
||||
rename: (collectionId, apiId) => {
|
||||
const collection = collections.find(c => c.id === collectionId);
|
||||
const api = collection?.apis?.find(a => a.id === apiId);
|
||||
if (api) {
|
||||
setApiToRename({ ...api, collectionId });
|
||||
setRenameApiDialogOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameApi = async (apiId, newName) => {
|
||||
if (apiToRename) {
|
||||
try {
|
||||
const updatedApi = {
|
||||
...apiToRename,
|
||||
name: newName
|
||||
};
|
||||
await updateAPIRequest(updatedApi);
|
||||
setApiToRename(null);
|
||||
setRenameApiDialogOpen(false);
|
||||
await loadCollections(); // Refresh the collections
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to rename API:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteApi = async () => {
|
||||
if (apiToDelete) {
|
||||
try {
|
||||
await deleteAPIRequest(apiToDelete.collectionId, apiToDelete.apiId);
|
||||
setApiToDelete(null);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddApi = async (collectionId, apiData) => {
|
||||
try {
|
||||
await createAPIRequest(collectionId, apiData);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to create API:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectionAction = {
|
||||
delete: async (collection) => {
|
||||
setCollectionToDelete(collection);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
rename: (collection) => {
|
||||
setCollectionToRename(collection);
|
||||
setRenameDialogOpen(true);
|
||||
},
|
||||
addRequest: (collection) => {
|
||||
setSelectedCollection(collection);
|
||||
setShowAddApi(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (collectionToDelete) {
|
||||
try {
|
||||
const success = await deleteCollection(collectionToDelete.id);
|
||||
if (success) {
|
||||
// If the deleted collection was selected, clear the selection
|
||||
if (selectedCollection === collectionToDelete.id) {
|
||||
setSelectedCollection(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete collection:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameConfirm = async (collectionId, newName) => {
|
||||
try {
|
||||
const success = await updateCollection(collectionId, newName);
|
||||
if (success) {
|
||||
await loadCollections();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to rename collection:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Render helpers
|
||||
const renderApiList = (collection) => {
|
||||
if (selectedCollection !== collection.id || !collection.apis?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return collection.apis.map((api) => (
|
||||
<DraggableApiItem
|
||||
key={api.id}
|
||||
api={api}
|
||||
collectionId={collection.id}
|
||||
onApiAction={handleApiAction}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const renderCollectionItem = (collection) => (
|
||||
<SidebarMenuItem key={collection.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={(e) => handleCollectionClick(collection.id, e)}
|
||||
className="w-full justify-start gap-2"
|
||||
asChild
|
||||
>
|
||||
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
|
||||
<ChevronRight
|
||||
className={`flex-shrink-0 transition-transform ${
|
||||
selectedCollection === collection.id ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate flex-1">{collection.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{collection.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SidebarMenuAction className="p-0 m-0 flex-shrink-0">
|
||||
<CollectionDropdownMenu
|
||||
collection={collection}
|
||||
onAddRequest={() => handleCollectionAction.addRequest(collection)}
|
||||
onRename={() => handleCollectionAction.rename(collection)}
|
||||
onDelete={() => handleCollectionAction.delete(collection)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
{renderApiList(collection)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<SidebarContent>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<SidebarContent>
|
||||
<Alert className="m-4">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</SidebarContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContent>
|
||||
<ScrollArea className="h-[calc(100vh-64px)]">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Collections</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{collections.map(renderCollectionItem)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</ScrollArea>
|
||||
</SidebarContent>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar side={side} className="py-16">
|
||||
{side === 'left' && (
|
||||
<SidebarHeader>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onToggleMode}
|
||||
>
|
||||
{isApiMode ? 'Switch to Scenario Mode' : 'Switch to API Mode'}
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
)}
|
||||
{renderContent()}
|
||||
</Sidebar>
|
||||
|
||||
<DeleteCollectionDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
collectionName={collectionToDelete?.name || ''}
|
||||
/>
|
||||
|
||||
<RenameCollectionDialog
|
||||
open={renameDialogOpen}
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
onSubmit={handleRenameConfirm}
|
||||
collection={collectionToRename}
|
||||
/>
|
||||
|
||||
|
||||
<DeleteAPIDialog
|
||||
open={!!apiToDelete}
|
||||
onOpenChange={(open) => !open && setApiToDelete(null)}
|
||||
onConfirm={handleDeleteApi}
|
||||
apiName={apiToDelete?.name || ''}
|
||||
/>
|
||||
<AddAPIDialog
|
||||
open={showAddApi}
|
||||
onOpenChange={setShowAddApi}
|
||||
onSubmit={handleAddApi}
|
||||
selectedCollection={collections.find(c => c.id === selectedCollection)}
|
||||
/>
|
||||
|
||||
<RenameAPIDialog
|
||||
open={renameApiDialogOpen}
|
||||
onOpenChange={setRenameApiDialogOpen}
|
||||
onSubmit={handleRenameApi}
|
||||
api={apiToRename}
|
||||
/>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default APINavigation;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { HTTPClientView } from '../client-views/HTTPClientView';
|
||||
import { TCPClientView } from '../client-views/TCPClientView';
|
||||
import { HTTPFlatClientView } from '../client-views/HTTPFlatClientView';
|
||||
import { X } from 'lucide-react';
|
||||
import {Button} from '@/components/ui/button.jsx';
|
||||
|
||||
const APITabs = ({
|
||||
apiRequests,
|
||||
currentIndex,
|
||||
onTabChange,
|
||||
onCloseTab
|
||||
}) => {
|
||||
const renderTabContent = (api, index) => {
|
||||
const commonProps = {
|
||||
model: api,
|
||||
index
|
||||
};
|
||||
|
||||
switch (api.type) {
|
||||
case 'HTTP':
|
||||
return <HTTPClientView {...commonProps} />;
|
||||
case 'TCP':
|
||||
return <TCPClientView {...commonProps} />;
|
||||
case 'HTTPFlat':
|
||||
return <HTTPFlatClientView {...commonProps} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={currentIndex.toString()}
|
||||
onValueChange={onTabChange}
|
||||
className="flex flex-col h-full w-full"
|
||||
>
|
||||
<div className="border-b bg-background w-full">
|
||||
<TabsList className="h-auto justify-start w-full">
|
||||
{apiRequests.map((request, index) => (
|
||||
<div key={`${request.id}-${index}`} className="flex items-center">
|
||||
<TabsTrigger
|
||||
value={String(index)}
|
||||
className="flex items-center gap-2 data-[state=active]:bg-background"
|
||||
>
|
||||
<span className="truncate max-w-[150px]">{request.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-0 hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCloseTab(index);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4"/>
|
||||
</Button>
|
||||
</TabsTrigger>
|
||||
</div>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<div className="flex-1 w-full overflow-hidden">
|
||||
{apiRequests.map((request, index) => (
|
||||
<TabsContent
|
||||
key={request.id}
|
||||
value={index.toString()}
|
||||
className="flex-1 h-full m-0 border-none p-0 outline-none data-[state=active]:flex-1"
|
||||
>
|
||||
{renderTabContent(request, index)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default APITabs;
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog';
|
||||
import {useAPI} from '@/providers/APIProvider';
|
||||
|
||||
const AddAPIDialog = ({open, onOpenChange, onSubmit, selectedCollection}) => {
|
||||
const {collections, loadCollections} = useAPI();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: '',
|
||||
collectionId: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCollection?.id) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
collectionId: selectedCollection.id
|
||||
}));
|
||||
}
|
||||
}, [selectedCollection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !isInitialized) {
|
||||
loadCollections();
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [open, isInitialized, loadCollections]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
setError('API 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.type) {
|
||||
setError('API 유형을 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.collectionId) {
|
||||
setError('컬렉션을 선택해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await onSubmit(formData.collectionId, {
|
||||
name: formData.name.trim(),
|
||||
type: formData.type
|
||||
});
|
||||
if (success) {
|
||||
setFormData({name: '', type: '', collectionId: ''});
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError('API 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'API 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
setFormData({
|
||||
name: '',
|
||||
type: '',
|
||||
collectionId: selectedCollection?.id || '' // selectedCollection이 있다면 유지
|
||||
});
|
||||
setError('');
|
||||
setIsInitialized(false); // Dialog가 닫힐 때 초기화 상태를 리셋
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 API 추가</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 API의 정보를 입력해주세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="collection">컬렉션</Label>
|
||||
<Select
|
||||
value={formData.collectionId}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({...prev, collectionId: value}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬렉션을 선택하세요"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{collections.map((collection) => (
|
||||
<SelectItem key={collection.id} value={collection.id}>
|
||||
{collection.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">API 유형</Label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(value) =>
|
||||
setFormData(prev => ({...prev, type: value}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="API 유형을 선택하세요"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HTTP">HTTP</SelectItem>
|
||||
<SelectItem value="HTTPFlat">HTTP Flat</SelectItem>
|
||||
<SelectItem value="TCP">TCP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">API 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({...prev, name: e.target.value}));
|
||||
setError('');
|
||||
}}
|
||||
placeholder="API 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ErrorAlertDialog
|
||||
open={showErrorDialog}
|
||||
onOpenChange={setShowErrorDialog}
|
||||
title="API 생성 오류"
|
||||
description={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAPIDialog;
|
||||
@@ -0,0 +1,113 @@
|
||||
// /components/AddCollectionDialog.jsx
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
|
||||
|
||||
const AddCollectionDialog = ({ open, onOpenChange, onSubmit }) => {
|
||||
const [collectionName, setCollectionName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!collectionName.trim()) {
|
||||
setError('컬렉션 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await onSubmit(collectionName.trim());
|
||||
if (success) {
|
||||
setCollectionName('');
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError('컬렉션 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '컬렉션 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog is closed
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
setCollectionName('');
|
||||
setError('');
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 컬렉션 추가</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 컬렉션의 이름을 입력해주세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">컬렉션 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={collectionName}
|
||||
onChange={(e) => {
|
||||
setCollectionName(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="컬렉션 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ErrorAlertDialog
|
||||
open={showErrorDialog}
|
||||
onOpenChange={setShowErrorDialog}
|
||||
title="컬렉션 생성 오류"
|
||||
description={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCollectionDialog;
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import {MoreHorizontal} from 'lucide-react';
|
||||
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const CollectionDropdownMenu = ({ collection, onAddRequest, onRename, onDelete }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-dropdown-trigger="true" asChild>
|
||||
<div className="p-0 h-auto hover:bg-accent rounded-sm">
|
||||
<MoreHorizontal className="h-4 w-4"/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onAddRequest}>
|
||||
요청 추가
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onRename}>
|
||||
이름 변경
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDelete(collection)}
|
||||
>
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
export default CollectionDropdownMenu;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const DeleteAPIDialog = ({ open, onOpenChange, onConfirm, apiName }) => {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>API 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{apiName}' API를 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAPIDialog;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const DeleteCollectionDialog = ({ open, onOpenChange, onConfirm, collectionName }) => {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>컬렉션 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{collectionName}' 컬렉션을 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없으며, 컬렉션에 포함된 모든 API 요청도 함께 삭제됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCollectionDialog;
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider } from '@xyflow/react';
|
||||
import React, { useCallback, memo, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
// Optimized node components with proper prop types and memoization
|
||||
const StartNode = memo(({ data, isConnectable }) => {
|
||||
// Memoize handles configuration
|
||||
const handles = useMemo(() => ([
|
||||
{ id: 'start-right', type: 'source', position: Position.Right }
|
||||
]), []);
|
||||
|
||||
return (
|
||||
<div className="bg-green-50 rounded-lg border-2 border-green-500 shadow-lg p-4 w-[100px]">
|
||||
{handles.map(handle => (
|
||||
<Handle
|
||||
key={handle.id}
|
||||
type={handle.type}
|
||||
position={handle.position}
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3 bg-green-500"
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Play className="h-4 w-4 text-green-600" />
|
||||
<span className="font-semibold text-sm text-green-700">Start</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ApiNode = memo(({ data, isConnectable }) => {
|
||||
// Memoize handles and colors
|
||||
const handles = useMemo(() => ([
|
||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
||||
]), [data.id]);
|
||||
|
||||
const methodColor = useMemo(() => {
|
||||
const colors = {
|
||||
GET: 'bg-green-500',
|
||||
POST: 'bg-blue-500',
|
||||
PUT: 'bg-yellow-500',
|
||||
DELETE: 'bg-red-500',
|
||||
default: 'bg-gray-500'
|
||||
};
|
||||
return colors[data.method] || colors.default;
|
||||
}, [data.method]);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
||||
{handles.map(handle => (
|
||||
<Handle
|
||||
key={handle.id}
|
||||
type={handle.type}
|
||||
position={handle.position}
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3 bg-gray-500"
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${methodColor}`} />
|
||||
<span className="font-bold text-sm">{data.method}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{data.name}</div>
|
||||
<div className="text-sm truncate text-gray-600 mb-2">
|
||||
{data.path}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ConditionNode = memo(({ data, isConnectable }) => {
|
||||
// Memoize handle styles
|
||||
const handleStyles = useMemo(() => ({
|
||||
true: {
|
||||
top: '30%',
|
||||
backgroundColor: '#22c55e',
|
||||
border: '2px solid #16a34a'
|
||||
},
|
||||
false: {
|
||||
top: '70%',
|
||||
backgroundColor: '#ef4444',
|
||||
border: '2px solid #dc2626'
|
||||
}
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id="in"
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="true"
|
||||
style={handleStyles.true}
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3 !opacity-100"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="false"
|
||||
style={handleStyles.false}
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3 !opacity-100"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
||||
<span className="font-bold text-sm">Condition</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{data.condition || 'Add condition'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const DelayNode = memo(({ data, isConnectable }) => {
|
||||
const handles = useMemo(() => ([
|
||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
||||
]), [data.id]);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-lg p-4 w-[200px]">
|
||||
{handles.map(handle => (
|
||||
<Handle
|
||||
key={handle.id}
|
||||
type={handle.type}
|
||||
position={handle.position}
|
||||
isConnectable={isConnectable}
|
||||
className="w-3 h-3 bg-gray-500"
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<span className="font-bold text-sm">Delay</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Delay: {data.duration || '1000'}ms
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Add display names for debugging
|
||||
StartNode.displayName = 'StartNode';
|
||||
ApiNode.displayName = 'ApiNode';
|
||||
ConditionNode.displayName = 'ConditionNode';
|
||||
DelayNode.displayName = 'DelayNode';
|
||||
|
||||
// Memoize initial nodes and edges
|
||||
const initialNodes = [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'startNode',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { id: 'start' }
|
||||
}
|
||||
];
|
||||
|
||||
const initialEdges = [];
|
||||
|
||||
// Memoize node types
|
||||
const nodeTypes = {
|
||||
startNode: StartNode,
|
||||
apiNode: ApiNode,
|
||||
conditionNode: ConditionNode,
|
||||
delayNode: DelayNode
|
||||
};
|
||||
|
||||
const DiagramArea = () => {
|
||||
const reactFlowWrapper = useRef(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||||
|
||||
// Click handlers for node selection
|
||||
const onNodeClick = useCallback((event, node) => {
|
||||
event.stopPropagation();
|
||||
setSelectedNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
}, []);
|
||||
|
||||
// Apply styles to nodes
|
||||
useEffect(() => {
|
||||
setNodes((nds) =>
|
||||
nds.map((node) => ({
|
||||
...node,
|
||||
className: node.id === selectedNodeId ? 'border-2 border-primary shadow-md bg-primary/5' : null
|
||||
}))
|
||||
);
|
||||
}, [selectedNodeId, setNodes]);
|
||||
|
||||
// Memoize callback functions
|
||||
const onConnect = useCallback((params) => {
|
||||
setEdges(prev => [...prev, { ...params, id: `e${prev.length + 1}` }]);
|
||||
}, []);
|
||||
|
||||
const onInit = useCallback((instance) => {
|
||||
reactFlowWrapper.current = instance;
|
||||
}, []);
|
||||
|
||||
const onDragOver = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!reactFlowWrapper.current) return;
|
||||
|
||||
try {
|
||||
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
const position = reactFlowWrapper.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode = {
|
||||
id: `api-${Date.now()}`,
|
||||
type: 'apiNode',
|
||||
position,
|
||||
data: {
|
||||
id: apiData.id,
|
||||
name: apiData.name,
|
||||
method: apiData.method,
|
||||
path: apiData.path,
|
||||
collectionId: apiData.collectionId
|
||||
}
|
||||
};
|
||||
|
||||
setNodes(prev => prev.concat(newNode));
|
||||
} catch (error) {
|
||||
console.error('Error adding new node:', error);
|
||||
}
|
||||
}, [setNodes]);
|
||||
|
||||
// Default edge options memoized
|
||||
const defaultEdgeOptions = useMemo(() => ({ type: 'smoothstep' }), []);
|
||||
|
||||
return (
|
||||
<div className="h-[50vh] border-b">
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onInit={onInit}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultZoom={1.0} // 초기 배율 설정 (1이 기본값)
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiagramArea);
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FileText, MoreHorizontal } from 'lucide-react';
|
||||
import { SidebarMenuSubItem, SidebarMenuSubButton, SidebarMenuAction } from '@/components/ui/sidebar';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
const ApiDropdownMenu = ({ onOpen, onRename, onDelete }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-dropdown-trigger="true" className="p-0 h-auto">
|
||||
<div className="hover:bg-accent rounded-sm">
|
||||
<MoreHorizontal className="h-4 w-4"/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onOpen}>
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onClick={onRename}>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const DraggableApiItem = ({ api, collectionId, onApiAction }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
// Prepare API data for drag operation
|
||||
const apiData = {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
method: api.method || 'GET',
|
||||
path: api.path || '/',
|
||||
collectionId: collectionId,
|
||||
headers: api.headers || [],
|
||||
parameters: api.parameters || [],
|
||||
body: api.body || '',
|
||||
description: api.description || '',
|
||||
type: api.type || 'HTTP'
|
||||
};
|
||||
|
||||
// Create drag image for better visual feedback
|
||||
const dragImage = document.createElement('div');
|
||||
dragImage.className = 'bg-white border rounded-md shadow-lg p-2 text-sm';
|
||||
dragImage.textContent = `${apiData.method} ${apiData.name}`;
|
||||
document.body.appendChild(dragImage);
|
||||
|
||||
// Set drag image and offset
|
||||
event.dataTransfer.setDragImage(dragImage, 0, 0);
|
||||
|
||||
// Clean up the drag image after it's no longer needed
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragImage);
|
||||
});
|
||||
|
||||
// Set the drag data
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(apiData));
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`cursor-move transition-colors duration-200 ${
|
||||
isDragging ? 'opacity-50 bg-accent' : ''
|
||||
}`}
|
||||
>
|
||||
<SidebarMenuSubButton
|
||||
onClick={(e) => {
|
||||
// Prevent click when dragging ends
|
||||
if (!isDragging) {
|
||||
onApiAction.open(collectionId, api.id);
|
||||
}
|
||||
}}
|
||||
className="w-full justify-start gap-2"
|
||||
asChild
|
||||
>
|
||||
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
|
||||
<FileText className="h-4 w-4 flex-shrink-0"/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate flex-1">
|
||||
{api.method && <span className="font-medium text-primary">{api.method} </span>}
|
||||
{api.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{api.name}</p>
|
||||
{api.path && <p className="text-xs text-muted-foreground">{api.path}</p>}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SidebarMenuAction
|
||||
className="p-0 m-0 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ApiDropdownMenu
|
||||
onOpen={() => onApiAction.open(collectionId, api.id)}
|
||||
onRename={() => onApiAction.rename(collectionId, api.id)}
|
||||
onDelete={() => onApiAction.delete(collectionId, api.id)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraggableApiItem;
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
|
||||
|
||||
const RenameAPIDialog = ({ open, onOpenChange, onSubmit, api }) => {
|
||||
const [apiName, setApiName] = useState(api?.name || '');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!apiName.trim()) {
|
||||
setError('API 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await onSubmit(api.id, apiName.trim());
|
||||
if (success) {
|
||||
setApiName('');
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError('API 이름 변경에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'API 이름 변경에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog is closed
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
setApiName(api?.name || '');
|
||||
setError('');
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>API 이름 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
API의 새로운 이름을 입력해주세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">API 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={apiName}
|
||||
onChange={(e) => {
|
||||
setApiName(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="API 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '변경 중...' : '변경'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ErrorAlertDialog
|
||||
open={showErrorDialog}
|
||||
onOpenChange={setShowErrorDialog}
|
||||
title="API 이름 변경 오류"
|
||||
description={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameAPIDialog;
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
|
||||
|
||||
const RenameCollectionDialog = ({ open, onOpenChange, onSubmit, collection }) => {
|
||||
const [collectionName, setCollectionName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
// Update collection name when collection prop changes or dialog opens
|
||||
useEffect(() => {
|
||||
if (collection && open) {
|
||||
setCollectionName(collection.name || '');
|
||||
}
|
||||
}, [collection, open]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!collectionName.trim()) {
|
||||
setError('컬렉션 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await onSubmit(collection.id, collectionName.trim());
|
||||
if (success) {
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError('컬렉션 이름 변경에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '컬렉션 이름 변경에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form when dialog is closed
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
setError('');
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>컬렉션 이름 변경</DialogTitle>
|
||||
<DialogDescription>
|
||||
변경할 컬렉션의 새로운 이름을 입력해주세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">컬렉션 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={collectionName}
|
||||
onChange={(e) => {
|
||||
setCollectionName(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="컬렉션 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '변경 중...' : '변경'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ErrorAlertDialog
|
||||
open={showErrorDialog}
|
||||
onOpenChange={setShowErrorDialog}
|
||||
title="컬렉션 이름 변경 오류"
|
||||
description={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameCollectionDialog;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import MonacoEditor from '@/components/shared/MonacoEditor';
|
||||
import ScenarioMenuBar from '@/components/api-tester/ScenarioMenuBar.jsx';
|
||||
import DiagramArea from '@/components/api-tester/DiagramArea.jsx';
|
||||
|
||||
// ConsoleArea Component
|
||||
const ConsoleArea = () => {
|
||||
const [consoleOutput, setConsoleOutput] = useState('');
|
||||
|
||||
return (
|
||||
<div className="h-[50vh]">
|
||||
<MonacoEditor
|
||||
id="scenario-console"
|
||||
value={consoleOutput}
|
||||
language="plaintext"
|
||||
readOnly={true}
|
||||
height="full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Main ScenarioEditor Component
|
||||
const ScenarioEditor = () => {
|
||||
const handleSave = () => {
|
||||
console.log('Saving scenario...');
|
||||
};
|
||||
|
||||
const handleStepRun = () => {
|
||||
console.log('Running step...');
|
||||
};
|
||||
|
||||
const handleFullRun = () => {
|
||||
console.log('Running all steps...');
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
console.log('Pausing execution...');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
console.log('Resetting scenario...');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ScenarioMenuBar
|
||||
onSave={handleSave}
|
||||
onStepRun={handleStepRun}
|
||||
onFullRun={handleFullRun}
|
||||
onPause={handlePause}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<DiagramArea/>
|
||||
<ConsoleArea/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioEditor;
|
||||
@@ -0,0 +1,52 @@
|
||||
import {Button} from '@/components/ui/button.jsx';
|
||||
import {Pause, Play, RotateCcw, Save, SkipForward} from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const ScenarioMenuBar = ({ onSave, onStepRun, onFullRun, onPause, onReset }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 border-b">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onStepRun}
|
||||
>
|
||||
<SkipForward className="h-4 w-4 mr-2" />
|
||||
단계별 실행
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onFullRun}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
전체 실행
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPause}
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
일시 정지
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioMenuBar;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FileText, MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction, SidebarHeader
|
||||
} from '@/components/ui/sidebar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLoadTest } from '@/providers/LoadTestProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
|
||||
const ScenarioDropdownMenu = ({ onOpen, onRename, onDelete }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-dropdown-trigger="true" asChild>
|
||||
<div className="p-0 h-auto hover:bg-accent rounded-sm">
|
||||
<MoreHorizontal className="h-4 w-4"/>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onOpen}>
|
||||
열기
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onRename}>
|
||||
이름 변경
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<SidebarContent>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
);
|
||||
|
||||
const ErrorMessage = ({ message }) => (
|
||||
<SidebarContent>
|
||||
<Alert className="m-4">
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
</SidebarContent>
|
||||
);
|
||||
|
||||
const ScenarioNavigation = ({
|
||||
contextPath = '/',
|
||||
onScenarioSelect,
|
||||
side,
|
||||
isApiMode,
|
||||
onToggleMode
|
||||
}) => {
|
||||
const {
|
||||
scenarios,
|
||||
loading,
|
||||
error,
|
||||
loadScenarioList
|
||||
} = useLoadTest();
|
||||
|
||||
useEffect(() => {
|
||||
loadScenarioList();
|
||||
}, [loadScenarioList]);
|
||||
|
||||
const handleScenarioAction = {
|
||||
open: (scenarioId) => {
|
||||
if (onScenarioSelect) {
|
||||
onScenarioSelect(scenarioId);
|
||||
}
|
||||
},
|
||||
delete: async (scenarioId) => {
|
||||
// TODO: Implement delete functionality
|
||||
console.log('Delete scenario:', scenarioId);
|
||||
},
|
||||
rename: (scenarioId) => {
|
||||
// TODO: Implement rename functionality
|
||||
console.log('Rename scenario:', scenarioId);
|
||||
}
|
||||
};
|
||||
|
||||
const renderScenarioItem = (scenario) => (
|
||||
<SidebarMenuItem key={scenario.id}>
|
||||
<SidebarMenuButton
|
||||
onClick={() => handleScenarioAction.open(scenario.id)}
|
||||
className="w-full justify-start gap-2"
|
||||
asChild
|
||||
>
|
||||
<div className="w-full flex items-center gap-2 min-w-0 pr-2">
|
||||
<FileText className="h-4 w-4 flex-shrink-0"/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate flex-1">{scenario.name}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{scenario.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SidebarMenuAction className="p-0 m-0 flex-shrink-0">
|
||||
<ScenarioDropdownMenu
|
||||
onOpen={() => handleScenarioAction.open(scenario.id)}
|
||||
onRename={() => handleScenarioAction.rename(scenario.id)}
|
||||
onDelete={() => handleScenarioAction.delete(scenario.id)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (loading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarContent>
|
||||
<ScrollArea className="h-[calc(100%-64px)]">
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Scenarios</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{scenarios.map(renderScenarioItem)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</ScrollArea>
|
||||
</SidebarContent>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar side={side} className="py-16">
|
||||
<SidebarHeader>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={onToggleMode}
|
||||
>
|
||||
{isApiMode ? 'Switch to Scenario Mode' : 'Switch to API Mode'}
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
{renderContent()}
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScenarioNavigation;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
import PropertyTable from '../shared/PropertyTable';
|
||||
import ServerManager from '@/components/ServerManager.jsx';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table.jsx';
|
||||
import {useAPI} from '@/providers/APIProvider';
|
||||
import MonacoEditor from '@/components/shared/MonacoEditor';
|
||||
// import MonacoVariableEditor from './MonacoVariableEditor'; // Import the new component
|
||||
|
||||
const HTTP_METHODS = [
|
||||
'POST', 'GET', 'PUT', 'DELETE', 'CONNECT',
|
||||
'HEAD', 'OPTIONS', 'TRACE', 'PATCH'
|
||||
];
|
||||
|
||||
export const HTTPClientView = ({model, index}) => {
|
||||
const { getServer } = useAPI();
|
||||
const [currentModel, setCurrentModel] = useState(model);
|
||||
const [basePath, setBasePath] = useState('');
|
||||
const [currentTab, setCurrentTab] = useState('body');
|
||||
const [currentResponseTab, setCurrentResponseTab] = useState('response-body');
|
||||
const [responseData, setResponseData] = useState({
|
||||
headers: [],
|
||||
status: '',
|
||||
time: '',
|
||||
size: ''
|
||||
});
|
||||
|
||||
const [editorContents, setEditorContents] = useState({
|
||||
requestBody: currentModel.requestBody || '',
|
||||
responseBody: currentModel.responseBody || '',
|
||||
console: '',
|
||||
preRequestScript: currentModel.preRequestScript || '',
|
||||
postRequestScript: currentModel.postRequestScript || ''
|
||||
});
|
||||
|
||||
const handleServerChange = (serverId) => {
|
||||
const selectedServer = getServer(serverId);
|
||||
|
||||
setCurrentModel(prev => ({
|
||||
...prev,
|
||||
server: serverId
|
||||
}));
|
||||
|
||||
if (selectedServer) {
|
||||
setBasePath(selectedServer.basePath || '');
|
||||
}
|
||||
};
|
||||
|
||||
const handleHeadersChange = (headers) => {
|
||||
setCurrentModel(prev => ({
|
||||
...prev,
|
||||
headers
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePathChange = (path) => {
|
||||
const queryParams = parseQueryParams(path);
|
||||
setCurrentModel(prev => ({
|
||||
...prev,
|
||||
path,
|
||||
queryParams
|
||||
}));
|
||||
};
|
||||
|
||||
const parseQueryParams = (path) => {
|
||||
const queryParams = [];
|
||||
const queryString = path.split('?')[1];
|
||||
|
||||
if (queryString) {
|
||||
queryString.split('&').forEach(param => {
|
||||
const [key, value] = param.split('=');
|
||||
queryParams.push({
|
||||
enabled: true,
|
||||
key,
|
||||
value: value || ''
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return queryParams;
|
||||
};
|
||||
|
||||
const renderRequestVariables = () => (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-medium mb-2">Available Variables</h3>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label>request</Label>
|
||||
<div className="text-sm text-gray-500">Request object containing headers, body, etc.</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>environment</Label>
|
||||
<div className="text-sm text-gray-500">Current environment variables</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderResponseHeaders = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responseData.headers.map((header, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{header.name}</TableCell>
|
||||
<TableCell>{header.value}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4 space-y-4">
|
||||
{/* Request Header Section */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<span>{currentModel.collectionName}</span>
|
||||
{' > '}
|
||||
<span>{currentModel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={currentModel.method}
|
||||
onValueChange={(value) => setCurrentModel(prev => ({...prev, method: value}))}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue placeholder="Method"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map(method => (
|
||||
<SelectItem key={method} value={method}>
|
||||
{method}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ServerManager
|
||||
onServerChange={handleServerChange}
|
||||
/>
|
||||
<Input
|
||||
value={basePath} // 선택된 서버의 basePath 표시
|
||||
readOnly
|
||||
className="w-40"
|
||||
/>
|
||||
<EditableInput
|
||||
value={currentModel.path}
|
||||
onChange={handlePathChange}
|
||||
className="flex-1"
|
||||
/>
|
||||
|
||||
<Button>Send</Button>
|
||||
<Button variant="secondary">Save</Button>
|
||||
</div>
|
||||
|
||||
{/* Request/Response Tabs */}
|
||||
<Tabs value={currentTab} onValueChange={setCurrentTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
<TabsTrigger value="params">Params</TabsTrigger>
|
||||
<TabsTrigger value="headers">Headers</TabsTrigger>
|
||||
<TabsTrigger value="pre-request">Pre-Request</TabsTrigger>
|
||||
<TabsTrigger value="post-request">Post-Request</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-2">
|
||||
{/* Body Tab Content */}
|
||||
<div style={{ display: currentTab === 'body' ? 'block' : 'none' }}>
|
||||
<Select
|
||||
value={currentModel.contentType}
|
||||
// onValueChange={(value) => setCurrentModel(prev => ({ ...prev, contentType: value }))}
|
||||
onValueChange={(value) => {
|
||||
// Update both content type and editor content
|
||||
setCurrentModel(prev => ({
|
||||
...prev,
|
||||
contentType: value,
|
||||
// Optionally format the content when switching to JSON
|
||||
requestBody: value === 'json' && prev.requestBody ?
|
||||
JSON.stringify(JSON.parse(prev.requestBody), null, 2) :
|
||||
prev.requestBody
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Content Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="xml">XML</SelectItem>
|
||||
<SelectItem value="plaintext">Text/Plain</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<MonacoEditor
|
||||
id={`request-body-${model.id}`}
|
||||
value={currentModel.requestBody}
|
||||
language={
|
||||
currentModel.contentType
|
||||
}
|
||||
onChange={(newValue) => {
|
||||
setCurrentModel(prev => ({
|
||||
...prev,
|
||||
requestBody: newValue
|
||||
}));
|
||||
}}
|
||||
height="64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Params Tab Content */}
|
||||
<div style={{ display: currentTab === 'params' ? 'block' : 'none' }}>
|
||||
<PropertyTable
|
||||
properties={currentModel.queryParams}
|
||||
onChange={handlePathChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headers Tab Content */}
|
||||
<div style={{ display: currentTab === 'headers' ? 'block' : 'none' }}>
|
||||
<PropertyTable
|
||||
properties={currentModel.headers}
|
||||
onChange={handleHeadersChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pre-Request Tab Content */}
|
||||
<div style={{ display: currentTab === 'pre-request' ? 'block' : 'none' }} className="flex">
|
||||
<div className="flex-1">
|
||||
<MonacoEditor
|
||||
id={`pre-request-${model.id}`}
|
||||
value={editorContents.preRequestScript}
|
||||
language="javascript"
|
||||
onChange={(value) => {
|
||||
setEditorContents(prev => ({ ...prev, preRequestScript: value }));
|
||||
setCurrentModel(prev => ({ ...prev, preRequestScript: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post-Request Tab Content */}
|
||||
<div style={{ display: currentTab === 'post-request' ? 'block' : 'none' }} className="flex">
|
||||
<div>
|
||||
<MonacoEditor
|
||||
id={`post-request-${model.id}`}
|
||||
value={editorContents.postRequestScript}
|
||||
language="javascript"
|
||||
onChange={(value) => {
|
||||
setEditorContents(prev => ({ ...prev, postRequestScript: value }));
|
||||
setCurrentModel(prev => ({ ...prev, postRequestScript: value }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
{/* Response Section */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span>Response</span>
|
||||
<span className="text-sm text-gray-500">{responseData.status}</span>
|
||||
<span className="text-sm text-gray-500">{responseData.time}</span>
|
||||
<span className="text-sm text-gray-500">{responseData.size}</span>
|
||||
</div>
|
||||
|
||||
<Tabs value={currentResponseTab} onValueChange={setCurrentResponseTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="response-body">Body</TabsTrigger>
|
||||
<TabsTrigger value="response-headers">Headers</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-2">
|
||||
<div style={{display: currentResponseTab === 'response-body' ? 'block' : 'none'}}>
|
||||
<MonacoEditor
|
||||
id={`response-body-${model.id}`}
|
||||
value={editorContents.responseBody}
|
||||
readOnly={true}
|
||||
onChange={() => {}} // Read-only, so no onChange needed
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{display: currentResponseTab === 'response-headers' ? 'block' : 'none'}}>
|
||||
{renderResponseHeaders()}
|
||||
</div>
|
||||
|
||||
<div style={{display: currentResponseTab === 'console' ? 'block' : 'none'}}>
|
||||
<MonacoEditor
|
||||
id={`console-${model.id}`}
|
||||
value={editorContents.console}
|
||||
language="plaintext"
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HTTPClientView;
|
||||
@@ -0,0 +1,288 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useMonaco } from '@/hooks/useMonaco';
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
import PropertyTable from '../shared/PropertyTable';
|
||||
import LayoutGrid from '../layout/LayoutGrid';
|
||||
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
|
||||
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
|
||||
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
|
||||
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
|
||||
import VariableSnippet from '../shared/VariableSnippet';
|
||||
|
||||
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE'];
|
||||
|
||||
export const HTTPFlatClientView = ({ parent, serverManager, model, index }) => {
|
||||
// State
|
||||
const [currentTab, setCurrentTab] = useState('layout');
|
||||
const [path, setPath] = useState(model.path || '');
|
||||
const [queryParams, setQueryParams] = useState(model.queryParams || []);
|
||||
const [headers, setHeaders] = useState(model.headers || []);
|
||||
const [method, setMethod] = useState(model.method || 'GET');
|
||||
const [server, setServer] = useState(model.server);
|
||||
const [totalMessageLength, setTotalMessageLength] = useState(0);
|
||||
|
||||
// Editor refs and state
|
||||
const { editor: layoutJsonEditor, containerRef: layoutJsonRef } = useMonaco({
|
||||
defaultValue: JSON.stringify(model.layout, null, 2),
|
||||
language: 'json'
|
||||
});
|
||||
|
||||
const { editor: requestBodyEditor, containerRef: requestBodyRef } = useMonaco({
|
||||
defaultValue: model.requestBody || '',
|
||||
language: 'json'
|
||||
});
|
||||
|
||||
const { editor: responseBodyEditor, containerRef: responseBodyRef } = useMonaco({
|
||||
defaultValue: '',
|
||||
language: 'json',
|
||||
readOnly: true
|
||||
});
|
||||
|
||||
const { editor: preRequestEditor, containerRef: preRequestRef } = useMonaco({
|
||||
defaultValue: model.preRequestScript || '',
|
||||
language: 'javascript'
|
||||
});
|
||||
|
||||
const { editor: postRequestEditor, containerRef: postRequestRef } = useMonaco({
|
||||
defaultValue: model.postRequestScript || '',
|
||||
language: 'javascript'
|
||||
});
|
||||
|
||||
const { editor: consoleEditor, containerRef: consoleRef } = useMonaco({
|
||||
defaultValue: '',
|
||||
language: 'text',
|
||||
readOnly: true
|
||||
});
|
||||
|
||||
// Layout tree options and state
|
||||
const layoutDataTreeOptions = [
|
||||
{ name: 'loutItemSerno', width: 100, isFirst: true, label: '#' },
|
||||
{ name: 'loutItemName', width: 100, align: 'left', label: '항목명(영문)' },
|
||||
{ name: 'loutItemDesc', width: 100, align: 'left', label: '항목설명' },
|
||||
{ name: 'loutItemDepth', width: 60, align: 'center', label: '깊이' },
|
||||
{ name: 'loutItemType', width: 100, align: 'center', label: '아이템 유형' },
|
||||
{ name: 'loutItemOccCnt', width: 100, align: 'center', label: '반복 횟수' },
|
||||
{ name: 'loutItemOccRef', width: 120, align: 'center', label: '반복 참조 필드' },
|
||||
{ name: 'loutItemLength', width: 100, align: 'center', label: '데이터 길이' },
|
||||
{ name: 'value', width: 200, align: 'left', label: '값', type: 'textfield' }
|
||||
];
|
||||
|
||||
const [layoutRoot, setLayoutRoot] = useState(() => {
|
||||
const root = ApiLayoutItemTreeBuilder.createTree(model.layout.layoutItems);
|
||||
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
|
||||
return replicator.createReplicatedTree(root);
|
||||
});
|
||||
|
||||
// Handlers
|
||||
const handlePathChange = useCallback((newPath) => {
|
||||
setPath(newPath);
|
||||
const newParams = parent.parseParam(newPath);
|
||||
setQueryParams(newParams);
|
||||
model.path = newPath;
|
||||
model.queryParams = newParams;
|
||||
}, [parent, model]);
|
||||
|
||||
const handleServerChange = useCallback((newServer) => {
|
||||
setServer(newServer);
|
||||
model.server = newServer;
|
||||
}, [model]);
|
||||
|
||||
const handleMethodChange = useCallback((newMethod) => {
|
||||
setMethod(newMethod);
|
||||
model.method = newMethod;
|
||||
}, [model]);
|
||||
|
||||
const handleLayoutUpdate = useCallback((updatedLayout) => {
|
||||
const updatedRoot = ApiLayoutItemTreeBuilder.createTree(updatedLayout.layoutItems);
|
||||
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
|
||||
const newRoot = replicator.createReplicatedTree(updatedRoot);
|
||||
setLayoutRoot(newRoot);
|
||||
model.layout = updatedLayout;
|
||||
}, [model]);
|
||||
|
||||
const handleLayoutDataChange = useCallback(async (finalMessage, layoutDataTree) => {
|
||||
if (requestBodyEditor) {
|
||||
const prefix = server && server.serverOptions
|
||||
? await serverManager.renderServerPrefix(server.serverOptions, model)
|
||||
: '';
|
||||
requestBodyEditor.setValue(prefix + finalMessage);
|
||||
}
|
||||
model.layout.computedLayoutItemRoot = layoutDataTree;
|
||||
}, [server, serverManager, model, requestBodyEditor]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
// Implement send logic
|
||||
}, [/* dependencies */]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
// Implement save logic
|
||||
}, [/* dependencies */]);
|
||||
|
||||
// Effect for editor cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup logic for editors if needed
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{model.collectionName} > {model.name}
|
||||
</div>
|
||||
|
||||
{/* Request Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={method} onValueChange={handleMethodChange}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map(m => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={server} onValueChange={handleServerChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverManager.renderHttpServers(server)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
value={serverManager.basePath(server)}
|
||||
readOnly
|
||||
className="w-[180px]"
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<EditableInput
|
||||
name="path"
|
||||
value={path}
|
||||
onChange={handlePathChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSend}>Send</Button>
|
||||
<Button variant="secondary" onClick={handleSave}>Save</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs value={currentTab} onValueChange={setCurrentTab} className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="layout">Layout</TabsTrigger>
|
||||
<TabsTrigger value="layoutJson">Layout JSON</TabsTrigger>
|
||||
<TabsTrigger value="layoutTree">Layout Tree</TabsTrigger>
|
||||
<TabsTrigger value="layoutData">Layout Data</TabsTrigger>
|
||||
<TabsTrigger value="param">Param</TabsTrigger>
|
||||
<TabsTrigger value="header">Header</TabsTrigger>
|
||||
<TabsTrigger value="preRequest">Pre-Request</TabsTrigger>
|
||||
<TabsTrigger value="postRequest">Post-Request</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="layout" className="flex-1">
|
||||
<LayoutGrid
|
||||
layout={model.layout}
|
||||
onChange={handleLayoutUpdate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="layoutJson" className="flex-1">
|
||||
<div ref={layoutJsonRef} className="h-[300px] border" />
|
||||
<Button
|
||||
onClick={() => {/* Implement load layout */}}
|
||||
className="mt-2"
|
||||
>
|
||||
Load Layout
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="layoutTree" className="flex-1">
|
||||
<LayoutTreeGrid
|
||||
root={layoutRoot}
|
||||
options={layoutDataTreeOptions}
|
||||
onChange={handleLayoutUpdate}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="layoutData" className="flex-1">
|
||||
<LayoutTreeDataGrid
|
||||
root={layoutRoot}
|
||||
apiRequest={model}
|
||||
options={layoutDataTreeOptions}
|
||||
onUpdate={handleLayoutDataChange}
|
||||
totalMessageLengthSelector={totalMessageLength}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="param" className="flex-1">
|
||||
<PropertyTable
|
||||
properties={queryParams}
|
||||
onChange={setQueryParams}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="header" className="flex-1">
|
||||
<PropertyTable
|
||||
properties={headers}
|
||||
onChange={setHeaders}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preRequest" className="flex-1">
|
||||
<div className="grid grid-cols-[1fr,200px] gap-4">
|
||||
<div ref={preRequestRef} className="h-[300px] border" />
|
||||
<VariableSnippet editor={preRequestEditor} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="postRequest" className="flex-1">
|
||||
<div className="grid grid-cols-[1fr,200px] gap-4">
|
||||
<div ref={postRequestRef} className="h-[300px] border" />
|
||||
<VariableSnippet editor={postRequestEditor} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Request Body */}
|
||||
<div className="h-[150px] border">
|
||||
<div ref={requestBodyRef} className="h-full" />
|
||||
</div>
|
||||
|
||||
{/* Total Message Length */}
|
||||
<div className="text-sm">
|
||||
Total Message Length: {totalMessageLength}
|
||||
</div>
|
||||
|
||||
{/* Response Section */}
|
||||
<Card className="flex-1">
|
||||
<Tabs defaultValue="body" className="h-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="body" className="flex-1">
|
||||
<div ref={responseBodyRef} className="h-[250px] border" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="console" className="flex-1">
|
||||
<div ref={consoleRef} className="h-[250px] border" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// export default HTTPFlatClientView;
|
||||
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
|
||||
const registerVariableSupport = (baseLanguage = 'plaintext') => {
|
||||
const languageId = `${baseLanguage}-with-variables`;
|
||||
|
||||
// Only register if not already registered
|
||||
if (!monaco.languages.getLanguages().some(lang => lang.id === languageId)) {
|
||||
// Register the new language
|
||||
monaco.languages.register({ id: languageId });
|
||||
|
||||
// Define basic language configuration
|
||||
const configuration = {
|
||||
brackets: [
|
||||
['{', '}'],
|
||||
['[', ']'],
|
||||
['(', ')'],
|
||||
['{{', '}}']
|
||||
],
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: '\'', close: '\'' },
|
||||
{ open: '{{', close: '}}' }
|
||||
],
|
||||
surroundingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: '\'', close: '\'' },
|
||||
{ open: '{{', close: '}}' }
|
||||
]
|
||||
};
|
||||
|
||||
// Set the configuration for our new language
|
||||
monaco.languages.setLanguageConfiguration(languageId, configuration);
|
||||
|
||||
// Define the token provider that combines base language and variable highlighting
|
||||
monaco.languages.setMonarchTokensProvider(languageId, {
|
||||
defaultToken: '',
|
||||
tokenizer: {
|
||||
root: [
|
||||
// Handle variables with highest priority
|
||||
[/{{[^}]*}}/, 'variable'],
|
||||
|
||||
// Default token handling
|
||||
[/[^{{]+/, { token: '', switchTo: '@baseLanguage' }]
|
||||
],
|
||||
|
||||
baseLanguage: [
|
||||
[/{{/, { token: 'variable', switchTo: 'root' }],
|
||||
[/[^{{]+/, '']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Always define the theme - Monaco will handle duplicate definitions
|
||||
monaco.editor.defineTheme('variable-highlight-theme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [
|
||||
{ token: 'variable', foreground: '0000ff', fontStyle: 'bold' }
|
||||
],
|
||||
colors: {}
|
||||
});
|
||||
}
|
||||
|
||||
return languageId;
|
||||
};
|
||||
|
||||
// Supported language configurations
|
||||
const SUPPORTED_LANGUAGES = {
|
||||
json: {
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
defaultValue: '{}',
|
||||
diagnosticsEnabled: true
|
||||
},
|
||||
javascript: {
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
defaultValue: '',
|
||||
diagnosticsEnabled: true
|
||||
},
|
||||
html: {
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
defaultValue: '',
|
||||
diagnosticsEnabled: true
|
||||
},
|
||||
xml: {
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
defaultValue: '<?xml version="1.0" encoding="UTF-8"?>',
|
||||
diagnosticsEnabled: true
|
||||
},
|
||||
plaintext: {
|
||||
formatOnType: false,
|
||||
formatOnPaste: false,
|
||||
defaultValue: '',
|
||||
diagnosticsEnabled: false
|
||||
}
|
||||
};
|
||||
|
||||
const MonacoVariableEditor = ({
|
||||
id,
|
||||
value = '',
|
||||
language = 'plaintext',
|
||||
readOnly = false,
|
||||
height = '64',
|
||||
onChange = () => {},
|
||||
options = {}
|
||||
}) => {
|
||||
const editorRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Register language with variable support
|
||||
const editorLanguage = registerVariableSupport(language);
|
||||
|
||||
// Get language-specific configurations
|
||||
const languageConfig = SUPPORTED_LANGUAGES[language] || SUPPORTED_LANGUAGES.plaintext;
|
||||
|
||||
// Create editor instance
|
||||
editorRef.current = monaco.editor.create(containerRef.current, {
|
||||
value: value || languageConfig.defaultValue,
|
||||
language: editorLanguage,
|
||||
automaticLayout: true,
|
||||
theme: 'variable-highlight-theme',
|
||||
minimap: { enabled: false },
|
||||
readOnly,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnType: languageConfig.formatOnType,
|
||||
formatOnPaste: languageConfig.formatOnPaste,
|
||||
...options,
|
||||
});
|
||||
|
||||
// Add command to insert variable wrapper
|
||||
editorRef.current.addCommand(
|
||||
monaco.KeyMod.CtrlCmd | monaco.KeyCode.Space,
|
||||
() => {
|
||||
const selection = editorRef.current.getSelection();
|
||||
const selectedText = editorRef.current.getModel().getValueInRange(selection);
|
||||
|
||||
const operation = {
|
||||
range: selection,
|
||||
text: selectedText ? `{{${selectedText}}}` : '{{}}'
|
||||
};
|
||||
|
||||
editorRef.current.executeEdits('insert-variable', [operation]);
|
||||
|
||||
// If no text was selected, place cursor inside brackets
|
||||
if (!selectedText) {
|
||||
const position = new monaco.Position(
|
||||
selection.startLineNumber,
|
||||
selection.startColumn + 2
|
||||
);
|
||||
editorRef.current.setPosition(position);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set up change event handler
|
||||
const changeDisposable = editorRef.current.onDidChangeModelContent(() => {
|
||||
const newValue = editorRef.current.getValue();
|
||||
onChange(newValue);
|
||||
});
|
||||
|
||||
// Set up resize observer
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(containerRef.current);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
changeDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [language]); // Re-initialize when language changes
|
||||
|
||||
// Update value if it changes externally
|
||||
useEffect(() => {
|
||||
if (editorRef.current && value !== editorRef.current.getValue()) {
|
||||
editorRef.current.setValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
ref={containerRef}
|
||||
className={`h-${height} border rounded-md overflow-hidden`}
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonacoVariableEditor;
|
||||
@@ -0,0 +1,458 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import APIClient from '../../lib/api-client';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import LayoutGrid from '../layout/LayoutGrid';
|
||||
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
|
||||
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
|
||||
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
|
||||
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
|
||||
import VariableSnippet from '../shared/VariableSnippet';
|
||||
|
||||
export const TCPClientView = ({ parent, serverManager, model, index }) => {
|
||||
const [apiClient] = useState(() => new APIClient());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [editors, setEditors] = useState({
|
||||
layoutJson: null,
|
||||
requestBody: null,
|
||||
responseBody: null,
|
||||
console: null,
|
||||
preRequest: null,
|
||||
postRequest: null
|
||||
});
|
||||
const [layoutData, setLayoutData] = useState(null);
|
||||
|
||||
// Refs
|
||||
const layoutJsonRef = useRef(null);
|
||||
const requestBodyRef = useRef(null);
|
||||
const responseBodyRef = useRef(null);
|
||||
const consoleRef = useRef(null);
|
||||
const preRequestRef = useRef(null);
|
||||
const postRequestRef = useRef(null);
|
||||
const totalMessageLengthRef = useRef(null);
|
||||
const layoutGridRef = useRef(null);
|
||||
|
||||
const handleResponseDisplay = useCallback((response) => {
|
||||
try {
|
||||
editors.responseBody?.setValue(response.body || '');
|
||||
if (response.time) {
|
||||
const responseTimeEl = document.querySelector('.response_time');
|
||||
if (responseTimeEl) {
|
||||
responseTimeEl.textContent = `time: ${response.time}ms`;
|
||||
}
|
||||
}
|
||||
if (response.size) {
|
||||
const responseSizeEl = document.querySelector('.response_size');
|
||||
if (responseSizeEl) {
|
||||
responseSizeEl.textContent = `size: ${response.size}bytes`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error displaying response:', error);
|
||||
}
|
||||
}, [editors.responseBody]);
|
||||
|
||||
const handleLoadLayout = useCallback(() => {
|
||||
try {
|
||||
const newLayout = JSON.parse(editors.layoutJson?.getValue() || '{}');
|
||||
model.layout = newLayout;
|
||||
if (newLayout.layoutItems) {
|
||||
newLayout.layoutItems.forEach((item) => {
|
||||
if (item.value === undefined) {
|
||||
item.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const layoutRoot = ApiLayoutItemTreeBuilder.createTree(newLayout.layoutItems);
|
||||
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
|
||||
const layoutDataRoot = replicator.createReplicatedTree(layoutRoot);
|
||||
|
||||
setLayoutData(layoutDataRoot);
|
||||
|
||||
if (layoutGridRef.current) {
|
||||
layoutGridRef.current.rebuild(newLayout);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading layout:', error);
|
||||
alert(error.message);
|
||||
}
|
||||
}, [model, editors.layoutJson]);
|
||||
|
||||
const handleSendRequest = useCallback(async () => {
|
||||
if (!model.server) {
|
||||
alert('서버를 등록해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
editors.responseBody?.setValue('');
|
||||
editors.console?.setValue('');
|
||||
|
||||
const startTime = Date.now();
|
||||
const response = await apiClient.sendAPIRequest(
|
||||
model,
|
||||
null,
|
||||
(message) => {
|
||||
const consoleEditor = editors.console;
|
||||
if (consoleEditor) {
|
||||
const model = consoleEditor.getModel();
|
||||
if (model) {
|
||||
const lastLine = model.getLineCount();
|
||||
const range = new monaco.Range(lastLine, 1, lastLine, 1);
|
||||
const text = typeof message === 'object' ? JSON.stringify(message, null, 2) : message;
|
||||
consoleEditor.executeEdits('console', [{
|
||||
identifier: { major: 1, minor: 1 },
|
||||
range,
|
||||
text: text + '\n',
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response) {
|
||||
const endTime = Date.now();
|
||||
response.time = endTime - startTime;
|
||||
handleResponseDisplay(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending request:', error);
|
||||
alert(error.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [model, editors, apiClient, handleResponseDisplay]);
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
Object.values(editors).forEach(editor => {
|
||||
if (editor) {
|
||||
editor.layout({ width: 0 });
|
||||
editor.layout();
|
||||
}
|
||||
});
|
||||
}, [editors]);
|
||||
|
||||
// Layout update handler
|
||||
const handleLayoutUpdate = useCallback(async (finalMessage, layoutDataTree) => {
|
||||
const prefix = (model.server && model.server.serverOptions)
|
||||
? await serverManager.renderServerPrefix(model.server.serverOptions, model)
|
||||
: '';
|
||||
|
||||
editors.requestBody?.setValue(prefix + finalMessage);
|
||||
model.layout.computedLayoutItemRoot = layoutDataTree;
|
||||
}, [model, editors.requestBody, serverManager]);
|
||||
|
||||
// Initialize Monaco editors
|
||||
useEffect(() => {
|
||||
if (layoutJsonRef.current && !editors.layoutJson) {
|
||||
const layoutJson = JSON.stringify(model.layout, null, 2);
|
||||
const layoutJsonEditor = monaco.editor.create(layoutJsonRef.current, {
|
||||
value: layoutJson,
|
||||
language: 'json',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false
|
||||
});
|
||||
|
||||
layoutJsonEditor.onDidChangeModelContent(() => {
|
||||
try {
|
||||
const content = layoutJsonEditor.getValue();
|
||||
JSON.parse(content); // Validate JSON
|
||||
} catch (e) {
|
||||
// Invalid JSON - ignore
|
||||
}
|
||||
});
|
||||
|
||||
setEditors(prev => ({ ...prev, layoutJson: layoutJsonEditor }));
|
||||
}
|
||||
|
||||
if (requestBodyRef.current && !editors.requestBody) {
|
||||
const requestBodyEditor = monaco.editor.create(requestBodyRef.current, {
|
||||
value: model.requestBody || '',
|
||||
language: 'text',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false
|
||||
});
|
||||
|
||||
requestBodyEditor.onDidChangeModelContent(() => {
|
||||
model.requestBody = requestBodyEditor.getValue();
|
||||
});
|
||||
|
||||
requestBodyEditor.onMouseMove((e) => {
|
||||
const position = e.target.position;
|
||||
if (position) {
|
||||
const editorModel = requestBodyEditor.getModel();
|
||||
if (!editorModel) return;
|
||||
|
||||
const word = editorModel.getWordAtPosition(position);
|
||||
const lineContent = editorModel.getLineContent(position.lineNumber);
|
||||
|
||||
if (word && lineContent.includes(`{{${word.word}}}`)) {
|
||||
parent?.showPopper?.(e.target.element, word.word);
|
||||
} else {
|
||||
parent?.hidePopper?.(e.target.element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setEditors(prev => ({ ...prev, requestBody: requestBodyEditor }));
|
||||
}
|
||||
|
||||
if (responseBodyRef.current && !editors.responseBody) {
|
||||
const responseBodyEditor = monaco.editor.create(responseBodyRef.current, {
|
||||
value: '',
|
||||
language: 'text',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false,
|
||||
readOnly: true
|
||||
});
|
||||
setEditors(prev => ({ ...prev, responseBody: responseBodyEditor }));
|
||||
}
|
||||
|
||||
if (consoleRef.current && !editors.console) {
|
||||
const consoleEditor = monaco.editor.create(consoleRef.current, {
|
||||
value: '',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false,
|
||||
readOnly: true
|
||||
});
|
||||
setEditors(prev => ({ ...prev, console: consoleEditor }));
|
||||
}
|
||||
|
||||
if (preRequestRef.current && !editors.preRequest) {
|
||||
const preRequestEditor = monaco.editor.create(preRequestRef.current, {
|
||||
value: model.preRequestScript || '',
|
||||
language: 'javascript',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false
|
||||
});
|
||||
|
||||
preRequestEditor.onDidChangeModelContent(() => {
|
||||
model.preRequestScript = preRequestEditor.getValue();
|
||||
});
|
||||
|
||||
setEditors(prev => ({ ...prev, preRequest: preRequestEditor }));
|
||||
}
|
||||
|
||||
if (postRequestRef.current && !editors.postRequest) {
|
||||
const postRequestEditor = monaco.editor.create(postRequestRef.current, {
|
||||
value: model.postRequestScript || '',
|
||||
language: 'javascript',
|
||||
automaticLayout: true,
|
||||
quickSuggestions: false
|
||||
});
|
||||
|
||||
postRequestEditor.onDidChangeModelContent(() => {
|
||||
model.postRequestScript = postRequestEditor.getValue();
|
||||
});
|
||||
|
||||
setEditors(prev => ({ ...prev, postRequest: postRequestEditor }));
|
||||
}
|
||||
|
||||
// Initialize layout data
|
||||
const layoutRoot = ApiLayoutItemTreeBuilder.createTree(model.layout.layoutItems);
|
||||
const replicator = new ApiLayoutItemTreeDataBuilder(10000);
|
||||
const layoutDataRoot = replicator.createReplicatedTree(layoutRoot);
|
||||
setLayoutData(layoutDataRoot);
|
||||
|
||||
return () => {
|
||||
Object.values(editors).forEach(editor => editor?.dispose());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle window resize
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
// Tab change handlers
|
||||
useEffect(() => {
|
||||
const handleTabChange = (tab) => {
|
||||
const currentEditor = {
|
||||
'requestBodyTab': editors.requestBody,
|
||||
'preRequestTab': editors.preRequest,
|
||||
'postRequestTab': editors.postRequest
|
||||
}[tab];
|
||||
|
||||
if (currentEditor) {
|
||||
currentEditor.layout();
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = ['requestBodyTab', 'preRequestTab', 'postRequestTab'];
|
||||
tabs.forEach(tab => {
|
||||
const element = document.querySelector(`#api_request_${model.id} .btn_${tab}`);
|
||||
if (element) {
|
||||
const handler = () => handleTabChange(tab);
|
||||
element.addEventListener('shown.bs.tab', handler);
|
||||
return () => element.removeEventListener('shown.bs.tab', handler);
|
||||
}
|
||||
});
|
||||
}, [editors, model.id]);
|
||||
|
||||
// Server options effect
|
||||
useEffect(() => {
|
||||
if (!model.server) return;
|
||||
|
||||
if (model.server.headers === undefined) {
|
||||
model.server.headers = [];
|
||||
}
|
||||
|
||||
if (model.server.serverOptions) {
|
||||
serverManager.renderTcpServerOptions(
|
||||
'.server_options',
|
||||
model.server.serverOptions,
|
||||
model.headers,
|
||||
parent
|
||||
);
|
||||
}
|
||||
}, [model.server, model.headers, parent, serverManager]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4">
|
||||
{/* Header Section */}
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{model.collectionName} > {model.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Server Selection */}
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<div className="form-floating">
|
||||
{serverManager.renderTcpServers(model.server)}
|
||||
<label className="text-sm text-muted-foreground">Server</label>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendRequest}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => parent?.handleSaveRequest?.(model)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Options */}
|
||||
<div className="server_options mb-4" />
|
||||
|
||||
{/* Main Content */}
|
||||
<Tabs defaultValue="layout" className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="layout">Layout</TabsTrigger>
|
||||
<TabsTrigger value="layoutJson">Layout JSON</TabsTrigger>
|
||||
<TabsTrigger value="layoutTree">Layout Tree</TabsTrigger>
|
||||
<TabsTrigger value="layoutData">Layout Data</TabsTrigger>
|
||||
<TabsTrigger value="preRequest">Pre-Request</TabsTrigger>
|
||||
<TabsTrigger value="postRequest">Post-Request</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="layout">
|
||||
<div ref={layoutGridRef}>
|
||||
<LayoutGrid
|
||||
layout={model.layout}
|
||||
onChange={(updatedLayout) => {
|
||||
model.layout = updatedLayout;
|
||||
editors.layoutJson?.setValue(JSON.stringify(updatedLayout, null, 2));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="layoutJson">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div ref={layoutJsonRef} className="h-[300px] border" />
|
||||
<Button
|
||||
onClick={handleLoadLayout}
|
||||
disabled={isLoading}
|
||||
>
|
||||
레이아웃 불러오기
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="layoutData">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="border h-[400px] overflow-auto">
|
||||
{layoutData && (
|
||||
<LayoutTreeDataGrid
|
||||
root={layoutData}
|
||||
apiRequest={model}
|
||||
options={layoutDataTreeOptions}
|
||||
totalMessageLengthSelector={totalMessageLengthRef.current}
|
||||
onUpdate={handleLayoutUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="preRequest">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<div ref={preRequestRef} className="h-[300px] border" />
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<VariableSnippet editor={editors.preRequest} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="postRequest">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<div ref={postRequestRef} className="h-[300px] border" />
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<VariableSnippet editor={editors.postRequest} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Message Length */}
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<span>전체 메시지 길이:</span>
|
||||
<span ref={totalMessageLengthRef} />
|
||||
</div>
|
||||
|
||||
{/* Response Section */}
|
||||
<Card className="mt-4">
|
||||
<CardContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>Response</span>
|
||||
<span className="response_time" />
|
||||
<span className="response_size" />
|
||||
</div>
|
||||
<Tabs defaultValue="body">
|
||||
<TabsList>
|
||||
<TabsTrigger value="body">Body</TabsTrigger>
|
||||
<TabsTrigger value="console">Console</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="body">
|
||||
<div ref={responseBodyRef} className="h-[250px] border" />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="console">
|
||||
<div ref={consoleRef} className="h-[250px] border" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// export default TCPClientView;
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
|
||||
const LayoutGrid = ({ layout, onChange }) => {
|
||||
const [items, setItems] = useState(layout?.layoutItems || []);
|
||||
|
||||
const getLastId = useCallback(() => {
|
||||
let lastId = 0;
|
||||
items.forEach(item => {
|
||||
if (item.id > lastId) {
|
||||
lastId = item.id;
|
||||
}
|
||||
});
|
||||
return lastId + 1;
|
||||
}, [items]);
|
||||
|
||||
const handleAddItem = useCallback(() => {
|
||||
const newItem = {
|
||||
id: getLastId(),
|
||||
parent: 0,
|
||||
level: 1,
|
||||
loutName: '',
|
||||
loutItemName: '',
|
||||
loutItemDesc: '',
|
||||
loutItemDepth: 1,
|
||||
loutItemType: 'Field',
|
||||
loutItemOccCnt: '',
|
||||
loutItemOccRef: '',
|
||||
loutItemDataType: 'String',
|
||||
loutItemLength: 1,
|
||||
loutItemDecimal: 0,
|
||||
loutItemDefault: '',
|
||||
loutItemMaskYn: 'N',
|
||||
loutItemMaskOffset: 0,
|
||||
loutItemMaskLength: 0,
|
||||
parentLoutItemIndex: 0,
|
||||
expanded: true,
|
||||
isLeaf: true,
|
||||
LOUTITEMPATH: '',
|
||||
value: '',
|
||||
loutItemSerno: items.length + 1
|
||||
};
|
||||
|
||||
const newItems = [...items, newItem];
|
||||
setItems(newItems);
|
||||
layout.layoutItems = newItems;
|
||||
onChange?.(layout);
|
||||
}, [items, layout, onChange, getLastId]);
|
||||
|
||||
const handleMove = useCallback((index, direction) => {
|
||||
if ((direction === 'up' && index > 0) ||
|
||||
(direction === 'down' && index < items.length - 1)) {
|
||||
const newItems = [...items];
|
||||
const swapIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
[newItems[index], newItems[swapIndex]] = [newItems[swapIndex], newItems[index]];
|
||||
|
||||
// Update loutItemSerno values
|
||||
newItems.forEach((item, idx) => {
|
||||
item.loutItemSerno = idx + 1;
|
||||
});
|
||||
|
||||
setItems(newItems);
|
||||
layout.layoutItems = newItems;
|
||||
onChange?.(layout);
|
||||
}
|
||||
}, [items, layout, onChange]);
|
||||
|
||||
const handleDelete = useCallback((index) => {
|
||||
const newItems = items.filter((_, idx) => idx !== index);
|
||||
|
||||
// Update loutItemSerno values
|
||||
newItems.forEach((item, idx) => {
|
||||
item.loutItemSerno = idx + 1;
|
||||
});
|
||||
|
||||
setItems(newItems);
|
||||
layout.layoutItems = newItems;
|
||||
onChange?.(layout);
|
||||
}, [items, layout, onChange]);
|
||||
|
||||
const handleFieldChange = useCallback((index, field, value) => {
|
||||
const newItems = [...items];
|
||||
newItems[index][field] = value;
|
||||
|
||||
if (field === 'value' && newItems[index].value === undefined) {
|
||||
newItems[index].value = '';
|
||||
}
|
||||
|
||||
setItems(newItems);
|
||||
layout.layoutItems = newItems;
|
||||
onChange?.(layout);
|
||||
}, [items, layout, onChange]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border p-2 text-center w-[50px]">#</th>
|
||||
<th className="border p-2 text-center w-[80px]">ID</th>
|
||||
<th className="border p-2 text-center w-[80px]">Parent</th>
|
||||
<th className="border p-2 text-center w-[120px]">항목명(영문)</th>
|
||||
<th className="border p-2 text-center w-[150px]">항목설명</th>
|
||||
<th className="border p-2 text-center w-[60px]">깊이</th>
|
||||
<th className="border p-2 text-center w-[110px]">아이템 유형</th>
|
||||
<th className="border p-2 text-center w-[80px]">반복 횟수</th>
|
||||
<th className="border p-2 text-center w-[100px]">반복 참조 필드</th>
|
||||
<th className="border p-2 text-center w-[80px]">데이터 길이</th>
|
||||
<th className="border p-2 text-center w-[120px]">값</th>
|
||||
<th className="border p-2 text-center w-[120px]">
|
||||
비고
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddItem}
|
||||
className="ml-2"
|
||||
>
|
||||
<i className="fas fa-plus" />
|
||||
</Button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id} className="layout-grid-row">
|
||||
<td className="border p-2 text-center">
|
||||
{item.loutItemSerno}
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.id}
|
||||
onChange={(e) => handleFieldChange(index, 'id', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.parent}
|
||||
onChange={(e) => handleFieldChange(index, 'parent', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.loutItemName}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemName', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.loutItemDesc}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemDesc', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.loutItemDepth}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemDepth', parseInt(e.target.value))}
|
||||
min={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Select
|
||||
value={item.loutItemType}
|
||||
onValueChange={(value) => handleFieldChange(index, 'loutItemType', value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="Field">Field</option>
|
||||
<option value="Grid">Grid</option>
|
||||
<option value="Group">Group</option>
|
||||
<option value="Attr">Attr</option>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.loutItemOccCnt}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemOccCnt', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={item.loutItemOccRef}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemOccRef', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={item.loutItemLength}
|
||||
onChange={(e) => handleFieldChange(index, 'loutItemLength', parseInt(e.target.value))}
|
||||
min={1}
|
||||
className="w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="border p-2">
|
||||
{item.loutItemType === 'Field' && (
|
||||
<EditableInput
|
||||
name={`items[${index}].value`}
|
||||
value={item.value}
|
||||
onChange={(value) => handleFieldChange(index, 'value', value)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="border p-2 text-center">
|
||||
<div className="flex justify-center space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMove(index, 'up')}
|
||||
>
|
||||
<i className="fas fa-arrow-up" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleMove(index, 'down')}
|
||||
>
|
||||
<i className="fas fa-arrow-down" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
>
|
||||
<i className="fas fa-trash-alt" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutGrid;
|
||||
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
import APIClient from '@/lib/api-client';
|
||||
import _ from 'lodash';
|
||||
|
||||
// Helper function to get indentation for tree visualization
|
||||
const getIndent = (depth) => {
|
||||
return Array(depth + 1).fill().map((_, i) => (
|
||||
<span key={i} className="treegrid-indent" />
|
||||
));
|
||||
};
|
||||
|
||||
const TreeNode = ({
|
||||
node,
|
||||
depth,
|
||||
options,
|
||||
isFirst,
|
||||
onExpand,
|
||||
expanded,
|
||||
onValueChange,
|
||||
getNodePath
|
||||
}) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
|
||||
const renderCell = (fieldInfo) => {
|
||||
const padding = fieldInfo.isFirst ? getIndent(depth) : null;
|
||||
const nodePath = getNodePath(node.id) + '.value';
|
||||
|
||||
if (node.loutItemType === 'Field' && fieldInfo.type === 'textfield') {
|
||||
return (
|
||||
<EditableInput
|
||||
name={fieldInfo.name}
|
||||
value={node[fieldInfo.name] || ''}
|
||||
onChange={(value) => onValueChange(nodePath, value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{padding}
|
||||
{fieldInfo.isFirst && hasChildren && (
|
||||
<button
|
||||
onClick={() => onExpand(node.id)}
|
||||
className="treegrid-expander w-4 h-4 flex items-center justify-center"
|
||||
>
|
||||
{expanded ? '-' : '+'}
|
||||
</button>
|
||||
)}
|
||||
{node[fieldInfo.name]}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className={`treegrid-${node.id} ${node.parent ? `treegrid-parent-${node.parent}` : ''}`}>
|
||||
{options.map((fieldInfo, index) => (
|
||||
<td
|
||||
key={fieldInfo.name}
|
||||
className={cn(
|
||||
"border p-2",
|
||||
fieldInfo.align === 'center' && "text-center",
|
||||
fieldInfo.align === 'right' && "text-right"
|
||||
)}
|
||||
style={{ width: fieldInfo.width }}
|
||||
>
|
||||
{renderCell(fieldInfo)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const LayoutTreeDataGrid = ({
|
||||
root,
|
||||
apiRequest,
|
||||
options,
|
||||
totalMessageLengthSelector,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set());
|
||||
const [apiClient] = useState(() => new APIClient());
|
||||
|
||||
const getNodePath = useCallback((targetId, node = root, path = '') => {
|
||||
if (node.id === targetId) return path;
|
||||
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const currentPath = path ? `${path}.children[${i}]` : `children[${i}]`;
|
||||
const resultPath = getNodePath(targetId, node.children[i], currentPath);
|
||||
if (resultPath) return resultPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [root]);
|
||||
|
||||
const handleExpand = useCallback((nodeId) => {
|
||||
setExpandedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(nodeId)) {
|
||||
next.delete(nodeId);
|
||||
} else {
|
||||
next.add(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fillPadding = useCallback(async (value, dataLength) => {
|
||||
let paddedValue = await apiClient.processPreScriptAndVariables(apiRequest, value.toString());
|
||||
while (paddedValue.length < dataLength) {
|
||||
paddedValue = ' ' + paddedValue;
|
||||
}
|
||||
return paddedValue;
|
||||
}, [apiRequest]);
|
||||
|
||||
const concatenateFieldValues = useCallback(async (node) => {
|
||||
let concatenatedValues = '';
|
||||
|
||||
if (node.loutItemType === 'Field') {
|
||||
concatenatedValues += await fillPadding(node.value || '', node.loutItemLength) || '';
|
||||
}
|
||||
|
||||
if (node.children?.length > 0) {
|
||||
for (const child of node.children) {
|
||||
concatenatedValues += await concatenateFieldValues(child);
|
||||
}
|
||||
}
|
||||
|
||||
return concatenatedValues;
|
||||
}, [fillPadding]);
|
||||
|
||||
const sumFieldLoutItemLengths = useCallback(() => {
|
||||
const queue = [root];
|
||||
let sum = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift();
|
||||
if (node.loutItemType === 'Field') {
|
||||
sum += parseInt(node.loutItemLength) || 0;
|
||||
}
|
||||
if (node.children?.length > 0) {
|
||||
queue.push(...node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
}, [root]);
|
||||
|
||||
const handleValueChange = useCallback(async (path, value) => {
|
||||
_.set(root, path, value);
|
||||
const finalMessage = await concatenateFieldValues(root);
|
||||
onUpdate?.(finalMessage, root);
|
||||
}, [root, concatenateFieldValues, onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (totalMessageLengthSelector) {
|
||||
totalMessageLengthSelector.textContent = sumFieldLoutItemLengths();
|
||||
}
|
||||
}, [root, sumFieldLoutItemLengths, totalMessageLengthSelector]);
|
||||
|
||||
const renderNodes = useCallback((node, depth = 0) => {
|
||||
const isExpanded = expandedNodes.has(node.id);
|
||||
const result = [];
|
||||
|
||||
result.push(
|
||||
<TreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={depth}
|
||||
options={options}
|
||||
isFirst={true}
|
||||
onExpand={handleExpand}
|
||||
expanded={isExpanded}
|
||||
onValueChange={handleValueChange}
|
||||
getNodePath={getNodePath}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isExpanded && node.children?.length > 0) {
|
||||
node.children.forEach(child => {
|
||||
result.push(...renderNodes(child, depth + 1));
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [expandedNodes, options, handleExpand, handleValueChange, getNodePath]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full border-collapse border">
|
||||
<thead>
|
||||
<tr>
|
||||
{options.map(field => (
|
||||
<th
|
||||
key={field.name}
|
||||
className="border p-2 text-center"
|
||||
style={{ width: field.width }}
|
||||
>
|
||||
{field.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderNodes(root)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutTreeDataGrid;
|
||||
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
|
||||
const LayoutTreeGrid = ({ root, options = [], onUpdate }) => {
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set());
|
||||
|
||||
// Helper function to get node path
|
||||
const getNodePath = useCallback((node, targetId, path = '') => {
|
||||
if (node.id === targetId) {
|
||||
return path;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const currentPath = path ? `${path}.children[${i}]` : `children[${i}]`;
|
||||
const resultPath = getNodePath(node.children[i], targetId, currentPath);
|
||||
if (resultPath) {
|
||||
return resultPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Get indentation for tree structure
|
||||
const getIndent = useCallback((depth) => {
|
||||
return Array(depth + 1).join(' ');
|
||||
}, []);
|
||||
|
||||
// Handle expand/collapse
|
||||
const handleExpanderClick = useCallback((nodeId) => {
|
||||
setExpandedNodes(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(nodeId)) {
|
||||
newSet.delete(nodeId);
|
||||
} else {
|
||||
newSet.add(nodeId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Render cell content
|
||||
const renderCell = useCallback((node, field, depth) => {
|
||||
const nodePath = getNodePath(root, node.id) + '.value';
|
||||
|
||||
if (node.loutItemType === 'Field' && field.type === 'textfield') {
|
||||
return (
|
||||
<EditableInput
|
||||
name={field.name}
|
||||
value={node[field.name]}
|
||||
path={nodePath}
|
||||
onChange={(value) => {
|
||||
node[field.name] = value;
|
||||
onUpdate?.(root);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.isFirst) {
|
||||
const hasChildren = node.children?.length > 0;
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span className="inline-block" style={{ width: `${depth * 20}px` }} />
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => handleExpanderClick(node.id)}
|
||||
className="w-4 h-4 flex items-center justify-center mr-1"
|
||||
>
|
||||
{expandedNodes.has(node.id) ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
<span>{node[field.name]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return node[field.name];
|
||||
}, [root, expandedNodes, handleExpanderClick, getNodePath, onUpdate]);
|
||||
|
||||
// Recursive function to render tree rows
|
||||
const renderTreeRows = useCallback((node, depth = 0, parentId = null) => {
|
||||
const rows = [];
|
||||
|
||||
const isVisible = depth === 0 || !parentId || expandedNodes.has(parentId);
|
||||
|
||||
if (!isVisible) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const row = (
|
||||
<tr
|
||||
key={node.id}
|
||||
className={cn(
|
||||
"layout-grid-row",
|
||||
depth === 0 && "hidden",
|
||||
parentId && `treegrid-parent-${parentId}`
|
||||
)}
|
||||
data-node-id={node.id}
|
||||
>
|
||||
{options.map((field, fieldIndex) => (
|
||||
<td
|
||||
key={fieldIndex}
|
||||
className={cn(
|
||||
"border p-2",
|
||||
field.align && `text-${field.align}`,
|
||||
"whitespace-nowrap"
|
||||
)}
|
||||
style={{ width: field.width }}
|
||||
>
|
||||
{renderCell(node, field, depth)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
|
||||
rows.push(row);
|
||||
|
||||
if (node.children?.length > 0) {
|
||||
node.children.forEach(child => {
|
||||
rows.push(...renderTreeRows(child, depth + 1, node.id));
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [options, expandedNodes, renderCell]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full border-collapse border">
|
||||
<thead>
|
||||
<tr>
|
||||
{options.map((field, index) => (
|
||||
<th
|
||||
key={index}
|
||||
className={cn(
|
||||
"border p-2",
|
||||
field.align && `text-${field.align}`,
|
||||
"whitespace-nowrap"
|
||||
)}
|
||||
style={{ width: field.width }}
|
||||
>
|
||||
{field.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{renderTreeRows(root)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutTreeGrid;
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const EditableInput = ({
|
||||
name = '',
|
||||
value = '',
|
||||
className = '',
|
||||
style = {},
|
||||
onChange = () => {},
|
||||
onVariableHover = () => {}
|
||||
}) => {
|
||||
const contentRef = useRef(null);
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
// Get current cursor position
|
||||
const saveSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel.rangeCount > 0) {
|
||||
const range = sel.getRangeAt(0);
|
||||
const preCaretRange = range.cloneRange();
|
||||
preCaretRange.selectNodeContents(contentRef.current);
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
||||
const start = preCaretRange.toString().length;
|
||||
|
||||
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
||||
const end = preCaretRange.toString().length;
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Restore cursor position
|
||||
const restoreSelection = (pos) => {
|
||||
if (!pos || !contentRef.current) return;
|
||||
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
let currentPos = 0;
|
||||
let startNode = null;
|
||||
let startOffset = 0;
|
||||
let endNode = null;
|
||||
let endOffset = 0;
|
||||
|
||||
const traverse = (node) => {
|
||||
if (node.nodeType === 3) { // Text node
|
||||
const nextPos = currentPos + node.length;
|
||||
|
||||
if (!startNode && pos.start >= currentPos && pos.start <= nextPos) {
|
||||
startNode = node;
|
||||
startOffset = pos.start - currentPos;
|
||||
}
|
||||
|
||||
if (!endNode && pos.end >= currentPos && pos.end <= nextPos) {
|
||||
endNode = node;
|
||||
endOffset = pos.end - currentPos;
|
||||
}
|
||||
|
||||
currentPos = nextPos;
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(contentRef.current);
|
||||
|
||||
if (startNode && endNode) {
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
};
|
||||
|
||||
// Parse content to find variables
|
||||
const parseContent = (text) => {
|
||||
const regex = /{{.*?}}/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: text.slice(lastIndex, match.index)
|
||||
});
|
||||
}
|
||||
parts.push({
|
||||
type: 'variable',
|
||||
content: match[0]
|
||||
});
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: text.slice(lastIndex)
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
};
|
||||
|
||||
const handleInput = (e) => {
|
||||
const newValue = e.target.textContent;
|
||||
const newSelection = saveSelection();
|
||||
|
||||
setInternalValue(newValue);
|
||||
setSelection(newSelection);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection.rangeCount > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
|
||||
const processedContent = parseContent(text);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
processedContent.forEach(({ type, content }) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = content;
|
||||
if (type === 'variable') {
|
||||
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
|
||||
}
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
|
||||
range.insertNode(fragment);
|
||||
handleInput({ target: contentRef.current });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return;
|
||||
|
||||
const parts = parseContent(internalValue);
|
||||
const currentSelection = saveSelection() || selection;
|
||||
|
||||
contentRef.current.innerHTML = '';
|
||||
|
||||
parts.forEach(({ type, content }) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = content;
|
||||
|
||||
if (type === 'variable') {
|
||||
span.className = 'bg-blue-100 text-blue-800 rounded px-1';
|
||||
span.onmouseover = () => onVariableHover(content);
|
||||
}
|
||||
|
||||
contentRef.current.appendChild(span);
|
||||
});
|
||||
|
||||
restoreSelection(currentSelection);
|
||||
}, [internalValue]);
|
||||
|
||||
// Update internal value when prop value changes
|
||||
useEffect(() => {
|
||||
if (value !== internalValue) {
|
||||
setInternalValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={contentRef}
|
||||
contentEditable
|
||||
className={cn(
|
||||
"min-h-[40px] rounded-md border border-input bg-background px-3 py-2",
|
||||
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"transition-colors",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
data-value={internalValue}
|
||||
data-name={name}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableInput;
|
||||
@@ -0,0 +1,33 @@
|
||||
// /components/ui/error-alert-dialog.jsx
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const ErrorAlertDialog = ({ open, onOpenChange, title, description }) => {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title || '오류'}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => onOpenChange(false)}>
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorAlertDialog;
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useEffect, useRef, memo } from 'react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
|
||||
const MonacoEditor = memo(({
|
||||
id,
|
||||
value = '',
|
||||
language = 'javascript',
|
||||
readOnly = false,
|
||||
height = '64',
|
||||
onChange = () => {}
|
||||
}) => {
|
||||
const editorRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const subscriptionRef = useRef(null);
|
||||
|
||||
// Configure editor options based on language
|
||||
const getEditorOptions = (lang) => {
|
||||
const baseOptions = {
|
||||
automaticLayout: true,
|
||||
theme: 'vs-light',
|
||||
minimap: { enabled: false },
|
||||
readOnly,
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
tabSize: 2,
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto'
|
||||
}
|
||||
};
|
||||
|
||||
// Language-specific options
|
||||
switch (lang) {
|
||||
case 'json':
|
||||
return {
|
||||
...baseOptions,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
autoClosingBrackets: 'always',
|
||||
autoClosingQuotes: 'always',
|
||||
bracketPairColorization: { enabled: true }
|
||||
};
|
||||
case 'xml':
|
||||
return {
|
||||
...baseOptions,
|
||||
formatOnType: true,
|
||||
autoClosingTags: true,
|
||||
autoClosingBrackets: 'always',
|
||||
autoClosingQuotes: 'always'
|
||||
};
|
||||
default:
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize or update editor
|
||||
const setupEditor = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
// Dispose existing editor if it exists
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispose();
|
||||
}
|
||||
|
||||
// Create new editor with language-specific options
|
||||
const options = getEditorOptions(language);
|
||||
editorRef.current = monaco.editor.create(containerRef.current, {
|
||||
value,
|
||||
language,
|
||||
...options
|
||||
});
|
||||
|
||||
// Set up change event handler
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current.dispose();
|
||||
}
|
||||
|
||||
subscriptionRef.current = editorRef.current.onDidChangeModelContent(() => {
|
||||
const newValue = editorRef.current.getValue();
|
||||
onChange(newValue);
|
||||
});
|
||||
|
||||
// Format document if it's JSON
|
||||
if (language === 'json' && value) {
|
||||
try {
|
||||
const modelUri = monaco.Uri.parse(`inmemory://model-${id}.json`);
|
||||
const model = monaco.editor.createModel(value, 'json', modelUri);
|
||||
setTimeout(() => {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: false,
|
||||
schemas: []
|
||||
});
|
||||
editorRef.current.setModel(model);
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
console.warn('Failed to format JSON:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
useEffect(() => {
|
||||
setupEditor();
|
||||
|
||||
// Set up resize observer
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.layout();
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current.dispose();
|
||||
}
|
||||
if (editorRef.current) {
|
||||
editorRef.current.dispose();
|
||||
}
|
||||
};
|
||||
}, [language]); // Reinitialize when language changes
|
||||
|
||||
// Update value if it changes externally
|
||||
useEffect(() => {
|
||||
if (editorRef.current && value !== editorRef.current.getValue()) {
|
||||
editorRef.current.setValue(value);
|
||||
|
||||
// Add formatting for JSON
|
||||
if (language === 'json' && value) {
|
||||
try {
|
||||
const action = editorRef.current.getAction('editor.action.formatDocument');
|
||||
if (action) {
|
||||
action.run();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to format JSON:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [value, language]);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
ref={containerRef}
|
||||
className={`h-${height} border mt-2`}
|
||||
style={{ minHeight: '100px' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
MonacoEditor.displayName = 'MonacoEditor';
|
||||
|
||||
export default MonacoEditor;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const PropertyTable = ({
|
||||
properties = [],
|
||||
onChange,
|
||||
propertyName = ''
|
||||
}) => {
|
||||
const [localProperties, setLocalProperties] = useState(properties);
|
||||
|
||||
// Handle property changes without causing infinite loops
|
||||
const handlePropertyChange = (index, field, value) => {
|
||||
const updatedProperties = localProperties.map((prop, i) => {
|
||||
if (i === index) {
|
||||
return { ...prop, [field]: value };
|
||||
}
|
||||
return prop;
|
||||
});
|
||||
setLocalProperties(updatedProperties);
|
||||
onChange?.(updatedProperties);
|
||||
};
|
||||
|
||||
// Add new property
|
||||
const handleAdd = () => {
|
||||
const newProperty = {
|
||||
enabled: true,
|
||||
key: '',
|
||||
value: ''
|
||||
};
|
||||
const updatedProperties = [...localProperties, newProperty];
|
||||
setLocalProperties(updatedProperties);
|
||||
onChange?.(updatedProperties);
|
||||
};
|
||||
|
||||
// Remove property
|
||||
const handleRemove = (index) => {
|
||||
const updatedProperties = localProperties.filter((_, i) => i !== index);
|
||||
setLocalProperties(updatedProperties);
|
||||
onChange?.(updatedProperties);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-medium">{propertyName}</h3>
|
||||
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-14">Enable</TableHead>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead className="w-14">
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-20"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{localProperties.map((property, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="">
|
||||
<Checkbox
|
||||
checked={property.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePropertyChange(index, 'enabled', checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={property.key}
|
||||
onChange={(e) =>
|
||||
handlePropertyChange(index, 'key', e.target.value)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={property.value}
|
||||
onChange={(e) =>
|
||||
handlePropertyChange(index, 'value', e.target.value)
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(index)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyTable;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Predefined commands/snippets
|
||||
const commands = [
|
||||
{
|
||||
name: 'set_global',
|
||||
displayName: 'Set Global Variable',
|
||||
text: 'globals["key"] = "value";\n'
|
||||
},
|
||||
{
|
||||
name: 'get_global',
|
||||
displayName: 'Get Global Variable',
|
||||
text: 'const value = globals["key"];\n'
|
||||
},
|
||||
{
|
||||
name: 'set_variable',
|
||||
displayName: 'Set Variable',
|
||||
text: 'variables["key"] = "value";\n'
|
||||
},
|
||||
{
|
||||
name: 'get_variable',
|
||||
displayName: 'Get Variable',
|
||||
text: 'const value = variables["key"];\n'
|
||||
},
|
||||
{
|
||||
name: 'get_response_body',
|
||||
displayName: 'Get Response Body',
|
||||
text: 'const body = response.body;\n'
|
||||
},
|
||||
{
|
||||
name: 'parse_response_body',
|
||||
displayName: 'Parse Response Body',
|
||||
text: 'JSON.parse(response.body);\n'
|
||||
},
|
||||
{
|
||||
name: 'get_response_headers',
|
||||
displayName: 'Get Response Header',
|
||||
text: 'const header_value = response.headers["header-key"];\n'
|
||||
}
|
||||
];
|
||||
|
||||
const VariableSnippet = ({ editor }) => {
|
||||
const appendTextToEditor = (text) => {
|
||||
if (!editor) return;
|
||||
|
||||
const model = editor.getModel();
|
||||
const position = editor.getPosition();
|
||||
editor.executeEdits('', [{
|
||||
range: new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
position.lineNumber,
|
||||
position.column
|
||||
),
|
||||
text: text,
|
||||
forceMoveMarkers: true
|
||||
}]);
|
||||
editor.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{commands.map((command) => (
|
||||
<Button
|
||||
key={command.name}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="justify-start h-8 text-sm px-2"
|
||||
onClick={() => appendTextToEditor(command.text)}
|
||||
>
|
||||
{command.displayName}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableSnippet;
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref} />
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props} />
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props} />
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
(<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
(<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu
|
||||
|
||||
const MenubarGroup = MenubarPrimitive.Group
|
||||
|
||||
const MenubarPortal = MenubarPrimitive.Portal
|
||||
|
||||
const MenubarSub = MenubarPrimitive.Sub
|
||||
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
||||
|
||||
const Menubar = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
||||
|
||||
const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
))
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
||||
|
||||
const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
||||
|
||||
const MenubarContent = React.forwardRef((
|
||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
||||
ref
|
||||
) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</MenubarPrimitive.Portal>
|
||||
))
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
||||
|
||||
const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
))
|
||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
||||
|
||||
const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
))
|
||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
||||
|
||||
const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
||||
|
||||
const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
(<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
MenubarShortcut.displayname = "MenubarShortcut"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||
{...props}>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn("p-1", position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props} />
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef((
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref} />
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
<SheetPrimitive.Close
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-2 text-center sm:text-left", className)}
|
||||
{...props} />
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props} />
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
const SidebarContext = React.createContext(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef((
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback((value) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}, [setOpenProp, open])
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo(() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
|
||||
|
||||
return (
|
||||
(<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style
|
||||
}
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>)
|
||||
);
|
||||
})
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef((
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
(<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>)
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
(<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
|
||||
}
|
||||
}
|
||||
side={side}>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
className="group peer hidden md:block text-sidebar-foreground"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)} />
|
||||
<div
|
||||
className={cn(
|
||||
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
(<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>)
|
||||
);
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
(<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
(<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
(<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props} />
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props} />
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props} />
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef((
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props} />
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
(<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip} />
|
||||
</Tooltip>)
|
||||
);
|
||||
})
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
(<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, [])
|
||||
|
||||
return (
|
||||
(<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
||||
{...props}>
|
||||
{showIcon && (
|
||||
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 flex-1 max-w-[--skeleton-width]"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width
|
||||
}
|
||||
} />
|
||||
</div>)
|
||||
);
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef(
|
||||
({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
(<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
)
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
(<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props} />)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props} />
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,18 @@
|
||||
export const monacoConfig = {
|
||||
getWorkerUrl: function(moduleId, label) {
|
||||
const contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
|
||||
const basePath = contextPath.endsWith('/') ? contextPath : `${contextPath}/`;
|
||||
|
||||
const workers = {
|
||||
json: 'json.worker.bundle.js',
|
||||
css: 'css.worker.bundle.js',
|
||||
html: 'html.worker.bundle.js',
|
||||
typescript: 'ts.worker.bundle.js',
|
||||
javascript: 'ts.worker.bundle.js',
|
||||
default: 'editor.worker.bundle.js'
|
||||
};
|
||||
|
||||
const workerPath = workers[label] || workers.default;
|
||||
return `${basePath}plugins/apitestmanager/${workerPath}`;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
|
||||
export const useMonaco = ({
|
||||
defaultValue = '',
|
||||
language = 'javascript',
|
||||
readOnly = false,
|
||||
automaticLayout = true,
|
||||
quickSuggestions = false
|
||||
}) => {
|
||||
const [editor, setEditor] = useState(null);
|
||||
const containerRef = useRef(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const instance = monaco.editor.create(containerRef.current, {
|
||||
value: defaultValue,
|
||||
language,
|
||||
automaticLayout,
|
||||
quickSuggestions,
|
||||
readOnly,
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
theme: 'vs-dark'
|
||||
});
|
||||
|
||||
setEditor(instance);
|
||||
|
||||
instance.onDidChangeModelContent(() => {
|
||||
setValue(instance.getValue());
|
||||
});
|
||||
|
||||
const resizeHandler = () => {
|
||||
// Monaco editor sometimes needs a manual resize trigger
|
||||
instance.layout({
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
instance.layout();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
instance.dispose();
|
||||
};
|
||||
}, [defaultValue, language, automaticLayout, quickSuggestions, readOnly]);
|
||||
|
||||
// Sync external value changes
|
||||
useEffect(() => {
|
||||
if (editor && defaultValue !== value) {
|
||||
editor.setValue(defaultValue);
|
||||
}
|
||||
}, [defaultValue, editor]);
|
||||
|
||||
return {
|
||||
editor,
|
||||
containerRef,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
export default useMonaco;
|
||||
@@ -0,0 +1,171 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ... */
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu-action {
|
||||
padding: 0; /* 내부 여백 제거 */
|
||||
margin: 0; /* 외부 여백 제거 */
|
||||
height: auto; /* 높이 자동화 */
|
||||
display: flex;
|
||||
align-items: center; /* 세로 중앙 정렬 */
|
||||
justify-content: center; /* 가로 중앙 정렬 */
|
||||
}
|
||||
|
||||
.sidebar-menu-sub-button {
|
||||
flex: 1; /* 남은 공간을 채움 */
|
||||
display: flex;
|
||||
justify-content: start; /* 왼쪽 정렬 */
|
||||
gap: 0.5rem; /* 아이콘과 텍스트 간 여백 */
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
class ApiLayoutItemTreeBuilder {
|
||||
static createTree(layoutItems) {
|
||||
layoutItems.sort((a, b) => a.loutItemSerno - b.loutItemSerno);
|
||||
const rootId = layoutItems.length === 0 ? 0 : parseInt(layoutItems[0].parent);
|
||||
const root = {id: rootId, children: [], loutItemSerno: 0, loutItemName: 'Root', loutItemDesc: 'Root', loutItemType: '', loutItemDepth: 0, parent: null};
|
||||
|
||||
const nodeMap = new Map();
|
||||
nodeMap.set(rootId, root);
|
||||
|
||||
for (const item of layoutItems) {
|
||||
if (['Grid', 'Group'].includes(item.loutItemType)) {
|
||||
if (item.loutItemOccCnt !== null) {
|
||||
if (item.loutItemOccCnt === '*') {
|
||||
let refItem = layoutItems.find(p => p.loutItemName === item.loutItemOccRef);
|
||||
if (refItem) {
|
||||
item.childOccCnt = parseInt(refItem.value, 10);
|
||||
}
|
||||
} else {
|
||||
item.childOccCnt = parseInt(item.loutItemOccCnt, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodeMap.has(parseInt(item.parent))) {
|
||||
nodeMap.get(parseInt(item.parent)).children.push(item);
|
||||
// console.log(item.id + ' to ' + item.parent);
|
||||
} else {
|
||||
console.log('Parent not found: ' + item.parent);
|
||||
}
|
||||
|
||||
nodeMap.set(item.id, item);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
static moveNode(root, node, moveUp) {
|
||||
const parent = this.getParentNode(root, node);
|
||||
if (parent === null) {
|
||||
// Node is the root node, cannot be moved
|
||||
return;
|
||||
}
|
||||
|
||||
const siblings = parent.children;
|
||||
const currentIndex = siblings.indexOf(node);
|
||||
if (currentIndex === -1) {
|
||||
// Node not found in parent's children list
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = moveUp ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex >= 0 && newIndex < siblings.length) {
|
||||
// Swap the positions of the current node and the node at the new index
|
||||
[siblings[currentIndex], siblings[newIndex]] = [siblings[newIndex], siblings[currentIndex]];
|
||||
}
|
||||
}
|
||||
|
||||
static getParentNode(root, node) {
|
||||
if (node.parent === null) {
|
||||
return null; // Node is the root node
|
||||
}
|
||||
|
||||
return this.getNodeById(root, node.parent);
|
||||
}
|
||||
|
||||
static getNodeIndex(parent, node) {
|
||||
return parent.children.indexOf(node);
|
||||
}
|
||||
|
||||
static getNodeById(node, id) {
|
||||
if (node.id == id) {
|
||||
return node;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const found = this.getNodeById(child, id);
|
||||
if (found !== null) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static findParent(root, node) {
|
||||
if (root === null || node === null) {
|
||||
return null; // Invalid input
|
||||
}
|
||||
|
||||
if (root.children.includes(node)) {
|
||||
return root; // The root is the parent of the node
|
||||
}
|
||||
|
||||
for (const child of root.children) {
|
||||
const parent = this.findParent(child, node);
|
||||
if (parent !== null) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
|
||||
return null; // Node not found in the tree
|
||||
}
|
||||
|
||||
static printTree(root) {
|
||||
console.log('====================================');
|
||||
this.printNode(root, 0);
|
||||
console.log('====================================');
|
||||
}
|
||||
|
||||
static printNode(node, level) {
|
||||
if (node === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Print indentation based on the level
|
||||
let indent = '';
|
||||
for (let i = 0; i < level; i++) {
|
||||
indent += ' ';
|
||||
}
|
||||
|
||||
// Print the node information
|
||||
console.log(`${indent}${node.id}: ${node.loutName}[${node.loutItemType}, Level: ${node.loutItemDepth}, Serno: ${node.loutItemSerno}]`);
|
||||
|
||||
// Recursively print the children
|
||||
if (node.children.length > 0) {
|
||||
for (const child of node.children) {
|
||||
this.printNode(child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static moveNodeDepth(root, node, newDepth) {
|
||||
if (node === null || root === null || newDepth < 0) {
|
||||
// Invalid input
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = this.getParentNode(root, node);
|
||||
if (parent === null) {
|
||||
// Node is the root node, cannot be moved to a different level
|
||||
return;
|
||||
}
|
||||
|
||||
const currentDepth = node.loutItemDepth;
|
||||
const nodeIndex = this.getNodeIndex(parent, node);
|
||||
const nextSibling = this.findClosestSibling(parent.children, nodeIndex);
|
||||
parent.children.splice(nodeIndex, 1);
|
||||
|
||||
if (newDepth > currentDepth) {
|
||||
// Moving to a higher depth
|
||||
if (nextSibling !== null) {
|
||||
nextSibling.children.push(node);
|
||||
node.parent = nextSibling.id;
|
||||
node.loutItemDepth = newDepth;
|
||||
this.adjustChildrenDepth(node, newDepth + 1);
|
||||
} else {
|
||||
// No next sibling found, add the node back to the original parent
|
||||
parent.children.push(node);
|
||||
}
|
||||
} else {
|
||||
// Moving to a lower depth
|
||||
const newParent = this.findParent(root, parent);
|
||||
if (newParent !== null) {
|
||||
newParent.children.push(node);
|
||||
node.parent = newParent.id;
|
||||
node.loutItemDepth = newDepth;
|
||||
this.adjustChildrenDepth(node, newDepth + 1);
|
||||
} else {
|
||||
// No parent found at the desired level, add the node back to the original parent
|
||||
parent.children.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static adjustChildrenDepth(node, depth) {
|
||||
for (const child of node.children) {
|
||||
child.loutItemDepth = depth;
|
||||
this.adjustChildrenDepth(child, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
static findNextSibling(siblings) {
|
||||
if (siblings === null || siblings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return siblings[0];
|
||||
}
|
||||
|
||||
static findClosestSibling(siblings, nodeIndex) {
|
||||
if (siblings === null || siblings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (nodeIndex < 1 || nodeIndex >= siblings.length) {
|
||||
return this.findNextSibling(siblings);
|
||||
}
|
||||
return siblings[nodeIndex - 1];
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiLayoutItemTreeBuilder;
|
||||
@@ -0,0 +1,51 @@
|
||||
class ApiLayoutItemTreeDataBuilder {
|
||||
constructor(startId = 10000) {
|
||||
this.idCounter = this.generateId(startId);
|
||||
this.nodeMap = new Map();
|
||||
}
|
||||
|
||||
* generateId(start) {
|
||||
let id = start;
|
||||
while (true) {
|
||||
yield id++;
|
||||
}
|
||||
}
|
||||
|
||||
cloneNode(node, parent) {
|
||||
|
||||
// Create a shallow copy of the node, except for the children array
|
||||
let newNode = {
|
||||
...node,
|
||||
id: this.idCounter.next().value, // Update with a new unique ID
|
||||
parent: parent ? parent.id : undefined // Set the new parent's ID if parent exists
|
||||
};
|
||||
|
||||
// Remove children property to handle it separately
|
||||
delete newNode.children;
|
||||
this.nodeMap.set(newNode.id, newNode);
|
||||
return newNode;
|
||||
}
|
||||
|
||||
replicateTree(node, parent = null) {
|
||||
const newNode = this.cloneNode(node, parent);
|
||||
newNode.children = []; // Initialize children array
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(child => {
|
||||
const childCount = node.childOccCnt || 1;
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
const childReplicas = this.replicateTree(child, newNode);
|
||||
newNode.children = newNode.children.concat(childReplicas);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [newNode]; // Return an array containing only the new singular parent node with replicated children
|
||||
}
|
||||
|
||||
createReplicatedTree(originalTree) {
|
||||
// console.log({originalTree});
|
||||
return this.replicateTree(originalTree)[0]; // Return the first element to maintain the tree structure
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiLayoutItemTreeDataBuilder;
|
||||
@@ -0,0 +1,272 @@
|
||||
export const fillPadding = async (apiRequest, value, dataLength) => {
|
||||
let paddedValue = value; // In real app, this would be processed by APIClient
|
||||
while (paddedValue.length < dataLength) {
|
||||
paddedValue = ' ' + paddedValue;
|
||||
}
|
||||
return paddedValue;
|
||||
};
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
// Initialize global variables store
|
||||
window.globals = window.globals || {};
|
||||
this.abortController = null;
|
||||
this.contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
|
||||
|
||||
if (!this.contextPath.endsWith('/')) {
|
||||
this.contextPath += '/';
|
||||
}
|
||||
}
|
||||
|
||||
getClientUrl() {
|
||||
return `${this.contextPath}mgmt/api/test.do`;
|
||||
}
|
||||
|
||||
// Format response for consistency
|
||||
formatResponse(data) {
|
||||
return {
|
||||
body: data.body,
|
||||
status: data.status,
|
||||
headers: data.headers.reduce((acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
size: data.size
|
||||
};
|
||||
}
|
||||
|
||||
// Abort ongoing requests
|
||||
abort() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the actual API request
|
||||
async makeAjaxRequest(processedRequest) {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.getClientUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(processedRequest),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Handle abort silently
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process pre-script and variables
|
||||
async processPreScriptAndVariables(model, value, consoleOutput) {
|
||||
const variables = {};
|
||||
const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
|
||||
return this.replacePlaceholderValueWithVariables(updatedModel, value, variables);
|
||||
}
|
||||
|
||||
// Replace placeholder values with actual variables
|
||||
replacePlaceholderValueWithVariables(model, originalValue, variables = {}) {
|
||||
const mergedVariables = {
|
||||
...window.globals,
|
||||
...variables
|
||||
};
|
||||
|
||||
return Object.entries(mergedVariables).reduce((value, [key, replacement]) => {
|
||||
const placeholder = `{{${key}}}`;
|
||||
return value.replace(new RegExp(placeholder, 'g'), replacement);
|
||||
}, originalValue);
|
||||
}
|
||||
|
||||
// Main method to send API requests
|
||||
async sendAPIRequest(model, preProcessCallback, consoleOutput) {
|
||||
try {
|
||||
// Execute pre-request script and get variables
|
||||
const variables = {};
|
||||
const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
|
||||
|
||||
// Process request with variables
|
||||
const processedRequest = this.replacePlaceholdersWithVariables(updatedModel, variables);
|
||||
|
||||
// Allow pre-processing if callback provided
|
||||
if (preProcessCallback) {
|
||||
preProcessCallback(processedRequest);
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const response = await this.makeAjaxRequest(processedRequest, consoleOutput);
|
||||
if (!response) return null;
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.error || 'Unknown error');
|
||||
}
|
||||
|
||||
// Format response
|
||||
const finalResponse = this.formatResponse(responseData);
|
||||
|
||||
// Execute post-request script
|
||||
return this.executePostRequestScript(
|
||||
model.postRequestScript,
|
||||
variables,
|
||||
processedRequest,
|
||||
finalResponse,
|
||||
consoleOutput
|
||||
).catch(error => {
|
||||
console.error('Post-request script error:', error);
|
||||
return finalResponse;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`API 호출 중 오류가 발생했습니다. 사유[${error.message}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace placeholders in request with variables
|
||||
replacePlaceholdersWithVariables(original, variables = {}) {
|
||||
const request = JSON.parse(JSON.stringify(original));
|
||||
const mergedVariables = {
|
||||
...window.globals,
|
||||
...variables
|
||||
};
|
||||
|
||||
Object.entries(mergedVariables).forEach(([key, value]) => {
|
||||
const placeholder = new RegExp(`{{${key}}}`, 'g');
|
||||
|
||||
// Replace in path
|
||||
request.path = request.path.replace(placeholder, value);
|
||||
|
||||
// Replace in request body
|
||||
if (request.requestBody) {
|
||||
request.requestBody = request.requestBody.replace(placeholder, value);
|
||||
}
|
||||
|
||||
// Replace in headers
|
||||
if (request.headers?.length) {
|
||||
request.headers = JSON.parse(
|
||||
JSON.stringify(request.headers).replace(placeholder, value)
|
||||
);
|
||||
}
|
||||
|
||||
// Replace in query params
|
||||
if (request.queryParams?.length) {
|
||||
request.queryParams = JSON.parse(
|
||||
JSON.stringify(request.queryParams).replace(placeholder, value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
// Execute pre-request script in an iframe
|
||||
executePreRequestScript(model, variables, consoleOutput) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resultFrame = document.getElementById('preRequest');
|
||||
if (!resultFrame) {
|
||||
console.warn('preRequest iframe not found');
|
||||
resolve(model);
|
||||
return;
|
||||
}
|
||||
|
||||
window.preRequestComplete = (updatedModel, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(updatedModel);
|
||||
}
|
||||
};
|
||||
|
||||
window.temp_variable = variables;
|
||||
window.currentRequest = model;
|
||||
window.consoleOutput = consoleOutput;
|
||||
|
||||
resultFrame.srcdoc = `
|
||||
<script>
|
||||
var oldLog = console.log;
|
||||
console.log = function(message) {
|
||||
if(window.parent.consoleOutput)
|
||||
window.parent.consoleOutput(message);
|
||||
oldLog.apply(console, arguments);
|
||||
};
|
||||
|
||||
window.globals = window.parent.globals;
|
||||
window.variables = window.parent.temp_variable;
|
||||
window.request = window.parent.currentRequest;
|
||||
|
||||
try {
|
||||
${model.preRequestScript || ''}
|
||||
window.parent.preRequestComplete(window.request, null);
|
||||
} catch (error) {
|
||||
window.parent.preRequestComplete(null, error);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// Execute post-request script in an iframe
|
||||
executePostRequestScript(script, variables, request, response, consoleOutput) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resultFrame = document.getElementById('postRequest');
|
||||
if (!resultFrame) {
|
||||
console.warn('postRequest iframe not found');
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
window.request = request;
|
||||
window.response = response;
|
||||
window.temp_variable = variables;
|
||||
window.postRequestComplete = (response, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
};
|
||||
window.consoleOutput = consoleOutput;
|
||||
|
||||
resultFrame.srcdoc = `
|
||||
<script>
|
||||
var oldLog = console.log;
|
||||
console.log = function(message) {
|
||||
if(window.parent.consoleOutput)
|
||||
window.parent.consoleOutput(message);
|
||||
oldLog.apply(console, arguments);
|
||||
};
|
||||
|
||||
window.globals = window.parent.globals;
|
||||
window.variables = window.parent.temp_variable;
|
||||
window.request = window.parent.request;
|
||||
window.response = window.parent.response;
|
||||
|
||||
try {
|
||||
${script || ''}
|
||||
window.parent.postRequestComplete(window.response, null);
|
||||
} catch (error) {
|
||||
window.parent.postRequestComplete(window.response, error.toString());
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default APIClient;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const basePath = (servers, serverId) => {
|
||||
if (!serverId) return '';
|
||||
return servers.find(server => server.id == serverId)?.basePath || '';
|
||||
};
|
||||
|
||||
export const getServer = (servers, serverId) => {
|
||||
return servers.find(server => server.id == serverId);
|
||||
};
|
||||
|
||||
export const getHeader = (headers = [], key) => {
|
||||
const header = headers.find(header => header.key === key);
|
||||
if (!header) {
|
||||
const newHeader = { key, value: '', enabled: true };
|
||||
headers.push(newHeader);
|
||||
return newHeader;
|
||||
}
|
||||
return header;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, {createContext, useState, useContext, useCallback, useEffect} from 'react';
|
||||
import APIClient from '../lib/api-client';
|
||||
import {collectionService} from '@/services/collectionService';
|
||||
import {apiRequestService} from '@/services/apiRequestService';
|
||||
import {serverService} from '@/services/serverService';
|
||||
import {authService} from '@/services/authService';
|
||||
|
||||
const APIContext = createContext(null);
|
||||
|
||||
export const useAPI = () => {
|
||||
const context = useContext(APIContext);
|
||||
if (!context) {
|
||||
throw new Error('useAPI must be used within an APIProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const APIProvider = ({
|
||||
children,
|
||||
contextPath = '/'
|
||||
}) => {
|
||||
const [apiClient] = useState(() => new APIClient());
|
||||
const [collections, setCollections] = useState([]);
|
||||
const [apiRequests, setApiRequests] = useState([]);
|
||||
const [servers, setServers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Initialize services
|
||||
const collections$ = collectionService(contextPath);
|
||||
const apiRequests$ = apiRequestService(contextPath);
|
||||
const servers$ = serverService();
|
||||
const auth$ = authService();
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
setIsAuthenticated(false);
|
||||
setCollections([]);
|
||||
setApiRequests([]);
|
||||
setServers([]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadServers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const validatedServers = await servers$.loadServers();
|
||||
setServers(validatedServers);
|
||||
console.log({validatedServers});
|
||||
return validatedServers;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.log({err});
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getServer = useCallback((serverId) => {
|
||||
const server = servers.find(server => server.id === serverId);
|
||||
console.log(server);
|
||||
return server;
|
||||
}, [servers]);
|
||||
|
||||
const loadCollections = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const processedCollections = await collections$.loadCollections();
|
||||
setCollections(processedCollections);
|
||||
return processedCollections;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createCollection = useCallback(async (name) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await collections$.createCollection(name);
|
||||
const updatedCollections = await collections$.loadCollections();
|
||||
setCollections(updatedCollections);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateCollection = useCallback(async (id, name) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await collections$.updateCollection(id, name);
|
||||
setCollections(prev =>
|
||||
prev.map(collection =>
|
||||
collection.id === id ? {...collection, name} : collection
|
||||
)
|
||||
);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteCollection = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await collections$.deleteCollection(id);
|
||||
setCollections(prev => prev.filter(collection => collection.id !== id));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createAPIRequest = useCallback(async (collectionId, apiData) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newApiRequest = await apiRequests$.createAPIRequest(collectionId, apiData);
|
||||
|
||||
setCollections(prev =>
|
||||
prev.map(collection =>
|
||||
collection.id === collectionId
|
||||
? {...collection, apis: [...(collection.apis || []), newApiRequest]}
|
||||
: collection
|
||||
)
|
||||
);
|
||||
|
||||
setApiRequests(prev => [...prev, newApiRequest]);
|
||||
return newApiRequest;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateAPIRequest = useCallback(async (apiRequest) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiRequests$.updateAPIRequest(apiRequest);
|
||||
|
||||
setCollections(prev =>
|
||||
prev.map(collection => {
|
||||
if (collection.id === apiRequest.collectionId) {
|
||||
return {
|
||||
...collection,
|
||||
apis: collection.apis.map(api =>
|
||||
api.id === apiRequest.id ? apiRequest : api
|
||||
)
|
||||
};
|
||||
}
|
||||
return collection;
|
||||
})
|
||||
);
|
||||
|
||||
setApiRequests(prev =>
|
||||
prev.map(req => req.id === apiRequest.id ? apiRequest : req)
|
||||
);
|
||||
|
||||
return apiRequest;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteAPIRequest = useCallback(async (collectionId, apiId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiRequests$.deleteAPIRequest(collectionId, apiId);
|
||||
|
||||
setCollections(prev =>
|
||||
prev.map(collection => {
|
||||
if (collection.id === collectionId) {
|
||||
return {
|
||||
...collection,
|
||||
apis: collection.apis.filter(api => api.id !== apiId)
|
||||
};
|
||||
}
|
||||
return collection;
|
||||
})
|
||||
);
|
||||
|
||||
setApiRequests(prev => prev.filter(req => req.id !== apiId));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendAPIRequest = useCallback(async (apiRequest, preProcessCallback, consoleOutput) => {
|
||||
try {
|
||||
return await apiClient.sendAPIRequest(apiRequest, preProcessCallback, consoleOutput);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
}
|
||||
}, [apiClient]);
|
||||
|
||||
const contextValue = {
|
||||
apiClient,
|
||||
collections,
|
||||
apiRequests,
|
||||
servers,
|
||||
loadServers,
|
||||
getServer,
|
||||
loading,
|
||||
error,
|
||||
loadCollections,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
createAPIRequest,
|
||||
updateAPIRequest,
|
||||
deleteAPIRequest,
|
||||
sendAPIRequest,
|
||||
isAuthenticated,
|
||||
setIsAuthenticated,
|
||||
handleLogout
|
||||
};
|
||||
|
||||
return (
|
||||
<APIContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</APIContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIProvider;
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { createContext, useState, useContext, useCallback } from 'react';
|
||||
|
||||
// Create the context
|
||||
const LoadTestContext = createContext();
|
||||
|
||||
// Provider component
|
||||
export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
const [tests, setTests] = useState([]);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [currentTest, setCurrentTest] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load scenario list
|
||||
const loadScenarioList = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load scenarios');
|
||||
|
||||
const data = await response.json();
|
||||
setScenarios(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return [];
|
||||
}
|
||||
}, [contextPath]);
|
||||
|
||||
// Load test list
|
||||
const loadTestList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load tests');
|
||||
|
||||
const data = await response.json();
|
||||
setTests(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
|
||||
// Create a new test
|
||||
const createTest = useCallback(async (testName) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ name: testName })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create test');
|
||||
|
||||
const newTest = await response.json();
|
||||
await loadTestList();
|
||||
return newTest;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath, loadTestList]);
|
||||
|
||||
// Update a test
|
||||
const updateTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update test');
|
||||
|
||||
// Update local state
|
||||
setTests(prevTests =>
|
||||
prevTests.map(t => t.id === test.id ? { ...t, ...test } : t)
|
||||
);
|
||||
|
||||
return test;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
|
||||
// Delete a test
|
||||
const deleteTest = useCallback(async (testId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ id: testId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete test');
|
||||
|
||||
// Remove test from local state
|
||||
setTests(prevTests => prevTests.filter(t => t.id !== testId));
|
||||
|
||||
// Clear current test if deleted
|
||||
if (currentTest?.id === testId) {
|
||||
setCurrentTest(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath, currentTest]);
|
||||
|
||||
// Start a load test
|
||||
const startTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}startLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to start test');
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
|
||||
// Stop a load test
|
||||
const stopTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}stopLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to stop test');
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
|
||||
// Prepare context value
|
||||
const contextValue = {
|
||||
tests,
|
||||
scenarios,
|
||||
currentTest,
|
||||
loading,
|
||||
error,
|
||||
setCurrentTest,
|
||||
loadScenarioList,
|
||||
loadTestList,
|
||||
createTest,
|
||||
updateTest,
|
||||
deleteTest,
|
||||
startTest,
|
||||
stopTest,
|
||||
setError
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadTestContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</LoadTestContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use the LoadTestContext
|
||||
export const useLoadTest = () => {
|
||||
const context = useContext(LoadTestContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useLoadTest must be used within a LoadTestProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export default LoadTestProvider;
|
||||
@@ -0,0 +1,57 @@
|
||||
import {getCSRFToken} from '@/services/token.js';
|
||||
|
||||
export const apiRequestService = (contextPath = '/') => {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCSRFToken()
|
||||
};
|
||||
|
||||
return {
|
||||
async createAPIRequest(collectionId, apiData) {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/${collectionId}/apis/save.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiData)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create API request');
|
||||
|
||||
const newApiId = await response.json();
|
||||
return {
|
||||
...apiData,
|
||||
id: newApiId,
|
||||
collectionId,
|
||||
responseModel: {
|
||||
status: 0,
|
||||
time: 0,
|
||||
size: 0,
|
||||
headers: [],
|
||||
body: ''
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async updateAPIRequest(apiRequest) {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/${apiRequest.collectionId}/apis/save.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(apiRequest)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update API request');
|
||||
return apiRequest;
|
||||
},
|
||||
|
||||
async deleteAPIRequest(collectionId, apiId) {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/${collectionId}/apis/delete.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id: apiId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete API request');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export const authService = () => {
|
||||
return {
|
||||
async checkAuth() {
|
||||
const response = await fetch('/api/account', {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) return { isAuthenticated: false };
|
||||
const data = await response.json();
|
||||
return { isAuthenticated: !!data.authorities, data };
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import {getCSRFToken} from '@/services/token.js';
|
||||
|
||||
export const collectionService = (contextPath = '/') => {
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCSRFToken()
|
||||
};
|
||||
|
||||
return {
|
||||
async loadCollections() {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/list.do`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch collections');
|
||||
|
||||
const data = await response.json();
|
||||
return data.map(collection => ({
|
||||
...collection,
|
||||
apis: collection.apis?.map(api => ({...api, collectionId: collection.id})) || []
|
||||
}));
|
||||
},
|
||||
|
||||
async createCollection(name) {
|
||||
await fetch(`${contextPath}mgmt/collections/create.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
},
|
||||
|
||||
async updateCollection(id, name) {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/update.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id, name })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update collection');
|
||||
return true;
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
const response = await fetch(`${contextPath}mgmt/collections/delete.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete collection');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
export const serverService = () => {
|
||||
return {
|
||||
async loadServers() {
|
||||
const response = await fetch('/servers/list.do');
|
||||
if (!response.ok) throw new Error(`Failed to fetch servers: ${response.statusText}`);
|
||||
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) throw new Error('Invalid server data format');
|
||||
|
||||
return data.map(server => ({
|
||||
id: server?.id || crypto.randomUUID(),
|
||||
name: server?.name || 'Unnamed Server',
|
||||
basePath: server?.basePath || '',
|
||||
environment: server?.environment || 'unknown'
|
||||
}));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
// Get CSRF token from meta tag or cookie
|
||||
export const getCSRFToken = () => {
|
||||
// First try to get from meta tag
|
||||
const metaToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
|
||||
if (metaToken) {
|
||||
return metaToken;
|
||||
}
|
||||
|
||||
// If meta tag token is not found, try to get from cookie
|
||||
const cookies = document.cookie.split(';');
|
||||
const xsrfCookie = cookies.find(cookie => cookie.trim().startsWith('XSRF-TOKEN='));
|
||||
if (xsrfCookie) {
|
||||
return xsrfCookie.split('=')[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: 'hsl(var(--sidebar-background))',
|
||||
foreground: 'hsl(var(--sidebar-foreground))',
|
||||
primary: 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
accent: 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
border: 'hsl(var(--sidebar-border))',
|
||||
ring: 'hsl(var(--sidebar-ring))'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from "path"
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import monacoEditorPlugin from './vite.monaco-editor';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
global: {},
|
||||
},
|
||||
plugins: [react(),monacoEditorPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/mgmt': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/actionLogin.do': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/actionLogout.do': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/img': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/servers': {
|
||||
target: 'http://localhost:38080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['@monaco-editor/react'],
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// Split Monaco Editor into a separate chunk
|
||||
'monaco-editor': ['monaco-editor'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
// vite.monaco-editor.js
|
||||
export default function monacoEditorPlugin() {
|
||||
return {
|
||||
name: 'monaco-editor-plugin',
|
||||
config(config) {
|
||||
config.define = {
|
||||
...config.define,
|
||||
'process.platform': JSON.stringify('darwin'),
|
||||
'process.env': {},
|
||||
};
|
||||
},
|
||||
configureServer(server) {
|
||||
return () => {
|
||||
server.middlewares.use('/_monaco', (req, res) => {
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user