From 53d33fd4d3930f3483a05dbfe3bc213da57d56ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=98=84=EC=84=B1=ED=95=84?= Date: Sun, 2 Feb 2025 15:38:09 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/client-views/TCPClientView.jsx | 60 +- .../src/components/layout/LayoutGrid.jsx | 10 +- .../components/layout/LayoutTreeDataGrid.jsx | 569 ++++++++++++------ .../src/components/layout/LayoutTreeGrid.jsx | 231 +++---- .../src/components/shared/MonacoEditor.jsx | 44 +- 5 files changed, 539 insertions(+), 375 deletions(-) diff --git a/TestMasterUI/src/components/client-views/TCPClientView.jsx b/TestMasterUI/src/components/client-views/TCPClientView.jsx index 4938bbb..e9d50ae 100644 --- a/TestMasterUI/src/components/client-views/TCPClientView.jsx +++ b/TestMasterUI/src/components/client-views/TCPClientView.jsx @@ -8,8 +8,7 @@ import APIClient from '@/services/APIClient'; import {SidebarTrigger} from '@/components/ui/sidebar.jsx'; import {Separator} from '@/components/ui/separator.jsx'; import LayoutGrid from '@/components/layout/LayoutGrid.jsx'; -import ApiLayoutItemTreeBuilder from '@/lib/ApiLayoutItemTreeBuilder.js'; -import ApiLayoutItemTreeDataBuilder from '@/lib/ApiLayoutItemTreeDataBuilder.js'; +import LayoutTreeGrid from '@/components/layout/LayoutTreeGrid.jsx'; export const TCPClientView = ({model, index}) => { const apiClient = new APIClient(); @@ -18,6 +17,7 @@ export const TCPClientView = ({model, index}) => { const [currentModel, setCurrentModel] = useState(model); const [currentTab, setCurrentTab] = useState('body'); const [currentResponseTab, setCurrentResponseTab] = useState('response-body'); + const [layoutItems, setLayoutItems] = useState(model.layout?.layoutItems || []); useEffect(() => { setCurrentModel(prev => ({ @@ -28,13 +28,6 @@ export const TCPClientView = ({model, index}) => { })); }, [model]); - - const [layoutRoot, setLayoutRoot] = useState(() => { - const root = ApiLayoutItemTreeBuilder.createTree(model.layout.layoutItems); - const replicator = new ApiLayoutItemTreeDataBuilder(10000); - return replicator.createReplicatedTree(root); - }); - const [responseData, setResponseData] = useState({ headers: [], status: '', @@ -120,12 +113,14 @@ export const TCPClientView = ({model, index}) => { }; const handleLayoutUpdate = useCallback((updatedLayout) => { - const updatedRoot = ApiLayoutItemTreeBuilder.createTree(updatedLayout.layoutItems); - const replicator = new ApiLayoutItemTreeDataBuilder(10000); - const newRoot = replicator.createReplicatedTree(updatedRoot); - setLayoutRoot(newRoot); - currentModel.layout = updatedLayout; - }, [currentModel]); + setLayoutItems(updatedLayout.layoutItems); // Add this + setCurrentModel(prev => ({ // Update this to use setState + ...prev, + layout: updatedLayout + })); + + console.log({updatedLayout}); + }, []); return (
@@ -163,6 +158,7 @@ export const TCPClientView = ({model, index}) => { Layout + Layout Tree Layout JSON Pre-Request Post-Request @@ -175,18 +171,21 @@ export const TCPClientView = ({model, index}) => {
- { - setCurrentModel(prev => ({ - ...prev, - requestBody: newValue - })); - }} +
+
+ + +
+
+
@@ -233,6 +232,17 @@ export const TCPClientView = ({model, index}) => { + { + setCurrentModel(prev => ({ + ...prev, + requestBody: newValue + })); + }} + /> {/* Response Section */}
diff --git a/TestMasterUI/src/components/layout/LayoutGrid.jsx b/TestMasterUI/src/components/layout/LayoutGrid.jsx index 97affa3..5b777ca 100644 --- a/TestMasterUI/src/components/layout/LayoutGrid.jsx +++ b/TestMasterUI/src/components/layout/LayoutGrid.jsx @@ -21,7 +21,7 @@ import { TableRow, } from "@/components/ui/table"; -const LayoutGrid = ({ layout, onChange }) => { +const LayoutGrid = ({ layout, layoutItems, setLayoutItems, onChange }) => { const [items, setItems] = useState(layout?.layoutItems || []); const getLastId = useCallback(() => { @@ -111,18 +111,18 @@ const LayoutGrid = ({ layout, onChange }) => { const handleFieldChange = useCallback( (index, field, value) => { - const newItems = [...items]; + const newItems = [...layoutItems]; // Use layoutItems from props newItems[index][field] = value; if (field === "value" && newItems[index].value === undefined) { newItems[index].value = ""; } - setItems(newItems); + setLayoutItems(newItems); // Use setLayoutItems from props layout.layoutItems = newItems; onChange?.(layout); }, - [items, layout, onChange] + [layoutItems, layout, onChange, setLayoutItems] // Update dependencies ); return ( @@ -157,7 +157,7 @@ const LayoutGrid = ({ layout, onChange }) => { - {items.map((item, index) => ( + {layoutItems.map((item, index) => ( {item.loutItemSerno} diff --git a/TestMasterUI/src/components/layout/LayoutTreeDataGrid.jsx b/TestMasterUI/src/components/layout/LayoutTreeDataGrid.jsx index 148570e..9d8a9c7 100644 --- a/TestMasterUI/src/components/layout/LayoutTreeDataGrid.jsx +++ b/TestMasterUI/src/components/layout/LayoutTreeDataGrid.jsx @@ -1,214 +1,391 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronRight, ChevronDown, Trash2, Plus } from "lucide-react"; +import EditableInput from "../shared/EditableInput"; import { cn } from "@/lib/utils"; -import EditableInput from '../shared/EditableInput'; -import _ from 'lodash'; -import APIClient from '@/services/APIClient.js'; +import ApiLayoutItemTreeBuilder from "@/lib/ApiLayoutItemTreeBuilder"; -// Helper function to get indentation for tree visualization -const getIndent = (depth) => { - return Array(depth + 1).fill().map((_, i) => ( - - )); -}; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; -const TreeNode = ({ - node, - depth, - options, - isFirst, - onExpand, - expanded, - onValueChange, - getNodePath -}) => { - const hasChildren = node.children && node.children.length > 0; +const LayoutTreeGrid = ({ layout, onChange }) => { + const [expandedNodes, setExpandedNodes] = useState(new Set()); + const [items, setItems] = useState(layout?.layoutItems || []); + const [treeRoot, setTreeRoot] = useState(() => ApiLayoutItemTreeBuilder.createTree(items)); - const renderCell = (fieldInfo) => { - const padding = fieldInfo.isFirst ? getIndent(depth) : null; - const nodePath = getNodePath(node.id) + '.value'; + useEffect(() => { + // Update tree structure when items change + const newRoot = ApiLayoutItemTreeBuilder.createTree(items); + setTreeRoot(newRoot); + }, [items]); - if (node.loutItemType === 'Field' && fieldInfo.type === 'textfield') { - return ( - onValueChange(nodePath, value)} - /> - ); + const toggleNode = (nodeId) => { + setExpandedNodes(prev => { + const newSet = new Set(prev); + if (newSet.has(nodeId)) { + newSet.delete(nodeId); + // Also collapse all children + const collapseChildren = (parentId) => { + items.forEach(item => { + if (item.parent === parentId) { + newSet.delete(item.id); + collapseChildren(item.id); + } + }); + }; + collapseChildren(nodeId); + } else { + newSet.add(nodeId); + } + return newSet; + }); + }; + + 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 handleFieldChange = useCallback( + (index, field, value) => { + const newItems = [...items]; + newItems[index][field] = value; + + if (field === "value" && newItems[index].value === undefined) { + newItems[index].value = ""; + } + + // Handle special cases for Grid and Group types + if (field === "loutItemType" && ["Grid", "Group"].includes(value)) { + const item = newItems[index]; + if (item.loutItemOccCnt === "*" && item.loutItemOccRef) { + const refItem = newItems.find(i => i.loutItemName === item.loutItemOccRef); + if (refItem) { + item.childOccCnt = parseInt(refItem.value, 10); + } + } else if (item.loutItemOccCnt) { + item.childOccCnt = parseInt(item.loutItemOccCnt, 10); + } + } + + setItems(newItems); + layout.layoutItems = newItems; + onChange?.(layout); + }, + [items, layout, onChange] + ); + + const handleDelete = useCallback( + (index) => { + const deleteItemAndChildren = (itemId) => { + const idsToDelete = new Set(); + + const collectIds = (id) => { + idsToDelete.add(id); + items.forEach(item => { + if (item.parent === id) { + collectIds(item.id); + } + }); + }; + + collectIds(itemId); + return items.filter(item => !idsToDelete.has(item.id)); + }; + + const itemToDelete = items[index]; + const newItems = deleteItemAndChildren(itemToDelete.id); + + // Update loutItemSerno values + newItems.forEach((item, idx) => { + item.loutItemSerno = idx + 1; + }); + + setItems(newItems); + layout.layoutItems = newItems; + onChange?.(layout); + }, + [items, layout, onChange] + ); + + const isVisible = useCallback((item) => { + if (item.parent === 0 || item.parent === treeRoot.id) return true; + + let current = item; + while (current && current.parent !== 0 && current.parent !== treeRoot.id) { + if (!expandedNodes.has(current.parent)) { + return false; + } + current = items.find(i => i.id === current.parent); + if (!current) return false; + } + return true; + }, [expandedNodes, items, treeRoot.id]); + + const handleMoveNode = useCallback((item, direction) => { + const parent = ApiLayoutItemTreeBuilder.findParent(treeRoot, item); + if (!parent) return; + + const siblings = parent.children; + const currentIndex = siblings.indexOf(item); + if (currentIndex === -1) return; + + const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; + if (newIndex >= 0 && newIndex < siblings.length) { + // Swap nodes in the tree + [siblings[currentIndex], siblings[newIndex]] = [siblings[newIndex], siblings[currentIndex]]; + + // Update the layout items accordingly + const newItems = [...items]; + newItems.forEach((item, idx) => { + item.loutItemSerno = idx + 1; + }); + + setItems(newItems); + layout.layoutItems = newItems; + onChange?.(layout); + } + }, [items, layout, onChange, treeRoot]); + + const renderTreeRow = (item, index) => { + const hasChildren = items.some(i => i.parent === item.id); + const isExpanded = expandedNodes.has(item.id); + const indentLevel = item.loutItemDepth || 1; + const paddingLeft = (indentLevel - 1) * 20; + + if (!isVisible(item)) { + return null; } return ( -
- {padding} - {fieldInfo.isFirst && hasChildren && ( - + ) : ( +
+ )} + handleFieldChange(index, "id", e.target.value)} + className="w-full" + /> +
+ + + handleFieldChange(index, "parent", e.target.value)} + className="w-full" + /> + + + handleFieldChange(index, "loutItemName", e.target.value)} + className="w-full" + /> + + + handleFieldChange(index, "loutItemDesc", e.target.value)} + className="w-full" + /> + + + handleFieldChange(index, "loutItemDepth", parseInt(e.target.value))} + min={1} + className="w-full" + /> + + + + + + handleFieldChange(index, "loutItemOccCnt", e.target.value)} + className="w-full" + /> + + + handleFieldChange(index, "loutItemOccRef", e.target.value)} + className="w-full" + /> + + + handleFieldChange(index, "loutItemLength", parseInt(e.target.value))} + min={1} + className="w-full" + /> + + + {item.loutItemType === "Field" && ( + handleFieldChange(index, "value", value)} + /> + )} + + +
+ - )} - {node[fieldInfo.name]} -
+ + + + +
+
+
); }; return ( - - {options.map((fieldInfo, index) => ( - - {renderCell(fieldInfo)} - - ))} - - ); -}; - -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( - - ); - - if (isExpanded && node.children?.length > 0) { - node.children.forEach(child => { - result.push(...renderNodes(child, depth + 1)); - }); - } - - return result; - }, [expandedNodes, options, handleExpand, handleValueChange, getNodePath]); - - return ( -
- - - - {options.map(field => ( -
+ + + + # + ID + Parent + 항목명(영문) + 항목설명 + 깊이 + 아이템 유형 + 반복 횟수 + 반복 참조 필드 + 데이터 길이 + + + 비고 + - {renderNodes(root)} - -
+ + + + + + + {items.map((item, index) => renderTreeRow(item, index))} + +
); }; -export default LayoutTreeDataGrid; +export default LayoutTreeGrid; diff --git a/TestMasterUI/src/components/layout/LayoutTreeGrid.jsx b/TestMasterUI/src/components/layout/LayoutTreeGrid.jsx index 7b043a4..9a439f0 100644 --- a/TestMasterUI/src/components/layout/LayoutTreeGrid.jsx +++ b/TestMasterUI/src/components/layout/LayoutTreeGrid.jsx @@ -1,36 +1,31 @@ -import React, { useState, useCallback } from 'react'; -import { cn } from "@/lib/utils"; -import EditableInput from '../shared/EditableInput'; +import React, { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from '@/components/ui/table'; +import ApiLayoutItemTreeBuilder from '@/lib/ApiLayoutItemTreeBuilder.js'; -const LayoutTreeGrid = ({ root, options = [], onUpdate }) => { +const LayoutTreeGrid = ({ layout, layoutItems }) => { + // 트리구조를 만들고, 각 노드의 펼침 여부를 관리하는 상태 const [expandedNodes, setExpandedNodes] = useState(new Set()); + const [treeRoot, setTreeRoot] = useState(() => + ApiLayoutItemTreeBuilder.createTree(layoutItems || []) + ); - // Helper function to get node path - const getNodePath = useCallback((node, targetId, path = '') => { - if (node.id === targetId) { - return path; - } + // layoutItems가 변경되면 트리 구조 재생성 + useEffect(() => { + setTreeRoot(ApiLayoutItemTreeBuilder.createTree(layoutItems || [])); + }, [layoutItems]); - 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 toggleNode = (nodeId) => { + setExpandedNodes((prev) => { const newSet = new Set(prev); if (newSet.has(nodeId)) { newSet.delete(nodeId); @@ -39,118 +34,86 @@ const LayoutTreeGrid = ({ root, options = [], onUpdate }) => { } return newSet; }); - }, []); + }; - // Render cell content - const renderCell = useCallback((node, field, depth) => { - const nodePath = getNodePath(root, node.id) + '.value'; + // 재귀적으로 트리 노드를 순회하며 테이블 행으로 렌더링하는 함수 + const renderTreeRows = (node, indentLevel = 0, index) => { + // 부모 노드의 id나 인덱스를 함께 결합하여 키를 만듭니다. + const key = `${node.id}-${indentLevel}-${index}`; - if (node.loutItemType === 'Field' && field.type === 'textfield') { - return ( - { - node[field.name] = value; - onUpdate?.(root); - }} - /> - ); - } + const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedNodes.has(node.id); - if (field.isFirst) { - const hasChildren = node.children?.length > 0; - return ( -
- - {hasChildren && ( - - )} - {node[field.name]} -
- ); - } - - 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 = ( - - {options.map((field, fieldIndex) => ( - - {renderCell(node, field, depth)} - - ))} - + return ( + + + {node.loutItemSerno} + +
+ {hasChildren ? ( + + ) : ( +
+ )} + {node.id} +
+ + {node.parent} + {node.loutItemName} + {node.loutItemDesc} + {node.loutItemDepth} + {node.loutItemType} + {node.loutItemOccCnt} + {node.loutItemOccRef} + {node.loutItemLength} + + {node.loutItemType === 'Field' ? node.value : ''} + + + {hasChildren && isExpanded && + node.children.map((child, childIndex) => + renderTreeRows(child, indentLevel + 1, childIndex) + )} + ); + }; - 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 ( -
- - - - {options.map((field, index) => ( - - ))} - - - - {renderTreeRows(root)} - -
- {field.label} -
+
+ + + + # + ID + Parent + 항목명(영문) + 항목설명 + 깊이 + 아이템 유형 + 반복 횟수 + 반복 참조 필드 + 데이터 길이 + + + + + {/* treeRoot는 일반적으로 Root 노드(예: id가 0 또는 첫번째 parent)로 생성되므로, + 실제 렌더링은 Root의 자식부터 시작 */} + {treeRoot && treeRoot.children.map((child) => renderTreeRows(child))} + +
); }; diff --git a/TestMasterUI/src/components/shared/MonacoEditor.jsx b/TestMasterUI/src/components/shared/MonacoEditor.jsx index ff4b312..fccc673 100644 --- a/TestMasterUI/src/components/shared/MonacoEditor.jsx +++ b/TestMasterUI/src/components/shared/MonacoEditor.jsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, memo } from 'react'; import * as monaco from 'monaco-editor'; -import {loader} from '@monaco-editor/react'; +import { loader } from '@monaco-editor/react'; loader.config({ paths: { @@ -9,8 +9,7 @@ loader.config({ 'vs/nls': { availableLanguages: {}, }, - // Specify which languages to load - languages: ['javascript', 'json', 'xml','plaintext'] + languages: ['javascript', 'json', 'xml', 'plaintext'] }); const MonacoEditor = memo(({ @@ -95,12 +94,28 @@ const MonacoEditor = memo(({ schemas: [] }); - const action = editor.getAction('editor.action.formatDocument'); - if (action) { - setTimeout(() => { - action.run(); - }, 100); - } + // Use a ref to track if component is mounted + const isMounted = { current: true }; + + // Format after a short delay to ensure editor is ready + const timeoutId = setTimeout(() => { + if (isMounted.current && editorRef.current) { + const action = editorRef.current.getAction('editor.action.formatDocument'); + if (action) { + try { + action.run(); + } catch (e) { + // Ignore formatting errors + console.debug('Format action failed:', e); + } + } + } + }, 100); + + return () => { + isMounted.current = false; + clearTimeout(timeoutId); + }; } catch (e) { console.warn('Failed to format JSON:', e); } @@ -116,7 +131,6 @@ const MonacoEditor = memo(({ resizeObserver.disconnect(); subscription.dispose(); - // Ensure proper cleanup if (editor) { try { const model = editor.getModel(); @@ -128,9 +142,14 @@ const MonacoEditor = memo(({ console.warn('Editor disposal error:', e); } } + if (editorRef.current) { + editorRef.current.dispose(); + editorRef.current = null; + } }; }, [language]); // Only recreate when language changes + // Handle value updates useEffect(() => { if (editorRef.current) { @@ -141,11 +160,6 @@ const MonacoEditor = memo(({ } }, [normalizedValue]); - // Convert height to pixel value if it's a number - // const containerHeight = typeof height === 'number' - // ? `${height}px` - // : height.endsWith('px') ? height : `${height}px`; - const getHeightStyle = (height) => { if (height === 'full') { return '100%';