[MM-64405] continously make syntax check (#31197)

* continously make syntax check

* reflect UX feedback
Этот коммит содержится в:
Ibrahim Serdar Acikgoz
2025-06-03 11:10:25 +02:00
коммит произвёл GitHub
родитель 147a5a0bd6
Коммит ec75435f3f
2 изменённых файлов: 157 добавлений и 123 удалений

Просмотреть файл

@@ -2,12 +2,13 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import React, {useCallback, useEffect, useRef, useState} from 'react'; import React, {useCallback, useEffect, useRef, useState, useMemo} from 'react';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import type {AccessControlTestResult} from '@mattermost/types/access_control'; import type {AccessControlTestResult} from '@mattermost/types/access_control';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control'; import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {debounce} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client'; import {Client4} from 'mattermost-redux/client';
import {MonacoLanguageProvider} from './language_provider'; import {MonacoLanguageProvider} from './language_provider';
@@ -19,7 +20,6 @@ import {TestButton, HelpText} from '../shared';
import './editor.scss'; import './editor.scss';
export const POLICY_LANGUAGE = 'expressionLanguage'; export const POLICY_LANGUAGE = 'expressionLanguage';
const VALIDATE_POLICY_SYNTAX_COMMAND_ID = 'policyEditorValidateSyntaxCommand';
const MONACO_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = { const MONACO_EDITOR_OPTIONS: monaco.editor.IStandaloneEditorConstructionOptions = {
extraEditorClassName: 'policyEditor', extraEditorClassName: 'policyEditor',
@@ -98,6 +98,7 @@ function CELEditor({
statusBarColor: 'var(--button-bg)', statusBarColor: 'var(--button-bg)',
showTestResults: false, showTestResults: false,
testResults: null as AccessControlTestResult | null, testResults: null as AccessControlTestResult | null,
isWaitingForValidation: false,
}); });
const schemas = { const schemas = {
@@ -111,6 +112,12 @@ function CELEditor({
const monacoRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null); const monacoRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const [showHelpModal, setShowHelpModal] = useState(false); const [showHelpModal, setShowHelpModal] = useState(false);
// Store the handleChange callback in a ref to avoid recreating the editor
const handleChangeRef = useRef<(value: string) => void>();
// Store the validateSyntax callback in a ref to avoid recreating debounced function
const validateSyntaxRef = useRef<(expression: string) => Promise<void>>();
useEffect(() => { useEffect(() => {
setEditorState((prev) => ({...prev, expression: value})); setEditorState((prev) => ({...prev, expression: value}));
}, [value]); }, [value]);
@@ -121,21 +128,25 @@ function CELEditor({
} }
}, [editorState.expression]); }, [editorState.expression]);
const handleChange = useCallback((newValue: string) => { const validateSyntax = useCallback(async (expression: string) => {
setEditorState((prev) => ({ // Skip validation if expression is empty
...prev, if (!expression.trim()) {
expression: newValue, setEditorState((prev) => ({
statusBarColor: 'var(--button-bg)', ...prev,
validationErrors: [], isValid: true,
})); validationErrors: [],
onChange(newValue); statusBarColor: 'var(--button-bg)',
}, [onChange]); isValidating: false,
isWaitingForValidation: false,
}));
onValidate?.(true);
return;
}
const validateSyntax = useCallback(async () => { setEditorState((prev) => ({...prev, isValidating: true, isWaitingForValidation: false}));
setEditorState((prev) => ({...prev, isValidating: true}));
try { try {
const errors = await Client4.checkAccessControlExpression(editorState.expression); const errors = await Client4.checkAccessControlExpression(expression);
const isValid = errors.length === 0; const isValid = errors.length === 0;
setEditorState((prev) => ({ setEditorState((prev) => ({
...prev, ...prev,
@@ -155,25 +166,69 @@ function CELEditor({
})); }));
onValidate?.(false); onValidate?.(false);
} }
}, [editorState.expression, onValidate]); }, [onValidate]);
// Update the validateSyntax ref whenever it changes
useEffect(() => {
validateSyntaxRef.current = validateSyntax;
}, [validateSyntax]);
// Create a stable debounced version of validateSyntax
const debouncedValidate = useMemo(
() => debounce((expression: string) => {
validateSyntaxRef.current?.(expression);
}, 1000), // 1000ms delay for typical typing speed
[], // Empty deps array ensures this is only created once
);
const handleChange = useCallback((newValue: string) => {
setEditorState((prev) => ({
...prev,
expression: newValue,
statusBarColor: 'var(--button-bg)',
validationErrors: [],
isWaitingForValidation: true,
}));
onChange(newValue);
// Trigger debounced validation
debouncedValidate(newValue);
}, [onChange, debouncedValidate]);
// Update the ref whenever handleChange changes
useEffect(() => {
handleChangeRef.current = handleChange;
}, [handleChange]);
// Validate initial value on mount
useEffect(() => {
if (value.trim()) {
validateSyntax(value);
}
}, []); // Only run on mount
// initialize monaco editor // initialize monaco editor
useEffect(() => { useEffect(() => {
if (!editorRef.current || monacoRef.current) { if (!editorRef.current || monacoRef.current) {
return () => {}; return undefined;
} }
monacoRef.current = monaco.editor.create(editorRef.current, MONACO_EDITOR_OPTIONS); // Create the editor instance
const editor = monaco.editor.create(editorRef.current, MONACO_EDITOR_OPTIONS);
monacoRef.current = editor;
// Set the initial value from the expression state // Set the initial value from the expression state
monacoRef.current.setValue(editorState.expression); editor.setValue(editorState.expression);
monacoRef.current.getModel()?.onDidChangeContent(() => { // Set up event listeners
const newValue = monacoRef.current?.getValue() || ''; const contentChangeDisposable = editor.getModel()?.onDidChangeContent(() => {
handleChange(newValue); const newValue = editor.getValue();
// Use the ref to call the latest handleChange
handleChangeRef.current?.(newValue);
}); });
monacoRef.current.onDidChangeCursorPosition((e) => { const cursorChangeDisposable = editor.onDidChangeCursorPosition((e) => {
setEditorState((prev) => ({ setEditorState((prev) => ({
...prev, ...prev,
cursorPosition: {line: e.position.lineNumber, column: e.position.column}, cursorPosition: {line: e.position.lineNumber, column: e.position.column},
@@ -186,23 +241,84 @@ function CELEditor({
command: null, command: null,
}); });
monaco.editor.addCommand({ // Cleanup function
id: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
run: validateSyntax,
});
monaco.editor.addKeybindingRule({
keybinding: monaco.KeyMod.Alt | monaco.KeyCode.Enter,
command: VALIDATE_POLICY_SYNTAX_COMMAND_ID,
});
return () => { return () => {
if (monacoRef.current) { contentChangeDisposable?.dispose();
monacoRef.current.dispose(); cursorChangeDisposable?.dispose();
monacoRef.current = null; editor.dispose();
} monacoRef.current = null;
}; };
}, []); }, []); // Only run once on mount
// Helper function to determine current validation state
const getValidationState = useCallback(() => {
if (editorState.validationErrors.length > 0) {
return 'error';
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return 'validated';
}
if (editorState.isValidating) {
return 'validating';
}
if (!editorState.expression.trim()) {
return 'empty';
}
if (editorState.isWaitingForValidation) {
return 'waiting';
}
return 'unvalidated';
}, [editorState]);
// Helper function to render status message based on state
const renderStatusMessage = useCallback((state: string) => {
switch (state) {
case 'error':
return (
<span className='cel-editor__error'>
<i className='icon icon-alert-circle-outline'/>
{editorState.validationErrors[0]}
</span>
);
case 'validated':
return (
<span className='cel-editor__valid'>
<i className='icon icon-check'/>
{'Valid'}
</span>
);
case 'validating':
return (
<span className='cel-editor__loading'>
<i className='fa fa-spinner fa-spin'/>
<FormattedMessage
id='admin.access_control.cel.validating'
defaultMessage='Validating...'
/>
</span>
);
case 'empty':
return (
<span className='cel-editor__empty'>
<FormattedMessage
id='admin.access_control.cel.type_expression'
defaultMessage='Type an expression...'
/>
</span>
);
case 'waiting':
return (
<span className='cel-editor__waiting'>
<FormattedMessage
id='admin.access_control.cel.incomplete_expression'
defaultMessage='Incomplete expression, awaiting input...'
/>
</span>
);
default:
return null;
}
}, [editorState.validationErrors]);
return ( return (
<div className={`cel-editor ${className}`}> <div className={`cel-editor ${className}`}>
@@ -228,93 +344,10 @@ function CELEditor({
<div <div
className='cel-editor__status-bar' className='cel-editor__status-bar'
style={{backgroundColor: editorState.statusBarColor}} style={{backgroundColor: editorState.statusBarColor}}
onClick={() => { data-validation-state={getValidationState()}
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}}
role='button'
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (!editorState.isValidating && editorState.validationErrors.length === 0 &&
!(editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)')) {
validateSyntax();
}
}
}}
data-validation-state={
(() => {
if (editorState.isValidating) {
return 'validating';
}
if (editorState.validationErrors.length > 0) {
return 'error';
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return 'validated';
}
return 'unvalidated';
})()
}
> >
<div className='cel-editor__status-message'> <div className='cel-editor__status-message'>
{(() => { {renderStatusMessage(getValidationState())}
if (editorState.validationErrors.length > 0) {
return (
<span className='cel-editor__error'>
<i
className='icon icon-refresh'
onClick={validateSyntax}
role='button'
aria-label='Retry validation'
/>
{editorState.validationErrors[0]}
</span>
);
}
if (editorState.isValid && editorState.statusBarColor === 'var(--online-indicator)') {
return (
<span className='cel-editor__valid'>
<i className='icon icon-check'/>
{'Valid'}
</span>
);
}
return (
<button
className='cel-editor__inline-validate-btn'
onClick={validateSyntax}
disabled={editorState.isValidating}
>
<span className='cel-editor__loading'>
{editorState.isValidating ? (
<>
<i className='fa fa-spinner fa-spin'/>
<FormattedMessage
id='admin.access_control.cel.validating'
defaultMessage='Validating...'
/>
</>
) : (
<>
<i className='icon icon-magnify'/>
<FormattedMessage
id='admin.access_control.cel.validateSyntax'
defaultMessage='Validate syntax'
/>
</>
)}
</span>
</button>
);
})()}
</div> </div>
<div className='cel-editor__cursor-position'> <div className='cel-editor__cursor-position'>
<FormattedMessage <FormattedMessage

Просмотреть файл

@@ -257,8 +257,9 @@
"admin.access_control.cel_help_modal.important_notes_title": "Important Notes", "admin.access_control.cel_help_modal.important_notes_title": "Important Notes",
"admin.access_control.cel_help_modal.subheader": "With CEL you can define conditions to filter user attributes and control resource access.", "admin.access_control.cel_help_modal.subheader": "With CEL you can define conditions to filter user attributes and control resource access.",
"admin.access_control.cel_help_modal.title": "Common Expression Language (CEL)", "admin.access_control.cel_help_modal.title": "Common Expression Language (CEL)",
"admin.access_control.cel.incomplete_expression": "Incomplete expression, awaiting input...",
"admin.access_control.cel.line_and_column_number": "L{lineNumber}:{columnNumber}", "admin.access_control.cel.line_and_column_number": "L{lineNumber}:{columnNumber}",
"admin.access_control.cel.validateSyntax": "Validate syntax", "admin.access_control.cel.type_expression": "Type an expression...",
"admin.access_control.cel.validating": "Validating...", "admin.access_control.cel.validating": "Validating...",
"admin.access_control.delete": "Delete", "admin.access_control.delete": "Delete",
"admin.access_control.edit": "Edit", "admin.access_control.edit": "Edit",