mirror of
https://git.mirrors.martin98.com/https://github.com/SigNoz/signoz
synced 2025-08-12 21:39:05 +08:00
feat: added error preview and warning text with info on cyclic dependency detected (#7893)
* feat: added error preview and warning text with info on cyclic dependency detected * feat: removed console.log * feat: restricted save when cycle found * feat: added test cases for variableitem flow and update test cases * feat: updated test cases * feat: corrected the recent resolved title text
This commit is contained in:
parent
88e1e42bf0
commit
10ba0e6b4f
@ -0,0 +1,474 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||||
|
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||||
|
import {
|
||||||
|
IDashboardVariable,
|
||||||
|
TSortVariableValuesType,
|
||||||
|
VariableSortTypeArr,
|
||||||
|
} from 'types/api/dashboard/getAll';
|
||||||
|
|
||||||
|
import VariableItem from './VariableItem';
|
||||||
|
|
||||||
|
// Mock modules
|
||||||
|
jest.mock('api/dashboard/variables/dashboardVariablesQuery', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn().mockResolvedValue({
|
||||||
|
payload: {
|
||||||
|
variableValues: ['value1', 'value2', 'value3'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({
|
||||||
|
v4: jest.fn().mockReturnValue('test-uuid'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock functions
|
||||||
|
const onCancel = jest.fn();
|
||||||
|
const onSave = jest.fn();
|
||||||
|
const validateName = jest.fn(() => true);
|
||||||
|
|
||||||
|
// Mode constant
|
||||||
|
const VARIABLE_MODE = 'ADD';
|
||||||
|
|
||||||
|
// Common text constants
|
||||||
|
const TEXT = {
|
||||||
|
INCLUDE_ALL_VALUES: 'Include an option for ALL values',
|
||||||
|
ENABLE_MULTI_VALUES: 'Enable multiple values to be checked',
|
||||||
|
VARIABLE_EXISTS: 'Variable name already exists',
|
||||||
|
SORT_VALUES: 'Sort Values',
|
||||||
|
DEFAULT_VALUE: 'Default Value',
|
||||||
|
ALL_VARIABLES: 'All variables',
|
||||||
|
DISCARD: 'Discard',
|
||||||
|
OPTIONS: 'Options',
|
||||||
|
QUERY: 'Query',
|
||||||
|
TEXTBOX: 'Textbox',
|
||||||
|
CUSTOM: 'Custom',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common test constants
|
||||||
|
const VARIABLE_DEFAULTS = {
|
||||||
|
sort: VariableSortTypeArr[0] as TSortVariableValuesType,
|
||||||
|
multiSelect: false,
|
||||||
|
showALLOption: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common variable properties
|
||||||
|
const TEST_VAR_NAMES = {
|
||||||
|
VAR1: 'variable1',
|
||||||
|
VAR2: 'variable2',
|
||||||
|
VAR3: 'variable3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_VAR_IDS = {
|
||||||
|
VAR1: 'var1',
|
||||||
|
VAR2: 'var2',
|
||||||
|
VAR3: 'var3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_VAR_DESCRIPTIONS = {
|
||||||
|
VAR1: 'Variable 1',
|
||||||
|
VAR2: 'Variable 2',
|
||||||
|
VAR3: 'Variable 3',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common UI elements
|
||||||
|
const SAVE_BUTTON_TEXT = 'Save Variable';
|
||||||
|
const UNIQUE_NAME_PLACEHOLDER = 'Unique name of the variable';
|
||||||
|
|
||||||
|
// Create QueryClient for wrapping the component
|
||||||
|
const createTestQueryClient = (): QueryClient =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrapper component with QueryClientProvider
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||||
|
<QueryClientProvider client={createTestQueryClient()}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Basic variable data for testing
|
||||||
|
const basicVariableData: IDashboardVariable = {
|
||||||
|
id: TEST_VAR_IDS.VAR1,
|
||||||
|
name: TEST_VAR_NAMES.VAR1,
|
||||||
|
description: 'Test Variable 1',
|
||||||
|
type: 'QUERY',
|
||||||
|
queryValue: 'SELECT * FROM test',
|
||||||
|
...VARIABLE_DEFAULTS,
|
||||||
|
order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to render VariableItem with common props
|
||||||
|
const renderVariableItem = (
|
||||||
|
variableData: IDashboardVariable = basicVariableData,
|
||||||
|
existingVariables: Record<string, IDashboardVariable> = {},
|
||||||
|
validateNameFn = validateName,
|
||||||
|
): void => {
|
||||||
|
render(
|
||||||
|
<VariableItem
|
||||||
|
variableData={variableData}
|
||||||
|
existingVariables={existingVariables}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onSave={onSave}
|
||||||
|
validateName={validateNameFn}
|
||||||
|
mode={VARIABLE_MODE}
|
||||||
|
/>,
|
||||||
|
{ wrapper } as any,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to find button by text within its span
|
||||||
|
const findButtonByText = (text: string): HTMLElement | null => {
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
return buttons.find((button) => button.textContent?.includes(text)) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('VariableItem Component', () => {
|
||||||
|
// Test SQL query patterns
|
||||||
|
const SQL_PATTERN_DOT = 'SELECT * FROM test WHERE env = {{.variable2}}';
|
||||||
|
const SQL_PATTERN_DOLLAR = 'SELECT * FROM test WHERE env = $variable2';
|
||||||
|
const SQL_PATTERN_BRACKET = 'SELECT * FROM test WHERE service = [[variable3]]';
|
||||||
|
const SQL_PATTERN_BRACES = 'SELECT * FROM test WHERE app = {{variable1}}';
|
||||||
|
const SQL_PATTERN_NO_VARS = 'SELECT * FROM test WHERE env = "prod"';
|
||||||
|
const SQL_PATTERN_DOT_VAR1 =
|
||||||
|
'SELECT * FROM test WHERE service = {{.variable1}}';
|
||||||
|
|
||||||
|
// Error message text constant
|
||||||
|
const CIRCULAR_DEPENDENCY_ERROR = /Cannot save: Circular dependency detected/;
|
||||||
|
|
||||||
|
// Test functions and utilities
|
||||||
|
const createVariable = (
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
queryValue: string,
|
||||||
|
order: number,
|
||||||
|
): IDashboardVariable => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
type: 'QUERY',
|
||||||
|
queryValue,
|
||||||
|
...VARIABLE_DEFAULTS,
|
||||||
|
order,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without crashing', () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
expect(screen.getByText(TEXT.ALL_VARIABLES)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Description')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Variable Type')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Variable Name Validation', () => {
|
||||||
|
test('shows error when variable name already exists', () => {
|
||||||
|
// Set validateName to return false (name exists)
|
||||||
|
const mockValidateName = jest.fn().mockReturnValue(false);
|
||||||
|
|
||||||
|
renderVariableItem({ ...basicVariableData, name: '' }, {}, mockValidateName);
|
||||||
|
|
||||||
|
// Enter a name that already exists
|
||||||
|
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
|
||||||
|
fireEvent.change(nameInput, { target: { value: 'existingVariable' } });
|
||||||
|
|
||||||
|
// Error message should be displayed
|
||||||
|
expect(screen.getByText(TEXT.VARIABLE_EXISTS)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// We won't check for button disabled state as it might be inconsistent in tests
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows save when current variable name is used', () => {
|
||||||
|
// Mock validate to return false for all other names but true for own name
|
||||||
|
const mockValidateName = jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((name) => name === TEST_VAR_NAMES.VAR1);
|
||||||
|
|
||||||
|
renderVariableItem(basicVariableData, {}, mockValidateName);
|
||||||
|
|
||||||
|
// Enter the current variable name
|
||||||
|
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
|
||||||
|
fireEvent.change(nameInput, { target: { value: TEST_VAR_NAMES.VAR1 } });
|
||||||
|
|
||||||
|
// Error should not be visible
|
||||||
|
expect(screen.queryByText(TEXT.VARIABLE_EXISTS)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Variable Type Switching', () => {
|
||||||
|
test('switches to CUSTOM variable type correctly', () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
// Find the Query button
|
||||||
|
const queryButton = findButtonByText(TEXT.QUERY);
|
||||||
|
expect(queryButton).toBeInTheDocument();
|
||||||
|
expect(queryButton).toHaveClass('selected');
|
||||||
|
|
||||||
|
// Find and click Custom button
|
||||||
|
const customButton = findButtonByText(TEXT.CUSTOM);
|
||||||
|
expect(customButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
if (customButton) {
|
||||||
|
fireEvent.click(customButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom button should now be selected
|
||||||
|
expect(customButton).toHaveClass('selected');
|
||||||
|
expect(queryButton).not.toHaveClass('selected');
|
||||||
|
|
||||||
|
// Custom options input should appear
|
||||||
|
expect(screen.getByText(TEXT.OPTIONS)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switches to TEXTBOX variable type correctly', () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
// Find and click Textbox button
|
||||||
|
const textboxButton = findButtonByText(TEXT.TEXTBOX);
|
||||||
|
expect(textboxButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
if (textboxButton) {
|
||||||
|
fireEvent.click(textboxButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textbox button should now be selected
|
||||||
|
expect(textboxButton).toHaveClass('selected');
|
||||||
|
|
||||||
|
// Default Value input should appear
|
||||||
|
expect(screen.getByText(TEXT.DEFAULT_VALUE)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByPlaceholderText('Enter a default value (if any)...'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MultiSelect and ALL Option', () => {
|
||||||
|
test('enables ALL option only when multiSelect is enabled', async () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
// Initially, ALL option should not be visible
|
||||||
|
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Enable multiple values
|
||||||
|
const multipleValuesSwitch = screen
|
||||||
|
.getByText(TEXT.ENABLE_MULTI_VALUES)
|
||||||
|
.closest('.multiple-values-section')
|
||||||
|
?.querySelector('button');
|
||||||
|
|
||||||
|
expect(multipleValuesSwitch).toBeInTheDocument();
|
||||||
|
if (multipleValuesSwitch) {
|
||||||
|
fireEvent.click(multipleValuesSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now ALL option should be visible
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable multiple values
|
||||||
|
if (multipleValuesSwitch) {
|
||||||
|
fireEvent.click(multipleValuesSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALL option should be hidden again
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables ALL option when multiSelect is disabled', async () => {
|
||||||
|
// Create variable with multiSelect and showALLOption both enabled
|
||||||
|
const variable: IDashboardVariable = {
|
||||||
|
...basicVariableData,
|
||||||
|
multiSelect: true,
|
||||||
|
showALLOption: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
renderVariableItem(variable);
|
||||||
|
|
||||||
|
// ALL option should be visible initially
|
||||||
|
expect(screen.getByText(TEXT.INCLUDE_ALL_VALUES)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Disable multiple values
|
||||||
|
const multipleValuesSwitch = screen
|
||||||
|
.getByText(TEXT.ENABLE_MULTI_VALUES)
|
||||||
|
.closest('.multiple-values-section')
|
||||||
|
?.querySelector('button');
|
||||||
|
|
||||||
|
if (multipleValuesSwitch) {
|
||||||
|
fireEvent.click(multipleValuesSwitch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALL option should be hidden
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText(TEXT.INCLUDE_ALL_VALUES)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that when saving, showALLOption is set to false
|
||||||
|
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({
|
||||||
|
multiSelect: false,
|
||||||
|
showALLOption: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cancel and Navigation', () => {
|
||||||
|
test('calls onCancel when clicking All Variables button', () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
// Click All variables button
|
||||||
|
const allVariablesButton = screen.getByText(TEXT.ALL_VARIABLES);
|
||||||
|
fireEvent.click(allVariablesButton);
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onCancel when clicking Discard button', () => {
|
||||||
|
renderVariableItem();
|
||||||
|
|
||||||
|
// Click Discard button
|
||||||
|
const discardButton = screen.getByText(TEXT.DISCARD);
|
||||||
|
fireEvent.click(discardButton);
|
||||||
|
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cyclic Dependency Detection', () => {
|
||||||
|
// Common function to render the component with variables and click save
|
||||||
|
const renderAndSave = async (
|
||||||
|
variableData: IDashboardVariable,
|
||||||
|
existingVariables: Record<string, IDashboardVariable>,
|
||||||
|
): Promise<void> => {
|
||||||
|
renderVariableItem(variableData, existingVariables);
|
||||||
|
|
||||||
|
// Fill in the variable name if it's not already populated
|
||||||
|
const nameInput = screen.getByPlaceholderText(UNIQUE_NAME_PLACEHOLDER);
|
||||||
|
if (nameInput.getAttribute('value') === '') {
|
||||||
|
fireEvent.change(nameInput, { target: { value: variableData.name || '' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click save button to trigger the dependency check
|
||||||
|
const saveButton = screen.getByText(SAVE_BUTTON_TEXT);
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Common expectations for finding circular dependency error
|
||||||
|
const expectCircularDependencyError = async (): Promise<void> => {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(CIRCULAR_DEPENDENCY_ERROR)).toBeInTheDocument();
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test for cyclic dependency detection
|
||||||
|
test('detects circular dependency and shows error message', async () => {
|
||||||
|
// Create variables with circular dependency
|
||||||
|
const variable1 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR1,
|
||||||
|
TEST_VAR_NAMES.VAR1,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR1,
|
||||||
|
SQL_PATTERN_DOT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const variable2 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR2,
|
||||||
|
TEST_VAR_NAMES.VAR2,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR2,
|
||||||
|
SQL_PATTERN_DOT_VAR1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingVariables = {
|
||||||
|
[TEST_VAR_IDS.VAR2]: variable2,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderAndSave(variable1, existingVariables);
|
||||||
|
await expectCircularDependencyError();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test for saving with no circular dependency
|
||||||
|
test('allows saving when no circular dependency exists', async () => {
|
||||||
|
// Create variables without circular dependency
|
||||||
|
const variable1 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR1,
|
||||||
|
TEST_VAR_NAMES.VAR1,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR1,
|
||||||
|
SQL_PATTERN_NO_VARS,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const variable2 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR2,
|
||||||
|
TEST_VAR_NAMES.VAR2,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR2,
|
||||||
|
SQL_PATTERN_DOT_VAR1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingVariables = {
|
||||||
|
[TEST_VAR_IDS.VAR2]: variable2,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderAndSave(variable1, existingVariables);
|
||||||
|
|
||||||
|
// Verify the onSave function was called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with multiple variable formats in query
|
||||||
|
test('detects circular dependency with different variable formats', async () => {
|
||||||
|
// Create variables with circular dependency using different formats
|
||||||
|
const variable1 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR1,
|
||||||
|
TEST_VAR_NAMES.VAR1,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR1,
|
||||||
|
SQL_PATTERN_DOLLAR,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const variable2 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR2,
|
||||||
|
TEST_VAR_NAMES.VAR2,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR2,
|
||||||
|
SQL_PATTERN_BRACKET,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const variable3 = createVariable(
|
||||||
|
TEST_VAR_IDS.VAR3,
|
||||||
|
TEST_VAR_NAMES.VAR3,
|
||||||
|
TEST_VAR_DESCRIPTIONS.VAR3,
|
||||||
|
SQL_PATTERN_BRACES,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingVariables = {
|
||||||
|
[TEST_VAR_IDS.VAR2]: variable2,
|
||||||
|
[TEST_VAR_IDS.VAR3]: variable3,
|
||||||
|
};
|
||||||
|
|
||||||
|
await renderAndSave(variable1, existingVariables);
|
||||||
|
await expectCircularDependencyError();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -28,6 +28,10 @@ import {
|
|||||||
} from 'types/api/dashboard/getAll';
|
} from 'types/api/dashboard/getAll';
|
||||||
import { v4 as generateUUID } from 'uuid';
|
import { v4 as generateUUID } from 'uuid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildDependencies,
|
||||||
|
buildDependencyGraph,
|
||||||
|
} from '../../../DashboardVariablesSelection/util';
|
||||||
import { variablePropsToPayloadVariables } from '../../../utils';
|
import { variablePropsToPayloadVariables } from '../../../utils';
|
||||||
import { TVariableMode } from '../types';
|
import { TVariableMode } from '../types';
|
||||||
import { LabelContainer, VariableItemRow } from './styles';
|
import { LabelContainer, VariableItemRow } from './styles';
|
||||||
@ -107,7 +111,8 @@ function VariableItem({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSave = (): void => {
|
const handleSave = (): void => {
|
||||||
const variable: IDashboardVariable = {
|
// Check for cyclic dependencies
|
||||||
|
const newVariable = {
|
||||||
name: variableName,
|
name: variableName,
|
||||||
description: variableDescription,
|
description: variableDescription,
|
||||||
type: queryType,
|
type: queryType,
|
||||||
@ -126,7 +131,21 @@ function VariableItem({
|
|||||||
order: variableData.order,
|
order: variableData.order,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSave(mode, variable);
|
const allVariables = [...Object.values(existingVariables), newVariable];
|
||||||
|
|
||||||
|
const dependencies = buildDependencies(allVariables);
|
||||||
|
const { hasCycle, cycleNodes } = buildDependencyGraph(dependencies);
|
||||||
|
|
||||||
|
if (hasCycle) {
|
||||||
|
setErrorPreview(
|
||||||
|
`Cannot save: Circular dependency detected between variables: ${cycleNodes?.join(
|
||||||
|
' → ',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(mode, newVariable);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetches the preview values for the SQL variable query
|
// Fetches the preview values for the SQL variable query
|
||||||
|
@ -106,3 +106,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cycle-error-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { Row } from 'antd';
|
import './DashboardVariableSelection.styles.scss';
|
||||||
|
|
||||||
|
import { Alert, Row } from 'antd';
|
||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
@ -64,7 +66,7 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variablesTableData.length > 0) {
|
if (variablesTableData.length > 0) {
|
||||||
const depGrp = buildDependencies(variablesTableData);
|
const depGrp = buildDependencies(variablesTableData);
|
||||||
const { order, graph } = buildDependencyGraph(depGrp);
|
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
|
||||||
const parentDependencyGraph = buildParentDependencyGraph(graph);
|
const parentDependencyGraph = buildParentDependencyGraph(graph);
|
||||||
|
|
||||||
// cleanup order to only include variables that are of type 'QUERY'
|
// cleanup order to only include variables that are of type 'QUERY'
|
||||||
@ -79,6 +81,8 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
order: cleanedOrder,
|
order: cleanedOrder,
|
||||||
graph,
|
graph,
|
||||||
parentDependencyGraph,
|
parentDependencyGraph,
|
||||||
|
hasCycle,
|
||||||
|
cycleNodes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [setVariablesToGetUpdated, variables, variablesTableData]);
|
}, [setVariablesToGetUpdated, variables, variablesTableData]);
|
||||||
@ -166,25 +170,37 @@ function DashboardVariableSelection(): JSX.Element | null {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row style={{ display: 'flex', gap: '12px' }}>
|
<>
|
||||||
{orderBasedSortedVariables &&
|
{dependencyData?.hasCycle && (
|
||||||
Array.isArray(orderBasedSortedVariables) &&
|
<Alert
|
||||||
orderBasedSortedVariables.length > 0 &&
|
message={`Circular dependency detected: ${dependencyData?.cycleNodes?.join(
|
||||||
orderBasedSortedVariables.map((variable) => (
|
' → ',
|
||||||
<VariableItem
|
)}`}
|
||||||
key={`${variable.name}${variable.id}}${variable.order}`}
|
type="error"
|
||||||
existingVariables={variables}
|
showIcon
|
||||||
variableData={{
|
className="cycle-error-alert"
|
||||||
name: variable.name,
|
/>
|
||||||
...variable,
|
)}
|
||||||
}}
|
<Row style={{ display: 'flex', gap: '12px' }}>
|
||||||
onValueUpdate={onValueUpdate}
|
{orderBasedSortedVariables &&
|
||||||
variablesToGetUpdated={variablesToGetUpdated}
|
Array.isArray(orderBasedSortedVariables) &&
|
||||||
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
orderBasedSortedVariables.length > 0 &&
|
||||||
dependencyData={dependencyData}
|
orderBasedSortedVariables.map((variable) => (
|
||||||
/>
|
<VariableItem
|
||||||
))}
|
key={`${variable.name}${variable.id}}${variable.order}`}
|
||||||
</Row>
|
existingVariables={variables}
|
||||||
|
variableData={{
|
||||||
|
name: variable.name,
|
||||||
|
...variable,
|
||||||
|
}}
|
||||||
|
onValueUpdate={onValueUpdate}
|
||||||
|
variablesToGetUpdated={variablesToGetUpdated}
|
||||||
|
setVariablesToGetUpdated={setVariablesToGetUpdated}
|
||||||
|
dependencyData={dependencyData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,6 +53,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
@ -74,6 +75,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
@ -94,6 +96,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
@ -128,6 +131,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
@ -157,6 +161,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
@ -178,6 +183,7 @@ describe('VariableItem', () => {
|
|||||||
order: [],
|
order: [],
|
||||||
graph: {},
|
graph: {},
|
||||||
parentDependencyGraph: {},
|
parentDependencyGraph: {},
|
||||||
|
hasCycle: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</MockQueryClientProvider>,
|
</MockQueryClientProvider>,
|
||||||
|
@ -191,16 +191,6 @@ describe('dashboardVariables - utilities and processors', () => {
|
|||||||
describe('buildDependencyGraph', () => {
|
describe('buildDependencyGraph', () => {
|
||||||
it('should build complete dependency graph with correct structure and order', () => {
|
it('should build complete dependency graph with correct structure and order', () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
graph: {
|
|
||||||
deployment_environment: ['service_name', 'endpoint'],
|
|
||||||
service_name: ['endpoint'],
|
|
||||||
endpoint: ['http_status_code'],
|
|
||||||
http_status_code: [],
|
|
||||||
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
|
|
||||||
k8s_node_name: ['k8s_namespace_name'],
|
|
||||||
k8s_namespace_name: [],
|
|
||||||
environment: [],
|
|
||||||
},
|
|
||||||
order: [
|
order: [
|
||||||
'deployment_environment',
|
'deployment_environment',
|
||||||
'k8s_cluster_name',
|
'k8s_cluster_name',
|
||||||
@ -211,6 +201,28 @@ describe('dashboardVariables - utilities and processors', () => {
|
|||||||
'k8s_namespace_name',
|
'k8s_namespace_name',
|
||||||
'http_status_code',
|
'http_status_code',
|
||||||
],
|
],
|
||||||
|
graph: {
|
||||||
|
deployment_environment: ['service_name', 'endpoint'],
|
||||||
|
service_name: ['endpoint'],
|
||||||
|
endpoint: ['http_status_code'],
|
||||||
|
http_status_code: [],
|
||||||
|
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
|
||||||
|
k8s_node_name: ['k8s_namespace_name'],
|
||||||
|
k8s_namespace_name: [],
|
||||||
|
environment: [],
|
||||||
|
},
|
||||||
|
parentDependencyGraph: {
|
||||||
|
deployment_environment: [],
|
||||||
|
service_name: ['deployment_environment'],
|
||||||
|
endpoint: ['deployment_environment', 'service_name'],
|
||||||
|
http_status_code: ['endpoint'],
|
||||||
|
k8s_cluster_name: [],
|
||||||
|
k8s_node_name: ['k8s_cluster_name'],
|
||||||
|
k8s_namespace_name: ['k8s_cluster_name', 'k8s_node_name'],
|
||||||
|
environment: [],
|
||||||
|
},
|
||||||
|
hasCycle: false,
|
||||||
|
cycleNodes: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(buildDependencyGraph(graph)).toEqual(expected);
|
expect(buildDependencyGraph(graph)).toEqual(expected);
|
||||||
|
@ -95,10 +95,96 @@ export const buildDependencies = (
|
|||||||
return graph;
|
return graph;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to build the dependency graph
|
export interface IDependencyData {
|
||||||
|
order: string[];
|
||||||
|
graph: VariableGraph;
|
||||||
|
parentDependencyGraph: VariableGraph;
|
||||||
|
hasCycle: boolean;
|
||||||
|
cycleNodes?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildParentDependencyGraph = (
|
||||||
|
graph: VariableGraph,
|
||||||
|
): VariableGraph => {
|
||||||
|
const parentGraph: VariableGraph = {};
|
||||||
|
|
||||||
|
// Initialize empty arrays for all nodes
|
||||||
|
Object.keys(graph).forEach((node) => {
|
||||||
|
parentGraph[node] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// For each node and its children in the original graph
|
||||||
|
Object.entries(graph).forEach(([node, children]) => {
|
||||||
|
// For each child, add the current node as its parent
|
||||||
|
children.forEach((child) => {
|
||||||
|
parentGraph[child].push(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return parentGraph;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectCyclePath = (
|
||||||
|
graph: VariableGraph,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
): string[] => {
|
||||||
|
const path: string[] = [];
|
||||||
|
let current = start;
|
||||||
|
|
||||||
|
const findParent = (node: string): string | undefined =>
|
||||||
|
Object.keys(graph).find((key) => graph[key]?.includes(node));
|
||||||
|
|
||||||
|
while (current !== end) {
|
||||||
|
const parent = findParent(current);
|
||||||
|
if (!parent) break;
|
||||||
|
path.push(parent);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [start, ...path];
|
||||||
|
};
|
||||||
|
|
||||||
|
const detectCycle = (
|
||||||
|
graph: VariableGraph,
|
||||||
|
node: string,
|
||||||
|
visited: Set<string>,
|
||||||
|
recStack: Set<string>,
|
||||||
|
): string[] | null => {
|
||||||
|
if (!visited.has(node)) {
|
||||||
|
visited.add(node);
|
||||||
|
recStack.add(node);
|
||||||
|
|
||||||
|
const neighbors = graph[node] || [];
|
||||||
|
let cycleNodes: string[] | null = null;
|
||||||
|
|
||||||
|
neighbors.some((neighbor) => {
|
||||||
|
if (!visited.has(neighbor)) {
|
||||||
|
const foundCycle = detectCycle(graph, neighbor, visited, recStack);
|
||||||
|
if (foundCycle) {
|
||||||
|
cycleNodes = foundCycle;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (recStack.has(neighbor)) {
|
||||||
|
// Found a cycle, collect the cycle nodes
|
||||||
|
cycleNodes = collectCyclePath(graph, node, neighbor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cycleNodes) {
|
||||||
|
return cycleNodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recStack.delete(node);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const buildDependencyGraph = (
|
export const buildDependencyGraph = (
|
||||||
dependencies: VariableGraph,
|
dependencies: VariableGraph,
|
||||||
): { order: string[]; graph: VariableGraph } => {
|
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||||
|
): IDependencyData => {
|
||||||
const inDegree: Record<string, number> = {};
|
const inDegree: Record<string, number> = {};
|
||||||
const adjList: VariableGraph = {};
|
const adjList: VariableGraph = {};
|
||||||
|
|
||||||
@ -113,6 +199,22 @@ export const buildDependencyGraph = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Detect cycles
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const recStack = new Set<string>();
|
||||||
|
let cycleNodes: string[] | undefined;
|
||||||
|
|
||||||
|
Object.keys(dependencies).some((node) => {
|
||||||
|
if (!visited.has(node)) {
|
||||||
|
const foundCycle = detectCycle(dependencies, node, visited, recStack);
|
||||||
|
if (foundCycle) {
|
||||||
|
cycleNodes = foundCycle;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
// Topological sort using Kahn's Algorithm
|
// Topological sort using Kahn's Algorithm
|
||||||
const queue: string[] = Object.keys(inDegree).filter(
|
const queue: string[] = Object.keys(inDegree).filter(
|
||||||
(node) => inDegree[node] === 0,
|
(node) => inDegree[node] === 0,
|
||||||
@ -132,11 +234,15 @@ export const buildDependencyGraph = (
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topologicalOrder.length !== Object.keys(dependencies)?.length) {
|
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
|
||||||
console.error('Cycle detected in the dependency graph!');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { order: topologicalOrder, graph: adjList };
|
return {
|
||||||
|
order: topologicalOrder,
|
||||||
|
graph: adjList,
|
||||||
|
parentDependencyGraph: buildParentDependencyGraph(adjList),
|
||||||
|
hasCycle,
|
||||||
|
cycleNodes,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const onUpdateVariableNode = (
|
export const onUpdateVariableNode = (
|
||||||
@ -159,27 +265,6 @@ export const onUpdateVariableNode = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildParentDependencyGraph = (
|
|
||||||
graph: VariableGraph,
|
|
||||||
): VariableGraph => {
|
|
||||||
const parentGraph: VariableGraph = {};
|
|
||||||
|
|
||||||
// Initialize empty arrays for all nodes
|
|
||||||
Object.keys(graph).forEach((node) => {
|
|
||||||
parentGraph[node] = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
// For each node and its children in the original graph
|
|
||||||
Object.entries(graph).forEach(([node, children]) => {
|
|
||||||
// For each child, add the current node as its parent
|
|
||||||
children.forEach((child) => {
|
|
||||||
parentGraph[child].push(node);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return parentGraph;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkAPIInvocation = (
|
export const checkAPIInvocation = (
|
||||||
variablesToGetUpdated: string[],
|
variablesToGetUpdated: string[],
|
||||||
variableData: IDashboardVariable,
|
variableData: IDashboardVariable,
|
||||||
@ -206,9 +291,3 @@ export const checkAPIInvocation = (
|
|||||||
variablesToGetUpdated[0] === variableData.name
|
variablesToGetUpdated[0] === variableData.name
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IDependencyData {
|
|
||||||
order: string[];
|
|
||||||
graph: VariableGraph;
|
|
||||||
parentDependencyGraph: VariableGraph;
|
|
||||||
}
|
|
||||||
|
@ -73,18 +73,20 @@ describe('useGetResolvedText', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle different variable formats', () => {
|
it('should handle different variable formats', () => {
|
||||||
const text = 'Logs in $service.name, {{service.name}}, [[service.name]]';
|
const text =
|
||||||
|
'Logs in $service.name, {{service.name}}, [[service.name]] - $dyn-service.name';
|
||||||
const variables = {
|
const variables = {
|
||||||
'service.name': SERVICE_VAR,
|
'service.name': SERVICE_VAR,
|
||||||
|
'$dyn-service.name': 'dyn-1, dyn-2',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { result } = renderHookWithProps({ text, variables });
|
const { result } = renderHookWithProps({ text, variables });
|
||||||
|
|
||||||
expect(result.current.truncatedText).toBe(
|
expect(result.current.truncatedText).toBe(
|
||||||
'Logs in test, app +2, test, app +2, test, app +2',
|
'Logs in test, app +2, test, app +2, test, app +2 - dyn-1, dyn-2',
|
||||||
);
|
);
|
||||||
expect(result.current.fullText).toBe(
|
expect(result.current.fullText).toBe(
|
||||||
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env',
|
'Logs in test, app, frontend, env, test, app, frontend, env, test, app, frontend, env - dyn-1, dyn-2',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -82,11 +82,12 @@ function useGetResolvedText({
|
|||||||
|
|
||||||
const combinedPattern = useMemo(() => {
|
const combinedPattern = useMemo(() => {
|
||||||
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedMatcher = matcher.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
|
||||||
const variablePatterns = [
|
const variablePatterns = [
|
||||||
`\\{\\{\\s*?\\.([^\\s}]+)\\s*?\\}\\}`, // {{.var}}
|
`\\{\\{\\s*?\\.(${varNamePattern})\\s*?\\}\\}`, // {{.var}}
|
||||||
`\\{\\{\\s*([^\\s}]+)\\s*\\}\\}`, // {{var}}
|
`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`, // {{var}}
|
||||||
`${escapedMatcher}([\\w.]+)`, // matcher + var.name
|
`${escapedMatcher}(${varNamePattern})`, // matcher + var.name
|
||||||
`\\[\\[\\s*([^\\s\\]]+)\\s*\\]\\]`, // [[var]]
|
`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`, // [[var]]
|
||||||
];
|
];
|
||||||
return new RegExp(variablePatterns.join('|'), 'g');
|
return new RegExp(variablePatterns.join('|'), 'g');
|
||||||
}, [matcher]);
|
}, [matcher]);
|
||||||
@ -94,20 +95,38 @@ function useGetResolvedText({
|
|||||||
const extractVarName = useCallback(
|
const extractVarName = useCallback(
|
||||||
(match: string): string => {
|
(match: string): string => {
|
||||||
// Extract variable name from different formats
|
// Extract variable name from different formats
|
||||||
|
const varNamePattern = '[a-zA-Z_\\-][a-zA-Z0-9_.\\-]*';
|
||||||
if (match.startsWith('{{')) {
|
if (match.startsWith('{{')) {
|
||||||
const dotMatch = match.match(/\{\{\s*\.([^}]+)\}\}/);
|
const dotMatch = match.match(
|
||||||
|
new RegExp(`\\{\\{\\s*\\.(${varNamePattern})\\s*\\}\\}`),
|
||||||
|
);
|
||||||
if (dotMatch) return dotMatch[1].trim();
|
if (dotMatch) return dotMatch[1].trim();
|
||||||
const normalMatch = match.match(/\{\{\s*([^}]+)\}\}/);
|
const normalMatch = match.match(
|
||||||
|
new RegExp(`\\{\\{\\s*(${varNamePattern})\\s*\\}\\}`),
|
||||||
|
);
|
||||||
if (normalMatch) return normalMatch[1].trim();
|
if (normalMatch) return normalMatch[1].trim();
|
||||||
} else if (match.startsWith('[[')) {
|
} else if (match.startsWith('[[')) {
|
||||||
const bracketMatch = match.match(/\[\[\s*([^\]]+)\]\]/);
|
const bracketMatch = match.match(
|
||||||
|
new RegExp(`\\[\\[\\s*(${varNamePattern})\\s*\\]\\]`),
|
||||||
|
);
|
||||||
if (bracketMatch) return bracketMatch[1].trim();
|
if (bracketMatch) return bracketMatch[1].trim();
|
||||||
} else if (match.startsWith(matcher)) {
|
} else if (match.startsWith(matcher)) {
|
||||||
return match.substring(matcher.length);
|
// For $ variables, we always want to strip the prefix
|
||||||
|
// unless the full match exists in processedVariables
|
||||||
|
const withoutPrefix = match.substring(matcher.length).trim();
|
||||||
|
const fullMatch = match.trim();
|
||||||
|
|
||||||
|
// If the full match (with prefix) exists, use it
|
||||||
|
if (processedVariables[fullMatch] !== undefined) {
|
||||||
|
return fullMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise return without prefix
|
||||||
|
return withoutPrefix;
|
||||||
}
|
}
|
||||||
return match;
|
return match;
|
||||||
},
|
},
|
||||||
[matcher],
|
[matcher, processedVariables],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fullText = useMemo(() => {
|
const fullText = useMemo(() => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user