TCP 서버 모드 추가

This commit is contained in:
현성필
2025-01-21 13:19:21 +09:00
parent e23684d833
commit cb9201ddd2
106 changed files with 17710 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -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
+21
View File
@@ -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"
}
+38
View File
@@ -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 },
],
},
},
]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEW TESTMASTER</title>
</head>
<body>
<div id="root" style="width: 100%"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+7457
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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

After

Width:  |  Height:  |  Size: 1.5 KiB

+42
View File
@@ -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;
}
+126
View File
@@ -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
View File
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

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

After

Width:  |  Height:  |  Size: 4.0 KiB

+183
View File
@@ -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;
+176
View File
@@ -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;
+133
View File
@@ -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} &gt; {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} &gt; {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,
}
+47
View File
@@ -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 }
+48
View File
@@ -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 }
+50
View File
@@ -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 }
+94
View File
@@ -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,
}
+19
View File
@@ -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 }
+16
View File
@@ -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 }
+178
View File
@@ -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 }
+119
View File
@@ -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 }
+108
View File
@@ -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,
}
+619
View File
@@ -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 }
+86
View File
@@ -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,
}
+41
View File
@@ -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 }
+18
View File
@@ -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}`;
}
};
+19
View File
@@ -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
}
+68
View File
@@ -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;
+171
View File
@@ -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;
+272
View File
@@ -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;
+6
View File
@@ -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;
};
+10
View File
@@ -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>,
)
+259
View File
@@ -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;
}
};
};
+15
View File
@@ -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'
}));
}
};
};
+17
View File
@@ -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;
};
+68
View File
@@ -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")],
}
+63
View File
@@ -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'],
},
},
},
},
})
+20
View File
@@ -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');
});
};
}
};
}