mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 05:50:51 +01:00
feature: regex patterns (#10)
- add new regex patterns, matched using PCRE2, with case insensitivity - name, description, pattern, tags - add unit tests, attempt to highlight matches
This commit is contained in:
@@ -4,7 +4,7 @@ import os
|
||||
import yaml
|
||||
from .utils import (get_category_directory, load_yaml_file, validate,
|
||||
save_yaml_file, update_yaml_file, get_file_created_date,
|
||||
get_file_modified_date)
|
||||
get_file_modified_date, test_regex_pattern)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -129,3 +129,48 @@ def handle_item(category, name):
|
||||
except Exception as e:
|
||||
logger.exception("Unexpected error occurred")
|
||||
return jsonify({"error": "An unexpected error occurred"}), 500
|
||||
|
||||
|
||||
@bp.route('/<string:category>/test', methods=['POST'])
|
||||
def run_tests(category):
|
||||
logger.info(f"Received regex test request for category: {category}")
|
||||
|
||||
if category != 'regex_pattern':
|
||||
logger.warning(f"Rejected test request - invalid category: {category}")
|
||||
return jsonify({"error":
|
||||
"Testing only supported for regex patterns"}), 400
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
logger.warning("Rejected test request - no JSON data provided")
|
||||
return jsonify({"error": "No JSON data provided"}), 400
|
||||
|
||||
pattern = data.get('pattern')
|
||||
tests = data.get('tests', [])
|
||||
|
||||
logger.info(f"Processing test request - Pattern: {pattern}")
|
||||
logger.info(f"Number of test cases: {len(tests)}")
|
||||
|
||||
if not pattern:
|
||||
logger.warning("Rejected test request - missing pattern")
|
||||
return jsonify({"error": "Pattern is required"}), 400
|
||||
|
||||
if not tests:
|
||||
logger.warning("Rejected test request - no test cases provided")
|
||||
return jsonify({"error":
|
||||
"At least one test case is required"}), 400
|
||||
|
||||
success, message, updated_tests = test_regex_pattern(pattern, tests)
|
||||
logger.info(f"Test execution completed - Success: {success}")
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Test execution failed - {message}")
|
||||
return jsonify({"success": False, "message": message}), 400
|
||||
|
||||
return jsonify({"success": True, "tests": updated_tests}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Unexpected error in test endpoint: {str(e)}",
|
||||
exc_info=True)
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
@@ -2,9 +2,10 @@ import os
|
||||
import yaml
|
||||
import shutil
|
||||
import logging
|
||||
from typing import Dict, Any, Tuple
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Tuple, Union
|
||||
import git
|
||||
import regex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -15,7 +16,7 @@ FORMAT_DIR = '/app/data/db/custom_formats'
|
||||
PROFILE_DIR = '/app/data/db/profiles'
|
||||
|
||||
# Expected fields for each category
|
||||
REGEX_FIELDS = ["name", "pattern", "flags"]
|
||||
REGEX_FIELDS = ["name", "pattern", "description", "tags", "tests"]
|
||||
FORMAT_FIELDS = ["name", "format", "description"]
|
||||
PROFILE_FIELDS = [
|
||||
"name",
|
||||
@@ -138,17 +139,29 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
|
||||
# First save the updated content to the current file
|
||||
save_yaml_file(file_path, data_to_save, category)
|
||||
|
||||
# Then use git mv for the rename
|
||||
# Check if file is being tracked by git
|
||||
repo = git.Repo(REPO_PATH)
|
||||
# Convert to relative paths for git
|
||||
rel_old_path = os.path.relpath(file_path, REPO_PATH)
|
||||
rel_new_path = os.path.relpath(new_file_path, REPO_PATH)
|
||||
|
||||
try:
|
||||
repo.git.mv(rel_old_path, rel_new_path)
|
||||
# Check if file is tracked by git
|
||||
tracked_files = repo.git.ls_files().splitlines()
|
||||
is_tracked = rel_old_path in tracked_files
|
||||
|
||||
if is_tracked:
|
||||
# Use git mv for tracked files
|
||||
repo.git.mv(rel_old_path, rel_new_path)
|
||||
else:
|
||||
# For untracked files, manually move
|
||||
os.rename(file_path, new_file_path)
|
||||
|
||||
except git.GitCommandError as e:
|
||||
logger.error(f"Git mv failed: {e}")
|
||||
raise Exception("Failed to rename file using git mv")
|
||||
logger.error(f"Git operation failed: {e}")
|
||||
raise Exception("Failed to rename file")
|
||||
except OSError as e:
|
||||
logger.error(f"File operation failed: {e}")
|
||||
raise Exception("Failed to rename file")
|
||||
|
||||
else:
|
||||
# Normal update without rename
|
||||
@@ -163,3 +176,74 @@ def update_yaml_file(file_path: str, data: Dict[str, Any],
|
||||
|
||||
except Exception as e:
|
||||
raise
|
||||
|
||||
|
||||
def test_regex_pattern(
|
||||
pattern: str,
|
||||
tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Test a regex pattern against a list of test cases using PCRE2 compatible engine.
|
||||
|
||||
Args:
|
||||
pattern: The regex pattern to test
|
||||
tests: List of test dictionaries with 'input', 'expected', 'id', and 'passes' fields
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message, updated_tests)
|
||||
"""
|
||||
logger.info(f"Starting regex pattern test - Pattern: {pattern}")
|
||||
|
||||
try:
|
||||
# Try to compile the regex with PCRE2 compatibility
|
||||
try:
|
||||
compiled_pattern = regex.compile(pattern, regex.V1)
|
||||
logger.info(
|
||||
"Pattern compiled successfully with PCRE2 compatibility")
|
||||
except regex.error as e:
|
||||
logger.warning(f"Invalid regex pattern: {str(e)}")
|
||||
return False, f"Invalid regex pattern: {str(e)}", tests
|
||||
|
||||
current_time = datetime.now().isoformat()
|
||||
logger.info(f"Processing {len(tests)} test cases")
|
||||
|
||||
# Run each test
|
||||
for test in tests:
|
||||
test_id = test.get('id', 'unknown')
|
||||
test_input = test.get('input', '')
|
||||
expected = test.get('expected', False)
|
||||
|
||||
logger.info(
|
||||
f"Running test {test_id} - Input: {test_input}, Expected: {expected}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Test if pattern matches input
|
||||
matches = bool(compiled_pattern.search(test_input))
|
||||
# Update test result
|
||||
test['passes'] = matches == expected
|
||||
test['lastRun'] = current_time
|
||||
|
||||
if test['passes']:
|
||||
logger.info(
|
||||
f"Test {test_id} passed - Match result: {matches}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Test {test_id} failed - Expected {expected}, got {matches}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error running test {test_id}: {str(e)}")
|
||||
test['passes'] = False
|
||||
test['lastRun'] = current_time
|
||||
|
||||
# Log overall results
|
||||
passed_tests = sum(1 for test in tests if test.get('passes', False))
|
||||
logger.info(
|
||||
f"Test execution complete - {passed_tests}/{len(tests)} tests passed"
|
||||
)
|
||||
|
||||
return True, "", tests
|
||||
except Exception as e:
|
||||
logger.warning(f"Unexpected error in test_regex_pattern: {str(e)}",
|
||||
exc_info=True)
|
||||
return False, str(e), tests
|
||||
|
||||
@@ -386,27 +386,84 @@ def compare_upgrade_until(local_upgrade, remote_upgrade):
|
||||
return changes
|
||||
|
||||
|
||||
def compare_generic(local_data, remote_data):
|
||||
"""Process changes for non-profile files"""
|
||||
if local_data is None and remote_data is not None:
|
||||
def process_generic(old_data, new_data):
|
||||
if old_data is None and new_data is not None:
|
||||
return [{'key': 'File', 'change': 'added'}]
|
||||
|
||||
if local_data is not None and remote_data is None:
|
||||
if old_data is not None and new_data is None:
|
||||
return [{'key': 'File', 'change': 'deleted'}]
|
||||
|
||||
changes = []
|
||||
all_keys = set(local_data.keys()).union(set(remote_data.keys()))
|
||||
all_keys = set(old_data.keys()).union(set(new_data.keys()))
|
||||
|
||||
for key in all_keys:
|
||||
local_value = local_data.get(key)
|
||||
remote_value = remote_data.get(key)
|
||||
old_value = old_data.get(key)
|
||||
new_value = new_data.get(key)
|
||||
|
||||
if local_value != remote_value:
|
||||
changes.append({
|
||||
'key': key.title(), # Capitalize generic keys
|
||||
'change': 'modified',
|
||||
'from': local_value,
|
||||
'to': remote_value
|
||||
})
|
||||
if old_value != new_value:
|
||||
if key == 'tests':
|
||||
old_tests = {t['id']: t for t in old_value or []}
|
||||
new_tests = {t['id']: t for t in new_value or []}
|
||||
|
||||
# Handle deleted tests
|
||||
for test_id in set(old_tests) - set(new_tests):
|
||||
test = old_tests[test_id]
|
||||
changes.append({
|
||||
'key': 'Test',
|
||||
'change': 'deleted',
|
||||
'from':
|
||||
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})',
|
||||
'to': None
|
||||
})
|
||||
|
||||
# Handle added tests
|
||||
for test_id in set(new_tests) - set(old_tests):
|
||||
test = new_tests[test_id]
|
||||
changes.append({
|
||||
'key':
|
||||
'Test',
|
||||
'change':
|
||||
'added',
|
||||
'from':
|
||||
None,
|
||||
'to':
|
||||
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})'
|
||||
})
|
||||
|
||||
# Handle modified tests
|
||||
for test_id in set(old_tests) & set(new_tests):
|
||||
if old_tests[test_id] != new_tests[test_id]:
|
||||
old_test = old_tests[test_id]
|
||||
new_test = new_tests[test_id]
|
||||
|
||||
if old_test['input'] != new_test['input']:
|
||||
changes.append({
|
||||
'key':
|
||||
f'Test #{test_id}',
|
||||
'change':
|
||||
'modified',
|
||||
'from':
|
||||
f'Input: "{old_test["input"]}"',
|
||||
'to':
|
||||
f'Input: "{new_test["input"]}"'
|
||||
})
|
||||
|
||||
if old_test['expected'] != new_test['expected']:
|
||||
changes.append({
|
||||
'key':
|
||||
f'Test #{test_id}',
|
||||
'change':
|
||||
'modified',
|
||||
'from':
|
||||
f'Expected: {old_test["expected"]}',
|
||||
'to':
|
||||
f'Expected: {new_test["expected"]}'
|
||||
})
|
||||
else:
|
||||
changes.append({
|
||||
'key': key.title(),
|
||||
'change': 'modified',
|
||||
'from': old_value,
|
||||
'to': new_value
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
@@ -399,7 +399,6 @@ def compare_upgrade_until(ours_upgrade, theirs_upgrade):
|
||||
|
||||
|
||||
def compare_generic(ours_data, theirs_data):
|
||||
"""Compare generic files for conflicts"""
|
||||
conflicts = []
|
||||
all_keys = set(ours_data.keys()).union(set(theirs_data.keys()))
|
||||
|
||||
@@ -411,11 +410,63 @@ def compare_generic(ours_data, theirs_data):
|
||||
theirs_value = theirs_data.get(key)
|
||||
|
||||
if ours_value != theirs_value:
|
||||
conflicts.append({
|
||||
'parameter': key.title(),
|
||||
'local_value': ours_value,
|
||||
'incoming_value': theirs_value
|
||||
})
|
||||
if key == 'tests':
|
||||
ours_tests = {t['id']: t for t in ours_value or []}
|
||||
theirs_tests = {t['id']: t for t in theirs_value or []}
|
||||
|
||||
# Handle deleted tests
|
||||
for test_id in set(ours_tests) - set(theirs_tests):
|
||||
conflicts.append({
|
||||
'parameter': f'Test {test_id}',
|
||||
'local_value': {
|
||||
'input': ours_tests[test_id]['input'],
|
||||
'expected': ours_tests[test_id]['expected']
|
||||
},
|
||||
'incoming_value': None
|
||||
})
|
||||
|
||||
# Handle added tests
|
||||
for test_id in set(theirs_tests) - set(ours_tests):
|
||||
conflicts.append({
|
||||
'parameter': f'Test {test_id}',
|
||||
'local_value': None,
|
||||
'incoming_value': {
|
||||
'input': theirs_tests[test_id]['input'],
|
||||
'expected': theirs_tests[test_id]['expected']
|
||||
}
|
||||
})
|
||||
|
||||
# Handle modified tests
|
||||
for test_id in set(ours_tests) & set(theirs_tests):
|
||||
if ours_tests[test_id] != theirs_tests[test_id]:
|
||||
ours_test = ours_tests[test_id]
|
||||
theirs_test = theirs_tests[test_id]
|
||||
|
||||
if ours_test['input'] != theirs_test['input']:
|
||||
conflicts.append({
|
||||
'parameter':
|
||||
f'Test {test_id} Input',
|
||||
'local_value':
|
||||
ours_test['input'],
|
||||
'incoming_value':
|
||||
theirs_test['input']
|
||||
})
|
||||
|
||||
if ours_test['expected'] != theirs_test['expected']:
|
||||
conflicts.append({
|
||||
'parameter':
|
||||
f'Test {test_id} Expected',
|
||||
'local_value':
|
||||
ours_test['expected'],
|
||||
'incoming_value':
|
||||
theirs_test['expected']
|
||||
})
|
||||
else:
|
||||
conflicts.append({
|
||||
'parameter': key.title(),
|
||||
'local_value': ours_value,
|
||||
'incoming_value': theirs_value
|
||||
})
|
||||
|
||||
return conflicts
|
||||
|
||||
|
||||
@@ -418,10 +418,8 @@ def compare_upgrade_until(old_upgrade, new_upgrade):
|
||||
|
||||
|
||||
def process_generic(old_data, new_data):
|
||||
"""Process changes for non-profile files"""
|
||||
if old_data is None and new_data is not None:
|
||||
return [{'key': 'File', 'change': 'added'}]
|
||||
|
||||
if old_data is not None and new_data is None:
|
||||
return [{'key': 'File', 'change': 'deleted'}]
|
||||
|
||||
@@ -433,11 +431,70 @@ def process_generic(old_data, new_data):
|
||||
new_value = new_data.get(key)
|
||||
|
||||
if old_value != new_value:
|
||||
changes.append({
|
||||
'key': key.title(), # Capitalize generic keys
|
||||
'change': 'modified',
|
||||
'from': old_value,
|
||||
'to': new_value
|
||||
})
|
||||
if key == 'tests':
|
||||
old_tests = {t['id']: t for t in old_value or []}
|
||||
new_tests = {t['id']: t for t in new_value or []}
|
||||
|
||||
# Handle deleted tests
|
||||
for test_id in set(old_tests) - set(new_tests):
|
||||
test = old_tests[test_id]
|
||||
changes.append({
|
||||
'key': 'Test',
|
||||
'change': 'deleted',
|
||||
'from':
|
||||
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})',
|
||||
'to': None
|
||||
})
|
||||
|
||||
# Handle added tests
|
||||
for test_id in set(new_tests) - set(old_tests):
|
||||
test = new_tests[test_id]
|
||||
changes.append({
|
||||
'key':
|
||||
'Test',
|
||||
'change':
|
||||
'added',
|
||||
'from':
|
||||
None,
|
||||
'to':
|
||||
f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})'
|
||||
})
|
||||
|
||||
# Handle modified tests
|
||||
for test_id in set(old_tests) & set(new_tests):
|
||||
if old_tests[test_id] != new_tests[test_id]:
|
||||
old_test = old_tests[test_id]
|
||||
new_test = new_tests[test_id]
|
||||
|
||||
if old_test['input'] != new_test['input']:
|
||||
changes.append({
|
||||
'key':
|
||||
f'Test #{test_id}',
|
||||
'change':
|
||||
'modified',
|
||||
'from':
|
||||
f'Input: "{old_test["input"]}"',
|
||||
'to':
|
||||
f'Input: "{new_test["input"]}"'
|
||||
})
|
||||
|
||||
if old_test['expected'] != new_test['expected']:
|
||||
changes.append({
|
||||
'key':
|
||||
f'Test #{test_id}',
|
||||
'change':
|
||||
'modified',
|
||||
'from':
|
||||
f'Expected: {old_test["expected"]}',
|
||||
'to':
|
||||
f'Expected: {new_test["expected"]}'
|
||||
})
|
||||
else:
|
||||
changes.append({
|
||||
'key': key.title(),
|
||||
'change': 'modified',
|
||||
'from': old_value,
|
||||
'to': new_value
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
@@ -3,4 +3,5 @@ Flask-CORS==3.0.10
|
||||
PyYAML==5.4.1
|
||||
requests==2.26.0
|
||||
Werkzeug==2.0.1
|
||||
GitPython==3.1.24
|
||||
GitPython==3.1.24
|
||||
regex==2023.10.3
|
||||
@@ -19,8 +19,10 @@ services:
|
||||
- backend_data:/app/data
|
||||
environment:
|
||||
- FLASK_ENV=development
|
||||
- TZ=Australia/Adelaide # Add this line
|
||||
env_file:
|
||||
- .env.1
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
|
||||
@@ -4,16 +4,9 @@ const BASE_URL = '/api/data';
|
||||
|
||||
const handleError = (error, operation) => {
|
||||
console.error(`Error ${operation}:`, error);
|
||||
if (error.response?.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response.data.error
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to ${operation}`
|
||||
};
|
||||
const errorMessage =
|
||||
error.response?.data?.error || `Failed to ${operation}`;
|
||||
throw new Error(errorMessage); // Throw instead of returning an error object
|
||||
};
|
||||
|
||||
// Get all items for a category
|
||||
@@ -92,6 +85,22 @@ export const RegexPatterns = {
|
||||
getAll: () => getAllItems('regex_pattern'),
|
||||
get: name => getItem('regex_pattern', name),
|
||||
create: data => createItem('regex_pattern', data),
|
||||
update: (name, data) => updateItem('regex_pattern', name, data),
|
||||
delete: name => deleteItem('regex_pattern', name)
|
||||
update: (name, data, newName) =>
|
||||
updateItem('regex_pattern', name, data, newName),
|
||||
delete: name => deleteItem('regex_pattern', name),
|
||||
// Add this new method
|
||||
runTests: async (pattern, tests) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/regex_pattern/test`,
|
||||
{
|
||||
pattern,
|
||||
tests
|
||||
}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return handleError(error, 'run tests');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
138
frontend/src/components/regex/AddUnitTestModal.jsx
Normal file
138
frontend/src/components/regex/AddUnitTestModal.jsx
Normal file
@@ -0,0 +1,138 @@
|
||||
// AddUnitTestModal.jsx
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '../ui/Modal';
|
||||
|
||||
const AddUnitTestModal = ({isOpen, onClose, onAdd, tests, editTest = null}) => {
|
||||
const [input, setInput] = useState('');
|
||||
const [shouldMatch, setShouldMatch] = useState(true);
|
||||
|
||||
// Reset form when opening modal, handling both new and edit cases
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editTest) {
|
||||
setInput(editTest.input);
|
||||
setShouldMatch(editTest.expected);
|
||||
} else {
|
||||
setInput('');
|
||||
setShouldMatch(true);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editTest]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const getNextTestId = testArray => {
|
||||
if (!testArray || testArray.length === 0) return 1;
|
||||
return Math.max(...testArray.map(test => test.id)) + 1;
|
||||
};
|
||||
|
||||
const testData = {
|
||||
id: editTest ? editTest.id : getNextTestId(tests),
|
||||
input,
|
||||
expected: shouldMatch,
|
||||
passes: false,
|
||||
lastRun: null
|
||||
};
|
||||
|
||||
onAdd(testData);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setInput('');
|
||||
setShouldMatch(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={editTest ? 'Edit Test Case' : 'Add Test Case'}
|
||||
width='3xl'
|
||||
footer={
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200
|
||||
bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700'>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!input.trim()}
|
||||
className='px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-md
|
||||
hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed'>
|
||||
{editTest ? 'Save Changes' : 'Add Test'}
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
{/* Rest of the modal content remains the same */}
|
||||
<div className='space-y-4'>
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Test String
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600
|
||||
rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
|
||||
placeholder='Enter string to test against pattern...'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Expected Result
|
||||
</label>
|
||||
<div className='flex space-x-4'>
|
||||
<label className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='radio'
|
||||
checked={shouldMatch}
|
||||
onChange={() => setShouldMatch(true)}
|
||||
className='text-blue-600 focus:ring-blue-500'
|
||||
/>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
Should Match
|
||||
</span>
|
||||
</label>
|
||||
<label className='flex items-center space-x-2'>
|
||||
<input
|
||||
type='radio'
|
||||
checked={!shouldMatch}
|
||||
onChange={() => setShouldMatch(false)}
|
||||
className='text-blue-600 focus:ring-blue-500'
|
||||
/>
|
||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||
Should Not Match
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
AddUnitTestModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onAdd: PropTypes.func.isRequired,
|
||||
tests: PropTypes.array.isRequired,
|
||||
editTest: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
input: PropTypes.string.isRequired,
|
||||
expected: PropTypes.bool.isRequired,
|
||||
passes: PropTypes.bool.isRequired,
|
||||
lastRun: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
export default AddUnitTestModal;
|
||||
48
frontend/src/components/regex/DeleteConfirmationModal.jsx
Normal file
48
frontend/src/components/regex/DeleteConfirmationModal.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// DeleteConfirmationModal.jsx
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from '@ui/Modal';
|
||||
import {AlertTriangle} from 'lucide-react';
|
||||
|
||||
const DeleteConfirmationModal = ({isOpen, onClose, onConfirm}) => {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title='Delete Test'
|
||||
width='sm'
|
||||
footer={
|
||||
<div className='flex justify-end space-x-3'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200
|
||||
bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md
|
||||
hover:bg-gray-50 dark:hover:bg-gray-700'>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className='px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md
|
||||
hover:bg-red-700'>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
<div className='flex items-center gap-3 text-gray-700 dark:text-gray-200'>
|
||||
<AlertTriangle className='w-5 h-5 text-red-500' />
|
||||
<p>
|
||||
Are you sure you want to delete this test case? This action
|
||||
cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteConfirmationModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onConfirm: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DeleteConfirmationModal;
|
||||
@@ -1,71 +1,125 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Copy} from 'lucide-react';
|
||||
|
||||
function unsanitize(text) {
|
||||
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
|
||||
}
|
||||
const RegexCard = ({pattern, onEdit, onClone, formatDate, sortBy}) => {
|
||||
const totalTests = pattern.tests.length;
|
||||
const passedTests = pattern.tests.filter(t => t.passes).length;
|
||||
const passRate = Math.round((passedTests / totalTests) * 100);
|
||||
|
||||
function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) {
|
||||
return (
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-sm cursor-pointer hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-shadow"
|
||||
onClick={() => onEdit(regex)}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-200">
|
||||
{unsanitize(regex.name)}
|
||||
</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClone(regex);
|
||||
}}
|
||||
className="relative group"
|
||||
>
|
||||
<img
|
||||
src="/clone.svg"
|
||||
alt="Clone"
|
||||
className="w-5 h-5 transition-transform transform group-hover:scale-125 group-hover:rotate-12 group-hover:-translate-y-1 group-hover:translate-x-1"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 mb-2 bg-gray-100 dark:bg-gray-700 rounded p-2">
|
||||
<pre className="text-sm font-mono text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
|
||||
{regex.pattern}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
|
||||
{unsanitize(regex.description)}
|
||||
</p>
|
||||
{showDate && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-xs mb-2">
|
||||
Modified: {formatDate(regex.date_modified)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap">
|
||||
{regex.tags && regex.tags.map(tag => (
|
||||
<span key={tag} className="bg-blue-100 text-blue-800 text-xs font-medium mr-2 mb-1 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className='w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500 transition-all cursor-pointer max-h-96'
|
||||
onClick={() => onEdit(pattern)}>
|
||||
<div className='flex flex-col p-6 gap-3'>
|
||||
{/* Header Section */}
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100 truncate'>
|
||||
{pattern.name}
|
||||
</h3>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onClone(pattern);
|
||||
}}
|
||||
className='p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors shrink-0'>
|
||||
<Copy className='w-5 h-5 text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pattern Display with line clamp */}
|
||||
<div className='bg-gray-50 dark:bg-gray-900/50 rounded-md p-3 font-mono text-sm'>
|
||||
<code className='text-gray-800 dark:text-gray-200 break-all line-clamp-3'>
|
||||
{pattern.pattern}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Description if exists - with line clamp */}
|
||||
{pattern.description && (
|
||||
<p className='text-gray-600 dark:text-gray-300 text-sm line-clamp-2'>
|
||||
{pattern.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bottom Metadata */}
|
||||
<div className='flex flex-wrap items-center gap-4 text-sm'>
|
||||
{/* Test Results */}
|
||||
<div className='flex items-center gap-2 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded-md'>
|
||||
{totalTests > 0 ? (
|
||||
<>
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
passRate === 100
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: passRate >= 80
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{passRate}% Pass Rate
|
||||
</span>
|
||||
<span className='text-gray-500 dark:text-gray-400 text-xs'>
|
||||
({passedTests}/{totalTests} tests)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className='text-gray-500 dark:text-gray-400 text-sm'>
|
||||
No tests
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{pattern.tags && pattern.tags.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2 max-h-20 overflow-y-auto'>
|
||||
{pattern.tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-300 px-2.5 py-0.5 rounded text-xs'>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Modified/Created */}
|
||||
{(sortBy === 'dateModified' ||
|
||||
sortBy === 'dateCreated') && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 text-left'>
|
||||
{sortBy === 'dateModified' ? 'Modified' : 'Created'}
|
||||
:{' '}
|
||||
{formatDate(
|
||||
sortBy === 'dateModified'
|
||||
? pattern.modified_date
|
||||
: pattern.created_date
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RegexCard.propTypes = {
|
||||
regex: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
date_modified: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
}).isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onClone: PropTypes.func.isRequired, // Added clone handler prop
|
||||
showDate: PropTypes.bool.isRequired,
|
||||
formatDate: PropTypes.func.isRequired,
|
||||
pattern: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
tests: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
input: PropTypes.string.isRequired,
|
||||
expected: PropTypes.bool.isRequired,
|
||||
passes: PropTypes.bool.isRequired
|
||||
})
|
||||
).isRequired,
|
||||
created_date: PropTypes.string.isRequired,
|
||||
modified_date: PropTypes.string.isRequired
|
||||
}).isRequired,
|
||||
onEdit: PropTypes.func.isRequired,
|
||||
onClone: PropTypes.func.isRequired,
|
||||
formatDate: PropTypes.func.isRequired,
|
||||
sortBy: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default RegexCard;
|
||||
|
||||
194
frontend/src/components/regex/RegexGeneralTab.jsx
Normal file
194
frontend/src/components/regex/RegexGeneralTab.jsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import TextArea from '../ui/TextArea';
|
||||
import {InfoIcon} from 'lucide-react';
|
||||
|
||||
const RegexGeneralTab = ({
|
||||
name,
|
||||
description,
|
||||
pattern,
|
||||
tags,
|
||||
onNameChange,
|
||||
onDescriptionChange,
|
||||
onPatternChange,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
error,
|
||||
patternError
|
||||
}) => {
|
||||
const [newTag, setNewTag] = useState('');
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
||||
onAddTag(newTag.trim());
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{error && (
|
||||
<div className='bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-md p-4 mb-6'>
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className='space-y-6'>
|
||||
{/* Name Input */}
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Pattern Name
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={name}
|
||||
onChange={e => onNameChange(e.target.value)}
|
||||
placeholder='Enter pattern name'
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className='space-y-2'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Description
|
||||
</label>
|
||||
<TextArea
|
||||
value={description}
|
||||
onChange={e => onDescriptionChange(e.target.value)}
|
||||
placeholder='Describe what this pattern matches...'
|
||||
rows={3}
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pattern Input */}
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Pattern
|
||||
</label>
|
||||
<div className='flex items-center gap-2 text-xs text-blue-600 dark:text-blue-400'>
|
||||
<InfoIcon className='h-4 w-4' />
|
||||
<span>Case insensitive PCRE2</span>
|
||||
</div>
|
||||
</div>
|
||||
{patternError && (
|
||||
<p className='text-sm text-red-600 dark:text-red-400'>
|
||||
{patternError}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
value={pattern}
|
||||
onChange={e => onPatternChange(e.target.value)}
|
||||
className='w-full h-24 rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 px-3 py-2
|
||||
text-gray-900 dark:text-gray-100 font-mono text-sm
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500 dark:focus:ring-blue-400'
|
||||
placeholder='Enter your regex pattern here...'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className='space-y-4'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
Tags
|
||||
</label>
|
||||
<div className='flex space-x-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={newTag}
|
||||
onChange={e => setNewTag(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder='Add a tag'
|
||||
className='w-full rounded-md border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700 px-3 py-2 text-sm
|
||||
text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-500 dark:placeholder-gray-400
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
transition-colors duration-200'
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!newTag.trim()}
|
||||
className='px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-400
|
||||
text-white rounded-md text-sm font-medium transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
dark:focus:ring-offset-gray-800'>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='inline-flex items-center p-1.5 rounded-md text-xs
|
||||
bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300 group'>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => onRemoveTag(tag)}
|
||||
className='ml-1.5 hover:text-blue-900 dark:hover:text-blue-200 focus:outline-none'>
|
||||
<svg
|
||||
className='w-3.5 h-3.5 opacity-60 group-hover:opacity-100 transition-opacity'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M6 18L18 6M6 6l12 12'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className='flex items-center justify-center h-[2.5rem] text-sm
|
||||
text-gray-500 dark:text-gray-400 rounded-md border border-dashed border-gray-300 dark:border-gray-600'>
|
||||
No tags added yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RegexGeneralTab.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
onNameChange: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onPatternChange: PropTypes.func.isRequired,
|
||||
onAddTag: PropTypes.func.isRequired,
|
||||
onRemoveTag: PropTypes.func.isRequired,
|
||||
error: PropTypes.string,
|
||||
patternError: PropTypes.string
|
||||
};
|
||||
|
||||
export default RegexGeneralTab;
|
||||
@@ -1,189 +1,74 @@
|
||||
import {useState, useEffect, useRef} from 'react';
|
||||
import React, {useEffect} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {saveRegex, deleteRegex, createRegex101Link} from '../../api/api';
|
||||
import Modal from '../ui/Modal';
|
||||
import Alert from '../ui/Alert';
|
||||
import Modal from '@ui/Modal';
|
||||
import RegexGeneralTab from './RegexGeneralTab';
|
||||
import RegexTestingTab from './RegexTestingTab';
|
||||
import {useRegexModal} from '@hooks/useRegexModal';
|
||||
import {RegexPatterns} from '@api/data';
|
||||
import Alert from '@ui/Alert';
|
||||
import {Loader, Play} from 'lucide-react';
|
||||
|
||||
function unsanitize(text) {
|
||||
return text.replace(/\\:/g, ':').replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function RegexModal({
|
||||
regex: initialRegex,
|
||||
const RegexModal = ({
|
||||
pattern: initialPattern,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
isCloning = false
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [pattern, setPattern] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const [regex101Link, setRegex101Link] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [modalTitle, setModalTitle] = useState('');
|
||||
}) => {
|
||||
const {
|
||||
// Form state
|
||||
name,
|
||||
description,
|
||||
patternValue,
|
||||
tags,
|
||||
tests,
|
||||
|
||||
// UI state
|
||||
error,
|
||||
patternError,
|
||||
activeTab,
|
||||
isDeleting,
|
||||
isRunningTests,
|
||||
|
||||
// Actions
|
||||
setName,
|
||||
setDescription,
|
||||
setPatternValue,
|
||||
setTags,
|
||||
setTests,
|
||||
setActiveTab,
|
||||
setIsDeleting,
|
||||
|
||||
// Main handlers
|
||||
initializeForm,
|
||||
handleSave,
|
||||
handleRunTests
|
||||
} = useRegexModal(initialPattern, onSave);
|
||||
|
||||
const tabs = [
|
||||
{id: 'general', label: 'General'},
|
||||
{id: 'testing', label: 'Testing'}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Set the modal title
|
||||
if (isCloning) {
|
||||
setModalTitle('Clone Regex Pattern');
|
||||
} else if (initialRegex && initialRegex.id !== 0) {
|
||||
setModalTitle('Edit Regex Pattern');
|
||||
} else {
|
||||
setModalTitle('Add Regex Pattern');
|
||||
}
|
||||
|
||||
if (initialRegex && (initialRegex.id !== 0 || isCloning)) {
|
||||
setName(unsanitize(initialRegex.name));
|
||||
setPattern(initialRegex.pattern);
|
||||
setDescription(unsanitize(initialRegex.description));
|
||||
setTags(
|
||||
initialRegex.tags ? initialRegex.tags.map(unsanitize) : []
|
||||
);
|
||||
setRegex101Link(initialRegex.regex101Link || '');
|
||||
} else {
|
||||
setName('');
|
||||
setPattern('');
|
||||
setDescription('');
|
||||
setTags([]);
|
||||
setRegex101Link('');
|
||||
}
|
||||
setError('');
|
||||
setNewTag('');
|
||||
setIsLoading(false);
|
||||
setIsDeleting(false);
|
||||
initializeForm(initialPattern, isCloning);
|
||||
}
|
||||
}, [initialRegex, isOpen, isCloning]);
|
||||
|
||||
const handleCreateRegex101Link = async () => {
|
||||
if (!pattern.trim()) {
|
||||
setError('Please provide a regex pattern before creating tests.');
|
||||
return;
|
||||
}
|
||||
|
||||
const unitTests = [
|
||||
{
|
||||
description: "Test if 'D-Z0N3' is detected correctly",
|
||||
testString: 'Test D-Z0N3 pattern',
|
||||
criteria: 'DOES_MATCH',
|
||||
target: 'REGEX'
|
||||
},
|
||||
{
|
||||
description: "Test if 'random text' does not match",
|
||||
testString: 'random text',
|
||||
criteria: 'DOES_NOT_MATCH',
|
||||
target: 'REGEX'
|
||||
}
|
||||
];
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await createRegex101Link({
|
||||
regex: pattern,
|
||||
flavor: 'pcre',
|
||||
flags: 'gmi',
|
||||
delimiter: '/',
|
||||
unitTests: unitTests
|
||||
});
|
||||
const permalinkFragment = response.permalinkFragment;
|
||||
|
||||
const regex101Link = `https://regex101.com/r/${permalinkFragment}`;
|
||||
setRegex101Link(regex101Link);
|
||||
|
||||
await saveRegex({
|
||||
id: regex ? regex.id : 0,
|
||||
name,
|
||||
pattern,
|
||||
description,
|
||||
tags,
|
||||
regex101Link
|
||||
});
|
||||
|
||||
window.open(regex101Link, '_blank');
|
||||
onSave();
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error creating regex101 link:', error);
|
||||
setError('Failed to create regex101 link. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRegex101Link = async () => {
|
||||
const confirmRemoval = window.confirm(
|
||||
'Are you sure you want to remove this Regex101 link?'
|
||||
);
|
||||
if (!confirmRemoval) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setRegex101Link('');
|
||||
|
||||
try {
|
||||
await saveRegex({
|
||||
id: regex ? regex.id : 0,
|
||||
name,
|
||||
pattern,
|
||||
description,
|
||||
tags,
|
||||
regex101Link: ''
|
||||
});
|
||||
|
||||
onSave();
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error removing regex101 link:', error);
|
||||
setError('Failed to remove regex101 link. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !pattern.trim()) {
|
||||
setError('Name and pattern are required.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveRegex({
|
||||
id: initialRegex ? initialRegex.id : 0,
|
||||
name,
|
||||
pattern,
|
||||
description,
|
||||
tags,
|
||||
regex101Link
|
||||
});
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving regex:', error);
|
||||
setError('Failed to save regex. Please try again.');
|
||||
}
|
||||
};
|
||||
}, [initialPattern, isOpen, isCloning, initializeForm]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!initialPattern) return;
|
||||
|
||||
if (isDeleting) {
|
||||
try {
|
||||
console.log(
|
||||
'Attempting to delete regex with ID:',
|
||||
initialRegex.id
|
||||
await RegexPatterns.delete(
|
||||
initialPattern.file_name.replace('.yml', '')
|
||||
);
|
||||
const response = await deleteRegex(initialRegex.id);
|
||||
console.log('Delete response:', response);
|
||||
if (response.error) {
|
||||
Alert.error(`Cannot delete: ${response.message}`);
|
||||
} else {
|
||||
Alert.success('Regex deleted successfully');
|
||||
onSave();
|
||||
onClose();
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error deleting regex:', error);
|
||||
Alert.error('Failed to delete regex. Please try again.');
|
||||
console.error('Error deleting pattern:', error);
|
||||
Alert.error('Failed to delete pattern. Please try again.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@@ -192,152 +77,116 @@ function RegexModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (newTag.trim() && !tags.includes(newTag.trim())) {
|
||||
setTags([...tags, newTag.trim()]);
|
||||
setNewTag('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = tagToRemove => {
|
||||
setTags(tags.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
const footerContent = (
|
||||
<div className='flex justify-between'>
|
||||
{initialPattern && !isCloning && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={`px-4 py-2 text-white rounded transition-colors ${
|
||||
isDeleting
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-red-500 hover:bg-red-600'
|
||||
}`}>
|
||||
{isDeleting ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
{activeTab === 'testing' && tests?.length > 0 && (
|
||||
<button
|
||||
onClick={() => handleRunTests(patternValue, tests)}
|
||||
disabled={isRunningTests}
|
||||
className='inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700
|
||||
disabled:bg-green-600/50 text-white rounded transition-colors'>
|
||||
{isRunningTests ? (
|
||||
<Loader className='w-4 h-4 mr-2 animate-spin' />
|
||||
) : (
|
||||
<Play className='w-4 h-4 mr-2' />
|
||||
)}
|
||||
Run Tests
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className='bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded transition-colors'>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={modalTitle}
|
||||
height='auto'
|
||||
width='xl'>
|
||||
{error && <div className='text-red-500 mb-4'>{error}</div>}
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Regex Name
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder='Enter regex name'
|
||||
className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Regex Pattern
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={pattern}
|
||||
onChange={e => setPattern(e.target.value)}
|
||||
placeholder='Enter regex pattern'
|
||||
className='w-full p-2 border rounded font-mono dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-4'>
|
||||
{regex101Link ? (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<a
|
||||
href={regex101Link}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 transition-colors'>
|
||||
View in Regex101
|
||||
</a>
|
||||
<button
|
||||
onClick={handleRemoveRegex101Link}
|
||||
className='text-red-500 hover:text-red-600 transition-colors'
|
||||
disabled={isLoading}>
|
||||
{isLoading ? 'Removing...' : 'Remove Link'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCreateRegex101Link}
|
||||
className='bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors'
|
||||
disabled={isLoading}>
|
||||
{isLoading
|
||||
? 'Creating Tests...'
|
||||
: 'Create Tests in Regex101'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Description (Optional)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder='Enter description'
|
||||
className='w-full p-2 border rounded dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
|
||||
/>
|
||||
</div>
|
||||
<div className='mb-4'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'>
|
||||
Tags
|
||||
</label>
|
||||
<div className='flex flex-wrap mb-2'>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className='bg-blue-100 text-blue-800 text-xs font-medium mr-2 px-2.5 py-0.5 rounded dark:bg-blue-900 dark:text-blue-300'>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className='ml-1 text-xs'>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={
|
||||
isCloning
|
||||
? 'Clone Pattern'
|
||||
: initialPattern
|
||||
? 'Edit Pattern'
|
||||
: 'Add Pattern'
|
||||
}
|
||||
height='6xl'
|
||||
width='4xl'
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={footerContent}>
|
||||
{activeTab => {
|
||||
return (
|
||||
<div className='h-full'>
|
||||
{activeTab === 'general' && (
|
||||
<RegexGeneralTab
|
||||
name={name}
|
||||
description={description}
|
||||
pattern={patternValue}
|
||||
error={error}
|
||||
patternError={patternError}
|
||||
tags={tags}
|
||||
onNameChange={setName}
|
||||
onDescriptionChange={setDescription}
|
||||
onPatternChange={newPattern => {
|
||||
setPatternValue(newPattern);
|
||||
setPatternError('');
|
||||
}}
|
||||
onAddTag={tag => setTags([...tags, tag])}
|
||||
onRemoveTag={tag =>
|
||||
setTags(tags.filter(t => t !== tag))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'testing' && (
|
||||
<RegexTestingTab
|
||||
pattern={patternValue}
|
||||
tests={tests || []}
|
||||
onTestsChange={setTests}
|
||||
isRunningTests={isRunningTests}
|
||||
onRunTests={handleRunTests}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex'>
|
||||
<input
|
||||
type='text'
|
||||
value={newTag}
|
||||
onChange={e => setNewTag(e.target.value)}
|
||||
placeholder='Add a tag'
|
||||
className='flex-grow p-2 border rounded-l dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600'
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
className='bg-blue-500 text-white px-4 py-2 rounded-r hover:bg-blue-600 transition-colors'>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-between'>
|
||||
{initialRegex && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={`bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors ${
|
||||
isDeleting ? 'bg-red-600' : ''
|
||||
}`}>
|
||||
{isDeleting ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors'>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
RegexModal.propTypes = {
|
||||
regex: PropTypes.shape({
|
||||
id: PropTypes.number,
|
||||
pattern: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
description: PropTypes.string,
|
||||
tags: PropTypes.arrayOf(PropTypes.string),
|
||||
regex101Link: PropTypes.string
|
||||
tests: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
input: PropTypes.string.isRequired,
|
||||
expected: PropTypes.bool.isRequired,
|
||||
passes: PropTypes.bool.isRequired,
|
||||
lastRun: PropTypes.string
|
||||
})
|
||||
),
|
||||
created_date: PropTypes.string,
|
||||
modified_date: PropTypes.string
|
||||
}),
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
||||
@@ -1,168 +1,124 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import RegexCard from './RegexCard';
|
||||
import RegexModal from './RegexModal';
|
||||
import AddNewCard from '../ui/AddNewCard';
|
||||
import {getRegexes} from '../../api/api';
|
||||
import {RegexPatterns} from '@api/data';
|
||||
import FilterMenu from '../ui/FilterMenu';
|
||||
import SortMenu from '../ui/SortMenu';
|
||||
import {Loader} from 'lucide-react';
|
||||
import {getGitStatus} from '../../api/api';
|
||||
import Alert from '@ui/Alert';
|
||||
|
||||
function RegexPage() {
|
||||
const [regexes, setRegexes] = useState([]);
|
||||
const [patterns, setPatterns] = useState([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedRegex, setSelectedRegex] = useState(null);
|
||||
const [selectedPattern, setSelectedPattern] = useState(null);
|
||||
const [sortBy, setSortBy] = useState('title');
|
||||
const [filterType, setFilterType] = useState('none');
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const [allTags, setAllTags] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [mergeConflicts, setMergeConflicts] = useState([]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const loadingMessages = [
|
||||
'Compiling complex patterns...',
|
||||
'Analyzing regex efficiency...',
|
||||
'Optimizing search algorithms...',
|
||||
'Testing pattern boundaries...',
|
||||
'Loading regex libraries...',
|
||||
'Parsing intricate expressions...',
|
||||
'Detecting pattern conflicts...',
|
||||
'Refactoring nested groups...'
|
||||
];
|
||||
|
||||
// Load initial data
|
||||
useEffect(() => {
|
||||
fetchGitStatus();
|
||||
loadPatterns();
|
||||
}, []);
|
||||
|
||||
const fetchRegexes = async () => {
|
||||
const loadPatterns = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const fetchedRegexes = await getRegexes();
|
||||
setRegexes(fetchedRegexes);
|
||||
const tags = [
|
||||
...new Set(fetchedRegexes.flatMap(regex => regex.tags || []))
|
||||
];
|
||||
setAllTags(tags);
|
||||
const response = await RegexPatterns.getAll();
|
||||
if (Array.isArray(response)) {
|
||||
const patternsData = response.map(item => ({
|
||||
...item.content,
|
||||
file_name: item.file_name,
|
||||
created_date: item.created_date,
|
||||
modified_date: item.modified_date
|
||||
}));
|
||||
setPatterns(patternsData);
|
||||
|
||||
// Extract all unique tags
|
||||
const tags = new Set();
|
||||
patternsData.forEach(pattern => {
|
||||
pattern.tags?.forEach(tag => tags.add(tag));
|
||||
});
|
||||
setAllTags(Array.from(tags));
|
||||
} else {
|
||||
Alert.error('Failed to load patterns');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching regexes:', error);
|
||||
console.error('Error loading patterns:', error);
|
||||
Alert.error('Failed to load patterns');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitStatus = async () => {
|
||||
try {
|
||||
const result = await getGitStatus();
|
||||
if (result.success) {
|
||||
setMergeConflicts(result.data.merge_conflicts || []);
|
||||
if (result.data.merge_conflicts.length === 0) {
|
||||
fetchRegexes();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching Git status:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenModal = (regex = null) => {
|
||||
setSelectedRegex(regex);
|
||||
const handleOpenModal = (pattern = null) => {
|
||||
setSelectedPattern(pattern);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setSelectedRegex(null);
|
||||
setSelectedPattern(null);
|
||||
setIsModalOpen(false);
|
||||
setIsCloning(false);
|
||||
};
|
||||
|
||||
const handleCloneRegex = regex => {
|
||||
const clonedRegex = {
|
||||
...regex,
|
||||
id: 0,
|
||||
name: `${regex.name} [COPY]`,
|
||||
regex101Link: ''
|
||||
const handleClonePattern = pattern => {
|
||||
const clonedPattern = {
|
||||
...pattern,
|
||||
name: `${pattern.name} [COPY]`
|
||||
};
|
||||
setSelectedRegex(clonedRegex);
|
||||
setSelectedPattern(clonedPattern);
|
||||
setIsModalOpen(true);
|
||||
setIsCloning(true);
|
||||
};
|
||||
|
||||
const handleSaveRegex = () => {
|
||||
fetchRegexes();
|
||||
const handleSavePattern = async () => {
|
||||
await loadPatterns();
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const getFilteredAndSortedPatterns = () => {
|
||||
let filtered = [...patterns];
|
||||
|
||||
// Apply filters
|
||||
if (filterType === 'tag' && filterValue) {
|
||||
filtered = filtered.filter(pattern =>
|
||||
pattern.tags?.includes(filterValue)
|
||||
);
|
||||
} else if (filterType === 'name' && filterValue) {
|
||||
filtered = filtered.filter(pattern =>
|
||||
pattern.name.toLowerCase().includes(filterValue.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
return filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'dateModified':
|
||||
return (
|
||||
new Date(b.modified_date) - new Date(a.modified_date)
|
||||
);
|
||||
case 'dateCreated':
|
||||
return new Date(b.created_date) - new Date(a.created_date);
|
||||
case 'name':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = dateString => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const sortedAndFilteredRegexes = regexes
|
||||
.filter(regex => {
|
||||
if (filterType === 'tag') {
|
||||
return regex.tags && regex.tags.includes(filterValue);
|
||||
}
|
||||
if (filterType === 'date') {
|
||||
const regexDate = new Date(regex.date_modified);
|
||||
const filterDate = new Date(filterValue);
|
||||
return regexDate.toDateString() === filterDate.toDateString();
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'title') return a.name.localeCompare(b.name);
|
||||
if (sortBy === 'dateCreated')
|
||||
return new Date(b.date_created) - new Date(a.date_created);
|
||||
if (sortBy === 'dateModified')
|
||||
return new Date(b.date_modified) - new Date(a.date_modified);
|
||||
return 0;
|
||||
});
|
||||
|
||||
const hasConflicts = mergeConflicts.length > 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center h-screen'>
|
||||
<Loader size={48} className='animate-spin text-blue-500 mb-4' />
|
||||
<p className='text-lg font-medium text-gray-700 dark:text-gray-300'>
|
||||
{
|
||||
loadingMessages[
|
||||
Math.floor(Math.random() * loadingMessages.length)
|
||||
]
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
return (
|
||||
<div className='bg-gray-900 text-white'>
|
||||
<div className='mt-8 flex justify-between items-center'>
|
||||
<h4 className='text-xl font-extrabold'>
|
||||
Merge Conflicts Detected
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className='bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded transition'>
|
||||
Resolve Conflicts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='mt-6 p-4 bg-gray-800 rounded-lg shadow-md'>
|
||||
<h3 className='text-xl font-semibold'>What Happened?</h3>
|
||||
<p className='mt-2 text-gray-300'>
|
||||
This page is locked because there are unresolved merge
|
||||
conflicts. You need to address these conflicts in the
|
||||
settings page before continuing.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex justify-center items-center h-64'>
|
||||
<Loader className='w-8 h-8 animate-spin text-blue-500' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -180,25 +136,24 @@ function RegexPage() {
|
||||
allTags={allTags}
|
||||
/>
|
||||
</div>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-4'>
|
||||
{sortedAndFilteredRegexes.map(regex => (
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4'>
|
||||
{getFilteredAndSortedPatterns().map(pattern => (
|
||||
<RegexCard
|
||||
key={regex.id}
|
||||
regex={regex}
|
||||
onEdit={() => handleOpenModal(regex)}
|
||||
onClone={handleCloneRegex} // Pass the clone handler
|
||||
showDate={sortBy !== 'title'}
|
||||
key={pattern.name}
|
||||
pattern={pattern}
|
||||
onEdit={() => handleOpenModal(pattern)}
|
||||
onClone={handleClonePattern}
|
||||
formatDate={formatDate}
|
||||
sortBy={sortBy}
|
||||
/>
|
||||
))}
|
||||
<AddNewCard onAdd={() => handleOpenModal()} />
|
||||
</div>
|
||||
<RegexModal
|
||||
regex={selectedRegex}
|
||||
pattern={selectedPattern}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onSave={handleSaveRegex}
|
||||
allTags={allTags}
|
||||
onSave={handleSavePattern}
|
||||
isCloning={isCloning}
|
||||
/>
|
||||
</div>
|
||||
|
||||
181
frontend/src/components/regex/RegexTestingTab.jsx
Normal file
181
frontend/src/components/regex/RegexTestingTab.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
// RegexTestingTab.jsx
|
||||
import React, {useState, useCallback} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Plus, Loader, Play} from 'lucide-react';
|
||||
import UnitTest from './UnitTest';
|
||||
import AddUnitTestModal from './AddUnitTestModal';
|
||||
|
||||
const formatTestDate = dateString => {
|
||||
if (!dateString) return null;
|
||||
|
||||
try {
|
||||
return new Date(dateString).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const RegexTestingTab = ({
|
||||
pattern,
|
||||
tests,
|
||||
onTestsChange,
|
||||
isRunningTests,
|
||||
onRunTests
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingTest, setEditingTest] = useState(null);
|
||||
|
||||
const handleAddOrUpdateTest = useCallback(
|
||||
testData => {
|
||||
let updatedTests;
|
||||
if (editingTest) {
|
||||
// Update existing test
|
||||
updatedTests = tests.map(test =>
|
||||
test.id === testData.id ? testData : test
|
||||
);
|
||||
} else {
|
||||
// Add new test
|
||||
updatedTests = [...tests, testData];
|
||||
}
|
||||
onTestsChange(updatedTests);
|
||||
onRunTests(pattern, updatedTests);
|
||||
setEditingTest(null);
|
||||
},
|
||||
[tests, onTestsChange, onRunTests, pattern, editingTest]
|
||||
);
|
||||
|
||||
const handleEditTest = useCallback(test => {
|
||||
setEditingTest(test);
|
||||
setIsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteTest = useCallback(
|
||||
testId => {
|
||||
const updatedTests = tests.filter(test => test.id !== testId);
|
||||
onTestsChange(updatedTests);
|
||||
},
|
||||
[tests, onTestsChange]
|
||||
);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingTest(null);
|
||||
}, []);
|
||||
|
||||
// Calculate test statistics
|
||||
const totalTests = tests?.length || 0;
|
||||
const passedTests = tests?.filter(test => test.passes)?.length || 0;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col h-full'>
|
||||
{/* Header Section with Progress Bar */}
|
||||
<div className='flex items-center justify-between pb-4 pr-2'>
|
||||
<div>
|
||||
<h2 className='text-xl font-semibold text-gray-900 dark:text-white mb-3'>
|
||||
Unit Tests
|
||||
</h2>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='h-1.5 w-32 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden'>
|
||||
<div
|
||||
className='h-full bg-emerald-500 rounded-full transition-all duration-300'
|
||||
style={{
|
||||
width: `${
|
||||
totalTests
|
||||
? (passedTests / totalTests) * 100
|
||||
: 0
|
||||
}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className='text-sm text-gray-600 dark:text-gray-300'>
|
||||
{totalTests > 0
|
||||
? `${passedTests}/${totalTests} tests passing`
|
||||
: 'No tests added yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{tests?.length > 0 && (
|
||||
<button
|
||||
onClick={() => onRunTests(pattern, tests)}
|
||||
disabled={isRunningTests}
|
||||
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-green-600/50 text-white'>
|
||||
{isRunningTests ? (
|
||||
<Loader className='w-4 h-4 mr-2 animate-spin' />
|
||||
) : (
|
||||
<Play className='w-4 h-4 mr-2' />
|
||||
)}
|
||||
Run Tests
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className='inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 text-white'>
|
||||
<Plus className='w-4 h-4 mr-2' />
|
||||
Add Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test List */}
|
||||
<div className='flex-1 overflow-y-auto pr-2'>
|
||||
{tests?.length > 0 ? (
|
||||
<div className='space-y-3'>
|
||||
{tests.map(test => (
|
||||
<UnitTest
|
||||
key={test.id}
|
||||
test={{
|
||||
...test,
|
||||
lastRun: formatTestDate(test.lastRun)
|
||||
}}
|
||||
pattern={pattern}
|
||||
onDelete={() => handleDeleteTest(test.id)}
|
||||
onEdit={() => handleEditTest(test)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
|
||||
<p className='text-gray-500 dark:text-gray-400'>
|
||||
No tests added yet
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddUnitTestModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onAdd={handleAddOrUpdateTest}
|
||||
editTest={editingTest}
|
||||
tests={tests}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RegexTestingTab.propTypes = {
|
||||
pattern: PropTypes.string.isRequired,
|
||||
tests: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
input: PropTypes.string.isRequired,
|
||||
expected: PropTypes.bool.isRequired,
|
||||
passes: PropTypes.bool.isRequired,
|
||||
lastRun: PropTypes.string
|
||||
})
|
||||
),
|
||||
onTestsChange: PropTypes.func.isRequired,
|
||||
isRunningTests: PropTypes.bool.isRequired,
|
||||
onRunTests: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default RegexTestingTab;
|
||||
222
frontend/src/components/regex/UnitTest.jsx
Normal file
222
frontend/src/components/regex/UnitTest.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, {useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Trash2, Pencil} from 'lucide-react';
|
||||
import DeleteConfirmationModal from './DeleteConfirmationModal';
|
||||
|
||||
const MatchHighlight = ({input, pattern, test}) => {
|
||||
if (!pattern) return <span className='font-mono'>{input}</span>;
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'g');
|
||||
const matches = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(input)) !== null) {
|
||||
// Avoid infinite loops with zero-length matches
|
||||
if (match.index === regex.lastIndex) {
|
||||
regex.lastIndex++;
|
||||
}
|
||||
matches.push(match);
|
||||
}
|
||||
|
||||
if (!matches.length) {
|
||||
return <span className='font-mono text-gray-100'>{input}</span>;
|
||||
}
|
||||
|
||||
let segments = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
matches.forEach(match => {
|
||||
let matchText = '';
|
||||
let matchStart = match.index;
|
||||
|
||||
// Use capturing groups if they exist
|
||||
let capturingGroupIndex = 1;
|
||||
while (
|
||||
capturingGroupIndex < match.length &&
|
||||
!match[capturingGroupIndex]
|
||||
) {
|
||||
capturingGroupIndex++;
|
||||
}
|
||||
if (capturingGroupIndex < match.length) {
|
||||
matchText = match[capturingGroupIndex];
|
||||
|
||||
// Find the position of matchText in the input, starting from match.index
|
||||
matchStart = input.indexOf(matchText, match.index);
|
||||
|
||||
if (matchStart === -1) {
|
||||
// If not found, skip this match
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// No capturing group match, use full match
|
||||
matchText = match[0];
|
||||
}
|
||||
|
||||
// Add non-highlighted segment before the match
|
||||
if (matchStart > lastIndex) {
|
||||
segments.push({
|
||||
text: input.slice(lastIndex, matchStart),
|
||||
highlight: false
|
||||
});
|
||||
}
|
||||
|
||||
// Add the highlighted match
|
||||
if (matchText.length > 0) {
|
||||
segments.push({
|
||||
text: matchText,
|
||||
highlight: true
|
||||
});
|
||||
lastIndex = matchStart + matchText.length;
|
||||
} else {
|
||||
// Handle zero-length matches
|
||||
lastIndex = matchStart;
|
||||
}
|
||||
});
|
||||
|
||||
// Add any remaining non-highlighted text
|
||||
if (lastIndex < input.length) {
|
||||
segments.push({
|
||||
text: input.slice(lastIndex),
|
||||
highlight: false
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<span className='font-mono'>
|
||||
<span className='bg-green-900/20 rounded px-0.5'>
|
||||
{segments.map((segment, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={
|
||||
segment.highlight
|
||||
? test.passes
|
||||
? 'bg-emerald-200 dark:bg-emerald-600 text-emerald-900 dark:text-emerald-100 px-0.5 rounded'
|
||||
: 'bg-red-200 dark:bg-red-600 text-red-900 dark:text-red-100 px-0.5 rounded'
|
||||
: 'text-gray-100'
|
||||
}>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Regex error:', error);
|
||||
return <span className='font-mono text-gray-100'>{input}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const UnitTest = ({test, pattern, onDelete, onEdit}) => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onDelete();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
relative rounded-lg border group
|
||||
${
|
||||
test.passes
|
||||
? 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20'
|
||||
: 'border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20'
|
||||
}
|
||||
`}>
|
||||
{/* Header */}
|
||||
<div className='px-4 py-2 pr-2 flex items-center justify-between border-b border-inherit'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div
|
||||
className={`
|
||||
w-2 h-2 rounded-full
|
||||
${
|
||||
test.passes
|
||||
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
|
||||
: 'bg-red-500 shadow-sm shadow-red-500/50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium
|
||||
${
|
||||
test.passes
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}
|
||||
`}>
|
||||
{test.expected
|
||||
? 'Should Match'
|
||||
: 'Should Not Match'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400'>
|
||||
Last run: {test.lastRun}
|
||||
</span>
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
|
||||
<Pencil className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className='p-1 rounded shrink-0 transition-transform transform hover:scale-110'>
|
||||
<Trash2 className='w-4 h-4 text-gray-500 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className='p-2 flex items-start gap-3'>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='rounded bg-white/75 dark:bg-black/25 px-2 py-1.5 text-xs'>
|
||||
<MatchHighlight
|
||||
input={test.input}
|
||||
pattern={pattern}
|
||||
test={test}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
UnitTest.propTypes = {
|
||||
test: PropTypes.shape({
|
||||
id: PropTypes.number.isRequired,
|
||||
input: PropTypes.string.isRequired,
|
||||
expected: PropTypes.bool.isRequired,
|
||||
passes: PropTypes.bool.isRequired,
|
||||
lastRun: PropTypes.string
|
||||
}).isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
onEdit: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MatchHighlight.propTypes = {
|
||||
input: PropTypes.string.isRequired,
|
||||
pattern: PropTypes.string.isRequired,
|
||||
test: PropTypes.shape({
|
||||
passes: PropTypes.bool.isRequired
|
||||
}).isRequired
|
||||
};
|
||||
|
||||
export default UnitTest;
|
||||
@@ -18,7 +18,6 @@ const Modal = ({
|
||||
}) => {
|
||||
const modalRef = useRef();
|
||||
const [activeTab, setActiveTab] = useState(tabs?.[0]?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !disableCloseOnEscape) {
|
||||
const handleEscape = event => {
|
||||
@@ -34,10 +33,15 @@ const Modal = ({
|
||||
}, [isOpen, onClose, disableCloseOnEscape]);
|
||||
|
||||
const handleClickOutside = e => {
|
||||
// Get the current selection
|
||||
const selection = window.getSelection();
|
||||
const hasSelection = selection && selection.toString().length > 0;
|
||||
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(e.target) &&
|
||||
!disableCloseOnOutsideClick
|
||||
!disableCloseOnOutsideClick &&
|
||||
!hasSelection // Don't close if there's text selected
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
125
frontend/src/hooks/useRegexModal.js
Normal file
125
frontend/src/hooks/useRegexModal.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import {useState, useCallback} from 'react';
|
||||
import {RegexPatterns} from '@api/data';
|
||||
import Alert from '@ui/Alert';
|
||||
import {useRegexTesting} from './useRegexTesting';
|
||||
|
||||
export const useRegexModal = (initialPattern, onSave) => {
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [originalName, setOriginalName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [patternValue, setPatternValue] = useState('');
|
||||
const [tags, setTags] = useState([]);
|
||||
const [tests, setTests] = useState([]);
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [error, setError] = useState('');
|
||||
const [patternError, setPatternError] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Initialize testing functionality
|
||||
const {isRunningTests, runTests} = useRegexTesting();
|
||||
|
||||
const initializeForm = useCallback((pattern, cloning) => {
|
||||
setIsCloning(cloning || false);
|
||||
if (pattern) {
|
||||
const initialName = cloning ? `${pattern.name}` : pattern.name;
|
||||
setName(initialName);
|
||||
setOriginalName(cloning ? '' : pattern.name);
|
||||
setDescription(pattern.description || '');
|
||||
setPatternValue(pattern.pattern || '');
|
||||
setTags(pattern.tags || []);
|
||||
setTests(pattern.tests || []);
|
||||
} else {
|
||||
setName('');
|
||||
setOriginalName('');
|
||||
setDescription('');
|
||||
setPatternValue('');
|
||||
setTags([]);
|
||||
setTests([]);
|
||||
}
|
||||
setError('');
|
||||
setPatternError('');
|
||||
setIsDeleting(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const data = {
|
||||
name,
|
||||
pattern: patternValue,
|
||||
description,
|
||||
tags,
|
||||
tests
|
||||
};
|
||||
|
||||
// Validation checks
|
||||
if (!name.trim()) {
|
||||
Alert.error('Name is required');
|
||||
return;
|
||||
}
|
||||
if (!patternValue.trim()) {
|
||||
Alert.error('Pattern is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (initialPattern && !isCloning) {
|
||||
// Check if name has changed
|
||||
const hasNameChanged = name !== originalName;
|
||||
await RegexPatterns.update(
|
||||
initialPattern.file_name.replace('.yml', ''),
|
||||
data,
|
||||
hasNameChanged ? name : undefined // Only pass new name if it changed
|
||||
);
|
||||
Alert.success('Pattern updated successfully');
|
||||
} else {
|
||||
await RegexPatterns.create(data);
|
||||
Alert.success('Pattern created successfully');
|
||||
}
|
||||
onSave();
|
||||
} catch (error) {
|
||||
console.error('Error saving pattern:', error);
|
||||
Alert.error('Failed to save pattern. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunTests = useCallback(
|
||||
async (pattern, tests) => {
|
||||
const updatedTests = await runTests(pattern, tests);
|
||||
if (updatedTests) {
|
||||
setTests(updatedTests);
|
||||
}
|
||||
},
|
||||
[runTests]
|
||||
);
|
||||
|
||||
return {
|
||||
// Form state
|
||||
name,
|
||||
description,
|
||||
patternValue,
|
||||
tags,
|
||||
tests,
|
||||
// UI state
|
||||
error,
|
||||
patternError,
|
||||
activeTab,
|
||||
isDeleting,
|
||||
isRunningTests,
|
||||
isCloning,
|
||||
// Actions
|
||||
setName,
|
||||
setDescription,
|
||||
setPatternValue,
|
||||
setTags,
|
||||
setTests,
|
||||
setActiveTab,
|
||||
setIsDeleting,
|
||||
// Main handlers
|
||||
initializeForm,
|
||||
handleSave,
|
||||
handleRunTests
|
||||
};
|
||||
};
|
||||
58
frontend/src/hooks/useRegexTesting.js
Normal file
58
frontend/src/hooks/useRegexTesting.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// useRegexTesting.js
|
||||
import {useState, useCallback} from 'react';
|
||||
import {RegexPatterns} from '@api/data';
|
||||
import Alert from '@ui/Alert';
|
||||
|
||||
export const useRegexTesting = onUpdateTests => {
|
||||
const [isRunningTests, setIsRunningTests] = useState(false);
|
||||
|
||||
const runTests = useCallback(
|
||||
async (pattern, tests) => {
|
||||
if (!pattern?.trim() || !tests?.length) {
|
||||
return tests;
|
||||
}
|
||||
|
||||
setIsRunningTests(true);
|
||||
try {
|
||||
const result = await RegexPatterns.runTests(pattern, tests);
|
||||
if (result.success) {
|
||||
// Calculate test statistics
|
||||
const totalTests = result.tests.length;
|
||||
const passedTests = result.tests.filter(
|
||||
test => test.passes
|
||||
).length;
|
||||
|
||||
// Show success message with statistics
|
||||
Alert.success(
|
||||
`Tests completed: ${passedTests}/${totalTests} passed`,
|
||||
{
|
||||
autoClose: 3000,
|
||||
hideProgressBar: false
|
||||
}
|
||||
);
|
||||
|
||||
// Update tests through the callback
|
||||
if (onUpdateTests) {
|
||||
onUpdateTests(result.tests);
|
||||
}
|
||||
return result.tests;
|
||||
} else {
|
||||
Alert.error(result.message || 'Failed to run tests');
|
||||
return tests;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error running tests:', error);
|
||||
Alert.error('An error occurred while running tests');
|
||||
return tests;
|
||||
} finally {
|
||||
setIsRunningTests(false);
|
||||
}
|
||||
},
|
||||
[onUpdateTests]
|
||||
);
|
||||
|
||||
return {
|
||||
isRunningTests,
|
||||
runTests
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@components': path.resolve(__dirname, './src/components'),
|
||||
'@hooks': path.resolve(__dirname, './src/hooks'),
|
||||
'@ui': path.resolve(__dirname, './src/components/ui'),
|
||||
'@assets': path.resolve(__dirname, './src/assets'),
|
||||
'@logo': path.resolve(__dirname, './src/assets/logo'),
|
||||
|
||||
Reference in New Issue
Block a user